Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/www/src/content/docs/components/select/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Demo data={basicDemo} />

Expand Down Expand Up @@ -117,6 +117,8 @@ The container that holds the Select items.

<auto-type-table path="./props.ts" name="SelectContentProps" />

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.
Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions apps/www/src/content/docs/components/select/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Select>
<Select defaultValue='apple'>
<Select.Trigger>
<Select.Value placeholder='Select a fruit' />
</Select.Trigger>
Expand Down Expand Up @@ -76,7 +76,8 @@ describe('Select data-slot contract', () => {
'select-label',
'select-separator',
'select-item',
'select-item-text'
'select-item-text',
'select-item-indicator'
]);
});

Expand Down
32 changes: 32 additions & 0 deletions packages/react/components/select/__tests__/select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ describe('Select', () => {
});

describe('Single Selection', () => {
it('marks only the selected item with an indicator', async () => {
const user = userEvent.setup();
render(<BasicSelect defaultValue='banana' />);
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(<BasicSelect defaultValue='apple' />);
await flushMicrotasks();
Expand Down Expand Up @@ -281,6 +298,21 @@ describe('Select', () => {
expect(searchInput).toBeInTheDocument();
});

it('marks the selected item with an indicator', async () => {
render(<BasicSelect autocomplete defaultValue='banana' />);

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(<BasicSelect autocomplete />);
Expand Down
3 changes: 2 additions & 1 deletion packages/react/components/select/select-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function SelectContent({
sideOffset = 4,
side = 'bottom',
align = 'start',
alignItemWithTrigger = false,
radius,
...props
}: SelectContentProps) {
Expand Down Expand Up @@ -80,7 +81,7 @@ export function SelectContent({
side={side}
align={align}
className={styles.positioner}
alignItemWithTrigger={false}
alignItemWithTrigger={alignItemWithTrigger}
data-slot='select-positioner'
>
<SelectPrimitive.Popup
Expand Down
26 changes: 25 additions & 1 deletion packages/react/components/select/select-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@base-ui/react';
import { cx } from 'class-variance-authority';
import { ReactNode, useLayoutEffect } from 'react';
import { CheckIcon } from '~/icons';
import { Checkbox } from '../checkbox';
import { getMatch } from '../menu/utils';
import { Text } from '../text';
Expand Down Expand Up @@ -50,7 +51,19 @@ export function SelectItem({
{leadingIcon}
</div>
)}
<Text data-slot='select-item-text'>{children}</Text>
{autocomplete ? (
<Text className={styles.itemText} data-slot='select-item-text'>
{children}
</Text>
) : (
<SelectPrimitive.ItemText
render={<Text />}
className={styles.itemText}
data-slot='select-item-text'
>
{children}
</SelectPrimitive.ItemText>
)}
</>
) : (
children
Expand All @@ -70,6 +83,9 @@ export function SelectItem({
const ItemPrimitive = autocomplete
? ComboboxPrimitive.Item
: SelectPrimitive.Item;
const IndicatorPrimitive = autocomplete
? ComboboxPrimitive.ItemIndicator
: SelectPrimitive.ItemIndicator;

return (
<ItemPrimitive
Expand All @@ -83,6 +99,14 @@ export function SelectItem({
<div {...renderProps}>
{multiple && <Checkbox checked={state.selected} />}
{element}
{!multiple && (
<IndicatorPrimitive
className={styles.itemIndicator}
data-slot='select-item-indicator'
>
<CheckIcon aria-hidden='true' />
</IndicatorPrimitive>
)}
</div>
)}
/>
Expand Down
18 changes: 18 additions & 0 deletions packages/react/components/select/select.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading