import { CheckIcon, ChevronDownIcon, CircleAlertIcon, LoaderCircleIcon, LockIcon, SearchIcon, } from "lucide-react"; import { Popover } from "radix-ui"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject, } from "react"; import { useFieldControlProps } from "./field"; import { MENU_CHECK_CLASS, MENU_LABEL_CLASS, MENU_NOTE_CLASS, MENU_SURFACE_CLASS, } from "./menu-styles"; import { cn } from "../lib/cn"; import { containedPortal } from "./portal-container"; const PopoverContainedPortal = containedPortal(Popover.Portal); /* ---------------------------------------------------------------------------- Types -------------------------------------------------------------------------- */ export interface SelectOption { value: V; /** "GPT-6 Sol", "Member", "Oslo time". */ label: string; /** One sentence: what picking it means. */ description?: ReactNode | undefined; /** * Quiet detail shown after the label in the menu and the trigger. For * models this is the payment source ("Codex plan"); for time zones the * offset; for people the email. */ meta?: string | undefined; /** A 20px avatar or 16px icon before the label. */ leading?: ReactNode | undefined; disabled?: boolean | undefined; /** Why it can't be picked and who can fix it. Replaces the description. */ disabledReason?: string | undefined; /** Consecutive options with the same group get one quiet heading. */ group?: string | undefined; /** Extra words the combobox search matches. */ keywords?: string[] | undefined; } /** * - `menu` (default): a popover list with title, description and meta, and a * check on the selected option. * - `native`: the browser's select, restyled; the chosen option's description * shows under it. * - `combobox`: the menu with a search field, for long or remote lists. */ export type SelectMenuVariant = "native" | "menu" | "combobox"; export interface SelectMenuProps { options: ReadonlyArray>; value?: V | null | undefined; defaultValue?: V | null | undefined; onValueChange?: ((value: V) => void) | undefined; variant?: SelectMenuVariant | undefined; /** Shown while nothing is chosen. */ placeholder?: string | undefined; /** `md` is 36px for forms, `sm` is 32px for rows. Native is always 32px. */ size?: "sm" | "md" | undefined; disabled?: boolean | undefined; /** Why the whole control is disabled and who can change it. Shown under it. */ disabledReason?: ReactNode | undefined; /** Options are loading: the trigger shows a spinner and can't open. */ loading?: boolean | undefined; /** "Loading models…". */ loadingLabel?: string | undefined; /** The value is wrong; pair it with a message through `aria-describedby`. */ invalid?: boolean | undefined; /** Shown in the menu when there are no options at all. */ emptyMessage?: ReactNode | undefined; /** Show the meta text in the trigger too (default true). */ showMetaInTrigger?: boolean | undefined; /** Native only: show the chosen option's description under the select (default true). */ showSelectedDescription?: boolean | undefined; /** Combobox: "Search time zones". */ searchPlaceholder?: string | undefined; /** Combobox: called on every keystroke, for remote search. */ onSearchChange?: ((query: string) => void) | undefined; /** Combobox: set false when `options` already come filtered from the server. */ filterLocally?: boolean | undefined; /** Combobox: the remote search is running. */ searching?: boolean | undefined; /** Menu alignment against the trigger. */ align?: "start" | "end" | undefined; id?: string | undefined; name?: string | undefined; "aria-label"?: string | undefined; "aria-labelledby"?: string | undefined; "aria-describedby"?: string | undefined; /** Trigger classes, usually a width: "w-60". */ className?: string | undefined; /** Classes on the popover panel. */ menuClassName?: string | undefined; } /* ---------------------------------------------------------------------------- Shared helpers -------------------------------------------------------------------------- */ function useControllableValue( value: V | null | undefined, defaultValue: V | null | undefined, onChange: ((value: V) => void) | undefined, ) { const [inner, setInner] = useState(defaultValue ?? null); const controlled = value !== undefined; const current = controlled ? value : inner; const setValue = useCallback( (next: V) => { if (!controlled) setInner(next); onChange?.(next); }, [controlled, onChange], ); return [current, setValue] as const; } /** Words in the query must all appear in the option's label, meta, description or keywords. */ export function filterSelectOptions( options: ReadonlyArray>, query: string, ): SelectOption[] { const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean); if (words.length === 0) return [...options]; return options.filter((option) => { const haystack = [ option.label, option.meta, typeof option.description === "string" ? option.description : "", ...(option.keywords ?? []), ] .join(" ") .toLowerCase(); return words.every((word) => haystack.includes(word)); }); } /** The next enabled option index from `from` in `direction`, wrapping around. */ export function nextEnabledIndex( options: ReadonlyArray>, from: number, direction: 1 | -1, ): number { const count = options.length; if (count === 0) return -1; for (let step = 1; step <= count; step += 1) { const index = (((from + direction * step) % count) + count) % count; if (!options[index]?.disabled) return index; } return -1; } function firstEnabledIndex(options: ReadonlyArray>): number { return options.findIndex((option) => !option.disabled); } function lastEnabledIndex(options: ReadonlyArray>): number { for (let index = options.length - 1; index >= 0; index -= 1) { if (!options[index]?.disabled) return index; } return -1; } /** * Typeahead: the first enabled option after `from` whose label starts with * `buffer`. Repeating one letter cycles through options starting with it. */ export function typeaheadIndex( options: ReadonlyArray>, buffer: string, from: number, ): number { const needle = buffer.toLowerCase(); if (!needle) return -1; const repeated = needle.split("").every((char) => char === needle[0]); const search = repeated ? needle[0]! : needle; const count = options.length; const start = repeated ? from + 1 : Math.max(from, 0); for (let step = 0; step < count; step += 1) { const index = (start + step) % count; const option = options[index]; if (option && !option.disabled && option.label.toLowerCase().startsWith(search)) return index; } return -1; } // Matches the Field controls: hover darkens the border, keyboard focus turns it // brand under the global 2px ring, and an invalid value keeps a danger border. const TRIGGER_CLASS = "group/select-trigger inline-flex w-full min-w-0 items-center gap-2 rounded-[10px] border border-border bg-surface px-3 text-left text-sm text-fg transition-colors duration-[120ms] hover:border-border-strong focus-visible:border-brand aria-expanded:border-border-strong aria-expanded:focus-visible:border-brand disabled:cursor-not-allowed disabled:border-border disabled:bg-surface-2 disabled:text-fg-muted aria-invalid:border-danger aria-invalid:hover:border-danger pointer-coarse:h-11"; // The one menu surface (menu-styles.ts); the list inside carries the 6px inset. const PANEL_CLASS = cn( MENU_SURFACE_CLASS, "z-50 flex max-h-[min(22rem,var(--radix-popover-content-available-height))] w-[max(var(--radix-popover-trigger-width),22rem)] max-w-[calc(100vw-24px)] flex-col overflow-hidden p-0 outline-hidden transition-opacity duration-[120ms] starting:opacity-0", ); function TriggerValue({ option, placeholder, showMeta, loading, loadingLabel, }: { option: SelectOption | undefined; placeholder: string; showMeta: boolean; loading?: boolean | undefined; loadingLabel?: string | undefined; }) { if (loading) { return {loadingLabel}; } if (!option) { return {placeholder}; } return ( <> {option.leading ? ( ) : null} {option.label} {showMeta && option.meta ? ( {" "} · {option.meta} ) : null} ); } function TriggerIcon({ loading }: { loading?: boolean | undefined }) { return loading ? (