From 0029b931b38e141d7eef08abf42f5a8d5269c633 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 23 Jun 2026 18:05:37 +0200 Subject: [PATCH 1/2] feat: Add filtering to button dropdown --- package.json | 2 +- pages/button-dropdown/filtering.page.tsx | 297 +++++++++ .../__snapshots__/documenter.test.ts.snap | 420 ++++++++---- .../button-dropdown-filtering.test.tsx | 598 ++++++++++++++++++ .../__tests__/filter-items.test.ts | 155 +++++ .../__tests__/render-item.test.tsx | 13 + .../category-elements/category-element.tsx | 7 + .../expandable-category-element.tsx | 28 +- .../mobile-expandable-category-element.tsx | 17 +- src/button-dropdown/filter.tsx | 38 ++ src/button-dropdown/index.tsx | 12 +- src/button-dropdown/interfaces.ts | 37 ++ src/button-dropdown/internal-interfaces.ts | 9 + src/button-dropdown/internal.tsx | 76 ++- src/button-dropdown/item-element/index.tsx | 74 ++- src/button-dropdown/items-list.tsx | 15 + src/button-dropdown/styles.scss | 6 + src/button-dropdown/tooltip.tsx | 10 +- src/button-dropdown/utils/filter-items.ts | 61 ++ .../utils/use-button-dropdown.ts | 103 ++- src/test-utils/dom/button-dropdown/index.ts | 24 + 21 files changed, 1825 insertions(+), 177 deletions(-) create mode 100644 pages/button-dropdown/filtering.page.tsx create mode 100644 src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx create mode 100644 src/button-dropdown/__tests__/filter-items.test.ts create mode 100644 src/button-dropdown/filter.tsx create mode 100644 src/button-dropdown/utils/filter-items.ts diff --git a/package.json b/package.json index 0274e72393..68584f0358 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,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..b9c8b5d2f3 --- /dev/null +++ b/pages/button-dropdown/filtering.page.tsx @@ -0,0 +1,297 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import SpaceBetween from '~components/space-between'; + +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 [checkboxItems, setCheckboxItems] = React.useState(withCheckboxItems); + const onItemClick = (event: CustomEvent) => console.log(event.detail); + + return ( +
+

Button Dropdown with Filtering

+ + +
+

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.} + ariaLabel="Editor actions" + 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" + 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 318c4ae8d9..21dba9125b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6111,6 +6111,44 @@ 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", + }, + { + "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", @@ -6730,6 +6768,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 @@ -6740,6 +6779,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 @@ -6750,6 +6790,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. @@ -6807,6 +6848,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", } @@ -34668,6 +34714,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 \`filteringType\` is set. +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 \`filteringType\` is set and there +are no matching items, this contains the \`noMatch\` content. 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. @@ -34819,6 +34895,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": [ { @@ -37530,108 +37708,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": [ { @@ -45313,6 +45389,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 \`filteringType\` is set. +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 \`filteringType\` is set and there +are no matching items, this contains the \`noMatch\` content. 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. @@ -46701,6 +46813,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 \`filteringType\` is set. +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 \`filteringType\` is set and there +are no matching items, this contains the \`noMatch\` content. 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. @@ -46805,6 +46942,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": [ { @@ -48772,30 +48933,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": [ { @@ -54228,6 +54365,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 \`filteringType\` is set. +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 \`filteringType\` is set and there +are no matching items, this contains the \`noMatch\` content. 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/__tests__/button-dropdown-filtering.test.tsx b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx new file mode 100644 index 0000000000..a6a56765ac --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx @@ -0,0 +1,598 @@ +// 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 { 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' }, + ], + }, +]; + +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(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')); +} + +describe('ButtonDropdown filtering', () => { + beforeEach(() => { + jest.mocked(scrollElementIntoView).mockClear(); + }); + + describe('filter input rendering', () => { + test('does not render filter input when filteringType is not set', () => { + const { container, wrapper } = renderDropdown(); + wrapper.openDropdown(); + expect(getFilterInput(container)).toBeNull(); + }); + + test('renders filter input when filteringType="auto"', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + expect(getFilterInput(container)).not.toBeNull(); + }); + + test('filter input has combobox role and aria-expanded', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + const input = getFilterInput(container)!; + 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'); + }); + + test('does not render a fallback no match state when noMatch is not provided', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('zzz'); + expect(wrapper.findFooterRegion()).toBeNull(); + }); + }); + + 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)!; + + const dropdownEl = wrapper.findOpenDropdown()!.getElement(); + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: 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 { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + const menuItems = getMenuItems(container); + 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 dropdownEl = wrapper.findOpenDropdown()!.getElement(); + // Arrow down twice to reach the expandable group header + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: KeyCode.down }); + }); + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: 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)!; + const dropdownEl = wrapper.findOpenDropdown()!.getElement(); + + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: 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 = getMenuItems(container)[0].closest('li')!; + + act(() => { + fireEvent.mouseEnter(menuItemLi); + }); + + expect(document.activeElement).toBe(input); + }); + + test('without filtering, highlighted items receive DOM focus', () => { + const { wrapper } = renderDropdown(); + wrapper.openDropdown(); + + const dropdownEl = wrapper.findOpenDropdown()!.getElement(); + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: 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 { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + + const dropdownEl = wrapper.findOpenDropdown()!.getElement(); + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: KeyCode.down }); + }); + + const menuItems = getMenuItems(container); + 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(); + + const dropdownEl = wrapper.findOpenDropdown()!.getElement(); + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: 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('without filtering, highlighted item gets tabIndex=0', () => { + const { wrapper } = renderDropdown(); + wrapper.openDropdown(); + + const dropdownEl = wrapper.findOpenDropdown()!.getElement(); + act(() => { + fireEvent.keyDown(dropdownEl, { keyCode: KeyCode.down }); + }); + + const highlightedItem = wrapper.findHighlightedItem()!.find('[role="menuitem"]')!.getElement(); + expect(highlightedItem.getAttribute('tabindex')).toBe('0'); + }); + }); + + 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' }); + act(() => { + fireEvent.keyDown(wrapper.findNativeButton().getElement(), { keyCode: 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' }); + act(() => { + fireEvent.keyDown(wrapper.findNativeButton().getElement(), { keyCode: KeyCode.up }); + }); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(wrapper.findHighlightedItem()).toBeNull(); + }); + + test('opening with ArrowDown highlights the first item when filtering is not enabled', () => { + const { wrapper } = renderDropdown(); + act(() => { + fireEvent.keyDown(wrapper.findNativeButton().getElement(), { keyCode: KeyCode.down }); + }); + expect(wrapper.findHighlightedItem()).not.toBeNull(); + }); + + test('Escape clears the filtering value before closing the dropdown', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + expect(getFilterInput(container)!.value).toBe('Cut'); + + act(() => { + fireEvent.keyDown(getFilterInput(container)!, { keyCode: KeyCode.escape }); + }); + // First Escape only clears the filter; the dropdown stays open. + expect(wrapper.findOpenDropdown()).not.toBeNull(); + expect(getFilterInput(container)!.value).toBe(''); + + act(() => { + fireEvent.keyDown(getFilterInput(container)!, { keyCode: KeyCode.escape }); + }); + // Second Escape closes the dropdown. + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('Escape closes the dropdown directly when there is no filtering value', () => { + const { wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + act(() => { + fireEvent.keyDown(wrapper.findOpenDropdown()!.getElement(), { keyCode: KeyCode.escape }); + }); + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('moving focus from the filter input to the clear button keeps the dropdown open', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + const input = getFilterInput(container)!; + const clearButton = wrapper.findFilteringInput()!.findClearButton()!.getElement(); + act(() => { + input.focus(); + }); + act(() => { + clearButton.focus(); + }); + + // Tabbing forward to the clear button stays within the dropdown, so it remains open. + expect(document.activeElement).toBe(clearButton); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('moving focus back from the clear button to the filter input keeps the dropdown open', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + const input = getFilterInput(container)!; + const clearButton = wrapper.findFilteringInput()!.findClearButton()!.getElement(); + act(() => { + clearButton.focus(); + }); + act(() => { + input.focus(); + }); + + // Shift+Tabbing back to the input stays within the dropdown, so it remains open. + expect(document.activeElement).toBe(input); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('moving focus out of the dropdown closes it', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + const input = getFilterInput(container)!; + const outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + + act(() => { + input.focus(); + }); + act(() => { + outsideButton.focus(); + }); + + // Focus left the dropdown entirely, so it closes. + expect(wrapper.findOpenDropdown()).toBeNull(); + + document.body.removeChild(outsideButton); + }); + + test('Tab does not close the dropdown via keydown while filtering', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + const input = getFilterInput(container)!; + act(() => { + fireEvent.keyDown(input, { keyCode: KeyCode.tab }); + }); + + // Closing is handled by focus leaving the dropdown, not by the Tab keydown itself. + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('moving focus between the filter input and clear button keeps the dropdown open with expandToViewport', () => { + const { container, wrapper } = renderDropdown({ filteringType: 'auto', expandToViewport: true }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + const input = getFilterInput(container)!; + const clearButton = wrapper.findFilteringInput()!.findClearButton()!.getElement(); + act(() => { + input.focus(); + }); + act(() => { + clearButton.focus(); + }); + + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('Space does not activate the highlighted item while filtering', () => { + const onItemClick = jest.fn(); + const { container, wrapper } = renderDropdown({ filteringType: 'auto', onItemClick }); + wrapper.openDropdown(); + act(() => { + fireEvent.keyDown(wrapper.findOpenDropdown()!.getElement(), { keyCode: KeyCode.down }); + }); + act(() => { + fireEvent.keyUp(getFilterInput(container)!, { keyCode: KeyCode.space }); + }); + expect(onItemClick).not.toHaveBeenCalled(); + }); + + test('Enter does nothing when no item is highlighted while filtering', () => { + const onItemClick = jest.fn(); + const { container, 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. + act(() => { + fireEvent.keyDown(getFilterInput(container)!, { keyCode: KeyCode.enter }); + }); + expect(onItemClick).not.toHaveBeenCalled(); + expect(wrapper.findOpenDropdown()).not.toBeNull(); + }); + + test('Enter activates the highlighted item while filtering', () => { + const onItemClick = jest.fn(); + const { container, wrapper } = renderDropdown({ filteringType: 'auto', onItemClick }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Cut'); + + const input = getFilterInput(container)!; + act(() => { + fireEvent.keyDown(input, { keyCode: KeyCode.down }); + }); + act(() => { + fireEvent.keyDown(input, { keyCode: KeyCode.enter }); + }); + expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'i1' } })); + expect(wrapper.findOpenDropdown()).toBeNull(); + }); + + test('Left and Right arrow keys do not expand groups while filtering', () => { + const { container, wrapper } = renderDropdown({ + filteringType: 'auto', + items: expandableItems, + expandableGroups: true, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Instance'); + + const input = getFilterInput(container)!; + act(() => { + fireEvent.keyDown(input, { keyCode: KeyCode.right }); + }); + act(() => { + fireEvent.keyDown(input, { keyCode: KeyCode.left }); + }); + // Filtering renders groups flat, so the nested items are visible regardless of expansion. + 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 with ids and non-focusable tab indexes', () => { + const { container, wrapper } = renderDropdown({ + filteringType: 'auto', + items: expandableItems, + expandableGroups: true, + }); + wrapper.openDropdown(); + wrapper.findFilteringInput()!.setInputValue('Start'); + + const menuItems = getMenuItems(container); + expect(menuItems.length).toBeGreaterThan(0); + menuItems.forEach(item => { + expect(item.id).toBeTruthy(); + expect(item.getAttribute('tabindex')).toBe('-1'); + }); + }); + + 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(); + const categoryEl = wrapper.findExpandableCategoryById('states')!.getElement(); + // With filtering enabled the focus stays on the input, so mouse down is prevented on items. + expect(fireEvent.mouseDown(categoryEl)).toBe(false); + }); + + 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')!.getElement(); + expect(fireEvent.mouseDown(categoryEl)).toBe(true); + }); + }); +}); diff --git a/src/button-dropdown/__tests__/filter-items.test.ts b/src/button-dropdown/__tests__/filter-items.test.ts new file mode 100644 index 0000000000..fb6787cfe7 --- /dev/null +++ b/src/button-dropdown/__tests__/filter-items.test.ts @@ -0,0 +1,155 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ButtonDropdownProps } from '../interfaces'; +import { filterItems } from '../utils/filter-items'; +import { isItemGroup } from '../utils/utils'; + +const items: ButtonDropdownProps.Items = [ + { id: 'cut', text: 'Cut' }, + { id: 'copy', text: 'Copy' }, + { id: 'paste', text: 'Paste' }, + { id: 'settings', text: 'Settings', secondaryText: 'Configure preferences' }, + { id: 'tagged', text: 'Tagged item', labelTag: 'Beta' }, +]; + +const groupedItems: ButtonDropdownProps.Items = [ + { + text: 'Edit', + items: [ + { id: 'cut', text: 'Cut' }, + { id: 'copy', text: 'Copy' }, + { id: 'paste', text: 'Paste' }, + ], + }, + { + text: 'View', + items: [ + { id: 'zoom-in', text: 'Zoom in' }, + { id: 'zoom-out', text: 'Zoom out' }, + ], + }, + { id: 'settings', text: 'Settings' }, +]; + +describe('filterItems', () => { + it('returns all items when filterText is empty', () => { + expect(filterItems(items, '')).toBe(items); + }); + + it('filters items by text (case-insensitive)', () => { + const result = filterItems(items, 'cop'); + expect(result).toHaveLength(1); + expect((result[0] as ButtonDropdownProps.Item).id).toBe('copy'); + }); + + it('filters items by text (case-insensitive, uppercase query)', () => { + const result = filterItems(items, 'CUT'); + expect(result).toHaveLength(1); + expect((result[0] as ButtonDropdownProps.Item).id).toBe('cut'); + }); + + it('filters items by secondaryText', () => { + const result = filterItems(items, 'prefer'); + expect(result).toHaveLength(1); + expect((result[0] as ButtonDropdownProps.Item).id).toBe('settings'); + }); + + it('filters items by labelTag', () => { + const result = filterItems(items, 'beta'); + expect(result).toHaveLength(1); + expect((result[0] as ButtonDropdownProps.Item).id).toBe('tagged'); + }); + + it('returns empty array when nothing matches', () => { + const result = filterItems(items, 'xyz'); + expect(result).toHaveLength(0); + }); + + describe('groups', () => { + it('does not match on group title text', () => { + const result = filterItems(groupedItems, 'Edit'); + expect(result).toHaveLength(0); + }); + + it('includes group with only matching children when children match', () => { + const result = filterItems(groupedItems, 'zoom'); + expect(result).toHaveLength(1); + const group = result[0] as ButtonDropdownProps.ItemGroup; + expect(group.text).toBe('View'); + expect(group.items).toHaveLength(2); + }); + + it('includes group with partial matching children', () => { + const result = filterItems(groupedItems, 'cut'); + expect(result).toHaveLength(1); + const group = result[0] as ButtonDropdownProps.ItemGroup; + expect(group.text).toBe('Edit'); + expect(group.items).toHaveLength(1); + expect((group.items[0] as ButtonDropdownProps.Item).id).toBe('cut'); + }); + + it('includes flat items that match alongside groups', () => { + const result = filterItems(groupedItems, 'set'); + expect(result).toHaveLength(1); + expect((result[0] as ButtonDropdownProps.Item).id).toBe('settings'); + }); + + it('excludes groups with no matching children', () => { + const result = filterItems(groupedItems, 'paste'); + expect(result).toHaveLength(1); + const group = result[0] as ButtonDropdownProps.ItemGroup; + expect(group.text).toBe('Edit'); + expect(group.items).toHaveLength(1); + expect((group.items[0] as ButtonDropdownProps.Item).id).toBe('paste'); + }); + }); + + describe('nested groups', () => { + const nestedItems: ButtonDropdownProps.Items = [ + { + text: 'Compute', + items: [ + { id: 'launch', text: 'Launch instance' }, + { + text: 'Auto scaling', + items: [ + { id: 'create-asg', text: 'Create Auto Scaling group' }, + { id: 'delete-asg', text: 'Delete Auto Scaling group' }, + ], + }, + ], + }, + ]; + + it('matches an item nested inside a sub-group', () => { + const result = filterItems(nestedItems, 'create'); + expect(result).toHaveLength(1); + const group = result[0] as ButtonDropdownProps.ItemGroup; + expect(group.text).toBe('Compute'); + expect(group.items).toHaveLength(1); + expect((group.items[0] as ButtonDropdownProps.Item).id).toBe('create-asg'); + }); + + it('flattens descendants into the top-most group, keeping only its header', () => { + const result = filterItems(nestedItems, 'scaling'); + expect(result).toHaveLength(1); + const group = result[0] as ButtonDropdownProps.ItemGroup; + expect(group.text).toBe('Compute'); + // "Launch instance" does not match, but both auto-scaling actions do. + // The nested "Auto scaling" sub-group header is dropped; its items are + // hoisted directly into the top-level group. + expect(group.items).toHaveLength(2); + expect(group.items.every(item => !isItemGroup(item))).toBe(true); + expect((group.items[0] as ButtonDropdownProps.Item).id).toBe('create-asg'); + expect((group.items[1] as ButtonDropdownProps.Item).id).toBe('delete-asg'); + }); + + it('excludes sub-groups with no matching descendants', () => { + const result = filterItems(nestedItems, 'launch'); + expect(result).toHaveLength(1); + const group = result[0] as ButtonDropdownProps.ItemGroup; + expect(group.items).toHaveLength(1); + expect((group.items[0] as ButtonDropdownProps.Item).id).toBe('launch'); + }); + }); +}); 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..6ea4fe2860 100644 --- a/src/button-dropdown/category-elements/category-element.tsx +++ b/src/button-dropdown/category-elements/category-element.tsx @@ -25,6 +25,9 @@ const CategoryElement = ({ variant, position, renderItem, + filteringText, + filteringEnabled, + menuId, }: CategoryProps) => { const highlighted = isHighlighted(item); const groupProps: ButtonDropdownProps.GroupRenderItem = { @@ -35,6 +38,7 @@ const CategoryElement = ({ highlighted: !!highlighted, expanded: true, expandDirection: 'vertical', + filterText: filteringText, }; const renderResult = renderItem?.({ item: groupProps }) ?? null; @@ -85,6 +89,9 @@ const CategoryElement = ({ position={position} renderItem={renderItem} parentProps={groupProps} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} /> )} diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index a1301e0c42..3a41fcbe71 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -38,6 +38,9 @@ const ExpandableCategoryElement = ({ variant, position, renderItem, + filteringText, + filteringEnabled, + menuId, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); @@ -46,16 +49,24 @@ 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 => { + if (filteringEnabled) { + event.preventDefault(); } }; @@ -77,11 +88,13 @@ const ExpandableCategoryElement = ({ highlighted: !!highlighted, expanded: expanded, expandDirection: 'horizontal', + filterText: filteringText, }; const renderResult = renderItem?.({ item: groupProps }) ?? null; const trigger = item.text && ( ) : undefined @@ -190,6 +203,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..3726388669 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,9 @@ const MobileExpandableCategoryElement = ({ variant, position, renderItem, + filteringText, + filteringEnabled, + menuId, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); @@ -40,10 +43,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) { @@ -69,11 +72,13 @@ const MobileExpandableCategoryElement = ({ highlighted: !!highlighted, expanded: expanded, expandDirection: 'vertical', + filterText: filteringText, }; const renderResult = renderItem?.({ item: groupProps }) ?? 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..af1017b755 100644 --- a/src/button-dropdown/index.tsx +++ b/src/button-dropdown/index.tsx @@ -41,12 +41,17 @@ const ButtonDropdown = React.forwardRef( nativeMainActionAttributes, nativeTriggerAttributes, renderItem, + filteringType = 'none', + filteringPlaceholder, + filteringAriaLabel, + filteringClearAriaLabel, + noMatch, ...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 +93,11 @@ const ButtonDropdown = React.forwardRef( fullWidth={fullWidth} nativeMainActionAttributes={nativeMainActionAttributes} nativeTriggerAttributes={nativeTriggerAttributes} + filteringType={filteringType} + filteringPlaceholder={filteringPlaceholder} + filteringAriaLabel={filteringAriaLabel} + filteringClearAriaLabel={filteringClearAriaLabel} + noMatch={noMatch} {...getAnalyticsMetadataAttribute({ component: analyticsComponentMetadata, })} diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index a269438f46..8f254420f7 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,36 @@ 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; + + /** + * Displayed when filtering is enabled and there are no matches for the filtering input. + */ + noMatch?: React.ReactNode; + /** * Attributes to add to the native `button` element. * Some attributes will be automatically combined with internal attribute values: @@ -226,6 +259,7 @@ 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 ActionRenderItem { type: 'action'; @@ -234,6 +268,7 @@ export namespace ButtonDropdownProps { highlighted: boolean; disabled: boolean; parent: GroupRenderItem | null; + filterText?: string; } export interface CheckboxRenderItem { type: 'checkbox'; @@ -243,6 +278,7 @@ export namespace ButtonDropdownProps { highlighted: boolean; checked: boolean; parent: GroupRenderItem | null; + filterText?: string; } export interface GroupRenderItem { type: 'group'; @@ -252,6 +288,7 @@ export namespace ButtonDropdownProps { highlighted: boolean; expanded: boolean; expandDirection: 'vertical' | 'horizontal'; + filterText?: string; } export type RenderItem = ActionRenderItem | CheckboxRenderItem | GroupRenderItem; diff --git a/src/button-dropdown/internal-interfaces.ts b/src/button-dropdown/internal-interfaces.ts index 652793d9c2..50f772b4da 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -25,6 +25,9 @@ export interface CategoryProps extends HighlightProps { variant?: ItemListProps['variant']; position?: string; renderItem?: ButtonDropdownProps.ItemRenderer; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; } export interface ItemListProps extends HighlightProps { @@ -42,6 +45,9 @@ export interface ItemListProps extends HighlightProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; } export interface ItemProps { @@ -60,6 +66,9 @@ export interface ItemProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; } export interface InternalItem extends ButtonDropdownProps.Item { diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index f6c2d6fa15..33562d8625 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -13,6 +13,8 @@ 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 { useMobile } from '../internal/hooks/use-mobile'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode/index.js'; @@ -23,6 +25,7 @@ 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'; @@ -65,12 +68,19 @@ const InternalButtonDropdown = React.forwardRef( nativeMainActionAttributes, nativeTriggerAttributes, renderItem, + filteringType, + filteringPlaceholder, + filteringAriaLabel, + filteringClearAriaLabel, + noMatch, ...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,20 +115,36 @@ const InternalButtonDropdown = React.forwardRef( onKeyUp, onItemActivate, onGroupToggle, + onDropdownBlur, toggleDropdown, closeDropdown, setIsUsingMouse, + filteringValue, + setFilteringValue, + filteredItems, + effectiveHasExpandableGroups, } = useButtonDropdown({ items, onItemClick, onItemFollow, - // Scroll is unnecessary when moving focus back to the dropdown trigger. onReturnFocus: () => triggerRef.current?.focus({ preventScroll: true }), expandToViewport, hasExpandableGroups: expandableGroups, isInRestrictedView, + hasFiltering, }); + const filterRef = useRef(null); + + useEffect(() => { + if (isOpen && hasFiltering) { + // Delay to allow dropdown to render before focusing + requestAnimationFrame(() => { + filterRef.current?.focus(); + }); + } + }, [isOpen, hasFiltering]); + const handleMouseEvent = () => { setIsUsingMouse(true); }; @@ -187,7 +213,7 @@ const InternalButtonDropdown = React.forwardRef( ariaLabel, ariaExpanded: canBeOpened && isOpen, formAction: 'none', - ariaHaspopup: true, + ariaHaspopup: hasFiltering ? 'dialog' : true, nativeButtonAttributes: nativeTriggerAttributes, }; @@ -344,9 +370,35 @@ 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 dropdownStatus = useDropdownStatus({ + statusType: 'finished', + isNoMatch, + noMatch, + }); 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 +434,19 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport={expandToViewport} preferredAlignment={preferCenter ? 'center' : 'start'} onOutsideClick={() => toggleDropdown()} + // In filtering mode the dropdown is a dialog with several focusable elements, so we + // close it once focus leaves the trigger and the dropdown content rather than on Tab. + onBlur={hasFiltering ? onDropdownBlur : undefined} trigger={trigger} dropdownId={dropdownId} + header={filterElement} + ariaRole={hasFiltering ? 'dialog' : undefined} + ariaLabel={hasFiltering ? ariaLabel : undefined} + footer={ + dropdownStatus.content ? ( + + ) : null + } content={ <> {hasHeader && ( @@ -412,17 +475,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" > diff --git a/src/button-dropdown/item-element/index.tsx b/src/button-dropdown/item-element/index.tsx index d40f240cbf..1db8d9edcc 100644 --- a/src/button-dropdown/item-element/index.tsx +++ b/src/button-dropdown/item-element/index.tsx @@ -10,9 +10,11 @@ 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 { GeneratedAnalyticsMetadataButtonDropdownClick } from '../analytics-metadata/interfaces'; import { ButtonDropdownProps, LinkItem } from '../interfaces'; import { InternalCheckboxItem, InternalItem, ItemProps } from '../internal-interfaces'; @@ -40,6 +42,9 @@ const ItemElement = ({ linkStyle, renderItem, parentProps, + filteringText, + filteringEnabled, + menuId, }: ItemProps) => { const isLink = isLinkItem(item); const isCheckbox = isCheckboxItem(item); @@ -101,6 +106,9 @@ const ItemElement = ({ linkStyle={linkStyle} renderItem={renderItem} parentProps={parentProps} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} /> ); @@ -114,18 +122,38 @@ interface MenuItemProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + filteringEnabled?: boolean; + menuId?: string; } -function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, parentProps }: MenuItemProps) { +function MenuItem({ + index, + item, + disabled, + highlighted, + linkStyle, + renderItem, + parentProps, + filteringText, + filteringEnabled, + menuId, +}: 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 }; @@ -139,6 +167,7 @@ function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, p highlighted: highlighted, checked: item.checked, parent: parentProps ?? null, + filterText: filteringText, }, }; } else { @@ -150,6 +179,7 @@ function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, p disabled: disabled, highlighted: highlighted, parent: parentProps ?? null, + filterText: filteringText, }, }; } @@ -158,7 +188,9 @@ 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 menuItemProps: React.HTMLAttributes = { + id: itemDomId, 'aria-label': item.ariaLabel, className: clsx( styles['menu-item'], @@ -171,10 +203,9 @@ 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 filteringEnabled is true (filtering mode), focus stays on the filter input + // and we use aria-activedescendant. All items get tabIndex=-1. + tabIndex: filteringEnabled ? -1 : highlighted ? 0 : -1, ...(isCheckbox ? getMenuItemCheckboxProps({ disabled, checked: item.checked }) : getMenuItemProps({ disabled })), ...(isDisabledWithReason ? targetProps : {}), }; @@ -187,18 +218,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 +255,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 +280,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..291d640d8c 100644 --- a/src/button-dropdown/items-list.tsx +++ b/src/button-dropdown/items-list.tsx @@ -30,6 +30,9 @@ export default function ItemsList({ linkStyle, renderItem, parentProps, + filteringText, + filteringEnabled, + menuId, }: ItemListProps) { const isMobile = useMobile(); @@ -55,6 +58,9 @@ export default function ItemsList({ linkStyle={linkStyle} renderItem={renderItem} parentProps={parentProps} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} /> ); } @@ -77,6 +83,9 @@ export default function ItemsList({ variant={variant} position={`${position ? `${position},` : ''}${index + 1}`} renderItem={renderItem} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} /> ) : ( ) ) : null; @@ -117,6 +129,9 @@ export default function ItemsList({ variant={variant} position={`${position ? `${position},` : ''}${index + 1}`} renderItem={renderItem} + filteringText={filteringText} + filteringEnabled={filteringEnabled} + menuId={menuId} /> ); }); 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..c6a4ea2715 100644 --- a/src/button-dropdown/tooltip.tsx +++ b/src/button-dropdown/tooltip.tsx @@ -14,20 +14,22 @@ interface TooltipProps { content?: React.ReactNode; position?: 'top' | 'right' | 'bottom' | 'left'; className?: string; + show?: 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, show }: TooltipProps) { const ref = useRef(null); const isReducedMotion = useReducedMotion(ref); const { open, triggerProps } = useTooltipOpen(isReducedMotion ? 0 : DEFAULT_OPEN_TIMEOUT_IN_MS); + const isOpen = open || !!show; const portalClasses = usePortalModeClasses(ref); return ( {children} - {open && ( + {isOpen && ( -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 { + if (!filterText) { + return items; + } + + const searchText = filterText.toLowerCase(); + + return items.reduce((acc, item) => { + if (!isItemGroup(item)) { + if (matchesItem(item, searchText)) { + 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, searchText); + + 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; + }, []); +} diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index f166c78c71..f20bf0e1b3 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; + onDropdownBlur: () => void; toggleDropdown: (options?: { moveHighlightOnOpen?: boolean }) => void; closeDropdown: () => void; setIsUsingMouse: (isUsingMouse: boolean) => void; + filteringValue: string; + setFilteringValue: (value: string) => void; + filteredItems: ButtonDropdownProps.Items; + effectiveHasExpandableGroups: 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 effectiveHasExpandableGroups = hasExpandableGroups && !filteringValue; + const { targetItem, isHighlighted, @@ -50,20 +67,47 @@ export function useButtonDropdown({ reset, setIsUsingMouse, } = useHighlightedMenu({ - items, - hasExpandableGroups, + items: filteredItems, + hasExpandableGroups: effectiveHasExpandableGroups, isInRestrictedView, }); - const { isOpen, closeDropdown, ...openStateProps } = useOpenState({ onClose: reset }); + 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(); }; + // When filtering is enabled the dropdown holds several focusable elements (the filter input + // and its clear button), so closing on Tab is driven by focus actually leaving the dropdown + // rather than by the Tab keydown. The Dropdown's onBlur only fires once focus moves outside + // the trigger and the dropdown content, which is exactly when we want to close. + const onDropdownBlur = () => { + if (hasFiltering && isOpen) { + closeDropdown(); + } + }; + const onGroupToggle: GroupToggle = item => (!isExpanded(item) ? expandGroup(item) : collapseGroup()); const onItemActivate: ItemActivate = (item, event) => { @@ -92,7 +136,6 @@ export function useButtonDropdown({ }; const actOnParentDropdown = (event: React.KeyboardEvent) => { - // if there is no highlighted item we act on the trigger by opening or closing dropdown if (!targetItem) { if (isOpen && !isInRestrictedView) { toggleDropdown(); @@ -111,7 +154,6 @@ export function useButtonDropdown({ const activate = (event: React.KeyboardEvent, isEnter?: boolean) => { setIsUsingMouse(false); - // if item is a link we rely on default behavior of an anchor, no need to prevent if (targetItem && isLinkItem(targetItem) && isEnter) { return; } @@ -126,7 +168,9 @@ export function useButtonDropdown({ case KeyCode.down: { if (!isOpen) { toggleDropdown(); - moveHighlight(1, true); + if (!hasFiltering) { + moveHighlight(1, true); + } } else { moveHighlight(1); } @@ -136,7 +180,9 @@ export function useButtonDropdown({ case KeyCode.up: { if (!isOpen) { toggleDropdown(); - moveHighlight(-1, true); + if (!hasFiltering) { + moveHighlight(-1, true); + } } else { moveHighlight(-1); } @@ -144,11 +190,18 @@ export function useButtonDropdown({ break; } case KeyCode.space: { - // Prevent scrolling the list of items and highlighting the trigger - event.preventDefault(); + if (!hasFiltering) { + event.preventDefault(); + } break; } case KeyCode.enter: { + // While filtering, pressing Enter without a highlighted item should do nothing + // (matching select/multiselect) rather than closing the dropdown with no selection. + if (hasFiltering && !targetItem) { + event.preventDefault(); + break; + } if (!targetItem?.disabled) { activate(event, true); } @@ -156,6 +209,9 @@ export function useButtonDropdown({ } case KeyCode.left: case KeyCode.right: { + if (hasFiltering && filteringValue) { + break; + } if (targetItem && !targetItem.disabled && isItemGroup(targetItem) && !isExpanded(targetItem)) { expandGroup(); } else if (hasExpandableGroups) { @@ -166,6 +222,12 @@ export function useButtonDropdown({ break; } case KeyCode.escape: { + if (hasFiltering && filteringValue) { + setFilteringValue(''); + event.preventDefault(); + event.stopPropagation(); + break; + } onReturnFocus(); closeDropdown(); event.preventDefault(); @@ -175,8 +237,13 @@ export function useButtonDropdown({ break; } case KeyCode.tab: { - // When expanded to viewport the focus can't move naturally to the next element. - // Returning the focus to the trigger instead. + // 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 the focus-leave handler (onDropdownBlur) instead, which + // only fires once focus actually leaves the dropdown. + if (hasFiltering) { + break; + } if (expandToViewport) { onReturnFocus(); } @@ -186,10 +253,7 @@ 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) { + if (event.keyCode === KeyCode.space && !targetItem?.disabled && !hasFiltering) { activate(event); } }; @@ -205,8 +269,13 @@ export function useButtonDropdown({ onKeyUp, onItemActivate, onGroupToggle, + onDropdownBlur, toggleDropdown, closeDropdown, setIsUsingMouse, + filteringValue, + setFilteringValue, + filteredItems, + effectiveHasExpandableGroups, }; } diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index f4323d354d..0f7ba05cc6 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 `filteringType` is set. + * 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 `filteringType` is set and there + * are no matching items, this contains the `noMatch` content. 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(() => { From 3371c37feebf59d8fe20391442c00e44a9cd3452 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 26 Jun 2026 16:54:59 +0200 Subject: [PATCH 2/2] Non-test review improvements. --- .../__snapshots__/documenter.test.ts.snap | 16 ++--- .../button-dropdown-filtering.test.tsx | 22 +------ .../expandable-category-element.tsx | 6 ++ .../mobile-expandable-category-element.tsx | 5 ++ src/button-dropdown/internal.tsx | 17 +++--- src/button-dropdown/item-element/index.tsx | 2 +- src/button-dropdown/tooltip.tsx | 45 ++++++++------ src/button-dropdown/utils/filter-items.ts | 10 ++-- .../utils/use-button-dropdown.ts | 59 +++++++++++-------- src/test-utils/dom/button-dropdown/index.ts | 4 +- 10 files changed, 97 insertions(+), 89 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 21dba9125b..221d6ed171 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -34715,7 +34715,7 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop }, }, { - "description": "Finds the filtering input rendered inside the open dropdown when \`filteringType\` is set. + "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.", @@ -34727,7 +34727,7 @@ This utility does not open the dropdown. To find the filtering input, call \`ope }, }, { - "description": "Finds the footer region rendered at the bottom of the open dropdown. When \`filteringType\` is set and there + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and there are no matching items, this contains the \`noMatch\` content. Returns null if there is no open dropdown or the footer is not displayed. @@ -45390,7 +45390,7 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop }, }, { - "description": "Finds the filtering input rendered inside the open dropdown when \`filteringType\` is set. + "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.", @@ -45405,7 +45405,7 @@ This utility does not open the dropdown. To find the filtering input, call \`ope }, }, { - "description": "Finds the footer region rendered at the bottom of the open dropdown. When \`filteringType\` is set and there + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and there are no matching items, this contains the \`noMatch\` content. Returns null if there is no open dropdown or the footer is not displayed. @@ -46814,7 +46814,7 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop }, }, { - "description": "Finds the filtering input rendered inside the open dropdown when \`filteringType\` is set. + "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.", @@ -46826,7 +46826,7 @@ This utility does not open the dropdown. To find the filtering input, call \`ope }, }, { - "description": "Finds the footer region rendered at the bottom of the open dropdown. When \`filteringType\` is set and there + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and there are no matching items, this contains the \`noMatch\` content. Returns null if there is no open dropdown or the footer is not displayed. @@ -54366,7 +54366,7 @@ This utility does not open the dropdown. To find dropdown items, call \`openDrop }, }, { - "description": "Finds the filtering input rendered inside the open dropdown when \`filteringType\` is set. + "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.", @@ -54381,7 +54381,7 @@ This utility does not open the dropdown. To find the filtering input, call \`ope }, }, { - "description": "Finds the footer region rendered at the bottom of the open dropdown. When \`filteringType\` is set and there + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and there are no matching items, this contains the \`noMatch\` content. Returns null if there is no open dropdown or the footer is not displayed. diff --git a/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx index a6a56765ac..98d18e0e47 100644 --- a/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx @@ -320,27 +320,7 @@ describe('ButtonDropdown filtering', () => { expect(wrapper.findHighlightedItem()).not.toBeNull(); }); - test('Escape clears the filtering value before closing the dropdown', () => { - const { container, wrapper } = renderDropdown({ filteringType: 'auto' }); - wrapper.openDropdown(); - wrapper.findFilteringInput()!.setInputValue('Cut'); - expect(getFilterInput(container)!.value).toBe('Cut'); - - act(() => { - fireEvent.keyDown(getFilterInput(container)!, { keyCode: KeyCode.escape }); - }); - // First Escape only clears the filter; the dropdown stays open. - expect(wrapper.findOpenDropdown()).not.toBeNull(); - expect(getFilterInput(container)!.value).toBe(''); - - act(() => { - fireEvent.keyDown(getFilterInput(container)!, { keyCode: KeyCode.escape }); - }); - // Second Escape closes the dropdown. - expect(wrapper.findOpenDropdown()).toBeNull(); - }); - - test('Escape closes the dropdown directly when there is no filtering value', () => { + test('Escape closes the dropdown', () => { const { wrapper } = renderDropdown({ filteringType: 'auto' }); wrapper.openDropdown(); act(() => { diff --git a/src/button-dropdown/category-elements/expandable-category-element.tsx b/src/button-dropdown/category-elements/expandable-category-element.tsx index 3a41fcbe71..0842b89a19 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -65,6 +65,7 @@ const ExpandableCategoryElement = ({ }; const onMouseDown: React.MouseEventHandler = event => { + // Ensure that focus remains on the filtering input at all times. if (filteringEnabled) { event.preventDefault(); } @@ -102,6 +103,11 @@ const ExpandableCategoryElement = ({ [styles['is-focused']]: isKeyboardHighlighted, [styles['visual-refresh']]: isVisualRefresh, })} + // 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} ref={triggerRef} {...getMenuItemProps({ parent: true, expanded, disabled })} 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 3726388669..a40201044c 100644 --- a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx @@ -86,6 +86,11 @@ const MobileExpandableCategoryElement = ({ [styles.disabled]: disabled, [styles['is-focused']]: isKeyboardHighlighted, })} + // 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} ref={triggerRef} {...getMenuItemProps({ parent: true, disabled, expanded })} diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 33562d8625..e4c76474aa 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -115,18 +115,19 @@ const InternalButtonDropdown = React.forwardRef( onKeyUp, onItemActivate, onGroupToggle, - onDropdownBlur, + onDropdownFocusLeave, toggleDropdown, closeDropdown, setIsUsingMouse, filteringValue, setFilteringValue, filteredItems, - effectiveHasExpandableGroups, + showExpandableGroups, } = useButtonDropdown({ items, onItemClick, onItemFollow, + // Scroll is unnecessary when moving focus back to the dropdown trigger. onReturnFocus: () => triggerRef.current?.focus({ preventScroll: true }), expandToViewport, hasExpandableGroups: expandableGroups, @@ -137,11 +138,9 @@ const InternalButtonDropdown = React.forwardRef( const filterRef = useRef(null); useEffect(() => { + // Focus the filter input when it opens. if (isOpen && hasFiltering) { - // Delay to allow dropdown to render before focusing - requestAnimationFrame(() => { - filterRef.current?.focus(); - }); + filterRef.current?.focus(); } }, [isOpen, hasFiltering]); @@ -434,9 +433,7 @@ const InternalButtonDropdown = React.forwardRef( expandToViewport={expandToViewport} preferredAlignment={preferCenter ? 'center' : 'start'} onOutsideClick={() => toggleDropdown()} - // In filtering mode the dropdown is a dialog with several focusable elements, so we - // close it once focus leaves the trigger and the dropdown content rather than on Tab. - onBlur={hasFiltering ? onDropdownBlur : undefined} + onFocusLeave={onDropdownFocusLeave} trigger={trigger} dropdownId={dropdownId} header={filterElement} @@ -487,7 +484,7 @@ const InternalButtonDropdown = React.forwardRef( items={filteredItems} onItemActivate={onItemActivate} onGroupToggle={onGroupToggle} - hasExpandableGroups={effectiveHasExpandableGroups} + hasExpandableGroups={showExpandableGroups} targetItem={targetItem} isHighlighted={isHighlighted} isKeyboardHighlight={isKeyboardHighlight} diff --git a/src/button-dropdown/item-element/index.tsx b/src/button-dropdown/item-element/index.tsx index 1db8d9edcc..a67b253daa 100644 --- a/src/button-dropdown/item-element/index.tsx +++ b/src/button-dropdown/item-element/index.tsx @@ -241,7 +241,7 @@ function MenuItem({ content={item.disabledReason} position={tooltipPosition} className={styles['item-tooltip-wrapper']} - show={highlighted && filteringEnabled} + controlledOpen={highlighted && filteringEnabled} > {menuItem} {descriptionEl} diff --git a/src/button-dropdown/tooltip.tsx b/src/button-dropdown/tooltip.tsx index c6a4ea2715..da9dd0aa48 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,22 +14,21 @@ interface TooltipProps { content?: React.ReactNode; position?: 'top' | 'right' | 'bottom' | 'left'; className?: string; - show?: boolean; + controlledOpen?: boolean; } const DEFAULT_OPEN_TIMEOUT_IN_MS = 120; -export default function Tooltip({ children, content, position = 'right', className, show }: 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 isOpen = open || !!show; + const { open, triggerProps } = useTooltipOpen(controlledOpen, isReducedMotion ? 0 : DEFAULT_OPEN_TIMEOUT_IN_MS); const portalClasses = usePortalModeClasses(ref); return ( {children} - {isOpen && ( + {open && ( { + 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 index e994c90a7c..0550421843 100644 --- a/src/button-dropdown/utils/filter-items.ts +++ b/src/button-dropdown/utils/filter-items.ts @@ -3,11 +3,11 @@ import { ButtonDropdownProps } from '../interfaces'; import { isItemGroup } from './utils'; -function matchesString(value: string | undefined, searchText: string): boolean { +function matchesString(value: string | undefined, lowerCaseSearchText: string): boolean { if (!value) { return false; } - return value.toLowerCase().indexOf(searchText) > -1; + return value.toLowerCase().indexOf(lowerCaseSearchText) > -1; } function matchesItem(item: ButtonDropdownProps.Item | ButtonDropdownProps.CheckboxItem, searchText: string): boolean { @@ -23,11 +23,11 @@ export function filterItems(items: ButtonDropdownProps.Items, filterText: string return items; } - const searchText = filterText.toLowerCase(); + const lowerCaseSearchText = filterText.toLowerCase(); return items.reduce((acc, item) => { if (!isItemGroup(item)) { - if (matchesItem(item, searchText)) { + if (matchesItem(item, lowerCaseSearchText)) { acc.push(item); } return acc; @@ -36,7 +36,7 @@ export function filterItems(items: ButtonDropdownProps.Items, filterText: string // 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, searchText); + const matchingChildren = collectMatchingLeaves(item.items, lowerCaseSearchText); if (matchingChildren.length > 0) { acc.push({ ...item, items: matchingChildren }); diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index f20bf0e1b3..607e82f055 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -26,14 +26,14 @@ interface UseButtonDropdownApi extends HighlightProps { onKeyUp: (event: React.KeyboardEvent) => void; onItemActivate: ItemActivate; onGroupToggle: GroupToggle; - onDropdownBlur: () => void; + onDropdownFocusLeave: () => void; toggleDropdown: (options?: { moveHighlightOnOpen?: boolean }) => void; closeDropdown: () => void; setIsUsingMouse: (isUsingMouse: boolean) => void; filteringValue: string; setFilteringValue: (value: string) => void; filteredItems: ButtonDropdownProps.Items; - effectiveHasExpandableGroups: boolean; + showExpandableGroups: boolean; } export function useButtonDropdown({ @@ -53,7 +53,7 @@ export function useButtonDropdown({ [hasFiltering, filteringValue, items] ); - const effectiveHasExpandableGroups = hasExpandableGroups && !filteringValue; + const showExpandableGroups = hasExpandableGroups && !filteringValue; const { targetItem, @@ -68,10 +68,12 @@ export function useButtonDropdown({ setIsUsingMouse, } = useHighlightedMenu({ items: filteredItems, - hasExpandableGroups: effectiveHasExpandableGroups, + hasExpandableGroups: showExpandableGroups, isInRestrictedView, }); + // 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) { @@ -98,12 +100,13 @@ export function useButtonDropdown({ openStateProps.toggleDropdown(); }; - // When filtering is enabled the dropdown holds several focusable elements (the filter input - // and its clear button), so closing on Tab is driven by focus actually leaving the dropdown - // rather than by the Tab keydown. The Dropdown's onBlur only fires once focus moves outside - // the trigger and the dropdown content, which is exactly when we want to close. - const onDropdownBlur = () => { + 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(); } }; @@ -136,6 +139,7 @@ export function useButtonDropdown({ }; const actOnParentDropdown = (event: React.KeyboardEvent) => { + // if there is no highlighted item we act on the trigger by opening or closing dropdown if (!targetItem) { if (isOpen && !isInRestrictedView) { toggleDropdown(); @@ -154,6 +158,7 @@ export function useButtonDropdown({ const activate = (event: React.KeyboardEvent, isEnter?: boolean) => { setIsUsingMouse(false); + // if item is a link we rely on default behavior of an anchor, no need to prevent if (targetItem && isLinkItem(targetItem) && isEnter) { return; } @@ -190,16 +195,18 @@ export function useButtonDropdown({ break; } case KeyCode.space: { - if (!hasFiltering) { - event.preventDefault(); + // Hitting space when filtering is enabled should just type a space character + // into the input, or activate the clear button. + if (isOpen && hasFiltering) { + break; } + event.preventDefault(); break; } case KeyCode.enter: { - // While filtering, pressing Enter without a highlighted item should do nothing - // (matching select/multiselect) rather than closing the dropdown with no selection. - if (hasFiltering && !targetItem) { - event.preventDefault(); + // 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) { @@ -209,6 +216,8 @@ 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; } @@ -222,12 +231,6 @@ export function useButtonDropdown({ break; } case KeyCode.escape: { - if (hasFiltering && filteringValue) { - setFilteringValue(''); - event.preventDefault(); - event.stopPropagation(); - break; - } onReturnFocus(); closeDropdown(); event.preventDefault(); @@ -239,11 +242,13 @@ export function useButtonDropdown({ 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 the focus-leave handler (onDropdownBlur) instead, which - // only fires once focus actually leaves the dropdown. + // 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) { onReturnFocus(); } @@ -253,6 +258,10 @@ export function useButtonDropdown({ } }; const onKeyUp = (event: React.KeyboardEvent) => { + // 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); } @@ -269,13 +278,13 @@ export function useButtonDropdown({ onKeyUp, onItemActivate, onGroupToggle, - onDropdownBlur, + onDropdownFocusLeave, toggleDropdown, closeDropdown, setIsUsingMouse, filteringValue, setFilteringValue, filteredItems, - effectiveHasExpandableGroups, + showExpandableGroups, }; } diff --git a/src/test-utils/dom/button-dropdown/index.ts b/src/test-utils/dom/button-dropdown/index.ts index 0f7ba05cc6..621a4c4d6f 100644 --- a/src/test-utils/dom/button-dropdown/index.ts +++ b/src/test-utils/dom/button-dropdown/index.ts @@ -123,7 +123,7 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { } /** - * Finds the filtering input rendered inside the open dropdown when `filteringType` is set. + * 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. @@ -133,7 +133,7 @@ export default class ButtonDropdownWrapper extends ComponentWrapper { } /** - * Finds the footer region rendered at the bottom of the open dropdown. When `filteringType` is set and there + * Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and there * are no matching items, this contains the `noMatch` content. Returns null if there is no open dropdown or the * footer is not displayed. *