diff --git a/package.json b/package.json index 9af50e9da5..d90317ae47 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ { "path": "lib/components/internal/widget-exports.js", "brotli": false, - "limit": "1310 kB", + "limit": "1350 kB", "ignore": "react-dom" } ], diff --git a/pages/button-dropdown/filtering.page.tsx b/pages/button-dropdown/filtering.page.tsx new file mode 100644 index 0000000000..94c9a5b4ab --- /dev/null +++ b/pages/button-dropdown/filtering.page.tsx @@ -0,0 +1,319 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Checkbox } from '~components'; +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; + +import styles from './styles.scss'; + +const flatItems: ButtonDropdownProps['items'] = [ + { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, + { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, + { id: 'paste', text: 'Paste', labelTag: 'Ctrl+V' }, + { id: 'undo', text: 'Undo', labelTag: 'Ctrl+Z' }, + { id: 'redo', text: 'Redo', labelTag: 'Ctrl+Y' }, + { id: 'select-all', text: 'Select all', labelTag: 'Ctrl+A' }, + { id: 'find', text: 'Find and replace', secondaryText: 'Search within document', labelTag: 'Ctrl+H' }, + { id: 'preferences', text: 'Preferences', secondaryText: 'Configure editor settings' }, +]; + +const groupedItems: ButtonDropdownProps['items'] = [ + { + text: 'File', + items: [ + { id: 'new', text: 'New file' }, + { id: 'open', text: 'Open file', secondaryText: 'Open an existing file' }, + { id: 'save', text: 'Save', labelTag: 'Ctrl+S' }, + { id: 'save-as', text: 'Save as...', labelTag: 'Ctrl+Shift+S' }, + { id: 'export', text: 'Export', secondaryText: 'Export to different format' }, + ], + }, + { + text: 'Edit', + items: [ + { id: 'cut', text: 'Cut', labelTag: 'Ctrl+X' }, + { id: 'copy', text: 'Copy', labelTag: 'Ctrl+C' }, + { id: 'paste', text: 'Paste', labelTag: 'Ctrl+V' }, + { id: 'find', text: 'Find and replace', labelTag: 'Ctrl+H' }, + ], + }, + { + text: 'View', + items: [ + { id: 'zoom-in', text: 'Zoom in', labelTag: 'Ctrl++' }, + { id: 'zoom-out', text: 'Zoom out', labelTag: 'Ctrl+-' }, + { id: 'fullscreen', text: 'Fullscreen', labelTag: 'F11' }, + { id: 'sidebar', text: 'Toggle sidebar' }, + ], + }, +]; + +const expandableGroupedItems: ButtonDropdownProps['items'] = [ + { id: 'connect', text: 'Connect', secondaryText: 'Connect to instance' }, + { id: 'password', text: 'Get password' }, + { + id: 'instance-state', + text: 'Instance state', + items: [ + { id: 'start', text: 'Start' }, + { id: 'stop', text: 'Stop', disabled: true, disabledReason: 'Instance is already stopped' }, + { id: 'hibernate', text: 'Hibernate' }, + { id: 'reboot', text: 'Reboot' }, + { id: 'terminate', text: 'Terminate', secondaryText: 'Permanently delete instance' }, + ], + }, + { + id: 'networking', + text: 'Networking', + items: [ + { id: 'attach-eni', text: 'Attach network interface' }, + { id: 'detach-eni', text: 'Detach network interface' }, + { id: 'manage-ip', text: 'Manage IP addresses' }, + { id: 'elastic-ip', text: 'Associate Elastic IP address' }, + ], + }, + { + id: 'security', + text: 'Security', + items: [ + { id: 'change-sg', text: 'Change security groups' }, + { id: 'modify-iam', text: 'Modify IAM role' }, + ], + }, +]; + +const expandableWithRegularGroups: ButtonDropdownProps['items'] = [ + { id: 'connect', text: 'Connect', secondaryText: 'Connect to instance' }, + { + id: 'instance-state', + text: 'Instance state', + items: [ + { id: 'start', text: 'Start' }, + { id: 'stop', text: 'Stop', disabled: true, disabledReason: 'Instance is already stopped' }, + { id: 'reboot', text: 'Reboot' }, + ], + }, + { + id: 'monitoring', + text: 'Monitoring and troubleshooting', + items: [ + { + text: 'CloudWatch', + items: [ + { id: 'detailed-monitoring', text: 'Enable detailed monitoring' }, + { id: 'view-metrics', text: 'View CloudWatch metrics' }, + ], + }, + { + text: 'Diagnostics', + items: [ + { id: 'system-log', text: 'Get system log' }, + { id: 'screenshot', text: 'Get instance screenshot' }, + ], + }, + ], + }, + { + id: 'networking', + text: 'Networking', + items: [ + { + text: 'Interfaces', + items: [ + { id: 'attach-eni', text: 'Attach network interface' }, + { id: 'detach-eni', text: 'Detach network interface' }, + ], + }, + { + text: 'IP addresses', + items: [ + { id: 'manage-ip', text: 'Manage IP addresses' }, + { id: 'elastic-ip', text: 'Associate Elastic IP address' }, + ], + }, + ], + }, +]; + +const withDisabledItems: ButtonDropdownProps['items'] = [ + { id: 'create', text: 'Create resource' }, + { id: 'update', text: 'Update resource' }, + { id: 'delete', text: 'Delete resource', disabled: true, disabledReason: 'Resource is protected' }, + { id: 'clone', text: 'Clone resource' }, + { id: 'archive', text: 'Archive resource', disabled: true }, +]; + +const withCheckboxItems: ButtonDropdownProps['items'] = [ + { id: 'action-1', text: 'Run build' }, + { id: 'action-2', text: 'Deploy' }, + { itemType: 'checkbox', id: 'notifications', text: 'Notifications', checked: true }, + { itemType: 'checkbox', id: 'auto-deploy', text: 'Auto-deploy on commit', checked: false }, + { itemType: 'checkbox', id: 'verbose-logs', text: 'Verbose logging', checked: true }, +]; + +export default function ButtonDropdownFilteringPage() { + const [expandToViewport, setExpandToViewport] = useState(false); + + const [checkboxItems, setCheckboxItems] = useState(withCheckboxItems); + const filteringResultsText = (matches: number, total: number) => `${matches} out of ${total} matches`; + const onItemClick = (event: CustomEvent) => console.log(event.detail); + + return ( + + + setExpandToViewport(event.detail.checked)} + data-testid="expand-to-viewport" + > + Expand to viewport + + +
+

Flat items

+ + Actions + +
+ +
+

Grouped items (non-expandable)

+ + Menu + +
+ +
+

Expandable groups (collapse to flat when searching)

+ + Instance actions + +
+ +
+

Expandable groups containing regular (nested) groups

+ + Instance actions + +
+ +
+

With disabled items and disabled reasons

+ + Resource actions + +
+ +
+

With checkbox items

+ { + onItemClick(event); + if (event.detail.checked !== undefined) { + setCheckboxItems(prev => + prev.map(item => (item.id === event.detail.id ? { ...item, checked: event.detail.checked! } : item)) + ); + } + }} + > + Pipeline + +
+ +
+

Custom empty state

+ No actions match your search. Try a different keyword.} + expandToViewport={expandToViewport} + filteringResultsText={filteringResultsText} + onItemClick={onItemClick} + > + Actions (custom empty) + +
+ +
+

Split button (with main action) and filtering

+ void 0 }} + filteringType="auto" + filteringPlaceholder="Search instance actions" + filteringAriaLabel="Filter instance actions" + ariaLabel="Instance actions" + expandToViewport={expandToViewport} + filteringResultsText={filteringResultsText} + onItemClick={onItemClick} + /> +
+
+
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 3a778ff8b2..a6b649ff84 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6114,12 +6114,88 @@ because fixed positioning results in a slight, visible lag when scrolling comple "optional": true, "type": "boolean", }, + { + "description": "Adds an \`aria-label\` to the filtering input. Only relevant when filtering is enabled.", + "name": "filteringAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds an \`aria-label\` to the clear button inside the filtering input. Only relevant when filtering is enabled.", + "i18nTag": true, + "name": "filteringClearAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the placeholder to display in the filtering input. Only relevant when filtering is enabled.", + "name": "filteringPlaceholder", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the text to display with the number of matches at the bottom of the dropdown menu while filtering.", + "inlineType": { + "name": "(matchesCount: number, totalCount: number) => string", + "parameters": [ + { + "name": "matchesCount", + "type": "number", + }, + { + "name": "totalCount", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "filteringResultsText", + "optional": true, + "type": "((matchesCount: number, totalCount: number) => string)", + }, + { + "defaultValue": "'none'", + "description": "Enables filtering of the dropdown items. + +When set to \`auto\`, a search input is rendered inside the dropdown and the items are filtered as the user +types. Items are matched client-side using a case-insensitive substring match against their \`text\`, +\`secondaryText\`, and \`labelTag\`.", + "inlineType": { + "name": "ButtonDropdownProps.FilteringType", + "type": "union", + "values": [ + "auto", + "none", + ], + }, + "name": "filteringType", + "optional": true, + "type": "string", + }, { "description": "Sets the button width to be 100% of the parent container width. Button content is centered.", "name": "fullWidth", "optional": true, "type": "boolean", }, + { + "description": "An object containing all the necessary localized strings required by the component.", + "inlineType": { + "name": "ButtonDropdownProps.I18nStrings", + "properties": [ + { + "name": "filteringItemAriaDescription", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "ButtonDropdownProps.I18nStrings", + }, { "description": "Specifies alternate text for a custom icon, for use with \`iconUrl\`. Applies to the \`icon\` and \`inline-icon\` variants only.", "name": "iconAlt", @@ -6735,6 +6811,7 @@ The item inside the props has a different shape depending on its type: - \`highlighted\` (boolean) - Whether the item is currently highlighted. - \`disabled\` (boolean) - Whether the item is disabled. - \`parent\` (GroupRenderItem | null) - The parent group item, if any. +- \`filterText\` (string) - The current value of the filtering input, when filtering is enabled. ### checkbox @@ -6745,6 +6822,7 @@ The item inside the props has a different shape depending on its type: - \`highlighted\` (boolean) - Whether the item is currently highlighted. - \`checked\` (boolean) - Controls the state of the checkbox item. - \`parent\` (GroupRenderItem | null) - The parent group item, if any. +- \`filterText\` (string) - The current value of the filtering input, when filtering is enabled. ### group @@ -6755,6 +6833,7 @@ The item inside the props has a different shape depending on its type: - \`highlighted\` (boolean) - Whether the item is currently highlighted. - \`expanded\` (boolean) - Whether the group is expanded. - \`expandDirection\` ('vertical' | 'horizontal') - The direction in which the group expands. +- \`filterText\` (string) - The current value of the filtering input, when filtering is enabled. When providing a custom \`renderItem\` implementation, it fully replaces the default visual rendering and content for that item. The component still manages focus, keyboard interactions, and selection state, but it no longer applies its default item layout or typography. @@ -6765,7 +6844,7 @@ When returning \`null\`, the default styling will be applied.", "parameters": [ { "name": "props", - "type": "{ item: ButtonDropdownProps.RenderItem; }", + "type": "{ item: ButtonDropdownProps.RenderItem; filterText?: string | undefined; }", }, ], "returnType": "React.ReactNode", @@ -6812,6 +6891,11 @@ If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.", "isDefault": false, "name": "iconSvg", }, + { + "description": "Displayed when filtering is enabled and there are no matches for the filtering input.", + "isDefault": false, + "name": "noMatch", + }, ], "releaseStatus": "stable", } @@ -34686,6 +34770,36 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. +Returns null if there is no open dropdown or filtering is not enabled. + +This utility does not open the dropdown. To find the filtering input, call \`openDropdown()\` first.", + "name": "findFilteringInput", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "InputWrapper", + }, + }, + { + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is +entered, this contains content rendered by filteringResultsText if there are matching items and the \`noMatch\` +content if there are none. Returns null if there is no open dropdown or the footer is not displayed. + +This utility does not open the dropdown. To find the footer region, call \`openDropdown()\` first.", + "name": "findFooterRegion", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the highlighted item in the open dropdown. Returns null if there is no open dropdown. @@ -34837,6 +34951,108 @@ Supported options: ], "name": "ButtonDropdownWrapper", }, + { + "methods": [ + { + "inheritedFrom": { + "name": "BaseInputWrapper.blur", + }, + "name": "blur", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { + "name": "findClearButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "inheritedFrom": { + "name": "BaseInputWrapper.findNativeInput", + }, + "name": "findNativeInput", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLInputElement", + }, + ], + }, + }, + { + "inheritedFrom": { + "name": "BaseInputWrapper.focus", + }, + "name": "focus", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { + "description": "Gets the value of the component. + +Returns the current value of the input.", + "inheritedFrom": { + "name": "BaseInputWrapper.getInputValue", + }, + "name": "getInputValue", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "string", + }, + }, + { + "inheritedFrom": { + "name": "BaseInputWrapper.isDisabled", + }, + "name": "isDisabled", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "boolean", + }, + }, + { + "description": "Sets the value of the component and calls the \`onChange\` handler", + "inheritedFrom": { + "name": "BaseInputWrapper.setInputValue", + }, + "name": "setInputValue", + "parameters": [ + { + "description": "The value the input is set to.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + ], + "name": "InputWrapper", + }, { "methods": [ { @@ -37548,108 +37764,6 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ ], "name": "TextFilterWrapper", }, - { - "methods": [ - { - "inheritedFrom": { - "name": "BaseInputWrapper.blur", - }, - "name": "blur", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "void", - }, - }, - { - "name": "findClearButton", - "parameters": [], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], - }, - }, - { - "inheritedFrom": { - "name": "BaseInputWrapper.findNativeInput", - }, - "name": "findNativeInput", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLInputElement", - }, - ], - }, - }, - { - "inheritedFrom": { - "name": "BaseInputWrapper.focus", - }, - "name": "focus", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "void", - }, - }, - { - "description": "Gets the value of the component. - -Returns the current value of the input.", - "inheritedFrom": { - "name": "BaseInputWrapper.getInputValue", - }, - "name": "getInputValue", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "string", - }, - }, - { - "inheritedFrom": { - "name": "BaseInputWrapper.isDisabled", - }, - "name": "isDisabled", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "boolean", - }, - }, - { - "description": "Sets the value of the component and calls the \`onChange\` handler", - "inheritedFrom": { - "name": "BaseInputWrapper.setInputValue", - }, - "name": "setInputValue", - "parameters": [ - { - "description": "The value the input is set to.", - "flags": { - "isOptional": false, - }, - "name": "value", - "typeName": "string", - }, - ], - "returnType": { - "isNullable": false, - "name": "void", - }, - }, - ], - "name": "InputWrapper", - }, { "methods": [ { @@ -45324,6 +45438,42 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop ], }, }, + { + "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. +Returns null if there is no open dropdown or filtering is not enabled. + +This utility does not open the dropdown. To find the filtering input, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFilteringInput", + }, + "name": "findFilteringInput", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "InputWrapper", + }, + }, + { + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is +entered, this contains content rendered by filteringResultsText if there are matching items and the \`noMatch\` +content if there are none. Returns null if there is no open dropdown or the footer is not displayed. + +This utility does not open the dropdown. To find the footer region, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFooterRegion", + }, + "name": "findFooterRegion", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Finds the highlighted item in the open dropdown. Returns null if there is no open dropdown. @@ -46712,6 +46862,31 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. +Returns null if there is no open dropdown or filtering is not enabled. + +This utility does not open the dropdown. To find the filtering input, call \`openDropdown()\` first.", + "name": "findFilteringInput", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "InputWrapper", + }, + }, + { + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is +entered, this contains content rendered by filteringResultsText if there are matching items and the \`noMatch\` +content if there are none. Returns null if there is no open dropdown or the footer is not displayed. + +This utility does not open the dropdown. To find the footer region, call \`openDropdown()\` first.", + "name": "findFooterRegion", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the highlighted item in the open dropdown. Returns null if there is no open dropdown. @@ -46816,6 +46991,30 @@ Supported options: ], "name": "ButtonDropdownWrapper", }, + { + "methods": [ + { + "name": "findClearButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "inheritedFrom": { + "name": "BaseInputWrapper.findNativeInput", + }, + "name": "findNativeInput", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + ], + "name": "InputWrapper", + }, { "methods": [ { @@ -48783,30 +48982,6 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ ], "name": "TextFilterWrapper", }, - { - "methods": [ - { - "name": "findClearButton", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - { - "inheritedFrom": { - "name": "BaseInputWrapper.findNativeInput", - }, - "name": "findNativeInput", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", - }, - }, - ], - "name": "InputWrapper", - }, { "methods": [ { @@ -54243,6 +54418,37 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop "name": "ElementWrapper", }, }, + { + "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. +Returns null if there is no open dropdown or filtering is not enabled. + +This utility does not open the dropdown. To find the filtering input, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFilteringInput", + }, + "name": "findFilteringInput", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "InputWrapper", + }, + }, + { + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is +entered, this contains content rendered by filteringResultsText if there are matching items and the \`noMatch\` +content if there are none. Returns null if there is no open dropdown or the footer is not displayed. + +This utility does not open the dropdown. To find the footer region, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFooterRegion", + }, + "name": "findFooterRegion", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Finds the highlighted item in the open dropdown. Returns null if there is no open dropdown. diff --git a/src/button-dropdown/__integ__/button-dropdown-filtering.test.ts b/src/button-dropdown/__integ__/button-dropdown-filtering.test.ts new file mode 100644 index 0000000000..e72aa376b8 --- /dev/null +++ b/src/button-dropdown/__integ__/button-dropdown-filtering.test.ts @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import ButtonDropdownPage from '../../__integ__/page-objects/button-dropdown-page'; + +const setupTest = ( + expandToViewport: boolean, + testFn: (page: ButtonDropdownPage) => Promise, + id = 'filtering-flat' +) => { + return useBrowser(async browser => { + const page = new ButtonDropdownPage(id, browser); + await browser.url('#/light/button-dropdown/filtering'); + await page.waitForVisible(page.getTrigger()); + if (expandToViewport) { + await page.click('[data-testid="expand-to-viewport"]'); + } + await testFn(page); + }); +}; + +const getFilterInput = (page: ButtonDropdownPage) => + page.findButtonDropdown().findFilteringInput()!.findNativeInput().toSelector(); + +const getClearButton = (page: ButtonDropdownPage) => + page.findButtonDropdown().findFilteringInput()!.findClearButton().toSelector(); + +describe.each([true, false])('Button dropdown filtering (with expandToViewport=%s)', (expandToViewport: boolean) => { + test( + 'focuses on the input by default', + setupTest(expandToViewport, async page => { + await page.openDropdown(); + const input = getFilterInput(page); + await page.waitForAssertion(async () => expect(await page.isFocused(input)).toBe(true)); + }) + ); + + test( + 'filters when text is typed', + setupTest(expandToViewport, async page => { + await page.openDropdown(); + const input = getFilterInput(page); + const itemsBefore = await page.getAllItemsCount(); + + await page.setValue(input, 'copy'); + + const itemsAfter = await page.getAllItemsCount(); + expect(itemsAfter).toBeLessThan(itemsBefore); + expect(itemsAfter).toBe(1); + }) + ); + + test( + 'tabbing to the clear button and activating it clears the input', + setupTest(expandToViewport, async page => { + await page.openDropdown(); + const input = getFilterInput(page); + await page.setValue(input, 'copy'); + + const clearButton = getClearButton(page); + await page.waitForVisible(clearButton); + + // Tab moves focus from the input to the clear button without closing the dropdown. + await page.keys('Tab'); + await expect(page.isFocused(clearButton)).resolves.toBe(true); + await expect(page.isDropdownOpen()).resolves.toBe(true); + + // Activating the clear button empties the input and keeps the dropdown open. + await page.keys('Enter'); + await expect(page.getValue(input)).resolves.toBe(''); + await expect(page.isDropdownOpen()).resolves.toBe(true); + }) + ); + + test( + 'shift+tabbing from the clear button to the input keeps the input open', + setupTest(expandToViewport, async page => { + await page.openDropdown(); + const input = getFilterInput(page); + await page.setValue(input, 'copy'); + + const clearButton = getClearButton(page); + await page.waitForVisible(clearButton); + + await page.keys('Tab'); + await expect(page.isFocused(clearButton)).resolves.toBe(true); + + // Shift+Tab moves focus back to the input, staying within the dropdown. + await page.keys(['Shift', 'Tab', 'Null']); + await expect(page.isFocused(input)).resolves.toBe(true); + await expect(page.isDropdownOpen()).resolves.toBe(true); + }) + ); + + test( + 'tabbing after clear button closes the dropdown', + setupTest(expandToViewport, async page => { + await page.openDropdown(); + const input = getFilterInput(page); + await page.setValue(input, 'copy'); + + const clearButton = getClearButton(page); + await page.waitForVisible(clearButton); + + await page.keys('Tab'); + await expect(page.isFocused(clearButton)).resolves.toBe(true); + + // Tabbing past the clear button moves focus out of the dropdown, which closes it. + await page.keys('Tab'); + await page.waitForAssertion(async () => expect(await page.isDropdownOpen()).toBe(false)); + }) + ); +}); diff --git a/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx new file mode 100644 index 0000000000..9bb5e30fde --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx @@ -0,0 +1,720 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; + +import ButtonDropdown, { ButtonDropdownProps } from '../../../lib/components/button-dropdown'; +import { scrollElementIntoView } from '../../../lib/components/internal/utils/scrollable-containers'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import ButtonDropdownWrapper from '../../../lib/components/test-utils/dom/button-dropdown'; +import { KeyCode } from '../../internal/keycode'; + +jest.mock('../../../lib/components/internal/utils/scrollable-containers', () => ({ + ...jest.requireActual('../../../lib/components/internal/utils/scrollable-containers'), + scrollElementIntoView: jest.fn(), +})); + +const items: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Cut' }, + { id: 'i2', text: 'Copy' }, + { id: 'i3', text: 'Paste' }, + { id: 'i4', text: 'Undo', secondaryText: 'Revert last action' }, +]; + +const expandableItems: ButtonDropdownProps.Items = [ + { id: 'connect', text: 'Connect' }, + { + id: 'states', + text: 'Instance state', + items: [ + { id: 'start', text: 'Start' }, + { id: 'stop', text: 'Stop' }, + ], + }, +]; + +const nestedExpandableItems: ButtonDropdownProps.Items = [ + { id: 'connect', text: 'Connect' }, + { + id: 'actions', + text: 'Actions', + items: [ + { + id: 'states', + text: 'Instance state', + items: [ + { id: 'start', text: 'Start instance' }, + { id: 'stop', text: 'Stop instance' }, + { id: 'reboot', text: 'Reboot instance' }, + ], + }, + { id: 'terminate', text: 'Terminate' }, + ], + }, +]; + +function renderDropdown(props: Partial = {}) { + const result = render( + + Actions + + ); + const wrapper = createWrapper(result.container).findButtonDropdown()!; + return { ...result, wrapper }; +} + +function getFilterInput(container: HTMLElement): HTMLInputElement | null { + return createWrapper(container).findButtonDropdown()!.findFilteringInput()?.findNativeInput().getElement() ?? null; +} + +function getMenuItems(wrapper: ButtonDropdownWrapper): HTMLElement[] { + return wrapper.findItems().map(item => item.find('[role="menuitem"], [role="menuitemcheckbox"]')!.getElement()); +} + +describe('Button dropdown filtering', () => { + beforeEach(() => { + jest.mocked(scrollElementIntoView).mockClear(); + }); + + describe('Filter input', () => { + test('does not render filter input when filteringType is not set', () => { + const { container, wrapper } = renderDropdown(); + wrapper.openDropdown(); + expect(getFilterInput(container)).toBeNull(); + }); + + test('does not render filter input when filteringType="none"', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'none' }); + wrapper.openDropdown(); + expect(getFilterInput(container)).toBeNull(); + }); + + test('renders filter input when filteringType="auto"', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + const input = getFilterInput(container)!; + expect(input).not.toBeNull(); + expect(input.getAttribute('role')).toBe('combobox'); + expect(input.getAttribute('aria-expanded')).toBe('true'); + }); + + test('filter input has aria-controls pointing to the menu', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + const input = getFilterInput(container)!; + const menuId = input.getAttribute('aria-controls'); + expect(menuId).toBeTruthy(); + const menu = container.querySelector(`#${menuId}`); + expect(menu).not.toBeNull(); + expect(menu!.getAttribute('role')).toBe('menu'); + }); + }); + + describe('No match state', () => { + test('does not render the no match state when there are matching items', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto', noMatch: 'No actions found' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + expect(wrapper.findFooterRegion()).toBeNull(); + }); + + test('renders the provided noMatch content when there are no matches', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto', noMatch: 'No actions found' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('zzz'); + const noMatch = wrapper.findFooterRegion(); + expect(noMatch).not.toBeNull(); + expect(noMatch!.getElement()).toHaveTextContent('No actions found'); + }); + }); + + describe('aria-activedescendant', () => { + test('aria-activedescendant is empty when no item is highlighted', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + const input = getFilterInput(container)!; + expect(input.getAttribute('aria-activedescendant')).toBe(''); + }); + + test('aria-activedescendant updates when navigating with arrow keys', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + const input = getFilterInput(container)!; + + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + + const activedescendant = input.getAttribute('aria-activedescendant'); + expect(activedescendant).toBeTruthy(); + expect(activedescendant).not.toBe(''); + + const highlightedEl = container.querySelector(`#${activedescendant}`); + expect(highlightedEl).not.toBeNull(); + expect(highlightedEl!.getAttribute('role')).toBe('menuitem'); + }); + + test('menu items have id attributes when filtering is active', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + const menuItems = getMenuItems(wrapper); + const itemsWithId = menuItems.filter(el => el.id); + expect(itemsWithId.length).toBe(items.length); + }); + + test('aria-activedescendant references expandable group headers', () => { + const { container, wrapper } = renderDropdown({ + filteringType: 'auto', + items: expandableItems, + expandableGroups: true, + }); + wrapper.openDropdown(); + const input = getFilterInput(container)!; + + const dropdown = wrapper.findOpenDropdown()!; + // Arrow down twice to reach the expandable group header + dropdown.keydown(KeyCode.down); + dropdown.keydown(KeyCode.down); + + const activedescendant = input.getAttribute('aria-activedescendant'); + expect(activedescendant).toContain('states'); + const el = container.querySelector(`#${activedescendant}`); + expect(el).not.toBeNull(); + }); + }); + + describe('focus behavior', () => { + test('filter input receives focus when dropdown opens', async () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + const input = getFilterInput(container)!; + expect(document.activeElement).toBe(input); + }); + + test('focus stays on filter input when navigating items with arrow keys', async () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + const input = getFilterInput(container)!; + + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + + expect(document.activeElement).toBe(input); + }); + + test('focus stays on filter input when hovering menu items', async () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + + const input = getFilterInput(container)!; + const menuItemLi = wrapper.findItems()[0].getElement(); + + act(() => { + fireEvent.mouseEnter(menuItemLi); + }); + + expect(document.activeElement).toBe(input); + }); + + test('without filtering, highlighted items receive DOM focus', () => { + const { wrapper } = renderDropdown(); + wrapper.openDropdown(); + + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + + const focused = document.activeElement as HTMLElement; + expect(focused.getAttribute('role')).toBe('menuitem'); + }); + + test('all menu items have tabIndex=-1 when filtering is active', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + + const menuItems = getMenuItems(wrapper); + menuItems.forEach(item => { + expect(item.getAttribute('tabindex')).toBe('-1'); + }); + }); + + test('scrolls the highlighted item into view when navigating with arrow keys while filtering', async () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + await act(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + expect(scrollElementIntoView).not.toHaveBeenCalled(); + + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + + // The highlighted item is scrolled into view since focus stays on the filter input. + expect(scrollElementIntoView).toHaveBeenCalled(); + const highlightedEl = wrapper.findHighlightedItem()!.find('[role="menuitem"]')!.getElement(); + expect(scrollElementIntoView).toHaveBeenCalledWith(highlightedEl); + }); + + test('returns focus to trigger when focus leaves the dropdown with expandToViewport and filtering', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto', expandToViewport: true }); + wrapper.openDropdown(); + + // Simulate focus leaving the dropdown + wrapper.findFilteringInput()!.findNativeInput().blur(); + + expect(wrapper.findOpenDropdown()).toBeNull(); + expect(document.activeElement).toBe(wrapper.findNativeButton().getElement()); + }); + }); + + describe('trigger aria attributes', () => { + test('trigger has aria-haspopup="dialog" when filtering is enabled', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + expect(wrapper.findNativeButton().getElement().getAttribute('aria-haspopup')).toBe('dialog'); + }); + + test('trigger has aria-haspopup="true" when filtering is not enabled', () => { + const { wrapper } = renderDropdown(); + expect(wrapper.findNativeButton().getElement().getAttribute('aria-haspopup')).toBe('true'); + }); + }); + + describe('keyboard behavior', () => { + test('opening with ArrowDown does not highlight an item when filtering is enabled', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.findNativeButton().keydown(KeyCode.down); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(wrapper.findHighlightedItem()).toBeNull(); + }); + + test('opening with ArrowUp does not highlight an item when filtering is enabled', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.findNativeButton().keydown(KeyCode.up); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(wrapper.findHighlightedItem()).toBeNull(); + }); + + test('Escape closes the dropdown', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.keydown(KeyCode.escape); + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('Escape closes the dropdown when filtering input has text', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cu'); + wrapper.findFilteringInput()!.keydown(KeyCode.escape); + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('Space does not activate the highlighted item while filtering', () => { + const onItemClick = jest.fn(); + const { wrapper } = renderDropdown({ filteringType: 'auto', onItemClick }); + wrapper.openDropdown(); + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + wrapper.findFilteringInput()!.findNativeInput().keyup(KeyCode.space); + expect(onItemClick).not.toHaveBeenCalled(); + }); + + test('Enter does nothing when no item is highlighted while filtering', () => { + const onItemClick = jest.fn(); + const { wrapper } = renderDropdown({ filteringType: 'auto', onItemClick }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + // No item is highlighted (typing resets the highlight), so Enter must not select + // anything and must keep the dropdown open, matching select/multiselect. + wrapper.findFilteringInput()!.findNativeInput().keydown(KeyCode.enter); + expect(onItemClick).not.toHaveBeenCalled(); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('Enter activates the highlighted item while filtering', () => { + const onItemClick = jest.fn(); + const { wrapper } = renderDropdown({ filteringType: 'auto', onItemClick }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + const input = wrapper.findFilteringInput()!.findNativeInput(); + input.keydown(KeyCode.down); + input.keydown(KeyCode.enter); + expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'i1' } })); + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('Enter activates the highlighted item without filtering', () => { + const onItemClick = jest.fn(); + const { wrapper } = renderDropdown({ onItemClick }); + wrapper.openDropdown(); + + // The first item is already highlighted when opened without filtering. + // Enter on a non-link item triggers preventDefault and activates it. + wrapper.findOpenDropdown()!.keydown(KeyCode.enter); + + expect(onItemClick).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ id: 'i1' }) }) + ); + }); + + test('Left and Right arrow keys do not expand groups while filtering', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + items: expandableItems, + expandableGroups: true, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Instance'); + + const input = wrapper.findFilteringInput()!.findNativeInput(); + input.keydown(KeyCode.right); + input.keydown(KeyCode.left); + // Filtering renders groups flat, so the nested items are visible regardless of expansion. + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('Tab closes the dropdown when filtering is disabled', () => { + const { wrapper } = renderDropdown(); + wrapper.openDropdown(); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + + wrapper.findHighlightedItem()!.keydown(KeyCode.tab); + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('Tab with expandToViewport returns focus to trigger and closes dropdown', () => { + const { wrapper } = renderDropdown({ expandToViewport: true }); + wrapper.openDropdown(); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + + wrapper.findHighlightedItem()!.keydown(KeyCode.tab); + expect(wrapper.findOpenDropdown()).toBeNull(); + expect(document.activeElement).toBe(wrapper.findNativeButton().getElement()); + }); + + test('Tab does not close the dropdown when filtering is enabled', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + + wrapper.findFilteringInput()!.keydown(KeyCode.tab); + // Dropdown stays open because Tab is handled by onDropdownFocusLeave in filtering mode + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + }); + + describe('filtering value reset', () => { + test('reopening the dropdown clears the previous filtering value', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + expect(getFilterInput(container)!.value).toBe('Cut'); + + // Clicking the trigger while open toggles the dropdown closed and resets the filter. + wrapper.openDropdown(); + // Reopen the dropdown. + wrapper.openDropdown(); + expect(getFilterInput(container)!.value).toBe(''); + }); + + test('activating a filtered item closes the dropdown and resets the filter', () => { + const onItemClick = jest.fn(); + const { container, wrapper } = renderDropdown({ filteringType: 'auto', onItemClick }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Copy'); + + wrapper.findItemById('i2')!.click(); + expect(onItemClick).toHaveBeenCalledTimes(1); + expect(wrapper.findOpenDropdown()).toBeNull(); + + wrapper.openDropdown(); + expect(getFilterInput(container)!.value).toBe(''); + }); + }); + + describe('match highlighting', () => { + const richItems: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Copy', secondaryText: 'Copy selection', labelTag: 'Copyable' }, + ]; + + test('highlights the matching part of the item text, secondary text, and label tag', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto', items: richItems }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cop'); + + const marks = container.querySelectorAll('mark'); + // text, secondaryText and labelTag each contain a highlighted match. + expect(marks.length).toBeGreaterThanOrEqual(3); + marks.forEach(mark => expect(mark.textContent?.toLowerCase()).toBe('cop')); + }); + + test('does not highlight anything when there is no filtering value', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto', items: richItems }); + wrapper.openDropdown(); + expect(container.querySelectorAll('mark')).toHaveLength(0); + }); + }); + + describe('filtered expandable groups', () => { + test('renders matching nested items and collapses expandable groups', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + items: expandableItems, + expandableGroups: true, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Start'); + + expect(wrapper.findExpandableCategoryById('states')).toBeNull(); // Category is no longer expandable + expect(getMenuItems(wrapper)).toHaveLength(1); // Excluding groups + expect(wrapper.findItemById('start')).not.toBeNull(); + }); + + test('prevents focus stealing on mouse down for expandable categories while filtering is enabled', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + items: expandableItems, + expandableGroups: true, + }); + wrapper.openDropdown(); + expect(wrapper.findFilteringInput()!.findNativeInput().getElement()).toHaveFocus(); + wrapper.findExpandableCategoryById('states')!.click(); + expect(wrapper.findFilteringInput()!.findNativeInput().getElement()).toHaveFocus(); + }); + + test('does not prevent mouse down for expandable categories without filtering', () => { + const { wrapper } = renderDropdown({ items: expandableItems, expandableGroups: true }); + wrapper.openDropdown(); + const categoryEl = wrapper.findExpandableCategoryById('states')!; + categoryEl.click(); + // Clicking on a category moves focus to the first item in the category. + const startMenuItemEl = wrapper.findItemById('start')!; + expect(startMenuItemEl.find('[role=menuitem]')!.getElement()).toHaveFocus(); + }); + + test('flattens matching items from nested groups into the top-level group', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + items: nestedExpandableItems, + expandableGroups: true, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Reboot'); + + // The deeply-nested "Reboot instance" item should surface as a result + // under the top-level "Actions" group header. + const menuItems = getMenuItems(wrapper); + expect(menuItems.length).toBe(1); + expect(menuItems[0]).toHaveTextContent('Reboot instance'); + }); + }); + + describe('filtering results text', () => { + test('does not render filtering results text by default', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('C'); + expect(wrapper.findFooterRegion()).toBeNull(); + }); + + test('does not render filtering results text when filter input is empty', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + filteringResultsText: (matchesCount, totalCount) => `${matchesCount} of ${totalCount} items`, + }); + wrapper.openDropdown(); + expect(wrapper.findFooterRegion()).toBeNull(); + }); + + test('renders filtering results text with correct counts when filtering produces matches', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + filteringResultsText: (matchesCount, totalCount) => `${matchesCount} of ${totalCount} items`, + }); + wrapper.openDropdown(); + // "Co" matches "Copy" => 1 of 4 items + wrapper.findFilteringInput()!.setInputValue('Co'); + const footer = wrapper.findFooterRegion(); + expect(footer).not.toBeNull(); + expect(footer!.getElement()).toHaveTextContent('1 of 4 items'); + }); + + test('does not render filtering results text when there are no matches', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + filteringResultsText: (matchesCount, totalCount) => `${matchesCount} of ${totalCount} items`, + noMatch: 'No items found', + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('zzz'); + const footer = wrapper.findFooterRegion(); + // The noMatch content is shown, not the filteringResultsText + expect(footer).not.toBeNull(); + expect(footer!.getElement()).toHaveTextContent('No items found'); + }); + + test('counts leaf items in groups for totalCount', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + items: expandableItems, + expandableGroups: true, + filteringResultsText: (matchesCount, totalCount) => `${matchesCount} of ${totalCount} items`, + }); + wrapper.openDropdown(); + // "St" matches "Start" and "Stop" (2 leaf items), expandableItems has 3 leaf items total + wrapper.findFilteringInput()!.setInputValue('St'); + const footer = wrapper.findFooterRegion(); + expect(footer).not.toBeNull(); + expect(footer!.getElement()).toHaveTextContent('2 of 3 items'); + }); + }); + + describe('i18nStrings.filteringItemAriaDescription', () => { + test('menu items do not have an accessible description when i18nStrings is not provided', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('C'); + const menuItems = getMenuItems(wrapper); + menuItems.forEach(menuItem => { + expect(menuItem).not.toHaveAccessibleDescription(); + }); + }); + + test('menu items have the filtering description as their accessible description', () => { + const { wrapper } = renderDropdown({ + filteringType: 'auto', + i18nStrings: { filteringItemAriaDescription: 'Continue typing to further filter the list' }, + }); + wrapper.openDropdown(); + const menuItems = getMenuItems(wrapper); + menuItems.forEach(menuItem => { + expect(menuItem).toHaveAccessibleDescription('Continue typing to further filter the list'); + }); + }); + + test('disabled item with disabledReason includes both descriptions', () => { + const disabledItems: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Cut', disabled: true, disabledReason: 'Not available' }, + { id: 'i2', text: 'Copy' }, + ]; + const { wrapper } = renderDropdown({ + filteringType: 'auto', + items: disabledItems, + i18nStrings: { filteringItemAriaDescription: 'Continue typing to further filter the list' }, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('C'); + + // The disabled item should have both descriptions combined + const disabledMenuItem = wrapper.findItemById('i1')!.find('[role="menuitem"]')!.getElement(); + expect(disabledMenuItem).toHaveAccessibleDescription('Not available Continue typing to further filter the list'); + + // The non-disabled item should only have the filtering description + const enabledMenuItem = wrapper.findItemById('i2')!.find('[role="menuitem"]')!.getElement(); + expect(enabledMenuItem).toHaveAccessibleDescription('Continue typing to further filter the list'); + }); + }); + + describe('disabled reason', () => { + const disabledItems: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Cut' }, + { id: 'i2', text: 'Copy', disabled: true, disabledReason: 'Cannot copy right now' }, + { id: 'i3', text: 'Paste' }, + ]; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('opens tooltip when a disabled item with disabledReason is highlighted via keyboard while filtering', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto', items: disabledItems }); + wrapper.openDropdown(); + + // Navigate down twice to reach the disabled item + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + + // Wait for open delay + act(() => jest.advanceTimersByTime(200)); + + expect(wrapper.findDisabledReason()).not.toBeNull(); + expect(wrapper.findDisabledReason()!.getElement()).toHaveTextContent('Cannot copy right now'); + }); + + test('closes tooltip when highlight moves away from disabled item while filtering', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto', items: disabledItems }); + wrapper.openDropdown(); + + // Navigate to the disabled item + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + + // Wait for open delay + act(() => jest.advanceTimersByTime(200)); + expect(wrapper.findDisabledReason()).not.toBeNull(); + + // Move highlight away + wrapper.findOpenDropdown()!.keydown(KeyCode.down); + expect(wrapper.findDisabledReason()).toBeNull(); + }); + }); + + test('passes filterText to renderItem for different item types', () => { + const items: ButtonDropdownProps.Items = [ + { id: 'i1', text: 'Cut' }, + { id: 'c1', itemType: 'checkbox', text: 'Toggle feature', checked: true }, + ...expandableItems, + ]; + + const renderItem = jest.fn(() => null); + const { wrapper } = renderDropdown({ filteringType: 'auto', items, expandableGroups: true, renderItem }); + wrapper.openDropdown(); + + wrapper.findFilteringInput()!.setInputValue('Cu'); + expect(renderItem).toHaveBeenCalledWith( + expect.objectContaining({ + filterText: 'Cu', + item: expect.objectContaining({ + type: 'action', + option: expect.objectContaining({ id: 'i1', text: 'Cut' }), + }), + }) + ); + + wrapper.findFilteringInput()!.setInputValue('Toggle'); + expect(renderItem).toHaveBeenCalledWith( + expect.objectContaining({ + filterText: 'Toggle', + item: expect.objectContaining({ + type: 'checkbox', + option: expect.objectContaining({ id: 'c1', text: 'Toggle feature' }), + }), + }) + ); + + wrapper.findFilteringInput()!.setInputValue('Start'); + expect(renderItem).toHaveBeenCalledWith( + expect.objectContaining({ + filterText: 'Start', + item: expect.objectContaining({ + type: 'group', + option: expect.objectContaining({ id: 'states', text: 'Instance state' }), + }), + }) + ); + }); +}); diff --git a/src/button-dropdown/__tests__/button-dropdown-keyboard.test.tsx b/src/button-dropdown/__tests__/button-dropdown-keyboard.test.tsx index 21273fd46c..94dd87d0c7 100644 --- a/src/button-dropdown/__tests__/button-dropdown-keyboard.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-keyboard.test.tsx @@ -1,7 +1,7 @@ // 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 { act, fireEvent, render } from '@testing-library/react'; import ButtonDropdown, { ButtonDropdownProps } from '../../../lib/components/button-dropdown'; import createWrapper, { ButtonDropdownWrapper } from '../../../lib/components/test-utils/dom'; @@ -45,6 +45,13 @@ const items: ButtonDropdownProps.Items = [ expect(wrapper.findHighlightedItem()!.getElement()).toHaveTextContent('item5'); }); + test('should preventDefault on space keydown when dropdown is closed to prevent page scroll', () => { + expect(wrapper.findOpenDropdown()).toBe(null); + const trigger = wrapper.findNativeButton().getElement(); + const defaultPrevented = !fireEvent.keyDown(trigger, { keyCode: KeyCode.space }); + expect(defaultPrevented).toBe(true); + }); + test('should show secondaryText in highlighted items', () => { const itemsWithSecondary = [ { id: 'i1', text: 'item1', secondaryText: 'Description 1' }, diff --git a/src/button-dropdown/__tests__/render-item.test.tsx b/src/button-dropdown/__tests__/render-item.test.tsx index d253c9f9e0..98881b11f9 100644 --- a/src/button-dropdown/__tests__/render-item.test.tsx +++ b/src/button-dropdown/__tests__/render-item.test.tsx @@ -40,6 +40,19 @@ describe('ButtonDropdown renderItem', () => { expect(elementWrapper).toHaveTextContent('Custom'); }); + test('renders custom item content for link items', () => { + const renderItem = jest.fn(() =>
Custom link
); + const wrapper = renderButtonDropdown({ + items: [{ id: 'link1', text: 'Docs', href: '#docs' }], + renderItem, + }); + wrapper.openDropdown(); + const element = wrapper.findItemById('link1')!.getElement(); + // The custom rendered content replaces the default content inside the anchor element. + expect(element.querySelector('a')).not.toBeNull(); + expect(element).toHaveTextContent('Custom link'); + }); + test('receives correct item properties for action item', () => { const renderItem = jest.fn(() =>
Custom
); const actionItem = { id: 'test-action', text: 'Test Action' }; diff --git a/src/button-dropdown/category-elements/category-element.tsx b/src/button-dropdown/category-elements/category-element.tsx index a3fac0384e..104fdedccb 100644 --- a/src/button-dropdown/category-elements/category-element.tsx +++ b/src/button-dropdown/category-elements/category-element.tsx @@ -25,6 +25,10 @@ const CategoryElement = ({ variant, position, renderItem, + filteringText, + filteringEnabled, + menuId, + filteringDescriptionId, }: CategoryProps) => { const highlighted = isHighlighted(item); const groupProps: ButtonDropdownProps.GroupRenderItem = { @@ -36,7 +40,7 @@ const CategoryElement = ({ expanded: true, expandDirection: 'vertical', }; - const renderResult = renderItem?.({ item: groupProps }) ?? null; + const renderResult = renderItem?.({ item: groupProps, filterText: filteringText }) ?? null; // Hide the category title element from screen readers because it will be // provided as an ARIA label. @@ -85,6 +89,10 @@ const CategoryElement = ({ position={position} renderItem={renderItem} parentProps={groupProps} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} + filteringDescriptionId={filteringDescriptionId} /> )} diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index a1301e0c42..11e87689cd 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -38,6 +38,10 @@ const ExpandableCategoryElement = ({ variant, position, renderItem, + filteringText, + filteringEnabled, + menuId, + filteringDescriptionId, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); @@ -46,16 +50,25 @@ const ExpandableCategoryElement = ({ const ref = useRef(null); useEffect(() => { - if (triggerRef.current && highlighted && !expanded) { + if (triggerRef.current && highlighted && !expanded && !filteringEnabled) { triggerRef.current.focus(); } - }, [expanded, highlighted]); + }, [expanded, highlighted, filteringEnabled]); const onClick: React.MouseEventHandler = event => { if (!disabled) { event.preventDefault(); onGroupToggle(item, event); - triggerRef.current?.focus(); + if (!filteringEnabled) { + triggerRef.current?.focus(); + } + } + }; + + const onMouseDown: React.MouseEventHandler = event => { + // Ensure that focus remains on the filtering input at all times. + if (filteringEnabled) { + event.preventDefault(); } }; @@ -78,10 +91,11 @@ const ExpandableCategoryElement = ({ expanded: expanded, expandDirection: 'horizontal', }; - const renderResult = renderItem?.({ item: groupProps }) ?? null; + const renderResult = renderItem?.({ item: groupProps, filterText: filteringText }) ?? null; const trigger = item.text && ( ) : undefined @@ -190,6 +210,7 @@ const ExpandableCategoryElement = ({ data-testid={item.id} ref={ref} onClick={onClick} + onMouseDown={onMouseDown} onMouseEnter={onHover} onTouchStart={onHover} > diff --git a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx index 8664f2aef0..076376ce51 100644 --- a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx @@ -33,6 +33,10 @@ const MobileExpandableCategoryElement = ({ variant, position, renderItem, + filteringText, + filteringEnabled, + menuId, + filteringDescriptionId, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); @@ -40,10 +44,10 @@ const MobileExpandableCategoryElement = ({ const triggerRef = React.useRef(null); useEffect(() => { - if (triggerRef.current && highlighted && !expanded) { + if (triggerRef.current && highlighted && !expanded && !filteringEnabled) { triggerRef.current.focus(); } - }, [expanded, highlighted]); + }, [expanded, highlighted, filteringEnabled]); const onClick = (e: React.MouseEvent) => { if (!disabled) { @@ -70,10 +74,11 @@ const MobileExpandableCategoryElement = ({ expanded: expanded, expandDirection: 'vertical', }; - const renderResult = renderItem?.({ item: groupProps }) ?? null; + const renderResult = renderItem?.({ item: groupProps, filterText: filteringText }) ?? null; const trigger = item.text && ( )} diff --git a/src/button-dropdown/filter.tsx b/src/button-dropdown/filter.tsx new file mode 100644 index 0000000000..999143411f --- /dev/null +++ b/src/button-dropdown/filter.tsx @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import InternalInput, { InternalInputProps } from '../input/internal'; + +import styles from './styles.css.js'; + +export interface ButtonDropdownFilterProps extends Omit { + ref?: React.Ref; +} + +const ButtonDropdownFilter = React.forwardRef((props: ButtonDropdownFilterProps, ref: React.Ref) => { + return ( +
+ +
+ ); +}); + +export default ButtonDropdownFilter; diff --git a/src/button-dropdown/index.tsx b/src/button-dropdown/index.tsx index b5a840039d..b9183506ad 100644 --- a/src/button-dropdown/index.tsx +++ b/src/button-dropdown/index.tsx @@ -41,12 +41,19 @@ const ButtonDropdown = React.forwardRef( nativeMainActionAttributes, nativeTriggerAttributes, renderItem, + filteringType = 'none', + filteringPlaceholder, + filteringAriaLabel, + filteringClearAriaLabel, + filteringResultsText, + noMatch, + i18nStrings, ...props }: ButtonDropdownProps, ref: React.Ref ) => { const baseComponentProps = useBaseComponent('ButtonDropdown', { - props: { expandToViewport, expandableGroups, variant, iconName }, + props: { expandToViewport, expandableGroups, variant, iconName, filteringType }, metadata: { mainAction: !!mainAction, checkboxItems: hasCheckboxItems(items), @@ -88,6 +95,13 @@ const ButtonDropdown = React.forwardRef( fullWidth={fullWidth} nativeMainActionAttributes={nativeMainActionAttributes} nativeTriggerAttributes={nativeTriggerAttributes} + filteringType={filteringType} + filteringPlaceholder={filteringPlaceholder} + filteringAriaLabel={filteringAriaLabel} + filteringClearAriaLabel={filteringClearAriaLabel} + filteringResultsText={filteringResultsText} + noMatch={noMatch} + i18nStrings={i18nStrings} {...getAnalyticsMetadataAttribute({ component: analyticsComponentMetadata, })} diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index a269438f46..1ca0a03ac6 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -75,6 +75,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * - `highlighted` (boolean) - Whether the item is currently highlighted. * - `disabled` (boolean) - Whether the item is disabled. * - `parent` (GroupRenderItem | null) - The parent group item, if any. + * - `filterText` (string) - The current value of the filtering input, when filtering is enabled. * * ### checkbox * @@ -85,6 +86,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * - `highlighted` (boolean) - Whether the item is currently highlighted. * - `checked` (boolean) - Controls the state of the checkbox item. * - `parent` (GroupRenderItem | null) - The parent group item, if any. + * - `filterText` (string) - The current value of the filtering input, when filtering is enabled. * * ### group * @@ -95,6 +97,7 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor * - `highlighted` (boolean) - Whether the item is currently highlighted. * - `expanded` (boolean) - Whether the group is expanded. * - `expandDirection` ('vertical' | 'horizontal') - The direction in which the group expands. + * - `filterText` (string) - The current value of the filtering input, when filtering is enabled. * * When providing a custom `renderItem` implementation, it fully replaces the default visual rendering and content for that item. * The component still manages focus, keyboard interactions, and selection state, but it no longer applies its default item layout or typography. @@ -192,6 +195,46 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor */ fullWidth?: boolean; + /** + * Enables filtering of the dropdown items. + * + * When set to `auto`, a search input is rendered inside the dropdown and the items are filtered as the user + * types. Items are matched client-side using a case-insensitive substring match against their `text`, + * `secondaryText`, and `labelTag`. + */ + filteringType?: ButtonDropdownProps.FilteringType; + + /** + * Specifies the placeholder to display in the filtering input. Only relevant when filtering is enabled. + */ + filteringPlaceholder?: string; + + /** + * Adds an `aria-label` to the filtering input. Only relevant when filtering is enabled. + */ + filteringAriaLabel?: string; + + /** + * Adds an `aria-label` to the clear button inside the filtering input. Only relevant when filtering is enabled. + * @i18n + */ + filteringClearAriaLabel?: string; + + /** + * Specifies the text to display with the number of matches at the bottom of the dropdown menu while filtering. + */ + filteringResultsText?: (matchesCount: number, totalCount: number) => string; + + /** + * Displayed when filtering is enabled and there are no matches for the filtering input. + */ + noMatch?: React.ReactNode; + + /** + * An object containing all the necessary localized strings required by the component. + */ + i18nStrings?: ButtonDropdownProps.I18nStrings; + /** * Attributes to add to the native `button` element. * Some attributes will be automatically combined with internal attribute values: @@ -226,6 +269,11 @@ export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewpor export namespace ButtonDropdownProps { export type Variant = 'normal' | 'primary' | 'icon' | 'inline-icon'; export type ItemType = 'action' | 'group'; + export type FilteringType = 'auto' | 'none'; + + export interface I18nStrings { + filteringItemAriaDescription?: string; + } export interface ActionRenderItem { type: 'action'; @@ -255,7 +303,7 @@ export namespace ButtonDropdownProps { } export type RenderItem = ActionRenderItem | CheckboxRenderItem | GroupRenderItem; - export type ItemRenderer = (props: { item: ButtonDropdownProps.RenderItem }) => ReactNode | null; + export type ItemRenderer = (props: { item: ButtonDropdownProps.RenderItem; filterText?: string }) => ReactNode | null; export interface MainAction { text?: string; diff --git a/src/button-dropdown/internal-interfaces.ts b/src/button-dropdown/internal-interfaces.ts index 652793d9c2..9b75e87c59 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -25,6 +25,10 @@ export interface CategoryProps extends HighlightProps { variant?: ItemListProps['variant']; position?: string; renderItem?: ButtonDropdownProps.ItemRenderer; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; + filteringDescriptionId?: string; } export interface ItemListProps extends HighlightProps { @@ -42,6 +46,10 @@ export interface ItemListProps extends HighlightProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; + filteringDescriptionId?: string; } export interface ItemProps { @@ -60,6 +68,10 @@ export interface ItemProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; + filteringDescriptionId?: string; } export interface InternalItem extends ButtonDropdownProps.Item { diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index f6c2d6fa15..51d997c21c 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useImperativeHandle, useRef } from 'react'; +import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import clsx from 'clsx'; import { isThemeActive, Theme, useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -13,7 +13,10 @@ import Dropdown from '../dropdown/internal'; import { IconProps } from '../icon/interfaces'; import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; +import DropdownFooter from '../internal/components/dropdown-footer'; +import { useDropdownStatus } from '../internal/components/dropdown-status'; import OptionsList from '../internal/components/options-list'; +import useHiddenDescription from '../internal/hooks/use-hidden-description'; import { useMobile } from '../internal/hooks/use-mobile'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode/index.js'; import { isDevelopment } from '../internal/is-development'; @@ -23,9 +26,11 @@ import { GeneratedAnalyticsMetadataButtonDropdownCollapse, GeneratedAnalyticsMetadataButtonDropdownExpand, } from './analytics-metadata/interfaces.js'; +import ButtonDropdownFilter from './filter'; import { ButtonDropdownProps } from './interfaces'; import { InternalButtonDropdownProps, InternalItem } from './internal-interfaces'; import ItemsList from './items-list'; +import { countLeafItems } from './utils/filter-items'; import { useButtonDropdown } from './utils/use-button-dropdown'; import { isLinkItem } from './utils/utils.js'; @@ -65,12 +70,21 @@ const InternalButtonDropdown = React.forwardRef( nativeMainActionAttributes, nativeTriggerAttributes, renderItem, + filteringType, + filteringPlaceholder, + filteringAriaLabel, + filteringClearAriaLabel, + filteringResultsText, + noMatch, + i18nStrings, ...props }: InternalButtonDropdownProps, ref: React.Ref ) => { const isInRestrictedView = useMobile(); const dropdownId = useUniqueId('dropdown'); + const menuId = useUniqueId('button-dropdown-menu'); + const hasFiltering = filteringType === 'auto'; for (const item of items) { if (isLinkItem(item)) { checkSafeUrl('ButtonDropdown', item.href); @@ -105,9 +119,14 @@ const InternalButtonDropdown = React.forwardRef( onKeyUp, onItemActivate, onGroupToggle, + onDropdownFocusLeave, toggleDropdown, closeDropdown, setIsUsingMouse, + filteringValue, + setFilteringValue, + filteredItems, + showExpandableGroups, } = useButtonDropdown({ items, onItemClick, @@ -117,8 +136,18 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport, hasExpandableGroups: expandableGroups, isInRestrictedView, + hasFiltering, }); + const filterRef = useRef(null); + + useEffect(() => { + // Focus the filter input when it opens. + if (isOpen && hasFiltering) { + filterRef.current?.focus(); + } + }, [isOpen, hasFiltering]); + const handleMouseEvent = () => { setIsUsingMouse(true); }; @@ -187,7 +216,7 @@ const InternalButtonDropdown = React.forwardRef( ariaLabel, ariaExpanded: canBeOpened && isOpen, formAction: 'none', - ariaHaspopup: true, + ariaHaspopup: hasFiltering ? 'dialog' : true, nativeButtonAttributes: nativeTriggerAttributes, }; @@ -344,9 +373,49 @@ const InternalButtonDropdown = React.forwardRef( const hasHeader = title || description; const headerId = useUniqueId('awsui-button-dropdown__header'); + const footerId = useUniqueId('awsui-button-dropdown__footer'); + + const isNoMatch = hasFiltering && !!filteringValue && filteredItems.length === 0; + const isFiltered = hasFiltering && !!filteringValue && filteredItems.length > 0; + + const totalCount = useMemo(() => countLeafItems(items), [items]); + const matchesCount = useMemo(() => countLeafItems(filteredItems), [filteredItems]); + const filteredText = isFiltered ? filteringResultsText?.(matchesCount, totalCount) : undefined; + + const dropdownStatus = useDropdownStatus({ + statusType: 'finished', + isNoMatch, + noMatch, + filteringResultsText: filteredText, + }); + + // Only create a filteringDescription element if filtering is actually enabled, + // not just if the string is provided. + const filteringItemDescription = hasFiltering ? i18nStrings?.filteringItemAriaDescription : undefined; + const { descriptionEl: filteringDescriptionEl, descriptionId: filteringDescriptionId } = useHiddenDescription( + hasFiltering ? i18nStrings?.filteringItemAriaDescription : undefined + ); const shouldLabelWithTrigger = !ariaLabel && !mainAction && variant !== 'icon' && variant !== 'inline-icon'; + const highlightedItemId = targetItem && targetItem.id ? `${menuId}-${targetItem.id}` : undefined; + + const filterElement = hasFiltering ? ( + setFilteringValue(event.detail.value)} + placeholder={filteringPlaceholder} + ariaLabel={filteringAriaLabel} + clearAriaLabel={filteringClearAriaLabel} + nativeInputAttributes={{ + 'aria-activedescendant': highlightedItemId ?? '', + 'aria-owns': menuId, + 'aria-controls': menuId, + }} + /> + ) : null; + const { loadingButtonCount } = useFunnel(); useEffect(() => { if (loading) { @@ -382,8 +451,17 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport={expandToViewport} preferredAlignment={preferCenter ? 'center' : 'start'} onOutsideClick={() => toggleDropdown()} + onFocusLeave={onDropdownFocusLeave} trigger={trigger} dropdownId={dropdownId} + header={filterElement} + ariaRole={hasFiltering ? 'dialog' : undefined} + ariaLabel={hasFiltering ? ariaLabel : undefined} + footer={ + dropdownStatus.content ? ( + + ) : null + } content={ <> {hasHeader && ( @@ -412,17 +490,19 @@ const InternalButtonDropdown = React.forwardRef( open={canBeOpened && isOpen} position="static" role="menu" + nativeAttributes={{ id: menuId }} tagOverride="ul" decreaseBlockMargin={true} ariaLabel={ariaLabel} ariaLabelledby={hasHeader ? headerId : shouldLabelWithTrigger ? triggerId : undefined} + ariaDescribedby={dropdownStatus.content ? footerId : undefined} statusType="finished" > + {filteringDescriptionEl} } /> diff --git a/src/button-dropdown/item-element/index.tsx b/src/button-dropdown/item-element/index.tsx index d40f240cbf..e16e00f9d2 100644 --- a/src/button-dropdown/item-element/index.tsx +++ b/src/button-dropdown/item-element/index.tsx @@ -10,9 +10,12 @@ import { import { useDropdownContext } from '../../dropdown/context'; import InternalIcon, { InternalIconProps } from '../../icon/internal'; +import HighlightMatch from '../../internal/components/option/highlight-match'; import useHiddenDescription from '../../internal/hooks/use-hidden-description'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { getDataAttributes } from '../../internal/utils/data-attributes'; +import { scrollElementIntoView } from '../../internal/utils/scrollable-containers'; +import { joinStrings } from '../../internal/utils/strings'; import { GeneratedAnalyticsMetadataButtonDropdownClick } from '../analytics-metadata/interfaces'; import { ButtonDropdownProps, LinkItem } from '../interfaces'; import { InternalCheckboxItem, InternalItem, ItemProps } from '../internal-interfaces'; @@ -40,6 +43,10 @@ const ItemElement = ({ linkStyle, renderItem, parentProps, + filteringText, + filteringEnabled, + menuId, + filteringDescriptionId, }: ItemProps) => { const isLink = isLinkItem(item); const isCheckbox = isCheckboxItem(item); @@ -101,6 +108,10 @@ const ItemElement = ({ linkStyle={linkStyle} renderItem={renderItem} parentProps={parentProps} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} + filteringDescriptionId={filteringDescriptionId} /> ); @@ -114,20 +125,42 @@ interface MenuItemProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; + filteringDescriptionId?: string; } -function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, parentProps }: MenuItemProps) { +function MenuItem({ + index, + item, + disabled, + highlighted, + linkStyle, + renderItem, + parentProps, + filteringText, + filteringEnabled, + menuId, + filteringDescriptionId, +}: MenuItemProps) { const menuItemRef = useRef<(HTMLSpanElement & HTMLAnchorElement) | null>(null); const isCheckbox = isCheckboxItem(item); const isCurrentBreadcrumb = !isCheckbox && item.isCurrentBreadcrumb; useEffect(() => { if (highlighted && menuItemRef.current) { - menuItemRef.current.focus(); + if (filteringEnabled) { + // In filtering mode focus stays on the filter input, so we scroll the + // highlighted item into view manually instead of relying on focus(). + scrollElementIntoView(menuItemRef.current); + } else { + menuItemRef.current.focus(); + } } - }, [highlighted]); + }, [highlighted, filteringEnabled]); - let itemProps: { item: ButtonDropdownProps.RenderItem }; + let itemProps: { item: ButtonDropdownProps.RenderItem; filterText?: string }; if (isCheckbox) { itemProps = { @@ -140,6 +173,7 @@ function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, p checked: item.checked, parent: parentProps ?? null, }, + filterText: filteringText, }; } else { itemProps = { @@ -151,6 +185,7 @@ function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, p highlighted: highlighted, parent: parentProps ?? null, }, + filterText: filteringText, }; } @@ -158,8 +193,15 @@ function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, p const isDisabledWithReason = disabled && item.disabledReason; const { targetProps, descriptionEl } = useHiddenDescription(item.disabledReason); + const itemDomId = menuId && item.id ? `${menuId}-${item.id}` : undefined; + const ariaDescribedby = joinStrings( + isDisabledWithReason ? targetProps['aria-describedby'] : undefined, + filteringDescriptionId + ); const menuItemProps: React.HTMLAttributes = { + id: itemDomId, 'aria-label': item.ariaLabel, + 'aria-describedby': ariaDescribedby, className: clsx( styles['menu-item'], !!renderResult && styles['no-content-styling'], @@ -171,12 +213,13 @@ function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, p 'aria-current': isCurrentBreadcrumb, lang: item.lang, ref: menuItemRef, - // We are using the roving tabindex technique to manage the focus state of the dropdown. - // The current element will always have tabindex=0 which means that it can be tabbed to, - // while all other items have tabindex=-1 so we can focus them when necessary. - tabIndex: highlighted ? 0 : -1, + // When filtering is enabled, we use aria-activedescendant on the filter input and provide + // the `id` of the item to select it. When filtering is disabled, we are using the roving + // tabindex technique to manage the focus state of the dropdown. The current element will + // have tabindex=0 which means that it can be tabbed to, while all other items have + // tabindex=-1 so we can focus them when necessary. + tabIndex: filteringEnabled ? -1 : highlighted ? 0 : -1, ...(isCheckbox ? getMenuItemCheckboxProps({ disabled, checked: item.checked }) : getMenuItemProps({ disabled })), - ...(isDisabledWithReason ? targetProps : {}), }; const menuItem = isLinkItem(item) ? ( @@ -187,18 +230,31 @@ function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, p target={getItemTarget(item)} rel={item.external ? 'noopener noreferrer' : undefined} > - {renderResult ? renderResult : } + {renderResult ? ( + renderResult + ) : ( + + )} ) : ( - {renderResult ? renderResult : } + {renderResult ? ( + renderResult + ) : ( + + )} ); const { position } = useDropdownContext(); const tooltipPosition = position === 'bottom-left' || position === 'top-left' ? 'left' : 'right'; return isDisabledWithReason ? ( - + {menuItem} {descriptionEl} @@ -211,10 +267,12 @@ const MenuItemContent = ({ item, disabled, highlighted, + filteringText, }: { item: InternalItem | InternalCheckboxItem; disabled: boolean; highlighted: boolean; + filteringText?: string; }) => { const hasIcon = !!(item.iconName || item.iconUrl || item.iconSvg); const hasExternal = isLinkItem(item) && item.external; @@ -234,18 +292,20 @@ const MenuItemContent = ({
- {item.text} + {hasExternal && }
{item.labelTag && ( -
{item.labelTag}
+
+ +
)}
{item.secondaryText && (
- {item.secondaryText} +
)}
diff --git a/src/button-dropdown/items-list.tsx b/src/button-dropdown/items-list.tsx index eca795fae9..a4b0df7578 100644 --- a/src/button-dropdown/items-list.tsx +++ b/src/button-dropdown/items-list.tsx @@ -30,6 +30,10 @@ export default function ItemsList({ linkStyle, renderItem, parentProps, + filteringText, + filteringEnabled, + menuId, + filteringDescriptionId, }: ItemListProps) { const isMobile = useMobile(); @@ -55,6 +59,10 @@ export default function ItemsList({ linkStyle={linkStyle} renderItem={renderItem} parentProps={parentProps} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} + filteringDescriptionId={filteringDescriptionId} /> ); } @@ -77,6 +85,10 @@ export default function ItemsList({ variant={variant} position={`${position ? `${position},` : ''}${index + 1}`} renderItem={renderItem} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} + filteringDescriptionId={filteringDescriptionId} /> ) : ( ) ) : null; @@ -117,6 +133,10 @@ export default function ItemsList({ variant={variant} position={`${position ? `${position},` : ''}${index + 1}`} renderItem={renderItem} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} + filteringDescriptionId={filteringDescriptionId} /> ); }); diff --git a/src/button-dropdown/styles.scss b/src/button-dropdown/styles.scss index d535d7363c..ff801d17df 100644 --- a/src/button-dropdown/styles.scss +++ b/src/button-dropdown/styles.scss @@ -145,6 +145,12 @@ $dropdown-trigger-icon-offset: 2px; flex: 0 0 auto; } +.filter { + position: relative; + z-index: 4; + flex-shrink: 0; +} + .test-utils-button-trigger { /* used in test-utils */ } diff --git a/src/button-dropdown/tooltip.tsx b/src/button-dropdown/tooltip.tsx index 0526099b14..2a4da46974 100644 --- a/src/button-dropdown/tooltip.tsx +++ b/src/button-dropdown/tooltip.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { KeyboardEventHandler, useEffect, useRef, useState } from 'react'; +import React, { KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'; import { Portal, useReducedMotion } from '@cloudscape-design/component-toolkit/internal'; @@ -14,14 +14,15 @@ interface TooltipProps { content?: React.ReactNode; position?: 'top' | 'right' | 'bottom' | 'left'; className?: string; + controlledOpen?: boolean; } const DEFAULT_OPEN_TIMEOUT_IN_MS = 120; -export default function Tooltip({ children, content, position = 'right', className }: TooltipProps) { +export default function Tooltip({ children, content, position = 'right', className, controlledOpen }: TooltipProps) { const ref = useRef(null); const isReducedMotion = useReducedMotion(ref); - const { open, triggerProps } = useTooltipOpen(isReducedMotion ? 0 : DEFAULT_OPEN_TIMEOUT_IN_MS); + const { open, triggerProps } = useTooltipOpen(controlledOpen, isReducedMotion ? 0 : DEFAULT_OPEN_TIMEOUT_IN_MS); const portalClasses = usePortalModeClasses(ref); return ( @@ -58,7 +59,7 @@ export default function Tooltip({ children, content, position = 'right', classNa ); } -function useTooltipOpen(timeout: number) { +function useTooltipOpen(controlledOpen: boolean | undefined, timeout: number) { const [isOpen, setIsOpen] = useState(false); // The delayed effect is aborted in case the component unmounts. We cannot use the conventional clearTimeout() @@ -72,22 +73,34 @@ function useTooltipOpen(timeout: number) { }; }, []); - const close = () => { + const close = useCallback(() => { clearTimeout(timeoutRef.current); setIsOpen(false); - }; - const open = () => { + }, []); + + const open = useCallback(() => { if (!timeoutAbortRef.current) { setIsOpen(true); } - }; - const openDelayed = () => { + }, []); + + const openDelayed = useCallback(() => { timeoutRef.current = setTimeout(open, timeout); - }; - const onKeyDown: KeyboardEventHandler = e => { - if (isOpen && isEscape(e.key)) { - e.preventDefault(); - e.stopPropagation(); + }, [open, timeout]); + + useEffect(() => { + if (controlledOpen === true) { + openDelayed(); + } + if (controlledOpen === false) { + close(); + } + }, [controlledOpen, openDelayed, close]); + + const onKeyDown: KeyboardEventHandler = event => { + if (isOpen && isEscape(event.key)) { + event.preventDefault(); + event.stopPropagation(); close(); } }; diff --git a/src/button-dropdown/utils/filter-items.ts b/src/button-dropdown/utils/filter-items.ts new file mode 100644 index 0000000000..257748053c --- /dev/null +++ b/src/button-dropdown/utils/filter-items.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ButtonDropdownProps } from '../interfaces'; +import { isItemGroup } from './utils'; + +function matchesString(value: string | undefined, lowerCaseSearchText: string): boolean { + if (!value) { + return false; + } + return value.toLowerCase().indexOf(lowerCaseSearchText) > -1; +} + +function matchesItem(item: ButtonDropdownProps.Item | ButtonDropdownProps.CheckboxItem, searchText: string): boolean { + return ( + matchesString(item.text, searchText) || + matchesString(item.secondaryText, searchText) || + matchesString(item.labelTag, searchText) + ); +} + +export function filterItems(items: ButtonDropdownProps.Items, filterText: string): ButtonDropdownProps.Items { + const lowerCaseSearchText = filterText.toLowerCase(); + + return items.reduce((acc, item) => { + if (!isItemGroup(item)) { + if (matchesItem(item, lowerCaseSearchText)) { + acc.push(item); + } + return acc; + } + + // Filtered results are rendered as a collapsed (flat) list, so nested + // groups would otherwise render a header per level. Flatten all matching + // descendants into the top-most group and keep only its header. + const matchingChildren = collectMatchingLeaves(item.items, lowerCaseSearchText); + + if (matchingChildren.length > 0) { + acc.push({ ...item, items: matchingChildren }); + } + + return acc; + }, []); +} + +function collectMatchingLeaves( + items: ButtonDropdownProps.Items, + searchText: string +): Array { + return items.reduce>((acc, item) => { + if (isItemGroup(item)) { + acc.push(...collectMatchingLeaves(item.items, searchText)); + } else if (matchesItem(item, searchText)) { + acc.push(item); + } + return acc; + }, []); +} + +export function countLeafItems(items: ButtonDropdownProps.Items): number { + let count = 0; + for (const item of items) { + if (isItemGroup(item)) { + count += countLeafItems(item.items); + } else { + count++; + } + } + return count; +} diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index f166c78c71..c5bbf8aea1 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -1,12 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useOpenState } from '../../internal/components/options-list/utils/use-open-state'; import { fireCancelableEvent, isPlainLeftClick } from '../../internal/events'; import { KeyCode } from '../../internal/keycode'; import { CancelableEventHandler } from '../../types/events'; import { ButtonDropdownProps, ButtonDropdownSettings, GroupToggle, HighlightProps, ItemActivate } from '../interfaces'; +import { filterItems } from './filter-items'; import useHighlightedMenu from './use-highlighted-menu'; import { getItemTarget, isCheckboxItem, isItemGroup, isLinkItem } from './utils'; @@ -16,6 +17,7 @@ interface UseButtonDropdownOptions extends ButtonDropdownSettings { onItemFollow?: CancelableEventHandler; onReturnFocus: () => void; expandToViewport?: boolean; + hasFiltering: boolean; } interface UseButtonDropdownApi extends HighlightProps { @@ -24,9 +26,14 @@ interface UseButtonDropdownApi extends HighlightProps { onKeyUp: (event: React.KeyboardEvent) => void; onItemActivate: ItemActivate; onGroupToggle: GroupToggle; + onDropdownFocusLeave: () => void; toggleDropdown: (options?: { moveHighlightOnOpen?: boolean }) => void; closeDropdown: () => void; setIsUsingMouse: (isUsingMouse: boolean) => void; + filteringValue: string; + setFilteringValue: (value: string) => void; + filteredItems: ButtonDropdownProps.Items; + showExpandableGroups: boolean; } export function useButtonDropdown({ @@ -37,7 +44,17 @@ export function useButtonDropdown({ hasExpandableGroups, isInRestrictedView = false, expandToViewport = false, + hasFiltering, }: UseButtonDropdownOptions): UseButtonDropdownApi { + const [filteringValue, setFilteringValue] = useState(''); + + const filteredItems = useMemo( + () => (hasFiltering && filteringValue ? filterItems(items, filteringValue) : items), + [hasFiltering, filteringValue, items] + ); + + const showExpandableGroups = hasExpandableGroups && !filteringValue; + const { targetItem, isHighlighted, @@ -50,20 +67,50 @@ export function useButtonDropdown({ reset, setIsUsingMouse, } = useHighlightedMenu({ - items, - hasExpandableGroups, + items: filteredItems, + hasExpandableGroups: showExpandableGroups, isInRestrictedView, }); - const { isOpen, closeDropdown, ...openStateProps } = useOpenState({ onClose: reset }); + // If the filtering input changes, the options list also changes, + // so the highlight isn't valid anymore. + const prevFilteringValue = useRef(filteringValue); + useEffect(() => { + if (prevFilteringValue.current !== filteringValue) { + prevFilteringValue.current = filteringValue; + reset(); + } + }, [filteringValue, reset]); + + const { isOpen, closeDropdown: closeDropdownState, ...openStateProps } = useOpenState({ onClose: reset }); + + const closeDropdown = () => { + setFilteringValue(''); + closeDropdownState(); + }; + const toggleDropdown = (options: { moveHighlightOnOpen?: boolean } = {}) => { const moveHighlightOnOpen = options.moveHighlightOnOpen ?? true; - if (!isOpen && moveHighlightOnOpen) { + if (!isOpen && moveHighlightOnOpen && !hasFiltering) { moveHighlight(1); } + if (isOpen) { + setFilteringValue(''); + } openStateProps.toggleDropdown(); }; + const onDropdownFocusLeave = () => { + if (hasFiltering && isOpen) { + if (expandToViewport) { + // When expanded to viewport the focus can't move naturally to the next element. + // Returning the focus to the trigger instead. + onReturnFocus(); + } + closeDropdown(); + } + }; + const onGroupToggle: GroupToggle = item => (!isExpanded(item) ? expandGroup(item) : collapseGroup()); const onItemActivate: ItemActivate = (item, event) => { @@ -126,7 +173,9 @@ export function useButtonDropdown({ case KeyCode.down: { if (!isOpen) { toggleDropdown(); - moveHighlight(1, true); + if (!hasFiltering) { + moveHighlight(1, true); + } } else { moveHighlight(1); } @@ -136,7 +185,9 @@ export function useButtonDropdown({ case KeyCode.up: { if (!isOpen) { toggleDropdown(); - moveHighlight(-1, true); + if (!hasFiltering) { + moveHighlight(-1, true); + } } else { moveHighlight(-1); } @@ -144,11 +195,20 @@ export function useButtonDropdown({ break; } case KeyCode.space: { - // Prevent scrolling the list of items and highlighting the trigger - event.preventDefault(); + // Prevent scrolling the list of items and highlighting the trigger when filtering + // isn't active. Hitting space when filtering is active should just type a space + // character into the input, or activate the clear button. + if (!hasFiltering && !isOpen) { + event.preventDefault(); + } break; } case KeyCode.enter: { + // While filtering, pressing Enter without a highlighted item should not select + // any item or close the dropdown. + if (isOpen && hasFiltering && !targetItem) { + break; + } if (!targetItem?.disabled) { activate(event, true); } @@ -156,6 +216,11 @@ export function useButtonDropdown({ } case KeyCode.left: case KeyCode.right: { + // When filtering is enabled and there's text in the filter input, left/right + // arrow keys should move the caret between the characters. + if (hasFiltering && filteringValue) { + break; + } if (targetItem && !targetItem.disabled && isItemGroup(targetItem) && !isExpanded(targetItem)) { expandGroup(); } else if (hasExpandableGroups) { @@ -175,6 +240,13 @@ export function useButtonDropdown({ break; } case KeyCode.tab: { + // In filtering mode the dropdown contains multiple focusable elements (the filter + // input and its clear button). Tabbing between them must not close the dropdown, so + // closing on Tab is handled by onDropdownFocusLeave instead, which only fires once + // focus actually leaves the dropdown. + if (hasFiltering) { + break; + } // When expanded to viewport the focus can't move naturally to the next element. // Returning the focus to the trigger instead. if (expandToViewport) { @@ -186,10 +258,11 @@ export function useButtonDropdown({ } }; const onKeyUp = (event: React.KeyboardEvent) => { - // We need to handle activating items with Space separately because there is a bug - // in Firefox where changing the focus during a Space keydown event it will trigger - // unexpected click events on the new element: https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 - if (event.keyCode === KeyCode.space && !targetItem?.disabled) { + // When using a roving tabindex (filtering disabled), we need to handle activating items + // with Space separately because there is a bug in Firefox where changing the focus during + // a Space keydown event it will trigger unexpected click events on the new element. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 + if (event.keyCode === KeyCode.space && !targetItem?.disabled && !hasFiltering) { activate(event); } }; @@ -205,8 +278,13 @@ export function useButtonDropdown({ onKeyUp, onItemActivate, onGroupToggle, + onDropdownFocusLeave, toggleDropdown, closeDropdown, setIsUsingMouse, + filteringValue, + setFilteringValue, + filteredItems, + showExpandableGroups, }; } diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index f4323d354d..04f247f2a8 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -4,12 +4,15 @@ import { ComponentWrapper, createWrapper, ElementWrapper, usesDom } from '@cloud import { act } from '@cloudscape-design/test-utils-core/utils-dom'; import ButtonWrapper from '../button/index.js'; +import InputWrapper from '../input/index.js'; import buttonStyles from '../../../button/styles.selectors.js'; import categoryStyles from '../../../button-dropdown/category-elements/styles.selectors.js'; import itemStyles from '../../../button-dropdown/item-element/styles.selectors.js'; import styles from '../../../button-dropdown/styles.selectors.js'; import dropdownStyles from '../../../dropdown/styles.selectors.js'; +import inputStyles from '../../../input/styles.selectors.js'; +import footerStyles from '../../../internal/components/dropdown-status/styles.selectors.js'; function getItemSelector({ disabled }: { disabled?: boolean }): string { let selector = `.${itemStyles['item-element']}`; @@ -119,6 +122,27 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { return createWrapper().find(`[data-testid="button-dropdown-disabled-reason"]`); } + /** + * Finds the filtering input rendered inside the open dropdown when filtering is enabled. + * Returns null if there is no open dropdown or filtering is not enabled. + * + * This utility does not open the dropdown. To find the filtering input, call `openDropdown()` first. + */ + findFilteringInput(): InputWrapper | null { + return this.findOpenDropdown()?.findComponent(`.${inputStyles['input-container']}`, InputWrapper) ?? null; + } + + /** + * Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is + * entered, this contains content rendered by filteringResultsText if there are matching items and the `noMatch` + * content if there are none. Returns null if there is no open dropdown or the footer is not displayed. + * + * This utility does not open the dropdown. To find the footer region, call `openDropdown()` first. + */ + findFooterRegion(): ElementWrapper | null { + return this.findOpenDropdown()?.findByClassName(footerStyles.root) ?? null; + } + @usesDom openDropdown(): void { act(() => {