Skip to content
Closed
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-adornments.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MultiSelector
label="Fruit"
options={defaultOptions}
value={[]}
onChange={() => {}}
hasSearch
searchStartContent={<span data-testid="search-start">S</span>}
searchEndContent={<span data-testid="search-end">E</span>}
/>,
);
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(
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%',
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -639,6 +665,8 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
selectAllLabel: selectAllLabelFromProps,
hasSearch = false,
searchPlaceholder: searchPlaceholderFromProps,
searchStartContent,
searchEndContent,
triggerDisplay = 'count',
maxBadges = 3,
renderOption,
Expand Down Expand Up @@ -1066,6 +1094,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
}
return (
<div {...stylex.props(styles.searchWrapper)}>
{searchStartContent != null && (
<span aria-hidden="true" {...stylex.props(styles.searchAdornment)}>
{searchStartContent}
</span>
)}
<input
ref={searchRef}
id={searchId}
Expand Down Expand Up @@ -1105,6 +1138,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
placeholder={searchPlaceholder}
{...stylex.props(styles.searchInput)}
/>
{searchEndContent != null && (
<span aria-hidden="true" {...stylex.props(styles.searchAdornment)}>
{searchEndContent}
</span>
)}
</div>
);
}, [
Expand All @@ -1113,6 +1151,8 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
listboxId,
searchQuery,
searchPlaceholder,
searchStartContent,
searchEndContent,
handleSearchChange,
onKeyDown,
popover.isOpen,
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/Selector/Selector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/Selector/Selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,45 @@ describe('Selector', () => {
).toBeInTheDocument();
});

it('renders searchStartContent / searchEndContent beside the input', async () => {
const user = userEvent.setup();
render(
<Selector
label="Fruit"
options={OPTIONS}
value="Apple"
onChange={() => {}}
hasSearch
searchStartContent={<span data-testid="search-start">S</span>}
searchEndContent={<span data-testid="search-end">E</span>}
/>,
);
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(
<Selector
label="Fruit"
options={OPTIONS}
value="Apple"
onChange={() => {}}
searchStartContent={<span data-testid="search-start">S</span>}
/>,
);
// 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();
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/Selector/Selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%',
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -615,6 +642,8 @@ export function Selector<T extends SelectorOptionType>(
renderOption,
hasSearch = false,
searchPlaceholder: searchPlaceholderFromProps,
searchStartContent,
searchEndContent,
placement,
isDefaultOpen = false,
'data-testid': testId,
Expand Down Expand Up @@ -857,6 +886,11 @@ export function Selector<T extends SelectorOptionType>(
}
return (
<div {...stylex.props(styles.searchWrapper)}>
{searchStartContent != null && (
<span aria-hidden="true" {...stylex.props(styles.searchAdornment)}>
{searchStartContent}
</span>
)}
<input
ref={searchRef}
id={searchId}
Expand Down Expand Up @@ -897,6 +931,11 @@ export function Selector<T extends SelectorOptionType>(
placeholder={searchPlaceholder}
{...stylex.props(styles.searchInput)}
/>
{searchEndContent != null && (
<span aria-hidden="true" {...stylex.props(styles.searchAdornment)}>
{searchEndContent}
</span>
)}
</div>
);
}, [
Expand All @@ -905,6 +944,8 @@ export function Selector<T extends SelectorOptionType>(
listboxId,
searchQuery,
searchPlaceholder,
searchStartContent,
searchEndContent,
handleSearchChange,
onKeyDown,
popover.isOpen,
Expand Down
Loading