Skip to content

Commit efcd14e

Browse files
committed
fix: Prevent page scroll on Home and End in Select, Multiselect and OptionList
When the list element has focus, Home and End moved the highlight to the first or last option, and the browser also ran its native action, which scrolls the page or a scroll container to its top or bottom. The list's keydown handler from useListbox now prevents the native Home and End action. This applies to Select and Multiselect without a filter, embedded Multiselect and OptionList. With a filter, focus is in the input, and Home and End keep moving the text cursor.
1 parent ae64fee commit efcd14e

2 files changed

Lines changed: 35 additions & 6 deletions

File tree

‎src/internal/components/listbox/__tests__/use-listbox.test.tsx‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ describe('useListbox', () => {
131131
});
132132

133133
describe('Home and End', () => {
134-
test('move the highlight and keep the native action', () => {
134+
test('move the highlight and keep the native action in a filter input, to move the text cursor', () => {
135135
const { hook, keyDown } = renderListbox();
136136

137137
const end = keyDown(END);
@@ -142,6 +142,21 @@ describe('useListbox', () => {
142142
expect(hook.result.current.highlightedIndex).toBe(1);
143143
expect(home.defaultPrevented).toBe(false);
144144
});
145+
146+
test('move the highlight and prevent the native action on the list, so the page does not scroll', () => {
147+
const { hook } = renderListbox();
148+
const listKeyDown = (keyCode: number) => {
149+
const event = createKeyEvent(keyCode);
150+
act(() => hook.result.current.getListProps({ withKeyboard: true }).onKeyDown!(event));
151+
return event;
152+
};
153+
const end = listKeyDown(END);
154+
expect(hook.result.current.highlightedIndex).toBe(4);
155+
expect(end.defaultPrevented).toBe(true);
156+
const home = listKeyDown(HOME);
157+
expect(hook.result.current.highlightedIndex).toBe(1);
158+
expect(home.defaultPrevented).toBe(true);
159+
});
145160
});
146161

147162
describe('choosing', () => {
@@ -270,12 +285,12 @@ describe('useListbox', () => {
270285
test('adds onKeyDown and aria-activedescendant with withKeyboard', () => {
271286
const { hook } = renderListbox();
272287
act(() => hook.result.current.mouseHandlers.onMouseMove(3));
273-
const { listId, listRef, mouseHandlers, onKeyDown, getListProps } = hook.result.current;
288+
const { listId, listRef, mouseHandlers, getListProps } = hook.result.current;
274289
expect(getListProps({ withKeyboard: true })).toEqual({
275290
id: listId,
276291
ref: listRef,
277292
...mouseHandlers,
278-
onKeyDown,
293+
onKeyDown: expect.any(Function),
279294
nativeAttributes: { 'aria-activedescendant': getOptionId(listId, 3) },
280295
});
281296
});

‎src/internal/components/listbox/use-listbox.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useUniqueId } from '@cloudscape-design/component-toolkit/internal';
77
import { SelectProps } from '../../../select/interfaces';
88
import { BaseKeyDetail, CancelableEventHandler } from '../../../types/events';
99
import { OptionDefinition, OptionGroup } from '../../../types/option';
10+
import { KeyCode } from '../../keycode';
1011
import { DropdownOption } from '../option/interfaces';
1112
import { isGroupInteractive, isInteractive } from '../option/utils/filter-options';
1213
import {
@@ -76,15 +77,20 @@ export interface UseListboxApi {
7677
highlight: HighlightedOptionHandlers<DropdownOption>;
7778
/** Chooses `option`, or the highlighted option when omitted. Ignores non-interactive options. */
7879
selectOption(option?: DropdownOption): void;
79-
/** Keydown handler for the element that has focus (list or filter input). */
80+
/**
81+
* Keydown handler for a filter input that controls the list. Home and End keep their native action there, to move
82+
* the text cursor. The list element gets its own handler from `getListProps({ withKeyboard: true })`, which
83+
* prevents the native Home and End action.
84+
*/
8085
onKeyDown: CancelableEventHandler<BaseKeyDetail>;
8186
/** `onMouseUp` / `onMouseMove` for the list element. */
8287
mouseHandlers: ReturnType<typeof getListMouseHandlers>;
8388
/** Ref of the list element, also set by `getListProps`. */
8489
listRef: RefObject<HTMLDivElement>;
8590
/**
8691
* Base props for the list element: id, ref and mouse handlers. With `withKeyboard`, also `onKeyDown` and
87-
* `aria-activedescendant`, for a list that has focus itself (not through a filter input). With `embedded`, also
92+
* `aria-activedescendant`, for a list that has focus itself (not through a filter input). This `onKeyDown`
93+
* prevents the native Home and End action, so they don't scroll the page or a scroll container. With `embedded`, also
8894
* `onFocus` and `onBlur`, for an embedded list: an always visible list that is a tab stop. Focus highlights the
8995
* first selected option (in selection order), or the first option when nothing is selected, and blur clears the
9096
* highlight. A hover highlight is kept on focus, so a click selects the hovered option.
@@ -133,6 +139,7 @@ export function useListbox({
133139
}
134140
};
135141

142+
// Shared by the list element and a filter input. In a filter input, Home and End also move the text cursor.
136143
const onKeyDown = useMenuKeyboard({
137144
...getListboxNavigation({
138145
options,
@@ -150,6 +157,13 @@ export function useListbox({
150157
// With 'keyboard-highlight', Space chooses only while an option has a keyboard highlight, and types otherwise.
151158
preventNativeSpace: spaceSelects === 'always' || (!!highlightedOption && highlightType.type === 'keyboard'),
152159
});
160+
// On the list element, Home and End only move the highlight, so they don't scroll the page or a scroll container.
161+
const onListKeyDown: CancelableEventHandler<BaseKeyDetail> = event => {
162+
if (event.detail.keyCode === KeyCode.home || event.detail.keyCode === KeyCode.end) {
163+
event.preventDefault();
164+
}
165+
onKeyDown(event);
166+
};
153167

154168
const mouseHandlers = getListMouseHandlers({
155169
options,
@@ -188,7 +202,7 @@ export function useListbox({
188202
const getListProps = ({ withKeyboard, embedded }: { withKeyboard: boolean; embedded?: boolean }) => {
189203
const listProps: BaseListProps = { id: listId, ref: listRef, ...mouseHandlers };
190204
if (withKeyboard) {
191-
listProps.onKeyDown = onKeyDown;
205+
listProps.onKeyDown = onListKeyDown;
192206
listProps.nativeAttributes = { 'aria-activedescendant': activeDescendantId };
193207
}
194208
if (embedded) {

0 commit comments

Comments
 (0)