Skip to content

Commit 33cfa99

Browse files
committed
refactor: Share listbox keyboard navigation
Move Select's arrow key wrap logic (goUp/goDown) into a shared helper, getListboxNavigation, in src/internal/components/listbox/utils/navigation.ts, and use it in useSelect. The helper keeps Select's current behavior exactly, including wrapping at the first and last rows. Home/End handling stays in useMenuKeyboard unchanged. No behavior change for Select or Multiselect.
1 parent 82cd274 commit 33cfa99

3 files changed

Lines changed: 138 additions & 18 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { OptionDefinition, OptionGroup } from '../../../../types/option';
4+
import { DropdownOption } from '../../option/interfaces';
5+
import { flattenOptions } from '../../option/utils/flatten-options';
6+
import { getListboxNavigation } from '../utils/navigation';
7+
8+
const flatten = (options: ReadonlyArray<OptionDefinition | OptionGroup>) => flattenOptions(options).flatOptions;
9+
10+
const flat = flatten([{ value: '1' }, { value: '2' }, { value: '3' }]);
11+
// [header A, a1, a2, header B, b1]
12+
const grouped = flatten([
13+
{ label: 'A', options: [{ value: 'a1' }, { value: 'a2' }] },
14+
{ label: 'B', options: [{ value: 'b1' }] },
15+
]);
16+
// [1, header Empty]
17+
const endsWithEmptyGroup = flatten([{ value: '1' }, { label: 'Empty', options: [] }]);
18+
19+
function setup(options: ReadonlyArray<DropdownOption>, highlightedIndex: number, interactiveGroups = false) {
20+
const moveHighlight = jest.fn();
21+
const goHome = jest.fn();
22+
const goEnd = jest.fn();
23+
const navigation = getListboxNavigation({
24+
options,
25+
highlightedIndex,
26+
highlightedOption: options[highlightedIndex],
27+
interactiveGroups,
28+
moveHighlight,
29+
goHome,
30+
goEnd,
31+
});
32+
return { ...navigation, moveHighlight, goHome, goEnd };
33+
}
34+
35+
describe('getListboxNavigation', () => {
36+
test('moves the highlight by one option', () => {
37+
const { goUp, goDown, moveHighlight } = setup(flat, 1);
38+
goDown();
39+
expect(moveHighlight).toHaveBeenLastCalledWith(1);
40+
goUp();
41+
expect(moveHighlight).toHaveBeenLastCalledWith(-1);
42+
});
43+
44+
test('wraps from the first option to the end and from the last option to the start', () => {
45+
const first = setup(flat, 0);
46+
first.goUp();
47+
expect(first.goEnd).toHaveBeenCalledTimes(1);
48+
expect(first.moveHighlight).not.toHaveBeenCalled();
49+
50+
const last = setup(flat, 2);
51+
last.goDown();
52+
expect(last.goHome).toHaveBeenCalledTimes(1);
53+
expect(last.moveHighlight).not.toHaveBeenCalled();
54+
});
55+
56+
test('wraps up from the first child of a leading group when groups are not interactive', () => {
57+
const { goUp, goEnd, moveHighlight } = setup(grouped, 1);
58+
goUp();
59+
expect(goEnd).toHaveBeenCalledTimes(1);
60+
expect(moveHighlight).not.toHaveBeenCalled();
61+
});
62+
63+
test('moves up to the leading group header when groups are interactive', () => {
64+
const { goUp, goEnd, moveHighlight } = setup(grouped, 1, true);
65+
goUp();
66+
expect(goEnd).not.toHaveBeenCalled();
67+
expect(moveHighlight).toHaveBeenCalledWith(-1);
68+
});
69+
70+
test('wraps down only from the last row, even when it is not highlightable', () => {
71+
const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0);
72+
goDown();
73+
expect(goHome).not.toHaveBeenCalled();
74+
expect(moveHighlight).toHaveBeenCalledWith(1);
75+
});
76+
77+
test('moves down from no highlight', () => {
78+
const { goDown, goHome, moveHighlight } = setup(flat, -1);
79+
goDown();
80+
expect(goHome).not.toHaveBeenCalled();
81+
expect(moveHighlight).toHaveBeenCalledWith(1);
82+
});
83+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { DropdownOption } from '../../option/interfaces';
4+
5+
interface ListboxNavigationProps {
6+
options: ReadonlyArray<DropdownOption>;
7+
highlightedIndex: number;
8+
highlightedOption: DropdownOption | undefined;
9+
interactiveGroups: boolean;
10+
moveHighlight: (direction: -1 | 1) => void;
11+
goHome: () => void;
12+
goEnd: () => void;
13+
}
14+
15+
/**
16+
* Arrow key handlers that wrap around the list: up from the first option goes to the end, down from the last row
17+
* goes to the start. Without interactive groups, the first child of a leading group counts as the first option.
18+
*/
19+
export const getListboxNavigation = ({
20+
options,
21+
highlightedIndex,
22+
highlightedOption,
23+
interactiveGroups,
24+
moveHighlight,
25+
goHome,
26+
goEnd,
27+
}: ListboxNavigationProps) => ({
28+
goUp: () => {
29+
if (
30+
(!interactiveGroups && highlightedOption?.type === 'child' && highlightedIndex === 1) ||
31+
highlightedIndex === 0
32+
) {
33+
goEnd();
34+
return;
35+
}
36+
moveHighlight(-1);
37+
},
38+
goDown: () => {
39+
if (highlightedIndex === options.length - 1) {
40+
goHome();
41+
return;
42+
}
43+
moveHighlight(1);
44+
},
45+
});

‎src/select/utils/use-select.ts‎

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ItemProps } from '../../internal/components/listbox/parts/item';
1111
import { SelectionModel } from '../../internal/components/listbox/selection/interfaces';
1212
import { singleSelection } from '../../internal/components/listbox/selection/single-selection';
1313
import { connectOptionsByValue } from '../../internal/components/listbox/utils/connect-options';
14+
import { getListboxNavigation } from '../../internal/components/listbox/utils/navigation';
1415
import {
1516
getListboxOptionProps,
1617
getListMouseHandlers,
@@ -149,24 +150,15 @@ export function useSelect({
149150
};
150151

151152
const activeKeyDownHandler = useMenuKeyboard({
152-
goUp: () => {
153-
if (
154-
(!useInteractiveGroups && highlightedOption?.type === 'child' && highlightedIndex === 1) ||
155-
highlightedIndex === 0
156-
) {
157-
goEndWithKeyboard();
158-
return;
159-
}
160-
moveHighlightWithKeyboard(-1);
161-
},
162-
goDown: () => {
163-
if (highlightedIndex === options.length - 1) {
164-
goHomeWithKeyboard();
165-
return;
166-
}
167-
168-
moveHighlightWithKeyboard(1);
169-
},
153+
...getListboxNavigation({
154+
options,
155+
highlightedIndex,
156+
highlightedOption,
157+
interactiveGroups: useInteractiveGroups,
158+
moveHighlight: moveHighlightWithKeyboard,
159+
goHome: goHomeWithKeyboard,
160+
goEnd: goEndWithKeyboard,
161+
}),
170162
selectOption,
171163
goHome: goHomeWithKeyboard,
172164
goEnd: goEndWithKeyboard,

0 commit comments

Comments
 (0)