'use client' import { SystemIcon } from './kit' import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { placePopup, selectedIndex, selectKey } from './select-state' import './select.css' export type SelectOption = { value: string; label: string; disabled?: boolean; icon?: ReactNode } type Props = { value: string onChange: (value: string) => void options: readonly SelectOption[] 'aria-label'?: string 'aria-labelledby'?: string disabled?: boolean name?: string className?: string size?: 'regular' | 'compact' autoFocus?: boolean } const TOKENS = ['--fs-select-ink', '--fs-select-muted', '--fs-select-line', '--fs-select-panel', '--fs-select-hover', '--fs-select-accent', '--fs-select-radius', '--fs-select-shadow', '--fs-select-font'] export default function Select({ value, onChange, options, disabled, name, className, size = 'regular', autoFocus, ...aria }: Props) { const id = useId() const trigger = useRef(null), list = useRef(null) const [open, setOpen] = useState(false) const [active, setActive] = useState(-1) const [style, setStyle] = useState({}) const [up, setUp] = useState(false) const current = options.find(option => option.value === value) const place = useCallback(() => { const button = trigger.current if (!button) return const rect = button.getBoundingClientRect() const spot = placePopup(rect, { width: window.innerWidth, height: window.innerHeight }, 320) const computed = getComputedStyle(button) const tokens: Record = {} for (const token of TOKENS) { const resolved = computed.getPropertyValue(token).trim(); if (resolved) tokens[token] = resolved } setUp(spot.up) setStyle({ ...tokens, fontFamily: computed.fontFamily, left: spot.left, minWidth: spot.width, maxWidth: Math.min(360, window.innerWidth - 16), top: spot.top, bottom: spot.bottom, maxHeight: spot.maxHeight } as CSSProperties) }, []) const show = (index: number) => { place(); setActive(index); setOpen(true) } const close = (focus: boolean) => { setOpen(false); if (focus) trigger.current?.focus() } const choose = (next: string) => { if (next !== value) onChange(next) } useLayoutEffect(() => { if (open) place() }, [open, place]) useLayoutEffect(() => { const popup = list.current if (!popup || typeof style.left !== 'number') return const left = Math.max(8, Math.min(style.left, window.innerWidth - 8 - popup.offsetWidth)) if (left !== style.left) setStyle(previous => ({ ...previous, left })) }, [style]) useEffect(() => { if (!open) return const outside = (event: PointerEvent) => { const target = event.target as Node if (!trigger.current?.contains(target) && !list.current?.contains(target)) setOpen(false) } window.addEventListener('pointerdown', outside, true) window.addEventListener('resize', place) const scrolled = (event: Event) => { if (!list.current?.contains(event.target as Node)) place() } window.addEventListener('scroll', scrolled, true) return () => { window.removeEventListener('pointerdown', outside, true); window.removeEventListener('resize', place); window.removeEventListener('scroll', scrolled, true) } }, [open, place]) useEffect(() => { if (open && active >= 0) list.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: 'nearest' }) }, [open, active]) useEffect(() => { if (disabled) setOpen(false) }, [disabled]) const keydown = (event: React.KeyboardEvent) => { if (event.altKey || event.ctrlKey || event.metaKey) return const result = selectKey({ open, active }, event.key, options, value) if (!result.handled && result.close !== 'blur') return if (result.handled) { event.preventDefault(); event.stopPropagation() } if (result.open && !open) { show(result.active); return } setActive(result.active) if (!result.open) close(result.close === 'focus') if (result.choose !== undefined) choose(result.choose) } const labelId = `${id}-label`, valueId = `${id}-value`, listId = `${id}-list` const labelledby = aria['aria-labelledby'] ?? (aria['aria-label'] ? labelId : undefined) const classes = ['fs-select', size === 'compact' ? 'fs-select-compact' : '', open ? 'is-open' : '', className ?? ''].filter(Boolean).join(' ') return <> {aria['aria-label'] && !aria['aria-labelledby'] && } {name && } {open && typeof document !== 'undefined' && createPortal(
    event.preventDefault()}> {options.map((option, index) =>
  • { if (!option.disabled) setActive(index) }} onClick={() => { if (option.disabled) return; close(true); choose(option.value) }}> {option.icon && } {option.label} {option.value === value && }
  • )}
, document.fullscreenElement ?? document.body)} }