From 03d2f6049afbd54045ede1d34435940309b84148 Mon Sep 17 00:00:00 2001 From: freddymeta Date: Sat, 1 Aug 2026 14:20:19 +0000 Subject: [PATCH 1/4] feat(selector): built-in search magnifier + clear button in dropdown search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The searchable dropdown (Selector & MultiSelector) now has two built-in search affordances rather than opt-in props: - A leading magnifier icon, rendered whenever `hasSearch` is on. It is decorative (aria-hidden) and sits outside the combobox contract. - A trailing clear (✕) button, rendered once a query is typed. Clicking it resets the query and returns focus to the input. It is a sibling of the input, not part of the combobox — the input remains the combobox and keeps its role/aria/keydown navigation. Both are on by default (standard search-field affordances) with no config knob. Themeable via selector-search-icon / selector-search-clear-icon and the multi-selector equivalents. Non-breaking, but note the magnifier is a new default glyph on existing hasSearch usages. --- .changeset/selector-search-affordances.md | 6 ++ .../stories/MultiSelector.stories.tsx | 3 +- apps/storybook/stories/Selector.stories.tsx | 37 ++++++++ packages/core/locales/en.json | 8 ++ .../src/MultiSelector/MultiSelector.doc.mjs | 4 +- .../src/MultiSelector/MultiSelector.test.tsx | 87 ++++++++++++++++++ .../core/src/MultiSelector/MultiSelector.tsx | 57 ++++++++++++ packages/core/src/Selector/Selector.doc.mjs | 4 +- packages/core/src/Selector/Selector.test.tsx | 89 +++++++++++++++++++ packages/core/src/Selector/Selector.tsx | 57 ++++++++++++ 10 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 .changeset/selector-search-affordances.md diff --git a/.changeset/selector-search-affordances.md b/.changeset/selector-search-affordances.md new file mode 100644 index 000000000000..d837c87e0fae --- /dev/null +++ b/.changeset/selector-search-affordances.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Selector & MultiSelector: the dropdown search field now has built-in affordances — a leading search magnifier icon and a trailing clear (✕) button that appears once a query is typed (clearing resets the query and refocuses the input). Themeable via `selector-search-icon` / `selector-search-clear-icon` (and the multi-selector equivalents). Non-breaking, but note the magnifier is a new default glyph, so existing `hasSearch` dropdowns gain a leading icon. +@freddymeta diff --git a/apps/storybook/stories/MultiSelector.stories.tsx b/apps/storybook/stories/MultiSelector.stories.tsx index 7e4c0c503e8d..bd560d7d59f0 100644 --- a/apps/storybook/stories/MultiSelector.stories.tsx +++ b/apps/storybook/stories/MultiSelector.stories.tsx @@ -149,7 +149,8 @@ export const SelectAll: Story = { decorators: [Story => ], }; -// Searchable +// Searchable: the dropdown search field has a built-in leading magnifier icon +// and a trailing clear (✕) button that appears once a query is typed. export const Searchable: Story = { render: () => { const [value, setValue] = useState([]); diff --git a/apps/storybook/stories/Selector.stories.tsx b/apps/storybook/stories/Selector.stories.tsx index 50930c1d53dc..a1f2882e6cc5 100644 --- a/apps/storybook/stories/Selector.stories.tsx +++ b/apps/storybook/stories/Selector.stories.tsx @@ -323,6 +323,43 @@ export const SearchableWithSections: Story = { }, }; +// Searchable: the dropdown search field has a built-in leading magnifier icon +// and a trailing clear (✕) button that appears once a query is typed. +export const Searchable: Story = { + render: args => { + const { + value: argsValue, + onChange: _onChange, + changeAction: _ca, + hasClear: _hc, + ...rest + } = args; + const [value, setValue] = useState(argsValue ?? undefined); + return ( + setValue(v)} + /> + ); + }, + args: { + placeholder: 'Select a fruit...', + }, +}; + // Custom render export const CustomRender: Story = { render: args => { diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index 9c4b6111001d..5214acee3edd 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -411,6 +411,10 @@ "defaultMessage": "Search options", "description": "Screen-reader-only accessible name for that same search input inside a MultiSelector." }, + "@astryx.multiSelector.clearSearch": { + "defaultMessage": "Clear search", + "description": "Screen-reader-only label on the X button that clears the typed query from a MultiSelector's dropdown search field. \"Clear\" = empty/reset the field." + }, "@astryx.multiSelector.selectAllPartiallySelected": { "defaultMessage": "{label}, partially selected", "description": "Accessible name for the MultiSelector select-all option while only some options are selected. `{label}` is the visible select-all label (e.g. \"Select all\"). ARIA forbids aria-selected=\"mixed\" on options, so the indeterminate state is conveyed through the name instead. \"Partially\" = some but not all." @@ -427,6 +431,10 @@ "defaultMessage": "Search options", "description": "\"Options\" = the list of choices in the dropdown. Screen-reader-only accessible name for the search input inside a Selector." }, + "@astryx.selector.clearSearch": { + "defaultMessage": "Clear search", + "description": "Screen-reader-only label on the X button that clears the typed query from a Selector's dropdown search field. \"Clear\" = empty/reset the field." + }, "@astryx.sideNav.label": { "defaultMessage": "Side navigation", "description": "Screen-reader-only accessible name for the primary vertical sidebar nav (usually on the left)." diff --git a/packages/core/src/MultiSelector/MultiSelector.doc.mjs b/packages/core/src/MultiSelector/MultiSelector.doc.mjs index bc2ae90f0426..5815918e2b19 100644 --- a/packages/core/src/MultiSelector/MultiSelector.doc.mjs +++ b/packages/core/src/MultiSelector/MultiSelector.doc.mjs @@ -26,6 +26,8 @@ export const docs = { className: 'astryx-multi-selector-indicator-icon', states: ['state'], }, + {className: 'astryx-multi-selector-search-icon'}, + {className: 'astryx-multi-selector-search-clear-icon'}, ], }, components: [ @@ -105,7 +107,7 @@ export const docs = { name: 'hasSearch', type: 'boolean', description: - 'Whether to show a search input for filtering options. As the user types, the match count (or "No results found") is announced to screen readers via a polite live region.', + 'Whether to show a search input for filtering options. As the user types, the match count (or "No results found") is announced to screen readers via a polite live region. The search field has built-in affordances: a leading magnifier icon and, once a query is typed, a trailing clear (✕) button that resets the query and returns focus to the input.', }, { name: 'searchPlaceholder', diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index 4f90b9760034..2fb8591ed28d 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -1681,3 +1681,90 @@ describe('MultiSelector indicator (chevron) icon theme target', () => { expect(css).toContain('color: var(--color-icon-primary)'); }); }); + +describe('MultiSelector search affordances', () => { + it('renders a decorative (aria-hidden) magnifier icon whenever hasSearch is on', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const search = screen.getByRole('combobox', h); + const wrapper = search.parentElement; + const magnifier = wrapper?.querySelector( + '.astryx-multi-selector-search-icon', + ); + expect(magnifier).toBeTruthy(); + expect(magnifier?.getAttribute('aria-hidden')).toBe('true'); + expect(magnifier?.getAttribute('aria-label')).toBeNull(); + }); + + it('renders the clear button once a query is typed and clears + refocuses on click', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const search = screen.getByRole('combobox', h); + await user.type(search, 'ap'); + expect(search).toHaveValue('ap'); + + const clear = screen.getByRole('button', { + name: 'Clear search', + hidden: true, + }); + expect( + clear.querySelector('.astryx-multi-selector-search-clear-icon'), + ).toBeTruthy(); + + await user.click(clear); + expect(search).toHaveValue(''); + expect(search).toHaveFocus(); + }); + + it('does not render the clear button when the query is empty', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + expect( + screen.queryByRole('button', {name: 'Clear search', hidden: true}), + ).not.toBeInTheDocument(); + }); + + it('keeps the combobox contract on the input, not the affordances', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const search = screen.getByRole('combobox', h); + expect(search.tagName).toBe('INPUT'); + expect(search).toHaveAttribute('aria-autocomplete', 'list'); + }); +}); diff --git a/packages/core/src/MultiSelector/MultiSelector.tsx b/packages/core/src/MultiSelector/MultiSelector.tsx index 241c98246211..2ce456349ef8 100644 --- a/packages/core/src/MultiSelector/MultiSelector.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.tsx @@ -212,11 +212,19 @@ const styles = stylex.create({ // Search input searchWrapper: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-1'], paddingInline: spacingVars['--spacing-2'], paddingBlock: spacingVars['--spacing-1'], }, + searchIcon: { + flexShrink: 0, + }, searchInput: { boxSizing: 'border-box', + flexGrow: 1, + minWidth: 0, width: '100%', paddingBlock: spacingVars['--spacing-1'], paddingInline: spacingVars['--spacing-2'], @@ -237,6 +245,24 @@ const styles = stylex.create({ }, outlineOffset: '0', }, + searchClearButton: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: 0, + margin: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + cursor: 'pointer', + borderRadius: radiusVars['--radius-element'], + outline: { + default: 'none', + ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`, + }, + outlineOffset: 1, + }, // Select-all wrapper selectAllWrapper: { @@ -1059,6 +1085,13 @@ export function MultiSelector({ } }, [optimisticValue, triggerDisplay, selectedLabels, placeholder, maxBadges]); + // Reset the search query and return focus to the search input so keyboard + // users aren't stranded after clearing. + const handleSearchClear = useCallback(() => { + setSearchQuery(''); + searchRef.current?.focus(); + }, []); + // Render search input const renderSearch = useCallback(() => { if (!hasSearch) { @@ -1066,6 +1099,15 @@ export function MultiSelector({ } return (
+ ({ placeholder={searchPlaceholder} {...stylex.props(styles.searchInput)} /> + {searchQuery.length > 0 && ( + + )}
); }, [ @@ -1119,6 +1175,7 @@ export function MultiSelector({ highlightedIndex, getItemId, t, + handleSearchClear, ]); // Render an individual item (index-based) diff --git a/packages/core/src/Selector/Selector.doc.mjs b/packages/core/src/Selector/Selector.doc.mjs index 1da9ec456cfa..eb92e7a3bb57 100644 --- a/packages/core/src/Selector/Selector.doc.mjs +++ b/packages/core/src/Selector/Selector.doc.mjs @@ -25,6 +25,8 @@ export const docs = { {className: 'astryx-selector-option'}, {className: 'astryx-selector-clear-icon'}, {className: 'astryx-selector-indicator-icon', states: ['state']}, + {className: 'astryx-selector-search-icon'}, + {className: 'astryx-selector-search-clear-icon'}, ], }, description: 'Dropdown selector for choosing from a list of options.', @@ -63,7 +65,7 @@ export const docs = { name: 'hasSearch', type: 'boolean', description: - 'Whether to show a search input for filtering options. As the user types, the match count (or "No results found") is announced to screen readers via a polite live region.', + 'Whether to show a search input for filtering options. As the user types, the match count (or "No results found") is announced to screen readers via a polite live region. The search field has built-in affordances: a leading magnifier icon and, once a query is typed, a trailing clear (✕) button that resets the query and returns focus to the input.', default: 'false', }, { diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index b14f246929a0..c484791da974 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -1473,3 +1473,92 @@ describe('Selector indicator (chevron) icon theme target', () => { expect(css).toContain('color: var(--color-icon-primary)'); }); }); + +describe('Selector search affordances', () => { + it('renders a decorative (aria-hidden) magnifier icon whenever hasSearch is on', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const search = screen.getByRole('combobox', {hidden: true}); + const wrapper = search.parentElement; + const magnifier = wrapper?.querySelector('.astryx-selector-search-icon'); + expect(magnifier).toBeTruthy(); + // Decorative: must be hidden from assistive tech and carry no accessible name. + expect(magnifier?.getAttribute('aria-hidden')).toBe('true'); + expect(magnifier?.getAttribute('aria-label')).toBeNull(); + }); + + it('renders the clear button once a query is typed and clears + refocuses on click', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const search = screen.getByRole('combobox', {hidden: true}); + await user.type(search, 'ap'); + expect(search).toHaveValue('ap'); + + const clear = screen.getByRole('button', { + name: 'Clear search', + hidden: true, + }); + expect( + clear.querySelector('.astryx-selector-search-clear-icon'), + ).toBeTruthy(); + + await user.click(clear); + expect(search).toHaveValue(''); + expect(search).toHaveFocus(); + }); + + it('does not render the clear button when the query is empty', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + expect( + screen.queryByRole('button', {name: 'Clear search', hidden: true}), + ).not.toBeInTheDocument(); + }); + + it('keeps the combobox contract on the input, not the affordances', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + // Exactly one combobox — the input. The magnifier and clear button are not + // part of the combobox contract. + const comboboxes = screen.getAllByRole('combobox', {hidden: true}); + expect(comboboxes).toHaveLength(1); + expect(comboboxes[0].tagName).toBe('INPUT'); + expect(comboboxes[0]).toHaveAttribute('aria-autocomplete', 'list'); + }); +}); diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 466f0d6edbfe..bea3f63a940f 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -193,11 +193,19 @@ const styles = stylex.create({ }, // Search input searchWrapper: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-1'], paddingInline: spacingVars['--spacing-2'], paddingBlock: spacingVars['--spacing-1'], }, + searchIcon: { + flexShrink: 0, + }, searchInput: { boxSizing: 'border-box', + flexGrow: 1, + minWidth: 0, width: '100%', paddingBlock: spacingVars['--spacing-1'], paddingInline: spacingVars['--spacing-2'], @@ -218,6 +226,24 @@ const styles = stylex.create({ }, outlineOffset: '0', }, + searchClearButton: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: 0, + margin: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + cursor: 'pointer', + borderRadius: radiusVars['--radius-element'], + outline: { + default: 'none', + ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`, + }, + outlineOffset: 1, + }, // Empty state emptyState: { @@ -850,6 +876,13 @@ export function Selector( [clearValue], ); + // Reset the search query and return focus to the search input so keyboard + // users aren't stranded after clearing. + const handleSearchClear = useCallback(() => { + setSearchQuery(''); + searchRef.current?.focus(); + }, []); + // Render search input const renderSearch = useCallback(() => { if (!hasSearch) { @@ -857,6 +890,15 @@ export function Selector( } return (
+ ( placeholder={searchPlaceholder} {...stylex.props(styles.searchInput)} /> + {searchQuery.length > 0 && ( + + )}
); }, [ @@ -911,6 +967,7 @@ export function Selector( highlightedIndex, getItemId, t, + handleSearchClear, ]); // Render an individual item From c6b67ae33c84590e6f4ebbf5d00e32d24aefa4af Mon Sep 17 00:00:00 2001 From: freddymeta Date: Mon, 3 Aug 2026 02:43:56 +0000 Subject: [PATCH 2/4] refactor(selector): build dropdown search on TextInput (startIcon + hasClear) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropdown search field was a bespoke flex row — a raw with a magnifier Icon and a clear - )} ); }, [ @@ -1175,7 +1109,6 @@ export function MultiSelector({ highlightedIndex, getItemId, t, - handleSearchClear, ]); // Render an individual item (index-based) diff --git a/packages/core/src/Selector/Selector.doc.mjs b/packages/core/src/Selector/Selector.doc.mjs index eb92e7a3bb57..c8df91ccb3d0 100644 --- a/packages/core/src/Selector/Selector.doc.mjs +++ b/packages/core/src/Selector/Selector.doc.mjs @@ -25,8 +25,6 @@ export const docs = { {className: 'astryx-selector-option'}, {className: 'astryx-selector-clear-icon'}, {className: 'astryx-selector-indicator-icon', states: ['state']}, - {className: 'astryx-selector-search-icon'}, - {className: 'astryx-selector-search-clear-icon'}, ], }, description: 'Dropdown selector for choosing from a list of options.', diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index c484791da974..7c5650ca3655 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -1488,10 +1488,12 @@ describe('Selector search affordances', () => { ); await user.click(screen.getByRole('button', {name: 'Fruit'})); const search = screen.getByRole('combobox', {hidden: true}); - const wrapper = search.parentElement; - const magnifier = wrapper?.querySelector('.astryx-selector-search-icon'); + // The search field is a TextInput; the magnifier is its startIcon, so it + // sits inside the input container as a sibling of the . + const container = search.parentElement; + const magnifier = container?.querySelector('.astryx-icon'); expect(magnifier).toBeTruthy(); - // Decorative: must be hidden from assistive tech and carry no accessible name. + // Decorative: the icon is hidden from assistive tech and carries no name. expect(magnifier?.getAttribute('aria-hidden')).toBe('true'); expect(magnifier?.getAttribute('aria-label')).toBeNull(); }); @@ -1512,13 +1514,12 @@ describe('Selector search affordances', () => { await user.type(search, 'ap'); expect(search).toHaveValue('ap'); + // The clear button is TextInput's built-in hasClear affordance; its name is + // derived from the field label ("Search options"). const clear = screen.getByRole('button', { - name: 'Clear search', + name: 'Clear Search options', hidden: true, }); - expect( - clear.querySelector('.astryx-selector-search-clear-icon'), - ).toBeTruthy(); await user.click(clear); expect(search).toHaveValue(''); @@ -1538,7 +1539,10 @@ describe('Selector search affordances', () => { ); await user.click(screen.getByRole('button', {name: 'Fruit'})); expect( - screen.queryByRole('button', {name: 'Clear search', hidden: true}), + screen.queryByRole('button', { + name: 'Clear Search options', + hidden: true, + }), ).not.toBeInTheDocument(); }); diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index bea3f63a940f..90a302b04e03 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -43,6 +43,7 @@ import {Divider} from '../Divider'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; import type {LayerPlacement} from '../Layer/useLayer'; import {Spinner} from '../Spinner'; +import {TextInput} from '../TextInput'; import {useAnnounce} from '../hooks/useAnnounce'; import { colorVars, @@ -191,59 +192,15 @@ const styles = stylex.create({ popover: { minWidth: 'anchor-size(width)', }, - // Search input + // Search field. The inner TextInput owns the border, focus ring, magnifier + // (startIcon), and clear button (hasClear); this wrapper only supplies the + // dropdown's inline/block padding around it. searchWrapper: { display: 'flex', alignItems: 'center', - gap: spacingVars['--spacing-1'], paddingInline: spacingVars['--spacing-2'], paddingBlock: spacingVars['--spacing-1'], }, - searchIcon: { - flexShrink: 0, - }, - searchInput: { - boxSizing: 'border-box', - flexGrow: 1, - minWidth: 0, - width: '100%', - paddingBlock: spacingVars['--spacing-1'], - paddingInline: spacingVars['--spacing-2'], - borderWidth: borderVars['--border-width'], - borderStyle: 'solid', - borderColor: colorVars['--color-border-emphasized'], - borderRadius: radiusVars['--radius-element'], - backgroundColor: colorVars['--color-background-surface'], - fontFamily: typographyVars['--font-family-body'], - fontSize: { - default: typeScaleVars['--text-label-size'], - '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--text-label-size']})`, - }, - color: colorVars['--color-text-primary'], - outline: { - default: 'none', - ':focus': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`, - }, - outlineOffset: '0', - }, - searchClearButton: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - padding: 0, - margin: 0, - borderWidth: 0, - borderStyle: 'none', - backgroundColor: 'transparent', - cursor: 'pointer', - borderRadius: radiusVars['--radius-element'], - outline: { - default: 'none', - ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`, - }, - outlineOffset: 1, - }, // Empty state emptyState: { @@ -763,8 +720,7 @@ export function Selector( // next query here fires the announcement exactly once per keystroke and does // not re-speak on unrelated re-renders. const handleSearchChange = useCallback( - (event: React.ChangeEvent) => { - const nextQuery = event.target.value; + (nextQuery: string) => { setSearchQuery(nextQuery); if (nextQuery.length === 0) { // Emptying the query clears the region rather than announcing a count. @@ -876,13 +832,6 @@ export function Selector( [clearValue], ); - // Reset the search query and return focus to the search input so keyboard - // users aren't stranded after clearing. - const handleSearchClear = useCallback(() => { - setSearchQuery(''); - searchRef.current?.focus(); - }, []); - // Render search input const renderSearch = useCallback(() => { if (!hasSearch) { @@ -890,22 +839,24 @@ export function Selector( } return (
- - via BaseProps. role="combobox" aria-expanded={popover.isOpen} aria-controls={listboxId} @@ -915,8 +866,6 @@ export function Selector( ? getItemId(highlightedIndex) : undefined } - aria-label={t('@astryx.selector.searchOptions')} - type="text" value={searchQuery} onChange={handleSearchChange} onKeyDown={e => { @@ -937,22 +886,7 @@ export function Selector( } }} placeholder={searchPlaceholder} - {...stylex.props(styles.searchInput)} /> - {searchQuery.length > 0 && ( - - )}
); }, [ @@ -967,7 +901,6 @@ export function Selector( highlightedIndex, getItemId, t, - handleSearchClear, ]); // Render an individual item From d410f9a76ddbbb63cab1cc3abb6fb2c1d59d2b1b Mon Sep 17 00:00:00 2001 From: freddymeta Date: Mon, 3 Aug 2026 03:12:41 +0000 Subject: [PATCH 3/4] fix(selector): flush search field + make clear button keyboard-reachable - width=100% so the search TextInput fills the dropdown (was sized to content, leaving a gap at the end). - Forward-Tab from the search input no longer dismisses the popup when a query is showing the clear (X) button; instead focus moves to that button so it is keyboard-reachable. Tabbing off the button (or Tab with no query) dismisses as before. Shift+Tab returns to the input natively. --- packages/core/src/Selector/Selector.tsx | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 90a302b04e03..6a483342c38a 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -627,6 +627,9 @@ export function Selector( const inputGroup = useInputGroup(); const [searchQuery, setSearchQuery] = useState(''); + // A typed query shows TextInput's built-in clear (✕) button, which becomes + // the next tab stop after the search input. + const hasQuery = searchQuery.length > 0; const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(normalizedValue); @@ -838,7 +841,21 @@ export function Selector( return null; } return ( -
+
{ + // The clear (✕) button lives inside the TextInput, after the input in + // DOM order. When it is focused and the user tabs forward there is + // nothing else in the popup, so dismiss it (Shift+Tab returns to the + // input natively). Key events originating on the input are handled on + // the input below; ignore them here so we don't double-dismiss. + if (e.target === searchRef.current) { + return; + } + if (e.key === 'Tab' && !e.shiftKey) { + onKeyDown(e); + } + }}> ( startIcon="search" hasClear size="sm" + // Fill the dropdown's width (minus the wrapper's inline padding) so + // the field is flush end-to-end rather than sized to its content. + width="100%" // When hasSearch is set, focus moves into this input on open, so it — // not the trigger — must be the combobox that reports the highlighted // option via aria-activedescendant (comboboxes-4). A bare searchbox @@ -869,7 +889,7 @@ export function Selector( value={searchQuery} onChange={handleSearchChange} onKeyDown={e => { - // Arrow keys navigate options; Enter selects; Escape/Tab close. + // Arrow keys navigate options; Enter selects; Escape closes. // Home/End are left to the input for caret movement (APG editable // combobox); PageUp/PageDown are the sanctioned substitute for // jumping to the first/last option. @@ -879,10 +899,16 @@ export function Selector( e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Enter' || - e.key === 'Escape' || - e.key === 'Tab' + e.key === 'Escape' ) { onKeyDown(e); + return; + } + // Tab: when a query is showing the clear (✕) button, forward-tab + // moves focus to it (keeping the popup open) so the affordance is + // keyboard-reachable. Every other Tab dismisses the popup as usual. + if (e.key === 'Tab' && (e.shiftKey || !hasQuery)) { + onKeyDown(e); } }} placeholder={searchPlaceholder} @@ -894,6 +920,7 @@ export function Selector( searchId, listboxId, searchQuery, + hasQuery, searchPlaceholder, handleSearchChange, onKeyDown, From 325da2e2049d3bc3cffba3594354bf84fdd4c987 Mon Sep 17 00:00:00 2001 From: freddymeta Date: Mon, 3 Aug 2026 03:31:58 +0000 Subject: [PATCH 4/4] fix(multi-selector): mirror flush + keyboard-reachable clear; add Tab tests Apply the same width=100% flush and Tab-to-clear-button behavior to MultiSelector's dropdown search, and cover the new keyboard behavior in both Selector and MultiSelector suites (Tab moves to the clear button when a query is present; Tab with no query dismisses). --- .../src/MultiSelector/MultiSelector.test.tsx | 52 +++++++++++++++++++ .../core/src/MultiSelector/MultiSelector.tsx | 35 +++++++++++-- packages/core/src/Selector/Selector.test.tsx | 29 +++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index 89e1bc0160f0..e4e037fd87eb 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -1769,4 +1769,56 @@ describe('MultiSelector search affordances', () => { expect(search.tagName).toBe('INPUT'); expect(search).toHaveAttribute('aria-autocomplete', 'list'); }); + + it('tabs from the search input to the clear button (keeping the popup open) when a query is showing it', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const trigger = screen.getByRole('button', {name: 'Fruit'}); + const search = screen.getByRole('combobox', h); + await user.type(search, 'ap'); + expect(search).toHaveFocus(); + + // Forward-tab lands on the clear (✕) button and the popup stays open, so + // the affordance is keyboard-reachable rather than being skipped when the + // input's Tab dismisses the popup. + await user.tab(); + const clear = screen.getByRole('button', { + name: 'Clear Search options', + hidden: true, + }); + expect(clear).toHaveFocus(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('dismisses on Tab from the search input when there is no query (no clear button)', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + const trigger = screen.getByRole('button', {name: 'Fruit'}); + await user.click(trigger); + const search = screen.getByRole('combobox', h); + // Focus moves into the search input on open (via rAF). + await waitFor(() => expect(search).toHaveFocus()); + + // With no query there is no clear button, so Tab dismisses the popup as a + // plain combobox does. + await user.tab(); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); }); diff --git a/packages/core/src/MultiSelector/MultiSelector.tsx b/packages/core/src/MultiSelector/MultiSelector.tsx index 3766e749be06..07e943a7bc2c 100644 --- a/packages/core/src/MultiSelector/MultiSelector.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.tsx @@ -652,6 +652,9 @@ export function MultiSelector({ const inputGroup = useInputGroup(); const [searchQuery, setSearchQuery] = useState(''); + // A typed query shows TextInput's built-in clear (✕) button, which becomes + // the next tab stop after the search input. + const hasQuery = searchQuery.length > 0; // Snapshot of which values were selected when the dropdown opened. // Stored as state (not a ref) so sortedItems recomputes exactly once on open, @@ -1047,7 +1050,21 @@ export function MultiSelector({ return null; } return ( -
+
{ + // The clear (✕) button lives inside the TextInput, after the input in + // DOM order. When it is focused and the user tabs forward there is + // nothing else in the popup, so dismiss it (Shift+Tab returns to the + // input natively). Key events originating on the input are handled on + // the input below; ignore them here so we don't double-dismiss. + if (e.target === searchRef.current) { + return; + } + if (e.key === 'Tab' && !e.shiftKey) { + onKeyDown(e); + } + }}> ({ startIcon="search" hasClear size="sm" + // Fill the dropdown's width (minus the wrapper's inline padding) so + // the field is flush end-to-end rather than sized to its content. + width="100%" // When hasSearch is set, focus moves into this input on open, so it — // not the trigger — must be the combobox reporting the highlighted // option via aria-activedescendant (comboboxes-4). role + aria-* pass @@ -1077,7 +1097,7 @@ export function MultiSelector({ value={searchQuery} onChange={handleSearchChange} onKeyDown={e => { - // Arrow keys navigate options; Enter toggles; Escape/Tab close. + // Arrow keys navigate options; Enter toggles; Escape closes. // Space and Home/End are left to the input (type a space / move // the caret) per the APG editable combobox; PageUp/PageDown are // the sanctioned substitute for jumping to the first/last option. @@ -1087,10 +1107,16 @@ export function MultiSelector({ e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Enter' || - e.key === 'Escape' || - e.key === 'Tab' + e.key === 'Escape' ) { onKeyDown(e); + return; + } + // Tab: when a query is showing the clear (✕) button, forward-tab + // moves focus to it (keeping the popup open) so the affordance is + // keyboard-reachable. Every other Tab dismisses the popup as usual. + if (e.key === 'Tab' && (e.shiftKey || !hasQuery)) { + onKeyDown(e); } }} placeholder={searchPlaceholder} @@ -1102,6 +1128,7 @@ export function MultiSelector({ searchId, listboxId, searchQuery, + hasQuery, searchPlaceholder, handleSearchChange, onKeyDown, diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index 7c5650ca3655..778b07593532 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -1565,4 +1565,33 @@ describe('Selector search affordances', () => { expect(comboboxes[0].tagName).toBe('INPUT'); expect(comboboxes[0]).toHaveAttribute('aria-autocomplete', 'list'); }); + + it('tabs from the search input to the clear button (keeping the popup open) when a query is showing it', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const trigger = screen.getByRole('button', {name: 'Fruit'}); + const search = screen.getByRole('combobox', {hidden: true}); + await user.type(search, 'ap'); + expect(search).toHaveFocus(); + + // Forward-tab lands on the clear (✕) button and the popup stays open, so + // the affordance is keyboard-reachable rather than being skipped when the + // input's Tab dismisses the popup. + await user.tab(); + const clear = screen.getByRole('button', { + name: 'Clear Search options', + hidden: true, + }); + expect(clear).toHaveFocus(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); });