diff --git a/app/components/ArrowKeyNavigation.tsx b/app/components/ArrowKeyNavigation.tsx index 1c714f1fa..9a7f693bd 100644 --- a/app/components/ArrowKeyNavigation.tsx +++ b/app/components/ArrowKeyNavigation.tsx @@ -5,10 +5,11 @@ import * as React from "react"; type Props = React.HTMLAttributes & { children: () => React.ReactNode; onEscape?: (ev: React.KeyboardEvent) => void; + items: unknown[]; }; function ArrowKeyNavigation( - { children, onEscape, ...rest }: Props, + { children, onEscape, items, ...rest }: Props, ref: React.RefObject ) { const handleKeyDown = React.useCallback( @@ -36,7 +37,10 @@ function ArrowKeyNavigation( ); return ( - +
{children()}
diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx index df267991c..3e9ddef90 100644 --- a/app/components/List/Item.tsx +++ b/app/components/List/Item.tsx @@ -8,6 +8,7 @@ import styled, { useTheme } from "styled-components"; import { s, ellipsis } from "@shared/styles"; import Flex from "~/components/Flex"; import NavLink from "~/components/NavLink"; +import { hover } from "~/styles"; export type Props = Omit, "title"> & { /** An icon or image to display to the left of the list item */ @@ -16,6 +17,8 @@ export type Props = Omit, "title"> & { to?: LocationDescriptor; /** An optional click handler, if provided the list item will have hover styles */ onClick?: React.MouseEventHandler; + /** An optional keydown handler, if provided the list item will have hover styles */ + onKeyDown?: React.KeyboardEventHandler; /** Whether to match the location exactly */ exact?: boolean; /** The title of the list item */ @@ -28,10 +31,22 @@ export type Props = Omit, "title"> & { border?: boolean; /** Whether to display the list item in a compact style */ small?: boolean; + /** Whether to enable keyboard navigation */ + keyboardNavigation?: boolean; }; const ListItem = ( - { image, title, subtitle, actions, small, border, to, ...rest }: Props, + { + image, + title, + subtitle, + actions, + small, + border, + to, + keyboardNavigation, + ...rest + }: Props, ref?: React.Ref ) => { const theme = useTheme(); @@ -45,7 +60,7 @@ const ListItem = ( const { focused, ...rovingTabIndex } = useRovingTabIndex( itemRef as React.RefObject, - to ? false : true + keyboardNavigation || to ? false : true ); useFocusEffect(focused, itemRef as React.RefObject); @@ -89,6 +104,12 @@ const ListItem = ( } rovingTabIndex.onClick(ev); }} + onKeyDown={(ev) => { + if (rest.onKeyDown) { + rest.onKeyDown(ev); + } + rovingTabIndex.onKeyDown(ev); + }} as={NavLink} to={to} > @@ -98,7 +119,25 @@ const ListItem = ( } return ( - + { + if (rest.onClick) { + rest.onClick(ev); + } + rovingTabIndex.onClick(ev); + }} + onKeyDown={(ev) => { + if (rest.onKeyDown) { + rest.onKeyDown(ev); + } + rovingTabIndex.onKeyDown(ev); + }} + > {content(false)} ); @@ -123,7 +162,13 @@ const Wrapper = styled.a<{ border-bottom: 0; } - &:hover { + &:focus-visible { + outline: none; + } + + &:${hover}, + &:focus, + &:focus-within { background: ${(props) => props.onClick ? props.theme.secondaryBackground : "inherit"}; } diff --git a/app/components/PaginatedList.tsx b/app/components/PaginatedList.tsx index 87661d084..7b8fcc267 100644 --- a/app/components/PaginatedList.tsx +++ b/app/components/PaginatedList.tsx @@ -1,5 +1,5 @@ import isEqual from "lodash/isEqual"; -import { observable, action } from "mobx"; +import { observable, action, computed } from "mobx"; import { observer } from "mobx-react"; import * as React from "react"; import { withTranslation, WithTranslation } from "react-i18next"; @@ -39,7 +39,9 @@ type Props = WithTranslation & }; @observer -class PaginatedList extends React.Component> { +class PaginatedList extends React.PureComponent< + Props +> { @observable error?: Error; @@ -145,6 +147,11 @@ class PaginatedList extends React.Component> { } }; + @computed + get itemsToRender() { + return this.props.items?.slice(0, this.renderCount) ?? []; + } + render() { const { items = [], @@ -188,10 +195,11 @@ class PaginatedList extends React.Component> { aria-label={this.props["aria-label"]} onEscape={onEscape} className={this.props.className} + items={this.itemsToRender} > {() => { let previousHeading = ""; - return items.slice(0, this.renderCount).map((item, index) => { + return this.itemsToRender.map((item, index) => { const children = this.props.renderItem(item, index); // If there is no renderHeading method passed then no date diff --git a/app/components/Sharing/Collection/SharePopover.tsx b/app/components/Sharing/Collection/SharePopover.tsx index a33655f74..a5fcbd6e9 100644 --- a/app/components/Sharing/Collection/SharePopover.tsx +++ b/app/components/Sharing/Collection/SharePopover.tsx @@ -20,6 +20,7 @@ import useBoolean from "~/hooks/useBoolean"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useKeyDown from "~/hooks/useKeyDown"; import usePolicy from "~/hooks/usePolicy"; +import usePrevious from "~/hooks/usePrevious"; import useStores from "~/hooks/useStores"; import { EmptySelectValue, Permission } from "~/types"; import { collectionPath, urlify } from "~/utils/routeHelpers"; @@ -56,6 +57,11 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { CollectionPermission.Read ); + const prevPendingIds = usePrevious(pendingIds); + + const suggestionsRef = React.useRef(null); + const searchInputRef = React.useRef(null); + useKeyDown( "Escape", (ev) => { @@ -97,6 +103,19 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { } }, [visible]); + React.useEffect(() => { + if (prevPendingIds && pendingIds.length > prevPendingIds.length) { + setQuery(""); + searchInputRef.current?.focus(); + } else if (prevPendingIds && pendingIds.length < prevPendingIds.length) { + const firstPending = suggestionsRef.current?.firstElementChild; + + if (firstPending) { + (firstPending as HTMLAnchorElement).focus(); + } + } + }, [pendingIds, prevPendingIds]); + const handleQuery = React.useCallback( (event) => { showPicker(); @@ -119,6 +138,39 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { [setPendingIds] ); + const handleKeyDown = React.useCallback( + (ev: React.KeyboardEvent) => { + if (ev.nativeEvent.isComposing) { + return; + } + if (ev.key === "ArrowDown" && !ev.shiftKey) { + ev.preventDefault(); + + if (ev.currentTarget.value) { + const length = ev.currentTarget.value.length; + const selectionStart = ev.currentTarget.selectionStart || 0; + if (selectionStart < length) { + ev.currentTarget.selectionStart = length; + ev.currentTarget.selectionEnd = length; + return; + } + } + + const firstSuggestion = suggestionsRef.current?.firstElementChild; + + if (firstSuggestion) { + (firstSuggestion as HTMLAnchorElement).focus(); + } + } + }, + [] + ); + + const handleEscape = React.useCallback( + () => searchInputRef.current?.focus(), + [] + ); + const inviteAction = React.useMemo( () => createAction({ @@ -292,8 +344,10 @@ function SharePopover({ collection, visible, onRequestClose }: Props) { {can.update && ( )} diff --git a/app/components/Sharing/Document/SharePopover.tsx b/app/components/Sharing/Document/SharePopover.tsx index 8bef2e122..d1c2d8416 100644 --- a/app/components/Sharing/Document/SharePopover.tsx +++ b/app/components/Sharing/Document/SharePopover.tsx @@ -18,6 +18,7 @@ import useBoolean from "~/hooks/useBoolean"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useKeyDown from "~/hooks/useKeyDown"; import usePolicy from "~/hooks/usePolicy"; +import usePrevious from "~/hooks/usePrevious"; import useStores from "~/hooks/useStores"; import { Permission } from "~/types"; import { documentPath, urlify } from "~/utils/routeHelpers"; @@ -64,6 +65,11 @@ function SharePopover({ DocumentPermission.Read ); + const prevPendingIds = usePrevious(pendingIds); + + const suggestionsRef = React.useRef(null); + const searchInputRef = React.useRef(null); + useKeyDown( "Escape", (ev) => { @@ -107,6 +113,19 @@ function SharePopover({ } }, [picker]); + React.useEffect(() => { + if (prevPendingIds && pendingIds.length > prevPendingIds.length) { + setQuery(""); + searchInputRef.current?.focus(); + } else if (prevPendingIds && pendingIds.length < prevPendingIds.length) { + const firstPending = suggestionsRef.current?.firstElementChild; + + if (firstPending) { + (firstPending as HTMLAnchorElement).focus(); + } + } + }, [pendingIds, prevPendingIds]); + const inviteAction = React.useMemo( () => createAction({ @@ -202,6 +221,39 @@ function SharePopover({ [setPendingIds] ); + const handleKeyDown = React.useCallback( + (ev: React.KeyboardEvent) => { + if (ev.nativeEvent.isComposing) { + return; + } + if (ev.key === "ArrowDown" && !ev.shiftKey) { + ev.preventDefault(); + + if (ev.currentTarget.value) { + const length = ev.currentTarget.value.length; + const selectionStart = ev.currentTarget.selectionStart || 0; + if (selectionStart < length) { + ev.currentTarget.selectionStart = length; + ev.currentTarget.selectionEnd = length; + return; + } + } + + const firstSuggestion = suggestionsRef.current?.firstElementChild; + + if (firstSuggestion) { + (firstSuggestion as HTMLAnchorElement).focus(); + } + } + }, + [] + ); + + const handleEscape = React.useCallback( + () => searchInputRef.current?.focus(), + [] + ); + const permissions = React.useMemo( () => [ @@ -266,8 +318,10 @@ function SharePopover({ {can.manageUsers && ( - - + )}
diff --git a/app/components/Sharing/components/ListItem.tsx b/app/components/Sharing/components/ListItem.tsx index 635570645..443bcfc50 100644 --- a/app/components/Sharing/components/ListItem.tsx +++ b/app/components/Sharing/components/ListItem.tsx @@ -15,7 +15,9 @@ export const ListItem = styled(BaseListItem).attrs({ padding: 6px 16px; border-radius: 8px; - &: ${hover} ${InviteIcon} { + &: ${hover} ${InviteIcon}, + &:focus ${InviteIcon}, + &:focus-within ${InviteIcon} { opacity: 1; } `; diff --git a/app/components/Sharing/components/SearchInput.tsx b/app/components/Sharing/components/SearchInput.tsx index c398c69e4..3bcf3c63c 100644 --- a/app/components/Sharing/components/SearchInput.tsx +++ b/app/components/Sharing/components/SearchInput.tsx @@ -1,6 +1,7 @@ import { AnimatePresence } from "framer-motion"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { mergeRefs } from "react-merge-refs"; import Flex from "~/components/Flex"; import useMobile from "~/hooks/useMobile"; import Input, { NativeInput } from "../../Input"; @@ -10,13 +11,18 @@ type Props = { query: string; onChange: React.ChangeEventHandler; onClick: React.MouseEventHandler; + onKeyDown: React.KeyboardEventHandler; back: React.ReactNode; action: React.ReactNode; }; -export function SearchInput({ onChange, onClick, query, back, action }: Props) { +export const SearchInput = React.forwardRef(function _SearchInput( + { onChange, onClick, onKeyDown, query, back, action }: Props, + ref: React.Ref +) { const { t } = useTranslation(); const inputRef = React.useRef(null); + const isMobile = useMobile(); const focusInput = React.useCallback( @@ -39,6 +45,7 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) { value={query} onChange={onChange} onClick={onClick} + onKeyDown={onKeyDown} autoFocus margin={0} flex @@ -52,15 +59,16 @@ export function SearchInput({ onChange, onClick, query, back, action }: Props) { {back} {action} ); -} +}); diff --git a/app/components/Sharing/components/Suggestions.tsx b/app/components/Sharing/components/Suggestions.tsx index 4ea24e158..e330ff5cc 100644 --- a/app/components/Sharing/components/Suggestions.tsx +++ b/app/components/Sharing/components/Suggestions.tsx @@ -1,4 +1,5 @@ import { isEmail } from "class-validator"; +import concat from "lodash/concat"; import { observer } from "mobx-react"; import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons"; import * as React from "react"; @@ -11,6 +12,7 @@ import Collection from "~/models/Collection"; import Document from "~/models/Document"; import Group from "~/models/Group"; import User from "~/models/User"; +import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; import Avatar from "~/components/Avatar"; import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar"; import Empty from "~/components/Empty"; @@ -40,18 +42,24 @@ type Props = { removePendingId: (id: string) => void; /** Show group suggestions. */ showGroups?: boolean; + /** Handles escape from suggestions list */ + onEscape?: (ev: React.KeyboardEvent) => void; }; export const Suggestions = observer( - ({ - document, - collection, - query, - pendingIds, - addPendingId, - removePendingId, - showGroups, - }: Props) => { + React.forwardRef(function _Suggestions( + { + document, + collection, + query, + pendingIds, + addPendingId, + removePendingId, + showGroups, + onEscape, + }: Props, + ref: React.Ref + ) { const neverRenderedList = React.useRef(false); const { users, groups } = useStores(); const { t } = useTranslation(); @@ -174,34 +182,57 @@ export const Suggestions = observer( neverRenderedList.current = false; return ( - <> - {pending.map((suggestion) => ( - removePendingId(suggestion.id)} - actions={ - <> - - - - } - /> - ))} - {pending.length > 0 && - (suggestionsWithPending.length > 0 || isEmpty) && } - {suggestionsWithPending.map((suggestion) => ( - addPendingId(suggestion.id)} - actions={} - /> - ))} - {isEmpty && {t("No matches")}} - + + {() => [ + ...pending.map((suggestion) => ( + removePendingId(suggestion.id)} + onKeyDown={(ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + ev.stopPropagation(); + removePendingId(suggestion.id); + } + }} + actions={ + <> + + + + } + /> + )), + pending.length > 0 && + (suggestionsWithPending.length > 0 || isEmpty) && , + ...suggestionsWithPending.map((suggestion) => ( + addPendingId(suggestion.id)} + onKeyDown={(ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + ev.stopPropagation(); + addPendingId(suggestion.id); + } + }} + actions={} + /> + )), + isEmpty && {t("No matches")}, + ]} + ); - } + }) ); const InvitedIcon = styled(CheckmarkIcon)` diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index 15721d6ca..a4a3b5e9a 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -270,6 +270,7 @@ function Search(props: Props) { ref={resultListRef} onEscape={handleEscape} aria-label={t("Search Results")} + items={data ?? []} > {() => data?.length diff --git a/app/scenes/Search/components/RecentSearches.tsx b/app/scenes/Search/components/RecentSearches.tsx index 3c9150d27..ac253494e 100644 --- a/app/scenes/Search/components/RecentSearches.tsx +++ b/app/scenes/Search/components/RecentSearches.tsx @@ -34,6 +34,7 @@ function RecentSearches( ref={ref} onEscape={onEscape} aria-label={t("Recent searches")} + items={searches.recent} > {() => searches.recent.map((searchQuery) => ( diff --git a/package.json b/package.json index ca52bdc8c..9e1bf8545 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@getoutline/react-roving-tabindex": "^3.2.2", + "@getoutline/react-roving-tabindex": "^3.2.4", "@getoutline/y-prosemirror": "^1.0.18", "@hocuspocus/extension-throttle": "1.1.2", "@hocuspocus/provider": "1.1.2", diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 4f74121aa..2c9256f6a 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -280,6 +280,7 @@ "Add or invite": "Add or invite", "Viewer": "Viewer", "Editor": "Editor", + "Suggestions for invitation": "Suggestions for invitation", "No matches": "No matches", "{{ userName }} was removed from the document": "{{ userName }} was removed from the document", "Could not remove user": "Could not remove user", diff --git a/yarn.lock b/yarn.lock index 19c39c913..990f11d07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2776,10 +2776,10 @@ dependencies: prop-types "^15.8.1" -"@getoutline/react-roving-tabindex@^3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@getoutline/react-roving-tabindex/-/react-roving-tabindex-3.2.2.tgz#a3d957b0aa298d8c601a02a575cbfb263dcd940e" - integrity sha512-K8uk2BQpngOTrJUMvw/Ai3zOmKNkEo2wmjnkfgHrpW6eOgzTh3VCDOLdqCMNBNqFlNwJudpVRD6EuXNO77cQ0w== +"@getoutline/react-roving-tabindex@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@getoutline/react-roving-tabindex/-/react-roving-tabindex-3.2.4.tgz#c1c9ec97865a89d3ce3a4a665e712f91f16ea23c" + integrity sha512-XlzvLD0dVkzVSXSKBap8cBUSt6mMzI1FfNqQ6R1JwCOcO+LO6QKMojrGuvaccP45RpLoAT5Sjy5UYyRWmF+BZg== dependencies: warning "^4.0.3"