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/pages/option-list/permutations-common.tsx b/pages/option-list/permutations-common.tsx new file mode 100644 index 00000000000..12f1fe3dedd --- /dev/null +++ b/pages/option-list/permutations-common.tsx @@ -0,0 +1,62 @@ +// 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 PermutationsView from '../utils/permutations-view'; + +export 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 +); + +export const grouped: OptionListProps.Options = [ + { value: 'group-1', label: 'Goals', options: plain.slice(0, 2) }, + { value: 'group-2', label: 'More goals', options: plain.slice(2) }, +]; + +export const allOptions = [plain, withIcons, withTags, withDisabled, grouped]; + +export const renderOption: OptionListProps['renderOption'] = ({ item }) => {item.option.label}; + +export type OptionListPermutation = OptionListProps & { width: number }; + +export function OptionListPermutations({ + title, + permutations, +}: { + title: string; + permutations: ReadonlyArray; +}) { + return ( + + ( +
+ {}} /> +
+ )} + /> +
+ ); +} diff --git a/pages/option-list/permutations-multi.page.tsx b/pages/option-list/permutations-multi.page.tsx new file mode 100644 index 00000000000..4712d93f26e --- /dev/null +++ b/pages/option-list/permutations-multi.page.tsx @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import createPermutations from '../utils/permutations'; +import { + allOptions, + grouped, + OptionListPermutation, + OptionListPermutations, + plain, + renderOption, +} from './permutations-common'; + +const permutations = createPermutations([ + { + width: [240, 480], + options: allOptions, + selectionType: ['multi'], + selectedOptions: [[], [plain[1]], [plain[1], plain[2]]], + }, + { + width: [240], + options: [grouped], + selectionType: ['multi'], + selectedOptions: [[plain[1]]], + renderOption: [renderOption], + }, +]); + +export default function OptionListMultiPermutationsPage() { + return ; +} diff --git a/pages/option-list/permutations-single.page.tsx b/pages/option-list/permutations-single.page.tsx new file mode 100644 index 00000000000..46d44aaa944 --- /dev/null +++ b/pages/option-list/permutations-single.page.tsx @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import createPermutations from '../utils/permutations'; +import { + allOptions, + grouped, + OptionListPermutation, + OptionListPermutations, + plain, + renderOption, +} from './permutations-common'; + +const permutations = createPermutations([ + { + width: [240, 480], + options: allOptions, + selectionType: ['single'], + selectedOptions: [[], [plain[1]]], + }, + { + width: [240], + options: [grouped], + selectionType: ['single'], + selectedOptions: [[plain[1]]], + renderOption: [renderOption], + }, +]); + +export default function OptionListSinglePermutationsPage() { + 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..2dc7a044741 --- /dev/null +++ b/pages/option-list/simple.page.tsx @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import OptionList, { OptionListProps } from '~components/option-list'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; + +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.' }, + ], + }, +]; + +function Section({ + id, + title, + options, + selectionType, +}: { + id: string; + title: string; + options: OptionListProps.Options; + selectionType: OptionListProps.SelectionType; +}) { + const [selectedOptions, setSelectedOptions] = useState>([]); + return ( + +
+ {title} +
+ setSelectedOptions(detail.selectedOptions)} + /> + + Selected: {selectedOptions.map(option => option.value).join(', ') || 'none'} + +
+ ); +} + +export default function OptionListSimplePage() { + return ( + +
+ +
+
+
+ +
+
+ ); +} 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 a1a80d98a64..873b52d92ae 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -21257,6 +21257,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", @@ -44949,6 +45135,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": [ { @@ -55983,6 +56367,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/internal/components/listbox/index.tsx b/src/internal/components/listbox/index.tsx new file mode 100644 index 00000000000..b2f1f58e2a1 --- /dev/null +++ b/src/internal/components/listbox/index.tsx @@ -0,0 +1,59 @@ +// 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 PlainList, { SelectListProps } from './parts/plain-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 (`PlainList` 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 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 cd39e2a04e1..1ae34599887 100644 --- a/src/internal/components/listbox/parts/plain-list.tsx +++ b/src/internal/components/listbox/parts/plain-list.tsx @@ -14,7 +14,7 @@ import { HighlightType } from '../../options-list/utils/use-highlight-option'; 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 SelectListProps { 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'; } export namespace SelectListProps { @@ -51,6 +57,7 @@ const PlainList = ( firstOptionSticky, isMultiSelect, renderOption, + scrollStrategy = 'menu', }: SelectListProps, ref: React.Ref ) => { @@ -79,10 +86,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 new file mode 100644 index 00000000000..67293a9d071 --- /dev/null +++ b/src/internal/components/listbox/use-listbox.ts @@ -0,0 +1,257 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { OptionDefinition, OptionGroup } from '../../../types/option'; +import { DropdownOption } from '../option/interfaces'; +import { isGroupInteractive, isInteractive } from '../option/utils/filter-options'; +import { OptionsListProps } from '../options-list'; +import { HighlightType, useHighlightedOption } from '../options-list/utils/use-highlight-option'; +import { getOptionId } from '../options-list/utils/use-ids'; +import { useMenuKeyboard } from '../options-list/utils/use-keyboard'; +import { ItemProps } from './parts/item'; +import { SelectionModel } from './selection/interfaces'; +import { useAnnouncement } from './use-announcement'; +import { useTypeAhead } from './use-type-ahead'; +import { connectOptionsByValue } from './utils/connect-options'; +import { getListboxNavigation } from './utils/navigation'; +import { + getListboxOptionProps, + getListMouseHandlers, + getSelectableOption, + isAnnouncedSelected, + isHighlightable as isHighlightableOption, +} from './utils/option-props'; + +export type ListElementProps = Omit & { ref: React.RefObject }; +export type GetOptionProps = (option: DropdownOption, index: number) => ItemProps; + +export interface UseListboxProps { + /** Visible options. Filtering is the caller's job. */ + options: ReadonlyArray; + selection: SelectionModel; + /** Called with the full next selection when the user chooses an option. */ + onChange: (selectedOptions: ReadonlyArray) => void; + /** Returns the group of a child option, used by announcements. */ + getParent?: (option: DropdownOption) => OptionGroup | undefined; + /** Replaces the default live-region text for the highlighted option. */ + renderHighlightedAriaLive?: (option: OptionDefinition, group?: OptionGroup) => string; + i18nStrings?: { selectedAriaLabel?: string }; +} + +export interface UseListboxApi { + selection: SelectionModel; + listId: string; + listRef: React.RefObject; + highlightedIndex: number; + highlightType: HighlightType; + /** Changes on every keyboard highlight request, even one that keeps the highlighted index. */ + scrollRequest: number; + /** Live-region text for the highlighted option. */ + announcement: string; + + /** Chooses `option`, or the highlighted option when omitted. Ignores non-interactive options. */ + selectOption(option?: DropdownOption): void; + + /** + * Spread onto the list. The list is rendered inline: it is a tab stop and handles keyboard, mouse, focus, blur + * and aria-activedescendant. + */ + getListProps(): ListElementProps; + getOptionProps: GetOptionProps; + /** Type-ahead handler. Attach to a container of the list. */ + onTypeAhead(event: React.KeyboardEvent): void; +} + +const noParent = () => undefined; + +// Options are recreated when the consumer's options change, so they are matched by `value`, which must be unique. +// Group headers are compared only with group headers, so a group and an option sharing a value don't match. +// Without a value there is nothing stable to match by. +const isSameOption = (a: DropdownOption, b: DropdownOption) => + a.option.value !== undefined && a.option.value === b.option.value && (a.type === 'parent') === (b.type === 'parent'); + +export function useListbox({ + options, + selection, + onChange, + getParent = noParent, + renderHighlightedAriaLive, + i18nStrings, +}: UseListboxProps): UseListboxApi { + const { interactiveGroups, selectionType } = selection; + const interactivityCheck = interactiveGroups ? isGroupInteractive : isInteractive; + const isHighlightable = (option?: DropdownOption) => isHighlightableOption(option, interactiveGroups); + + const listRef = useRef(null); + const listId = useUniqueId('option-list'); + const pointerDown = useRef(false); + + const connectedSelected = connectOptionsByValue(options, selection.selectedOptions); + const connectedSelectedSet = new Set(connectedSelected); + + const [ + { highlightType, highlightedOption, highlightedIndex }, + { + moveHighlightWithKeyboard, + resetHighlightWithKeyboard, + setHighlightedIndexWithMouse, + highlightOptionWithKeyboard, + goHomeWithKeyboard, + goEndWithKeyboard, + }, + ] = useHighlightedOption({ options, isHighlightable }); + const highlightedOptionId = highlightedOption ? getOptionId(listId, highlightedIndex) : undefined; + + // The highlight is stored as an index, so when the options change it must follow the highlighted option to its + // new index, or be cleared when that option is gone. This runs during render (the "previous props" pattern), so a + // stale index is never committed. It keeps the highlight type and doesn't request a scroll. + const [previousOptions, setPreviousOptions] = useState(options); + if (previousOptions !== options) { + setPreviousOptions(options); + const previousHighlighted = previousOptions[highlightedIndex]; + const nextIndex = previousHighlighted + ? options.findIndex(option => isSameOption(option, previousHighlighted) && isHighlightable(option)) + : -1; + if (nextIndex === -1) { + if (highlightedIndex !== -1) { + resetHighlightWithKeyboard(); + } + } else if (nextIndex !== highlightedIndex) { + if (highlightType.type === 'mouse') { + setHighlightedIndexWithMouse(nextIndex, highlightType.moveFocus); + } else { + highlightOptionWithKeyboard(options[nextIndex]); + } + } + } + + // Counts keyboard highlight requests. A request that keeps the highlighted index (End again after scrolling + // away, or a keyboard highlight of the hovered option) changes no highlight state, so this is what tells the + // list to scroll again. Mouse highlights don't count. + const [scrollRequest, setScrollRequest] = useState(0); + const requestScroll = () => setScrollRequest(count => count + 1); + // Keyboard navigation must use these wrapped setters, not the raw `*WithKeyboard` ones above: the scroll + // effect runs on `scrollRequest`, so a raw setter would move the highlight without scrolling it into view. + const withScroll = + (highlight: (...args: Args) => void) => + (...args: Args) => { + highlight(...args); + requestScroll(); + }; + const moveHighlight = withScroll(moveHighlightWithKeyboard); + const highlightOption = withScroll(highlightOptionWithKeyboard); + const goHome = withScroll(goHomeWithKeyboard); + const goEnd = withScroll(goEndWithKeyboard); + + const selectOption = (option?: DropdownOption) => { + const optionToSelect = getSelectableOption(option, highlightedOption, interactivityCheck); + if (!optionToSelect) { + return; + } + onChange(selection.getNextSelection(optionToSelect)); + }; + + const listKeyDownHandler = useMenuKeyboard({ + ...getListboxNavigation({ + options, + highlightedIndex, + highlightedOption, + interactiveGroups, + moveHighlight, + goHome, + goEnd, + }), + selectOption: () => selectOption(), + goHome, + goEnd, + // When the list has focus, Space always chooses. + preventNativeSpace: true, + }); + + const getListProps = (): ListElementProps => ({ + id: listId, + ref: listRef, + open: true, + embedded: true, + isMultiSelect: selectionType === 'multi' || undefined, + ...getListMouseHandlers({ options, selectOption, setHighlightedIndexWithMouse }), + onKeyDown: listKeyDownHandler, + nativeAttributes: { + 'aria-activedescendant': highlightedOptionId, + // Focus caused by a click is followed by mouse highlight and selection, so skip the keyboard highlight. + // Only a mousedown that moves focus into the list counts: on an already focused list no focus event + // follows, and the flag would leak into the next programmatic or keyboard focus. + onMouseDown: () => (pointerDown.current = listRef.current !== document.activeElement), + }, + onFocus: () => { + const fromPointer = pointerDown.current; + pointerDown.current = false; + // Only focus caused by a click keeps the current highlight. A hover highlight from before the list had + // focus is replaced, so keyboard focus entry always starts on the selected option, or the first option. + if (fromPointer) { + return; + } + // The first selected option in list order, not selection order, as the ARIA listbox pattern describes. + const selectedOption = options.find(option => connectedSelectedSet.has(option)); + if (selectedOption) { + highlightOption(selectedOption); + } else { + goHome(); + } + }, + onBlur: () => { + pointerDown.current = false; + resetHighlightWithKeyboard(); + }, + onMouseLeave: () => { + if (listRef.current !== document.activeElement) { + resetHighlightWithKeyboard(); + } + }, + }); + + const getOptionProps = (option: DropdownOption, index: number) => + getListboxOptionProps(option, index, { + options, + selectedSet: connectedSelectedSet, + selection, + highlightedOption, + interactiveGroups, + listId, + }); + + const announceSelected = isAnnouncedSelected(highlightedOption, connectedSelectedSet, selection); + + const announcement = useAnnouncement({ + announceSelected, + highlightedOption, + getParent, + selectedAriaLabel: i18nStrings?.selectedAriaLabel, + renderHighlightedAriaLive, + }); + + const onTypeAhead = useTypeAhead({ + isEnabled: true, + options, + highlightOption, + highlightedOption: highlightedOption?.option, + interactiveGroups, + }); + + return { + selection, + listId, + listRef, + highlightedIndex, + highlightType, + scrollRequest, + announcement, + selectOption, + getListProps, + getOptionProps, + onTypeAhead, + }; +} diff --git a/src/internal/components/options-list/index.tsx b/src/internal/components/options-list/index.tsx index 400414a960c..240b259d7f7 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 */ @@ -31,6 +34,7 @@ export interface OptionsListProps extends BaseComponentProps { onFocus?: NonCancelableEventHandler; onMouseUp?: (itemIndex: number) => void; onMouseMove?: (itemIndex: number) => void; + onMouseLeave?: () => void; position?: React.CSSProperties['position']; tagOverride?: 'div' | 'ul'; role?: 'listbox' | 'list' | 'menu'; @@ -60,7 +64,7 @@ const getItemIndex = (containerRef: React.RefObject, event: React.M const OptionsList = ( { open, - statusType, + statusType = 'finished', children, nativeAttributes = {}, onKeyDown, @@ -69,6 +73,7 @@ const OptionsList = ( onLoadMore, onMouseUp, onMouseMove, + onMouseLeave, position = 'relative', role = 'listbox', tagOverride: Tag = 'div', @@ -124,6 +129,7 @@ const OptionsList = ( onKeyDown={event => fireKeyboardEvent(onKeyDown, event)} onMouseMove={event => onMouseMove?.(getItemIndex(menuRef, event))} onMouseUp={event => onMouseUp?.(getItemIndex(menuRef, event))} + onMouseLeave={onMouseLeave} onBlur={event => fireNonCancelableEvent(onBlur, { relatedTarget: event.relatedTarget })} onFocus={() => fireNonCancelableEvent(onFocus)} tabIndex={embedded ? 0 : -1} diff --git a/src/internal/components/options-list/utils/use-keyboard.ts b/src/internal/components/options-list/utils/use-keyboard.ts index 0a27dea0f8e..786e4c54971 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; - closeDropdown: () => void; + /** Escape handler. When omitted, Escape is not handled and keeps propagating. */ + closeDropdown?: () => void; preventNativeSpace?: boolean; }): CancelableEventHandler; } @@ -44,8 +45,10 @@ export const useMenuKeyboard: UseMenuKeyboard = ({ goEnd(); break; case KeyCode.escape: - event.stopPropagation(); - closeDropdown(); + if (closeDropdown) { + event.stopPropagation(); + closeDropdown(); + } 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..dffcea12b15 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 inline lists (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; diff --git a/src/option-list/__integ__/option-list-scroll.test.ts b/src/option-list/__integ__/option-list-scroll.test.ts new file mode 100644 index 00000000000..d69f938b633 --- /dev/null +++ b/src/option-list/__integ__/option-list-scroll.test.ts @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors'; + +const list = createWrapper().findOptionList('[data-testid="option-list"]'); +const listbox = `${list.toSelector()} [role="listbox"]`; + +class ScrollPage extends BasePageObject { + /** Whether the highlighted option is inside the viewport and inside every scrolling ancestor. */ + isHighlightVisible() { + return this.browser.execute(selector => { + const option = document.querySelector(selector); + if (!option) { + return false; + } + const rect = option.getBoundingClientRect(); + const within = (top: number, bottom: number) => rect.top >= top - 1 && rect.bottom <= bottom + 1; + for (let parent = option.parentElement; parent; parent = parent.parentElement) { + const { overflowY } = getComputedStyle(parent); + if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) { + const parentRect = parent.getBoundingClientRect(); + if (!within(parentRect.top + parent.clientTop, parentRect.top + parent.clientTop + parent.clientHeight)) { + return false; + } + } + } + return within(0, window.innerHeight); + }, list.findHighlightedOption().toSelector()); + } + + getHighlightedText() { + return this.getText(list.findHighlightedOption().findLabel().toSelector()); + } + + /** Scrolls the list's nearest scroll container (or the page) back to the top, away from the highlight. */ + scrollToTop() { + return this.browser.execute(selector => { + for (let parent = document.querySelector(selector)!.parentElement; parent; parent = parent.parentElement) { + const { overflowY } = getComputedStyle(parent); + if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) { + parent.scrollTop = 0; + return; + } + } + window.scrollTo(0, 0); + }, listbox); + } + + async expectHighlightVisibleAfter(keys: string[]) { + await this.keys(keys); + await expect(this.isHighlightVisible()).resolves.toBe(true); + } + + async assertKeyboardScrolling() { + for (let i = 0; i < 30; i++) { + await this.keys(['ArrowDown']); + } + await expect(this.isHighlightVisible()).resolves.toBe(true); + + await this.expectHighlightVisibleAfter(['End']); + await expect(this.getHighlightedText()).resolves.toBe('Option 100'); + await this.expectHighlightVisibleAfter(['Home']); + await expect(this.getHighlightedText()).resolves.toBe('Option 1'); + await this.expectHighlightVisibleAfter(['ArrowUp']); + await expect(this.getHighlightedText()).resolves.toBe('Option 100'); + + await this.scrollToTop(); + await this.expectHighlightVisibleAfter(['ArrowDown']); + await expect(this.getHighlightedText()).resolves.toBe('Option 1'); + } +} + +const setupTest = (url: string, testFn: (page: ScrollPage) => Promise) => + useBrowser({ width: 1200, height: 800 }, async browser => { + await browser.url(url); + const page = new ScrollPage(browser); + await page.waitForVisible('h1'); + await testFn(page); + }); + +describe('OptionList keyboard scrolling', () => { + describe.each(['wrapper', 'page'])('container=%s', container => { + const focusList = async (page: ScrollPage) => { + await page.click('#option-list-heading'); + await page.keys(['Tab']); + await expect(page.isFocused(listbox)).resolves.toBe(true); + }; + + test( + 'keeps the highlighted option visible', + setupTest(`#/light/option-list/scroll?container=${container}`, async page => { + await focusList(page); + await page.assertKeyboardScrolling(); + }) + ); + + test( + 'focus entry scrolls to a preselected option near the end', + setupTest(`#/light/option-list/scroll?container=${container}&preselected=near-end`, async page => { + await focusList(page); + await expect(page.getHighlightedText()).resolves.toBe('Option 81'); + await expect(page.isHighlightVisible()).resolves.toBe(true); + }) + ); + }); + + describe.each([false, true])('in a Dropdown, expandToViewport=%s', expandToViewport => { + test( + 'keeps the highlighted option visible without scrolling the page on open', + setupTest(`#/light/option-list/scroll?container=dropdown&expandToViewport=${expandToViewport}`, async page => { + const scrollBefore = await page.getWindowScroll(); + await page.click('#dropdown-trigger'); + await page.waitForVisible(listbox); + await expect(page.isFocused(listbox)).resolves.toBe(true); + await expect(page.getWindowScroll()).resolves.toEqual(scrollBefore); + await page.assertKeyboardScrolling(); + }) + ); + }); +}); diff --git a/src/option-list/__integ__/option-list.test.ts b/src/option-list/__integ__/option-list.test.ts new file mode 100644 index 00000000000..c46d3c2245a --- /dev/null +++ b/src/option-list/__integ__/option-list.test.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors'; + +const wrapper = createWrapper(); +const singleList = wrapper.findOptionList('[data-testid="single"]'); +const multiList = wrapper.findOptionList('[data-testid="multi"]'); +const groupsList = wrapper.findOptionList('[data-testid="groups"]'); +const listbox = (list: typeof singleList) => `${list.toSelector()} [role="listbox"]`; + +const setupTest = (testFn: (page: BasePageObject) => Promise) => + useBrowser(async browser => { + await browser.url('#/light/option-list/simple'); + const page = new BasePageObject(browser); + await page.waitForVisible(singleList.toSelector()); + await testFn(page); + }); + +describe('OptionList', () => { + test( + 'selects an option with a click anywhere on the row', + setupTest(async page => { + await page.click(singleList.findOptionByValue('performance').findDescription().toSelector()); + await expect(page.getText('[data-testid="single-value"]')).resolves.toBe('Selected: performance'); + await expect(page.getElementsCount(singleList.findSelectedOptions().toSelector())).resolves.toBe(1); + }) + ); + + test( + 'selects with the keyboard: Tab into the list, arrows move, Enter selects', + setupTest(async page => { + await page.click('#single-heading'); + await page.keys(['Tab']); + await expect(page.isFocused(listbox(singleList))).resolves.toBe(true); + await page.keys(['ArrowDown', 'ArrowDown', 'Enter']); + await expect(page.getText('[data-testid="single-value"]')).resolves.toBe('Selected: reliability'); + }) + ); + + test( + 'Tab leaves the list after one stop', + setupTest(async page => { + await page.click('#single-heading'); + await page.keys(['Tab', 'Tab']); + await expect(page.isFocused(listbox(singleList))).resolves.toBe(false); + await expect(page.isFocused(listbox(multiList))).resolves.toBe(true); + }) + ); + + test( + 'clicking a group header selects its enabled options', + setupTest(async page => { + await page.click(groupsList.findGroupHeaderByValue('storage').toSelector()); + await expect(page.getText('[data-testid="groups-value"]')).resolves.toBe('Selected: s3'); + }) + ); +}); diff --git a/src/option-list/__tests__/common.tsx b/src/option-list/__tests__/common.tsx new file mode 100644 index 00000000000..ff284287706 --- /dev/null +++ b/src/option-list/__tests__/common.tsx @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import OptionList, { OptionListProps } from '../../../lib/components/option-list'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +export const options: OptionListProps.Option[] = [ + { value: 'a', label: 'First', description: 'First description' }, + { value: 'b', label: 'Second' }, + { value: 'c', label: 'Third' }, +]; + +export const withDisabled: OptionListProps.Options = [ + options[0], + { ...options[1], disabled: true, disabledReason: 'Not available in this Region' }, + options[2], +]; + +export const groupedOptions: OptionListProps.Options = [ + { + value: 'compute', + label: 'Compute', + options: [ + { value: 'ec2', label: 'EC2' }, + { value: 'lambda', label: 'Lambda' }, + ], + }, + { + value: 'storage', + label: 'Storage', + options: [ + { value: 's3', label: 'S3' }, + { value: 'efs', label: 'EFS', disabled: true }, + ], + }, +]; + +const leafOptions = (source: OptionListProps.Options) => + source.flatMap(option => (option as OptionListProps.OptionGroup).options ?? [option]); + +const byValue = (values: string[], source: OptionListProps.Options) => + values.map(value => leafOptions(source).find(option => option.value === value)!); + +export type StatefulProps = Partial> & { + initialSelected?: string[]; + onChange?: jest.Mock; +}; + +// Controlled OptionList that keeps its selection in state and reports the selected values to `onChange`. +export function Stateful({ initialSelected = [], onChange, ...props }: StatefulProps) { + const [selectedOptions, setSelectedOptions] = useState>( + byValue(initialSelected, props.options ?? options) + ); + return ( + { + onChange?.(event.detail.selectedOptions.map(option => option.value)); + setSelectedOptions(event.detail.selectedOptions); + }} + /> + ); +} + +export function renderOptionList(jsx: React.ReactElement) { + const { container, rerender } = render(jsx); + const wrapper = createWrapper(container).findOptionList()!; + // There is no test-utils finder for the listbox element, which receives focus and keyboard events. + const listbox = wrapper.find('[role="listbox"]')!.getElement(); + return { container, rerender, wrapper, listbox }; +} + +export type Wrapper = ReturnType['wrapper']; + +export const clickOption = (wrapper: Wrapper, index: number) => + fireEvent.mouseUp(wrapper.findOptionByIndex(index)!.getElement()); + +export const pressKey = (listbox: HTMLElement, keyCode: number) => fireEvent.keyDown(listbox, { keyCode }); + +export const selectedLabels = (wrapper: Wrapper) => + wrapper.findSelectedOptions().map(option => option.findLabel()!.getElement().textContent); + +export const highlightedValue = (wrapper: Wrapper) => wrapper.findHighlightedOption()?.getElement().dataset.value; diff --git a/src/option-list/__tests__/disabled.test.tsx b/src/option-list/__tests__/disabled.test.tsx new file mode 100644 index 00000000000..74057a95915 --- /dev/null +++ b/src/option-list/__tests__/disabled.test.tsx @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import { OptionListProps } from '../../../lib/components/option-list'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { + clickOption, + highlightedValue, + options, + pressKey, + renderOptionList, + selectedLabels, + Stateful, + withDisabled, +} from './common'; + +const findTooltip = () => createWrapper().findTooltip(); + +describe('OptionList disabled options', () => { + test.each(['single', 'multi'])( + 'marks disabled options and ignores clicks on them (%s)', + selectionType => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList( + + ); + const disabledOption = wrapper.findOptionByValue('b')!; + expect(disabledOption.isDisabled()).toBe(true); + expect(disabledOption.getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findOptionByValue('a')!.isDisabled()).toBe(false); + + clickOption(wrapper, 2); + expect(onChange).not.toHaveBeenCalled(); + expect(wrapper.findSelectedOptions()).toHaveLength(0); + } + ); + + test('keyboard can reach a disabled option but Enter does not select it', () => { + const onChange = jest.fn(); + const { wrapper, listbox } = renderOptionList(); + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.down); + expect(highlightedValue(wrapper)).toBe('b'); + pressKey(listbox, KeyCode.enter); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('a selected disabled option stays selected and cannot be deselected in multi mode', () => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList( + + ); + clickOption(wrapper, 2); + expect(onChange).not.toHaveBeenCalled(); + expect(selectedLabels(wrapper)).toEqual(['Second']); + }); + + test('describes the disabled option with its disabled reason', () => { + const { wrapper } = renderOptionList(); + const describedBy = wrapper.findOptionByValue('b')!.getElement().getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy!)).toHaveTextContent('Not available in this Region'); + }); + + test.each(['single', 'multi'])( + 'does not show the disabled reason tooltip when renderOption is used (%s)', + selectionType => { + const { wrapper, listbox } = renderOptionList( + Custom {item.option.label}} + /> + ); + // Custom content owns the visible output, so the tooltip isn't shown. + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.down); + expect(highlightedValue(wrapper)).toBe('b'); + expect(findTooltip()).toBeNull(); + } + ); + + test('Escape closes the disabled reason tooltip and keeps the highlight and selection', () => { + const onChange = jest.fn(); + const { wrapper, listbox } = renderOptionList( + Needs a quota }]} + initialSelected={['a']} + onChange={onChange} + /> + ); + fireEvent.focus(listbox); + expect(findTooltip()).toBeNull(); + pressKey(listbox, KeyCode.down); + expect(findTooltip()!.findContent()!.getElement()).toHaveTextContent('Needs a quota'); + + fireEvent.keyDown(listbox, { key: 'Escape', keyCode: KeyCode.escape }); + expect(findTooltip()).toBeNull(); + expect(highlightedValue(wrapper)).toBe('b'); + expect(onChange).not.toHaveBeenCalled(); + expect(selectedLabels(wrapper)).toEqual(['First']); + }); +}); diff --git a/src/option-list/__tests__/group-state.test.tsx b/src/option-list/__tests__/group-state.test.tsx new file mode 100644 index 00000000000..a77f2cce5c3 --- /dev/null +++ b/src/option-list/__tests__/group-state.test.tsx @@ -0,0 +1,156 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { fireEvent } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import OptionList, { OptionListProps } from '../../../lib/components/option-list'; +import { clickOption, groupedOptions, highlightedValue, pressKey, renderOptionList, Stateful } from './common'; + +describe('OptionList option groups', () => { + test('renders each group labelled by its header, and option finders skip the headers', () => { + const { wrapper } = renderOptionList(); + expect(wrapper.findGroupHeaders().map(header => header.findLabel()!.getElement().textContent)).toEqual([ + 'Compute', + 'Storage', + ]); + const header = wrapper.findGroupHeaderByValue('storage')!.getElement(); + expect(header.closest('[role="group"]')).toHaveAttribute('aria-labelledby', header.id); + expect(wrapper.findOptions().map(option => option.getElement().dataset.value)).toEqual([ + 'ec2', + 'lambda', + 's3', + 'efs', + ]); + expect(wrapper.findOptionByIndex(3)!.getElement().dataset.value).toBe('s3'); + }); + + test('onChange returns the original options and never a group', () => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList( + onChange(detail.selectedOptions)} + /> + ); + fireEvent.mouseUp(wrapper.findGroupHeaderByValue('compute')!.getElement()); + const [ec2, lambda] = (groupedOptions[0] as OptionListProps.OptionGroup).options; + expect(onChange.mock.calls[0][0]).toHaveLength(2); + expect(onChange.mock.calls[0][0][0]).toBe(ec2); + expect(onChange.mock.calls[0][0][1]).toBe(lambda); + }); + + describe('single selection', () => { + test('group headers cannot be chosen and arrow keys skip them', () => { + const onChange = jest.fn(); + const { wrapper, listbox } = renderOptionList(); + fireEvent.mouseUp(wrapper.findGroupHeaderByValue('compute')!.getElement()); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.focus(listbox); + expect(highlightedValue(wrapper)).toBe('ec2'); + pressKey(listbox, KeyCode.down); + pressKey(listbox, KeyCode.down); + expect(highlightedValue(wrapper)).toBe('s3'); + pressKey(listbox, KeyCode.home); + pressKey(listbox, KeyCode.up); + expect(highlightedValue(wrapper)).toBe('efs'); + }); + + test('arrows still wrap when the list starts with a group that has options', () => { + const { wrapper, listbox } = renderOptionList( + + ); + fireEvent.focus(listbox); + expect(highlightedValue(wrapper)).toBe('apple'); + pressKey(listbox, KeyCode.up); + expect(highlightedValue(wrapper)).toBe('banana'); + pressKey(listbox, KeyCode.down); + expect(highlightedValue(wrapper)).toBe('apple'); + }); + }); + + describe('multi selection', () => { + test('the group header is checked, mixed or unchecked by its selected options', () => { + const { wrapper } = renderOptionList(); + const header = () => wrapper.findGroupHeaderByValue('compute')!; + expect(header().getElement()).toHaveAttribute('aria-checked', 'false'); + clickOption(wrapper, 1); + expect(header().getElement()).toHaveAttribute('aria-checked', 'mixed'); + expect(header().isSelected()).toBe(false); + clickOption(wrapper, 2); + expect(header().getElement()).toHaveAttribute('aria-checked', 'true'); + expect(header().isSelected()).toBe(true); + }); + + test('choosing a group header toggles its enabled options with the mouse and keyboard', () => { + const onChange = jest.fn(); + const { wrapper, listbox } = renderOptionList( + + ); + const storageHeader = wrapper.findGroupHeaderByValue('storage')!.getElement(); + fireEvent.mouseUp(storageHeader); + expect(onChange).toHaveBeenLastCalledWith(['efs', 's3']); + fireEvent.mouseUp(storageHeader); + expect(onChange).toHaveBeenLastCalledWith(['efs']); + + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.home); + expect(wrapper.findGroupHeaderByValue('compute')!.isHighlighted()).toBe(true); + pressKey(listbox, KeyCode.enter); + expect(onChange).toHaveBeenLastCalledWith(['efs', 'ec2', 'lambda']); + pressKey(listbox, KeyCode.space); + expect(onChange).toHaveBeenLastCalledWith(['efs']); + }); + + test('the group header keeps its highlight when the options are rebuilt on change', () => { + // The options are created on every render, so they are new objects after each change. + function RebuiltOptions() { + const [selectedOptions, setSelectedOptions] = useState>([]); + return ( + setSelectedOptions(detail.selectedOptions)} + /> + ); + } + const { wrapper, listbox } = renderOptionList(); + const header = () => wrapper.findGroupHeaderByValue('compute')!; + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.home); + expect(header().isHighlighted()).toBe(true); + pressKey(listbox, KeyCode.enter); + expect(header().isSelected()).toBe(true); + expect(header().isHighlighted()).toBe(true); + pressKey(listbox, KeyCode.enter); + expect(header().isSelected()).toBe(false); + }); + + test('a disabled group disables its options', () => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList( + + ); + expect(wrapper.findGroupHeaderByValue('db')!.isDisabled()).toBe(true); + expect(wrapper.findOptionByValue('rds')!.isDisabled()).toBe(true); + fireEvent.mouseUp(wrapper.findGroupHeaderByValue('db')!.getElement()); + clickOption(wrapper, 1); + expect(onChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/option-list/__tests__/highlight.test.tsx b/src/option-list/__tests__/highlight.test.tsx new file mode 100644 index 00000000000..4eabc6a5198 --- /dev/null +++ b/src/option-list/__tests__/highlight.test.tsx @@ -0,0 +1,238 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, fireEvent } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import { OptionListProps } from '../../../lib/components/option-list'; +import { + clickOption, + groupedOptions, + highlightedValue, + options, + pressKey, + renderOptionList, + selectedLabels, + Stateful, + StatefulProps, +} from './common'; + +import selectableItemStyles from '../../../lib/components/internal/components/selectable-item/styles.css.js'; + +// OptionList scrolls the highlighted option into view with `scrollIntoView`, which jsdom doesn't implement. +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; +let scrollIntoView: jest.Mock; + +beforeEach(() => { + scrollIntoView = HTMLElement.prototype.scrollIntoView = jest.fn(); +}); +afterEach(() => { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; +}); + +const lastScrolledValue = () => + (scrollIntoView.mock.instances[scrollIntoView.mock.instances.length - 1] as HTMLElement).dataset.value; + +describe('OptionList focus and highlight', () => { + const longOptions: OptionListProps.Option[] = [ + ...Array.from({ length: 9 }, (_, index) => ({ value: `${index}`, label: `Option ${index}` })), + { value: 'special', label: 'Special' }, + ]; + + const renderLongList = (props: StatefulProps = {}) => renderOptionList(); + + test('does not scroll when a grouped list mounts without a highlight', () => { + // Single-selection group headers are not interactive and render with data-mouse-target="-1", + // the same index the list reports when nothing is highlighted. + const twoGroups: OptionListProps.Options = [ + { value: 'group-1', label: 'Group 1', options: [{ value: '1', label: 'Option 1' }] }, + { value: 'group-2', label: 'Group 2', options: [{ value: '2', label: 'Option 2' }] }, + ]; + renderOptionList(); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test('hovering highlights the option without scrolling, and leaving the list clears it', () => { + const { wrapper } = renderLongList(); + fireEvent.mouseMove(wrapper.findOptionByIndex(5)!.getElement()); + expect(highlightedValue(wrapper)).toBe('4'); + expect(scrollIntoView).not.toHaveBeenCalled(); + fireEvent.mouseLeave(wrapper.getElement()); + expect(wrapper.findHighlightedOption()).toBeNull(); + }); + + test('focus entry highlights and scrolls to the first option', () => { + const { wrapper, listbox } = renderLongList(); + fireEvent.focus(listbox); + expect(highlightedValue(wrapper)).toBe('0'); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(lastScrolledValue()).toBe('0'); + }); + + test('focus entry highlights the selected option with the keyboard style and scrolls to it', () => { + const { wrapper, listbox } = renderLongList({ initialSelected: ['special'] }); + fireEvent.focus(listbox); + const highlighted = wrapper.findHighlightedOption()!; + expect(highlighted.getElement().dataset.value).toBe('special'); + expect(highlighted.getElement()).toHaveClass(selectableItemStyles['is-keyboard']); + expect(wrapper.findOptions().filter(option => option.isHighlighted())).toHaveLength(1); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' }); + expect(lastScrolledValue()).toBe('special'); + }); + + test('focus entry highlights the first selected option in list order, not selection order', () => { + const { wrapper, listbox } = renderLongList({ selectionType: 'multi', initialSelected: ['special', '2'] }); + fireEvent.focus(listbox); + expect(highlightedValue(wrapper)).toBe('2'); + expect(lastScrolledValue()).toBe('2'); + }); + + test('keyboard focus entry replaces a hover highlight with the selected option', () => { + const { wrapper, listbox } = renderLongList({ initialSelected: ['special'] }); + // Hover while the list isn't focused, and keep the pointer over the list. + fireEvent.mouseMove(wrapper.findOptionByIndex(5)!.getElement()); + expect(highlightedValue(wrapper)).toBe('4'); + + fireEvent.focus(listbox); + const highlighted = wrapper.findHighlightedOption()!; + expect(highlighted.getElement().dataset.value).toBe('special'); + expect(highlighted.getElement()).toHaveClass(selectableItemStyles['is-keyboard']); + expect(lastScrolledValue()).toBe('special'); + }); + + test('keyboard focus entry replaces a hover highlight with the first option when nothing is selected', () => { + const { wrapper, listbox } = renderLongList(); + fireEvent.mouseMove(wrapper.findOptionByIndex(5)!.getElement()); + fireEvent.focus(listbox); + expect(highlightedValue(wrapper)).toBe('0'); + expect(wrapper.findHighlightedOption()!.getElement()).toHaveClass(selectableItemStyles['is-keyboard']); + expect(lastScrolledValue()).toBe('0'); + }); + + test('focus from a pointer adds no highlight and does not scroll', () => { + const { wrapper, listbox } = renderLongList({ initialSelected: ['special'] }); + fireEvent.mouseDown(listbox); + fireEvent.focus(listbox); + expect(wrapper.findHighlightedOption()).toBeNull(); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test('focus after selecting with the mouse highlights and scrolls to the selected option', () => { + const { wrapper, listbox } = renderLongList(); + // Real focus, so the mousedown below lands on an already focused list. + act(() => listbox.focus()); + fireEvent.mouseDown(listbox); + clickOption(wrapper, 10); + expect(selectedLabels(wrapper)).toEqual(['Special']); + act(() => listbox.blur()); + scrollIntoView.mockClear(); + act(() => listbox.focus()); + expect(highlightedValue(wrapper)).toBe('special'); + expect(lastScrolledValue()).toBe('special'); + }); + + test('keyboard moves scroll the highlighted option into view', () => { + const { listbox } = renderLongList(); + fireEvent.focus(listbox); + scrollIntoView.mockClear(); + // The repeated End keeps the highlight in place and still scrolls it into view again. + const scrolledValues = [KeyCode.down, KeyCode.end, KeyCode.end, KeyCode.home, KeyCode.up].map(keyCode => { + pressKey(listbox, keyCode); + return lastScrolledValue(); + }); + expect(scrolledValues).toEqual(['1', 'special', 'special', '0', 'special']); + expect(scrollIntoView).toHaveBeenCalledTimes(5); + }); + + test('a keyboard highlight of the hovered option scrolls it into view', () => { + const { wrapper, listbox } = renderLongList(); + fireEvent.mouseMove(wrapper.findOptionByValue('special')!.getElement()); + expect(scrollIntoView).not.toHaveBeenCalled(); + fireEvent.keyDown(listbox, { key: 's' }); + expect(highlightedValue(wrapper)).toBe('special'); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(lastScrolledValue()).toBe('special'); + }); + + test('hovering after keyboard navigation does not scroll', () => { + const { wrapper, listbox } = renderLongList(); + fireEvent.focus(listbox); + scrollIntoView.mockClear(); + fireEvent.mouseMove(wrapper.findOptionByIndex(1)!.getElement()); + fireEvent.mouseMove(wrapper.findOptionByIndex(5)!.getElement()); + expect(highlightedValue(wrapper)).toBe('4'); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); +}); + +describe('OptionList highlight when options change', () => { + const fruit = (value: string) => ({ value, label: value }); + + const activeDescendant = (listbox: HTMLElement) => { + const id = listbox.getAttribute('aria-activedescendant'); + return id === null ? null : document.getElementById(id); + }; + + test('clears the highlight when the highlighted option is removed, and arrow keys still move it', () => { + const { wrapper, listbox, rerender } = renderOptionList(); + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.end); + expect(highlightedValue(wrapper)).toBe('c'); + + rerender(); + expect(wrapper.findHighlightedOption()).toBeNull(); + expect(listbox).not.toHaveAttribute('aria-activedescendant'); + + pressKey(listbox, KeyCode.down); + expect(highlightedValue(wrapper)).toBe('a'); + expect(activeDescendant(listbox)).toBe(wrapper.findOptionByValue('a')!.getElement()); + }); + + test('keeps the keyboard highlight on the same option when options are reordered or inserted, without scrolling', () => { + const { wrapper, listbox, rerender } = renderOptionList( + + ); + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.down); + expect(highlightedValue(wrapper)).toBe('banana'); + scrollIntoView.mockClear(); + + rerender(); + const banana = wrapper.findOptionByValue('banana')!.getElement(); + expect(highlightedValue(wrapper)).toBe('banana'); + expect(banana).toHaveClass(selectableItemStyles['is-keyboard']); + expect(activeDescendant(listbox)).toBe(banana); + expect(scrollIntoView).not.toHaveBeenCalled(); + + pressKey(listbox, KeyCode.down); + expect(highlightedValue(wrapper)).toBe('apple'); + }); + + test('keeps the mouse highlight on the same option when options are reordered', () => { + const { wrapper, rerender } = renderOptionList(); + fireEvent.mouseMove(wrapper.findOptionByValue('banana')!.getElement()); + + rerender(); + expect(highlightedValue(wrapper)).toBe('banana'); + expect(wrapper.findHighlightedOption()!.getElement()).not.toHaveClass(selectableItemStyles['is-keyboard']); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test('multi: keeps a highlighted group header when groups are reordered', () => { + const { wrapper, listbox, rerender } = renderOptionList( + + ); + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.end); + pressKey(listbox, KeyCode.up); + pressKey(listbox, KeyCode.up); + expect(wrapper.findGroupHeaderByValue('storage')!.isHighlighted()).toBe(true); + + rerender(); + expect(wrapper.findGroupHeaderByValue('storage')!.isHighlighted()).toBe(true); + expect(wrapper.findGroupHeaderByValue('compute')!.isHighlighted()).toBe(false); + expect(activeDescendant(listbox)).toContainElement(wrapper.findGroupHeaderByValue('storage')!.getElement()); + }); +}); diff --git a/src/option-list/__tests__/i18n.test.tsx b/src/option-list/__tests__/i18n.test.tsx new file mode 100644 index 00000000000..cc5b22dc35b --- /dev/null +++ b/src/option-list/__tests__/i18n.test.tsx @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent } from '@testing-library/react'; + +import TestI18nProvider from '../../../lib/components/i18n/testing'; +import { renderOptionList, Stateful } from './common'; + +describe('OptionList i18n', () => { + const messages = { 'option-list': { 'i18nStrings.selectedAriaLabel': 'Custom selected' } }; + + test('announces the selected option with the provider message', () => { + const { wrapper, listbox } = renderOptionList( + + + + ); + fireEvent.focus(listbox); + expect(wrapper.findOptionByValue('a')!.getElement()).toHaveTextContent('Custom selected'); + }); + + test('i18nStrings.selectedAriaLabel takes precedence over the provider message', () => { + const { wrapper, listbox } = renderOptionList( + + + + ); + fireEvent.focus(listbox); + const option = wrapper.findOptionByValue('a')!.getElement(); + expect(option).toHaveTextContent('Picked'); + expect(option).not.toHaveTextContent('Custom selected'); + }); +}); diff --git a/src/option-list/__tests__/keyboard.test.tsx b/src/option-list/__tests__/keyboard.test.tsx new file mode 100644 index 00000000000..5fe39eca614 --- /dev/null +++ b/src/option-list/__tests__/keyboard.test.tsx @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { createEvent, fireEvent, render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import createWrapper from '../../../lib/components/test-utils/dom'; +import { pressKey, renderOptionList, Stateful } from './common'; + +describe('OptionList keyboard', () => { + test('focus highlights the first option, arrows move with wrap-around, Home and End jump', () => { + const { wrapper, listbox } = renderOptionList(); + const optionId = (index: number) => wrapper.findOptionByIndex(index)!.getElement().id; + + fireEvent.focus(listbox); + expect(listbox).toHaveAttribute('aria-activedescendant', optionId(1)); + + pressKey(listbox, KeyCode.down); + expect(listbox).toHaveAttribute('aria-activedescendant', optionId(2)); + + pressKey(listbox, KeyCode.up); + pressKey(listbox, KeyCode.up); + expect(listbox).toHaveAttribute('aria-activedescendant', optionId(3)); + + pressKey(listbox, KeyCode.down); + expect(listbox).toHaveAttribute('aria-activedescendant', optionId(1)); + + const endEvent = createEvent.keyDown(listbox, { keyCode: KeyCode.end }); + fireEvent(listbox, endEvent); + expect(listbox).toHaveAttribute('aria-activedescendant', optionId(3)); + + const homeEvent = createEvent.keyDown(listbox, { keyCode: KeyCode.home }); + fireEvent(listbox, homeEvent); + expect(listbox).toHaveAttribute('aria-activedescendant', optionId(1)); + }); + + test('Enter and Space select the highlighted option', () => { + const onChange = jest.fn(); + const { listbox } = renderOptionList(); + fireEvent.focus(listbox); + + pressKey(listbox, KeyCode.enter); + expect(onChange).toHaveBeenLastCalledWith(['a']); + + pressKey(listbox, KeyCode.down); + pressKey(listbox, KeyCode.space); + expect(onChange).toHaveBeenLastCalledWith(['a', 'b']); + + pressKey(listbox, KeyCode.space); + expect(onChange).toHaveBeenLastCalledWith(['a']); + }); + + test('Escape is not handled and reaches the parent', () => { + const onChange = jest.fn(); + const onParentKeyDown = jest.fn(); + const { container } = render( +
onParentKeyDown(event.keyCode)}> + +
+ ); + const listbox = createWrapper(container).findOptionList()!.find('[role="listbox"]')!.getElement(); + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.escape); + expect(onParentKeyDown).toHaveBeenCalledWith(KeyCode.escape); + expect(onChange).not.toHaveBeenCalled(); + }); + + test('blur clears the highlight and aria-activedescendant', () => { + const { wrapper, listbox } = renderOptionList(); + expect(listbox).not.toHaveAttribute('aria-activedescendant'); + fireEvent.focus(listbox); + expect(wrapper.findHighlightedOption()).not.toBeNull(); + expect(listbox).toHaveAttribute('aria-activedescendant'); + fireEvent.blur(listbox); + expect(wrapper.findHighlightedOption()).toBeNull(); + expect(listbox).not.toHaveAttribute('aria-activedescendant'); + }); +}); diff --git a/src/option-list/__tests__/option-list-a11y.test.tsx b/src/option-list/__tests__/option-list-a11y.test.tsx new file mode 100644 index 00000000000..46bfde3c183 --- /dev/null +++ b/src/option-list/__tests__/option-list-a11y.test.tsx @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import '../../__a11y__/to-validate-a11y'; +import { OptionListProps } from '../../../lib/components/option-list'; +import { groupedOptions, pressKey, renderOptionList, Stateful, withDisabled } from './common'; + +import selectableItemStyles from '../../../lib/components/internal/components/selectable-item/styles.css.js'; + +describe('OptionList a11y', () => { + test.each<[string, OptionListProps.SelectionType, OptionListProps.Options, string]>([ + ['single selection', 'single', withDisabled, 'a'], + ['multi selection', 'multi', withDisabled, 'a'], + ['single selection with groups', 'single', groupedOptions, 'ec2'], + ['multi selection with groups', 'multi', groupedOptions, 'ec2'], + ])('is accessible: %s', async (_, selectionType, source, selected) => { + const { container } = render( + + ); + await expect(container).toValidateA11y(); + }); + + describe('renderHighlightedAriaLive', () => { + test('announces the highlighted option with the custom text, for example with custom option content', () => { + const { wrapper, listbox } = renderOptionList( + {item.option.value === 'a' ? 'Production' : 'Staging'}} + renderHighlightedAriaLive={option => (option.value === 'a' ? 'Production' : 'Staging')} + /> + ); + // The visible custom content is hidden from screen readers while highlighted, so only this text is announced. + const announcement = () => + wrapper + .findHighlightedOption()! + .getElement() + .closest('[role="option"]')! + .querySelector(`.${selectableItemStyles['screenreader-content']}`)!.textContent; + fireEvent.focus(listbox); + expect(announcement()).toBe('Production'); + pressKey(listbox, KeyCode.down); + expect(announcement()).toBe('Staging'); + }); + + test('receives the consumer option objects and the group when it changes', () => { + const renderHighlightedAriaLive = jest.fn(() => 'announcement'); + const [compute] = groupedOptions as OptionListProps.OptionGroup[]; + const { listbox } = renderOptionList( + + ); + fireEvent.focus(listbox); + expect(renderHighlightedAriaLive).toHaveBeenLastCalledWith(compute.options[0], compute); + }); + + test('receives the consumer group object when a group header is highlighted in multi selection', () => { + const renderHighlightedAriaLive = jest.fn(() => 'announcement'); + const [compute] = groupedOptions as OptionListProps.OptionGroup[]; + const { wrapper, listbox } = renderOptionList( + + ); + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.home); + expect(wrapper.findGroupHeaderByValue('compute')!.isHighlighted()).toBe(true); + const calls = renderHighlightedAriaLive.mock + .calls as unknown as Parameters[]; + expect(calls[calls.length - 1][0]).toBe(compute); + }); + }); +}); diff --git a/src/option-list/__tests__/option-list.test.tsx b/src/option-list/__tests__/option-list.test.tsx new file mode 100644 index 00000000000..e86d1efa42b --- /dev/null +++ b/src/option-list/__tests__/option-list.test.tsx @@ -0,0 +1,270 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import OptionList, { OptionListProps } from '../../../lib/components/option-list'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { clickOption, options, renderOptionList, selectedLabels, Stateful } from './common'; + +import styles from '../../../lib/components/option-list/styles.selectors.js'; + +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: jest.fn(), +})); + +afterEach(() => { + (warnOnce as jest.Mock).mockReset(); +}); + +describe('OptionList', () => { + describe('rendering', () => { + test('renders a labelled, focusable listbox with an option per item', () => { + const { wrapper, listbox } = renderOptionList(); + expect(listbox).toHaveAttribute('aria-label', 'Goals'); + expect(listbox).toHaveAttribute('tabindex', '0'); + expect(wrapper.findOptions().map(option => option.getElement().getAttribute('role'))).toEqual([ + 'option', + 'option', + 'option', + ]); + }); + + test('renders label and description', () => { + const { wrapper } = renderOptionList(); + const first = wrapper.findOptionByValue('a')!; + expect(first.findLabel()!.getElement()).toHaveTextContent('First'); + expect(first.findDescription()!.getElement()).toHaveTextContent('First description'); + expect(wrapper.findOptionByValue('b')!.findDescription()).toBeNull(); + }); + + test('renders icon, label tag and tags', () => { + const { wrapper } = renderOptionList( + , + labelTag: '128 GB', + tags: ['CPU-v2', '2 GB RAM'], + }, + ]} + /> + ); + const option = wrapper.findOptionByValue('a')!; + expect(option.find('[data-testid="custom-icon"]')).not.toBeNull(); + expect(option.findLabelTag()!.getElement()).toHaveTextContent('128 GB'); + expect(option.findTags().map(tag => tag.getElement().textContent)).toEqual(['CPU-v2', '2 GB RAM']); + }); + + test('wraps the icon in the icon slot that spaces it from the content', () => { + const { wrapper } = renderOptionList( + }]} /> + ); + const icon = wrapper.findOptionByValue('a')!.find('[data-testid="custom-icon"]')!.getElement(); + expect(icon.parentElement).toHaveClass(styles.icon); + }); + + test('renders no icon slot when the option has no icon', () => { + const { wrapper } = renderOptionList(); + expect(wrapper.findOptionByValue('a')!.find(`.${styles.icon}`)).toBeNull(); + }); + + test('passes aria-labelledby and aria-describedby to the listbox', () => { + const { listbox } = renderOptionList( + + ); + expect(listbox).toHaveAttribute('aria-labelledby', 'heading'); + expect(listbox).toHaveAttribute('aria-describedby', 'description'); + }); + + test('renders an empty listbox when there are no options', () => { + const { wrapper } = renderOptionList(); + expect(wrapper.findOptions()).toHaveLength(0); + }); + + test('warns about duplicate values', () => { + renderOptionList( + + ); + expect(warnOnce).toHaveBeenCalledWith( + 'OptionList', + 'Every option must have a unique `value`. The component may work incorrectly.' + ); + }); + + test('warns about duplicate group values, but not when a group shares a value with an option', () => { + const groupWarning = 'Every option group must have a unique `value`. The component may work incorrectly.'; + renderOptionList( + + ); + expect(warnOnce).not.toHaveBeenCalledWith('OptionList', groupWarning); + renderOptionList( + + ); + expect(warnOnce).toHaveBeenCalledWith('OptionList', groupWarning); + }); + + test('passes id, className and data attributes to the root', () => { + const { container } = render( + + ); + const root = createWrapper(container).findOptionList()!.getElement(); + expect(root).toHaveAttribute('id', 'my-list'); + expect(root).toHaveClass('custom'); + expect(root).toHaveAttribute('data-testid', 'root'); + }); + }); + + describe('selection', () => { + test('single: marks only the selected option with aria-selected and a check mark', () => { + const { wrapper, listbox } = renderOptionList(); + expect(wrapper.findOptions().map(option => option.getElement().getAttribute('aria-selected'))).toEqual([ + 'false', + 'true', + 'false', + ]); + expect(selectedLabels(wrapper)).toEqual(['Second']); + expect(listbox).not.toHaveAttribute('aria-multiselectable'); + }); + + test('single: clicking replaces the selection and repeat click keeps it selected', () => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList(); + + clickOption(wrapper, 2); + expect(onChange).toHaveBeenLastCalledWith(['b']); + + clickOption(wrapper, 3); + expect(onChange).toHaveBeenLastCalledWith(['c']); + + clickOption(wrapper, 3); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenLastCalledWith(['c']); + expect(selectedLabels(wrapper)).toEqual(['Third']); + }); + + test('onChange returns the original option objects', () => { + const onChange = jest.fn(); + const optionsWithIcon = [{ ...options[0], icon: }, ...options.slice(1)]; + const { wrapper } = renderOptionList( + onChange(detail)} + /> + ); + clickOption(wrapper, 1); + expect(onChange.mock.calls[0][0].selectedOptions[0]).toBe(optionsWithIcon[0]); + }); + + test('multi: marks the listbox multiselectable and every selected option', () => { + const { wrapper, listbox } = renderOptionList(); + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + expect(wrapper.findOptions().map(option => option.getElement().getAttribute('aria-selected'))).toEqual([ + 'true', + 'false', + 'true', + ]); + expect(selectedLabels(wrapper)).toEqual(['First', 'Third']); + }); + + test('multi: clicking toggles options and keeps the selection order', () => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList( + + ); + + clickOption(wrapper, 1); + expect(onChange).toHaveBeenLastCalledWith(['c', 'a']); + + clickOption(wrapper, 3); + expect(onChange).toHaveBeenLastCalledWith(['a']); + expect(selectedLabels(wrapper)).toEqual(['First']); + }); + + test('controlled: does not change the selection without the consumer updating selectedOptions', () => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList( + onChange(detail.selectedOptions.map(option => option.value))} + /> + ); + clickOption(wrapper, 2); + expect(onChange).toHaveBeenCalledWith(['b']); + expect(selectedLabels(wrapper)).toEqual(['First']); + }); + + test('controlled: matches selected options by value, not by reference', () => { + const props = { ariaLabel: 'Goals', options, selectionType: 'single' as const, onChange: () => {} }; + const { wrapper, rerender } = renderOptionList(); + expect(selectedLabels(wrapper)).toEqual(['First']); + rerender(); + expect(selectedLabels(wrapper)).toEqual(['Third']); + }); + + test('ignores selected options that do not match an option', () => { + const { wrapper } = renderOptionList( + {}} + /> + ); + expect(selectedLabels(wrapper)).toEqual(['Second']); + }); + + test('uncontrolled: manages selection internally and fires onChange', () => { + const onChange = jest.fn(); + const { wrapper } = renderOptionList( + onChange(detail.selectedOptions.map(option => option.value))} + /> + ); + clickOption(wrapper, 1); + clickOption(wrapper, 2); + expect(onChange).toHaveBeenLastCalledWith(['a', 'b']); + expect(selectedLabels(wrapper)).toEqual(['First', 'Second']); + }); + }); + + test('ref.focus() focuses the listbox', () => { + const ref = React.createRef(); + const { listbox } = renderOptionList( + + ); + act(() => ref.current!.focus()); + expect(document.activeElement).toBe(listbox); + }); +}); diff --git a/src/option-list/__tests__/render-option.test.tsx b/src/option-list/__tests__/render-option.test.tsx new file mode 100644 index 00000000000..3a55308b659 --- /dev/null +++ b/src/option-list/__tests__/render-option.test.tsx @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import { OptionListProps } from '../../../lib/components/option-list'; +import { groupedOptions, pressKey, renderOptionList, Stateful } from './common'; + +describe('OptionList renderOption', () => { + test('renders custom content for options and group headers with the original options', () => { + const renderOption = jest.fn(({ item }: Parameters[0]) => ( + {`${item.type}: ${item.option.label}`} + )); + const { wrapper } = renderOptionList( + + ); + expect(wrapper.findOptionByValue('ec2')!.getElement()).toHaveTextContent('item: EC2'); + expect(wrapper.findGroupHeaderByValue('compute')!.getElement()).toHaveTextContent('group: Compute'); + + const [compute] = groupedOptions as OptionListProps.OptionGroup[]; + const items = renderOption.mock.calls.map(([{ item }]) => item); + expect(items).toContainEqual({ type: 'group', index: 0, option: compute, disabled: false }); + expect(items).toContainEqual({ + type: 'item', + index: 1, + option: compute.options[0], + selected: true, + highlighted: false, + disabled: false, + parent: { type: 'group', index: 0, option: compute, disabled: false }, + }); + expect(items.find(item => item.option === compute.options[0])!.option).toBe(compute.options[0]); + + // Single selection leaves out the multi-selection group state, on group headers and on `parent`. + const groupItems = [ + ...items.filter(item => item.type === 'group'), + ...items.flatMap(item => (item.type === 'item' && item.parent ? [item.parent] : [])), + ]; + expect(groupItems.length).toBeGreaterThan(0); + for (const group of groupItems) { + expect(group).not.toHaveProperty('selected'); + expect(group).not.toHaveProperty('indeterminate'); + expect(group).not.toHaveProperty('highlighted'); + } + }); + + test('multi: items carry the option icon, and group header items the group and its checkbox state', () => { + const icon = ; + const iconOptions: OptionListProps.Options = [ + { + value: 'compute', + label: 'Compute', + options: [ + { value: 'ec2', label: 'EC2', icon }, + { value: 'lambda', label: 'Lambda' }, + ], + }, + ]; + const optionIcons: React.ReactNode[] = []; + const groupStates: Array> = []; + const renderOption = jest.fn(({ item }: { item: OptionListProps.Item }) => { + if (item.type === 'item') { + optionIcons.push(item.option.icon); + } else { + groupStates.push({ + selected: item.selected, + indeterminate: item.indeterminate, + highlighted: item.highlighted, + }); + } + return null; + }); + const { wrapper, listbox } = renderOptionList( + + ); + expect(optionIcons).toContain(icon); + expect(renderOption).toHaveBeenCalledWith( + expect.objectContaining({ item: expect.objectContaining({ type: 'group', option: iconOptions[0] }) }) + ); + expect(groupStates[groupStates.length - 1]).toEqual({ selected: false, indeterminate: true, highlighted: false }); + + fireEvent.focus(listbox); + pressKey(listbox, KeyCode.home); + expect(wrapper.findGroupHeaderByValue('compute')!.isHighlighted()).toBe(true); + expect(groupStates[groupStates.length - 1]).toEqual({ selected: false, indeterminate: true, highlighted: true }); + + pressKey(listbox, KeyCode.enter); + expect(groupStates[groupStates.length - 1]).toEqual({ selected: true, indeterminate: false, highlighted: true }); + }); +}); 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