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.page.tsx b/pages/option-list/permutations.page.tsx new file mode 100644 index 00000000000..1b7147bca60 --- /dev/null +++ b/pages/option-list/permutations.page.tsx @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Icon from '~components/icon'; +import OptionList, { OptionListProps } from '~components/option-list'; + +import { PermutationsPage } from '../app/templates'; +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; + +const plain: OptionListProps.Option[] = [ + { value: '1', label: 'Reduce cost' }, + { value: '2', label: 'Improve performance', description: 'Prioritize latency and throughput.' }, + { + value: '3', + label: 'Increase reliability across every Region and Availability Zone that hosts the workload', + description: 'A long label and a long description that wrap onto multiple lines on narrow containers.', + }, + { value: '4', label: 'Strengthen security' }, +]; +const withIcons = plain.map(option => ({ ...option, icon: })); +const withTags = plain.map(option => ({ ...option, labelTag: '128 GB', tags: ['CPU-v2', '2 GB RAM'] })); +const withDisabled = plain.map(option => + option.value === '2' || option.value === '4' + ? { ...option, disabled: true, disabledReason: option.value === '4' ? 'Requires Enterprise support' : undefined } + : option +); +const grouped: OptionListProps.Options = [ + { value: 'group-1', label: 'Goals', options: plain.slice(0, 2) }, + { value: 'group-2', label: 'More goals', options: plain.slice(2) }, +]; + +const renderOption: OptionListProps['renderOption'] = ({ item }) => {item.option.label}; + +const permutations = createPermutations([ + { + width: [240, 480], + options: [plain, withIcons, withTags, withDisabled, grouped], + selectionType: ['single', 'multi'], + selectedOptions: [[], [plain[1]], [plain[1], plain[2]]], + }, + { + width: [240], + options: [grouped], + selectionType: ['single', 'multi'], + selectedOptions: [[plain[1]]], + renderOption: [renderOption], + }, +]).filter(({ selectionType, selectedOptions }) => selectionType === 'multi' || (selectedOptions?.length ?? 0) < 2); + +export default function OptionListPermutationsPage() { + return ( + + ( +
+ {}} /> +
+ )} + /> +
+ ); +} diff --git a/pages/option-list/scroll.page.tsx b/pages/option-list/scroll.page.tsx new file mode 100644 index 00000000000..aab31aa4c02 --- /dev/null +++ b/pages/option-list/scroll.page.tsx @@ -0,0 +1,216 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useRef, useState } from 'react'; + +import Button, { ButtonProps } from '~components/button'; +import Dropdown from '~components/dropdown'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import OptionList, { OptionListProps } from '~components/option-list'; +import SegmentedControl from '~components/segmented-control'; +import SpaceBetween from '~components/space-between'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +import styles from './scroll.scss'; + +/** + * Where the scrolling happens: + * - `wrapper`: a wrapper div with a fixed height and `overflow: auto`. + * - `page`: no container, the page scrolls. + * - `dropdown`: the list is the content of a Dropdown opened by a button. + */ +type Container = 'wrapper' | 'page' | 'dropdown'; +type Preselected = 'none' | 'near-end'; + +type PageContext = React.Context< + AppContextType<{ + container?: Container; + expandToViewport?: boolean; + selectionType?: OptionListProps.SelectionType; + preselected?: Preselected; + }> +>; + +const options: OptionListProps.Option[] = Array.from({ length: 100 }, (_, index) => ({ + value: `option-${index + 1}`, + label: `Option ${index + 1}`, +})); + +function DropdownOptionList({ + expandToViewport, + list, + listRef, +}: { + expandToViewport: boolean; + list: (props: { onChange: () => void }) => React.ReactNode; + listRef: React.RefObject; +}) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + // Move focus into the list once the Dropdown has rendered its content. + useEffect(() => { + if (open) { + listRef.current?.focus(); + } + }, [open, listRef]); + + const close = (returnFocus: boolean) => { + setOpen(false); + if (returnFocus) { + triggerRef.current?.focus(); + } + }; + + return ( + close(false)} + onEscape={() => close(true)} + minWidth={320} + expandToViewport={expandToViewport} + trigger={ + + } + content={
{list({ onChange: () => close(true) })}
} + /> + ); +} + +function ConfiguredOptionList({ + container, + expandToViewport, + selectionType, + preselected, +}: { + container: Container; + expandToViewport: boolean; + selectionType: OptionListProps.SelectionType; + preselected: Preselected; +}) { + const [selectedOptions, setSelectedOptions] = useState>( + preselected === 'near-end' ? [options[80]] : [] + ); + const listRef = useRef(null); + + const list = ({ onChange }: { onChange?: () => void } = {}) => ( + { + setSelectedOptions(detail.selectedOptions); + onChange?.(); + }} + /> + ); + + return ( + + {/* Integ tests click the heading, then press Tab to move keyboard focus into the list. */} +
+ Long list +
+ {container === 'wrapper' && ( +
+ {list()} +
+ )} + {container === 'page' && list()} + {container === 'dropdown' && ( + + )} +
+ Selected: {selectedOptions.map(option => option.value).join(', ') || 'none'} +
+
+ ); +} + +export default function OptionListScrollPage() { + const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + const container = urlParams.container ?? 'wrapper'; + const expandToViewport = urlParams.expandToViewport ?? false; + const selectionType = urlParams.selectionType ?? 'single'; + const preselected = urlParams.preselected ?? 'none'; + + return ( + + + setUrlParams({ container: detail.selectedId as Container })} + options={[ + { id: 'wrapper', text: 'Wrapper' }, + { id: 'page', text: 'Page' }, + { id: 'dropdown', text: 'Dropdown' }, + ]} + /> + + + setUrlParams({ expandToViewport: detail.selectedId === 'true' })} + options={[ + { id: 'false', text: 'No' }, + { id: 'true', text: 'Yes' }, + ]} + /> + + + + setUrlParams({ selectionType: detail.selectedId as OptionListProps.SelectionType }) + } + options={[ + { id: 'single', text: 'Single' }, + { id: 'multi', text: 'Multi' }, + ]} + /> + + + setUrlParams({ preselected: detail.selectedId as Preselected })} + options={[ + { id: 'none', text: 'None' }, + { id: 'near-end', text: 'Option 81' }, + ]} + /> + + + } + screenshotArea={{}} + > +
+ {/* Remount on every configuration change so the initial selection follows the settings. */} + +
+
+ ); +} diff --git a/pages/option-list/scroll.scss b/pages/option-list/scroll.scss new file mode 100644 index 00000000000..d52e0391478 --- /dev/null +++ b/pages/option-list/scroll.scss @@ -0,0 +1,15 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +// A scroll container for the list. Applied to a wrapper div. +.fixed-height { + block-size: 240px; + overflow: auto; +} + +// Lets the list scroll inside the Dropdown content instead of overflowing it. +.dropdown-content { + overflow: auto; +} diff --git a/pages/option-list/simple.page.tsx b/pages/option-list/simple.page.tsx new file mode 100644 index 00000000000..12a65d6cd90 --- /dev/null +++ b/pages/option-list/simple.page.tsx @@ -0,0 +1,235 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useRef, useState } from 'react'; + +import Box from '~components/box'; +import Button from '~components/button'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import OptionList, { OptionListProps } from '~components/option-list'; +import SegmentedControl from '~components/segmented-control'; +import SpaceBetween from '~components/space-between'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +/** + * Options selected when the page loads: + * - `first` and `last`: the first or last option of each list. + * - `two`: the second and fourth options, given in reverse order so selection order differs from list order. + * Multi selection only; single-selection lists start empty. + */ +type Preselected = 'none' | 'first' | 'last' | 'two'; + +type PageContext = React.Context>; + +const goals: OptionListProps.Option[] = [ + { + value: 'cost', + label: 'Reduce cost', + description: 'Prioritize cheaper instance types and storage tiers.', + icon: , + }, + { value: 'performance', label: 'Improve performance', description: 'Prioritize latency and throughput.' }, + { value: 'reliability', label: 'Increase reliability', description: 'Add redundancy across Availability Zones.' }, + { value: 'security', label: 'Strengthen security', description: 'Tighten IAM policies and encryption settings.' }, +]; + +const services: OptionListProps.Options = [ + { + value: 'compute', + label: 'Compute', + options: [ + { value: 'ec2', label: 'Amazon EC2' }, + { value: 'lambda', label: 'AWS Lambda' }, + ], + }, + { + value: 'storage', + label: 'Storage', + options: [ + { value: 's3', label: 'Amazon S3' }, + { value: 'efs', label: 'Amazon EFS', disabled: true, disabledReason: 'Not available in this Region.' }, + ], + }, +]; + +/** + * Labels share first letters within and across groups, so repeated letters cycle through matches, and a + * child's first letter differs from its group's (typing `e` should reach "EC2", not "Compute"). + */ +const typeAheadServices: OptionListProps.Options = [ + { + value: 'compute', + label: 'Compute', + options: [ + { value: 'ec2', label: 'EC2' }, + { value: 'elastic-beanstalk', label: 'Elastic Beanstalk' }, + { value: 'lambda', label: 'Lambda' }, + ], + }, + { + value: 'containers', + label: 'Containers', + options: [ + { value: 'ecs', label: 'ECS' }, + { value: 'eks', label: 'EKS' }, + { value: 'fargate', label: 'Fargate' }, + ], + }, + { + value: 'storage', + label: 'Storage', + options: [ + { value: 's3', label: 'S3' }, + { value: 'efs', label: 'EFS' }, + { value: 'glacier', label: 'Glacier' }, + ], + }, +]; + +const groupKey: keyof OptionListProps.OptionGroup = 'options'; + +const leafOptions = (options: OptionListProps.Options): OptionListProps.Option[] => + options.flatMap(option => (groupKey in option ? [...(option as OptionListProps.OptionGroup).options] : [option])); + +function initialSelection( + options: OptionListProps.Options, + selectionType: OptionListProps.SelectionType, + preselected: Preselected +): ReadonlyArray { + const leaves = leafOptions(options); + switch (preselected) { + case 'first': + return [leaves[0]]; + case 'last': + return [leaves[leaves.length - 1]]; + case 'two': + return selectionType === 'multi' ? [leaves[3], leaves[1]] : []; + default: + return []; + } +} + +function Section({ + id, + title, + description, + options, + selectionType, + preselected, + listRef, +}: { + id: string; + title: string; + description?: string; + options: OptionListProps.Options; + selectionType: OptionListProps.SelectionType; + preselected: Preselected; + listRef: React.RefObject; +}) { + const [selectedOptions, setSelectedOptions] = useState(() => initialSelection(options, selectionType, preselected)); + return ( + + {/* Header's `id` is on its root element, so a header description would become part of the list's name. */} +
+ {title} +
+ {description && {description}} + setSelectedOptions(detail.selectedOptions)} + /> + + Selected: {selectedOptions.map(option => option.value).join(', ') || 'none'} + +
+ ); +} + +export default function OptionListSimplePage() { + const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + const preselected = urlParams.preselected ?? 'none'; + + const singleRef = useRef(null); + const multiRef = useRef(null); + const groupsRef = useRef(null); + const typeAheadRef = useRef(null); + + return ( + + + setUrlParams({ preselected: detail.selectedId as Preselected })} + options={[ + { id: 'none', text: 'None' }, + { id: 'first', text: 'First' }, + { id: 'last', text: 'Last' }, + { id: 'two', text: 'Second and fourth (multi only)' }, + ]} + /> + + {/* Moves focus with `ref.focus()`, the way a builder's own dropdown does when it opens. */} + + + + + + + + + + } + screenshotArea={{}} + > +
+ {/* Remount when the setting changes so the initial selection follows it. */} + +
+
+
+
+ +
+
+ ); +} diff --git a/src/__tests__/required-props-for-components.ts b/src/__tests__/required-props-for-components.ts index 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..d386a1fcdbe 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -21257,6 +21257,193 @@ need to be direct children of the group: } `; +exports[`Components definition for option-list matches the snapshot: option-list 1`] = ` +{ + "dashCaseName": "option-list", + "events": [ + { + "cancelable": false, + "description": "Called when the user selects or deselects an option. +The event \`detail\` contains the new \`selectedOptions\`.", + "detailInlineType": { + "name": "OptionListProps.ChangeDetail", + "properties": [ + { + "name": "selectedOptions", + "optional": false, + "type": "ReadonlyArray", + }, + ], + "type": "object", + }, + "detailType": "OptionListProps.ChangeDetail", + "name": "onChange", + }, + ], + "functions": [ + { + "name": "focus", + "parameters": [], + "returnType": "void", + }, + ], + "name": "OptionList", + "properties": [ + { + "description": "Adds \`aria-describedby\` to the list.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-label\` to the list. Use this when there is no visible label.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the list. Use this to reference the visible label of the list.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "An object containing all the necessary localized strings required by the component. +* \`selectedAriaLabel\` - Announced with the highlighted option when it is selected.", + "i18nTag": true, + "inlineType": { + "name": "OptionListProps.I18nStrings", + "properties": [ + { + "name": "selectedAriaLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "OptionListProps.I18nStrings", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Specifies an array of options that are displayed inline as a list. +You can group options into one level of option groups. + +#### Option +- \`value\` (string) - The returned value of the option when selected. Must be unique. +- \`label\` (string) - (Optional) Option text displayed to the user. +- \`lang\` (string) - (Optional) The language of the option, provided as a BCP 47 language tag. +- \`description\` (string) - (Optional) Further information about the option that appears below the label. +- \`disabled\` (boolean) - (Optional) Determines whether the option is disabled. Users can't select or deselect a disabled option. +- \`disabledReason\` (ReactNode) - (Optional) Displays a tooltip near the item when disabled. Use to provide additional context. +- \`labelTag\` (string) - (Optional) A label tag that provides additional guidance, shown next to the label. +- \`tags\` [string[]] - (Optional) A list of tags giving further guidance about the option. +- \`icon\` (ReactNode) - (Optional) An icon displayed in the option, such as an [icon component](/components/icon/). + +#### OptionGroup +- \`value\` (string) - (Optional) Used to locate option group in test utils. +- \`label\` (string) - Option group text displayed to the user. +- \`disabled\` (boolean) - (Optional) Determines whether the option group is disabled. Disables every option in the group. +- \`options\` (Option[]) - The options in the group. + +In multi selection, choosing a group header selects or deselects all its enabled options. + +Options are the selection target. Do not render interactive elements such as buttons or links inside them.", + "name": "options", + "optional": false, + "type": "OptionListProps.Options", + }, + { + "description": "Overrides the element that is announced to screen readers +when the highlighted option changes. By default, this announces +the option's name and properties, and its selected state if +the \`selectedAriaLabel\` property is defined. +The highlighted option is provided, and its group (if groups +are used and it differs from the group of the previously highlighted option). + +For more information, see the +[accessibility guidelines](/components/select/?tabId=usage#accessibility-guidelines).", + "inlineType": { + "name": "OptionListProps.ContainingOptionAndGroupString", + "parameters": [ + { + "name": "option", + "type": "OptionListProps.Option", + }, + { + "name": "group", + "type": "OptionListProps.OptionGroup", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "renderHighlightedAriaLive", + "optional": true, + "type": "OptionListProps.ContainingOptionAndGroupString", + }, + { + "description": "Specifies a render function to render custom options.", + "inlineType": { + "name": "OptionListProps.OptionItemRenderer", + "parameters": [ + { + "name": "props", + "type": "{ item: OptionListProps.Item; }", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "renderOption", + "optional": true, + "type": "OptionListProps.OptionItemRenderer", + }, + { + "description": "The selected options. Options are matched by \`value\`. In single selection, only the first option of the array is used. +If you don't provide this property, the component manages its selection internally.", + "name": "selectedOptions", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "Specifies whether the user can select one option (\`single\`) or several (\`multi\`). +Multi selection shows a checkbox next to each option.", + "inlineType": { + "name": "OptionListProps.SelectionType", + "type": "union", + "values": [ + "single", + "multi", + ], + }, + "name": "selectionType", + "optional": false, + "type": "string", + }, + ], + "regions": [], + "releaseStatus": "stable", +} +`; + exports[`Components definition for pagination matches the snapshot: pagination 1`] = ` { "dashCaseName": "pagination", @@ -44949,6 +45136,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 +56368,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/__tests__/navigation.test.ts b/src/internal/components/listbox/__tests__/navigation.test.ts index de91150d8a1..4ef52acc8bc 100644 --- a/src/internal/components/listbox/__tests__/navigation.test.ts +++ b/src/internal/components/listbox/__tests__/navigation.test.ts @@ -15,6 +15,8 @@ const grouped = flatten([ ]); // [1, header Empty] const endsWithEmptyGroup = flatten([{ value: '1' }, { label: 'Empty', options: [] }]); +// [header Empty, 1] +const startsWithEmptyGroup = flatten([{ label: 'Empty', options: [] }, { value: '1' }]); function setup(options: ReadonlyArray, highlightedIndex: number, interactiveGroups = false) { const moveHighlight = jest.fn(); @@ -23,7 +25,6 @@ function setup(options: ReadonlyArray, highlightedIndex: number, const navigation = getListboxNavigation({ options, highlightedIndex, - highlightedOption: options[highlightedIndex], interactiveGroups, moveHighlight, goHome, @@ -67,9 +68,23 @@ describe('getListboxNavigation', () => { expect(moveHighlight).toHaveBeenCalledWith(-1); }); - test('wraps down only from the last row, even when it is not highlightable', () => { + test('wraps down from the last highlightable option when the list ends with an empty group', () => { const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0); goDown(); + expect(goHome).toHaveBeenCalledTimes(1); + expect(moveHighlight).not.toHaveBeenCalled(); + }); + + test('wraps up from the first highlightable option when the list starts with an empty group', () => { + const { goUp, goEnd, moveHighlight } = setup(startsWithEmptyGroup, 1); + goUp(); + expect(goEnd).toHaveBeenCalledTimes(1); + expect(moveHighlight).not.toHaveBeenCalled(); + }); + + test('moves down to a trailing empty group header when groups are interactive', () => { + const { goDown, goHome, moveHighlight } = setup(endsWithEmptyGroup, 0, true); + goDown(); expect(goHome).not.toHaveBeenCalled(); expect(moveHighlight).toHaveBeenCalledWith(1); }); diff --git a/src/internal/components/listbox/__tests__/use-type-ahead.test.ts b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts index 01f9a14d6b4..854380f1db9 100644 --- a/src/internal/components/listbox/__tests__/use-type-ahead.test.ts +++ b/src/internal/components/listbox/__tests__/use-type-ahead.test.ts @@ -61,4 +61,107 @@ describe('useTypeAhead', () => { expect(highlightOption).not.toHaveBeenCalled(); }); + + test('ignores keys that were already handled by another handler', () => { + const hook = renderHook(useTypeAhead, { initialProps }); + act(() => { + hook.result.current({ key: ' ', defaultPrevented: true } as React.KeyboardEvent); + }); + expect(highlightOption).not.toHaveBeenCalled(); + act(() => { + hook.result.current({ key: 'o' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenCalledWith(options[0]); + }); + + test('cycles from a highlighted option that is an equal copy rather than the same object', () => { + const hook = renderHook(useTypeAhead, { + initialProps: { ...initialProps, highlightedOption: { label: 'option 1', value: 'option1' } }, + }); + act(() => { + hook.result.current({ key: 'o' } as React.KeyboardEvent); + }); + act(() => { + hook.result.current({ key: 'o' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenLastCalledWith(options[1]); + }); + + describe('with groups', () => { + const groupedOptions: DropdownOption[] = [ + { type: 'parent', option: { label: 'Compute', options: [] } }, + { type: 'child', option: { label: 'EC2', value: 'ec2' } }, + { type: 'child', option: { label: 'Lambda', value: 'lambda' } }, + { type: 'child', option: { label: 'Fargate', value: 'fargate' } }, + { type: 'parent', option: { label: 'Storage', options: [] } }, + { type: 'child', option: { label: 'S3', value: 's3' } }, + ]; + const renderGroupedHook = (highlightedOption: DropdownOption['option'] | null = null) => + renderHook(useTypeAhead, { + initialProps: { ...initialProps, options: groupedOptions, highlightedOption, interactiveGroups: true }, + }); + + test('with interactive groups, highlights the matching child rather than its non-matching group', () => { + const hook = renderGroupedHook(); + act(() => { + hook.result.current({ key: 'e' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenCalledTimes(1); + expect(highlightOption).toHaveBeenCalledWith(groupedOptions[1]); + }); + + test('with interactive groups, highlights a group whose own label matches', () => { + const hook = renderGroupedHook(); + act(() => { + hook.result.current({ key: 'c' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenCalledWith(groupedOptions[0]); + }); + + test('with interactive groups, does not cycle into children of a matching group when their own labels do not match', () => { + const hook = renderGroupedHook(groupedOptions[0].option); + act(() => { + hook.result.current({ key: 'c' } as React.KeyboardEvent); + }); + act(() => { + hook.result.current({ key: 'c' } as React.KeyboardEvent); + }); + // Only the Compute group itself starts with "c", so repeating the key keeps it highlighted. + expect(highlightOption).toHaveBeenCalledTimes(2); + expect(highlightOption).toHaveBeenLastCalledWith(groupedOptions[0]); + }); + + test('with interactive groups, cycles from the highlighted option when its group has the same value', () => { + const sharedValueOptions: DropdownOption[] = [ + { type: 'parent', option: { label: 'Compute', value: 'shared', options: [] } }, + { type: 'child', option: { label: 'Cloud', value: 'shared' } }, + { type: 'child', option: { label: 'Cache', value: 'cache' } }, + ]; + const hook = renderHook(useTypeAhead, { + initialProps: { + ...initialProps, + options: sharedValueOptions, + highlightedOption: sharedValueOptions[1].option, + interactiveGroups: true, + }, + }); + act(() => { + hook.result.current({ key: 'c' } as React.KeyboardEvent); + }); + act(() => { + hook.result.current({ key: 'c' } as React.KeyboardEvent); + }); + expect(highlightOption).toHaveBeenLastCalledWith(sharedValueOptions[2]); + }); + + test('without interactive groups, does not highlight children of a group whose label matches', () => { + const hook = renderHook(useTypeAhead, { + initialProps: { ...initialProps, options: groupedOptions }, + }); + act(() => { + hook.result.current({ key: 'c' } as React.KeyboardEvent); + }); + expect(highlightOption).not.toHaveBeenCalled(); + }); + }); }); 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..de4298b609e 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; @@ -94,7 +96,7 @@ const toSelectOptionItem = (props: { index: props.parentProps?.index, virtualIndex: props.parentProps?.virtualIndex, option: props.parentProps?.option, - disabled: props.disabled, + disabled: props.parentProps.disabled, }) : null, }; @@ -135,7 +137,7 @@ const toMultiselectOptionItem = (props: { index: props.parentProps?.index, virtualIndex: props.parentProps?.virtualIndex, option: props.parentProps?.option, - disabled: props.disabled, + disabled: props.parentProps.disabled, highlighted: props.parentProps?.highlighted ?? false, indeterminate: props.parentProps?.indeterminate ?? false, selected: props.parentProps?.selected ?? false, @@ -163,6 +165,7 @@ const Item = ( ariaSetsize, highlightType, withScrollbar, + hideTopDivider, sticky, renderOption, parentProps, @@ -270,10 +273,11 @@ const Item = ( /> ); - const disabledReasonElement = !renderResult && isDisabledWithReason && ( + const disabledReasonElement = isDisabledWithReason && ( <> + {/* Stays mounted with custom content, so aria-describedby always points to an element. */} {descriptionEl} - {highlighted && canShowTooltip && ( + {!renderResult && highlighted && canShowTooltip && ( internalRef.current} @@ -311,6 +315,7 @@ const Item = ( value={option.option.value} afterHeader={option.afterHeader} withScrollbar={withScrollbar} + hideTopDivider={hideTopDivider} sticky={sticky} {...baseProps} > @@ -352,6 +357,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..e6bdce220db --- /dev/null +++ b/src/internal/components/listbox/use-listbox.ts @@ -0,0 +1,256 @@ +// 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, + 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/listbox/use-type-ahead.ts b/src/internal/components/listbox/use-type-ahead.ts index 8571cc7c9ad..0d69903a66d 100644 --- a/src/internal/components/listbox/use-type-ahead.ts +++ b/src/internal/components/listbox/use-type-ahead.ts @@ -24,8 +24,10 @@ function findMatchingOption( interactiveGroups?: boolean ) { const interactivityCheck = interactiveGroups ? isGroupInteractive : isInteractive; + // Match each option on its own content. Filtering the list as a whole would also return the group headers + // of matching children (and the children of matching groups), which become targets with interactive groups. const filter = (searchText: string) => - filterOptions(options, searchText, true).filter(option => interactivityCheck(option)); + options.filter(option => interactivityCheck(option) && filterOptions([option], searchText, true).length > 0); const matchingOptions = filter(searchText); if (matchingOptions.length === 1) { @@ -37,12 +39,14 @@ function findMatchingOption( const matchingOptions = filter(searchText[0]); if (matchingOptions.length > 0) { - //handling OptionGroups when interactiveGroups is set - let active: number = isGroup(currentHighlight) - ? matchingOptions.map(({ option }) => option).indexOf(currentHighlight) - : matchingOptions - .map(({ option }) => (option as OptionDefinition).value) - .indexOf((currentHighlight as OptionDefinition).value); + // Find the highlight by identity first: with interactive groups, a group can share its option's value. + // Fall back to the value for a highlight that isn't one of the given objects, like Select's selectedOption. + let active = matchingOptions.findIndex(({ option }) => option === currentHighlight); + if (active === -1 && !isGroup(currentHighlight)) { + active = matchingOptions + .map(({ option }) => (option as OptionDefinition).value) + .indexOf((currentHighlight as OptionDefinition).value); + } // Pick the next thing (if something with this prefix wasn't selected // we'll end up with the first option) @@ -78,7 +82,8 @@ export function useTypeAhead({ const { key } = event; - if (!key || key.length !== 1) { + // Skip keys already handled elsewhere, e.g. Space selecting the highlighted option or opening the dropdown. + if (event.defaultPrevented || !key || key.length !== 1) { return; } diff --git a/src/internal/components/listbox/utils/navigation.ts b/src/internal/components/listbox/utils/navigation.ts index 3351188104e..bed51892a82 100644 --- a/src/internal/components/listbox/utils/navigation.ts +++ b/src/internal/components/listbox/utils/navigation.ts @@ -1,11 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { DropdownOption } from '../../option/interfaces'; +import { isHighlightable } from './option-props'; interface ListboxNavigationProps { options: ReadonlyArray; highlightedIndex: number; - highlightedOption: DropdownOption | undefined; interactiveGroups: boolean; moveHighlight: (direction: -1 | 1) => void; goHome: () => void; @@ -13,33 +13,33 @@ interface ListboxNavigationProps { } /** - * Arrow key handlers that wrap around the list: up from the first option goes to the end, down from the last row - * goes to the start. Without interactive groups, the first child of a leading group counts as the first option. + * Arrow key handlers that wrap around the list: up from the first highlightable option goes to the end, down from + * the last highlightable option goes to the start. Group headers aren't highlightable without interactive groups, + * and an empty group leaves a header row at either end of the list, so the check can't use the first and last rows. */ export const getListboxNavigation = ({ options, highlightedIndex, - highlightedOption, interactiveGroups, moveHighlight, goHome, goEnd, -}: ListboxNavigationProps) => ({ - goUp: () => { - if ( - (!interactiveGroups && highlightedOption?.type === 'child' && highlightedIndex === 1) || - highlightedIndex === 0 - ) { - goEnd(); - return; - } - moveHighlight(-1); - }, - goDown: () => { - if (highlightedIndex === options.length - 1) { - goHome(); - return; - } - moveHighlight(1); - }, -}); +}: ListboxNavigationProps) => { + const highlightable = (option: DropdownOption) => isHighlightable(option, interactiveGroups); + return { + goUp: () => { + if (highlightedIndex === options.findIndex(highlightable)) { + goEnd(); + return; + } + moveHighlight(-1); + }, + goDown: () => { + if (!options.slice(highlightedIndex + 1).some(highlightable)) { + goHome(); + return; + } + moveHighlight(1); + }, + }; +}; diff --git a/src/internal/components/options-list/__tests__/use-keyboard.test.ts b/src/internal/components/options-list/__tests__/use-keyboard.test.ts index 96b36dd3f4e..43da0afa438 100644 --- a/src/internal/components/options-list/__tests__/use-keyboard.test.ts +++ b/src/internal/components/options-list/__tests__/use-keyboard.test.ts @@ -135,3 +135,36 @@ describe('useMenuKeyboard', () => { expect(menuKeys.space.preventDefault).toHaveBeenCalled(); }); }); + +describe('useMenuKeyboard native Home and End', () => { + const homeEndKeys = (): any[] => + [36, 35].map(keyCode => ({ detail: { keyCode }, preventDefault: jest.fn(), stopPropagation: jest.fn() })); + + test('prevents the native Home and End action when preventNativeHomeEnd is enabled', () => { + const goHome = jest.fn(); + const goEnd = jest.fn(); + const hook = renderHook(useMenuKeyboard, { + initialProps: { ...menuInitialProps, goHome, goEnd, preventNativeHomeEnd: true }, + }); + const [home, end] = homeEndKeys(); + act(() => hook.result.current(home)); + act(() => hook.result.current(end)); + expect(goHome).toHaveBeenCalled(); + expect(goEnd).toHaveBeenCalled(); + expect(home.preventDefault).toHaveBeenCalled(); + expect(end.preventDefault).toHaveBeenCalled(); + }); + + test('keeps the native Home and End action by default, for example to move the cursor in a filter input', () => { + const goHome = jest.fn(); + const goEnd = jest.fn(); + const hook = renderHook(useMenuKeyboard, { initialProps: { ...menuInitialProps, goHome, goEnd } }); + const [home, end] = homeEndKeys(); + act(() => hook.result.current(home)); + act(() => hook.result.current(end)); + expect(goHome).toHaveBeenCalled(); + expect(goEnd).toHaveBeenCalled(); + expect(home.preventDefault).not.toHaveBeenCalled(); + expect(end.preventDefault).not.toHaveBeenCalled(); + }); +}); 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..1e0829d1d4b 100644 --- a/src/internal/components/options-list/utils/use-keyboard.ts +++ b/src/internal/components/options-list/utils/use-keyboard.ts @@ -13,8 +13,12 @@ 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; + // Prevents the native Home and End action, which would also scroll the page or a scroll container. + // Leave it off when focus is in a text input, where Home and End move the cursor. + preventNativeHomeEnd?: boolean; }): CancelableEventHandler; } @@ -26,6 +30,7 @@ export const useMenuKeyboard: UseMenuKeyboard = ({ goEnd, closeDropdown, preventNativeSpace = false, + preventNativeHomeEnd = false, }) => { return (event: CustomEvent) => { switch (event.detail.keyCode) { @@ -38,14 +43,22 @@ export const useMenuKeyboard: UseMenuKeyboard = ({ goDown(); break; case HOME: + if (preventNativeHomeEnd) { + event.preventDefault(); + } goHome(); break; case END: + if (preventNativeHomeEnd) { + event.preventDefault(); + } 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/multiselect/__integ__/multiselect.test.ts b/src/multiselect/__integ__/multiselect.test.ts index af60db983e1..fa99f01442f 100644 --- a/src/multiselect/__integ__/multiselect.test.ts +++ b/src/multiselect/__integ__/multiselect.test.ts @@ -73,7 +73,7 @@ const createSetupTest = (wrapper: MultiselectWrapper) => (testFn: (page: Multise await page.clickSelect(); await page.keys(['o']); - await expect(page.getHighlightedOptionLabel()).resolves.toBe('First category'); + await expect(page.getHighlightedOptionLabel()).resolves.toBe('option1'); }) ); @@ -83,7 +83,7 @@ const createSetupTest = (wrapper: MultiselectWrapper) => (testFn: (page: Multise await page.clickSelect(); await page.keys(['o', 'o']); - await expect(page.getHighlightedOptionLabel()).resolves.toBe('option1'); + await expect(page.getHighlightedOptionLabel()).resolves.toBe('option3'); }) ); @@ -91,9 +91,9 @@ const createSetupTest = (wrapper: MultiselectWrapper) => (testFn: (page: Multise 'highlights the first match on search on triple (length of matches + 1) keypress with dropdown open', setupTest(async page => { await page.clickSelect(); - await page.keys(['o', 'o', 'o', 'o', 'o', 'o', 'o']); + await page.keys(['o', 'o', 'o', 'o', 'o']); - await expect(page.getHighlightedOptionLabel()).resolves.toBe('First category'); + await expect(page.getHighlightedOptionLabel()).resolves.toBe('option1'); }) ); diff --git a/src/multiselect/__tests__/home-end.test.tsx b/src/multiselect/__tests__/home-end.test.tsx new file mode 100644 index 00000000000..4cc5573a7b9 --- /dev/null +++ b/src/multiselect/__tests__/home-end.test.tsx @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import Multiselect, { MultiselectProps } from '../../../lib/components/multiselect'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +const HOME = 36; +const END = 35; + +const options = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, +]; + +function renderComponent(props: Partial = {}) { + const { container } = render( {}} options={options} {...props} />); + const wrapper = createWrapper(container).findMultiselect()!; + wrapper.openDropdown(); + return wrapper; +} + +const highlightedLabel = (wrapper: ReturnType) => + wrapper.findDropdown().findHighlightedOption()?.findLabel().getElement().textContent; + +describe('Multiselect Home and End', () => { + test('without filtering, moves the highlight and prevents the native page scroll', () => { + const wrapper = renderComponent(); + const list = wrapper.findDropdown().findOptionsContainer()!.getElement(); + + expect(fireEvent.keyDown(list, { keyCode: END })).toBe(false); + expect(highlightedLabel(wrapper)).toBe('Cherry'); + + expect(fireEvent.keyDown(list, { keyCode: HOME })).toBe(false); + expect(highlightedLabel(wrapper)).toBe('Apple'); + }); + + test('with filtering, moves the highlight and keeps the native cursor movement in the filter input', () => { + const wrapper = renderComponent({ filteringType: 'auto' }); + const input = wrapper.findFilteringInput()!.findNativeInput().getElement(); + + expect(fireEvent.keyDown(input, { keyCode: END })).toBe(true); + expect(highlightedLabel(wrapper)).toBe('Cherry'); + + expect(fireEvent.keyDown(input, { keyCode: HOME })).toBe(true); + expect(highlightedLabel(wrapper)).toBe('Apple'); + }); +}); diff --git a/src/multiselect/__tests__/native-search.test.tsx b/src/multiselect/__tests__/native-search.test.tsx new file mode 100644 index 00000000000..876edb11835 --- /dev/null +++ b/src/multiselect/__tests__/native-search.test.tsx @@ -0,0 +1,97 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import Multiselect, { MultiselectProps } from '../../../lib/components/multiselect'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +const options: MultiselectProps.Options = [ + { + label: 'Compute', + value: 'compute', + options: [ + { label: 'EC2', value: 'ec2' }, + { label: 'Lambda', value: 'lambda' }, + { label: 'Fargate', value: 'fargate' }, + ], + }, + { label: 'Glacier', value: 'glacier' }, +]; + +function renderMultiselect(props: Partial = {}) { + const onChange = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findMultiselect()!; + wrapper.openDropdown(); + const optionsContainer = wrapper.findDropdown().findOptionsContainer()!; + const type = (key: string) => optionsContainer.keydown({ key }); + const getHighlightedLabel = () => + wrapper.findDropdown().findHighlightedOption()?.findLabel().getElement().textContent; + return { wrapper, onChange, optionsContainer, type, getHighlightedLabel }; +} + +describe('Multiselect native search', () => { + test('highlights the matching option inside a group rather than the group', () => { + const { type, getHighlightedLabel } = renderMultiselect(); + type('e'); + expect(getHighlightedLabel()).toBe('EC2'); + }); + + test('Enter after typing selects only the matching option inside a group', () => { + const { optionsContainer, type, onChange } = renderMultiselect(); + type('e'); + optionsContainer.keydown(KeyCode.enter); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].detail.selectedOptions).toEqual([{ label: 'EC2', value: 'ec2' }]); + }); + + test('highlights a group when its own label matches', () => { + const { type, getHighlightedLabel } = renderMultiselect(); + type('c'); + expect(getHighlightedLabel()).toBe('Compute'); + }); + + test('repeating a character does not cycle into group children that do not match', () => { + const { type, getHighlightedLabel } = renderMultiselect(); + type('c'); + type('c'); + expect(getHighlightedLabel()).toBe('Compute'); + }); + + test('repeating a character cycles to the next match when a group and its option share a value', () => { + const { type, getHighlightedLabel } = renderMultiselect({ + options: [ + { + label: 'Compute', + value: 'shared', + options: [ + { label: 'Cloud', value: 'shared' }, + { label: 'Cache', value: 'cache' }, + ], + }, + ], + }); + type('c'); + expect(getHighlightedLabel()).toBe('Compute'); + type('c'); + expect(getHighlightedLabel()).toBe('Cloud'); + type('c'); + expect(getHighlightedLabel()).toBe('Cache'); + }); + + test('typing right after selecting with Space moves the highlight to the matching option', () => { + const { optionsContainer, type, onChange, getHighlightedLabel } = renderMultiselect(); + // Highlight with the arrow keys so the type-ahead buffer is empty before Space is pressed. + optionsContainer.keydown(KeyCode.down); + expect(getHighlightedLabel()).toBe('EC2'); + optionsContainer.keydown({ keyCode: KeyCode.space, key: ' ' }); + expect(onChange.mock.calls[0][0].detail.selectedOptions).toEqual([{ label: 'EC2', value: 'ec2' }]); + type('g'); + expect(getHighlightedLabel()).toBe('Glacier'); + }); +}); diff --git a/src/multiselect/__tests__/render-option.test.tsx b/src/multiselect/__tests__/render-option.test.tsx index 7ba58a96eff..b697147289a 100644 --- a/src/multiselect/__tests__/render-option.test.tsx +++ b/src/multiselect/__tests__/render-option.test.tsx @@ -184,6 +184,47 @@ describe('Multiselect renderOption', () => { ); }); + test('parent reflects the disabled state of the group, not of the option', () => { + const renderOption = jest.fn(() =>
Custom
); + const wrapper = renderMultiselect({ + options: [ + { + label: 'Enabled group', + value: 'g1', + options: [ + { label: 'Enabled child', value: 'c1' }, + { label: 'Disabled child', value: 'c2', disabled: true }, + ], + }, + { label: 'Disabled group', value: 'g2', disabled: true, options: [{ label: 'Child', value: 'c3' }] }, + ], + renderOption, + }); + wrapper.openDropdown(); + const items = renderOption.mock.calls.map(([{ item }]: any) => item); + const parentOf = (value: string) => items.find(item => item.type === 'item' && item.option.value === value).parent; + + expect(parentOf('c1').disabled).toBe(false); + expect(parentOf('c2').disabled).toBe(false); + expect(parentOf('c3').disabled).toBe(true); + }); + + test('keeps the disabled reason description for screen readers with custom content', () => { + const renderOption = jest.fn(() =>
Custom
); + const wrapper = renderMultiselect({ + options: [ + { label: 'Enabled', value: '1' }, + { label: 'Disabled', value: '2', disabled: true, disabledReason: 'Not available in this Region' }, + ], + renderOption, + }); + wrapper.openDropdown(); + const option = wrapper.findDropdown().findOptionByValue('2')!.getElement().closest('[role=option]')!; + const describedBy = option.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy!)).toHaveTextContent('Not available in this Region'); + }); + test('allows selection with custom rendered options', () => { const onChange = jest.fn(); const renderOption = jest.fn(props =>
{props.item.option.value}
); 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..126e392246a --- /dev/null +++ b/src/option-list/__tests__/group-state.test.tsx @@ -0,0 +1,130 @@ +// 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 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('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..67f20fd0772 --- /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 = [ + { label: 'Group 1', options: [{ value: '1', label: 'Option 1' }] }, + { 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..5def092b0fe --- /dev/null +++ b/src/option-list/__tests__/option-list-a11y.test.tsx @@ -0,0 +1,59 @@ +// 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); + }); + }); +}); 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..d94bbb3458b --- /dev/null +++ b/src/option-list/__tests__/option-list.test.tsx @@ -0,0 +1,253 @@ +// 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('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..6cc72a34b2c --- /dev/null +++ b/src/option-list/interfaces.ts @@ -0,0 +1,156 @@ +// 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 inline as a list. + * You can group options into one level of option groups. + * + * #### Option + * - `value` (string) - The returned value of the option when selected. Must be unique. + * - `label` (string) - (Optional) Option text displayed to the user. + * - `lang` (string) - (Optional) The language of the option, provided as a BCP 47 language tag. + * - `description` (string) - (Optional) Further information about the option that appears below the label. + * - `disabled` (boolean) - (Optional) Determines whether the option is disabled. Users can't select or deselect a disabled option. + * - `disabledReason` (ReactNode) - (Optional) Displays a tooltip near the item when disabled. Use to provide additional context. + * - `labelTag` (string) - (Optional) A label tag that provides additional guidance, shown next to the label. + * - `tags` [string[]] - (Optional) A list of tags giving further guidance about the option. + * - `icon` (ReactNode) - (Optional) An icon displayed in the option, such as an [icon component](/components/icon/). + * + * #### OptionGroup + * - `value` (string) - (Optional) Used to locate option group in test utils. + * - `label` (string) - Option group text displayed to the user. + * - `disabled` (boolean) - (Optional) Determines whether the option group is disabled. Disables every option in the group. + * - `options` (Option[]) - The options in the group. + * + * In multi selection, choosing a group header selects or deselects all its enabled options. + * + * Options are the selection target. Do not render interactive elements such as buttons or links inside them. + */ + 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). + * + * 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 Omit { + value?: string; + options: ReadonlyArray