From d572ea39390390ba9af74710bdb51f023e52e837 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:08:33 -0500 Subject: [PATCH 1/4] fix: [select] mark the selected item and allow alignItemWithTrigger --- .../content/docs/components/select/index.mdx | 5 ++- .../content/docs/components/select/props.ts | 6 ++++ .../select/__tests__/data-slots.test.tsx | 7 ++-- .../select/__tests__/select.test.tsx | 32 +++++++++++++++++++ .../components/select/select-content.tsx | 3 +- .../react/components/select/select-item.tsx | 26 ++++++++++++++- .../react/components/select/select.module.css | 18 +++++++++++ 7 files changed, 91 insertions(+), 6 deletions(-) diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 2c2925bff..70b2fd9e1 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. `side` and `align` have no effect while it is on. + ### 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); +} From 33fb330ea417f021151fba77d2515a0847db2130 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 20:11:44 -0500 Subject: [PATCH 2/4] fix: [docs] clarify when select side and align apply --- apps/www/src/content/docs/components/select/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 70b2fd9e1..ef6f79097 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -117,7 +117,7 @@ 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. `side` and `align` have no effect while it is on. +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 From 1c260a0f975d71c74d667cd7fc6d1871403b362d Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:48:51 -0500 Subject: [PATCH 3/4] refactor: [select] use base ui value, label, and empty --- apps/www/src/components/theme-panel-demo.tsx | 5 +- .../docs/components/code-block/demo.ts | 4 +- .../docs/components/code-block/index.mdx | 1 + .../src/content/docs/components/form/demo.ts | 2 +- .../content/docs/components/select/demo.ts | 100 +++++++-- .../content/docs/components/select/index.mdx | 65 +++++- .../content/docs/components/select/props.ts | 45 ++++ apps/www/src/content/docs/theme/icons/demo.ts | 4 +- .../src/content/docs/theme/overview/demo.ts | 2 +- .../react/components/calendar/calendar.tsx | 4 + .../code-block/__tests__/code-block.test.tsx | 4 +- .../color-picker/color-picker-mode.tsx | 9 +- .../data-table/components/grouping.tsx | 12 +- .../data-table/components/ordering.tsx | 4 + .../data-view/components/grouping.tsx | 12 +- .../data-view/components/ordering.tsx | 4 + .../filter-chip/filter-chip-operation.tsx | 7 + .../components/filter-chip/filter-chip.tsx | 16 +- .../select/__tests__/data-slots.test.tsx | 46 +++- .../select/__tests__/select.test.tsx | 199 +++++++++++++++++- .../components/select/select-content.tsx | 73 ++++--- .../react/components/select/select-item.tsx | 30 ++- .../react/components/select/select-misc.tsx | 70 +++++- .../select/select-multiple-value.tsx | 17 +- .../react/components/select/select-root.tsx | 126 +++++++---- .../react/components/select/select-value.tsx | 100 ++++----- .../react/components/select/select.module.css | 48 ++++- packages/react/components/select/select.tsx | 14 +- packages/react/components/select/types.ts | 8 +- 29 files changed, 813 insertions(+), 218 deletions(-) diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx index 4784ef4de..a6030f9cc 100644 --- a/apps/www/src/components/theme-panel-demo.tsx +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -148,7 +148,10 @@ function Sampler() { - diff --git a/apps/www/src/content/docs/components/code-block/demo.ts b/apps/www/src/content/docs/components/code-block/demo.ts index 2505e71f5..1b17a1cb2 100644 --- a/apps/www/src/content/docs/components/code-block/demo.ts +++ b/apps/www/src/content/docs/components/code-block/demo.ts @@ -40,7 +40,7 @@ const getCode = (props: ComponentPropsType) => { return ` Code - + @@ -115,7 +115,7 @@ export const languageSwitcherDemo = { code: ` Code - + diff --git a/apps/www/src/content/docs/components/code-block/index.mdx b/apps/www/src/content/docs/components/code-block/index.mdx index 9a044bdd4..5cc352f7e 100644 --- a/apps/www/src/content/docs/components/code-block/index.mdx +++ b/apps/www/src/content/docs/components/code-block/index.mdx @@ -58,6 +58,7 @@ Add a structured header with labels and controls using `CodeBlock.Header`, `Code Support multiple programming languages by including multiple `CodeBlock.Code` components with different language values. Use `CodeBlock.LanguageSelect` to provide a dropdown for users to switch between languages. The component automatically displays the code block matching the selected language value, which can be controlled programmatically using the `value` and `onValueChange` props. +Pass `items` to show the language label in the trigger. Without it, the trigger shows the raw value. diff --git a/apps/www/src/content/docs/components/form/demo.ts b/apps/www/src/content/docs/components/form/demo.ts index 3650dbc9f..6e41732b1 100644 --- a/apps/www/src/content/docs/components/form/demo.ts +++ b/apps/www/src/content/docs/components/form/demo.ts @@ -203,7 +203,7 @@ export const rhfDemo = { control={control} rules={{ required: 'Please select a role' }} render={({ field }) => ( - Developer diff --git a/apps/www/src/content/docs/components/select/demo.ts b/apps/www/src/content/docs/components/select/demo.ts index 9ac38d769..b4eb41d23 100644 --- a/apps/www/src/content/docs/components/select/demo.ts +++ b/apps/www/src/content/docs/components/select/demo.ts @@ -6,7 +6,7 @@ import { getPropsString } from '@/lib/utils'; export const getCode = (props: ComponentPropsType) => { const { autocomplete, multiple, ...rest } = props; return ` - + + ` }; export const basicDemo = { type: 'code', code: ` - @@ -80,7 +80,7 @@ export const sizeDemo = { type: 'code', code: ` - @@ -89,9 +89,9 @@ export const sizeDemo = { Option 2 - - + Option 1 @@ -107,7 +107,7 @@ export const variantDemo = { { name: 'Default', code: ` - @@ -121,7 +121,7 @@ export const variantDemo = { { name: 'Filter', code: ` - @@ -137,7 +137,7 @@ export const variantDemo = { export const separatorDemo = { type: 'code', code: ` - @@ -157,7 +157,10 @@ export const separatorDemo = { export const multipleDemo = { type: 'code', code: ` - ({ value: String(i + 1), label: "Option " + (i + 1) }))} + > @@ -182,20 +185,17 @@ export const autocompleteDemo = { { name: 'Default Autocomplete', code: ` - - + - - Option 1 - Option 2 - - - - Option 3 - Option 4 - + No fruit found + Apple + Banana + Blueberry + Grapes + Pineapple ` }, @@ -236,7 +236,11 @@ function ControlledSelect() { return ( - @@ -253,3 +257,53 @@ function ControlledSelect() { ); }` }; + +export const labelDemo = { + type: 'code', + code: ` + + +` +}; + +export const valueRenderDemo = { + type: 'code', + code: ` +function IconValue() { + const fruits = { + apple: { label: "Apple", icon: }, + banana: { label: "Banana", icon: } + }; + + return ( + + ); +}` +}; diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index ef6f79097..825a3f85e 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -14,6 +14,8 @@ import { autocompleteDemo, multipleDemo, controlledDemo, + labelDemo, + valueRenderDemo, } from "./demo.ts"; @@ -26,10 +28,15 @@ Import and assemble the component: import { Select } from "@raystack/apsara"; + + + ({ + value: opt.value.toString(), + label: opt.label + }))} open={open} onOpenChange={setOpen} > diff --git a/packages/react/components/code-block/__tests__/code-block.test.tsx b/packages/react/components/code-block/__tests__/code-block.test.tsx index 4bd22c031..8dd1c6c5b 100644 --- a/packages/react/components/code-block/__tests__/code-block.test.tsx +++ b/packages/react/components/code-block/__tests__/code-block.test.tsx @@ -57,7 +57,9 @@ const LanguageSelectCodeBlock = ( Code - + diff --git a/packages/react/components/color-picker/color-picker-mode.tsx b/packages/react/components/color-picker/color-picker-mode.tsx index a29d8bbd5..51583f55a 100644 --- a/packages/react/components/color-picker/color-picker-mode.tsx +++ b/packages/react/components/color-picker/color-picker-mode.tsx @@ -19,7 +19,14 @@ export const ColorPickerMode = ({ }: ColorPickerModeProps) => { const { mode, setMode } = useColorPicker(); return ( - setMode(value as ModeType)} + value={mode} + items={options.map(option => ({ + value: option, + label: option.toUpperCase() + }))} + > ({ align='center' className={styles['display-popover-properties-control']} > - ({ + value: column.id, + label: column.label + })) + ]} + > ({ + value: column.id, + label: column.label + }))} disabled={columnList.length === 0} > ({ className={styles['display-popover-properties-control']} data-slot='data-view-grouping-control' > - ({ + value: field.accessorKey, + label: field.label + })) + ]} + > ({ + value: column.id, + label: column.label + }))} disabled={columnList.length === 0} > ({ + value: operation.value, + label: + showAlternateLabel && operation?.alternateLabel + ? operation.alternateLabel + : operation.label + }))} aria-labelledby={`${label}-label`} > + options.find(opt => opt.value.toString() === optionValue)?.label ?? + optionValue; + const renderValueInput = () => { switch (columnType) { case FilterType.multiselect: @@ -219,6 +223,10 @@ export const FilterChip = ({ value={isMultiSelectColumn ? filterValue : filterValue.toString()} onValueChange={handleFilterValueChange} multiple={isMultiSelectColumn} + items={options.map(opt => ({ + value: opt.value.toString(), + label: opt.label + }))} {...selectProps} > - {isMultiSelectColumn && filterValue.length > 1 - ? `${filterValue.length} selected` - : undefined} + {selected => + Array.isArray(selected) && selected.length > 1 + ? `${selected.length} selected` + : getOptionLabel([selected].flat()[0]) + } diff --git a/packages/react/components/select/__tests__/data-slots.test.tsx b/packages/react/components/select/__tests__/data-slots.test.tsx index 9d3cbf254..42c2585fd 100644 --- a/packages/react/components/select/__tests__/data-slots.test.tsx +++ b/packages/react/components/select/__tests__/data-slots.test.tsx @@ -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'; @@ -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( + Fruit + + + + + ); + expect(getSlot(container, 'select-label')).not.toBeNull(); + }); + + it('exposes the empty and status slots in autocomplete mode', async () => { + render( + + ); + 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( + + + + {value => icon-{value}} + + + + Banana + + + ); + await flushMicrotasks(); + + expect(screen.getByTestId('custom')).toHaveTextContent('icon-banana'); + }); + + it('shows chips with labels in multiple mode', async () => { + const { container } = render( + + ); + await flushMicrotasks(); + + const value = container.querySelector( + '[data-slot="select-value-content"]' + ); + expect(value).toHaveTextContent('Grapes'); + }); + + it('shows the label in autocomplete mode before the popup opens', async () => { + render(); + await flushMicrotasks(); + + expect(screen.getByLabelText('Select option')).toHaveTextContent( + 'Pineapple' + ); + }); + }); + + describe('Label', () => { + it('labels the trigger', async () => { + render( + + ); + await flushMicrotasks(); + + const label = screen.getByText('Fruit'); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-labelledby', + expect.stringContaining(label.id) + ); + }); + }); + describe('Single Selection', () => { it('marks only the selected item with an indicator', async () => { const user = userEvent.setup(); @@ -313,6 +428,88 @@ describe('Select', () => { ).toBeNull(); }); + it('filters options by label when items are given', async () => { + const user = userEvent.setup(); + render( + + ); + + fireEvent.click(screen.getByLabelText('Select option')); + await flushMicrotasks(); + await user.type(screen.getByPlaceholderText('Search...'), 'ban'); + await flushMicrotasks(); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Banana'); + }); + + it('shows Select.Empty only when no item matches', async () => { + const user = userEvent.setup(); + render( + + ); + + fireEvent.click(screen.getByLabelText('Select option')); + await flushMicrotasks(); + expect(screen.queryByText('No fruit found')).not.toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('Search...'), 'zzz'); + await flushMicrotasks(); + expect(screen.getByText('No fruit found')).toBeInTheDocument(); + expect(screen.queryAllByRole('option')).toHaveLength(0); + }); + + it('renders Select.Status as a live region', async () => { + render( + + ); + + fireEvent.click(screen.getByLabelText('Select option')); + await flushMicrotasks(); + + const status = document.querySelector( + '[data-slot="select-status"]' + ); + expect(status).toHaveTextContent('5 fruits'); + expect(status).toHaveAttribute('aria-live', 'polite'); + expect(screen.getByRole('listbox')).not.toContainElement(status); + }); + 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 123d10e99..ae0e40118 100644 --- a/packages/react/components/select/select-content.tsx +++ b/packages/react/components/select/select-content.tsx @@ -5,11 +5,26 @@ import { Select as SelectPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; +import { Children, isValidElement, ReactNode } from 'react'; import { type Radius, radiusStyle } from '../../shared/radius'; import { useThemeInjection } from '../theme/portal'; import styles from './select.module.css'; +import { SelectEmpty, SelectStatus } from './select-misc'; import { useSelectContext } from './select-root'; +// Base UI expects Empty and Status as siblings of the list, not inside the listbox. +const splitLiveRegions = (children: ReactNode) => { + const regions: ReactNode[] = []; + const items: ReactNode[] = []; + Children.forEach(children, child => { + const isRegion = + isValidElement(child) && + (child.type === SelectEmpty || child.type === SelectStatus); + (isRegion ? regions : items).push(child); + }); + return { regions, items }; +}; + export interface SelectContentProps extends Omit< SelectPrimitive.Positioner.Props, @@ -36,6 +51,7 @@ export function SelectContent({ const theme = useThemeInjection(); if (autocomplete) { + const { regions, items } = splitLiveRegions(children); return ( + {regions} - {children} + {items} @@ -76,34 +93,36 @@ export function SelectContent({ } return ( - - + - - {children} - - - + + {children} + + + + ); } SelectContent.displayName = 'Select.Content'; diff --git a/packages/react/components/select/select-item.tsx b/packages/react/components/select/select-item.tsx index 9e1ebd9d8..56769a09d 100644 --- a/packages/react/components/select/select-item.tsx +++ b/packages/react/components/select/select-item.tsx @@ -5,13 +5,13 @@ import { Select as SelectPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ReactNode, useLayoutEffect } from 'react'; +import { ReactNode } from 'react'; import { CheckIcon } from '~/icons'; import { Checkbox } from '../checkbox'; import { getMatch } from '../menu/utils'; import { Text } from '../text'; import styles from './select.module.css'; -import { useSelectContext } from './select-root'; +import { useFilteredValues, useSelectContext } from './select-root'; export interface SelectItemProps extends SelectPrimitive.Item.Props { leadingIcon?: ReactNode; @@ -27,21 +27,28 @@ export function SelectItem({ }: SelectItemProps) { const value = String(providedValue); const { - registerItem, - unregisterItem, autocomplete, searchValue, value: selectValue, shouldFilter, - hasItems, multiple } = useSelectContext(); + const filteredValues = useFilteredValues(); + + // With `items`, Base UI filters by label and maps list positions to the + // filtered items, so every unmatched item must leave the list. + if (filteredValues && !filteredValues.has(value)) return null; const isSelected = multiple ? selectValue?.includes(value) : value === selectValue; const isMatched = getMatch(value, children, searchValue); - const isHidden = shouldFilter && !hasItems && isSelected && !isMatched; + const ownFilter = shouldFilter && !filteredValues; + const isHidden = ownFilter && isSelected && !isMatched; + + if (ownFilter && !isMatched && !isSelected) { + return null; + } const element = typeof children === 'string' ? ( @@ -69,17 +76,6 @@ export function SelectItem({ children ); - useLayoutEffect(() => { - registerItem({ leadingIcon, children, value }); - return () => { - unregisterItem(value); - }; - }, [value, children, registerItem, unregisterItem, leadingIcon]); - - if (shouldFilter && !hasItems && !isMatched && !isSelected) { - return null; - } - const ItemPrimitive = autocomplete ? ComboboxPrimitive.Item : SelectPrimitive.Item; diff --git a/packages/react/components/select/select-misc.tsx b/packages/react/components/select/select-misc.tsx index ef6beb90f..e61bd2a4c 100644 --- a/packages/react/components/select/select-misc.tsx +++ b/packages/react/components/select/select-misc.tsx @@ -5,7 +5,7 @@ import { Select as SelectPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { Fragment, ReactNode } from 'react'; +import { Fragment } from 'react'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; @@ -36,9 +36,13 @@ export function SelectGroup({ } SelectGroup.displayName = 'Select.Group'; -export interface SelectLabelProps extends SelectPrimitive.GroupLabel.Props {} +export interface SelectGroupLabelProps + extends SelectPrimitive.GroupLabel.Props {} -export function SelectLabel({ className, ...props }: SelectLabelProps) { +export function SelectGroupLabel({ + className, + ...props +}: SelectGroupLabelProps) { const { shouldFilter, autocomplete } = useSelectContext(); if (shouldFilter) return null; @@ -47,6 +51,25 @@ export function SelectLabel({ className, ...props }: SelectLabelProps) { ? ComboboxPrimitive.GroupLabel : SelectPrimitive.GroupLabel; + return ( + + ); +} +SelectGroupLabel.displayName = 'Select.GroupLabel'; + +export interface SelectLabelProps extends SelectPrimitive.Label.Props {} + +export function SelectLabel({ className, ...props }: SelectLabelProps) { + const { autocomplete } = useSelectContext(); + + const LabelPrimitive = autocomplete + ? ComboboxPrimitive.Label + : SelectPrimitive.Label; + return ( + {hasItems ? children : null} + + ); +} +SelectEmpty.displayName = 'Select.Empty'; + +export interface SelectStatusProps extends ComboboxPrimitive.Status.Props {} + +export function SelectStatus({ className, ...props }: SelectStatusProps) { + const { autocomplete } = useSelectContext(); + + if (!autocomplete) return null; + + return ( + + ); +} +SelectStatus.displayName = 'Select.Status'; + export interface SelectSeparatorProps extends SelectPrimitive.Separator.Props {} export function SelectSeparator({ className, ...props }: SelectSeparatorProps) { diff --git a/packages/react/components/select/select-multiple-value.tsx b/packages/react/components/select/select-multiple-value.tsx index ab3c544b9..99bf0407d 100644 --- a/packages/react/components/select/select-multiple-value.tsx +++ b/packages/react/components/select/select-multiple-value.tsx @@ -1,14 +1,13 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { useLayoutEffect, useRef, useState } from 'react'; +import { ReactNode, useLayoutEffect, useRef, useState } from 'react'; import { Chip } from '../chip'; import { Text } from '../text'; import styles from './select.module.css'; -import { ItemType } from './types'; interface SelectMultipleValueProps { - data: ItemType[]; + data: { value: string; label: ReactNode }[]; } /* @@ -44,10 +43,8 @@ export const SelectMultipleValue = ({ if (!containerRef.current || data.length === 0) return; const chipWidths: number[] = data.map(item => { - const text = - typeof item.children === 'string' ? item.children : item.value; - const iconWidth = item.leadingIcon ? 16 : 0; - return calculateTextWidth(text) + 8 + iconWidth; + const text = typeof item.label === 'string' ? item.label : item.value; + return calculateTextWidth(text) + 8; }); let totalWidth = 0; @@ -75,12 +72,10 @@ export const SelectMultipleValue = ({
{data.slice(0, visibleCount).map(item => ( - - {typeof item.children === 'string' ? item.children : item.value} - + {item.label} ))} {data.length > visibleCount && ( diff --git a/packages/react/components/select/select-root.tsx b/packages/react/components/select/select-root.tsx index 7bcf29527..38043e6d0 100644 --- a/packages/react/components/select/select-root.tsx +++ b/packages/react/components/select/select-root.tsx @@ -13,7 +13,7 @@ import { useState } from 'react'; import { useFieldContext } from '../field'; -import { ItemType } from './types'; +import { SelectItems } from './types'; interface CommonProps { autocomplete?: boolean; @@ -25,11 +25,9 @@ interface CommonProps { interface SelectContextValue extends CommonProps { value?: string | string[]; - registerItem: (item: ItemType) => void; - unregisterItem: (value: string) => void; multiple: boolean; - items: Record; - hasItems?: boolean; + hasItems: boolean; + getLabel: (value: string) => ReactNode; } interface UseSelectContext extends SelectContextValue { @@ -42,6 +40,11 @@ Root context to manage the Select control */ const SelectContext = createContext(undefined); +/** Values that match the search in autocomplete mode with `items`. */ +const FilteredValuesContext = createContext | null>(null); + +export const useFilteredValues = () => useContext(FilteredValuesContext); + export const useSelectContext = (): UseSelectContext => { const context = useContext(SelectContext); if (!context) { @@ -58,6 +61,27 @@ export const useSelectContext = (): UseSelectContext => { }; }; +function FilteredValuesProvider({ children }: { children: ReactNode }) { + const filteredItems = ComboboxPrimitive.useFilteredItems(); + const filteredValues = useMemo(() => new Set(filteredItems), [filteredItems]); + return ( + + {children} + + ); +} + +const toLabelMap = (items?: SelectItems) => { + const map = new Map(); + if (!items) return map; + if (Array.isArray(items)) { + for (const item of items) map.set(item.value, item.label); + } else { + for (const [value, label] of Object.entries(items)) map.set(value, label); + } + return map; +}; + export interface BaseSelectProps extends CommonProps { children?: ReactNode; open?: boolean; @@ -66,7 +90,11 @@ export interface BaseSelectProps extends CommonProps { disabled?: boolean; required?: boolean; name?: string; - items?: string[]; + /** + * Labels for each value, as `{ value, label }[]` or a value-to-label record. + * `Select.Value` uses them to show the label of the selected value. + */ + items?: SelectItems; } export interface SingleSelectProps extends BaseSelectProps { @@ -103,7 +131,7 @@ export const SelectRoot = (props: SelectRootProps) => { disabled, required, name, - items: itemsProp, + items, ...rest } = props; @@ -115,16 +143,32 @@ export const SelectRoot = (props: SelectRootProps) => { >(defaultValue); const [internalSearchValue, setInternalSearchValue] = useState(defaultSearchValue); - const [registeredItems, setRegisteredItems] = useState< - SelectContextValue['items'] - >({}); const computedValue = providedValue ?? internalValue; const searchValue = providedSearchValue ?? internalSearchValue; + const hasItems = !!items; + + const labels = useMemo(() => toLabelMap(items), [items]); + const itemValues = useMemo(() => Array.from(labels.keys()), [labels]); + + const getLabel = useCallback( + (value: string) => (labels.has(value) ? labels.get(value) : value), + [labels] + ); + + const itemToStringLabel = useCallback( + (value: string) => { + const label = labels.get(value); + return typeof label === 'string' || typeof label === 'number' + ? String(label) + : value; + }, + [labels] + ); const handleValueChange = useCallback( - (value: any, _eventDetails?: any) => { - setInternalValue(value); + (value: string | string[] | null) => { + setInternalValue(value ?? undefined); if (multiple) { (onValueChange as MultipleSelectProps['onValueChange'])?.( value as string[] @@ -139,7 +183,7 @@ export const SelectRoot = (props: SelectRootProps) => { ); const handleSearchValueChange = useCallback( - (value: string, _eventDetails?: any) => { + (value: string) => { setInternalSearchValue(value); onSearch?.(value); }, @@ -147,59 +191,41 @@ export const SelectRoot = (props: SelectRootProps) => { ); const handleOpenChange = useCallback( - (open: boolean, _eventDetails?: any) => { + (open: boolean) => { onOpenChange?.(open); }, [onOpenChange] ); - const registerItem = useCallback(item => { - setRegisteredItems(prev => ({ ...prev, [item.value]: item })); - }, []); - - const unregisterItem = useCallback( - value => { - setRegisteredItems(prev => { - const { [value]: _, ...rest } = prev; - return rest; - }); - }, - [] - ); - const contextValue = useMemo( () => ({ value: computedValue, - registerItem, - unregisterItem, autocomplete, autocompleteMode, searchValue, multiple, - items: registeredItems, - hasItems: !!itemsProp + hasItems, + getLabel }), [ computedValue, - registerItem, - unregisterItem, autocomplete, autocompleteMode, searchValue, multiple, - registeredItems, - itemsProp + hasItems, + getLabel ] ); const commonProps = { - value: providedValue as any, - defaultValue: defaultValue as any, + value: providedValue, + defaultValue, onValueChange: handleValueChange, open: providedOpen, defaultOpen, onOpenChange: handleOpenChange, - multiple: multiple as any, + multiple, disabled, modal: true as const, ...rest, @@ -208,27 +234,35 @@ export const SelectRoot = (props: SelectRootProps) => { }; if (autocomplete) { + const filterByLabel = hasItems && autocompleteMode === 'auto'; return ( - {...commonProps} onInputValueChange={handleSearchValueChange} - filter={itemsProp ? undefined : null} - items={itemsProp} + filter={filterByLabel ? undefined : null} + items={hasItems ? itemValues : undefined} + itemToStringLabel={itemToStringLabel} loopFocus={false} // @ts-ignore @base-ui/react@1.3.0 ComboboxRootProps types `autoHighlight` as `boolean | undefined`, but the runtime accepts `always | input-change`. Remove when upstream types are corrected. autoHighlight='always' > - {children} + {filterByLabel ? ( + {children} + ) : ( + children + )} ); } return ( - - {children} - + + {...commonProps} items={items}> + {children} + + ); }; diff --git a/packages/react/components/select/select-value.tsx b/packages/react/components/select/select-value.tsx index 548586d33..a1976a4f5 100644 --- a/packages/react/components/select/select-value.tsx +++ b/packages/react/components/select/select-value.tsx @@ -1,19 +1,27 @@ 'use client'; +import { + Combobox as ComboboxPrimitive, + Select as SelectPrimitive +} from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ComponentProps, ReactNode, useMemo } from 'react'; +import { ComponentProps, ReactNode } from 'react'; import styles from './select.module.css'; import { SelectMultipleValue } from './select-multiple-value'; import { useSelectContext } from './select-root'; -import { ItemType } from './types'; -type ValueType = Omit; +type SelectValueRenderer = (value: string | string[]) => ReactNode; -type SelectValueProps = ComponentProps<'span'> & { - placeholder?: string; - children?: ((value?: ValueType | ValueType[]) => ReactNode) | ReactNode; - className?: string; -}; +export interface SelectValueProps + extends Omit, 'children' | 'placeholder'> { + /** Shown when nothing is selected. */ + placeholder?: ReactNode; + /** Replaces the label. A function receives the selected value. */ + children?: ReactNode | SelectValueRenderer; +} + +const isEmpty = (value: unknown) => + value == null || value === '' || (Array.isArray(value) && !value.length); export function SelectValue({ children, @@ -21,66 +29,50 @@ export function SelectValue({ className, ...props }: SelectValueProps) { - const { value, items, multiple } = useSelectContext(); + const { multiple, autocomplete, getLabel, value } = useSelectContext(); - const hasValue = multiple - ? Array.isArray(value) && value.length > 0 - : !!value; - - const item = useMemo(() => { - if (!value) return undefined; - if (multiple && Array.isArray(value)) { - const itemValues = value.map(v => items[v]); - if (itemValues.length === 1) return itemValues[0]; - return itemValues; + const renderSelected = (selected: string | string[]) => { + if (typeof children === 'function') return children(selected); + if (children != null) return children; + if (Array.isArray(selected)) { + return ( + ({ value: v, label: getLabel(v) }))} + /> + ); } - return items[value as string]; - }, [value, items, multiple]); + return getLabel(selected); + }; + + const renderValue = (selected: string | string[] | null) => + isEmpty(selected) + ? placeholder + : renderSelected(selected as string | string[]); - if (!hasValue) { + if (autocomplete) { return ( - {placeholder} + {renderValue} ); } - if (typeof children === 'function') { - return ( - - {children(item)} - - ); - } - - if (children) { - return ( - - {children} - - ); - } - - if (Array.isArray(item)) { - return ; - } + const useDefaultLabel = !multiple && children == null; return ( - -
- {typeof item?.children === 'string' && item?.leadingIcon && ( -
- {item.leadingIcon} -
- )} - {item?.children ?? value} -
-
+ + {useDefaultLabel ? undefined : renderValue} + ); } SelectValue.displayName = 'Select.Value'; diff --git a/packages/react/components/select/select.module.css b/packages/react/components/select/select.module.css index b159a565e..e9594b54f 100644 --- a/packages/react/components/select/select.module.css +++ b/packages/react/components/select/select.module.css @@ -63,12 +63,40 @@ pointer-events: none; } -.label { +.groupLabel { padding: var(--rs-space-2) var(--rs-space-3); font-weight: var(--rs-font-weight-medium); font-size: var(--rs-font-size-mini); } +.label { + color: var(--rs-color-foreground-base-secondary); + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + cursor: default; +} + +.label[data-disabled] { + color: var(--rs-color-foreground-base-tertiary); +} + +.empty:not(:empty) { + padding: var(--rs-space-4); + color: var(--rs-color-foreground-base-secondary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.status:not(:empty) { + padding: var(--rs-space-3) var(--rs-space-4) 0; + color: var(--rs-color-foreground-base-secondary); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); +} + .separator { height: 1px; margin: var(--rs-space-2) calc(var(--rs-space-3) * -1); @@ -191,6 +219,14 @@ white-space: nowrap; } +.value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .valueContent { flex: 1; display: flex; @@ -203,7 +239,15 @@ text-overflow: ellipsis; } -.trigger-small .valueContent { +.trigger-small .value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.valueContent { gap: var(--rs-space-2); } diff --git a/packages/react/components/select/select.tsx b/packages/react/components/select/select.tsx index a4f29d02c..7fc46f196 100644 --- a/packages/react/components/select/select.tsx +++ b/packages/react/components/select/select.tsx @@ -1,7 +1,14 @@ import { Select as SelectPrimitive } from '@base-ui/react'; import { SelectContent } from './select-content'; import { SelectItem } from './select-item'; -import { SelectGroup, SelectLabel, SelectSeparator } from './select-misc'; +import { + SelectEmpty, + SelectGroup, + SelectGroupLabel, + SelectLabel, + SelectSeparator, + SelectStatus +} from './select-misc'; import { SelectRoot } from './select-root'; import { SelectTrigger } from './select-trigger'; import { SelectValue } from './select-value'; @@ -16,5 +23,8 @@ export const Select = Object.assign(SelectRoot, { Content: SelectContent, Item: SelectItem, Separator: SelectSeparator, - Label: SelectLabel + Label: SelectLabel, + GroupLabel: SelectGroupLabel, + Empty: SelectEmpty, + Status: SelectStatus }); diff --git a/packages/react/components/select/types.ts b/packages/react/components/select/types.ts index 335e5c089..5a3a0c8fd 100644 --- a/packages/react/components/select/types.ts +++ b/packages/react/components/select/types.ts @@ -1,7 +1,5 @@ import { ReactNode } from 'react'; -export type ItemType = { - leadingIcon?: ReactNode; - children: ReactNode; - value: string; -}; +export type SelectItems = + | { value: string; label: ReactNode }[] + | Record; From 67d534d7ae5462ce16ece1196a41582053d8dbdc Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Thu, 8 Oct 2026 21:48:52 -0500 Subject: [PATCH 4/4] refactor: [select] keep item auto-registration and make items optional --- apps/www/src/components/theme-panel-demo.tsx | 5 +- .../docs/components/code-block/demo.ts | 4 +- .../docs/components/code-block/index.mdx | 1 - .../src/content/docs/components/form/demo.ts | 2 +- .../content/docs/components/select/demo.ts | 64 ++++++--------- .../content/docs/components/select/index.mdx | 4 +- .../content/docs/components/select/props.ts | 15 +++- apps/www/src/content/docs/theme/icons/demo.ts | 4 +- .../src/content/docs/theme/overview/demo.ts | 2 +- .../react/components/calendar/calendar.tsx | 4 - .../code-block/__tests__/code-block.test.tsx | 4 +- .../color-picker/color-picker-mode.tsx | 9 +- .../data-table/components/grouping.tsx | 12 +-- .../data-table/components/ordering.tsx | 4 - .../data-view/components/grouping.tsx | 12 +-- .../data-view/components/ordering.tsx | 4 - .../filter-chip/filter-chip-operation.tsx | 7 -- .../components/filter-chip/filter-chip.tsx | 16 +--- .../select/__tests__/select.test.tsx | 58 +++++++++++-- .../components/select/select-content.tsx | 64 ++++++++------- .../react/components/select/select-item.tsx | 20 ++++- .../react/components/select/select-misc.tsx | 13 +-- .../react/components/select/select-root.tsx | 82 ++++++++++++++++--- .../react/components/select/select-value.tsx | 31 ++++--- packages/react/components/select/types.ts | 6 ++ 25 files changed, 264 insertions(+), 183 deletions(-) diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx index a6030f9cc..4784ef4de 100644 --- a/apps/www/src/components/theme-panel-demo.tsx +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -148,10 +148,7 @@ function Sampler() { - diff --git a/apps/www/src/content/docs/components/code-block/demo.ts b/apps/www/src/content/docs/components/code-block/demo.ts index 1b17a1cb2..2505e71f5 100644 --- a/apps/www/src/content/docs/components/code-block/demo.ts +++ b/apps/www/src/content/docs/components/code-block/demo.ts @@ -40,7 +40,7 @@ const getCode = (props: ComponentPropsType) => { return ` Code - + @@ -115,7 +115,7 @@ export const languageSwitcherDemo = { code: ` Code - + diff --git a/apps/www/src/content/docs/components/code-block/index.mdx b/apps/www/src/content/docs/components/code-block/index.mdx index 5cc352f7e..9a044bdd4 100644 --- a/apps/www/src/content/docs/components/code-block/index.mdx +++ b/apps/www/src/content/docs/components/code-block/index.mdx @@ -58,7 +58,6 @@ Add a structured header with labels and controls using `CodeBlock.Header`, `Code Support multiple programming languages by including multiple `CodeBlock.Code` components with different language values. Use `CodeBlock.LanguageSelect` to provide a dropdown for users to switch between languages. The component automatically displays the code block matching the selected language value, which can be controlled programmatically using the `value` and `onValueChange` props. -Pass `items` to show the language label in the trigger. Without it, the trigger shows the raw value. diff --git a/apps/www/src/content/docs/components/form/demo.ts b/apps/www/src/content/docs/components/form/demo.ts index 6e41732b1..3650dbc9f 100644 --- a/apps/www/src/content/docs/components/form/demo.ts +++ b/apps/www/src/content/docs/components/form/demo.ts @@ -203,7 +203,7 @@ export const rhfDemo = { control={control} rules={{ required: 'Please select a role' }} render={({ field }) => ( - Developer diff --git a/apps/www/src/content/docs/components/select/demo.ts b/apps/www/src/content/docs/components/select/demo.ts index b4eb41d23..78e98d42b 100644 --- a/apps/www/src/content/docs/components/select/demo.ts +++ b/apps/www/src/content/docs/components/select/demo.ts @@ -6,7 +6,7 @@ import { getPropsString } from '@/lib/utils'; export const getCode = (props: ComponentPropsType) => { const { autocomplete, multiple, ...rest } = props; return ` - + + + - @@ -107,7 +107,7 @@ export const variantDemo = { { name: 'Default', code: ` - @@ -121,7 +121,7 @@ export const variantDemo = { { name: 'Filter', code: ` - @@ -137,7 +137,7 @@ export const variantDemo = { export const separatorDemo = { type: 'code', code: ` - @@ -159,7 +159,6 @@ export const multipleDemo = { code: ` @@ -262,7 +260,7 @@ export const labelDemo = { type: 'code', code: ` - Fruit @@ -278,32 +276,20 @@ export const labelDemo = { export const valueRenderDemo = { type: 'code', code: ` -function IconValue() { - const fruits = { - apple: { label: "Apple", icon: }, - banana: { label: "Banana", icon: } - }; - - return ( - - ); -}` +` }; diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 825a3f85e..1830d18ab 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -52,7 +52,7 @@ 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. -Pass `items` to the root so `Select.Value` can show the label of the selected value. It takes `{ value, label }[]` or a value-to-label record. The list mounts only when it opens, so without `items` the trigger shows the raw value. +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. @@ -62,7 +62,7 @@ Pass `leadingIcon` to `Select.Item` to show an icon before the item text. -To show the icon in the trigger too, pass a function as the child of `Select.Value`. It receives the selected value. +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. diff --git a/apps/www/src/content/docs/components/select/props.ts b/apps/www/src/content/docs/components/select/props.ts index b8f7e404d..78118f53e 100644 --- a/apps/www/src/content/docs/components/select/props.ts +++ b/apps/www/src/content/docs/components/select/props.ts @@ -26,18 +26,27 @@ export interface SelectRootProps { /** Callback fired when the search value changes */ onSearch?: (value: string) => void; - /** Labels for each value, as `{ value, label }[]` or a value-to-label record. `Select.Value` shows the label of the selected value. In autocomplete mode, Base UI filters by label. */ + /** 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; } +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 value (an array in multiple mode). */ - children?: React.ReactNode | ((value: string | string[]) => 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; diff --git a/apps/www/src/content/docs/theme/icons/demo.ts b/apps/www/src/content/docs/theme/icons/demo.ts index 590271d86..54306bb7e 100644 --- a/apps/www/src/content/docs/theme/icons/demo.ts +++ b/apps/www/src/content/docs/theme/icons/demo.ts @@ -44,7 +44,7 @@ const MyChevron = props => ( render( - @@ -57,7 +57,7 @@ render( - diff --git a/apps/www/src/content/docs/theme/overview/demo.ts b/apps/www/src/content/docs/theme/overview/demo.ts index 095ef4c18..1d8b0b834 100644 --- a/apps/www/src/content/docs/theme/overview/demo.ts +++ b/apps/www/src/content/docs/theme/overview/demo.ts @@ -216,7 +216,7 @@ export const portalDemo = { - diff --git a/packages/react/components/calendar/calendar.tsx b/packages/react/components/calendar/calendar.tsx index 9e7c74a26..ad3817e9c 100644 --- a/packages/react/components/calendar/calendar.tsx +++ b/packages/react/components/calendar/calendar.tsx @@ -77,10 +77,6 @@ function DropDown({ setMode(value as ModeType)} - value={mode} - items={options.map(option => ({ - value: option, - label: option.toUpperCase() - }))} - > + ({ - value: column.id, - label: column.label - })) - ]} - > + ({ - value: field.accessorKey, - label: field.label - })) - ]} - > + + + + + + } + > + Apple + + + + ); + await flushMicrotasks(); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveTextContent('Apple'); + expect( + trigger.querySelector('[data-slot="select-value-icon"]') + ).toBeInTheDocument(); + }); + + it('keeps the label after the list opens and closes', async () => { + render(); + await flushMicrotasks(); + + fireEvent.click(screen.getByRole('combobox')); + await flushMicrotasks(); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' }); + await flushMicrotasks(); + + expect(screen.getByRole('combobox')).toHaveTextContent('Apple'); }); it('shows the placeholder when nothing is selected', async () => { @@ -132,12 +170,18 @@ describe('Select', () => { expect(value).toHaveAttribute('data-placeholder'); }); - it('passes the selected value to a function child', async () => { + it('passes the selected item to a function child', async () => { render( - - {value => icon-{value}} + {item => + Array.isArray(item) ? null : ( + + icon-{item.value}-{item.children} + + ) + } @@ -147,7 +191,9 @@ describe('Select', () => { ); await flushMicrotasks(); - expect(screen.getByTestId('custom')).toHaveTextContent('icon-banana'); + expect(screen.getByTestId('custom')).toHaveTextContent( + 'icon-banana-Banana' + ); }); it('shows chips with labels in multiple mode', async () => { diff --git a/packages/react/components/select/select-content.tsx b/packages/react/components/select/select-content.tsx index ae0e40118..f0fa9e684 100644 --- a/packages/react/components/select/select-content.tsx +++ b/packages/react/components/select/select-content.tsx @@ -10,7 +10,7 @@ import { type Radius, radiusStyle } from '../../shared/radius'; import { useThemeInjection } from '../theme/portal'; import styles from './select.module.css'; import { SelectEmpty, SelectStatus } from './select-misc'; -import { useSelectContext } from './select-root'; +import { SelectRegistrationContext, useSelectContext } from './select-root'; // Base UI expects Empty and Status as siblings of the list, not inside the listbox. const splitLiveRegions = (children: ReactNode) => { @@ -93,36 +93,42 @@ export function SelectContent({ } return ( - - - + {/* The portal mounts the list only when open, so items register their labels here. */} + + {children} + + + - - {children} - - - - + + {children} + + + + + ); } SelectContent.displayName = 'Select.Content'; diff --git a/packages/react/components/select/select-item.tsx b/packages/react/components/select/select-item.tsx index 56769a09d..4b1bda3ea 100644 --- a/packages/react/components/select/select-item.tsx +++ b/packages/react/components/select/select-item.tsx @@ -4,14 +4,19 @@ import { Combobox as ComboboxPrimitive, Select as SelectPrimitive } from '@base-ui/react'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { cx } from 'class-variance-authority'; -import { ReactNode } from 'react'; +import { ReactNode, useContext } from 'react'; import { CheckIcon } from '~/icons'; import { Checkbox } from '../checkbox'; import { getMatch } from '../menu/utils'; import { Text } from '../text'; import styles from './select.module.css'; -import { useFilteredValues, useSelectContext } from './select-root'; +import { + SelectRegistrationContext, + useFilteredValues, + useSelectContext +} from './select-root'; export interface SelectItemProps extends SelectPrimitive.Item.Props { leadingIcon?: ReactNode; @@ -31,9 +36,18 @@ export function SelectItem({ searchValue, value: selectValue, shouldFilter, - multiple + multiple, + registerItem } = useSelectContext(); const filteredValues = useFilteredValues(); + const registering = useContext(SelectRegistrationContext); + + useIsoLayoutEffect( + () => registerItem({ value, children, leadingIcon }), + [registerItem, value, children, leadingIcon] + ); + + if (registering) return null; // With `items`, Base UI filters by label and maps list positions to the // filtered items, so every unmatched item must leave the list. diff --git a/packages/react/components/select/select-misc.tsx b/packages/react/components/select/select-misc.tsx index e61bd2a4c..f3e447a0d 100644 --- a/packages/react/components/select/select-misc.tsx +++ b/packages/react/components/select/select-misc.tsx @@ -5,9 +5,9 @@ import { Select as SelectPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { Fragment } from 'react'; +import { Fragment, useContext } from 'react'; import styles from './select.module.css'; -import { useSelectContext } from './select-root'; +import { SelectRegistrationContext, useSelectContext } from './select-root'; export interface SelectGroupProps extends SelectPrimitive.Group.Props {} @@ -17,8 +17,9 @@ export function SelectGroup({ ...props }: SelectGroupProps) { const { shouldFilter, autocomplete } = useSelectContext(); + const registering = useContext(SelectRegistrationContext); - if (shouldFilter) return {children}; + if (shouldFilter || registering) return {children}; const GroupPrimitive = autocomplete ? ComboboxPrimitive.Group @@ -44,8 +45,9 @@ export function SelectGroupLabel({ ...props }: SelectGroupLabelProps) { const { shouldFilter, autocomplete } = useSelectContext(); + const registering = useContext(SelectRegistrationContext); - if (shouldFilter) return null; + if (shouldFilter || registering) return null; const LabelPrimitive = autocomplete ? ComboboxPrimitive.GroupLabel @@ -125,8 +127,9 @@ export interface SelectSeparatorProps extends SelectPrimitive.Separator.Props {} export function SelectSeparator({ className, ...props }: SelectSeparatorProps) { const { shouldFilter, autocomplete } = useSelectContext(); + const registering = useContext(SelectRegistrationContext); - if (shouldFilter) return null; + if (shouldFilter || registering) return null; const SeparatorPrimitive = autocomplete ? ComboboxPrimitive.Separator diff --git a/packages/react/components/select/select-root.tsx b/packages/react/components/select/select-root.tsx index 38043e6d0..e7575cc59 100644 --- a/packages/react/components/select/select-root.tsx +++ b/packages/react/components/select/select-root.tsx @@ -10,10 +10,11 @@ import { useCallback, useContext, useMemo, + useRef, useState } from 'react'; import { useFieldContext } from '../field'; -import { SelectItems } from './types'; +import { ItemType, SelectItems } from './types'; interface CommonProps { autocomplete?: boolean; @@ -28,6 +29,9 @@ interface SelectContextValue extends CommonProps { multiple: boolean; hasItems: boolean; getLabel: (value: string) => ReactNode; + getItem: (value: string) => ItemType; + /** Records an item's label and icon. Returns a function that removes it. */ + registerItem: (item: ItemType) => () => void; } interface UseSelectContext extends SelectContextValue { @@ -40,6 +44,12 @@ Root context to manage the Select control */ const SelectContext = createContext(undefined); +/** + * True inside the hidden pass that `Select.Content` renders while closed. + * Items only register their label there, so the trigger can show it before the first open. + */ +export const SelectRegistrationContext = createContext(false); + /** Values that match the search in autocomplete mode with `items`. */ const FilteredValuesContext = createContext | null>(null); @@ -91,8 +101,8 @@ export interface BaseSelectProps extends CommonProps { required?: boolean; name?: string; /** - * Labels for each value, as `{ value, label }[]` or a value-to-label record. - * `Select.Value` uses them to show the label of the selected value. + * Optional labels for each value, as `{ value, label }[]` or a value-to-label record. + * Items register their own labels, so you only need this for search by label or labels of items that are not rendered. */ items?: SelectItems; } @@ -151,19 +161,63 @@ export const SelectRoot = (props: SelectRootProps) => { const labels = useMemo(() => toLabelMap(items), [items]); const itemValues = useMemo(() => Array.from(labels.keys()), [labels]); + // Each value can be registered twice at once: by the hidden pass and by the open list. + const registrationCounts = useRef(new Map()); + const [registered, setRegistered] = useState>({}); + + const registerItem = useCallback((item: ItemType) => { + const counts = registrationCounts.current; + counts.set(item.value, (counts.get(item.value) ?? 0) + 1); + setRegistered(prev => { + const current = prev[item.value]; + if ( + current?.children === item.children && + current?.leadingIcon === item.leadingIcon + ) { + return prev; + } + return { ...prev, [item.value]: item }; + }); + return () => { + const remaining = (counts.get(item.value) ?? 1) - 1; + if (remaining > 0) { + counts.set(item.value, remaining); + return; + } + counts.delete(item.value); + setRegistered(prev => { + if (!(item.value in prev)) return prev; + const { [item.value]: _, ...rest } = prev; + return rest; + }); + }; + }, []); + const getLabel = useCallback( - (value: string) => (labels.has(value) ? labels.get(value) : value), - [labels] + (value: string): ReactNode => + labels.has(value) + ? labels.get(value) + : (registered[value]?.children ?? value), + [labels, registered] + ); + + const getItem = useCallback( + (value: string): ItemType => ({ + value, + children: getLabel(value), + leadingIcon: registered[value]?.leadingIcon + }), + [getLabel, registered] ); const itemToStringLabel = useCallback( (value: string) => { - const label = labels.get(value); + const label = getLabel(value); return typeof label === 'string' || typeof label === 'number' ? String(label) : value; }, - [labels] + [getLabel] ); const handleValueChange = useCallback( @@ -205,7 +259,9 @@ export const SelectRoot = (props: SelectRootProps) => { searchValue, multiple, hasItems, - getLabel + getLabel, + getItem, + registerItem }), [ computedValue, @@ -214,7 +270,9 @@ export const SelectRoot = (props: SelectRootProps) => { searchValue, multiple, hasItems, - getLabel + getLabel, + getItem, + registerItem ] ); @@ -259,7 +317,11 @@ export const SelectRoot = (props: SelectRootProps) => { return ( - {...commonProps} items={items}> + + {...commonProps} + items={items} + itemToStringLabel={itemToStringLabel} + > {children} diff --git a/packages/react/components/select/select-value.tsx b/packages/react/components/select/select-value.tsx index a1976a4f5..814cec95f 100644 --- a/packages/react/components/select/select-value.tsx +++ b/packages/react/components/select/select-value.tsx @@ -9,14 +9,15 @@ import { ComponentProps, ReactNode } from 'react'; import styles from './select.module.css'; import { SelectMultipleValue } from './select-multiple-value'; import { useSelectContext } from './select-root'; +import { ItemType } from './types'; -type SelectValueRenderer = (value: string | string[]) => ReactNode; +type SelectValueRenderer = (item: ItemType | ItemType[]) => ReactNode; export interface SelectValueProps extends Omit, 'children' | 'placeholder'> { /** Shown when nothing is selected. */ placeholder?: ReactNode; - /** Replaces the label. A function receives the selected value. */ + /** Replaces the label. A function receives the selected item, or items in multiple mode. */ children?: ReactNode | SelectValueRenderer; } @@ -29,19 +30,31 @@ export function SelectValue({ className, ...props }: SelectValueProps) { - const { multiple, autocomplete, getLabel, value } = useSelectContext(); + const { autocomplete, getItem, value } = useSelectContext(); const renderSelected = (selected: string | string[]) => { - if (typeof children === 'function') return children(selected); + const item = Array.isArray(selected) + ? selected.map(getItem) + : getItem(selected); + if (typeof children === 'function') return children(item); if (children != null) return children; - if (Array.isArray(selected)) { + if (Array.isArray(item)) { return ( ({ value: v, label: getLabel(v) }))} + data={item.map(i => ({ value: i.value, label: i.children }))} /> ); } - return getLabel(selected); + return ( + + {typeof item.children === 'string' && item.leadingIcon && ( + + {item.leadingIcon} + + )} + {item.children} + + ); }; const renderValue = (selected: string | string[] | null) => @@ -62,8 +75,6 @@ export function SelectValue({ ); } - const useDefaultLabel = !multiple && children == null; - return ( - {useDefaultLabel ? undefined : renderValue} + {renderValue} ); } diff --git a/packages/react/components/select/types.ts b/packages/react/components/select/types.ts index 5a3a0c8fd..eacdb893b 100644 --- a/packages/react/components/select/types.ts +++ b/packages/react/components/select/types.ts @@ -1,5 +1,11 @@ import { ReactNode } from 'react'; +export type ItemType = { + leadingIcon?: ReactNode; + children: ReactNode; + value: string; +}; + export type SelectItems = | { value: string; label: ReactNode }[] | Record;