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
70 changes: 55 additions & 15 deletions apps/www/src/content/docs/components/select/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const iconDemo = {
<Select.Item value="apple" leadingIcon={<Info size={16} />}>Apple</Select.Item>
<Select.Item value="banana" leadingIcon={<X size={16} />}>Banana</Select.Item>
<Select.Item value="grape" leadingIcon={<Home size={16} />}>Grape</Select.Item>
<Select.Item value="Orange" leadingIcon={<Laugh size={16} />}>Orange</Select.Item>
<Select.Item value="orange" leadingIcon={<Laugh size={16} />}>Orange</Select.Item>
</Select.Content>
</Select>`
};
Expand Down Expand Up @@ -91,7 +91,7 @@ export const sizeDemo = {
</Select>
<Select>
<Select.Trigger>
<Select.Value placeholder="Small select" />
<Select.Value placeholder="Medium select" />
</Select.Trigger>
<Select.Content>
<Select.Item value="1">Option 1</Select.Item>
Expand Down Expand Up @@ -157,7 +157,9 @@ export const separatorDemo = {
export const multipleDemo = {
type: 'code',
code: `
<Select multiple>
<Select
multiple
>
<Select.Trigger>
<Select.Value placeholder="Select..." />
</Select.Trigger>
Expand All @@ -182,20 +184,17 @@ export const autocompleteDemo = {
{
name: 'Default Autocomplete',
code: `
<Select autocomplete>
<Select autocomplete items={{ apple: "Apple", banana: "Banana", blueberry: "Blueberry", grapes: "Grapes", pineapple: "Pineapple" }}>
<Select.Trigger>
<Select.Value placeholder="Select..." />
<Select.Value placeholder="Select a fruit" />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Item value="1">Option 1</Select.Item>
<Select.Item value="2">Option 2</Select.Item>
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.Item value="3">Option 3</Select.Item>
<Select.Item value="4">Option 4</Select.Item>
</Select.Group>
<Select.Empty>No fruit found</Select.Empty>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
<Select.Item value="blueberry">Blueberry</Select.Item>
<Select.Item value="grapes">Grapes</Select.Item>
<Select.Item value="pineapple">Pineapple</Select.Item>
</Select.Content>
</Select>`
},
Expand Down Expand Up @@ -236,7 +235,10 @@ function ControlledSelect() {

return (
<Flex direction="column" gap={5}>
<Select value={fruit} onValueChange={setFruit}>
<Select
value={fruit}
onValueChange={setFruit}
>
<Select.Trigger width={200}>
<Select.Value />
</Select.Trigger>
Expand All @@ -253,3 +255,41 @@ function ControlledSelect() {
);
}`
};

export const labelDemo = {
type: 'code',
code: `
<Flex direction="column" gap={2}>
<Select>
<Select.Label>Fruit</Select.Label>
<Select.Trigger>
<Select.Value placeholder="Select a fruit" />
</Select.Trigger>
<Select.Content>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
</Select.Content>
</Select>
</Flex>`
};

export const valueRenderDemo = {
type: 'code',
code: `
<Select defaultValue="apple">
<Select.Trigger>
<Select.Value placeholder="Select a fruit">
{item => (
<Flex align="center" gap={2}>
{item.leadingIcon}
<Text weight="medium">{item.children}</Text>
</Flex>
)}
</Select.Value>
</Select.Trigger>
<Select.Content>
<Select.Item value="apple" leadingIcon={<Info size={16} />}>Apple</Select.Item>
<Select.Item value="banana" leadingIcon={<Home size={16} />}>Banana</Select.Item>
</Select.Content>
</Select>`
};
65 changes: 56 additions & 9 deletions apps/www/src/content/docs/components/select/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
autocompleteDemo,
multipleDemo,
controlledDemo,
labelDemo,
valueRenderDemo,
} from "./demo.ts";

<Demo data={playground} />
Expand All @@ -26,10 +28,15 @@ Import and assemble the component:
import { Select } from "@raystack/apsara";

<Select>
<Select.Trigger />
<Select.Label />
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Status />
<Select.Empty />
<Select.Group>
<Select.Label />
<Select.GroupLabel />
<Select.Item />
</Select.Group>
<Select.Separator />
Expand All @@ -45,14 +52,26 @@ Appearance first, meaning size and variant, then how many items can be chosen, a

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.

Each `Select.Item` registers its label and icon, so `Select.Value` shows them for the selected value, even before the list first opens. You can also pass `items` to the root, as `{ value, label }[]` or a value-to-label record. Use it for search by label, or for values whose item is not rendered.

<Demo data={basicDemo} />

### Icon

You can pass `leadingIcon` prop to Select.Item to include items
Pass `leadingIcon` to `Select.Item` to show an icon before the item text.

<Demo data={iconDemo} />

The trigger shows the selected item's icon too. To render the value yourself, pass a function as the child of `Select.Value`. It receives the selected item as `{ value, children, leadingIcon }`, or an array of items in multiple mode.

<Demo data={valueRenderDemo} />

### Label

`Select.Label` renders a visible label linked to the trigger. Clicking it focuses the trigger.

<Demo data={labelDemo} />

### Size

Two sizes. `medium` is the default; `small` fits toolbars and table filters.
Expand Down Expand Up @@ -83,7 +102,9 @@ When multiple selection is enabled, the value, onValueChange, and defaultValue w

To enable autocomplete, pass the `autocomplete` prop to the Select root element.

By default, only select items are filtered using a simple match. For more advanced control, set `autocompleteMode="manual"` and implement your own custom filtering logic.
With `items`, Base UI filters by label. Without `items`, the select filters items with a simple text match. For full control, set `autocompleteMode="manual"` and filter the items yourself.

`Select.Empty` shows its content only when no item matches the search. It needs `items` on the root. `Select.Status` is a polite live region for messages such as a result count or a loading state. Both work only in autocomplete mode.

<Demo data={autocompleteDemo} />

Expand Down Expand Up @@ -131,12 +152,36 @@ A way to group related Select items together.

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

### Value

Shows the label of the selected value, or the placeholder.

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

### Label

Renders a label in a Select group. This component can only be used inside Select.Group
A visible label linked to the trigger.

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

### GroupLabel

The label of a `Select.Group`. Use it only inside `Select.Group`.

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

### Empty

Shows its content when no item matches the search. Autocomplete mode only.

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

### Status

A polite live region for status messages. Autocomplete mode only.

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

### Separator

Visual divider between Select items or groups.
Expand All @@ -152,16 +197,18 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| `select-trigger` | The trigger button that opens the menu |
| `select-trigger-content` | Flex wrapper around the trigger's children |
| `select-trigger-icon` | The chevron dropdown icon in the trigger |
| `select-value` | The `<span>` (or wrapper) showing the selected value or placeholder |
| `select-value-content` | Inner wrapper around a single selected value |
| `select-value-icon` | Leading icon of the selected value (when set) |
| `select-label` | The label linked to the trigger |
| `select-value` | The `<span>` showing the selected label or placeholder |
| `select-value-content` | Wrapper around the chips in multiple mode |
| `select-value-overflow` | The "+N" overflow count for multiple values (when they don't all fit) |
| `select-positioner` | The floating positioner for the popup |
| `select-content` | The popup container |
| `select-search` | The search input (when autocomplete is enabled) |
| `select-list` | The scrollable list of items |
| `select-group` | A group of items |
| `select-label` | The label of a group |
| `select-group-label` | The label of a group |
| `select-empty` | The message shown when no item matches (autocomplete only) |
| `select-status` | The live region for status messages (autocomplete only) |
| `select-separator` | A divider between items or groups |
| `select-item` | An individual option row |
| `select-item-icon` | Leading icon on an item (when set) |
Expand Down
54 changes: 54 additions & 0 deletions apps/www/src/content/docs/components/select/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ export interface SelectRootProps {

/** Callback fired when the search value changes */
onSearch?: (value: string) => void;

/** Optional labels for each value, as `{ value, label }[]` or a value-to-label record. Items register their own labels, so this is only needed for search by label or for values whose item is not rendered. */
items?:
| { value: string; label: React.ReactNode }[]
| Record<string, React.ReactNode>;
}

export interface SelectValueItem {
value: string;
/** The item's label. */
children: React.ReactNode;
leadingIcon?: React.ReactNode;
}

export interface SelectValueProps {
/** Shown when nothing is selected. */
placeholder?: React.ReactNode;

/** Replaces the label. A function receives the selected item (an array in multiple mode). */
children?:
| React.ReactNode
| ((item: SelectValueItem | SelectValueItem[]) => React.ReactNode);

/** Additional CSS class names. */
className?: string;
}

export interface SelectTriggerProps {
Expand Down Expand Up @@ -129,6 +154,19 @@ export interface SelectLabelProps {
render?: React.ReactElement;
}

export interface SelectGroupLabelProps {
/** Additional CSS class names */
className?: string;

/**
* Allows rendering as a different element.
* Accepts a React element or a function that receives props and returns an element.
*
* @remarks `ReactElement | function`
*/
render?: React.ReactElement;
}

export interface SelectSeparatorProps {
/** Additional CSS class names */
className?: string;
Expand All @@ -141,3 +179,19 @@ export interface SelectSeparatorProps {
*/
render?: React.ReactElement;
}

export interface SelectEmptyProps {
/** Content shown when no item matches the search. */
children?: React.ReactNode;

/** Additional CSS class names */
className?: string;
}

export interface SelectStatusProps {
/** Status message, for example a result count. */
children?: React.ReactNode;

/** Additional CSS class names */
className?: string;
}
46 changes: 42 additions & 4 deletions packages/react/components/select/__tests__/data-slots.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { expectSlots, getSlot } from '~/test-utils/data-slots';
Expand Down Expand Up @@ -45,7 +45,7 @@ describe('Select data-slot contract', () => {
]);
});

it('exposes content, list, group, label, separator, item and indicator slots when open', async () => {
it('exposes content, list, group, group label, separator, item and indicator slots when open', async () => {
const user = userEvent.setup();
render(
<Select defaultValue='apple'>
Expand All @@ -54,7 +54,7 @@ describe('Select data-slot contract', () => {
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Fruits</Select.Label>
<Select.GroupLabel>Fruits</Select.GroupLabel>
{FRUIT_OPTIONS.map(option => (
<Select.Item key={option.value} value={option.value}>
{option.label}
Expand All @@ -73,7 +73,7 @@ describe('Select data-slot contract', () => {
'select-content',
'select-list',
'select-group',
'select-label',
'select-group-label',
'select-separator',
'select-item',
'select-item-text',
Expand Down Expand Up @@ -120,6 +120,44 @@ describe('Select data-slot contract', () => {
expect(getSlot(container, 'select-value')).not.toBeNull();
});

it('exposes the label slot', () => {
const { container } = render(
<Select>
<Select.Label>Fruit</Select.Label>
<Select.Trigger>
<Select.Value placeholder='Select a fruit' />
</Select.Trigger>
</Select>
);
expect(getSlot(container, 'select-label')).not.toBeNull();
});

it('exposes the empty and status slots in autocomplete mode', async () => {
render(
<Select autocomplete items={FRUIT_OPTIONS}>
<Select.Trigger>
<Select.Value placeholder='Select a fruit' />
</Select.Trigger>
<Select.Content>
<Select.Status />
<Select.Empty>No fruit found</Select.Empty>
{FRUIT_OPTIONS.map(option => (
<Select.Item key={option.value} value={option.value}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select>
);
fireEvent.click(screen.getByRole('combobox'));
await screen.findByRole('listbox');
expectSlots(document.body, [
'select-search',
'select-status',
'select-empty'
]);
});

it('lets FilterChip/DataView-style overrides win over Select defaults', () => {
const { container } = render(
<Select>
Expand Down
Loading
Loading