chore: new arrow key navigation (#3229)
* rebuild keyboard navigation lists * add new keyboard navigation components * remove references to boundless-arrow-key-navigation * fix aria-labels on paginated lists everywhere
This commit is contained in:
51
app/components/ArrowKeyNavigation.tsx
Normal file
51
app/components/ArrowKeyNavigation.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useCompositeState,
|
||||
Composite,
|
||||
CompositeStateReturn,
|
||||
} from "reakit/Composite";
|
||||
|
||||
type Props = {
|
||||
children: (composite: CompositeStateReturn) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
function ArrowKeyNavigation(
|
||||
{ children, onEscape, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const composite = useCompositeState();
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev) => {
|
||||
if (onEscape) {
|
||||
if (ev.key === "Escape") {
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
if (
|
||||
ev.key === "ArrowUp" &&
|
||||
composite.currentId === composite.items[0].id
|
||||
) {
|
||||
onEscape(ev);
|
||||
}
|
||||
}
|
||||
},
|
||||
[composite.currentId, composite.items, onEscape]
|
||||
);
|
||||
|
||||
return (
|
||||
<Composite
|
||||
{...rest}
|
||||
{...composite}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="menu"
|
||||
ref={ref}
|
||||
>
|
||||
{children(composite)}
|
||||
</Composite>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(React.forwardRef(ArrowKeyNavigation));
|
||||
@@ -72,6 +72,7 @@ function DocumentHistory() {
|
||||
</Header>
|
||||
<Scrollable topShadow>
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "~/models/Document";
|
||||
@@ -33,7 +34,8 @@ type Props = {
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
} & CompositeStateReturn;
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
function replaceResultMarks(tag: string) {
|
||||
@@ -61,6 +63,7 @@ function DocumentListItem(
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
} = props;
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
@@ -71,7 +74,8 @@ function DocumentListItem(
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
<CompositeItem
|
||||
as={DocumentLink}
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
@@ -82,6 +86,7 @@ function DocumentListItem(
|
||||
title: document.titleWithDefault,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
@@ -155,7 +160,7 @@ function DocumentListItem(
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</CompositeItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,6 +201,10 @@ const DocumentLink = styled(Link)<{
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`};
|
||||
|
||||
@@ -40,6 +40,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
<>
|
||||
{isOpen && (
|
||||
<PaginatedList
|
||||
aria-label={t("Viewers")}
|
||||
items={users}
|
||||
renderItem={(item) => {
|
||||
const view = documentViews.find((v) => v.user.id === item.id);
|
||||
|
||||
@@ -9,10 +9,14 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import styled, { css } from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import CompositeItem, {
|
||||
Props as ItemProps,
|
||||
} from "~/components/List/CompositeItem";
|
||||
import Item, { Actions } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -23,9 +27,9 @@ type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
latest?: boolean;
|
||||
};
|
||||
} & CompositeStateReturn;
|
||||
|
||||
const EventListItem = ({ event, latest, document }: Props) => {
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(document.id);
|
||||
@@ -35,6 +39,13 @@ const EventListItem = ({ event, latest, document }: Props) => {
|
||||
const isRevision = event.name === "revisions.create";
|
||||
let meta, icon, to;
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
// the time component tends to steal focus when clicked
|
||||
// ...so forward the focus back to the parent item
|
||||
const handleTimeClick = React.useCallback(() => {
|
||||
ref.current?.focus();
|
||||
}, [ref]);
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
case "documents.latest_version": {
|
||||
@@ -89,11 +100,15 @@ const EventListItem = ({ event, latest, document }: Props) => {
|
||||
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
if (document.isDeleted) {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
<BaseItem
|
||||
small
|
||||
exact
|
||||
to={document.isDeleted ? undefined : to}
|
||||
to={to}
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
@@ -101,6 +116,7 @@ const EventListItem = ({ event, latest, document }: Props) => {
|
||||
format="MMM do, h:mm a"
|
||||
relative={false}
|
||||
addSuffix
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
|
||||
@@ -115,10 +131,22 @@ const EventListItem = ({ event, latest, document }: Props) => {
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
) : undefined
|
||||
}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(
|
||||
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
|
||||
if (to) {
|
||||
return <CompositeListItem to={to} ref={ref} {...rest} />;
|
||||
}
|
||||
|
||||
return <ListItem ref={ref} {...rest} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
margin: -3px;
|
||||
@@ -126,7 +154,7 @@ const Subtitle = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
const ItemStyle = css`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px;
|
||||
@@ -172,4 +200,12 @@ const ListItem = styled(Item)`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
const CompositeListItem = styled(CompositeItem)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default EventListItem;
|
||||
|
||||
17
app/components/List/CompositeItem.tsx
Normal file
17
app/components/List/CompositeItem.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
CompositeStateReturn,
|
||||
CompositeItem as BaseCompositeItem,
|
||||
} from "reakit/Composite";
|
||||
import Item, { Props as ItemProps } from "./Item";
|
||||
|
||||
export type Props = ItemProps & CompositeStateReturn;
|
||||
|
||||
function CompositeItem(
|
||||
{ to, ...rest }: Props,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
|
||||
}
|
||||
|
||||
export default React.forwardRef(CompositeItem);
|
||||
@@ -3,7 +3,7 @@ import styled, { useTheme } from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import NavLink from "~/components/NavLink";
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
image?: React.ReactNode;
|
||||
to?: string;
|
||||
exact?: boolean;
|
||||
@@ -63,13 +63,13 @@ const ListItem = (
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper $border={border} $small={small} {...rest}>
|
||||
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
|
||||
{content(false)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.div<{ $small?: boolean; $border?: boolean }>`
|
||||
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
|
||||
display: flex;
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) =>
|
||||
@@ -81,6 +81,8 @@ const Wrapper = styled.div<{ $small?: boolean; $border?: boolean }>`
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
cursor: ${({ to }) => (to ? "pointer" : "default")};
|
||||
`;
|
||||
|
||||
const Image = styled(Flex)`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
@@ -22,23 +23,37 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
documents,
|
||||
fetch,
|
||||
options,
|
||||
showParentDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showTemplate,
|
||||
showDraft,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PaginatedList
|
||||
aria-label={t("Documents")}
|
||||
items={documents}
|
||||
empty={empty}
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
renderItem={(item, _index, compositeProps) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
showPin={!!options?.collectionId}
|
||||
{...rest}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showTemplate={showTemplate}
|
||||
showDraft={showDraft}
|
||||
{...compositeProps}
|
||||
/>
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,16 +29,19 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
renderItem={(item, index, compositeProps) => {
|
||||
return (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
{...compositeProps}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { isEqual } from "lodash";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { CompositeStateReturn } from "reakit/Composite";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import withStores from "~/components/withStores";
|
||||
@@ -18,9 +19,12 @@ type Props = WithTranslation &
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
|
||||
items: any[];
|
||||
renderItem: (arg0: any, index: number) => React.ReactNode;
|
||||
renderItem: (
|
||||
item: any,
|
||||
index: number,
|
||||
composite: CompositeStateReturn
|
||||
) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -129,44 +133,47 @@ class PaginatedList extends React.Component<Props> {
|
||||
{showList && (
|
||||
<>
|
||||
{heading}
|
||||
<ArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(item, index);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
auth.user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (!previousHeading || currentHeading !== previousHeading) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
<ArrowKeyNavigation aria-label={this.props["aria-label"]}>
|
||||
{(composite: CompositeStateReturn) =>
|
||||
items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(
|
||||
item,
|
||||
index,
|
||||
composite
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
})}
|
||||
// If there is no renderHeading method passed then no date
|
||||
// headings are rendered
|
||||
if (!renderHeading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
auth.user?.language
|
||||
);
|
||||
|
||||
// If the heading is different to any previous heading then we
|
||||
// should render it, otherwise the item can go under the previous
|
||||
// heading
|
||||
if (!previousHeading || currentHeading !== previousHeading) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
})
|
||||
}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
|
||||
@@ -9,9 +9,11 @@ const LocaleTime = React.lazy(
|
||||
)
|
||||
);
|
||||
|
||||
type Props = React.ComponentProps<typeof LocaleTime>;
|
||||
type Props = React.ComponentProps<typeof LocaleTime> & {
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function Time(props: Props) {
|
||||
function Time({ onClick, ...props }: Props) {
|
||||
let content = formatDistanceToNow(Date.parse(props.dateTime), {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
@@ -24,13 +26,15 @@ function Time(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime tooltipDelay={250} {...props} />
|
||||
</React.Suspense>
|
||||
<span onClick={onClick}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<time dateTime={props.dateTime}>{props.children || content}</time>
|
||||
}
|
||||
>
|
||||
<LocaleTime tooltipDelay={250} {...props} />
|
||||
</React.Suspense>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const ErrorSuspended = () => {
|
||||
<CenteredContent>
|
||||
<PageTitle title={t("Your account has been suspended")} />
|
||||
<h1>
|
||||
<span role="img" aria-label="Warning sign">
|
||||
<span role="img" aria-label={t("Warning Sign")}>
|
||||
⚠️
|
||||
</span>{" "}
|
||||
{t("Your account has been suspended")}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { isEqual } from "lodash";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -14,6 +13,7 @@ import { DateFilter as TDateFilter } from "@shared/types";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "~/stores/BaseStore";
|
||||
import { SearchParams } from "~/stores/DocumentsStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
import Empty from "~/components/Empty";
|
||||
import Fade from "~/components/Fade";
|
||||
@@ -44,7 +44,8 @@ type Props = RouteComponentProps<
|
||||
|
||||
@observer
|
||||
class Search extends React.Component<Props> {
|
||||
firstDocument: HTMLAnchorElement | null | undefined;
|
||||
compositeRef: HTMLDivElement | null | undefined;
|
||||
searchInputRef: HTMLInputElement | null | undefined;
|
||||
|
||||
lastQuery = "";
|
||||
|
||||
@@ -102,10 +103,11 @@ class Search extends React.Component<Props> {
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.firstDocument) {
|
||||
if (this.firstDocument instanceof HTMLElement) {
|
||||
this.firstDocument.focus();
|
||||
}
|
||||
if (this.compositeRef) {
|
||||
const linkItems = this.compositeRef.querySelectorAll(
|
||||
"[href]"
|
||||
) as NodeListOf<HTMLAnchorElement>;
|
||||
linkItems[0]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -252,8 +254,16 @@ class Search extends React.Component<Props> {
|
||||
});
|
||||
};
|
||||
|
||||
setFirstDocumentRef = (ref: HTMLAnchorElement | null) => {
|
||||
this.firstDocument = ref;
|
||||
setCompositeRef = (ref: HTMLDivElement | null) => {
|
||||
this.compositeRef = ref;
|
||||
};
|
||||
|
||||
setSearchInputRef = (ref: HTMLInputElement | null) => {
|
||||
this.searchInputRef = ref;
|
||||
};
|
||||
|
||||
handleEscape = () => {
|
||||
this.searchInputRef?.focus();
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -275,6 +285,7 @@ class Search extends React.Component<Props> {
|
||||
)}
|
||||
<ResultsWrapper column auto>
|
||||
<SearchInput
|
||||
ref={this.setSearchInputRef}
|
||||
placeholder={`${t("Search")}…`}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
defaultValue={this.query}
|
||||
@@ -329,26 +340,29 @@ class Search extends React.Component<Props> {
|
||||
)}
|
||||
<ResultList column>
|
||||
<StyledArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
ref={this.setCompositeRef}
|
||||
onEscape={this.handleEscape}
|
||||
aria-label={t("Search Results")}
|
||||
>
|
||||
{results.map((result, index) => {
|
||||
const document = documents.data.get(result.document.id);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DocumentListItem
|
||||
ref={(ref) => index === 0 && this.setFirstDocumentRef(ref)}
|
||||
key={document.id}
|
||||
document={document}
|
||||
highlight={this.query}
|
||||
context={result.context}
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(compositeProps) =>
|
||||
results.map((result) => {
|
||||
const document = documents.data.get(result.document.id);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DocumentListItem
|
||||
key={document.id}
|
||||
document={document}
|
||||
highlight={this.query}
|
||||
context={result.context}
|
||||
showCollection
|
||||
showTemplate
|
||||
{...compositeProps}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</StyledArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||
|
||||
@@ -8,18 +8,19 @@ type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
function SearchInput({ defaultValue, ...rest }: Props) {
|
||||
function SearchInput(
|
||||
{ defaultValue, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLInputElement>
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const focusInput = React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
ref.current?.focus();
|
||||
}, [ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// ensure that focus is placed at end of input
|
||||
const len = (defaultValue || "").length;
|
||||
inputRef.current?.setSelectionRange(len, len);
|
||||
ref.current?.setSelectionRange(len, len);
|
||||
const timeoutId = setTimeout(() => {
|
||||
focusInput();
|
||||
}, 100); // arbitrary number
|
||||
@@ -27,7 +28,7 @@ function SearchInput({ defaultValue, ...rest }: Props) {
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [defaultValue, focusInput]);
|
||||
}, [ref, defaultValue, focusInput]);
|
||||
|
||||
return (
|
||||
<Wrapper align="center">
|
||||
@@ -35,7 +36,7 @@ function SearchInput({ defaultValue, ...rest }: Props) {
|
||||
<StyledInput
|
||||
{...rest}
|
||||
defaultValue={defaultValue}
|
||||
ref={inputRef}
|
||||
ref={ref}
|
||||
spellCheck="false"
|
||||
type="search"
|
||||
autoFocus
|
||||
@@ -84,4 +85,4 @@ const StyledIcon = styled(SearchIcon)`
|
||||
left: 8px;
|
||||
`;
|
||||
|
||||
export default SearchInput;
|
||||
export default React.forwardRef(SearchInput);
|
||||
|
||||
@@ -97,5 +97,6 @@ export default createGlobalStyle`
|
||||
|
||||
.js-focus-visible .focus-visible {
|
||||
outline-color: ${(props) => props.theme.primary};
|
||||
outline-offset: -1px;
|
||||
}
|
||||
`;
|
||||
|
||||
2
app/typings/index.d.ts
vendored
2
app/typings/index.d.ts
vendored
@@ -1,7 +1,5 @@
|
||||
declare module "autotrack/autotrack.js";
|
||||
|
||||
declare module "boundless-arrow-key-navigation";
|
||||
|
||||
declare module "string-replace-to-array";
|
||||
|
||||
declare module "sequelize-encrypted";
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-styled-components": "^1.11.1",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"boundless-arrow-key-navigation": "^1.0.4",
|
||||
"bull": "^3.29.0",
|
||||
"cancan": "3.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
|
||||
@@ -49,7 +49,6 @@ module.exports = {
|
||||
"~": path.resolve(__dirname, 'app'),
|
||||
"@shared": path.resolve(__dirname, 'shared'),
|
||||
"@server": path.resolve(__dirname, 'server'),
|
||||
'boundless-arrow-key-navigation': 'boundless-arrow-key-navigation/build',
|
||||
'boundless-popover': 'boundless-popover/build',
|
||||
'boundless-utils-omit-keys': 'boundless-utils-omit-keys/build',
|
||||
'boundless-utils-uuid': 'boundless-utils-uuid/build'
|
||||
|
||||
Reference in New Issue
Block a user