import { useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, type SelectHTMLAttributes } from "react"; import { CaretDown, Check } from "@phosphor-icons/react"; import { cn } from "../../lib/cn"; import { FloatingPortal } from "./floating-portal"; import { fieldClassName, fieldNote, PrefixFieldShell, type FieldMeta } from "./shared"; export type SelectProps = SelectHTMLAttributes & FieldMeta & { options: { label: string; value: string }[]; }; export function Select({ className, error, hint, id, label, options, prefixIcon, suffixIcon, ...props }: SelectProps) { const generatedId = useId(); const fieldId = id ?? generatedId; const hintId = hint ? `${fieldId}-hint` : undefined; const errorId = error ? `${fieldId}-error` : undefined; const panelRef = useRef(null); const buttonRef = useRef(null); const [open, setOpen] = useState(false); const controlledValue = props.value; const [internalValue, setInternalValue] = useState( String(controlledValue ?? props.defaultValue ?? options[0]?.value ?? ""), ); const selectedValue = String(controlledValue ?? internalValue); const selectedIndex = Math.max( 0, options.findIndex((option) => option.value === selectedValue), ); const [activeIndex, setActiveIndex] = useState(selectedIndex); const selectedOption = options.find((option) => option.value === selectedValue) ?? options[0]; const activeDescendantId = useMemo( () => (open ? `${fieldId}-option-${activeIndex}` : undefined), [activeIndex, fieldId, open], ); useEffect(() => { function handlePointerDown(event: PointerEvent) { if (!panelRef.current?.contains(event.target as Node)) { setOpen(false); } } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { setOpen(false); } } window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); }; }, []); useEffect(() => { setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); }, [selectedIndex]); function handleSelect(value: string) { if (controlledValue === undefined) { setInternalValue(value); } const nextEvent = { target: { value }, currentTarget: { value }, } as ChangeEvent; props.onChange?.(nextEvent); setOpen(false); buttonRef.current?.focus(); } function moveActiveIndex(step: 1 | -1) { if (options.length === 0) { return; } const nextIndex = (activeIndex + step + options.length) % options.length; setActiveIndex(nextIndex); } function handleKeyDown(event: ReactKeyboardEvent) { if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); setOpen(true); setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); return; } if (!open) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setOpen(true); } return; } if (event.key === "ArrowDown") { event.preventDefault(); moveActiveIndex(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveActiveIndex(-1); return; } if (event.key === "Home") { event.preventDefault(); setActiveIndex(0); return; } if (event.key === "End") { event.preventDefault(); setActiveIndex(Math.max(0, options.length - 1)); return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); const option = options[activeIndex]; if (option) { handleSelect(option.value); } } if (event.key === "Escape") { event.preventDefault(); setOpen(false); } } return ( ); }