({
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,