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-dropdown-theme-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[feat] Selector & MultiSelector: add theme targets for the dropdown internals so themes can restyle them without fragile structural CSS. New targets: `selector-dropdown` / `multi-selector-dropdown` (the dropdown popover content wrapper), `selector-search` / `selector-search-input` (and the multi-selector equivalents) for the dropdown search field, `selector-empty` / `multi-selector-empty` for the "no results" state, and `selector-section-header` / `multi-selector-section-header` for grouped-option titles. Purely additive — default appearance is unchanged.
@freddymeta
5 changes: 5 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const docs = {
className: 'astryx-multi-selector-indicator-icon',
states: ['state'],
},
{className: 'astryx-multi-selector-dropdown'},
{className: 'astryx-multi-selector-search'},
{className: 'astryx-multi-selector-search-input'},
{className: 'astryx-multi-selector-empty'},
{className: 'astryx-multi-selector-section-header'},
],
},
components: [
Expand Down
55 changes: 55 additions & 0 deletions packages/core/src/MultiSelector/MultiSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1680,4 +1680,59 @@ describe('MultiSelector indicator (chevron) icon theme target', () => {
expect(css).toContain('.astryx-multi-selector-indicator-icon.expanded');
expect(css).toContain('color: var(--color-icon-primary)');
});

it('renders search wrapper + input theme targets in hasSearch mode', 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).toHaveClass('astryx-multi-selector-search-input');
expect(search.parentElement).toHaveClass('astryx-multi-selector-search');
});

it('renders the dropdown theme target on the popover content', async () => {
const user = userEvent.setup();
render(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
/>,
);
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(
document.querySelector('.astryx-multi-selector-dropdown'),
).toBeTruthy();
});
});

it('renders the empty-state theme target when no options match', async () => {
const user = userEvent.setup();
render(
<MultiSelector
label="Fruit"
options={['Apple', 'Banana', 'Orange']}
value={[]}
onChange={() => {}}
hasSearch
/>,
);
await user.click(screen.getByRole('button', {name: 'Fruit'}));
await user.type(screen.getByRole('combobox', h), 'zzzzz');
await waitFor(() => {
expect(
document.querySelector('.astryx-multi-selector-empty'),
).toBeTruthy();
});
});
});
23 changes: 19 additions & 4 deletions packages/core/src/MultiSelector/MultiSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
return null;
}
return (
<div {...stylex.props(styles.searchWrapper)}>
<div
{...mergeProps(
themeProps('multi-selector-search'),
stylex.props(styles.searchWrapper),
)}>
<input
ref={searchRef}
id={searchId}
Expand Down Expand Up @@ -1103,7 +1107,10 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
}
}}
placeholder={searchPlaceholder}
{...stylex.props(styles.searchInput)}
{...mergeProps(
themeProps('multi-selector-search-input'),
stylex.props(styles.searchInput),
)}
/>
</div>
);
Expand Down Expand Up @@ -1235,7 +1242,10 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
<div
key="empty"
role="presentation"
{...stylex.props(styles.emptyState)}>
{...mergeProps(
themeProps('multi-selector-empty'),
stylex.props(styles.emptyState),
)}>
No results found
</div>,
);
Expand Down Expand Up @@ -1290,6 +1300,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
<Divider
key={`section-divider-${i}`}
label={option.title}
className={themeProps('multi-selector-section-header').className}
xstyle={styles.sectionDivider}
/>,
);
Expand Down Expand Up @@ -1446,7 +1457,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
</div>

{popover.render(
<div {...stylex.props(styles.dropdown)}>
<div
{...mergeProps(
themeProps('multi-selector-dropdown'),
stylex.props(styles.dropdown),
)}>
{renderSearch()}
<div
id={listboxId}
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/Selector/Selector.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export const docs = {
{className: 'astryx-selector-option'},
{className: 'astryx-selector-clear-icon'},
{className: 'astryx-selector-indicator-icon', states: ['state']},
{className: 'astryx-selector-dropdown'},
{className: 'astryx-selector-search'},
{className: 'astryx-selector-search-input'},
{className: 'astryx-selector-empty'},
{className: 'astryx-selector-section-header'},
],
},
description: 'Dropdown selector for choosing from a list of options.',
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/Selector/Selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1472,4 +1472,84 @@ describe('Selector indicator (chevron) icon theme target', () => {
expect(css).toContain('.astryx-selector-indicator-icon.expanded');
expect(css).toContain('color: var(--color-icon-primary)');
});

it('renders search wrapper + input theme targets in hasSearch mode', async () => {
const user = userEvent.setup();
render(
<Selector
label="Fruit"
options={OPTIONS}
value="Apple"
onChange={() => {}}
hasSearch
/>,
);
await user.click(screen.getByRole('button', {name: 'Fruit'}));
const search = screen.getByRole('combobox', {hidden: true});
// Input carries its own target; the wrapper is its parent's target.
expect(search).toHaveClass('astryx-selector-search-input');
expect(search.parentElement).toHaveClass('astryx-selector-search');
});

it('renders the empty-state theme target when no options match', async () => {
const user = userEvent.setup();
const {container} = render(
<Selector
label="Fruit"
options={OPTIONS}
value="Apple"
onChange={() => {}}
hasSearch
/>,
);
await user.click(screen.getByRole('button', {name: 'Fruit'}));
const search = screen.getByRole('combobox', {hidden: true});
await user.type(search, 'zzzzz');
await waitFor(() => {
expect(
container.querySelector('.astryx-selector-empty') ??
document.querySelector('.astryx-selector-empty'),
).toBeTruthy();
});
});

it('renders the dropdown theme target on the popover content', async () => {
const user = userEvent.setup();
render(<Selector label="Fruit" options={OPTIONS} onChange={() => {}} />);
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(document.querySelector('.astryx-selector-dropdown')).toBeTruthy();
});
});

it('renders the section-header theme target on grouped options', async () => {
const user = userEvent.setup();
render(
<Selector
label="Fruit"
options={[
{
type: 'section' as const,
title: 'Citrus',
options: [
{value: 'lemon', label: 'Lemon'},
{value: 'lime', label: 'Lime'},
],
},
{
type: 'section' as const,
title: 'Berries',
options: [{value: 'strawberry', label: 'Strawberry'}],
},
]}
onChange={() => {}}
/>,
);
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(
document.querySelector('.astryx-selector-section-header'),
).toBeTruthy();
});
});
});
28 changes: 21 additions & 7 deletions packages/core/src/Selector/Selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,11 @@ export function Selector<T extends SelectorOptionType>(
return null;
}
return (
<div {...stylex.props(styles.searchWrapper)}>
<div
{...mergeProps(
themeProps('selector-search'),
stylex.props(styles.searchWrapper),
)}>
<input
ref={searchRef}
id={searchId}
Expand Down Expand Up @@ -895,7 +899,10 @@ export function Selector<T extends SelectorOptionType>(
}
}}
placeholder={searchPlaceholder}
{...stylex.props(styles.searchInput)}
{...mergeProps(
themeProps('selector-search-input'),
stylex.props(styles.searchInput),
)}
/>
</div>
);
Expand Down Expand Up @@ -971,7 +978,10 @@ export function Selector<T extends SelectorOptionType>(
<div
key="empty"
role="presentation"
{...stylex.props(styles.emptyState)}>
{...mergeProps(
themeProps('selector-empty'),
stylex.props(styles.emptyState),
)}>
No results found
</div>,
];
Expand Down Expand Up @@ -1010,6 +1020,7 @@ export function Selector<T extends SelectorOptionType>(
<Divider
key={`section-divider-${i}`}
label={option.title}
className={themeProps('selector-section-header').className}
xstyle={styles.sectionDivider}
/>,
);
Expand Down Expand Up @@ -1165,7 +1176,7 @@ export function Selector<T extends SelectorOptionType>(

{popover.render(
hasSearch ? (
<div>
<div {...themeProps('selector-dropdown')}>
{renderSearch()}
<div
ref={listboxRef}
Expand All @@ -1182,9 +1193,12 @@ export function Selector<T extends SelectorOptionType>(
id={listboxId}
role="listbox"
aria-labelledby={triggerId}
{...stylex.props(
styles.dropdown,
!isPositioned && styles.dropdownHidden,
{...mergeProps(
themeProps('selector-dropdown'),
stylex.props(
styles.dropdown,
!isPositioned && styles.dropdownHidden,
),
)}>
{renderOptions()}
</div>
Expand Down
Loading