Skip to content

Commit 070d71c

Browse files
feat(mosaic): add multiple selection to Select (#10240)
1 parent acd8b27 commit 070d71c

16 files changed

Lines changed: 559 additions & 86 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/components/select/select.test.tsx‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const roles = [
1313
{ value: 'member', label: 'Member' },
1414
];
1515

16-
function renderSelect(props?: Partial<React.ComponentProps<typeof Select.Root>>) {
16+
function renderSelect(props?: Partial<Extract<React.ComponentProps<typeof Select.Root>, { multiple?: false }>>) {
1717
return render(
1818
<Select.Root
1919
items={roles}
@@ -136,6 +136,48 @@ describe('Mosaic Select', () => {
136136
expect(screen.getByRole('combobox').querySelector('.cl-select-value')).toHaveTextContent('Admin');
137137
});
138138

139+
it('toggles options without closing and lists every selected label when multiple', async () => {
140+
const user = userEvent.setup();
141+
const onValueChange = vi.fn();
142+
render(
143+
<Select.Root
144+
items={roles}
145+
multiple
146+
defaultValue={['admin']}
147+
onValueChange={onValueChange}
148+
>
149+
<Select.Trigger placeholder='Choose roles' />
150+
<Select.Popup />
151+
</Select.Root>,
152+
);
153+
154+
await user.click(screen.getByRole('combobox'));
155+
await user.click(screen.getByRole('option', { name: 'Member' }));
156+
157+
expect(onValueChange).toHaveBeenCalledWith(['admin', 'member']);
158+
expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true');
159+
expect(screen.getByRole('option', { name: 'Admin' })).toHaveAttribute('data-selected', '');
160+
expect(screen.getByRole('option', { name: 'Member' })).toHaveAttribute('data-selected', '');
161+
expect(screen.getByRole('combobox').querySelector('.cl-select-value')).toHaveTextContent('Admin, Member');
162+
});
163+
164+
it('renders the value through a function passed as trigger children', () => {
165+
render(
166+
<Select.Root
167+
items={roles}
168+
multiple
169+
defaultValue={['admin', 'member']}
170+
>
171+
<Select.Trigger>{value => `${value.length} roles`}</Select.Trigger>
172+
<Select.Popup />
173+
</Select.Root>,
174+
);
175+
176+
const trigger = screen.getByRole('combobox');
177+
expect(trigger.querySelector('.cl-select-value')).toHaveTextContent('2 roles');
178+
expect(trigger.querySelector('.cl-select-trigger-icon')).toBeInTheDocument();
179+
});
180+
139181
it('renders one option per item when the popup is given no children', () => {
140182
render(
141183
<Select.Root
@@ -356,6 +398,21 @@ describe('Mosaic Select', () => {
356398
expect(positioner()).not.toHaveAttribute('data-side', 'none');
357399
});
358400

401+
it('hangs below the trigger by default when multiple', () => {
402+
render(
403+
<Select.Root
404+
items={roles}
405+
multiple
406+
defaultValue={['admin']}
407+
defaultOpen
408+
>
409+
<Select.Trigger />
410+
<Select.Popup />
411+
</Select.Root>,
412+
);
413+
expect(positioner()).not.toHaveAttribute('data-side', 'none');
414+
});
415+
359416
it('lets an explicit alignItemWithTrigger override the derived default', () => {
360417
renderSelect({ items: describedRoles, defaultValue: 'member', defaultOpen: true, alignItemWithTrigger: true });
361418
expect(positioner()).toHaveAttribute('data-side', 'none');

‎packages/mosaic/src/components/select/select.tsx‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as stylex from '@stylexjs/stylex';
22
import React from 'react';
33

44
import type {
5+
SelectMultipleProps as PrimitiveSelectMultipleProps,
56
SelectOptionProps as PrimitiveSelectOptionProps,
67
SelectPopupProps as PrimitiveSelectPopupProps,
78
SelectPortalProps,
8-
SelectProps as PrimitiveSelectProps,
9+
SelectSingleProps as PrimitiveSelectSingleProps,
910
} from '../../primitives/select';
1011
import { Select as Primitive } from '../../primitives/select';
1112
import type { MosaicComponentProps, MosaicStyleProps } from '../../props';
@@ -32,7 +33,7 @@ export interface SelectItem {
3233
disabled?: boolean;
3334
}
3435

35-
export interface SelectProps extends Omit<PrimitiveSelectProps, 'items'> {
36+
interface SelectItemsProps {
3637
/**
3738
* Every choice. Required, unlike the primitive's: the options only mount while the listbox is
3839
* open, so this is what lets the closed trigger show the selected label. `Select.Popup`
@@ -41,6 +42,10 @@ export interface SelectProps extends Omit<PrimitiveSelectProps, 'items'> {
4142
items: SelectItem[];
4243
}
4344

45+
export type SelectProps =
46+
| (Omit<PrimitiveSelectSingleProps, 'items'> & SelectItemsProps)
47+
| (Omit<PrimitiveSelectMultipleProps, 'items'> & SelectItemsProps);
48+
4449
const ItemsContext = React.createContext<SelectItem[]>([]);
4550

4651
/** Holds the selection and the items; renders no element of its own. */
@@ -51,7 +56,7 @@ export function SelectRoot({ items, alignItemWithTrigger, children, ...rest }: S
5156
<ItemsContext.Provider value={items}>
5257
<Primitive.Root
5358
items={items}
54-
alignItemWithTrigger={alignItemWithTrigger ?? !described}
59+
alignItemWithTrigger={alignItemWithTrigger ?? !(described || rest.multiple)}
5560
{...rest}
5661
>
5762
{children}
@@ -62,7 +67,7 @@ export function SelectRoot({ items, alignItemWithTrigger, children, ...rest }: S
6267

6368
export type SelectTriggerVariant = 'outline' | 'ghost';
6469

65-
export type SelectTriggerProps = MosaicComponentProps<'button'> & {
70+
export type SelectTriggerProps = Omit<MosaicComponentProps<'button'>, 'children'> & {
6671
/**
6772
* `outline` is the bordered form control; `ghost` drops the border for triggers that sit in
6873
* dense surfaces such as table rows.
@@ -72,6 +77,11 @@ export type SelectTriggerProps = MosaicComponentProps<'button'> & {
7277
variant?: SelectTriggerVariant;
7378
/** Shown in place of the selected label while nothing is selected. */
7479
placeholder?: React.ReactNode;
80+
/**
81+
* Content that replaces the selected label. A function renders inside the value slot and
82+
* receives the selected values; anything else replaces the value slot entirely.
83+
*/
84+
children?: React.ReactNode | ((value: string[]) => React.ReactNode);
7585
};
7686

7787
/**
@@ -108,6 +118,8 @@ export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerPr
108118
labelElementType: 'span',
109119
});
110120
const id = fieldProps?.id ?? idProp ?? generatedId;
121+
const renderValue = typeof children === 'function' ? children : undefined;
122+
const content = typeof children === 'function' ? undefined : children;
111123
// A combobox takes no name from its content, so the value is named explicitly, after any label.
112124
// An `aria-label` is kept by naming the trigger itself.
113125
const labelledBy = fieldProps?.['aria-labelledby'] ?? ariaLabelledBy ?? (ariaLabel ? id : undefined);
@@ -129,21 +141,23 @@ export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerPr
129141
id={id}
130142
disabled={fieldProps?.disabled ?? disabledProp}
131143
aria-label={ariaLabel}
132-
aria-labelledby={mergeIds(labelledBy, children === undefined ? valueId : undefined)}
144+
aria-labelledby={mergeIds(labelledBy, content === undefined ? valueId : undefined)}
133145
aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy}
134146
aria-invalid={fieldProps?.['aria-invalid'] ?? ariaInvalid}
135147
aria-required={ariaRequired ?? (fieldProps?.required ? true : undefined)}
136148
{...mergeStyleProps(themeProps('select-trigger', { variant }), stylex.props(xstyle), rest)}
137149
>
138-
{children ?? (
150+
{content ?? (
139151
<Primitive.Value
140152
id={valueId}
141153
placeholder={placeholder}
142154
{...mergeStyleProps(
143155
themeProps('select-value'),
144156
stylex.props(reset.base, truncationStyles.singleLine, slots.value.base),
145157
)}
146-
/>
158+
>
159+
{renderValue}
160+
</Primitive.Value>
147161
)}
148162
<Icon
149163
name='chevron-down'

‎packages/mosaic/src/primitives/select/README.md‎

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A dropdown select component with keyboard navigation, typeahead, and optional it
44

55
## When to Use
66

7-
- Picking a single value from a predefined list of options.
7+
- Picking one or several values from a predefined list of options.
88
- When you need typeahead, keyboard navigation, and full styling control.
99
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.
1010

@@ -49,6 +49,24 @@ const [value, setValue] = useState('apple');
4949
</Select.Root>;
5050
```
5151

52+
### Multiple
53+
54+
`multiple` makes the value a `string[]`. Picking an option adds or removes it and keeps the popup open.
55+
56+
```tsx
57+
<Select.Root
58+
multiple
59+
defaultValue={['apple', 'cherry']}
60+
>
61+
<Select.Trigger>
62+
<Select.Value placeholder='Choose fruits...'>{value => `${value.length} selected`}</Select.Value>
63+
</Select.Trigger>
64+
{/* ... */}
65+
</Select.Root>
66+
```
67+
68+
`Select.Value` shows the selected labels joined with commas unless given children.
69+
5270
### With `items` for SSR label resolution
5371

5472
The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):
@@ -94,9 +112,10 @@ By default, the selected option visually aligns with the trigger. Disable this f
94112

95113
| Prop | Type | Default | Description |
96114
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
97-
| `value` | `string` | — | Controlled selected value |
98-
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
99-
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
115+
| `multiple` | `boolean` | `false` | Allow selecting several options |
116+
| `value` | `string \| string[]` | — | Controlled selected value; an array when `multiple` |
117+
| `defaultValue` | `string \| string[]` | — | Initial selected value (uncontrolled); an array when `multiple` |
118+
| `onValueChange` | `(value) => void` | — | Called with the new value when the selection changes |
100119
| `open` | `boolean` | — | Controlled open state |
101120
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
102121
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
@@ -107,9 +126,10 @@ By default, the selected option visually aligns with the trigger. Disable this f
107126

108127
### `Select.Value`
109128

110-
| Prop | Type | Default | Description |
111-
| ------------- | ----------- | ------- | ------------------------------- |
112-
| `placeholder` | `ReactNode` | — | Shown when no value is selected |
129+
| Prop | Type | Default | Description |
130+
| ------------- | ----------------------------------------------- | ------- | --------------------------------------------------------------------- |
131+
| `placeholder` | `ReactNode` | — | Shown when no value is selected |
132+
| `children` | `ReactNode \| ((value: string[]) => ReactNode)` | — | Replaces the selected labels; a function receives the selected values |
113133

114134
### `Select.Option`
115135

@@ -129,13 +149,13 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall
129149

130150
## Keyboard Navigation
131151

132-
| Key | Action |
133-
| ----------------- | ------------------------------------- |
134-
| `ArrowDown` | Move to next option |
135-
| `ArrowUp` | Move to previous option |
136-
| `Enter` / `Space` | Select the active option, close popup |
137-
| `Escape` | Close the popup |
138-
| Type a character | Jump to matching option (typeahead) |
152+
| Key | Action |
153+
| ----------------- | ------------------------------------------------------------------------------------------------ |
154+
| `ArrowDown` | Move to next option |
155+
| `ArrowUp` | Move to previous option |
156+
| `Enter` / `Space` | Select the active option and close the popup; with `multiple`, toggle it and keep the popup open |
157+
| `Escape` | Close the popup |
158+
| Type a character | Jump to matching option (typeahead) |
139159

140160
Typeahead is active only while the popup is open. It highlights the matching option; pressing Enter or Space then selects it.
141161

@@ -144,7 +164,7 @@ Typeahead is active only while the popup is open. It highlights the matching opt
144164
| Attribute | Applies To | Description |
145165
| --------------------------- | ---------- | --------------------------------------------------------------------------------- |
146166
| `data-open` / `data-closed` | Trigger | Popup open state |
147-
| `data-selected` | Option | The currently selected option |
167+
| `data-selected` | Option | Each selected option |
148168
| `data-active` | Option | The keyboard-highlighted option |
149169
| `data-disabled` | Option | Disabled option |
150170
| `data-side` | Positioner | Resolved placement side, or `none` while the selected option overlays the trigger |
@@ -158,6 +178,6 @@ Typeahead is active only while the popup is open. It highlights the matching opt
158178

159179
## ARIA
160180

161-
- Popup: `role="listbox"`
181+
- Popup: `role="listbox"`, `aria-multiselectable` when `multiple`
162182
- Option: `role="option"`, `aria-selected`, `aria-disabled`
163183
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`

0 commit comments

Comments
 (0)