From 872248ac53b49692478585e5e04f052c22bfd950 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Thu, 22 Oct 2026 13:20:55 +0200 Subject: [PATCH 01/38] feat: Add OptionList component Adds the public OptionList component: an always-visible list of options with single and multi selection, keyboard navigation (arrows, Home/End, PageUp/PageDown), type-ahead, scroll-into-view for the highlighted option (nearest strategy, configurable scroll page), and focus-entry rules (on Tab-in, highlight the first selected option in list order for multi, the selected option for single, otherwise the first option; the pointer flag is only set when focus moves in). Introduces the listbox engine (use-listbox, Listbox, parts/list). OptionList is its only caller in this change. It reuses the shared listbox item, render-options and use-type-ahead that Select and Multiselect already use. onChange is computed with the selection models added in the previous PR. Select, Multiselect, Autosuggest, PromptInput and ButtonDropdown behaviour is unchanged. Supporting edits are additive and opt-in: - options-list: optional statusType (default 'finished') and onMouseLeave. - use-keyboard: closeDropdown is optional; when absent, Escape propagates to a surrounding Dropdown or Modal. - selectable-item: optional hideTopDivider. - shared listbox item: optional hideTopDivider, passed to SelectableItem. - scroll-to-index: additive scrollOptionIntoView export. - eslint: OptionList and the new engine files may not import runtime code from select/ or multiselect/ (type imports allowed). The shared item is not in the rule because it uses the Select item styles. - required-props-for-components: new 'option-list' entry so the shared functional suites can render it. - pluralize: OptionList -> OptionLists for test-utils generation. Also adds test-utils (OptionListWrapper), dev pages, a visual definition and the regenerated documenter/test-utils snapshots. Deliberate base adaptations: no list-container rename (options-list is kept) and no ListOption rename (DropdownOption is kept). Open before merge: listbox id forwarding / aria-controls; i18n namespace (currently useInternalI18n('select')); FormField integration; usage docs. --- build-tools/utils/pluralize.js | 1 + eslint.config.mjs | 29 + pages/option-list/dialog.page.tsx | 77 +++ pages/option-list/in-dropdown.page.tsx | 311 +++++++++ pages/option-list/in-dropdown.scss | 9 + pages/option-list/permutations.page.tsx | 54 ++ pages/option-list/scroll.page.tsx | 199 ++++++ pages/option-list/scroll.scss | 15 + pages/option-list/simple.page.tsx | 101 +++ .../required-props-for-components.ts | 1 + .../__snapshots__/documenter.test.ts.snap | 468 ++++++++++++++ .../test-utils-selectors.test.tsx.snap | 4 + .../test-utils-wrappers.test.tsx.snap | 80 +++ .../listbox/__tests__/scroll-to-index.test.ts | 27 +- src/internal/components/listbox/index.tsx | 81 +++ .../components/listbox/parts/item.tsx | 5 + .../components/listbox/parts/list.tsx | 125 ++++ .../listbox/parts/scroll-to-index.ts | 12 + .../components/listbox/parts/styles.scss | 8 + .../components/listbox/use-listbox.ts | 338 ++++++++++ .../components/options-list/index.tsx | 10 +- .../options-list/utils/use-keyboard.ts | 9 +- .../components/selectable-item/index.tsx | 2 + .../components/selectable-item/interfaces.ts | 1 + .../components/selectable-item/styles.scss | 6 + src/option-list/__integ__/option-list.test.ts | 179 ++++++ .../__tests__/option-list.test.tsx | 598 ++++++++++++++++++ src/option-list/index.tsx | 26 + src/option-list/interfaces.ts | 93 +++ src/option-list/internal.tsx | 117 ++++ src/option-list/styles.scss | 11 + src/option-list/test-classes/styles.scss | 8 + src/test-utils/dom/option-list/index.ts | 115 ++++ test/definitions/visual/option-list.ts | 17 + 34 files changed, 3131 insertions(+), 6 deletions(-) create mode 100644 pages/option-list/dialog.page.tsx create mode 100644 pages/option-list/in-dropdown.page.tsx create mode 100644 pages/option-list/in-dropdown.scss create mode 100644 pages/option-list/permutations.page.tsx create mode 100644 pages/option-list/scroll.page.tsx create mode 100644 pages/option-list/scroll.scss create mode 100644 pages/option-list/simple.page.tsx create mode 100644 src/internal/components/listbox/index.tsx create mode 100644 src/internal/components/listbox/parts/list.tsx create mode 100644 src/internal/components/listbox/parts/styles.scss create mode 100644 src/internal/components/listbox/use-listbox.ts create mode 100644 src/option-list/__integ__/option-list.test.ts create mode 100644 src/option-list/__tests__/option-list.test.tsx create mode 100644 src/option-list/index.tsx create mode 100644 src/option-list/interfaces.ts create mode 100644 src/option-list/internal.tsx create mode 100644 src/option-list/styles.scss create mode 100644 src/option-list/test-classes/styles.scss create mode 100644 src/test-utils/dom/option-list/index.ts create mode 100644 test/definitions/visual/option-list.ts diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index f03df7fa256..c81d1d676c8 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -59,6 +59,7 @@ const pluralizationMap = { Modal: 'Modals', Multiselect: 'Multiselects', NavigableGroup: 'NavigableGroups', + OptionList: 'OptionLists', Pagination: 'Paginations', AppLayoutToolbar: 'AppLayoutToolbars', PanelLayout: 'PanelLayouts', diff --git a/eslint.config.mjs b/eslint.config.mjs index 65e489aa9c0..7936570a523 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -200,6 +200,35 @@ export default tsEslint.config( ], }, }, + { + // OptionList and the shared listbox engine sit below Select and Multiselect, so they must not depend on them at + // runtime. Type-only imports (e.g. the `renderOption` types) are allowed. The remaining listbox files, including + // the shared item (which uses the Select item styles), join this rule when Select and Multiselect move onto the + // engine. + files: [ + 'src/option-list/**', + 'src/internal/components/listbox/use-listbox.ts', + 'src/internal/components/listbox/use-type-ahead.ts', + 'src/internal/components/listbox/index.tsx', + 'src/internal/components/listbox/parts/list.tsx', + ], + ignores: ['**/__tests__/**', '**/__integ__/**', '**/__motion__/**', '**/__a11y__/**'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/select/**', '**/multiselect/**'], + allowTypeImports: true, + message: + 'OptionList and the listbox engine sit below Select and Multiselect. Import only types from them, or move the shared code down.', + }, + ], + }, + ], + }, + }, { files: ['**/__tests__/**', '**/__integ__/**', '**/__motion__/**', '**/__a11y__/**'], ...jestPlugin.configs['flat/recommended'], diff --git a/pages/option-list/dialog.page.tsx b/pages/option-list/dialog.page.tsx new file mode 100644 index 00000000000..d26a1a81599 --- /dev/null +++ b/pages/option-list/dialog.page.tsx @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Button from '~components/button'; +import Dialog from '~components/dialog'; +import OptionList, { OptionListProps } from '~components/option-list'; + +import { SimplePage } from '../app/templates'; + +const options: OptionListProps.Options = [ + { value: 'increase', label: 'Increase volume size' }, + { value: 'change-type', label: 'Change volume type' }, + { value: 'snapshot', label: 'Create a snapshot', disabled: true, disabledReason: 'No volume selected' }, +]; + +function GoalQuestion({ onSubmit, onDismiss }: { onSubmit: (value: string) => void; onDismiss: () => void }) { + const [selectedOptions, setSelectedOptions] = useState>([]); + + return ( + What's your main goal?} + onDismiss={onDismiss} + i18nStrings={{ dismissAriaLabel: 'Close' }} + footer={ + + + + } + > + setSelectedOptions(detail.selectedOptions)} + /> + + ); +} + +export default function OptionListDialogPage() { + const [open, setOpen] = useState(true); + const [result, setResult] = useState(null); + + return ( + +
+ {open ? ( + { + setResult(`Submitted: ${value}`); + setOpen(false); + }} + onDismiss={() => { + setResult('Dismissed'); + setOpen(false); + }} + /> + ) : ( + + )} +
+ + {result ?? 'Not submitted'} + +
+ ); +} diff --git a/pages/option-list/in-dropdown.page.tsx b/pages/option-list/in-dropdown.page.tsx new file mode 100644 index 00000000000..1f6345943a9 --- /dev/null +++ b/pages/option-list/in-dropdown.page.tsx @@ -0,0 +1,311 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Do-it-yourself Select and Multiselect built only from public Dropdown, OptionList, Button and Input, +// rendered next to the real Select and Multiselect for comparing focus, keyboard and screen reader behavior. +// +// Trigger ARIA through the public Button API: +// - Can set: aria-expanded (ariaExpanded), aria-haspopup="listbox" (ariaHaspopup), aria-controls (ariaControls), +// aria-label (ariaLabel), aria-describedby (ariaDescribedby). +// - Cannot set: aria-labelledby (no ariaLabelledby prop, so the visible label is only linked through +// ariaLabel text), role="combobox", aria-activedescendant (focus moves into the list instead), and +// aria-required / aria-invalid that a form field trigger would carry. +// - aria-controls can only reference the OptionList root `id`, which is not the role="listbox" element itself +// (the listbox gets a generated id). + +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; + +import Box from '~components/box'; +import Button, { ButtonProps } from '~components/button'; +import Checkbox from '~components/checkbox'; +import Dropdown from '~components/dropdown'; +import Header from '~components/header'; +import Input from '~components/input'; +import Multiselect, { MultiselectProps } from '~components/multiselect'; +import OptionList, { OptionListProps } from '~components/option-list'; +import Select, { SelectProps } from '~components/select'; +import SpaceBetween from '~components/space-between'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +import styles from './in-dropdown.scss'; + +type PageContext = React.Context>; + +const shortOptions: OptionListProps.Option[] = [ + { value: 'us-east-1', label: 'US East (N. Virginia)' }, + { value: 'us-east-2', label: 'US East (Ohio)' }, + { value: 'us-west-1', label: 'US West (N. California)' }, + { value: 'us-west-2', label: 'US West (Oregon)', description: 'Lowest cost region for this workload.' }, + { value: 'eu-west-1', label: 'Europe (Ireland)' }, + { value: 'eu-central-1', label: 'Europe (Frankfurt)' }, + { value: 'ap-south-1', label: 'Asia Pacific (Mumbai)' }, + { value: 'ap-northeast-1', label: 'Asia Pacific (Tokyo)', disabled: true, disabledReason: 'Not enabled' }, + { value: 'sa-east-1', label: 'South America (São Paulo)' }, + { value: 'ca-central-1', label: 'Canada (Central)' }, +]; + +const longOptions: OptionListProps.Option[] = [ + ...shortOptions, + ...Array.from({ length: 200 - shortOptions.length }, (_, index) => ({ + value: `option-${index + 1}`, + label: `Option ${index + 1}`, + })), +]; + +const i18nStrings: OptionListProps.I18nStrings = { selectedAriaLabel: 'Selected' }; + +function useDiyDropdown() { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const listRef = useRef(null); + + // Move focus into the list once the Dropdown has rendered its content. + useEffect(() => { + if (open) { + listRef.current?.focus(); + } + }, [open]); + + const close = ({ returnFocus }: { returnFocus: boolean }) => { + setOpen(false); + if (returnFocus) { + triggerRef.current?.focus(); + } + }; + + return { open, setOpen, close, triggerRef, listRef }; +} + +function DiySelect({ options, expandToViewport }: { options: OptionListProps.Options; expandToViewport: boolean }) { + const id = 'diy-select'; + const { open, setOpen, close, triggerRef, listRef } = useDiyDropdown(); + const [selectedOptions, setSelectedOptions] = useState>([]); + const triggerText = selectedOptions[0]?.label ?? 'Choose an option'; + + return ( + +
+ DIY single select +
+ close({ returnFocus: false })} + onEscape={() => close({ returnFocus: true })} + minWidth={320} + expandToViewport={expandToViewport} + // No ariaRole here: OptionList already renders role="listbox", and wrapping it in another listbox is invalid. + trigger={ + + } + content={ +
+ { + setSelectedOptions(detail.selectedOptions); + close({ returnFocus: true }); + }} + /> +
+ } + /> + + Selected: {selectedOptions[0]?.value ?? 'none'} + +
+ ); +} + +function DiyMultiselect({ + options, + expandToViewport, +}: { + options: OptionListProps.Options; + expandToViewport: boolean; +}) { + const id = 'diy-multiselect'; + const { open, setOpen, close, triggerRef, listRef } = useDiyDropdown(); + const [selectedOptions, setSelectedOptions] = useState>([]); + const [filteringText, setFilteringText] = useState(''); + const triggerText = selectedOptions.length > 0 ? `${selectedOptions.length} selected` : 'Choose options'; + + const filteredOptions = useMemo(() => { + const text = filteringText.trim().toLowerCase(); + return text ? options.filter(option => (option.label ?? '').toLowerCase().includes(text)) : options; + }, [options, filteringText]); + + return ( + +
+ DIY multiselect +
+ close({ returnFocus: false })} + onEscape={() => close({ returnFocus: true })} + minWidth={320} + expandToViewport={expandToViewport} + // No ariaRole here: OptionList already renders role="listbox", and wrapping it in another listbox is invalid. + // Known limitation: while focus is in the filter input, arrow keys do not move the list highlight and + // there is no aria-activedescendant link, unlike the real Multiselect. Users must Tab into the list. + header={ + + setFilteringText(detail.value)} + /> + + } + trigger={ + + } + content={ + setSelectedOptions(detail.selectedOptions)} + /> + } + /> + + Selected: {selectedOptions.map(option => option.value).join(', ') || 'none'} + +
+ ); +} + +function RealSelect({ options, expandToViewport }: { options: SelectProps.Options; expandToViewport: boolean }) { + const id = 'real-select'; + const [selectedOption, setSelectedOption] = useState(null); + return ( + +
+ Select +
+ setFilteringText(detail.value)} - /> - - } - trigger={ - - } - content={ - setSelectedOptions(detail.selectedOptions)} - /> - } - /> - - Selected: {selectedOptions.map(option => option.value).join(', ') || 'none'} - -
- ); -} - -function RealSelect({ options, expandToViewport }: { options: SelectProps.Options; expandToViewport: boolean }) { - const id = 'real-select'; - const [selectedOption, setSelectedOption] = useState(null); - return ( - -
- Select -
- + ); + createWrapper(container).findSelect()!.findTrigger().keydown({ key: 'c' }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); From 0ad3a86c2a3e0a144ac79dc841cf7b2d781d3bfe Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Tue, 27 Oct 2026 19:07:44 +0100 Subject: [PATCH 10/38] fix: Ignore handled keys in OptionList type-ahead Type-ahead also ran for key events that were already handled elsewhere, for example Space selecting the highlighted option, so the space started a search and the next letter didn't move the highlight. The shared use-type-ahead now skips events with defaultPrevented. Select and Multiselect get the same fix, since they share use-type-ahead. Tests cover the hook, OptionList, Select and Multiselect. --- .../listbox/__tests__/use-type-ahead.test.ts | 12 ++++++ .../components/listbox/use-type-ahead.ts | 3 +- .../__tests__/native-search.test.tsx | 11 +++++ .../__tests__/option-list.test.tsx | 10 +++++ src/select/__tests__/native-search.test.tsx | 41 ++++++++++++++++++- 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/internal/components/listbox/__tests__/use-type-ahead.test.ts b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts index 90aa523c244..43859ec38f8 100644 --- a/src/internal/components/listbox/__tests__/use-type-ahead.test.ts +++ b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts @@ -62,6 +62,18 @@ describe('useTypeAhead', () => { expect(highlightOption).not.toHaveBeenCalled(); }); + test('ignores keys that were already handled by another handler', () => { + const hook = renderHook(useTypeAhead, { initialProps }); + act(() => { + hook.result.current({ key: ' ', defaultPrevented: true } as React.KeyboardEvent); + }); + expect(highlightOption).not.toHaveBeenCalled(); + act(() => { + hook.result.current({ key: 'o' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenCalledWith(options[0]); + }); + describe('with groups', () => { const groupedOptions: DropdownOption[] = [ { type: 'parent', option: { label: 'Compute', options: [] } }, diff --git a/src/internal/components/listbox/use-type-ahead.ts b/src/internal/components/listbox/use-type-ahead.ts index 328e2e2b66f..9eee3ec828d 100644 --- a/src/internal/components/listbox/use-type-ahead.ts +++ b/src/internal/components/listbox/use-type-ahead.ts @@ -80,7 +80,8 @@ export function useTypeAhead({ const { key } = event; - if (!key || key.length !== 1) { + // Skip keys already handled elsewhere, e.g. Space selecting the highlighted option or opening the dropdown. + if (event.defaultPrevented || !key || key.length !== 1) { return; } diff --git a/src/multiselect/__tests__/native-search.test.tsx b/src/multiselect/__tests__/native-search.test.tsx index 4672d8935e2..7b03a73a5ff 100644 --- a/src/multiselect/__tests__/native-search.test.tsx +++ b/src/multiselect/__tests__/native-search.test.tsx @@ -62,4 +62,15 @@ describe('Multiselect native search', () => { type('c'); expect(getHighlightedLabel()).toBe('Compute'); }); + + test('typing right after selecting with Space moves the highlight to the matching option', () => { + const { optionsContainer, type, onChange, getHighlightedLabel } = renderMultiselect(); + // Highlight with the arrow keys so the type-ahead buffer is empty before Space is pressed. + optionsContainer.keydown(KeyCode.down); + expect(getHighlightedLabel()).toBe('EC2'); + optionsContainer.keydown({ keyCode: KeyCode.space, key: ' ' }); + expect(onChange.mock.calls[0][0].detail.selectedOptions).toEqual([{ label: 'EC2', value: 'ec2' }]); + type('g'); + expect(getHighlightedLabel()).toBe('Glacier'); + }); }); diff --git a/src/option-list/__tests__/option-list.test.tsx b/src/option-list/__tests__/option-list.test.tsx index b30763216ee..b1d026dbc6e 100644 --- a/src/option-list/__tests__/option-list.test.tsx +++ b/src/option-list/__tests__/option-list.test.tsx @@ -450,6 +450,16 @@ describe('OptionList', () => { expect(highlightedValue(wrapper)).toBe('c'); }); + test('type-ahead after Space selects moves the highlight to the matching option', () => { + const onChange = jest.fn(); + const { wrapper, listbox } = renderOptionList(); + fireEvent.focus(listbox); + fireEvent.keyDown(listbox, { key: ' ', keyCode: KeyCode.space }); + expect(onChange).toHaveBeenLastCalledWith(['a']); + fireEvent.keyDown(listbox, { key: 't' }); + expect(highlightedValue(wrapper)).toBe('c'); + }); + test('blur clears the highlight', () => { const { wrapper, listbox } = renderOptionList(); fireEvent.focus(listbox); diff --git a/src/select/__tests__/native-search.test.tsx b/src/select/__tests__/native-search.test.tsx index 6dc9105941c..5520d39af3b 100644 --- a/src/select/__tests__/native-search.test.tsx +++ b/src/select/__tests__/native-search.test.tsx @@ -3,10 +3,49 @@ import * as React from 'react'; import { render } from '@testing-library/react'; -import Select from '../../../lib/components/select'; +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import Select, { SelectProps } from '../../../lib/components/select'; import createWrapper from '../../../lib/components/test-utils/dom'; +const options: SelectProps.Options = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Grape', value: 'grape' }, +]; + +const space = { keyCode: KeyCode.space, key: ' ' }; + +function renderSelect() { + const onChange = jest.fn(); + const { container } = render(); - const wrapper = createWrapper(container).findSelect()!; - const getHighlightedLabel = () => - wrapper.findDropdown().findHighlightedOption()?.findLabel().getElement().textContent; - return { wrapper, onChange, getHighlightedLabel }; -} - -describe('Select native search', () => { - test('typing right after opening the dropdown with Space highlights the matching option', () => { - const { wrapper, getHighlightedLabel } = renderSelect(); - wrapper.findTrigger().keydown(space); - expect(wrapper.findDropdown().findOpenDropdown()).not.toBeNull(); - wrapper.findDropdown().findOptionsContainer()!.keydown({ key: 'g' }); - expect(getHighlightedLabel()).toBe('Grape'); - }); - - test('typing right after selecting with Space selects the matching option', () => { - const { wrapper, onChange } = renderSelect(); - wrapper.findTrigger().keydown(KeyCode.down); - wrapper.findDropdown().findOptionsContainer()!.keydown(space); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange.mock.calls[0][0].detail.selectedOption).toEqual({ label: 'Apple', value: 'apple' }); - expect(wrapper.findDropdown().findOpenDropdown()).toBeNull(); - wrapper.findTrigger().keydown({ key: 'g' }); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange.mock.calls[1][0].detail.selectedOption).toEqual({ label: 'Grape', value: 'grape' }); - }); - - test('typing a group label prefix does not select an option inside that group', () => { - const onChange = jest.fn(); - const { container } = render( - + ); + createWrapper(container).findSelect()!.findTrigger().keydown({ key: 'c' }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); From 9a6e3cebc291c4673574536e2b113c5b537ae978 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Tue, 27 Oct 2026 19:17:13 +0100 Subject: [PATCH 30/38] fix: Ignore handled keys in Select and Multiselect type-ahead --- .../listbox/__tests__/use-type-ahead.test.ts | 12 ++++++ .../components/listbox/use-type-ahead.ts | 3 +- .../__tests__/native-search.test.tsx | 11 +++++ src/select/__tests__/native-search.test.tsx | 41 ++++++++++++++++++- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/internal/components/listbox/__tests__/use-type-ahead.test.ts b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts index cb8b915046c..504d7e24c6a 100644 --- a/src/internal/components/listbox/__tests__/use-type-ahead.test.ts +++ b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts @@ -62,6 +62,18 @@ describe('useTypeAhead', () => { expect(highlightOption).not.toHaveBeenCalled(); }); + test('ignores keys that were already handled by another handler', () => { + const hook = renderHook(useTypeAhead, { initialProps }); + act(() => { + hook.result.current({ key: ' ', defaultPrevented: true } as React.KeyboardEvent); + }); + expect(highlightOption).not.toHaveBeenCalled(); + act(() => { + hook.result.current({ key: 'o' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenCalledWith(options[0]); + }); + describe('with groups', () => { const groupedOptions: DropdownOption[] = [ { type: 'parent', option: { label: 'Compute', options: [] } }, diff --git a/src/internal/components/listbox/use-type-ahead.ts b/src/internal/components/listbox/use-type-ahead.ts index 328e2e2b66f..9eee3ec828d 100644 --- a/src/internal/components/listbox/use-type-ahead.ts +++ b/src/internal/components/listbox/use-type-ahead.ts @@ -80,7 +80,8 @@ export function useTypeAhead({ const { key } = event; - if (!key || key.length !== 1) { + // Skip keys already handled elsewhere, e.g. Space selecting the highlighted option or opening the dropdown. + if (event.defaultPrevented || !key || key.length !== 1) { return; } diff --git a/src/multiselect/__tests__/native-search.test.tsx b/src/multiselect/__tests__/native-search.test.tsx index 4672d8935e2..7b03a73a5ff 100644 --- a/src/multiselect/__tests__/native-search.test.tsx +++ b/src/multiselect/__tests__/native-search.test.tsx @@ -62,4 +62,15 @@ describe('Multiselect native search', () => { type('c'); expect(getHighlightedLabel()).toBe('Compute'); }); + + test('typing right after selecting with Space moves the highlight to the matching option', () => { + const { optionsContainer, type, onChange, getHighlightedLabel } = renderMultiselect(); + // Highlight with the arrow keys so the type-ahead buffer is empty before Space is pressed. + optionsContainer.keydown(KeyCode.down); + expect(getHighlightedLabel()).toBe('EC2'); + optionsContainer.keydown({ keyCode: KeyCode.space, key: ' ' }); + expect(onChange.mock.calls[0][0].detail.selectedOptions).toEqual([{ label: 'EC2', value: 'ec2' }]); + type('g'); + expect(getHighlightedLabel()).toBe('Glacier'); + }); }); diff --git a/src/select/__tests__/native-search.test.tsx b/src/select/__tests__/native-search.test.tsx index 6dc9105941c..5520d39af3b 100644 --- a/src/select/__tests__/native-search.test.tsx +++ b/src/select/__tests__/native-search.test.tsx @@ -3,10 +3,49 @@ import * as React from 'react'; import { render } from '@testing-library/react'; -import Select from '../../../lib/components/select'; +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import Select, { SelectProps } from '../../../lib/components/select'; import createWrapper from '../../../lib/components/test-utils/dom'; +const options: SelectProps.Options = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Grape', value: 'grape' }, +]; + +const space = { keyCode: KeyCode.space, key: ' ' }; + +function renderSelect() { + const onChange = jest.fn(); + const { container } = render( {}} options={options} {...props} />); + const wrapper = createWrapper(container).findSelect()!; + wrapper.openDropdown(); + return wrapper; +} + +const highlightedLabel = (wrapper: ReturnType) => + wrapper.findDropdown().findHighlightedOption()?.findLabel().getElement().textContent; + +describe('Select Home and End', () => { + test('without filtering, moves the highlight and prevents the native page scroll', () => { + const wrapper = renderComponent(); + const list = wrapper.findDropdown().findOptionsContainer()!.getElement(); + + expect(fireEvent.keyDown(list, { keyCode: END })).toBe(false); + expect(highlightedLabel(wrapper)).toBe('Cherry'); + + expect(fireEvent.keyDown(list, { keyCode: HOME })).toBe(false); + expect(highlightedLabel(wrapper)).toBe('Apple'); + }); + + test('with filtering, moves the highlight and keeps the native cursor movement in the filter input', () => { + const wrapper = renderComponent({ filteringType: 'auto' }); + const input = wrapper.findFilteringInput()!.findNativeInput().getElement(); + + expect(fireEvent.keyDown(input, { keyCode: END })).toBe(true); + expect(highlightedLabel(wrapper)).toBe('Cherry'); + + expect(fireEvent.keyDown(input, { keyCode: HOME })).toBe(true); + expect(highlightedLabel(wrapper)).toBe('Apple'); + }); +}); diff --git a/src/select/utils/use-select.ts b/src/select/utils/use-select.ts index 4a142f0b567..f36bee8fadf 100644 --- a/src/select/utils/use-select.ts +++ b/src/select/utils/use-select.ts @@ -169,6 +169,7 @@ export function useSelect({ } }, preventNativeSpace: !hasFilter || (highlightedOption && highlightType.type === 'keyboard'), + preventNativeHomeEnd: !hasFilter, }); const triggerKeyDownHandler = useTriggerKeyboard({ From cb3b57c952ed2fc9da8911a666186f8f7ad4d70f Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Wed, 28 Oct 2026 11:59:06 +0100 Subject: [PATCH 34/38] fix: Wrap Select arrow keys at the first and last highlightable options Without interactive groups, group headers can't be highlighted, and an empty group leaves a header row at the start or end of the list. ArrowUp and ArrowDown checked the raw first and last rows, so they didn't wrap and the highlight got stuck. Check against the first and last highlightable options instead. --- .../listbox/__tests__/navigation.test.ts | 19 +++++- .../components/listbox/utils/navigation.ts | 46 +++++++-------- src/select/__tests__/arrow-key-wrap.test.tsx | 59 +++++++++++++++++++ src/select/utils/use-select.ts | 1 - 4 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 src/select/__tests__/arrow-key-wrap.test.tsx diff --git a/src/internal/components/listbox/__tests__/navigation.test.ts b/src/internal/components/listbox/__tests__/navigation.test.ts index de91150d8a1..4ef52acc8bc 100644 --- a/src/internal/components/listbox/__tests__/navigation.test.ts +++ b/src/internal/components/listbox/__tests__/navigation.test.ts @@ -15,6 +15,8 @@ const grouped = flatten([ ]); // [1, header Empty] const endsWithEmptyGroup = flatten([{ value: '1' }, { label: 'Empty', options: [] }]); +// [header Empty, 1] +const startsWithEmptyGroup = flatten([{ label: 'Empty', options: [] }, { value: '1' }]); function setup(options: ReadonlyArray, highlightedIndex: number, interactiveGroups = false) { const moveHighlight = jest.fn(); @@ -23,7 +25,6 @@ function setup(options: ReadonlyArray, highlightedIndex: number, const navigation = getListboxNavigation({ options, highlightedIndex, - highlightedOption: options[highlightedIndex], interactiveGroups, moveHighlight, goHome, @@ -67,9 +68,23 @@ describe('getListboxNavigation', () => { expect(moveHighlight).toHaveBeenCalledWith(-1); }); - test('wraps down only from the last row, even when it is not highlightable', () => { + test('wraps down from the last highlightable option when the list ends with an empty group', () => { const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0); goDown(); + expect(goHome).toHaveBeenCalledTimes(1); + expect(moveHighlight).not.toHaveBeenCalled(); + }); + + test('wraps up from the first highlightable option when the list starts with an empty group', () => { + const { goUp, goEnd, moveHighlight } = setup(startsWithEmptyGroup, 1); + goUp(); + expect(goEnd).toHaveBeenCalledTimes(1); + expect(moveHighlight).not.toHaveBeenCalled(); + }); + + test('moves down to a trailing empty group header when groups are interactive', () => { + const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0, true); + goDown(); expect(goHome).not.toHaveBeenCalled(); expect(moveHighlight).toHaveBeenCalledWith(1); }); diff --git a/src/internal/components/listbox/utils/navigation.ts b/src/internal/components/listbox/utils/navigation.ts index 3351188104e..bed51892a82 100644 --- a/src/internal/components/listbox/utils/navigation.ts +++ b/src/internal/components/listbox/utils/navigation.ts @@ -1,11 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { DropdownOption } from '../../option/interfaces'; +import { isHighlightable } from './option-props'; interface ListboxNavigationProps { options: ReadonlyArray; highlightedIndex: number; - highlightedOption: DropdownOption | undefined; interactiveGroups: boolean; moveHighlight: (direction: -1 | 1) => void; goHome: () => void; @@ -13,33 +13,33 @@ interface ListboxNavigationProps { } /** - * Arrow key handlers that wrap around the list: up from the first option goes to the end, down from the last row - * goes to the start. Without interactive groups, the first child of a leading group counts as the first option. + * Arrow key handlers that wrap around the list: up from the first highlightable option goes to the end, down from + * the last highlightable option goes to the start. Group headers aren't highlightable without interactive groups, + * and an empty group leaves a header row at either end of the list, so the check can't use the first and last rows. */ export const getListboxNavigation = ({ options, highlightedIndex, - highlightedOption, interactiveGroups, moveHighlight, goHome, goEnd, -}: ListboxNavigationProps) => ({ - goUp: () => { - if ( - (!interactiveGroups && highlightedOption?.type === 'child' && highlightedIndex === 1) || - highlightedIndex === 0 - ) { - goEnd(); - return; - } - moveHighlight(-1); - }, - goDown: () => { - if (highlightedIndex === options.length - 1) { - goHome(); - return; - } - moveHighlight(1); - }, -}); +}: ListboxNavigationProps) => { + const highlightable = (option: DropdownOption) => isHighlightable(option, interactiveGroups); + return { + goUp: () => { + if (highlightedIndex === options.findIndex(highlightable)) { + goEnd(); + return; + } + moveHighlight(-1); + }, + goDown: () => { + if (!options.slice(highlightedIndex + 1).some(highlightable)) { + goHome(); + return; + } + moveHighlight(1); + }, + }; +}; diff --git a/src/select/__tests__/arrow-key-wrap.test.tsx b/src/select/__tests__/arrow-key-wrap.test.tsx new file mode 100644 index 00000000000..c14b6a46a79 --- /dev/null +++ b/src/select/__tests__/arrow-key-wrap.test.tsx @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import Select, { SelectProps } from '../../../lib/components/select'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +const HOME = 36; +const END = 35; + +function renderSelect(options: SelectProps.Options) { + const { container } = render(