From 244ee06dacc93a8fcc9163965e3f424bdabba3a4 Mon Sep 17 00:00:00 2001 From: freddymeta Date: Fri, 31 Jul 2026 13:27:39 +0000 Subject: [PATCH] feat(selector): add search adornment slots to Selector & MultiSelector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The searchable dropdown's search input had no way to add a leading magnifier (or any adornment) without CSS that paints a pseudo-element — which can't hold a real icon component or a handler. Add `searchStartContent` / `searchEndContent` slots (only in `hasSearch` mode) rendered beside the search input. They're wrapped `aria-hidden` and are non-interactive, so the input keeps its combobox role, focus, and keyboard behavior, and the adornments never appear as extra controls in the listbox. The search wrapper becomes a flex row; the input fills it. This is the composition-friendly alternative to the EPS POC's inline-SVG `mask` magnifier: a consumer passes a real ``. Interactive clear-style affordances still belong on the value via `onChange` — these slots are decorative and not wired to the query. Tests assert the slots render aria-hidden beside the input (both components) and don't render without `hasSearch`; docs list both props. --- .changeset/selector-search-adornments.md | 6 +++ .../src/MultiSelector/MultiSelector.doc.mjs | 12 ++++++ .../src/MultiSelector/MultiSelector.test.tsx | 24 +++++++++++ .../core/src/MultiSelector/MultiSelector.tsx | 40 ++++++++++++++++++ packages/core/src/Selector/Selector.doc.mjs | 12 ++++++ packages/core/src/Selector/Selector.test.tsx | 39 ++++++++++++++++++ packages/core/src/Selector/Selector.tsx | 41 +++++++++++++++++++ 7 files changed, 174 insertions(+) create mode 100644 .changeset/selector-search-adornments.md diff --git a/.changeset/selector-search-adornments.md b/.changeset/selector-search-adornments.md new file mode 100644 index 000000000000..ec0ff2b26e73 --- /dev/null +++ b/.changeset/selector-search-adornments.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Selector & MultiSelector: add `searchStartContent` / `searchEndContent` slots for decorative adornments beside the dropdown search input (only in `hasSearch` mode) — e.g. a leading search/magnifier `Icon`. The slots are rendered `aria-hidden` beside the input, so the input keeps its combobox role, focus, and keyboard behavior; they are not wired to the search query. Non-breaking. +@freddymeta diff --git a/packages/core/src/MultiSelector/MultiSelector.doc.mjs b/packages/core/src/MultiSelector/MultiSelector.doc.mjs index 2239c882e952..0ccb9b72394e 100644 --- a/packages/core/src/MultiSelector/MultiSelector.doc.mjs +++ b/packages/core/src/MultiSelector/MultiSelector.doc.mjs @@ -113,6 +113,18 @@ export const docs = { description: 'Placeholder text for the search input.', default: "'Search...'", }, + { + name: 'searchStartContent', + type: 'ReactNode', + description: + 'Decorative content at the inline-start of the search input (only with hasSearch), e.g. a search Icon. Non-interactive; the input keeps its combobox role.', + }, + { + name: 'searchEndContent', + type: 'ReactNode', + description: + 'Decorative content at the inline-end of the search input (only with hasSearch). Not wired to the search query.', + }, { name: 'isDisabled', type: 'boolean', diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index 4f90b9760034..b24823d6cd63 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -601,6 +601,30 @@ describe('MultiSelector', () => { expect(searchInput).toHaveAttribute('aria-autocomplete', 'list'); }); + it('renders searchStartContent / searchEndContent beside the input', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + searchStartContent={S} + searchEndContent={E} + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const start = screen.getByTestId('search-start'); + const end = screen.getByTestId('search-end'); + expect(start).toBeInTheDocument(); + expect(end).toBeInTheDocument(); + // Decorative — must not be exposed as controls; the input stays the sole + // combobox. + expect(start.closest('[aria-hidden="true"]')).not.toBeNull(); + expect(end.closest('[aria-hidden="true"]')).not.toBeNull(); + }); + it('filters options when searching', async () => { const user = userEvent.setup(); render( diff --git a/packages/core/src/MultiSelector/MultiSelector.tsx b/packages/core/src/MultiSelector/MultiSelector.tsx index d6a1e430a939..ab7fefdfebf4 100644 --- a/packages/core/src/MultiSelector/MultiSelector.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.tsx @@ -212,9 +212,20 @@ const styles = stylex.create({ // Search input searchWrapper: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-2'], paddingInline: spacingVars['--spacing-2'], paddingBlock: spacingVars['--spacing-1'], }, + // Decorative adornment beside the search input (searchStartContent / + // searchEndContent). Non-interactive; the input owns focus + combobox role. + searchAdornment: { + display: 'inline-flex', + alignItems: 'center', + flexShrink: 0, + color: colorVars['--color-icon-secondary'], + }, searchInput: { boxSizing: 'border-box', width: '100%', @@ -536,6 +547,21 @@ export interface MultiSelectorProps< */ searchPlaceholder?: string; + /** + * Content rendered at the inline-start of the search input (only when + * `hasSearch`). Typically a search/magnifier `Icon`. Purely decorative — + * it sits beside the input and does not receive focus, so the input keeps + * its combobox role and behavior. + */ + searchStartContent?: ReactNode; + + /** + * Content rendered at the inline-end of the search input (only when + * `hasSearch`). For a non-interactive adornment; not wired to the search + * query. + */ + searchEndContent?: ReactNode; + /** * How to display selected items in the trigger. * - 'count': "3 selected" @@ -639,6 +665,8 @@ export function MultiSelector({ selectAllLabel: selectAllLabelFromProps, hasSearch = false, searchPlaceholder: searchPlaceholderFromProps, + searchStartContent, + searchEndContent, triggerDisplay = 'count', maxBadges = 3, renderOption, @@ -1066,6 +1094,11 @@ export function MultiSelector({ } return (
+ {searchStartContent != null && ( + + )} ({ placeholder={searchPlaceholder} {...stylex.props(styles.searchInput)} /> + {searchEndContent != null && ( + + )}
); }, [ @@ -1113,6 +1151,8 @@ export function MultiSelector({ listboxId, searchQuery, searchPlaceholder, + searchStartContent, + searchEndContent, handleSearchChange, onKeyDown, popover.isOpen, diff --git a/packages/core/src/Selector/Selector.doc.mjs b/packages/core/src/Selector/Selector.doc.mjs index aea837c88242..eafae0fc1597 100644 --- a/packages/core/src/Selector/Selector.doc.mjs +++ b/packages/core/src/Selector/Selector.doc.mjs @@ -72,6 +72,18 @@ export const docs = { description: 'Placeholder text for the search input.', default: "'Search...'", }, + { + name: 'searchStartContent', + type: 'ReactNode', + description: + 'Decorative content at the inline-start of the search input (only with hasSearch), e.g. a search Icon. Non-interactive; the input keeps its combobox role.', + }, + { + name: 'searchEndContent', + type: 'ReactNode', + description: + 'Decorative content at the inline-end of the search input (only with hasSearch). Not wired to the search query.', + }, { name: 'placeholder', type: 'string', diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index b14f246929a0..59dcfb626ed4 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -696,6 +696,45 @@ describe('Selector', () => { ).toBeInTheDocument(); }); + it('renders searchStartContent / searchEndContent beside the input', async () => { + const user = userEvent.setup(); + render( + {}} + hasSearch + searchStartContent={S} + searchEndContent={E} + />, + ); + await user.click(screen.getByRole('button', {name: 'Fruit'})); + const start = screen.getByTestId('search-start'); + const end = screen.getByTestId('search-end'); + expect(start).toBeInTheDocument(); + expect(end).toBeInTheDocument(); + // Adornments are decorative — they must not be exposed as controls, so + // the search input stays the sole combobox in the popup. + expect(start.closest('[aria-hidden="true"]')).not.toBeNull(); + expect(end.closest('[aria-hidden="true"]')).not.toBeNull(); + }); + + it('does not render search adornments when hasSearch is false', () => { + render( + {}} + searchStartContent={S} + />, + ); + // The whole search row is gated on hasSearch, so the adornment never + // renders without it. + expect(screen.queryByTestId('search-start')).not.toBeInTheDocument(); + }); + describe('result announcements', () => { it('announces the match count politely while searching', async () => { const user = userEvent.setup(); diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 7a1d38cef25e..67c686d3ed36 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -193,9 +193,20 @@ const styles = stylex.create({ }, // Search input searchWrapper: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-2'], paddingInline: spacingVars['--spacing-2'], paddingBlock: spacingVars['--spacing-1'], }, + // Decorative adornment beside the search input (searchStartContent / + // searchEndContent). Non-interactive; the input owns focus + combobox role. + searchAdornment: { + display: 'inline-flex', + alignItems: 'center', + flexShrink: 0, + color: colorVars['--color-icon-secondary'], + }, searchInput: { boxSizing: 'border-box', width: '100%', @@ -476,6 +487,22 @@ interface SelectorPropsBase< */ searchPlaceholder?: string; + /** + * Content rendered at the inline-start of the search input (only when + * `hasSearch`). Typically a search/magnifier `Icon`. Purely decorative — + * it sits beside the input inside the search row and does not receive focus, + * so the input keeps its combobox role and behavior. + */ + searchStartContent?: ReactNode; + + /** + * Content rendered at the inline-end of the search input (only when + * `hasSearch`). For a non-interactive adornment (e.g. a hint). For an + * interactive clear affordance, drive the value via `onChange` — this slot + * is not wired to the search query. + */ + searchEndContent?: ReactNode; + /** * Position placement relative to the trigger. * @@ -615,6 +642,8 @@ export function Selector( renderOption, hasSearch = false, searchPlaceholder: searchPlaceholderFromProps, + searchStartContent, + searchEndContent, placement, isDefaultOpen = false, 'data-testid': testId, @@ -857,6 +886,11 @@ export function Selector( } return (
+ {searchStartContent != null && ( + + )} ( placeholder={searchPlaceholder} {...stylex.props(styles.searchInput)} /> + {searchEndContent != null && ( + + )}
); }, [ @@ -905,6 +944,8 @@ export function Selector( listboxId, searchQuery, searchPlaceholder, + searchStartContent, + searchEndContent, handleSearchChange, onKeyDown, popover.isOpen,