diff --git a/.changeset/selector-dropdown-theme-targets.md b/.changeset/selector-dropdown-theme-targets.md new file mode 100644 index 000000000000..c7beb7f7796b --- /dev/null +++ b/.changeset/selector-dropdown-theme-targets.md @@ -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 diff --git a/packages/core/src/MultiSelector/MultiSelector.doc.mjs b/packages/core/src/MultiSelector/MultiSelector.doc.mjs index bc2ae90f0426..34501e97c6d2 100644 --- a/packages/core/src/MultiSelector/MultiSelector.doc.mjs +++ b/packages/core/src/MultiSelector/MultiSelector.doc.mjs @@ -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: [ diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index 4f90b9760034..7a13ecf5f814 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -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( + {}} + 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( + {}} + />, + ); + 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( + {}} + 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(); + }); + }); }); diff --git a/packages/core/src/MultiSelector/MultiSelector.tsx b/packages/core/src/MultiSelector/MultiSelector.tsx index 241c98246211..f58978059a9b 100644 --- a/packages/core/src/MultiSelector/MultiSelector.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.tsx @@ -1065,7 +1065,11 @@ export function MultiSelector({ return null; } return ( -
+
({ } }} placeholder={searchPlaceholder} - {...stylex.props(styles.searchInput)} + {...mergeProps( + themeProps('multi-selector-search-input'), + stylex.props(styles.searchInput), + )} />
); @@ -1235,7 +1242,10 @@ export function MultiSelector({
+ {...mergeProps( + themeProps('multi-selector-empty'), + stylex.props(styles.emptyState), + )}> No results found
, ); @@ -1290,6 +1300,7 @@ export function MultiSelector({ , ); @@ -1446,7 +1457,11 @@ export function MultiSelector({
{popover.render( -
+
{renderSearch()}
{ 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( + {}} + 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( + {}} + 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( {}} />); + 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( + {}} + />, + ); + await user.click(screen.getByRole('combobox')); + await waitFor(() => { + expect( + document.querySelector('.astryx-selector-section-header'), + ).toBeTruthy(); + }); + }); }); diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 466f0d6edbfe..cb9d94430806 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -856,7 +856,11 @@ export function Selector( return null; } return ( -
+
( } }} placeholder={searchPlaceholder} - {...stylex.props(styles.searchInput)} + {...mergeProps( + themeProps('selector-search-input'), + stylex.props(styles.searchInput), + )} />
); @@ -971,7 +978,10 @@ export function Selector(
+ {...mergeProps( + themeProps('selector-empty'), + stylex.props(styles.emptyState), + )}> No results found
, ]; @@ -1010,6 +1020,7 @@ export function Selector( , ); @@ -1165,7 +1176,7 @@ export function Selector( {popover.render( hasSearch ? ( -
+
{renderSearch()}
( 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()}