Enable keyboard navigation in member invite list (#7102)

* feat: keyboard nav in share popover

* fix: memoize
This commit is contained in:
Apoorv Mishra
2024-06-22 06:05:05 +05:30
committed by GitHub
parent 5ddc36555d
commit 0f1f0e82c2
13 changed files with 274 additions and 63 deletions

View File

@@ -5,10 +5,11 @@ import * as React from "react";
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: () => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
items: unknown[];
};
function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
{ children, onEscape, items, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const handleKeyDown = React.useCallback(
@@ -36,7 +37,10 @@ function ArrowKeyNavigation(
);
return (
<RovingTabIndexProvider options={{ focusOnClick: true, direction: "both" }}>
<RovingTabIndexProvider
options={{ focusOnClick: true, direction: "both" }}
items={items}
>
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
{children()}
</div>

View File

@@ -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<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
/** An icon or image to display to the left of the list item */
@@ -16,6 +17,8 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
to?: LocationDescriptor;
/** An optional click handler, if provided the list item will have hover styles */
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
/** An optional keydown handler, if provided the list item will have hover styles */
onKeyDown?: React.KeyboardEventHandler<HTMLAnchorElement>;
/** Whether to match the location exactly */
exact?: boolean;
/** The title of the list item */
@@ -28,10 +31,22 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "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<HTMLAnchorElement>
) => {
const theme = useTheme();
@@ -45,7 +60,7 @@ const ListItem = (
const { focused, ...rovingTabIndex } = useRovingTabIndex(
itemRef as React.RefObject<HTMLAnchorElement>,
to ? false : true
keyboardNavigation || to ? false : true
);
useFocusEffect(focused, itemRef as React.RefObject<HTMLAnchorElement>);
@@ -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 (
<Wrapper ref={itemRef} $border={border} $small={small} {...rest}>
<Wrapper
ref={itemRef}
$border={border}
$small={small}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
onKeyDown={(ev) => {
if (rest.onKeyDown) {
rest.onKeyDown(ev);
}
rovingTabIndex.onKeyDown(ev);
}}
>
{content(false)}
</Wrapper>
);
@@ -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"};
}

View File

@@ -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<T> = WithTranslation &
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
@observable
error?: Error;
@@ -145,6 +147,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
};
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
render() {
const {
items = [],
@@ -188,10 +195,11 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
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

View File

@@ -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<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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) {
<Wrapper>
{can.update && (
<SearchInput
ref={searchInputRef}
onChange={handleQuery}
onClick={showPicker}
onKeyDown={handleKeyDown}
query={query}
back={backButton}
action={rightButton}
@@ -303,11 +357,13 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
{picker && (
<div>
<Suggestions
ref={suggestionsRef}
query={query}
collection={collection}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
/>
</div>
)}

View File

@@ -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<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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({
<Wrapper>
{can.manageUsers && (
<SearchInput
ref={searchInputRef}
onChange={handleQuery}
onClick={showPicker}
onKeyDown={handleKeyDown}
query={query}
back={backButton}
action={rightButton}
@@ -275,15 +329,15 @@ function SharePopover({
)}
{picker && (
<div>
<Suggestions
document={document}
query={query}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
/>
</div>
<Suggestions
ref={suggestionsRef}
document={document}
query={query}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
onEscape={handleEscape}
/>
)}
<div style={{ display: picker ? "none" : "block" }}>

View File

@@ -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;
}
`;

View File

@@ -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<HTMLInputElement>
) {
const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(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}
<NativeInput
key="input"
ref={inputRef}
ref={mergeRefs([inputRef, ref])}
placeholder={`${t("Add or invite")}`}
value={query}
onChange={onChange}
onClick={onClick}
onKeyDown={onKeyDown}
style={{ padding: "6px 0" }}
/>
{action}
</AnimatePresence>
</HeaderInput>
);
}
});

View File

@@ -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<HTMLDivElement>) => 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<HTMLDivElement>
) {
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) => (
<PendingListItem
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
))}
{pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
{suggestionsWithPending.map((suggestion) => (
<ListItem
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
actions={<InviteIcon />}
/>
))}
{isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>}
</>
<ArrowKeyNavigation
ref={ref}
onEscape={onEscape}
aria-label={t("Suggestions for invitation")}
items={concat(pending, suggestionsWithPending)}
>
{() => [
...pending.map((suggestion) => (
<PendingListItem
keyboardNavigation
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
removePendingId(suggestion.id);
}
}}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
)),
pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
...suggestionsWithPending.map((suggestion) => (
<ListItem
keyboardNavigation
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
addPendingId(suggestion.id);
}
}}
actions={<InviteIcon />}
/>
)),
isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>,
]}
</ArrowKeyNavigation>
);
}
})
);
const InvitedIcon = styled(CheckmarkIcon)`

View File

@@ -270,6 +270,7 @@ function Search(props: Props) {
ref={resultListRef}
onEscape={handleEscape}
aria-label={t("Search Results")}
items={data ?? []}
>
{() =>
data?.length

View File

@@ -34,6 +34,7 @@ function RecentSearches(
ref={ref}
onEscape={onEscape}
aria-label={t("Recent searches")}
items={searches.recent}
>
{() =>
searches.recent.map((searchQuery) => (

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"