Improve keyboard navigation on invite dialog (#30930)

* fix: improve keyboard navigation on `RichList`

* test: list focus handling

* test: update snapshot

* refactor: rename `useListKeydown` to `useListKeyboardNavigation`
This commit is contained in:
Florian Duros
2025-10-01 17:26:34 +02:00
committed by GitHub
parent 2d5f1b3fb7
commit f9e718644a
6 changed files with 60 additions and 16 deletions

View File

@@ -8,7 +8,7 @@
import { type KeyboardEvent } from "react";
import { renderHook } from "jest-matrix-react";
import { useListKeyDown } from "./useListKeyDown";
import { useListKeyboardNavigation } from "./useListKeyboardNavigation";
describe("useListKeyDown", () => {
let mockList: HTMLUListElement;
@@ -51,9 +51,10 @@ describe("useListKeyDown", () => {
current: {
listRef: React.RefObject<HTMLUListElement | null>;
onKeyDown: React.KeyboardEventHandler<HTMLUListElement>;
onFocus: React.FocusEventHandler<HTMLUListElement>;
};
} {
const { result } = renderHook(() => useListKeyDown());
const { result } = renderHook(() => useListKeyboardNavigation());
result.current.listRef.current = mockList;
return result;
}
@@ -137,4 +138,18 @@ describe("useListKeyDown", () => {
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
it("should focus the first item if list itself is focused", () => {
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[0].focus).toHaveBeenCalledTimes(1);
});
it("should focus the selected item if list itself is focused", () => {
mockItems[1].setAttribute("aria-selected", "true");
const result = render();
result.current.onFocus({ target: mockList } as React.FocusEvent<HTMLUListElement>);
expect(mockItems[1].focus).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,17 +5,45 @@
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useRef, type RefObject, type KeyboardEvent, type KeyboardEventHandler } from "react";
import {
useCallback,
useRef,
type RefObject,
type KeyboardEvent,
type KeyboardEventHandler,
type FocusEventHandler,
type FocusEvent,
} from "react";
/**
* A hook that provides keyboard navigation for a list of options.
*/
export function useListKeyDown(): {
export function useListKeyboardNavigation(): {
listRef: RefObject<HTMLUListElement | null>;
onKeyDown: KeyboardEventHandler<HTMLUListElement>;
onFocus: FocusEventHandler<HTMLUListElement>;
} {
const listRef = useRef<HTMLUListElement>(null);
const onFocus = useCallback((evt: FocusEvent<HTMLUListElement>) => {
if (!listRef.current) return;
if (evt.target === listRef.current) {
// By default, focus the selected item
let selectedChild = listRef.current?.firstElementChild;
// If there is a selected item, focus that instead
for (const child of listRef.current.children) {
if (child.getAttribute("aria-selected") === "true") {
selectedChild = child;
break;
}
}
(selectedChild as HTMLElement)?.focus();
}
}, []);
const onKeyDown = useCallback((evt: KeyboardEvent<HTMLUListElement>) => {
const { key } = evt;
@@ -60,5 +88,5 @@ export function useListKeyDown(): {
evt.preventDefault();
}
}, []);
return { listRef, onKeyDown };
return { listRef, onKeyDown, onFocus };
}

View File

@@ -67,7 +67,7 @@ export const RichItem = memo(function RichItem({
<li
className={styles.richItem}
role="option"
tabIndex={0}
tabIndex={-1}
aria-selected={selected}
aria-label={title}
{...props}

View File

@@ -10,7 +10,7 @@ exports[`RichItem renders the item in default state 1`] = `
aria-label="Rich Item Title"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
class="flex avatar"
@@ -52,7 +52,7 @@ exports[`RichItem renders the item in selected state 1`] = `
aria-selected="true"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
aria-hidden="true"
@@ -103,7 +103,7 @@ exports[`RichItem renders the item without timestamp 1`] = `
aria-label="Rich Item Title"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
class="flex avatar"

View File

@@ -10,7 +10,7 @@ import classNames from "classnames";
import styles from "./RichList.module.css";
import { Flex } from "../../utils/Flex";
import { useListKeyDown } from "../../hooks/useListKeyDown";
import { useListKeyboardNavigation } from "../../hooks/useListKeyboardNavigation";
export interface RichListProps extends HTMLProps<HTMLDivElement> {
/**
@@ -53,7 +53,7 @@ export function RichList({
...props
}: PropsWithChildren<RichListProps>): JSX.Element {
const id = useId();
const { listRef, onKeyDown } = useListKeyDown();
const { listRef, onKeyDown, onFocus } = useListKeyboardNavigation();
return (
<Flex className={classNames(styles.richList, className)} direction="column" {...props}>
@@ -70,6 +70,7 @@ export function RichList({
aria-labelledby={id}
tabIndex={0}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</ul>

View File

@@ -25,7 +25,7 @@ exports[`RichItem renders the list 1`] = `
aria-label="First Item"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
class="flex avatar"
@@ -51,7 +51,7 @@ exports[`RichItem renders the list 1`] = `
aria-selected="true"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
aria-hidden="true"
@@ -86,7 +86,7 @@ exports[`RichItem renders the list 1`] = `
aria-label="Third Item"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
class="flex avatar"
@@ -111,7 +111,7 @@ exports[`RichItem renders the list 1`] = `
aria-label="Fourth Item"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
class="flex avatar"
@@ -136,7 +136,7 @@ exports[`RichItem renders the list 1`] = `
aria-label="Fifth Item"
class="richItem"
role="option"
tabindex="0"
tabindex="-1"
>
<div
class="flex avatar"