From a5816e70d71b7ae63bd3742846d21d721b222f22 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Thu, 22 Oct 2026 13:20:55 +0200 Subject: [PATCH 01/23] 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 shared listbox engine (use-listbox, Listbox, parts/list, parts/item, parts/styles.scss, use-type-ahead). OptionList is its only caller in this change. 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. - render-options: optional selectionType renders the new listbox Item; unset keeps the existing path. It now statically imports parts/item, so that code ships in Select/Multiselect bundles (no behaviour change). - 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). - 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. Temporary duplication: use-type-ahead overlaps with Select's use-native-search until Select moves onto the engine. 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 +- .../listbox/__tests__/use-type-ahead.test.ts | 64 ++ src/internal/components/listbox/index.tsx | 81 +++ .../components/listbox/parts/item.tsx | 278 ++++++++ .../components/listbox/parts/list.tsx | 125 ++++ .../listbox/parts/render-options.tsx | 17 +- .../listbox/parts/scroll-to-index.ts | 12 + .../components/listbox/parts/styles.scss | 40 ++ .../components/listbox/use-listbox.ts | 338 ++++++++++ .../components/listbox/use-type-ahead.ts | 94 +++ .../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 + 37 files changed, 3609 insertions(+), 8 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/__tests__/use-type-ahead.test.ts create mode 100644 src/internal/components/listbox/index.tsx create mode 100644 src/internal/components/listbox/parts/item.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/internal/components/listbox/use-type-ahead.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..28d5afb14c0 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 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', + 'src/internal/components/listbox/parts/item.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 -
-