Skip to content

Commit fd44c64

Browse files
authored
refactor: Build Select and Multiselect on a shared useListbox hook (#5239)
1 parent e54ea56 commit fd44c64

13 files changed

Lines changed: 679 additions & 165 deletions

File tree

‎pages/dropdown/list-with-sticky-item.page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export default function MultiselectPage() {
8989
}
9090
content={
9191
<ListComponent
92-
menuProps={{ statusType: 'finished', ref, open }}
92+
listProps={{ statusType: 'finished', ref, open }}
9393
getOptionProps={(option, index) => ({ option: { ...option }, key: index, index: index, open })}
9494
filteredOptions={options}
9595
filteringValue={''}
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { act, renderHook } from '../../../../__tests__/render-hook';
4+
import { BaseKeyDetail } from '../../../../types/events';
5+
import { OptionDefinition } from '../../../../types/option';
6+
import { createCustomEvent } from '../../../events';
7+
import { KeyCode } from '../../../keycode';
8+
import { flattenOptions } from '../../option/utils/flatten-options';
9+
import { getOptionId } from '../../options-list/utils/use-ids';
10+
import { multiSelection } from '../selection/multi-selection';
11+
import { singleSelection } from '../selection/single-selection';
12+
import { useListbox, UseListboxProps } from '../use-listbox';
13+
14+
const HOME = 36;
15+
const END = 35;
16+
17+
const createKeyEvent = (keyCode: number): CustomEvent<BaseKeyDetail> =>
18+
createCustomEvent({
19+
cancelable: true,
20+
detail: {
21+
keyCode,
22+
key: '',
23+
ctrlKey: false,
24+
shiftKey: false,
25+
altKey: false,
26+
metaKey: false,
27+
isComposing: false,
28+
},
29+
});
30+
31+
const focusEvent = () => createCustomEvent({ detail: {} });
32+
const blurEvent = () => createCustomEvent({ detail: { relatedTarget: null } });
33+
34+
// Flattened: 0 group header, 1 child a, 2 disabled child b, 3 option c, 4 option d.
35+
const { flatOptions } = flattenOptions([
36+
{
37+
label: 'Group',
38+
options: [
39+
{ value: 'a', label: 'A' },
40+
{ value: 'b', label: 'B', disabled: true },
41+
],
42+
},
43+
{ value: 'c', label: 'C' },
44+
{ value: 'd', label: 'D' },
45+
]);
46+
47+
function renderListbox(props: Partial<UseListboxProps> = {}) {
48+
const onSelect = jest.fn();
49+
const hook = renderHook(useListbox, {
50+
initialProps: {
51+
options: flatOptions,
52+
selectedOptions: [],
53+
selection: singleSelection([]),
54+
onEscape: () => {},
55+
interactiveGroups: false,
56+
onSelect,
57+
spaceSelects: 'always',
58+
getParent: () => undefined,
59+
...props,
60+
} as UseListboxProps,
61+
});
62+
const keyDown = (keyCode: number) => {
63+
const event = createKeyEvent(keyCode);
64+
act(() => hook.result.current.onKeyDown(event));
65+
return event;
66+
};
67+
return { hook, onSelect, keyDown };
68+
}
69+
70+
describe('useListbox', () => {
71+
describe('Space', () => {
72+
test("chooses the highlighted option and prevents the native action when spaceSelects is 'always'", () => {
73+
const { hook, onSelect, keyDown } = renderListbox({ spaceSelects: 'always' });
74+
act(() => hook.result.current.highlight.setHighlightedIndexWithMouse(3));
75+
76+
const event = keyDown(KeyCode.space);
77+
78+
expect(event.defaultPrevented).toBe(true);
79+
expect(onSelect).toHaveBeenCalledWith(flatOptions[3]);
80+
});
81+
82+
test("types when nothing is highlighted and spaceSelects is 'keyboard-highlight'", () => {
83+
const { onSelect, keyDown } = renderListbox({ spaceSelects: 'keyboard-highlight' });
84+
85+
const event = keyDown(KeyCode.space);
86+
87+
expect(event.defaultPrevented).toBe(false);
88+
expect(onSelect).not.toHaveBeenCalled();
89+
});
90+
91+
test("types after a mouse highlight when spaceSelects is 'keyboard-highlight'", () => {
92+
const { hook, onSelect, keyDown } = renderListbox({ spaceSelects: 'keyboard-highlight' });
93+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
94+
95+
const event = keyDown(KeyCode.space);
96+
97+
expect(event.defaultPrevented).toBe(false);
98+
expect(onSelect).not.toHaveBeenCalled();
99+
});
100+
101+
test("chooses after a keyboard highlight when spaceSelects is 'keyboard-highlight'", () => {
102+
const { onSelect, keyDown } = renderListbox({ spaceSelects: 'keyboard-highlight' });
103+
keyDown(KeyCode.down);
104+
105+
const event = keyDown(KeyCode.space);
106+
107+
expect(event.defaultPrevented).toBe(true);
108+
expect(onSelect).toHaveBeenCalledWith(flatOptions[1]);
109+
});
110+
});
111+
112+
describe('Escape', () => {
113+
test('calls onEscape and stops propagation', () => {
114+
const onEscape = jest.fn();
115+
const { keyDown } = renderListbox({ onEscape });
116+
117+
const event = keyDown(KeyCode.escape);
118+
119+
expect(onEscape).toHaveBeenCalledTimes(1);
120+
expect(event.cancelBubble).toBe(true);
121+
});
122+
});
123+
124+
describe('Home and End', () => {
125+
test('move the highlight and keep the native action', () => {
126+
const { hook, keyDown } = renderListbox();
127+
128+
const end = keyDown(END);
129+
expect(hook.result.current.highlightedIndex).toBe(4);
130+
expect(end.defaultPrevented).toBe(false);
131+
132+
const home = keyDown(HOME);
133+
expect(hook.result.current.highlightedIndex).toBe(1);
134+
expect(home.defaultPrevented).toBe(false);
135+
});
136+
});
137+
138+
describe('choosing', () => {
139+
test('ignores a disabled option', () => {
140+
const { hook, onSelect, keyDown } = renderListbox();
141+
act(() => hook.result.current.highlight.setHighlightedIndexWithMouse(2));
142+
143+
keyDown(KeyCode.enter);
144+
act(() => hook.result.current.selectOption(flatOptions[2]));
145+
146+
expect(onSelect).not.toHaveBeenCalled();
147+
});
148+
149+
test('ignores a group header when groups are not interactive', () => {
150+
const { hook, onSelect } = renderListbox({ interactiveGroups: false });
151+
152+
act(() => hook.result.current.selectOption(flatOptions[0]));
153+
154+
expect(onSelect).not.toHaveBeenCalled();
155+
});
156+
157+
test('chooses a group header when groups are interactive', () => {
158+
const { hook, onSelect, keyDown } = renderListbox({ interactiveGroups: true });
159+
keyDown(KeyCode.down);
160+
expect(hook.result.current.highlightedOption).toBe(flatOptions[0]);
161+
162+
keyDown(KeyCode.enter);
163+
164+
expect(onSelect).toHaveBeenCalledWith(flatOptions[0]);
165+
});
166+
167+
test('chooses the highlighted option on Enter and prevents the native action', () => {
168+
const { onSelect, keyDown } = renderListbox();
169+
keyDown(KeyCode.down);
170+
171+
const event = keyDown(KeyCode.enter);
172+
173+
expect(event.defaultPrevented).toBe(true);
174+
expect(onSelect).toHaveBeenCalledWith(flatOptions[1]);
175+
});
176+
});
177+
178+
describe('mouse', () => {
179+
test('onMouseMove highlights with mouse type and onMouseUp chooses', () => {
180+
const { hook, onSelect } = renderListbox();
181+
182+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
183+
expect(hook.result.current.highlightedOption).toBe(flatOptions[3]);
184+
expect(hook.result.current.highlightType.type).toBe('mouse');
185+
186+
act(() => hook.result.current.mouseHandlers.onMouseUp(4));
187+
expect(onSelect).toHaveBeenCalledWith(flatOptions[4]);
188+
});
189+
190+
test('ignore index -1', () => {
191+
const { hook, onSelect } = renderListbox();
192+
193+
act(() => hook.result.current.mouseHandlers.onMouseMove(-1));
194+
act(() => hook.result.current.mouseHandlers.onMouseUp(-1));
195+
196+
expect(hook.result.current.highlightedIndex).toBe(-1);
197+
expect(onSelect).not.toHaveBeenCalled();
198+
});
199+
});
200+
201+
describe('getOptionProps', () => {
202+
test('returns highlight, selection matched by value and the row id', () => {
203+
const selected: OptionDefinition = { value: 'c' };
204+
const { hook } = renderListbox({ selectedOptions: [selected], selection: singleSelection([selected]) });
205+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
206+
const { listId, getOptionProps, selectedOptions } = hook.result.current;
207+
208+
expect(selectedOptions).toEqual([flatOptions[3]]);
209+
expect(getOptionProps(flatOptions[3], 3)).toEqual(
210+
expect.objectContaining({
211+
option: flatOptions[3],
212+
highlighted: true,
213+
selected: true,
214+
id: getOptionId(listId, 3),
215+
'data-mouse-target': 3,
216+
})
217+
);
218+
expect(getOptionProps(flatOptions[4], 4)).toEqual(
219+
expect.objectContaining({ highlighted: false, selected: false, isPreviousSelected: true })
220+
);
221+
});
222+
223+
test('gives a non-highlightable group header data-mouse-target -1', () => {
224+
const { hook } = renderListbox({ interactiveGroups: false });
225+
226+
expect(hook.result.current.getOptionProps(flatOptions[0], 0)['data-mouse-target']).toBe(-1);
227+
});
228+
229+
test('takes group state from the selection model', () => {
230+
const selectedOptions = [{ value: 'a' }];
231+
const { hook } = renderListbox({
232+
interactiveGroups: true,
233+
selectedOptions,
234+
selection: multiSelection(selectedOptions, { options: flatOptions, interactiveGroups: true }),
235+
});
236+
237+
expect(hook.result.current.getOptionProps(flatOptions[0], 0)).toEqual(
238+
expect.objectContaining({ 'data-mouse-target': 0, selected: false, indeterminate: true })
239+
);
240+
expect(hook.result.current.getOptionProps(flatOptions[1], 1).selected).toBe(true);
241+
});
242+
});
243+
244+
describe('activeDescendantId', () => {
245+
test('is the id of the highlighted row, and undefined when nothing is highlighted', () => {
246+
const { hook } = renderListbox();
247+
expect(hook.result.current.activeDescendantId).toBeUndefined();
248+
249+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
250+
expect(hook.result.current.activeDescendantId).toBe(getOptionId(hook.result.current.listId, 3));
251+
});
252+
});
253+
254+
describe('getListProps', () => {
255+
test('returns the id, list ref and mouse handlers without keyboard props', () => {
256+
const { hook } = renderListbox();
257+
const { listId, listRef, mouseHandlers, getListProps } = hook.result.current;
258+
expect(getListProps({ withKeyboard: false })).toEqual({ id: listId, ref: listRef, ...mouseHandlers });
259+
});
260+
261+
test('adds onKeyDown and aria-activedescendant with withKeyboard', () => {
262+
const { hook } = renderListbox();
263+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
264+
const { listId, listRef, mouseHandlers, onKeyDown, getListProps } = hook.result.current;
265+
expect(getListProps({ withKeyboard: true })).toEqual({
266+
id: listId,
267+
ref: listRef,
268+
...mouseHandlers,
269+
onKeyDown,
270+
nativeAttributes: { 'aria-activedescendant': getOptionId(listId, 3) },
271+
});
272+
});
273+
274+
describe('embedded', () => {
275+
const getProps = (hook: ReturnType<typeof renderListbox>['hook']) =>
276+
hook.result.current.getListProps({ withKeyboard: true, embedded: true });
277+
278+
test('adds onFocus and onBlur only with embedded', () => {
279+
const { hook } = renderListbox();
280+
const withoutEmbedded = hook.result.current.getListProps({ withKeyboard: true });
281+
expect(withoutEmbedded.onFocus).toBeUndefined();
282+
expect(withoutEmbedded.onBlur).toBeUndefined();
283+
});
284+
285+
test('focus without a highlight highlights the first option with keyboard, and blur resets it', () => {
286+
const { hook } = renderListbox();
287+
act(() => getProps(hook).onFocus!(focusEvent()));
288+
expect(hook.result.current.highlightedIndex).toBe(1);
289+
expect(hook.result.current.highlightType.type).toBe('keyboard');
290+
291+
act(() => getProps(hook).onBlur!(blurEvent()));
292+
expect(hook.result.current.highlightedIndex).toBe(-1);
293+
});
294+
295+
test('focus keeps an existing highlight', () => {
296+
const { hook } = renderListbox();
297+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
298+
act(() => getProps(hook).onFocus!(focusEvent()));
299+
expect(hook.result.current.highlightedIndex).toBe(3);
300+
});
301+
});
302+
});
303+
304+
describe('scrollToIndex', () => {
305+
test('is called with the highlighted index when the highlight changes', () => {
306+
const { hook, keyDown } = renderListbox();
307+
const scrollToIndex = jest.fn();
308+
(hook.result.current.scrollToIndex as { current: unknown }).current = scrollToIndex;
309+
310+
keyDown(KeyCode.down);
311+
expect(scrollToIndex).toHaveBeenLastCalledWith(1);
312+
313+
act(() => hook.result.current.mouseHandlers.onMouseMove(4));
314+
expect(scrollToIndex).toHaveBeenLastCalledWith(4);
315+
expect(scrollToIndex).toHaveBeenCalledTimes(2);
316+
});
317+
});
318+
319+
describe('announceSelected', () => {
320+
test('is true only when the highlighted option is selected', () => {
321+
const { hook } = renderListbox({
322+
selectedOptions: [{ value: 'c' }],
323+
selection: singleSelection([{ value: 'c' }]),
324+
});
325+
326+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
327+
expect(hook.result.current.announceSelected).toBe(true);
328+
329+
act(() => hook.result.current.mouseHandlers.onMouseMove(4));
330+
expect(hook.result.current.announceSelected).toBe(false);
331+
});
332+
});
333+
334+
describe('announcement', () => {
335+
test('announces the highlighted option, with selectedAriaLabel when it is selected', () => {
336+
const { hook } = renderListbox({
337+
selectedOptions: [{ value: 'c' }],
338+
selection: singleSelection([{ value: 'c' }]),
339+
selectedAriaLabel: 'Selected',
340+
});
341+
expect(hook.result.current.announcement).toBe('');
342+
343+
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
344+
expect(hook.result.current.announcement).toBe('Selected C');
345+
346+
act(() => hook.result.current.mouseHandlers.onMouseMove(4));
347+
expect(hook.result.current.announcement).toBe('D');
348+
});
349+
});
350+
});

‎src/internal/components/listbox/interfaces.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ export type ListElementProps = Omit<OptionsListProps, 'children'> & { ref: React
1111

1212
/** Props for the row at `index`. */
1313
export type GetOptionProps = (option: DropdownOption, index: number) => ItemProps;
14+
15+
/** Ref of a list component (`PlainList`, `VirtualList`): scrolls the row at `index` into view. */
16+
export type ScrollToIndexRef = (index: number) => void;

0 commit comments

Comments
 (0)