"use client"; import { Children, isValidElement, useMemo } from "react"; import { Select as SelectPrimitive } from "@base-ui/react/select"; import { cn } from "./cn"; /* * Inline rather than imported, matching Checkbox. The package takes no icon * dependency, so a product's icon set and version stay entirely its own. */ function ChevronDownIcon({ className }: { className?: string }) { return ( ); } function CheckIcon() { return ( ); } /* * No new tokens. A select trigger is a field, so it reads the `--input-*` * geometry; the popup is a menu-like surface, so it reads `--menu-*`. Reusing * both means a brand that has styled Input and Menu gets a coherent Select for * free, and there is one less thing to keep in sync. * * `container` is here for the reason it is on Dialog and Menu: a portal * re-parents to document.body and would otherwise escape the `data-brand` * subtree that supplies the theme. */ /* * Base UI's `Select.Value` renders the raw value, not the matching item's * text: it resolves labels only when the root is handed an `items` structure * up front. Products write ` * America/New_York` and expect the trigger to read * "America/New_York", so the labels are read straight off the JSX. * * Off the JSX, and not from the mounted items -- the popup is not in the DOM * until it is first opened, so anything that waits for items to mount is a * commit too late and the trigger paints the raw value. The element tree is * available on the very first render, which is when the trigger needs it. * * Items produced by a component that hides them behind its own boundary are * invisible to this walk; those fall back to the raw value, exactly as they * would without it. */ function collectItems( node: React.ReactNode, out: Array<{ label: React.ReactNode; value: string }>, ) { Children.forEach(node, (child) => { if (!isValidElement(child)) return; const childProps = child.props as { value?: unknown; children?: React.ReactNode; }; if (child.type === SelectItem && childProps.value != null) { out.push({ value: String(childProps.value), label: childProps.children }); return; } if (childProps.children != null) collectItems(childProps.children, out); }); } /* * Declared against `string` rather than re-exported. Base UI's root is * generic over any value type, so a consumer writing * `React.ComponentProps` collapses that generic to `unknown` -- * every `onValueChange={(v) => ...}` at the call site silently loses its * parameter type. RadioGroup hit this first. * * Constraining the parameter to `string` keeps that collapse harmless: it * yields `string`, the safe default, rather than `unknown`. Keeping it generic * at all is what lets a call site pass a handler narrowed to a string union * -- `(value: PeriodOption) => void` -- without a contravariance error, which * a plain `(value: string) => void` would reject. Products that genuinely * need non-string values can reach for the primitive directly. */ export interface SelectProps extends Omit< SelectPrimitive.Root.Props, "value" | "defaultValue" | "onValueChange" | "multiple" > { value?: T | null; defaultValue?: T | null; onValueChange?: (value: T) => void; } function Select({ onValueChange, ...props }: SelectProps) { const items = useMemo(() => { const collected: Array<{ label: React.ReactNode; value: string }> = []; collectItems(props.children, collected); return collected.length > 0 ? collected : undefined; }, [props.children]); return ( onValueChange((value ?? "") as T)) } /> ); } const SelectGroup = SelectPrimitive.Group; function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { return ( ); } export interface SelectTriggerProps extends Omit { className?: string; size?: "sm" | "md" | "lg"; } function SelectTrigger({ className, size = "md", children, ...props }: SelectTriggerProps) { return ( {children} } /> ); } export interface SelectContentProps extends Omit, Pick< SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" > { className?: string; container?: SelectPrimitive.Portal.Props["container"]; } function SelectContent({ className, container, side = "bottom", sideOffset = 4, align = "start", alignOffset = 0, alignItemWithTrigger = false, children, ...props }: SelectContentProps) { return ( {children} ); } function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { return ( {children} } > ); } function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { return ( ); } function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) { return ( ); } export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem, SelectLabel, SelectSeparator, };