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}
}
/>
))}
)
}