From 872248ac53b49692478585e5e04f052c22bfd950 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Thu, 22 Oct 2026 13:20:55 +0200 Subject: [PATCH 01/32] 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/32] 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( -