diff --git a/app/components/ArrowKeyNavigation.tsx b/app/components/ArrowKeyNavigation.tsx new file mode 100644 index 000000000..7033e58ad --- /dev/null +++ b/app/components/ArrowKeyNavigation.tsx @@ -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) => void; +}; + +function ArrowKeyNavigation( + { children, onEscape, ...rest }: Props, + ref: React.RefObject +) { + 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 ( + + {children(composite)} + + ); +} + +export default observer(React.forwardRef(ArrowKeyNavigation)); diff --git a/app/components/DocumentHistory.tsx b/app/components/DocumentHistory.tsx index 2fac71298..2323d2b77 100644 --- a/app/components/DocumentHistory.tsx +++ b/app/components/DocumentHistory.tsx @@ -72,6 +72,7 @@ function DocumentHistory() { ]*>(.*?)<\/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 ( - @@ -155,7 +160,7 @@ function DocumentListItem( modal={false} /> - + ); } @@ -196,6 +201,10 @@ const DocumentLink = styled(Link)<{ max-height: 50vh; width: calc(100vw - 8px); + &:focus-visible { + outline: none; + } + ${breakpoint("tablet")` width: auto; `}; diff --git a/app/components/DocumentViews.tsx b/app/components/DocumentViews.tsx index e77c205e1..e78f8442d 100644 --- a/app/components/DocumentViews.tsx +++ b/app/components/DocumentViews.tsx @@ -40,6 +40,7 @@ function DocumentViews({ document, isOpen }: Props) { <> {isOpen && ( { const view = documentViews.find((v) => v.user.id === item.id); diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx index 99a6e8416..43c9b0d66 100644 --- a/app/components/EventListItem.tsx +++ b/app/components/EventListItem.tsx @@ -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(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 ( - { format="MMM do, h:mm a" relative={false} addSuffix + onClick={handleTimeClick} /> } image={} @@ -115,10 +131,22 @@ const EventListItem = ({ event, latest, document }: Props) => { ) : undefined } + ref={ref} + {...rest} /> ); }; +const BaseItem = React.forwardRef( + ({ to, ...rest }: ItemProps, ref?: React.Ref) => { + if (to) { + return ; + } + + return ; + } +); + 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; diff --git a/app/components/List/CompositeItem.tsx b/app/components/List/CompositeItem.tsx new file mode 100644 index 000000000..0d9d9583f --- /dev/null +++ b/app/components/List/CompositeItem.tsx @@ -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 +) { + return ; +} + +export default React.forwardRef(CompositeItem); diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx index 0ca74c4e3..7c65cef8b 100644 --- a/app/components/List/Item.tsx +++ b/app/components/List/Item.tsx @@ -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 ( - + {content(false)} ); }; -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)` diff --git a/app/components/PaginatedDocumentList.tsx b/app/components/PaginatedDocumentList.tsx index 74b20e728..a23f62f89 100644 --- a/app/components/PaginatedDocumentList.tsx +++ b/app/components/PaginatedDocumentList.tsx @@ -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(function PaginatedDocumentList({ documents, fetch, options, + showParentDocuments, + showCollection, + showPublished, + showTemplate, + showDraft, ...rest }: Props) { + const { t } = useTranslation(); + return ( ( + renderItem={(item, _index, compositeProps) => ( )} + {...rest} /> ); }); diff --git a/app/components/PaginatedEventList.tsx b/app/components/PaginatedEventList.tsx index ff7af61a1..c8de4c8dd 100644 --- a/app/components/PaginatedEventList.tsx +++ b/app/components/PaginatedEventList.tsx @@ -29,16 +29,19 @@ const PaginatedEventList = React.memo(function PaginatedEventList({ heading={heading} fetch={fetch} options={options} - renderItem={(item, index) => ( - - )} + renderItem={(item, index, compositeProps) => { + return ( + + ); + }} renderHeading={(name) => {name}} + {...rest} /> ); }); diff --git a/app/components/PaginatedList.tsx b/app/components/PaginatedList.tsx index 040ca1b3d..da0132f0e 100644 --- a/app/components/PaginatedList.tsx +++ b/app/components/PaginatedList.tsx @@ -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; 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 | string) => React.ReactNode; }; @@ -129,44 +133,47 @@ class PaginatedList extends React.Component { {showList && ( <> {heading} - - {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 ( - - {renderHeading(currentHeading)} - {children} - + + {(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 ( + + {renderHeading(currentHeading)} + {children} + + ); + } + + return children; + }) + } {this.allowLoadMore && ( diff --git a/app/components/Time.tsx b/app/components/Time.tsx index 33055059f..7f5f47df1 100644 --- a/app/components/Time.tsx +++ b/app/components/Time.tsx @@ -9,9 +9,11 @@ const LocaleTime = React.lazy( ) ); -type Props = React.ComponentProps; +type Props = React.ComponentProps & { + 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 ( - {props.children || content} - } - > - - + + {props.children || content} + } + > + + + ); } diff --git a/app/scenes/ErrorSuspended.tsx b/app/scenes/ErrorSuspended.tsx index c0f994953..e397861c0 100644 --- a/app/scenes/ErrorSuspended.tsx +++ b/app/scenes/ErrorSuspended.tsx @@ -13,7 +13,7 @@ const ErrorSuspended = () => {

- + ⚠️ {" "} {t("Your account has been suspended")} diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index 5e7cfcf0e..e2fc5fa71 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -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 { - firstDocument: HTMLAnchorElement | null | undefined; + compositeRef: HTMLDivElement | null | undefined; + searchInputRef: HTMLInputElement | null | undefined; lastQuery = ""; @@ -102,10 +103,11 @@ class Search extends React.Component { 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; + linkItems[0]?.focus(); } } }; @@ -252,8 +254,16 @@ class Search extends React.Component { }); }; - 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 { )} { )} - {results.map((result, index) => { - const document = documents.data.get(result.document.id); - if (!document) { - return null; - } - return ( - 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 ( + + ); + }) + } {this.allowLoadMore && ( diff --git a/app/scenes/Search/components/SearchInput.tsx b/app/scenes/Search/components/SearchInput.tsx index 36143a343..3efea4e29 100644 --- a/app/scenes/Search/components/SearchInput.tsx +++ b/app/scenes/Search/components/SearchInput.tsx @@ -8,18 +8,19 @@ type Props = React.HTMLAttributes & { placeholder?: string; }; -function SearchInput({ defaultValue, ...rest }: Props) { +function SearchInput( + { defaultValue, ...rest }: Props, + ref: React.RefObject +) { const theme = useTheme(); - const inputRef = React.useRef(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 ( @@ -35,7 +36,7 @@ function SearchInput({ defaultValue, ...rest }: Props) { props.theme.primary}; + outline-offset: -1px; } `; diff --git a/app/typings/index.d.ts b/app/typings/index.d.ts index e7e8d4437..d279e404f 100644 --- a/app/typings/index.d.ts +++ b/app/typings/index.d.ts @@ -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"; diff --git a/package.json b/package.json index fba934940..b8b223d6e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/webpack.config.js b/webpack.config.js index 17acdbe95..10f01b1ec 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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'