diff --git a/apps/www/src/content/docs/components/select/demo.ts b/apps/www/src/content/docs/components/select/demo.ts index 9ac38d769..78e98d42b 100644 --- a/apps/www/src/content/docs/components/select/demo.ts +++ b/apps/www/src/content/docs/components/select/demo.ts @@ -58,7 +58,7 @@ export const iconDemo = { }>Apple }>Banana }>Grape - }>Orange + }>Orange ` }; @@ -91,7 +91,7 @@ export const sizeDemo = { + + ` }, @@ -236,7 +235,10 @@ function ControlledSelect() { return ( - @@ -253,3 +255,41 @@ function ControlledSelect() { ); }` }; + +export const labelDemo = { + type: 'code', + code: ` + + +` +}; + +export const valueRenderDemo = { + type: 'code', + code: ` +` +}; diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index ef6f79097..1830d18ab 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"; @@ -54,7 +54,7 @@ describe('Select data-slot contract', () => { - Fruits + Fruits {FRUIT_OPTIONS.map(option => ( {option.label} @@ -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', @@ -120,6 +120,44 @@ describe('Select data-slot contract', () => { expect(getSlot(container, 'select-value')).not.toBeNull(); }); + it('exposes the label slot', () => { + const { container } = render( + + ); + 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( + + + + + + } + > + 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 () => { + render(); + await flushMicrotasks(); + + const value = screen.getByText(TRIGGER_TEXT); + expect(value).toHaveAttribute('data-placeholder'); + }); + + it('passes the selected item to a function child', async () => { + render( + + ); + await flushMicrotasks(); + + expect(screen.getByTestId('custom')).toHaveTextContent( + 'icon-banana-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 +474,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..f0fa9e684 100644 --- a/packages/react/components/select/select-content.tsx +++ b/packages/react/components/select/select-content.tsx @@ -5,10 +5,25 @@ 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 { useSelectContext } from './select-root'; +import { SelectEmpty, SelectStatus } from './select-misc'; +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) => { + 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< @@ -36,6 +51,7 @@ export function SelectContent({ const theme = useThemeInjection(); if (autocomplete) { + const { regions, items } = splitLiveRegions(children); return ( + {regions} - {children} + {items} @@ -76,34 +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 9e1ebd9d8..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, useLayoutEffect } 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 { useSelectContext } from './select-root'; +import { + SelectRegistrationContext, + useFilteredValues, + useSelectContext +} from './select-root'; export interface SelectItemProps extends SelectPrimitive.Item.Props { leadingIcon?: ReactNode; @@ -27,21 +32,37 @@ export function SelectItem({ }: SelectItemProps) { const value = String(providedValue); const { - registerItem, - unregisterItem, autocomplete, searchValue, value: selectValue, shouldFilter, - hasItems, - 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. + 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 +90,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..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, ReactNode } 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 @@ -36,17 +37,41 @@ 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(); + const registering = useContext(SelectRegistrationContext); - if (shouldFilter) return null; + if (shouldFilter || registering) return null; const LabelPrimitive = autocomplete ? 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) { 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-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..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 { ItemType } from './types'; +import { ItemType, SelectItems } from './types'; interface CommonProps { autocomplete?: boolean; @@ -25,11 +26,12 @@ 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; + 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 { @@ -42,6 +44,17 @@ 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); + +export const useFilteredValues = () => useContext(FilteredValuesContext); + export const useSelectContext = (): UseSelectContext => { const context = useContext(SelectContext); if (!context) { @@ -58,6 +71,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 +100,11 @@ export interface BaseSelectProps extends CommonProps { disabled?: boolean; required?: boolean; name?: string; - items?: string[]; + /** + * 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; } export interface SingleSelectProps extends BaseSelectProps { @@ -103,7 +141,7 @@ export const SelectRoot = (props: SelectRootProps) => { disabled, required, name, - items: itemsProp, + items, ...rest } = props; @@ -115,16 +153,76 @@ 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]); + + // 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): 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 = getLabel(value); + return typeof label === 'string' || typeof label === 'number' + ? String(label) + : value; + }, + [getLabel] + ); 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 +237,7 @@ export const SelectRoot = (props: SelectRootProps) => { ); const handleSearchValueChange = useCallback( - (value: string, _eventDetails?: any) => { + (value: string) => { setInternalSearchValue(value); onSearch?.(value); }, @@ -147,59 +245,45 @@ 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, + getItem, + registerItem }), [ computedValue, - registerItem, - unregisterItem, autocomplete, autocompleteMode, searchValue, multiple, - registeredItems, - itemsProp + hasItems, + getLabel, + getItem, + registerItem ] ); 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 +292,39 @@ 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} + itemToStringLabel={itemToStringLabel} + > + {children} + + ); }; diff --git a/packages/react/components/select/select-value.tsx b/packages/react/components/select/select-value.tsx index 548586d33..814cec95f 100644 --- a/packages/react/components/select/select-value.tsx +++ b/packages/react/components/select/select-value.tsx @@ -1,19 +1,28 @@ '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 = (item: ItemType | ItemType[]) => 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 item, or items in multiple mode. */ + children?: ReactNode | SelectValueRenderer; +} + +const isEmpty = (value: unknown) => + value == null || value === '' || (Array.isArray(value) && !value.length); export function SelectValue({ children, @@ -21,66 +30,60 @@ export function SelectValue({ className, ...props }: SelectValueProps) { - const { value, items, multiple } = useSelectContext(); - - const hasValue = multiple - ? Array.isArray(value) && value.length > 0 - : !!value; + const { autocomplete, getItem, value } = useSelectContext(); - 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[]) => { + 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(item)) { + return ( + ({ value: i.value, label: i.children }))} + /> + ); } - return items[value as string]; - }, [value, items, multiple]); - - if (!hasValue) { return ( - - {placeholder} + + {typeof item.children === 'string' && item.leadingIcon && ( + + {item.leadingIcon} + + )} + {item.children} ); - } + }; - if (typeof children === 'function') { - return ( - - {children(item)} - - ); - } + const renderValue = (selected: string | string[] | null) => + isEmpty(selected) + ? placeholder + : renderSelected(selected as string | string[]); - if (children) { + if (autocomplete) { return ( - - {children} + + {renderValue} ); } - if (Array.isArray(item)) { - return ; - } - return ( - -
- {typeof item?.children === 'string' && item?.leadingIcon && ( -
- {item.leadingIcon} -
- )} - {item?.children ?? value} -
-
+ + {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..eacdb893b 100644 --- a/packages/react/components/select/types.ts +++ b/packages/react/components/select/types.ts @@ -5,3 +5,7 @@ export type ItemType = { children: ReactNode; value: string; }; + +export type SelectItems = + | { value: string; label: ReactNode }[] + | Record;