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/permutations.page.tsx b/pages/option-list/permutations.page.tsx new file mode 100644 index 00000000000..1b7147bca60 --- /dev/null +++ b/pages/option-list/permutations.page.tsx @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Icon from '~components/icon'; +import OptionList, { OptionListProps } from '~components/option-list'; + +import { PermutationsPage } from '../app/templates'; +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; + +const plain: OptionListProps.Option[] = [ + { value: '1', label: 'Reduce cost' }, + { value: '2', label: 'Improve performance', description: 'Prioritize latency and throughput.' }, + { + value: '3', + label: 'Increase reliability across every Region and Availability Zone that hosts the workload', + description: 'A long label and a long description that wrap onto multiple lines on narrow containers.', + }, + { value: '4', label: 'Strengthen security' }, +]; +const withIcons = plain.map(option => ({ ...option, icon: })); +const withTags = plain.map(option => ({ ...option, labelTag: '128 GB', tags: ['CPU-v2', '2 GB RAM'] })); +const withDisabled = plain.map(option => + option.value === '2' || option.value === '4' + ? { ...option, disabled: true, disabledReason: option.value === '4' ? 'Requires Enterprise support' : undefined } + : option +); +const grouped: OptionListProps.Options = [ + { value: 'group-1', label: 'Goals', options: plain.slice(0, 2) }, + { value: 'group-2', label: 'More goals', options: plain.slice(2) }, +]; + +const renderOption: OptionListProps['renderOption'] = ({ item }) => {item.option.label}; + +const permutations = createPermutations([ + { + width: [240, 480], + options: [plain, withIcons, withTags, withDisabled, grouped], + selectionType: ['single', 'multi'], + selectedOptions: [[], [plain[1]], [plain[1], plain[2]]], + }, + { + width: [240], + options: [grouped], + selectionType: ['single', 'multi'], + selectedOptions: [[plain[1]]], + renderOption: [renderOption], + }, +]).filter(({ selectionType, selectedOptions }) => selectionType === 'multi' || (selectedOptions?.length ?? 0) < 2); + +export default function OptionListPermutationsPage() { + return ( + + ( +
+ {}} /> +
+ )} + /> +
+ ); +} diff --git a/pages/option-list/scroll.page.tsx b/pages/option-list/scroll.page.tsx new file mode 100644 index 00000000000..aab31aa4c02 --- /dev/null +++ b/pages/option-list/scroll.page.tsx @@ -0,0 +1,216 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useRef, useState } from 'react'; + +import Button, { ButtonProps } from '~components/button'; +import Dropdown from '~components/dropdown'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import OptionList, { OptionListProps } from '~components/option-list'; +import SegmentedControl from '~components/segmented-control'; +import SpaceBetween from '~components/space-between'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +import styles from './scroll.scss'; + +/** + * Where the scrolling happens: + * - `wrapper`: a wrapper div with a fixed height and `overflow: auto`. + * - `page`: no container, the page scrolls. + * - `dropdown`: the list is the content of a Dropdown opened by a button. + */ +type Container = 'wrapper' | 'page' | 'dropdown'; +type Preselected = 'none' | 'near-end'; + +type PageContext = React.Context< + AppContextType<{ + container?: Container; + expandToViewport?: boolean; + selectionType?: OptionListProps.SelectionType; + preselected?: Preselected; + }> +>; + +const options: OptionListProps.Option[] = Array.from({ length: 100 }, (_, index) => ({ + value: `option-${index + 1}`, + label: `Option ${index + 1}`, +})); + +function DropdownOptionList({ + expandToViewport, + list, + listRef, +}: { + expandToViewport: boolean; + list: (props: { onChange: () => void }) => React.ReactNode; + listRef: React.RefObject; +}) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + // Move focus into the list once the Dropdown has rendered its content. + useEffect(() => { + if (open) { + listRef.current?.focus(); + } + }, [open, listRef]); + + const close = (returnFocus: boolean) => { + setOpen(false); + if (returnFocus) { + triggerRef.current?.focus(); + } + }; + + return ( + close(false)} + onEscape={() => close(true)} + minWidth={320} + expandToViewport={expandToViewport} + trigger={ + + } + content={
{list({ onChange: () => close(true) })}
} + /> + ); +} + +function ConfiguredOptionList({ + container, + expandToViewport, + selectionType, + preselected, +}: { + container: Container; + expandToViewport: boolean; + selectionType: OptionListProps.SelectionType; + preselected: Preselected; +}) { + const [selectedOptions, setSelectedOptions] = useState>( + preselected === 'near-end' ? [options[80]] : [] + ); + const listRef = useRef(null); + + const list = ({ onChange }: { onChange?: () => void } = {}) => ( + { + setSelectedOptions(detail.selectedOptions); + onChange?.(); + }} + /> + ); + + return ( + + {/* Integ tests click the heading, then press Tab to move keyboard focus into the list. */} +
+ Long list +
+ {container === 'wrapper' && ( +
+ {list()} +
+ )} + {container === 'page' && list()} + {container === 'dropdown' && ( + + )} +
+ Selected: {selectedOptions.map(option => option.value).join(', ') || 'none'} +
+
+ ); +} + +export default function OptionListScrollPage() { + const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + const container = urlParams.container ?? 'wrapper'; + const expandToViewport = urlParams.expandToViewport ?? false; + const selectionType = urlParams.selectionType ?? 'single'; + const preselected = urlParams.preselected ?? 'none'; + + return ( + + + setUrlParams({ container: detail.selectedId as Container })} + options={[ + { id: 'wrapper', text: 'Wrapper' }, + { id: 'page', text: 'Page' }, + { id: 'dropdown', text: 'Dropdown' }, + ]} + /> + + + setUrlParams({ expandToViewport: detail.selectedId === 'true' })} + options={[ + { id: 'false', text: 'No' }, + { id: 'true', text: 'Yes' }, + ]} + /> + + + + setUrlParams({ selectionType: detail.selectedId as OptionListProps.SelectionType }) + } + options={[ + { id: 'single', text: 'Single' }, + { id: 'multi', text: 'Multi' }, + ]} + /> + + + setUrlParams({ preselected: detail.selectedId as Preselected })} + options={[ + { id: 'none', text: 'None' }, + { id: 'near-end', text: 'Option 81' }, + ]} + /> + + + } + screenshotArea={{}} + > +
+ {/* Remount on every configuration change so the initial selection follows the settings. */} + +
+
+ ); +} diff --git a/pages/option-list/scroll.scss b/pages/option-list/scroll.scss new file mode 100644 index 00000000000..d52e0391478 --- /dev/null +++ b/pages/option-list/scroll.scss @@ -0,0 +1,15 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +// A scroll container for the list. Applied to a wrapper div. +.fixed-height { + block-size: 240px; + overflow: auto; +} + +// Lets the list scroll inside the Dropdown content instead of overflowing it. +.dropdown-content { + overflow: auto; +} diff --git a/pages/option-list/simple.page.tsx b/pages/option-list/simple.page.tsx new file mode 100644 index 00000000000..12a65d6cd90 --- /dev/null +++ b/pages/option-list/simple.page.tsx @@ -0,0 +1,235 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useRef, useState } from 'react'; + +import Box from '~components/box'; +import Button from '~components/button'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import OptionList, { OptionListProps } from '~components/option-list'; +import SegmentedControl from '~components/segmented-control'; +import SpaceBetween from '~components/space-between'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +/** + * Options selected when the page loads: + * - `first` and `last`: the first or last option of each list. + * - `two`: the second and fourth options, given in reverse order so selection order differs from list order. + * Multi selection only; single-selection lists start empty. + */ +type Preselected = 'none' | 'first' | 'last' | 'two'; + +type PageContext = React.Context>; + +const goals: OptionListProps.Option[] = [ + { + value: 'cost', + label: 'Reduce cost', + description: 'Prioritize cheaper instance types and storage tiers.', + icon: , + }, + { value: 'performance', label: 'Improve performance', description: 'Prioritize latency and throughput.' }, + { value: 'reliability', label: 'Increase reliability', description: 'Add redundancy across Availability Zones.' }, + { value: 'security', label: 'Strengthen security', description: 'Tighten IAM policies and encryption settings.' }, +]; + +const services: OptionListProps.Options = [ + { + value: 'compute', + label: 'Compute', + options: [ + { value: 'ec2', label: 'Amazon EC2' }, + { value: 'lambda', label: 'AWS Lambda' }, + ], + }, + { + value: 'storage', + label: 'Storage', + options: [ + { value: 's3', label: 'Amazon S3' }, + { value: 'efs', label: 'Amazon EFS', disabled: true, disabledReason: 'Not available in this Region.' }, + ], + }, +]; + +/** + * Labels share first letters within and across groups, so repeated letters cycle through matches, and a + * child's first letter differs from its group's (typing `e` should reach "EC2", not "Compute"). + */ +const typeAheadServices: OptionListProps.Options = [ + { + value: 'compute', + label: 'Compute', + options: [ + { value: 'ec2', label: 'EC2' }, + { value: 'elastic-beanstalk', label: 'Elastic Beanstalk' }, + { value: 'lambda', label: 'Lambda' }, + ], + }, + { + value: 'containers', + label: 'Containers', + options: [ + { value: 'ecs', label: 'ECS' }, + { value: 'eks', label: 'EKS' }, + { value: 'fargate', label: 'Fargate' }, + ], + }, + { + value: 'storage', + label: 'Storage', + options: [ + { value: 's3', label: 'S3' }, + { value: 'efs', label: 'EFS' }, + { value: 'glacier', label: 'Glacier' }, + ], + }, +]; + +const groupKey: keyof OptionListProps.OptionGroup = 'options'; + +const leafOptions = (options: OptionListProps.Options): OptionListProps.Option[] => + options.flatMap(option => (groupKey in option ? [...(option as OptionListProps.OptionGroup).options] : [option])); + +function initialSelection( + options: OptionListProps.Options, + selectionType: OptionListProps.SelectionType, + preselected: Preselected +): ReadonlyArray { + const leaves = leafOptions(options); + switch (preselected) { + case 'first': + return [leaves[0]]; + case 'last': + return [leaves[leaves.length - 1]]; + case 'two': + return selectionType === 'multi' ? [leaves[3], leaves[1]] : []; + default: + return []; + } +} + +function Section({ + id, + title, + description, + options, + selectionType, + preselected, + listRef, +}: { + id: string; + title: string; + description?: string; + options: OptionListProps.Options; + selectionType: OptionListProps.SelectionType; + preselected: Preselected; + listRef: React.RefObject; +}) { + const [selectedOptions, setSelectedOptions] = useState(() => initialSelection(options, selectionType, preselected)); + return ( + + {/* Header's `id` is on its root element, so a header description would become part of the list's name. */} +
+ {title} +
+ {description && {description}} + setSelectedOptions(detail.selectedOptions)} + /> + + Selected: {selectedOptions.map(option => option.value).join(', ') || 'none'} + +
+ ); +} + +export default function OptionListSimplePage() { + const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + const preselected = urlParams.preselected ?? 'none'; + + const singleRef = useRef(null); + const multiRef = useRef(null); + const groupsRef = useRef(null); + const typeAheadRef = useRef(null); + + return ( + + + setUrlParams({ preselected: detail.selectedId as Preselected })} + options={[ + { id: 'none', text: 'None' }, + { id: 'first', text: 'First' }, + { id: 'last', text: 'Last' }, + { id: 'two', text: 'Second and fourth (multi only)' }, + ]} + /> + + {/* Moves focus with `ref.focus()`, the way a builder's own dropdown does when it opens. */} + + + + + + + + + + } + screenshotArea={{}} + > +
+ {/* Remount when the setting changes so the initial selection follows it. */} + +
+
+
+
+ +
+
+ ); +} diff --git a/src/__tests__/required-props-for-components.ts b/src/__tests__/required-props-for-components.ts index 14d4bf233ac..aac588752d9 100644 --- a/src/__tests__/required-props-for-components.ts +++ b/src/__tests__/required-props-for-components.ts @@ -8,6 +8,7 @@ const defaultProps: Record> = { table: { columnDefinitions: [] }, cards: { cardDefinition: {} }, autosuggest: { options: [], value: '' }, + 'option-list': { options: [], selectionType: 'single' }, 'anchor-navigation': { anchors: [] }, 'code-editor': { i18nStrings }, wizard: { diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index faa0a22efec..03ab23019f3 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -21044,6 +21044,193 @@ need to be direct children of the group: } `; +exports[`Components definition for option-list matches the snapshot: option-list 1`] = ` +{ + "dashCaseName": "option-list", + "events": [ + { + "cancelable": false, + "description": "Called when the user selects or deselects an option. +The event \`detail\` contains the new \`selectedOptions\`.", + "detailInlineType": { + "name": "OptionListProps.ChangeDetail", + "properties": [ + { + "name": "selectedOptions", + "optional": false, + "type": "ReadonlyArray", + }, + ], + "type": "object", + }, + "detailType": "OptionListProps.ChangeDetail", + "name": "onChange", + }, + ], + "functions": [ + { + "name": "focus", + "parameters": [], + "returnType": "void", + }, + ], + "name": "OptionList", + "properties": [ + { + "description": "Adds \`aria-describedby\` to the list.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-label\` to the list. Use this when there is no visible label.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the list. Use this to reference the visible label of the list.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "An object containing all the necessary localized strings required by the component. +* \`selectedAriaLabel\` - Announced with the highlighted option when it is selected.", + "i18nTag": true, + "inlineType": { + "name": "OptionListProps.I18nStrings", + "properties": [ + { + "name": "selectedAriaLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "OptionListProps.I18nStrings", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Specifies an array of options that are displayed inline as a list. +You can group options into one level of option groups. + +#### Option +- \`value\` (string) - The returned value of the option when selected. Must be unique. +- \`label\` (string) - (Optional) Option text displayed to the user. +- \`lang\` (string) - (Optional) The language of the option, provided as a BCP 47 language tag. +- \`description\` (string) - (Optional) Further information about the option that appears below the label. +- \`disabled\` (boolean) - (Optional) Determines whether the option is disabled. Users can't select or deselect a disabled option. +- \`disabledReason\` (ReactNode) - (Optional) Displays a tooltip near the item when disabled. Use to provide additional context. +- \`labelTag\` (string) - (Optional) A label tag that provides additional guidance, shown next to the label. +- \`tags\` [string[]] - (Optional) A list of tags giving further guidance about the option. +- \`icon\` (ReactNode) - (Optional) An icon displayed in the option, such as an [icon component](/components/icon/). + +#### OptionGroup +- \`value\` (string) - (Optional) Used to locate option group in test utils. +- \`label\` (string) - Option group text displayed to the user. +- \`disabled\` (boolean) - (Optional) Determines whether the option group is disabled. Disables every option in the group. +- \`options\` (Option[]) - The options in the group. + +In multi selection, choosing a group header selects or deselects all its enabled options. + +Options are the selection target. Do not render interactive elements such as buttons or links inside them.", + "name": "options", + "optional": false, + "type": "OptionListProps.Options", + }, + { + "description": "Overrides the element that is announced to screen readers +when the highlighted option changes. By default, this announces +the option's name and properties, and its selected state if +the \`selectedAriaLabel\` property is defined. +The highlighted option is provided, and its group (if groups +are used and it differs from the group of the previously highlighted option). + +For more information, see the +[accessibility guidelines](/components/select/?tabId=usage#accessibility-guidelines).", + "inlineType": { + "name": "OptionListProps.ContainingOptionAndGroupString", + "parameters": [ + { + "name": "option", + "type": "OptionListProps.Option", + }, + { + "name": "group", + "type": "OptionListProps.OptionGroup", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "renderHighlightedAriaLive", + "optional": true, + "type": "OptionListProps.ContainingOptionAndGroupString", + }, + { + "description": "Specifies a render function to render custom options.", + "inlineType": { + "name": "OptionListProps.OptionItemRenderer", + "parameters": [ + { + "name": "props", + "type": "{ item: OptionListProps.Item; }", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "renderOption", + "optional": true, + "type": "OptionListProps.OptionItemRenderer", + }, + { + "description": "The selected options. Options are matched by \`value\`. In single selection, only the first option of the array is used. +If you don't provide this property, the component manages its selection internally.", + "name": "selectedOptions", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "Specifies whether the user can select one option (\`single\`) or several (\`multi\`). +Multi selection shows a checkbox next to each option.", + "inlineType": { + "name": "OptionListProps.SelectionType", + "type": "union", + "values": [ + "single", + "multi", + ], + }, + "name": "selectionType", + "optional": false, + "type": "string", + }, + ], + "regions": [], + "releaseStatus": "stable", +} +`; + exports[`Components definition for pagination matches the snapshot: pagination 1`] = ` { "dashCaseName": "pagination", @@ -44685,6 +44872,204 @@ wrapper.selectOptionByValue('option_1'); ], "name": "NavigableGroupWrapper", }, + { + "methods": [ + { + "description": "Returns an option group header for a given value.", + "name": "findGroupHeaderByValue", + "parameters": [ + { + "description": "The \`value\` of the option group.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns all option group headers.", + "name": "findGroupHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "OptionListOptionWrapper", + }, + ], + }, + }, + { + "description": "Returns the highlighted option. Group headers are not included.", + "name": "findHighlightedOption", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns an option for a given index. Group headers are not counted.", + "name": "findOptionByIndex", + "parameters": [ + { + "description": "1-based index of the option to return.", + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns an option for a given value.", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The \`value\` of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns all options. Group headers are not included.", + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "OptionListOptionWrapper", + }, + ], + }, + }, + { + "description": "Returns all selected options. Group headers are not included.", + "name": "findSelectedOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "OptionListOptionWrapper", + }, + ], + }, + }, + ], + "name": "OptionListWrapper", + }, + { + "methods": [ + { + "description": "Returns the option description.", + "name": "findDescription", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the option label.", + "name": "findLabel", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the option label tag.", + "name": "findLabelTag", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the option tags.", + "name": "findTags", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns \`true\` if the option is disabled.", + "name": "isDisabled", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "boolean", + }, + }, + { + "description": "Returns \`true\` if the option is highlighted.", + "name": "isHighlighted", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "boolean", + }, + }, + { + "description": "Returns \`true\` if the option is selected. A group header in multi selection is selected when all its options are selected.", + "name": "isSelected", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "boolean", + }, + }, + ], + "name": "OptionListOptionWrapper", + }, { "methods": [ { @@ -55563,6 +55948,162 @@ The dismiss button is only rendered when the \`dismissible\` property is set to ], "name": "NavigableGroupWrapper", }, + { + "methods": [ + { + "description": "Returns an option group header for a given value.", + "name": "findGroupHeaderByValue", + "parameters": [ + { + "description": "The \`value\` of the option group.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns all option group headers.", + "name": "findGroupHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "OptionListOptionWrapper", + }, + ], + }, + }, + { + "description": "Returns the highlighted option. Group headers are not included.", + "name": "findHighlightedOption", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns an option for a given index. Group headers are not counted.", + "name": "findOptionByIndex", + "parameters": [ + { + "description": "1-based index of the option to return.", + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns an option for a given value.", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The \`value\` of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionListOptionWrapper", + }, + }, + { + "description": "Returns all options. Group headers are not included.", + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "OptionListOptionWrapper", + }, + ], + }, + }, + { + "description": "Returns all selected options. Group headers are not included.", + "name": "findSelectedOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "OptionListOptionWrapper", + }, + ], + }, + }, + ], + "name": "OptionListWrapper", + }, + { + "methods": [ + { + "description": "Returns the option description.", + "name": "findDescription", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the option label.", + "name": "findLabel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the option label tag.", + "name": "findLabelTag", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the option tags.", + "name": "findTags", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "OptionListOptionWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index afd62836ff5..19098c0c241 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -515,6 +515,11 @@ exports[`test-utils selectors 1`] = ` "navigable-group": [ "awsui_root_efqlv", ], + "option-list": [ + "awsui_group-header_1kbcw", + "awsui_option_1kbcw", + "awsui_root_1kbcw", + ], "pagination": [ "awsui_arrow_fvjdu", "awsui_button-current_fvjdu", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 0ae69c9b241..5d123e012f3 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -67,6 +67,7 @@ import MixedLineBarChartWrapper from './mixed-line-bar-chart'; import ModalWrapper from './modal'; import MultiselectWrapper from './multiselect'; import NavigableGroupWrapper from './navigable-group'; +import OptionListWrapper from './option-list'; import PaginationWrapper from './pagination'; import PanelLayoutWrapper from './panel-layout'; import PieChartWrapper from './pie-chart'; @@ -171,6 +172,7 @@ export { MixedLineBarChartWrapper }; export { ModalWrapper }; export { MultiselectWrapper }; export { NavigableGroupWrapper }; +export { OptionListWrapper }; export { PaginationWrapper }; export { PanelLayoutWrapper }; export { PieChartWrapper }; @@ -1843,6 +1845,34 @@ findAllNavigableGroups(selector?: string): Array; * @returns {NavigableGroupWrapper | null} */ findClosestNavigableGroup(): NavigableGroupWrapper | null; +/** + * Returns the wrapper of the first OptionList that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first OptionList. + * If no matching OptionList is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {OptionListWrapper | null} + */ +findOptionList(selector?: string): OptionListWrapper | null; + +/** + * Returns an array of OptionList wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the OptionLists inside the current wrapper. + * If no matching OptionList is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllOptionLists(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent OptionList for the current element, + * or the element itself if it is an instance of OptionList. + * If no OptionList is found, returns \`null\`. + * + * @returns {OptionListWrapper | null} + */ +findClosestOptionList(): OptionListWrapper | null; /** * Returns the wrapper of the first Pagination that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first Pagination. @@ -3833,6 +3863,19 @@ ElementWrapper.prototype.findNavigableGroup = function(selector) { ElementWrapper.prototype.findAllNavigableGroups = function(selector) { return this.findAllComponents(NavigableGroupWrapper, selector); }; +ElementWrapper.prototype.findOptionList = function(selector) { + let rootSelector = \`.\${OptionListWrapper.rootSelector}\`; + if("legacyRootSelector" in OptionListWrapper && OptionListWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${OptionListWrapper.rootSelector}, .\${OptionListWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, OptionListWrapper); +}; + +ElementWrapper.prototype.findAllOptionLists = function(selector) { + return this.findAllComponents(OptionListWrapper, selector); +}; ElementWrapper.prototype.findPagination = function(selector) { let rootSelector = \`.\${PaginationWrapper.rootSelector}\`; if("legacyRootSelector" in PaginationWrapper && PaginationWrapper.legacyRootSelector){ @@ -4696,6 +4739,11 @@ ElementWrapper.prototype.findClosestNavigableGroup = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(NavigableGroupWrapper); }; +ElementWrapper.prototype.findClosestOptionList = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(OptionListWrapper); +}; ElementWrapper.prototype.findClosestPagination = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4993,6 +5041,7 @@ import MixedLineBarChartWrapper from './mixed-line-bar-chart'; import ModalWrapper from './modal'; import MultiselectWrapper from './multiselect'; import NavigableGroupWrapper from './navigable-group'; +import OptionListWrapper from './option-list'; import PaginationWrapper from './pagination'; import PanelLayoutWrapper from './panel-layout'; import PieChartWrapper from './pie-chart'; @@ -5097,6 +5146,7 @@ export { MixedLineBarChartWrapper }; export { ModalWrapper }; export { MultiselectWrapper }; export { NavigableGroupWrapper }; +export { OptionListWrapper }; export { PaginationWrapper }; export { PanelLayoutWrapper }; export { PieChartWrapper }; @@ -6131,6 +6181,23 @@ findNavigableGroup(selector?: string): NavigableGroupWrapper; * @returns {MultiElementWrapper} */ findAllNavigableGroups(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the OptionLists with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches OptionLists. + * + * @param {string} [selector] CSS Selector + * @returns {OptionListWrapper} + */ +findOptionList(selector?: string): OptionListWrapper; + +/** + * Returns a multi-element wrapper that matches OptionLists with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches OptionLists. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllOptionLists(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the Paginations with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches Paginations. @@ -7637,6 +7704,19 @@ ElementWrapper.prototype.findNavigableGroup = function(selector) { ElementWrapper.prototype.findAllNavigableGroups = function(selector) { return this.findAllComponents(NavigableGroupWrapper, selector); }; +ElementWrapper.prototype.findOptionList = function(selector) { + let rootSelector = \`.\${OptionListWrapper.rootSelector}\`; + if("legacyRootSelector" in OptionListWrapper && OptionListWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${OptionListWrapper.rootSelector}, .\${OptionListWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, OptionListWrapper); +}; + +ElementWrapper.prototype.findAllOptionLists = function(selector) { + return this.findAllComponents(OptionListWrapper, selector); +}; ElementWrapper.prototype.findPagination = function(selector) { let rootSelector = \`.\${PaginationWrapper.rootSelector}\`; if("legacyRootSelector" in PaginationWrapper && PaginationWrapper.legacyRootSelector){ diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 1bffd493d18..e4194ca06c7 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -377,6 +377,9 @@ export interface I18nFormatArgTypes { } "i18nStrings.selectAllText": never; } + "option-list": { + "i18nStrings.selectedAriaLabel": never; + } "pagination": { "ariaLabels.jumpToPageButtonLabel": never; "ariaLabels.nextPageLabel": never; diff --git a/src/i18n/messages/all.ar.json b/src/i18n/messages/all.ar.json index a97ca523c51..dfcdab61ae3 100644 --- a/src/i18n/messages/all.ar.json +++ b/src/i18n/messages/all.ar.json @@ -255,6 +255,9 @@ "deselectAriaLabel": "إزالة {option__label}", "i18nStrings.selectAllText": "حدد الكل" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "مُحدَّد" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "الانتقال إلى الصفحة", "ariaLabels.nextPageLabel": "الصفحة التالية", diff --git a/src/i18n/messages/all.de.json b/src/i18n/messages/all.de.json index 348494d5ba7..1c3716c74c3 100644 --- a/src/i18n/messages/all.de.json +++ b/src/i18n/messages/all.de.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "{option__label} entfernen", "i18nStrings.selectAllText": "Alles auswählen" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Ausgewählt" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Zur Seite springen", "ariaLabels.nextPageLabel": "Nächste Seite", diff --git a/src/i18n/messages/all.en-GB.json b/src/i18n/messages/all.en-GB.json index 7fd13a83338..9e4af12588b 100644 --- a/src/i18n/messages/all.en-GB.json +++ b/src/i18n/messages/all.en-GB.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "Remove {option__label}", "i18nStrings.selectAllText": "Select all" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Selected" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Jump to page", "ariaLabels.nextPageLabel": "Next page", diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index 47d0c9ace44..ff701c52b02 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "Remove {option__label}", "i18nStrings.selectAllText": "Select all" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Selected" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Jump to page", "ariaLabels.nextPageLabel": "Next page", diff --git a/src/i18n/messages/all.es.json b/src/i18n/messages/all.es.json index 2d92e97a372..5fddda3fea5 100644 --- a/src/i18n/messages/all.es.json +++ b/src/i18n/messages/all.es.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "Eliminar {option__label}", "i18nStrings.selectAllText": "Seleccionar todo" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Seleccionado" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Ir a la página", "ariaLabels.nextPageLabel": "Página siguiente", diff --git a/src/i18n/messages/all.fr.json b/src/i18n/messages/all.fr.json index 1ff84bcd77a..0efea98be95 100644 --- a/src/i18n/messages/all.fr.json +++ b/src/i18n/messages/all.fr.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "Supprimer {option__label}", "i18nStrings.selectAllText": "Tout sélectionner" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Sélectionné" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Aller à la page", "ariaLabels.nextPageLabel": "Page suivante", diff --git a/src/i18n/messages/all.id.json b/src/i18n/messages/all.id.json index 24e206c7be8..681b9397fcc 100644 --- a/src/i18n/messages/all.id.json +++ b/src/i18n/messages/all.id.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "Hapus {option__label}", "i18nStrings.selectAllText": "Pilih semua" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Dipilih" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Langsung ke halaman", "ariaLabels.nextPageLabel": "Halaman berikutnya", diff --git a/src/i18n/messages/all.it.json b/src/i18n/messages/all.it.json index 5f3db000ee2..ddb54ccbed0 100644 --- a/src/i18n/messages/all.it.json +++ b/src/i18n/messages/all.it.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "Rimuovi {option__label}", "i18nStrings.selectAllText": "Seleziona tutto" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Selezionato" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Vai alla pagina", "ariaLabels.nextPageLabel": "Pagina successiva", diff --git a/src/i18n/messages/all.ja.json b/src/i18n/messages/all.ja.json index e26f02a8a31..d75fbc177b7 100644 --- a/src/i18n/messages/all.ja.json +++ b/src/i18n/messages/all.ja.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "{option__label} を削除", "i18nStrings.selectAllText": "すべて選択" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "選択済み" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "ページに移動", "ariaLabels.nextPageLabel": "次のページ", diff --git a/src/i18n/messages/all.ko.json b/src/i18n/messages/all.ko.json index da274ed63f8..278a93410a4 100644 --- a/src/i18n/messages/all.ko.json +++ b/src/i18n/messages/all.ko.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "{option__label} 제거", "i18nStrings.selectAllText": "모두 선택" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "선택됨" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "페이지로 이동", "ariaLabels.nextPageLabel": "다음 페이지", diff --git a/src/i18n/messages/all.pt-BR.json b/src/i18n/messages/all.pt-BR.json index caea5d6d7ea..b0629007e40 100644 --- a/src/i18n/messages/all.pt-BR.json +++ b/src/i18n/messages/all.pt-BR.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "Remover {option__label}", "i18nStrings.selectAllText": "Selecionar tudo" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Selecionado" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Ir para a página", "ariaLabels.nextPageLabel": "Próxima página", diff --git a/src/i18n/messages/all.th.json b/src/i18n/messages/all.th.json index f1a11920b08..eaae619910b 100644 --- a/src/i18n/messages/all.th.json +++ b/src/i18n/messages/all.th.json @@ -160,6 +160,9 @@ "multiselect": { "deselectAriaLabel": "ลบ {option__label}" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "เลือกไว้" + }, "pagination": { "ariaLabels.nextPageLabel": "หน้าถัดไป", "ariaLabels.pageLabel": "หน้า {pageNumber} จากทุกหน้า", diff --git a/src/i18n/messages/all.tr.json b/src/i18n/messages/all.tr.json index 2311df9a5cd..3539575b335 100644 --- a/src/i18n/messages/all.tr.json +++ b/src/i18n/messages/all.tr.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "{option__label} ögesini kaldır", "i18nStrings.selectAllText": "Tümünü seç" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "Seçili" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "Sayfaya atla", "ariaLabels.nextPageLabel": "Sonraki sayfa", diff --git a/src/i18n/messages/all.zh-CN.json b/src/i18n/messages/all.zh-CN.json index d41ad0db0c9..f26dd968867 100644 --- a/src/i18n/messages/all.zh-CN.json +++ b/src/i18n/messages/all.zh-CN.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "删除 {option__label}", "i18nStrings.selectAllText": "选择全部" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "已选定" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "跳转到页面", "ariaLabels.nextPageLabel": "下一页", diff --git a/src/i18n/messages/all.zh-TW.json b/src/i18n/messages/all.zh-TW.json index e2f84f4f1e3..2bbe5c1dbd9 100644 --- a/src/i18n/messages/all.zh-TW.json +++ b/src/i18n/messages/all.zh-TW.json @@ -278,6 +278,9 @@ "deselectAriaLabel": "移除 {option__label}", "i18nStrings.selectAllText": "選取全部" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "已選取" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "跳到頁面", "ariaLabels.nextPageLabel": "下一頁", diff --git a/src/internal/components/listbox/__tests__/use-type-ahead.test.ts b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts new file mode 100644 index 00000000000..87120bd90fd --- /dev/null +++ b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { act, renderHook } from '../../../../__tests__/render-hook'; +import type { DropdownOption } from '../../option/interfaces'; +import { useTypeAhead } from '../use-type-ahead'; + +const options: DropdownOption[] = [ + { option: { label: 'option 1', value: 'option1' } }, + { option: { label: 'option 2', value: 'option2' } }, +]; + +const highlightOption = jest.fn(); + +const initialProps = { + isEnabled: true, + options, + highlightOption, + highlightedOption: null, +}; + +describe('useTypeAhead', () => { + const hook = renderHook(useTypeAhead, { + initialProps: initialProps, + }); + + beforeEach(() => { + highlightOption.mockClear(); + }); + + test('should highlight the correct option when a key is pressed', () => { + act(() => { + const event = { key: 'o' } as React.KeyboardEvent; + hook.result.current(event); + }); + + expect(highlightOption).toHaveBeenCalledWith(options[0]); + }); + + test('should not highlight an option when a non-character key is pressed', () => { + act(() => { + const event = { key: 'Enter' } as React.KeyboardEvent; + hook.result.current(event); + }); + + expect(highlightOption).not.toHaveBeenCalled(); + }); + + test('should not highlight an option when isEnabled is false', () => { + const hook = renderHook(useTypeAhead, { + initialProps: { + ...initialProps, + isEnabled: false, + }, + }); + + act(() => { + const event = { key: 'o' } as React.KeyboardEvent; + hook.result.current(event); + }); + + expect(highlightOption).not.toHaveBeenCalled(); + }); + + describe('with groups', () => { + const compute = { label: 'Compute', value: 'compute', options: [] }; + const databases = { label: 'Databases', value: 'databases', options: [] }; + const groupedOptions: DropdownOption[] = [ + { type: 'parent', option: compute }, + { type: 'child', option: { label: 'EC2', value: 'ec2' } }, + { type: 'child', option: { label: 'Lambda', value: 'lambda' } }, + { type: 'parent', option: databases, disabled: true }, + { type: 'child', option: { label: 'Cassandra', value: 'cassandra' }, disabled: true }, + { type: 'child', option: { label: 'DocumentDB', value: 'documentdb' }, disabled: true }, + { option: { label: 'Cherry', value: 'cherry' } }, + ]; + + // Feeds each highlighted option back into the hook, as the listbox does. + const renderGroupedHook = (interactiveGroups: boolean) => { + const highlightOption = jest.fn(); + const props = { ...initialProps, options: groupedOptions, highlightOption, interactiveGroups }; + const hook = renderHook(useTypeAhead, { initialProps: props }); + const type = (key: string) => { + act(() => hook.result.current({ key } as React.KeyboardEvent)); + const lastCall = highlightOption.mock.calls[highlightOption.mock.calls.length - 1]; + hook.rerender({ ...props, highlightedOption: lastCall?.[0].option ?? null }); + }; + const highlightedLabels = () => + highlightOption.mock.calls.map(([option]: [DropdownOption]) => option.option.label); + return { type, highlightedLabels }; + }; + + test('interactive groups: highlights the option whose label matches, not its group', () => { + const { type, highlightedLabels } = renderGroupedHook(true); + type('e'); + expect(highlightedLabels()).toEqual(['EC2']); + }); + + test('interactive groups: repeated key cycles only through matching options and skips disabled ones', () => { + const { type, highlightedLabels } = renderGroupedHook(true); + ['c', 'c', 'c'].forEach(type); + expect(highlightedLabels()).toEqual(['Compute', 'Cherry', 'Compute']); + }); + + test('non-interactive groups: skips the options of a group whose label matches', () => { + const { type, highlightedLabels } = renderGroupedHook(false); + type('c'); + expect(highlightedLabels()).toEqual(['Cherry']); + }); + }); + + test('ignores a key event that was already handled', () => { + const highlightOption = jest.fn(); + const hook = renderHook(useTypeAhead, { initialProps: { ...initialProps, highlightOption } }); + act(() => { + hook.result.current({ key: ' ', defaultPrevented: true } as React.KeyboardEvent); + hook.result.current({ key: 'o' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenCalledWith(options[0]); + }); +}); diff --git a/src/internal/components/listbox/index.tsx b/src/internal/components/listbox/index.tsx new file mode 100644 index 00000000000..4a5a81d19ff --- /dev/null +++ b/src/internal/components/listbox/index.tsx @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef } from 'react'; + +import { DropdownOption } from '../option/interfaces'; +import { ItemProps } from './parts/item'; +import List, { ListProps } from './parts/list'; +import { GetOptionProps, ListElementProps, UseListboxApi } from './use-listbox'; + +export interface ListboxProps { + /** The controller returned by `useListbox`. */ + controller: UseListboxApi; + /** The options the controller was created with (already flattened). */ + options: ReadonlyArray; + /** Merged over the controller's list props, such as aria attributes. */ + listProps?: Partial; + /** Overrides the controller's option props, for example to add test attributes. */ + getOptionProps?: GetOptionProps; + renderOption?: ItemProps['renderOption']; +} + +/** + * Renders the list for a `useListbox` controller and scrolls the highlighted option into view. + */ +export default function Listbox({ + controller, + options, + listProps, + getOptionProps = controller.getOptionProps, + renderOption, +}: ListboxProps) { + const scrollToIndex = useRef(null); + // Every keyboard highlight request, including the one added on focus entry, scrolls into view, even when it + // keeps the highlighted index. Mouse highlights don't (`List` checks `moveFocus`), and focus from a pointer adds + // no highlight. Index changes without a request, such as the highlight following its option when the options + // change, don't scroll. + useEffect(() => { + scrollToIndex.current?.(controller.highlightedIndex); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controller.scrollRequest]); + + const mergedListProps = { ...controller.getListProps(), ...listProps }; + + return ( + + ); +} diff --git a/src/internal/components/listbox/parts/item.tsx b/src/internal/components/listbox/parts/item.tsx new file mode 100644 index 00000000000..42820c43506 --- /dev/null +++ b/src/internal/components/listbox/parts/item.tsx @@ -0,0 +1,259 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; + +import InternalIcon from '../../../../icon/internal.js'; +import type { MultiselectProps } from '../../../../multiselect/interfaces'; +import type { SelectProps } from '../../../../select/interfaces'; +import Tooltip from '../../../../tooltip/internal.js'; +import { OptionDefinition, OptionGroup } from '../../../../types/option'; +import { getBaseProps } from '../../../base-component'; +import useHiddenDescription from '../../../hooks/use-hidden-description'; +import CheckboxIcon from '../../checkbox-icon'; +import Option from '../../option'; +import { DropdownOption } from '../../option/interfaces'; +import { HighlightType } from '../../options-list/utils/use-highlight-option.js'; +import SelectableItem from '../../selectable-item'; +import { SelectionType } from '../selection/interfaces'; + +import styles from './styles.css.js'; + +export interface ItemProps { + index: number; + option: DropdownOption; + /** + * `single` renders a check mark on the selected option. `multi` renders a checkbox on every option + * (including group headers) and supports the indeterminate state. + */ + selectionType?: SelectionType; + highlighted?: boolean; + selected?: boolean; + indeterminate?: boolean; + filteringValue?: string; + virtualPosition?: number; + padBottom?: boolean; + isNextSelected?: boolean; + isPreviousSelected?: boolean; + screenReaderContent?: string; + ariaPosinset?: number; + ariaSetsize?: number; + highlightType?: HighlightType['type']; + withScrollbar?: boolean; + hideTopDivider?: boolean; + sticky?: boolean; + renderOption?: SelectProps.SelectOptionItemRenderer | MultiselectProps.MultiselectOptionItemRenderer; + parentProps?: ItemParentProps; +} + +export interface ItemParentProps { + index: number; + option: DropdownOption; + disabled: boolean; + indeterminate?: boolean; + highlighted?: boolean; + selected?: boolean; +} + +interface RenderItemProps { + index: number; + option: DropdownOption; + disabled: boolean; + selected: boolean; + highlighted: boolean; + indeterminate: boolean; + parentProps?: ItemParentProps; +} + +const toSingleGroupItem = (props: ItemParentProps): SelectProps.SelectOptionGroupItem => ({ + type: 'group', + index: props.index, + option: props.option.option as OptionGroup, + disabled: props.disabled, +}); + +const toMultiGroupItem = (props: ItemParentProps): MultiselectProps.MultiselectOptionGroupItem => ({ + type: 'group', + index: props.index, + option: props.option.option as OptionGroup, + indeterminate: props.indeterminate ?? false, + selected: props.selected ?? false, + highlighted: props.highlighted ?? false, + disabled: props.disabled, +}); + +// The item shape passed to `renderOption`. Select and Multiselect expose different shapes, +// so the selection type picks the shape. +function toRenderItem( + selectionType: SelectionType, + props: RenderItemProps +): SelectProps.SelectItem | MultiselectProps.MultiselectItem { + const { option, index, disabled, selected, highlighted, indeterminate, parentProps } = props; + const isMulti = selectionType === 'multi'; + + if (option.type === 'parent') { + return isMulti + ? toMultiGroupItem({ option, index, disabled, highlighted, selected, indeterminate }) + : toSingleGroupItem({ option, index, disabled }); + } + return { + type: 'item', + index, + option: option.option as OptionDefinition, + selected, + highlighted, + disabled, + parent: parentProps ? (isMulti ? toMultiGroupItem(parentProps) : toSingleGroupItem(parentProps)) : null, + }; +} + +const Item = ( + { + index, + option, + selectionType = 'single', + highlighted, + selected, + indeterminate, + filteringValue, + virtualPosition, + padBottom, + isNextSelected, + isPreviousSelected, + screenReaderContent, + ariaPosinset, + ariaSetsize, + highlightType, + withScrollbar, + hideTopDivider, + sticky, + renderOption, + parentProps, + ...restProps + }: ItemProps, + ref: React.Ref +) => { + const baseProps = getBaseProps(restProps); + const isMulti = selectionType === 'multi'; + + const isParent = option.type === 'parent'; + const isChild = option.type === 'child'; + const wrappedOption: OptionDefinition = option.option; + const disabled = option.disabled || wrappedOption.disabled; + const disabledReason = disabled && wrappedOption.disabledReason ? wrappedOption.disabledReason : ''; + const isDisabledWithReason = !!disabledReason; + const internalRef = useRef(null); + const mergedRef = useMergeRefs(ref, internalRef); + + const { descriptionEl, descriptionId } = useHiddenDescription(disabledReason); + + const [canShowTooltip, setCanShowTooltip] = useState(true); + useEffect(() => setCanShowTooltip(true), [highlighted]); + + const renderResult = renderOption + ? (renderOption as (props: { item: any; filterText?: string }) => React.ReactNode)({ + item: toRenderItem(selectionType, { + option, + index, + disabled: !!disabled, + highlighted: !!highlighted, + selected: !!selected, + indeterminate: indeterminate ?? false, + parentProps, + }), + filterText: filteringValue, + }) + : null; + + const optionContent = ( +