diff --git a/.changeset/selector-search-affordances.md b/.changeset/selector-search-affordances.md new file mode 100644 index 000000000000..b22f6d7cff79 --- /dev/null +++ b/.changeset/selector-search-affordances.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Selector & MultiSelector: the dropdown search field is now a `TextInput`, so it gains that component's built-in affordances — a leading search magnifier (`startIcon`) rendered inside the field and a trailing clear (✕) button (`hasClear`) that appears once a query is typed and resets + refocuses on click. The field now shares TextInput's border, focus ring, and sizing, so it matches every other Astryx input instead of being a bespoke control. No new props or theme targets. 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/src/MultiSelector/MultiSelector.doc.mjs b/packages/core/src/MultiSelector/MultiSelector.doc.mjs index bc2ae90f0426..7b9e9ddb799c 100644 --- a/packages/core/src/MultiSelector/MultiSelector.doc.mjs +++ b/packages/core/src/MultiSelector/MultiSelector.doc.mjs @@ -105,7 +105,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..e4e037fd87eb 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -1681,3 +1681,144 @@ 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); + // The search field is a TextInput; the magnifier is its startIcon, so it + // sits inside the input container as a sibling of the . + const wrapper = search.parentElement; + const magnifier = wrapper?.querySelector('.astryx-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'); + + // 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 options', + hidden: true, + }); + + 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 options', + 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'); + }); + + 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 241c98246211..07e943a7bc2c 100644 --- a/packages/core/src/MultiSelector/MultiSelector.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.tsx @@ -41,6 +41,7 @@ import { } from '../Field'; import {Divider} from '../Divider'; import {Spinner} from '../Spinner'; +import {TextInput} from '../TextInput'; import {CheckboxInput} from '../CheckboxInput'; import {Badge} from '../Badge'; import { @@ -210,33 +211,15 @@ const styles = stylex.create({ marginBlockStart: spacingVars['--spacing-1'], }, - // 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', paddingInline: spacingVars['--spacing-2'], paddingBlock: spacingVars['--spacing-1'], }, - searchInput: { - boxSizing: 'border-box', - 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', - }, // Select-all wrapper selectAllWrapper: { @@ -669,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, @@ -818,8 +804,7 @@ export function MultiSelector({ // not re-speak on unrelated re-renders. Reuses the announce instance shared // with the selection-count announcements above. 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. @@ -1065,13 +1050,41 @@ 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); + } + }}> + via BaseProps. role="combobox" aria-expanded={popover.isOpen} aria-controls={listboxId} @@ -1081,12 +1094,10 @@ export function MultiSelector({ ? getItemId(highlightedIndex) : undefined } - aria-label={t('@astryx.multiSelector.searchOptions')} - type="text" 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. @@ -1096,14 +1107,19 @@ 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} - {...stylex.props(styles.searchInput)} />
); @@ -1112,6 +1128,7 @@ export function MultiSelector({ searchId, listboxId, searchQuery, + hasQuery, searchPlaceholder, handleSearchChange, onKeyDown, diff --git a/packages/core/src/Selector/Selector.doc.mjs b/packages/core/src/Selector/Selector.doc.mjs index 1da9ec456cfa..c8df91ccb3d0 100644 --- a/packages/core/src/Selector/Selector.doc.mjs +++ b/packages/core/src/Selector/Selector.doc.mjs @@ -63,7 +63,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..778b07593532 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -1473,3 +1473,125 @@ 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}); + // 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: the icon is hidden from assistive tech and carries no 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'); + + // 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 options', + hidden: true, + }); + + 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 options', + 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'); + }); + + 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'); + }); +}); diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 466f0d6edbfe..6a483342c38a 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,33 +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', paddingInline: spacingVars['--spacing-2'], paddingBlock: spacingVars['--spacing-1'], }, - searchInput: { - boxSizing: 'border-box', - 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', - }, // Empty state emptyState: { @@ -644,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); @@ -737,8 +723,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. @@ -856,14 +841,42 @@ 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); + } + }}> + via BaseProps. role="combobox" aria-expanded={popover.isOpen} aria-controls={listboxId} @@ -873,12 +886,10 @@ export function Selector( ? getItemId(highlightedIndex) : undefined } - aria-label={t('@astryx.selector.searchOptions')} - type="text" 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. @@ -888,14 +899,19 @@ 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} - {...stylex.props(styles.searchInput)} />
); @@ -904,6 +920,7 @@ export function Selector( searchId, listboxId, searchQuery, + hasQuery, searchPlaceholder, handleSearchChange, onKeyDown,