From ac94c51551a291856dee1362f4de41bbe90f7bce Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Wed, 4 Nov 2026 10:01:56 +0100 Subject: [PATCH 1/5] refactor: Add OptionList support to the shared listbox code Optional additions that OptionList needs and Select and Multiselect don't pass, so their behavior does not change: onEscape is optional in useListbox (without it Escape keeps propagating); PlainList takes scrollStrategy ('menu' by default, 'ancestors' scrolls through the page or a scroll container with the new scrollOptionIntoView); OptionsList statusType is optional with 'finished' as the default; useMenuKeyboard onEscape is optional; the shared item and SelectableItem take hideTopDivider. --- .../listbox/__tests__/use-listbox.test.tsx | 9 +++++++++ src/internal/components/listbox/parts/item.tsx | 5 +++++ .../components/listbox/parts/plain-list.tsx | 14 +++++++++++--- .../components/listbox/parts/scroll-to-index.ts | 17 +++++++++++++++++ src/internal/components/listbox/use-listbox.ts | 4 ++-- src/internal/components/options-list/index.tsx | 7 +++++-- .../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 ++++++ 10 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/internal/components/listbox/__tests__/use-listbox.test.tsx b/src/internal/components/listbox/__tests__/use-listbox.test.tsx index 3d6db3b64d9..e9f939b2776 100644 --- a/src/internal/components/listbox/__tests__/use-listbox.test.tsx +++ b/src/internal/components/listbox/__tests__/use-listbox.test.tsx @@ -119,6 +119,15 @@ describe('useListbox', () => { expect(onEscape).toHaveBeenCalledTimes(1); expect(event.cancelBubble).toBe(true); }); + + test('is not handled and keeps propagating without onEscape', () => { + const { keyDown } = renderListbox({ onEscape: undefined }); + + const event = keyDown(KeyCode.escape); + + expect(event.cancelBubble).toBe(false); + expect(event.defaultPrevented).toBe(false); + }); }); describe('Home and End', () => { diff --git a/src/internal/components/listbox/parts/item.tsx b/src/internal/components/listbox/parts/item.tsx index 8a0f5b4c4f2..354561dadea 100644 --- a/src/internal/components/listbox/parts/item.tsx +++ b/src/internal/components/listbox/parts/item.tsx @@ -43,6 +43,8 @@ export interface ItemProps { ariaSetsize?: number; highlightType?: HighlightType['type']; withScrollbar?: boolean; + /** Hides the top divider of the first option in lists without a dropdown border above it (OptionList). */ + hideTopDivider?: boolean; sticky?: boolean; renderOption?: SelectProps.SelectOptionItemRenderer | MultiselectProps.MultiselectOptionItemRenderer; parentProps?: ItemParentProps; @@ -163,6 +165,7 @@ const Item = ( ariaSetsize, highlightType, withScrollbar, + hideTopDivider, sticky, renderOption, parentProps, @@ -311,6 +314,7 @@ const Item = ( value={option.option.value} afterHeader={option.afterHeader} withScrollbar={withScrollbar} + hideTopDivider={hideTopDivider} sticky={sticky} {...baseProps} > @@ -352,6 +356,7 @@ const Item = ( ariaDescribedby={isDisabledWithReason ? descriptionId : ''} value={option.option.value} withScrollbar={withScrollbar} + hideTopDivider={hideTopDivider} sticky={sticky} {...baseProps} > diff --git a/src/internal/components/listbox/parts/plain-list.tsx b/src/internal/components/listbox/parts/plain-list.tsx index 5b3c5f432a6..3d20c512289 100644 --- a/src/internal/components/listbox/parts/plain-list.tsx +++ b/src/internal/components/listbox/parts/plain-list.tsx @@ -14,7 +14,7 @@ import { GetOptionProps, ListElementProps, ScrollToIndexRef } from '../interface import { SelectionType } from '../selection/interfaces'; import { fallbackItemHeight } from './common'; import { renderOptions } from './render-options'; -import scrollToIndex from './scroll-to-index'; +import scrollToIndex, { scrollOptionIntoView } from './scroll-to-index'; import styles from '../../../../select/parts/styles.css.js'; @@ -31,6 +31,12 @@ export interface ListProps { firstOptionSticky?: boolean; isMultiSelect?: boolean; renderOption?: SelectProps.SelectOptionItemRenderer | MultiselectProps.MultiselectOptionItemRenderer; + /** + * How the highlighted option is scrolled into view. `menu` (default) scrolls only the list itself. + * `ancestors` scrolls through all scrollable ancestors, for a list that is not its own scroll container, + * such as in OptionList. + */ + scrollStrategy?: 'menu' | 'ancestors'; } const PlainList = ( @@ -47,6 +53,7 @@ const PlainList = ( firstOptionSticky, isMultiSelect, renderOption, + scrollStrategy = 'menu', }: ListProps, ref: React.Ref ) => { @@ -75,10 +82,11 @@ const PlainList = ( () => (index: number) => { const isSticky = firstOptionSticky && index === 0; if (highlightType.moveFocus && menuRef.current && !isSticky) { - scrollToIndex({ index, menuEl: menuRef.current }); + const scroll = scrollStrategy === 'ancestors' ? scrollOptionIntoView : scrollToIndex; + scroll({ index, menuEl: menuRef.current }); } }, - [firstOptionSticky, highlightType.moveFocus, menuRef] + [firstOptionSticky, highlightType.moveFocus, menuRef, scrollStrategy] ); const withScrollbar = !!width && width.inner < width.outer; diff --git a/src/internal/components/listbox/parts/scroll-to-index.ts b/src/internal/components/listbox/parts/scroll-to-index.ts index 38b5484b15c..620bb786d31 100644 --- a/src/internal/components/listbox/parts/scroll-to-index.ts +++ b/src/internal/components/listbox/parts/scroll-to-index.ts @@ -1,5 +1,22 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { scrollElementIntoView } from '../../../utils/scrollable-containers'; + +/** + * Scrolls the option at the given index into view through all its scrollable ancestors. + * Used when the list is not its own scroll container, such as in OptionList. + */ +export function scrollOptionIntoView({ index, menuEl }: { index: number; menuEl: HTMLElement }) { + // -1 means nothing is highlighted. Non-interactive rows such as single-selection group headers also + // render with data-mouse-target="-1", so looking it up would scroll to one of them. + if (index < 0) { + return; + } + const item = menuEl.querySelector(`[data-mouse-target="${index}"]`); + if (item) { + scrollElementIntoView(item); + } +} export default function ({ index, menuEl }: { index: number; menuEl: HTMLElement }) { const item = menuEl.querySelector(`[data-mouse-target="${index}"]`); diff --git a/src/internal/components/listbox/use-listbox.ts b/src/internal/components/listbox/use-listbox.ts index 0432185fa0d..198bca46339 100644 --- a/src/internal/components/listbox/use-listbox.ts +++ b/src/internal/components/listbox/use-listbox.ts @@ -46,8 +46,8 @@ export interface UseListboxProps { * filter input. */ spaceSelects: 'always' | 'keyboard-highlight'; - /** Called when Escape is pressed. */ - onEscape: () => void; + /** Called when Escape is pressed. When omitted, Escape is not handled and keeps propagating. */ + onEscape?: () => void; /** Group of the option, announced with the highlighted option. */ getParent: (option: DropdownOption) => undefined | OptionGroup; /** Added to the announcement when the highlighted option is selected. */ diff --git a/src/internal/components/options-list/index.tsx b/src/internal/components/options-list/index.tsx index 400414a960c..1b1b35d801b 100644 --- a/src/internal/components/options-list/index.tsx +++ b/src/internal/components/options-list/index.tsx @@ -16,7 +16,10 @@ import styles from './styles.css.js'; export interface OptionsListProps extends BaseComponentProps { open?: boolean; - statusType: DropdownStatusProps.StatusType; + /** + * When the list opens with `pending` status, `onLoadMore` fires right away. Defaults to `finished`. + */ + statusType?: DropdownStatusProps.StatusType; /** * Options list */ @@ -60,7 +63,7 @@ const getItemIndex = (containerRef: React.RefObject, event: React.M const OptionsList = ( { open, - statusType, + statusType = 'finished', children, nativeAttributes = {}, onKeyDown, diff --git a/src/internal/components/options-list/utils/use-keyboard.ts b/src/internal/components/options-list/utils/use-keyboard.ts index cda17bb88f8..f5ffb1ae6b1 100644 --- a/src/internal/components/options-list/utils/use-keyboard.ts +++ b/src/internal/components/options-list/utils/use-keyboard.ts @@ -13,7 +13,8 @@ interface UseMenuKeyboard { selectOption: () => void; goHome: () => void; goEnd: () => void; - onEscape: () => void; + /** Escape handler. When omitted, Escape is not handled and keeps propagating. */ + onEscape?: () => void; preventNativeSpace?: boolean; }): CancelableEventHandler; } @@ -44,8 +45,10 @@ export const useMenuKeyboard: UseMenuKeyboard = ({ goEnd(); break; case KeyCode.escape: - event.stopPropagation(); - onEscape(); + if (onEscape) { + event.stopPropagation(); + onEscape(); + } break; case KeyCode.enter: event.preventDefault(); diff --git a/src/internal/components/selectable-item/index.tsx b/src/internal/components/selectable-item/index.tsx index 707af463158..5d2ff842399 100644 --- a/src/internal/components/selectable-item/index.tsx +++ b/src/internal/components/selectable-item/index.tsx @@ -41,6 +41,7 @@ const SelectableItem = ( sticky, afterHeader, withScrollbar, + hideTopDivider, ...restProps }: SelectableItemProps, ref: React.Ref @@ -65,6 +66,7 @@ const SelectableItem = ( [styles.sticky]: sticky, [styles['after-header']]: !!afterHeader, [styles['with-scrollbar']]: withScrollbar, + [styles['hide-top-divider']]: hideTopDivider, [styles['visual-refresh']]: isVisualRefresh, }); diff --git a/src/internal/components/selectable-item/interfaces.ts b/src/internal/components/selectable-item/interfaces.ts index 9fa93d00c47..31ec4f8d56a 100644 --- a/src/internal/components/selectable-item/interfaces.ts +++ b/src/internal/components/selectable-item/interfaces.ts @@ -28,6 +28,7 @@ export type SelectableItemProps = BaseComponentProps & { sticky?: boolean; afterHeader?: boolean; withScrollbar?: boolean; + hideTopDivider?: boolean; ariaSelected?: boolean | never; ariaChecked?: boolean | 'mixed' | never; }; diff --git a/src/internal/components/selectable-item/styles.scss b/src/internal/components/selectable-item/styles.scss index 5006969adf7..51ff3d3adc4 100644 --- a/src/internal/components/selectable-item/styles.scss +++ b/src/internal/components/selectable-item/styles.scss @@ -210,6 +210,12 @@ $neg-divider-w: calc(-1 * #{$divider-w}); box-shadow: inset 0 $neg-divider-w 0 0 awsui.$color-border-dropdown-item-default; } + // Used by Option List, where there is no dropdown border above the first item. + // Exclude highlighted/selected states to preserve their box-shadow. + &.hide-top-divider:not(.highlighted):not(.selected):not(.sticky) { + box-shadow: inset 0 $neg-divider-w 0 0 awsui.$color-border-dropdown-item-default; + } + &.sticky { position: sticky; inset-block-start: 0; From a1696d2db77d78645f91ebbe3b89bf2d97d9fb08 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Wed, 4 Nov 2026 10:01:56 +0100 Subject: [PATCH 2/5] feat: Add OptionList component OptionList shows selectable options inline, without a dropdown, with single or multi selection, option groups, disabled options with a disabled reason, icons, label tags and tags, custom option rendering (renderOption) and a custom live announcement (renderHighlightedAriaLive). useOptionList (src/option-list/use-option-list.ts) calls the shared useListbox with embedded: true, which handles focus and blur, and adds the mouse-leave reset and type-ahead. The list renders through the shared PlainList with scrollStrategy 'ancestors'. Also adds the OptionList test-utils and the option-list i18n namespace. --- build-tools/utils/pluralize.js | 1 + .../required-props-for-components.ts | 1 + .../__snapshots__/documenter.test.ts.snap | 540 ++++++++++++++++++ .../test-utils-selectors.test.tsx.snap | 5 + .../test-utils-wrappers.test.tsx.snap | 80 +++ src/i18n/messages-types.ts | 3 + src/i18n/messages/all.ar.json | 3 + src/i18n/messages/all.de.json | 3 + src/i18n/messages/all.en-GB.json | 3 + src/i18n/messages/all.en.json | 3 + src/i18n/messages/all.es.json | 3 + src/i18n/messages/all.fr.json | 3 + src/i18n/messages/all.id.json | 3 + src/i18n/messages/all.it.json | 3 + src/i18n/messages/all.ja.json | 3 + src/i18n/messages/all.ko.json | 3 + src/i18n/messages/all.pt-BR.json | 3 + src/i18n/messages/all.th.json | 3 + src/i18n/messages/all.tr.json | 3 + src/i18n/messages/all.zh-CN.json | 3 + src/i18n/messages/all.zh-TW.json | 3 + src/option-list/index.tsx | 26 + src/option-list/interfaces.ts | 154 +++++ src/option-list/internal.tsx | 229 ++++++++ src/option-list/styles.scss | 11 + src/option-list/test-classes/styles.scss | 9 + src/option-list/use-option-list.ts | 104 ++++ src/test-utils/dom/option-list/index.ts | 132 +++++ 28 files changed, 1340 insertions(+) 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/option-list/use-option-list.ts create mode 100644 src/test-utils/dom/option-list/index.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/src/__tests__/required-props-for-components.ts b/src/__tests__/required-props-for-components.ts index 37d6772212d..2f37c199453 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 d1df802eb19..8493604520a 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -21305,6 +21305,192 @@ 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 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) - Identifies the option group. Must be unique among the groups. +- \`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. + +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). +In multi selection, when a group header is highlighted, the group is provided as the option. + +For more information, see the +[accessibility guidelines](/components/select/?tabId=usage#accessibility-guidelines).", + "inlineType": { + "name": "OptionListProps.ContainingOptionAndGroupString", + "parameters": [ + { + "name": "option", + "type": "OptionListProps.Option | OptionListProps.OptionGroup", + }, + { + "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", @@ -45021,6 +45207,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": [ { @@ -56055,6 +56439,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 cccb90c75cb..7a132d00a3c 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 @@ -517,6 +517,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 f2e87738126..46cfaeb5e6a 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -379,6 +379,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 5541eed328a..e0e81e214ba 100644 --- a/src/i18n/messages/all.de.json +++ b/src/i18n/messages/all.de.json @@ -280,6 +280,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 2610e463819..720d0ef49b4 100644 --- a/src/i18n/messages/all.en-GB.json +++ b/src/i18n/messages/all.en-GB.json @@ -280,6 +280,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 7b4bb5ca9b7..c32103069f8 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -280,6 +280,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 37404a5e445..e794344e043 100644 --- a/src/i18n/messages/all.es.json +++ b/src/i18n/messages/all.es.json @@ -280,6 +280,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 8063516b962..62d0dbb6dff 100644 --- a/src/i18n/messages/all.fr.json +++ b/src/i18n/messages/all.fr.json @@ -280,6 +280,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 5257c04142b..30bfbcff9b5 100644 --- a/src/i18n/messages/all.id.json +++ b/src/i18n/messages/all.id.json @@ -280,6 +280,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 2f451754556..9fbda32d29d 100644 --- a/src/i18n/messages/all.it.json +++ b/src/i18n/messages/all.it.json @@ -280,6 +280,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 02e532b373f..d640b48bde3 100644 --- a/src/i18n/messages/all.ja.json +++ b/src/i18n/messages/all.ja.json @@ -280,6 +280,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 6befcd4d0d3..287896856f1 100644 --- a/src/i18n/messages/all.ko.json +++ b/src/i18n/messages/all.ko.json @@ -280,6 +280,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 0b1255e618b..0f2b9ba76b0 100644 --- a/src/i18n/messages/all.pt-BR.json +++ b/src/i18n/messages/all.pt-BR.json @@ -280,6 +280,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 22107ad6d5e..7c088f10343 100644 --- a/src/i18n/messages/all.tr.json +++ b/src/i18n/messages/all.tr.json @@ -280,6 +280,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 53e89882323..22863cfb15a 100644 --- a/src/i18n/messages/all.zh-CN.json +++ b/src/i18n/messages/all.zh-CN.json @@ -280,6 +280,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 daa0cf33cc3..741e4a57813 100644 --- a/src/i18n/messages/all.zh-TW.json +++ b/src/i18n/messages/all.zh-TW.json @@ -280,6 +280,9 @@ "deselectAriaLabel": "移除 {option__label}", "i18nStrings.selectAllText": "選取全部" }, + "option-list": { + "i18nStrings.selectedAriaLabel": "已選取" + }, "pagination": { "ariaLabels.jumpToPageButtonLabel": "跳到頁面", "ariaLabels.nextPageLabel": "下一頁", diff --git a/src/option-list/index.tsx b/src/option-list/index.tsx new file mode 100644 index 00000000000..7c37dde95a0 --- /dev/null +++ b/src/option-list/index.tsx @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { getExternalProps } from '../internal/utils/external-props'; +import { OptionListProps } from './interfaces'; +import InternalOptionList from './internal'; + +export { OptionListProps }; + +const OptionList = React.forwardRef((props: OptionListProps, ref: React.Ref) => { + const baseComponentProps = useBaseComponent('OptionList', { + props: { selectionType: props.selectionType }, + metadata: { + optionsCount: props.options.length, + }, + }); + const externalProps = getExternalProps(props); + return ; +}); + +applyDisplayName(OptionList, 'OptionList'); +export default OptionList; diff --git a/src/option-list/interfaces.ts b/src/option-list/interfaces.ts new file mode 100644 index 00000000000..864f0464040 --- /dev/null +++ b/src/option-list/interfaces.ts @@ -0,0 +1,154 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events'; + +export interface OptionListProps extends BaseComponentProps { + /** + * Specifies an array of options that are displayed 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) - Identifies the option group. Must be unique among the groups. + * - `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. + * + * Options are the selection target. Do not render interactive elements such as buttons or links inside them. + */ + options: OptionListProps.Options; + + /** + * Specifies whether the user can select one option (`single`) or several (`multi`). + * Multi selection shows a checkbox next to each option. + */ + selectionType: OptionListProps.SelectionType; + + /** + * 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. + */ + selectedOptions?: ReadonlyArray; + + /** + * Called when the user selects or deselects an option. + * The event `detail` contains the new `selectedOptions`. + */ + onChange?: NonCancelableEventHandler; + + /** + * Adds `aria-label` to the list. Use this when there is no visible label. + */ + ariaLabel?: string; + + /** + * Adds `aria-labelledby` to the list. Use this to reference the visible label of the list. + */ + ariaLabelledby?: string; + + /** + * Adds `aria-describedby` to the list. + */ + ariaDescribedby?: string; + + /** + * Specifies a render function to render custom options. + */ + renderOption?: OptionListProps.OptionItemRenderer; + + /** + * 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). + * In multi selection, when a group header is highlighted, the group is provided as the option. + * + * For more information, see the + * [accessibility guidelines](/components/select/?tabId=usage#accessibility-guidelines). + */ + renderHighlightedAriaLive?: OptionListProps.ContainingOptionAndGroupString; + + /** + * An object containing all the necessary localized strings required by the component. + * * `selectedAriaLabel` - Announced with the highlighted option when it is selected. + * @i18n + */ + i18nStrings?: OptionListProps.I18nStrings; +} + +export namespace OptionListProps { + export type SelectionType = 'single' | 'multi'; + + export interface Option { + value: string; + label?: string; + lang?: string; + description?: string; + disabled?: boolean; + disabledReason?: React.ReactNode; + labelTag?: string; + tags?: ReadonlyArray; + icon?: React.ReactNode; + } + + export interface OptionGroup extends Option { + options: ReadonlyArray