diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 2c2925bff..ef6f79097 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -43,7 +43,7 @@ Appearance first, meaning size and variant, then how many items can be chosen, a ### Basic Select -A trigger and a list of items. `Select.Value` renders the current selection in the trigger. +A trigger and a list of items. `Select.Value` renders the current selection in the trigger. A check mark shows the selected item in the list. In multiple mode, each item has a checkbox instead. @@ -117,6 +117,8 @@ The container that holds the Select items. +Set `alignItemWithTrigger` to open the list over the trigger, with the selected item lined up on the trigger's value. Base UI turns it off for touch input and when there is not enough space, and then places the list the normal way. `side` and `align` only apply when the list is placed the normal way. + ### Item Individual clickable options within the Select. @@ -164,6 +166,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `select-item` | An individual option row | | `select-item-icon` | Leading icon on an item (when set) | | `select-item-text` | The text label of an item | +| `select-item-indicator` | The check mark on the selected item (single selection only) | ## Accessibility diff --git a/apps/www/src/content/docs/components/select/props.ts b/apps/www/src/content/docs/components/select/props.ts index 80ddce754..14924698a 100644 --- a/apps/www/src/content/docs/components/select/props.ts +++ b/apps/www/src/content/docs/components/select/props.ts @@ -79,6 +79,12 @@ export interface SelectContentProps { */ sideOffset?: number; + /** + * Overlaps the trigger so the selected item's text lines up with the trigger's value. Has no effect in autocomplete mode. + * @default false + */ + alignItemWithTrigger?: boolean; + /** Additional CSS class names. */ className?: string; diff --git a/packages/react/components/select/__tests__/data-slots.test.tsx b/packages/react/components/select/__tests__/data-slots.test.tsx index d26d23883..9d3cbf254 100644 --- a/packages/react/components/select/__tests__/data-slots.test.tsx +++ b/packages/react/components/select/__tests__/data-slots.test.tsx @@ -45,10 +45,10 @@ describe('Select data-slot contract', () => { ]); }); - it('exposes content, list, group, label, separator and item slots when open', async () => { + it('exposes content, list, group, label, separator, item and indicator slots when open', async () => { const user = userEvent.setup(); render( - @@ -76,7 +76,8 @@ describe('Select data-slot contract', () => { 'select-label', 'select-separator', 'select-item', - 'select-item-text' + 'select-item-text', + 'select-item-indicator' ]); }); diff --git a/packages/react/components/select/__tests__/select.test.tsx b/packages/react/components/select/__tests__/select.test.tsx index ebf572071..33ff2e7c4 100644 --- a/packages/react/components/select/__tests__/select.test.tsx +++ b/packages/react/components/select/__tests__/select.test.tsx @@ -81,6 +81,23 @@ describe('Select', () => { }); describe('Single Selection', () => { + it('marks only the selected item with an indicator', async () => { + const user = userEvent.setup(); + render(); + await flushMicrotasks(); + await openSelect(user); + await flushMicrotasks(); + + const options = screen.getAllByRole('option'); + expect( + options[1].querySelector('[data-slot="select-item-indicator"]') + ).not.toBeNull(); + expect( + options[0].querySelector('[data-slot="select-item-indicator"]') + ).toBeNull(); + expect(within(options[1]).queryByRole('checkbox')).toBeNull(); + }); + it('displays selected value in trigger', async () => { render(); await flushMicrotasks(); @@ -281,6 +298,21 @@ describe('Select', () => { expect(searchInput).toBeInTheDocument(); }); + it('marks the selected item with an indicator', async () => { + render(); + + fireEvent.click(screen.getByLabelText('Select option')); + await flushMicrotasks(); + + const options = screen.getAllByRole('option'); + expect( + options[1].querySelector('[data-slot="select-item-indicator"]') + ).not.toBeNull(); + expect( + options[0].querySelector('[data-slot="select-item-indicator"]') + ).toBeNull(); + }); + it('filters options based on search', async () => { const user = userEvent.setup(); render(); diff --git a/packages/react/components/select/select-content.tsx b/packages/react/components/select/select-content.tsx index 1b6e20a52..123d10e99 100644 --- a/packages/react/components/select/select-content.tsx +++ b/packages/react/components/select/select-content.tsx @@ -28,6 +28,7 @@ export function SelectContent({ sideOffset = 4, side = 'bottom', align = 'start', + alignItemWithTrigger = false, radius, ...props }: SelectContentProps) { @@ -80,7 +81,7 @@ export function SelectContent({ side={side} align={align} className={styles.positioner} - alignItemWithTrigger={false} + alignItemWithTrigger={alignItemWithTrigger} data-slot='select-positioner' > )} - {children} + {autocomplete ? ( + + {children} + + ) : ( + } + className={styles.itemText} + data-slot='select-item-text' + > + {children} + + )} ) : ( children @@ -70,6 +83,9 @@ export function SelectItem({ const ItemPrimitive = autocomplete ? ComboboxPrimitive.Item : SelectPrimitive.Item; + const IndicatorPrimitive = autocomplete + ? ComboboxPrimitive.ItemIndicator + : SelectPrimitive.ItemIndicator; return ( {multiple && } {element} + {!multiple && ( + + + )} )} /> diff --git a/packages/react/components/select/select.module.css b/packages/react/components/select/select.module.css index b5f5ae2fb..b159a565e 100644 --- a/packages/react/components/select/select.module.css +++ b/packages/react/components/select/select.module.css @@ -286,3 +286,21 @@ .hidden { display: none; } + +.itemText { + flex: 1; + min-width: 0; +} + +.itemIndicator { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; + color: var(--rs-color-foreground-base-primary); +} + +.itemIndicator svg { + width: var(--rs-space-5); + height: var(--rs-space-5); +}