Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/selector-search-affordances.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion apps/storybook/stories/MultiSelector.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ export const SelectAll: Story = {
decorators: [Story => <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<string[]>([]);
Expand Down
37 changes: 37 additions & 0 deletions apps/storybook/stories/Selector.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Selector
{...rest}
label="Fruit"
hasSearch
options={[
'Apple',
'Apricot',
'Banana',
'Blueberry',
'Cherry',
'Grapefruit',
'Mango',
'Orange',
]}
value={value}
onChange={v => setValue(v)}
/>
);
},
args: {
placeholder: 'Select a fruit...',
},
};

// Custom render
export const CustomRender: Story = {
render: args => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/MultiSelector/MultiSelector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
141 changes: 141 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
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 <input>.
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(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
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(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
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(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
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(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
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(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
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');
});
});
85 changes: 51 additions & 34 deletions packages/core/src/MultiSelector/MultiSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -669,6 +652,9 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
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,
Expand Down Expand Up @@ -818,8 +804,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
// not re-speak on unrelated re-renders. Reuses the announce instance shared
// with the selection-count announcements above.
const handleSearchChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const nextQuery = event.target.value;
(nextQuery: string) => {
setSearchQuery(nextQuery);
if (nextQuery.length === 0) {
// Emptying the query clears the region rather than announcing a count.
Expand Down Expand Up @@ -1065,13 +1050,41 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
return null;
}
return (
<div {...stylex.props(styles.searchWrapper)}>
<input
<div
{...stylex.props(styles.searchWrapper)}
onKeyDown={e => {
// 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);
}
}}>
<TextInput
ref={searchRef}
id={searchId}
// The search field IS a TextInput: the leading magnifier is its
// `startIcon` and the trailing clear (✕) is its built-in `hasClear`
// (which resets the value and refocuses the input). We add no bespoke
// affordance chrome — the field just looks and behaves like every
// other Astryx input.
label={t('@astryx.multiSelector.searchOptions')}
isLabelHidden
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).
// option via aria-activedescendant (comboboxes-4). role + aria-* pass
// through to the underlying <input> via BaseProps.
role="combobox"
aria-expanded={popover.isOpen}
aria-controls={listboxId}
Expand All @@ -1081,12 +1094,10 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
? 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.
Expand All @@ -1096,14 +1107,19 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
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)}
/>
</div>
);
Expand All @@ -1112,6 +1128,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
searchId,
listboxId,
searchQuery,
hasQuery,
searchPlaceholder,
handleSearchChange,
onKeyDown,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Selector/Selector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
{
Expand Down
Loading
Loading