import { IconCheck, IconChevronDown } from '@tabler/icons-react' import { Select as BaseSelect } from '@base-ui/react/select' import type { ReactNode } from 'react' import { cn } from './cn' import { ScrollArea } from './ScrollArea' import { MenuPanel, menuItemClassName } from './Menu' import { TooltipTrigger } from './Tooltip' import { FIELD_DISABLED_CLASSES, FIELD_SIZE_CLASSES, FIELD_TEXT_CLASSES } from './looks' /** * Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may * carry a `leading` node — an avatar beside a person's name — shown in the * trigger and in the list. On Base UI's `Select` since stage 4 of the * migration (2026-09-07). * * A button that shows the choice and opens a listbox under it. **Under it: * Base UI would rather lay the list over the trigger so the chosen option's * text covers the trigger's, the way macOS does — `alignItemWithTrigger` is * off, because every Select in both apps opens below one today (Katerina, * D24).** * * What the port deleted: `createPortal`, the whole `onKeyDown` switch, the * outside-click, resize and scroll listeners, the `aria-activedescendant` * bookkeeping, the `scrollIntoView` that kept the highlight visible, and * `fit.ts` — the pure geometry this component grew and then shared with the * Menu shell. Floating UI does the fitting; `--available-height` is where the * old 288px cap now meets the room the screen actually has. * * What it gained, and none of it was written here: **typeahead** — type the * first letters of an option and the list jumps to it — a listbox whose * highlight is managed rather than counted, and a value that can be part of a * form. * * Two sizes. `disabled` explains nothing by itself; `disabledReason` says * why, as Button's does: the select looks disabled, will not open, stays * reachable by Tab, and shows the reason on hover and on keyboard focus. */ export interface SelectOption { value: string label: string /** Drawn before the label, 16px — an Avatar, an icon. */ leading?: ReactNode } export interface SelectProps { value: string onChange: (value: string) => void options: SelectOption[] size?: 'default' | 'small' ariaLabel?: string /** Set by a `Field` with `required`; a caller inside one owes nothing. */ 'aria-required'?: boolean | 'true' | 'false' placeholder?: string disabled?: boolean /** * Why it cannot be changed right now (UIG-14, Katerina, 19 September). It * looks disabled and will not open, but Tab still reaches it, and the reason * shows as its tooltip — a native disabled button cannot be focused, so it * could never say why. Takes the place of wrapping it in `WithTooltip`. * Given with `disabled`, the reason wins, as on `Button`. */ disabledReason?: string className?: string } /** The 4px between the trigger and the list, and the 8px the list keeps clear * of every screen edge — the two numbers `fitMenu` used. */ const GAP = 4 const VIEWPORT_PAD = 8 export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, disabledReason, className, ...aria }: SelectProps) { const selected = options.find((o) => o.value === value) // With a reason it is held shut rather than disabled: a disabled trigger is // a native `disabled` button, which Tab skips and a tooltip cannot open on. const held = Boolean(disabledReason) const trigger = ( // @estiva-escape(no-copied-look): N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is {selected?.leading && {selected.leading}} {() => selected?.label ?? placeholder} } /> ) return ( { if (!held) onChange(next as string) }} {...(held ? { open: false, onOpenChange: () => {} } : {})} disabled={disabled && !held} // Non-modal, as it has always been: the page behind stays scrollable and // keeps its scrollbar, so opening a select never shifts the layout. modal={false} > {held ? {trigger} : trigger} } className="min-w-[var(--anchor-width)] p-0" > {/* The list scrolls in a ScrollArea: the bar takes no width, so a long list is exactly as wide as a short one (Katerina, 2026-09-08). * * **The padding is on the scrolling content, not on the panel** * (D63, applied here 2026-09-13). On the panel it inset the * scrolling box, so the thumb sat 7px from the panel's edge where * DialogShell, Popover, Menu and ChipInput all draw it at 3px — * Katerina: "the position of the scrollbar in select … not closer * to the right side". The rows keep their 4px inset, because the * padding that was the panel's is the content's; and the cap loses * its `- 0.5rem`, because that padding is inside the box that * scrolls now, so 288px stays 288px. * * `gap-0.5`: 2px between the options, as between a menu's rows — * they touched, while every menu had moved to 2px (0.56.0). */} {options.map((option) => ( {option.leading && {option.leading}} {option.label} } /> ))} ) }