{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;