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