From bdf79d4525730263be9630a72e5df968f6b4f72c Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 16 Jun 2026 17:03:15 +0200 Subject: [PATCH 1/3] feat: Add filtering to button dropdown --- pages/button-dropdown/filtering.page.tsx | 221 ++++++++++++++++ .../button-dropdown-filtering.test.tsx | 241 ++++++++++++++++++ .../__tests__/filter-items.test.ts | 105 ++++++++ .../category-elements/category-element.tsx | 6 + .../expandable-category-element.tsx | 25 +- .../mobile-expandable-category-element.tsx | 14 +- src/button-dropdown/filter.tsx | 38 +++ src/button-dropdown/index.tsx | 12 +- src/button-dropdown/interfaces.ts | 51 +++- src/button-dropdown/internal.tsx | 120 ++++++--- src/button-dropdown/item-element/index.tsx | 58 ++++- src/button-dropdown/items-list.tsx | 13 + src/button-dropdown/styles.scss | 10 + src/button-dropdown/utils/filter-items.ts | 46 ++++ .../utils/use-button-dropdown.ts | 78 ++++-- 15 files changed, 963 insertions(+), 75 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/pages/button-dropdown/filtering.page.tsx b/pages/button-dropdown/filtering.page.tsx new file mode 100644 index 0000000000..3801b2683e --- /dev/null +++ b/pages/button-dropdown/filtering.page.tsx @@ -0,0 +1,221 @@ +// 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 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); + + return ( +
+

Button Dropdown with Filtering

+ + +
+

Flat items

+ + Actions + +
+ +
+

Grouped items (non-expandable)

+ + Menu + +
+ +
+

Expandable groups (collapse to flat when searching)

+ + Instance actions + +
+ +
+

With disabled items and disabled reasons

+ + Resource actions + +
+ +
+

With checkbox items

+ { + 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" + > + Actions (custom empty) + +
+ +
+

Primary variant with filtering

+ + Actions + +
+
+
+ ); +} 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..7b1591932d --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-filtering.test.tsx @@ -0,0 +1,241 @@ +// 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 createWrapper from '../../../lib/components/test-utils/dom'; +import { KeyCode } from '../../internal/keycode'; + +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 container.querySelector('input[role="combobox"]'); +} + +function getMenuItems(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')); +} + +describe('ButtonDropdown filtering', () => { + 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('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('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'); + }); + }); +}); 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..3dda800556 --- /dev/null +++ b/src/button-dropdown/__tests__/filter-items.test.ts @@ -0,0 +1,105 @@ +// 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'; + +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'); + }); + }); +}); diff --git a/src/button-dropdown/category-elements/category-element.tsx b/src/button-dropdown/category-elements/category-element.tsx index 7fbf7eadb1..1156387d20 100644 --- a/src/button-dropdown/category-elements/category-element.tsx +++ b/src/button-dropdown/category-elements/category-element.tsx @@ -24,6 +24,9 @@ const CategoryElement = ({ variant, position, renderItem, + filteringText, + preventItemFocus, + menuId, }: CategoryProps) => { const highlighted = isHighlighted(item); const groupProps: ButtonDropdownProps.GroupRenderItem = { @@ -84,6 +87,9 @@ const CategoryElement = ({ position={position} renderItem={renderItem} parentProps={groupProps} + filteringText={filteringText} + preventItemFocus={preventItemFocus} + 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 91cd949425..cf660ca132 100644 --- a/src/button-dropdown/category-elements/expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/expandable-category-element.tsx @@ -37,6 +37,8 @@ const ExpandableCategoryElement = ({ variant, position, renderItem, + preventItemFocus, + menuId, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); @@ -45,16 +47,24 @@ const ExpandableCategoryElement = ({ const ref = useRef(null); useEffect(() => { - if (triggerRef.current && highlighted && !expanded) { + if (triggerRef.current && highlighted && !expanded && !preventItemFocus) { triggerRef.current.focus(); } - }, [expanded, highlighted]); + }, [expanded, highlighted, preventItemFocus]); const onClick: React.MouseEventHandler = event => { if (!disabled) { event.preventDefault(); onGroupToggle(item, event); - triggerRef.current?.focus(); + if (!preventItemFocus) { + triggerRef.current?.focus(); + } + } + }; + + const onMouseDown: React.MouseEventHandler = event => { + if (preventItemFocus) { + event.preventDefault(); } }; @@ -81,6 +91,7 @@ const ExpandableCategoryElement = ({ const trigger = item.text && ( ) : undefined @@ -189,6 +199,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 61545d8aa3..15cb5cf55e 100644 --- a/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx +++ b/src/button-dropdown/category-elements/mobile-expandable-category-element.tsx @@ -32,6 +32,8 @@ const MobileExpandableCategoryElement = ({ variant, position, renderItem, + preventItemFocus, + menuId, }: CategoryProps) => { const highlighted = isHighlighted(item); const expanded = isExpanded(item); @@ -39,10 +41,10 @@ const MobileExpandableCategoryElement = ({ const triggerRef = React.useRef(null); useEffect(() => { - if (triggerRef.current && highlighted && !expanded) { + if (triggerRef.current && highlighted && !expanded && !preventItemFocus) { triggerRef.current.focus(); } - }, [expanded, highlighted]); + }, [expanded, highlighted, preventItemFocus]); const onClick = (e: React.MouseEvent) => { if (!disabled) { @@ -73,6 +75,7 @@ const MobileExpandableCategoryElement = ({ 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..3890d4e428 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, + filteringPlaceholder, + filteringAriaLabel, + filteringClearAriaLabel, + filteringEmpty, ...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} + filteringEmpty={filteringEmpty} {...getAnalyticsMetadataAttribute({ component: analyticsComponentMetadata, })} diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index 096041a77e..fef1c6455c 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -16,6 +16,32 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface ButtonDropdownProps extends BaseComponentProps, ExpandToViewport { + /** + * Enables filtering (search) for the dropdown items. + * When set to `"auto"`, a search input is rendered inside the dropdown and items are + * filtered client-side by substring match against their `text`, `secondaryText`, and `labelTag`. + */ + filteringType?: 'auto'; + + /** + * Placeholder text for the filtering input. Only relevant when `filteringType` is set. + */ + filteringPlaceholder?: string; + + /** + * ARIA label for the filtering input. Only relevant when `filteringType` is set. + */ + filteringAriaLabel?: string; + + /** + * ARIA label for the clear button inside the filtering input. Only relevant when `filteringType` is set. + */ + filteringClearAriaLabel?: string; + + /** + * Content displayed when filtering produces no matching items. Only relevant when `filteringType` is set. + */ + filteringEmpty?: React.ReactNode; /** * Array of objects with a number of supported types. * @@ -373,6 +399,9 @@ export interface CategoryProps extends HighlightProps { variant?: ItemListProps['variant']; position?: string; renderItem?: ButtonDropdownProps.ItemRenderer; + filteringText?: string; + preventItemFocus?: boolean; + menuId?: string; } export interface ItemListProps extends HighlightProps { @@ -390,6 +419,9 @@ export interface ItemListProps extends HighlightProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + preventItemFocus?: boolean; + menuId?: string; } export interface LinkItem extends ButtonDropdownProps.Item { @@ -412,6 +444,9 @@ export interface ItemProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + preventFocus?: boolean; + menuId?: string; } export interface InternalItem extends ButtonDropdownProps.Item { @@ -435,8 +470,22 @@ type InternalItems = ReadonlyArray; export type InternalItemOrGroup = InternalItem | InternalCheckboxItem | InternalItemGroup; export interface InternalButtonDropdownProps - extends Omit, + extends Omit< + ButtonDropdownProps, + | 'variant' + | 'items' + | 'filteringType' + | 'filteringPlaceholder' + | 'filteringAriaLabel' + | 'filteringClearAriaLabel' + | 'filteringEmpty' + >, InternalBaseComponentProps { + filteringType?: 'auto'; + filteringPlaceholder?: string; + filteringAriaLabel?: string; + filteringClearAriaLabel?: string; + filteringEmpty?: React.ReactNode; customTriggerBuilder?: (props: CustomTriggerProps) => React.ReactNode; variant?: ButtonDropdownProps['variant'] | 'navigation'; items: ReadonlyArray; diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index ead6b58ec8..22f475cd07 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -23,6 +23,7 @@ import { GeneratedAnalyticsMetadataButtonDropdownCollapse, GeneratedAnalyticsMetadataButtonDropdownExpand, } from './analytics-metadata/interfaces.js'; +import ButtonDropdownFilter from './filter'; import { ButtonDropdownProps, InternalButtonDropdownProps, InternalItem } from './interfaces'; import ItemsList from './items-list'; import { useButtonDropdown } from './utils/use-button-dropdown'; @@ -64,12 +65,19 @@ const InternalButtonDropdown = React.forwardRef( nativeMainActionAttributes, nativeTriggerAttributes, renderItem, + filteringType, + filteringPlaceholder, + filteringAriaLabel, + filteringClearAriaLabel, + filteringEmpty, ...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); @@ -107,17 +115,32 @@ const InternalButtonDropdown = React.forwardRef( 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 +210,7 @@ const InternalButtonDropdown = React.forwardRef( ariaLabel, ariaExpanded: canBeOpened && isOpen, formAction: 'none', - ariaHaspopup: true, + ariaHaspopup: hasFiltering ? 'dialog' : true, nativeButtonAttributes: nativeTriggerAttributes, }; @@ -348,6 +371,24 @@ const InternalButtonDropdown = React.forwardRef( 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) { @@ -385,6 +426,9 @@ const InternalButtonDropdown = React.forwardRef( onOutsideClick={() => toggleDropdown()} trigger={trigger} dropdownId={dropdownId} + header={filterElement} + ariaRole={hasFiltering ? 'dialog' : undefined} + ariaLabel={hasFiltering ? ariaLabel : undefined} content={ <> {hasHeader && ( @@ -409,35 +453,49 @@ const InternalButtonDropdown = React.forwardRef( )} )} - - - + {hasFiltering && filteringValue && filteredItems.length === 0 ? ( +
+ {filteringEmpty ?? ( + + No matches found + + )} +
+ ) : ( + + + + )} } /> diff --git a/src/button-dropdown/item-element/index.tsx b/src/button-dropdown/item-element/index.tsx index 71203d43ae..3746d30fa7 100644 --- a/src/button-dropdown/item-element/index.tsx +++ b/src/button-dropdown/item-element/index.tsx @@ -10,6 +10,7 @@ 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'; @@ -39,6 +40,9 @@ const ItemElement = ({ linkStyle, renderItem, parentProps, + filteringText, + preventFocus, + menuId, }: ItemProps) => { const isLink = isLinkItem(item); const isCheckbox = isCheckboxItem(item); @@ -100,6 +104,9 @@ const ItemElement = ({ linkStyle={linkStyle} renderItem={renderItem} parentProps={parentProps} + filteringText={filteringText} + preventFocus={preventFocus} + menuId={menuId} /> ); @@ -113,18 +120,32 @@ interface MenuItemProps { linkStyle?: boolean; renderItem?: ButtonDropdownProps.ItemRenderer; parentProps?: ButtonDropdownProps.GroupRenderItem; + filteringText?: string; + preventFocus?: boolean; + menuId?: string; } -function MenuItem({ index, item, disabled, highlighted, linkStyle, renderItem, parentProps }: MenuItemProps) { +function MenuItem({ + index, + item, + disabled, + highlighted, + linkStyle, + renderItem, + parentProps, + filteringText, + preventFocus, + menuId, +}: MenuItemProps) { const menuItemRef = useRef<(HTMLSpanElement & HTMLAnchorElement) | null>(null); const isCheckbox = isCheckboxItem(item); const isCurrentBreadcrumb = !isCheckbox && item.isCurrentBreadcrumb; useEffect(() => { - if (highlighted && menuItemRef.current) { + if (highlighted && menuItemRef.current && !preventFocus) { menuItemRef.current.focus(); } - }, [highlighted]); + }, [highlighted, preventFocus]); let itemProps: { item: ButtonDropdownProps.RenderItem }; @@ -157,7 +178,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'], @@ -170,10 +193,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 preventFocus is true (filtering mode), focus stays on the filter input + // and we use aria-activedescendant. All items get tabIndex=-1. + tabIndex: preventFocus ? -1 : highlighted ? 0 : -1, ...(isCheckbox ? getMenuItemCheckboxProps({ disabled, checked: item.checked }) : getMenuItemProps({ disabled })), ...(isDisabledWithReason ? targetProps : {}), }; @@ -186,11 +208,19 @@ 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 + ) : ( + + )} ); @@ -210,10 +240,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; @@ -233,18 +265,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 b0be93581a..b2fcd65624 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, + preventItemFocus, + menuId, }: ItemListProps) { const isMobile = useMobile(); @@ -55,6 +58,9 @@ export default function ItemsList({ linkStyle={linkStyle} renderItem={renderItem} parentProps={parentProps} + filteringText={filteringText} + preventFocus={preventItemFocus} + menuId={menuId} /> ); } @@ -77,6 +83,8 @@ export default function ItemsList({ variant={variant} position={`${position ? `${position},` : ''}${index + 1}`} renderItem={renderItem} + preventItemFocus={preventItemFocus} + menuId={menuId} /> ) : ( ) ) : null; @@ -117,6 +127,9 @@ export default function ItemsList({ variant={variant} position={`${position ? `${position},` : ''}${index + 1}`} renderItem={renderItem} + filteringText={filteringText} + preventItemFocus={preventItemFocus} + menuId={menuId} /> ); }); diff --git a/src/button-dropdown/styles.scss b/src/button-dropdown/styles.scss index 13a40729d0..799f444c9c 100644 --- a/src/button-dropdown/styles.scss +++ b/src/button-dropdown/styles.scss @@ -144,6 +144,16 @@ $dropdown-trigger-icon-offset: 2px; flex: 0 0 auto; } +.filter { + position: relative; + z-index: 4; + flex-shrink: 0; +} + +.filtering-empty { + /* empty state container */ +} + .test-utils-button-trigger { /* used in test-utils */ } diff --git a/src/button-dropdown/utils/filter-items.ts b/src/button-dropdown/utils/filter-items.ts new file mode 100644 index 0000000000..7364269610 --- /dev/null +++ b/src/button-dropdown/utils/filter-items.ts @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ButtonDropdownProps } from '../interfaces'; +import { isItemGroup } from './utils'; + +function matchesString(value: string | undefined, searchText: string): boolean { + if (!value) { + return false; + } + return value.toLowerCase().indexOf(searchText) > -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; + } + + const matchingChildren = item.items.filter( + child => !isItemGroup(child) && matchesItem(child as ButtonDropdownProps.Item, searchText) + ); + + if (matchingChildren.length > 0) { + acc.push({ ...item, items: matchingChildren }); + } + + return acc; + }, []); +} diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index a7d3961675..013806fa1d 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -1,11 +1,12 @@ // 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 { CancelableEventHandler, fireCancelableEvent, isPlainLeftClick } from '../../internal/events'; import { KeyCode } from '../../internal/keycode'; 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'; @@ -15,6 +16,7 @@ interface UseButtonDropdownOptions extends ButtonDropdownSettings { onItemFollow?: CancelableEventHandler; onReturnFocus: () => void; expandToViewport?: boolean; + hasFiltering?: boolean; } interface UseButtonDropdownApi extends HighlightProps { @@ -26,6 +28,10 @@ interface UseButtonDropdownApi extends HighlightProps { 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({ @@ -36,7 +42,17 @@ export function useButtonDropdown({ hasExpandableGroups, isInRestrictedView = false, expandToViewport = false, + hasFiltering = false, }: 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, @@ -49,17 +65,34 @@ 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(); }; @@ -91,7 +124,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(); @@ -110,7 +142,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; } @@ -125,7 +156,9 @@ export function useButtonDropdown({ case KeyCode.down: { if (!isOpen) { toggleDropdown(); - moveHighlight(1, true); + if (!hasFiltering) { + moveHighlight(1, true); + } } else { moveHighlight(1); } @@ -135,7 +168,9 @@ export function useButtonDropdown({ case KeyCode.up: { if (!isOpen) { toggleDropdown(); - moveHighlight(-1, true); + if (!hasFiltering) { + moveHighlight(-1, true); + } } else { moveHighlight(-1); } @@ -143,8 +178,9 @@ 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: { @@ -155,6 +191,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) { @@ -165,6 +204,12 @@ export function useButtonDropdown({ break; } case KeyCode.escape: { + if (hasFiltering && filteringValue) { + setFilteringValue(''); + event.preventDefault(); + event.stopPropagation(); + break; + } onReturnFocus(); closeDropdown(); event.preventDefault(); @@ -174,8 +219,6 @@ 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. if (expandToViewport) { onReturnFocus(); } @@ -185,10 +228,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); } }; @@ -207,5 +247,9 @@ export function useButtonDropdown({ toggleDropdown, closeDropdown, setIsUsingMouse, + filteringValue, + setFilteringValue, + filteredItems, + effectiveHasExpandableGroups, }; } From 5bfe560d2ac29f90e372c1acc221caff5ef12a10 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 16 Jun 2026 17:10:34 +0200 Subject: [PATCH 2/3] Increase size limit. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 368682e67d..4f168e88e6 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ { "path": "lib/components/internal/widget-exports.js", "brotli": false, - "limit": "1285 kB", + "limit": "1300 kB", "ignore": "react-dom" } ], From 27e96d97f5a39ac13b07a2f030e5fa2e12547071 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 16 Jun 2026 23:57:32 +0200 Subject: [PATCH 3/3] Update documenter and fix disabled reason bug. --- .../__snapshots__/documenter.test.ts.snap | 38 +++++++++++++++++++ src/button-dropdown/item-element/index.tsx | 7 +++- src/button-dropdown/tooltip.tsx | 10 ++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 09c5b8aae0..5499f34748 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -6107,6 +6107,39 @@ because fixed positioning results in a slight, visible lag when scrolling comple "optional": true, "type": "boolean", }, + { + "description": "ARIA label for the filtering input. Only relevant when \`filteringType\` is set.", + "name": "filteringAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "ARIA label for the clear button inside the filtering input. Only relevant when \`filteringType\` is set.", + "name": "filteringClearAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Placeholder text for the filtering input. Only relevant when \`filteringType\` is set.", + "name": "filteringPlaceholder", + "optional": true, + "type": "string", + }, + { + "description": "Enables filtering (search) for the dropdown items. +When set to \`"auto"\`, a search input is rendered inside the dropdown and items are +filtered client-side by substring match against their \`text\`, \`secondaryText\`, and \`labelTag\`.", + "inlineType": { + "name": "auto", + "type": "union", + "values": [ + "auto", + ], + }, + "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", @@ -6791,6 +6824,11 @@ When returning \`null\`, the default styling will be applied.", "isDefault": true, "name": "children", }, + { + "description": "Content displayed when filtering produces no matching items. Only relevant when \`filteringType\` is set.", + "isDefault": false, + "name": "filteringEmpty", + }, { "description": "Custom SVG icon. Equivalent to the \`svg\` slot of the [icon component](/components/icon/). Applies to the \`icon\` and \`inline-icon\` variants only. diff --git a/src/button-dropdown/item-element/index.tsx b/src/button-dropdown/item-element/index.tsx index 3746d30fa7..4f32bf910f 100644 --- a/src/button-dropdown/item-element/index.tsx +++ b/src/button-dropdown/item-element/index.tsx @@ -227,7 +227,12 @@ function MenuItem({ const { position } = useDropdownContext(); const tooltipPosition = position === 'bottom-left' || position === 'top-left' ? 'left' : 'right'; return isDisabledWithReason ? ( - + {menuItem} {descriptionEl} 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 && (