Skip to content

Commit c31444b

Browse files
authored
Fix a11y issue on list in invite dialog (#30878)
* fix: focus decoration when tabbing on rich item * feat: add `useListKeyDown` hook * fix: improve keyboard navigation on `RichList` and `RichItem`
1 parent 88d4f36 commit c31444b

File tree

7 files changed

+256
-34
lines changed

7 files changed

+256
-34
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type KeyboardEvent } from "react";
9+
import { renderHook } from "jest-matrix-react";
10+
11+
import { useListKeyDown } from "./useListKeyDown";
12+
13+
describe("useListKeyDown", () => {
14+
let mockList: HTMLUListElement;
15+
let mockItems: HTMLElement[];
16+
let mockEvent: Partial<KeyboardEvent<HTMLUListElement>>;
17+
18+
beforeEach(() => {
19+
// Create mock DOM elements
20+
mockList = document.createElement("ul");
21+
mockItems = [document.createElement("li"), document.createElement("li"), document.createElement("li")];
22+
23+
// Set up the DOM structure
24+
mockItems.forEach((item, index) => {
25+
item.setAttribute("tabindex", "0");
26+
item.setAttribute("data-testid", `item-${index}`);
27+
mockList.appendChild(item);
28+
});
29+
30+
document.body.appendChild(mockList);
31+
32+
// Mock event object
33+
mockEvent = {
34+
preventDefault: jest.fn(),
35+
key: "",
36+
};
37+
38+
// Mock focus methods
39+
mockItems.forEach((item) => {
40+
item.focus = jest.fn();
41+
item.click = jest.fn();
42+
});
43+
});
44+
45+
afterEach(() => {
46+
document.body.removeChild(mockList);
47+
jest.clearAllMocks();
48+
});
49+
50+
function render(): {
51+
current: {
52+
listRef: React.RefObject<HTMLUListElement | null>;
53+
onKeyDown: React.KeyboardEventHandler<HTMLUListElement>;
54+
};
55+
} {
56+
const { result } = renderHook(() => useListKeyDown());
57+
result.current.listRef.current = mockList;
58+
return result;
59+
}
60+
61+
it.each([
62+
["Enter", "Enter"],
63+
["Space", " "],
64+
])("should handle %s key to click active element", (name, key) => {
65+
const result = render();
66+
67+
// Mock document.activeElement
68+
Object.defineProperty(document, "activeElement", {
69+
value: mockItems[1],
70+
configurable: true,
71+
});
72+
73+
// Simulate key press
74+
result.current.onKeyDown({
75+
...mockEvent,
76+
key,
77+
} as KeyboardEvent<HTMLUListElement>);
78+
79+
expect(mockItems[1].click).toHaveBeenCalledTimes(1);
80+
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
81+
});
82+
83+
it.each(
84+
// key, finalPosition, startPosition
85+
[
86+
["ArrowDown", 1, 0],
87+
["ArrowUp", 1, 2],
88+
["Home", 0, 1],
89+
["End", 2, 1],
90+
],
91+
)("should handle %s to focus the %inth element", (key, finalPosition, startPosition) => {
92+
const result = render();
93+
mockList.contains = jest.fn().mockReturnValue(true);
94+
95+
Object.defineProperty(document, "activeElement", {
96+
value: mockItems[startPosition],
97+
configurable: true,
98+
});
99+
100+
result.current.onKeyDown({
101+
...mockEvent,
102+
key,
103+
} as KeyboardEvent<HTMLUListElement>);
104+
105+
expect(mockItems[finalPosition].focus).toHaveBeenCalledTimes(1);
106+
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
107+
});
108+
109+
it.each([["ArrowDown"], ["ArrowUp"]])("should not handle %s when active element is not in list", (key) => {
110+
const result = render();
111+
mockList.contains = jest.fn().mockReturnValue(false);
112+
113+
const outsideElement = document.createElement("button");
114+
115+
Object.defineProperty(document, "activeElement", {
116+
value: outsideElement,
117+
configurable: true,
118+
});
119+
120+
result.current.onKeyDown({
121+
...mockEvent,
122+
key,
123+
} as KeyboardEvent<HTMLUListElement>);
124+
125+
// No item should be focused
126+
mockItems.forEach((item) => expect(item.focus).not.toHaveBeenCalled());
127+
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
128+
});
129+
130+
it("should not prevent default for unhandled keys", () => {
131+
const result = render();
132+
133+
result.current.onKeyDown({
134+
...mockEvent,
135+
key: "Tab",
136+
} as KeyboardEvent<HTMLUListElement>);
137+
138+
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
139+
});
140+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useCallback, useRef, type RefObject, type KeyboardEvent, type KeyboardEventHandler } from "react";
9+
10+
/**
11+
* A hook that provides keyboard navigation for a list of options.
12+
*/
13+
export function useListKeyDown(): {
14+
listRef: RefObject<HTMLUListElement | null>;
15+
onKeyDown: KeyboardEventHandler<HTMLUListElement>;
16+
} {
17+
const listRef = useRef<HTMLUListElement>(null);
18+
19+
const onKeyDown = useCallback((evt: KeyboardEvent<HTMLUListElement>) => {
20+
const { key } = evt;
21+
22+
let handled = false;
23+
24+
switch (key) {
25+
case "Enter":
26+
case " ": {
27+
handled = true;
28+
(document.activeElement as HTMLElement).click();
29+
break;
30+
}
31+
case "ArrowDown": {
32+
handled = true;
33+
const currentFocus = document.activeElement;
34+
if (listRef.current?.contains(currentFocus) && currentFocus) {
35+
(currentFocus.nextElementSibling as HTMLElement)?.focus();
36+
}
37+
break;
38+
}
39+
case "ArrowUp": {
40+
handled = true;
41+
const currentFocus = document.activeElement;
42+
if (listRef.current?.contains(currentFocus) && currentFocus) {
43+
(currentFocus.previousElementSibling as HTMLElement)?.focus();
44+
}
45+
break;
46+
}
47+
case "Home": {
48+
handled = true;
49+
(listRef.current?.firstElementChild as HTMLElement)?.focus();
50+
break;
51+
}
52+
case "End": {
53+
handled = true;
54+
(listRef.current?.lastElementChild as HTMLElement)?.focus();
55+
break;
56+
}
57+
}
58+
59+
if (handled) {
60+
evt.preventDefault();
61+
}
62+
}, []);
63+
return { listRef, onKeyDown };
64+
}

src/shared-components/rich-list/RichItem/RichItem.module.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
*/
77

88
.richItem {
9-
all: unset;
9+
/* Remove browser button style */
10+
background: transparent;
11+
border: none;
1012
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-4x);
1113
width: 100%;
1214
box-sizing: border-box;
1315
cursor: pointer;
16+
text-align: start;
1417

1518
display: grid;
1619
column-gap: var(--cpd-space-3x);
@@ -20,7 +23,8 @@
2023
"avatar description time";
2124
}
2225

23-
.richItem:hover {
26+
.richItem:hover,
27+
.richItem:focus {
2428
background-color: var(--cpd-color-bg-subtle-secondary);
2529
border-radius: 12px;
2630
}

src/shared-components/rich-list/RichItem/RichItem.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import styles from "./RichItem.module.css";
1212
import { humanizeTime } from "../../utils/humanize";
1313
import { Flex } from "../../utils/Flex";
1414

15-
export interface RichItemProps extends HTMLAttributes<HTMLButtonElement> {
15+
export interface RichItemProps extends HTMLAttributes<HTMLLIElement> {
1616
/**
1717
* Avatar to display at the start of the item
1818
*/
@@ -64,10 +64,10 @@ export const RichItem = memo(function RichItem({
6464
...props
6565
}: RichItemProps): JSX.Element {
6666
return (
67-
<button
67+
<li
6868
className={styles.richItem}
69-
type="button"
7069
role="option"
70+
tabIndex={0}
7171
aria-selected={selected}
7272
aria-label={title}
7373
{...props}
@@ -80,7 +80,7 @@ export const RichItem = memo(function RichItem({
8080
{humanizeTime(timestamp)}
8181
</span>
8282
)}
83-
</button>
83+
</li>
8484
);
8585
});
8686

src/shared-components/rich-list/RichItem/__snapshots__/RichItem.test.tsx.snap

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ exports[`RichItem renders the item in default state 1`] = `
66
role="listbox"
77
style="all: unset; list-style: none;"
88
>
9-
<button
9+
<li
1010
aria-label="Rich Item Title"
1111
class="richItem"
1212
role="option"
13-
type="button"
13+
tabindex="0"
1414
>
1515
<div
1616
class="flex avatar"
@@ -36,7 +36,7 @@ exports[`RichItem renders the item in default state 1`] = `
3636
>
3737
145 days ago
3838
</span>
39-
</button>
39+
</li>
4040
</ul>
4141
</div>
4242
`;
@@ -47,12 +47,12 @@ exports[`RichItem renders the item in selected state 1`] = `
4747
role="listbox"
4848
style="all: unset; list-style: none;"
4949
>
50-
<button
50+
<li
5151
aria-label="Rich Item Title"
5252
aria-selected="true"
5353
class="richItem"
5454
role="option"
55-
type="button"
55+
tabindex="0"
5656
>
5757
<div
5858
aria-hidden="true"
@@ -88,7 +88,7 @@ exports[`RichItem renders the item in selected state 1`] = `
8888
>
8989
145 days ago
9090
</span>
91-
</button>
91+
</li>
9292
</ul>
9393
</div>
9494
`;
@@ -99,11 +99,11 @@ exports[`RichItem renders the item without timestamp 1`] = `
9999
role="listbox"
100100
style="all: unset; list-style: none;"
101101
>
102-
<button
102+
<li
103103
aria-label="Rich Item Title"
104104
class="richItem"
105105
role="option"
106-
type="button"
106+
tabindex="0"
107107
>
108108
<div
109109
class="flex avatar"
@@ -123,7 +123,7 @@ exports[`RichItem renders the item without timestamp 1`] = `
123123
>
124124
This is a description of the rich item.
125125
</span>
126-
</button>
126+
</li>
127127
</ul>
128128
</div>
129129
`;

src/shared-components/rich-list/RichList/RichList.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import React, { type HTMLProps, type JSX, type PropsWithChildren } from "react";
8+
import React, { type HTMLProps, type JSX, type PropsWithChildren, useId } from "react";
99
import classNames from "classnames";
1010

1111
import styles from "./RichList.module.css";
1212
import { Flex } from "../../utils/Flex";
13+
import { useListKeyDown } from "../../hooks/useListKeyDown";
1314

1415
export interface RichListProps extends HTMLProps<HTMLDivElement> {
1516
/**
@@ -51,15 +52,25 @@ export function RichList({
5152
isEmpty = false,
5253
...props
5354
}: PropsWithChildren<RichListProps>): JSX.Element {
55+
const id = useId();
56+
const { listRef, onKeyDown } = useListKeyDown();
57+
5458
return (
5559
<Flex className={classNames(styles.richList, className)} direction="column" {...props}>
56-
<span className={styles.title} {...titleAttributes}>
60+
<span id={id} className={styles.title} {...titleAttributes}>
5761
{title}
5862
</span>
5963
{isEmpty ? (
6064
<span className={styles.empty}>{children}</span>
6165
) : (
62-
<ul role="listbox" className={styles.content} aria-label={title}>
66+
<ul
67+
ref={listRef}
68+
role="listbox"
69+
className={styles.content}
70+
aria-labelledby={id}
71+
tabIndex={0}
72+
onKeyDown={onKeyDown}
73+
>
6374
{children}
6475
</ul>
6576
)}

0 commit comments

Comments
 (0)