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

@@ -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)`