"use client"; import * as React from "react"; import { createPortal } from "react-dom"; import { Check, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; export type SelectOption = { value: string; label: React.ReactNode; disabled?: boolean; }; type SelectPosition = { left: number; maxHeight: number; top: number; width: number; }; type SelectProps = { "aria-describedby"?: string; "aria-label"?: string; "aria-labelledby"?: string; className?: string; defaultValue?: string; disabled?: boolean; id?: string; label?: React.ReactNode; name?: string; onBlur?: React.FocusEventHandler; onFocus?: React.FocusEventHandler; onValueChange?: (value: string) => void; options: SelectOption[]; placeholder?: string; required?: boolean; value?: string; }; const SELECT_GAP = 6; const SELECT_MAX_HEIGHT = 288; const VIEWPORT_PADDING = 8; function getEnabledValues(options: SelectOption[]): string[] { return options.filter((option) => option.disabled !== true).map((option) => option.value); } function getInitialValue(options: SelectOption[], defaultValue?: string, placeholder?: string): string { if (defaultValue !== undefined) { return defaultValue; } if (placeholder !== undefined) { return ""; } return getEnabledValues(options)[0] ?? ""; } function getAdjacentValue(options: SelectOption[], currentValue: string, direction: 1 | -1): string { const enabledValues = getEnabledValues(options); if (enabledValues.length === 0) { return currentValue; } const currentIndex = enabledValues.indexOf(currentValue); const nextIndex = currentIndex === -1 ? direction === 1 ? 0 : enabledValues.length - 1 : (currentIndex + direction + enabledValues.length) % enabledValues.length; return enabledValues[nextIndex] ?? currentValue; } function getBoundaryValue(options: SelectOption[], boundary: "first" | "last", fallbackValue: string): string { const enabledValues = getEnabledValues(options); if (enabledValues.length === 0) { return fallbackValue; } if (boundary === "first") { return enabledValues[0] ?? fallbackValue; } return enabledValues[enabledValues.length - 1] ?? fallbackValue; } function findOption(options: SelectOption[], value: string): SelectOption | undefined { return options.find((option) => option.value === value); } export function Select({ "aria-describedby": ariaDescribedBy, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className, defaultValue, disabled = false, id, label, name, onBlur, onFocus, onValueChange, options, placeholder, required, value, }: SelectProps): React.ReactElement { const generatedId = React.useId().replace(/:/g, ""); const controlId = id ?? `teatea-select-${generatedId}`; const labelId = label ? `${controlId}-label` : undefined; const listboxId = `${controlId}-listbox`; const triggerRef = React.useRef(null); const listboxRef = React.useRef(null); const [portalRoot, setPortalRoot] = React.useState(null); const [isOpen, setIsOpen] = React.useState(false); const [position, setPosition] = React.useState(null); const [uncontrolledValue, setUncontrolledValue] = React.useState(() => getInitialValue(options, defaultValue, placeholder), ); const isControlled = value !== undefined; const selectedValue = isControlled ? value : uncontrolledValue; const selectedOption = findOption(options, selectedValue); const [highlightedValue, setHighlightedValue] = React.useState(selectedValue); const highlightedIndex = options.findIndex((option) => option.value === highlightedValue); const activeDescendant = isOpen && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined; const updatePosition = React.useCallback(() => { const trigger = triggerRef.current; if (!trigger || typeof window === "undefined") { return; } const rect = trigger.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const availableBelow = viewportHeight - rect.bottom - SELECT_GAP - VIEWPORT_PADDING; const availableAbove = rect.top - SELECT_GAP - VIEWPORT_PADDING; const estimatedHeight = Math.min(SELECT_MAX_HEIGHT, Math.max(120, options.length * 40 + 12)); const shouldOpenBelow = availableBelow >= estimatedHeight || availableBelow >= availableAbove; const availableHeight = shouldOpenBelow ? availableBelow : availableAbove; const maxHeight = Math.max(96, Math.min(SELECT_MAX_HEIGHT, availableHeight)); const left = Math.min( Math.max(VIEWPORT_PADDING, rect.left), Math.max(VIEWPORT_PADDING, viewportWidth - rect.width - VIEWPORT_PADDING), ); const top = shouldOpenBelow ? Math.min(rect.bottom + SELECT_GAP, viewportHeight - maxHeight - VIEWPORT_PADDING) : Math.max(VIEWPORT_PADDING, rect.top - SELECT_GAP - maxHeight); setPosition({ left, maxHeight, top, width: rect.width, }); }, [options.length]); const openSelect = React.useCallback(() => { if (disabled || options.length === 0) { return; } const nextHighlightedValue = findOption(options, selectedValue)?.disabled === true ? getBoundaryValue(options, "first", selectedValue) : selectedValue; setHighlightedValue(nextHighlightedValue); setIsOpen(true); }, [disabled, options, selectedValue]); const closeSelect = React.useCallback(() => { setIsOpen(false); }, []); const commitValue = React.useCallback( (nextValue: string) => { const nextOption = findOption(options, nextValue); if (!nextOption || nextOption.disabled === true) { return; } if (!isControlled) { setUncontrolledValue(nextValue); } if (nextValue !== selectedValue) { onValueChange?.(nextValue); } setHighlightedValue(nextValue); closeSelect(); triggerRef.current?.focus(); }, [closeSelect, isControlled, onValueChange, options, selectedValue], ); React.useEffect(() => { setPortalRoot(document.body); }, []); React.useEffect(() => { if (isControlled) { return; } if (findOption(options, uncontrolledValue)) { return; } setUncontrolledValue(getInitialValue(options, defaultValue, placeholder)); }, [defaultValue, isControlled, options, placeholder, uncontrolledValue]); React.useEffect(() => { const trigger = triggerRef.current; const form = trigger?.form; if (!form || isControlled) { return; } function handleReset(): void { setUncontrolledValue(getInitialValue(options, defaultValue, placeholder)); } form.addEventListener("reset", handleReset); return () => { form.removeEventListener("reset", handleReset); }; }, [defaultValue, isControlled, options, placeholder]); React.useLayoutEffect(() => { if (!isOpen) { return; } updatePosition(); }, [isOpen, updatePosition]); React.useEffect(() => { if (!isOpen) { return; } function handlePointerDown(event: MouseEvent | TouchEvent): void { const target = event.target; if (!(target instanceof Node)) { return; } if (triggerRef.current?.contains(target) || listboxRef.current?.contains(target)) { return; } closeSelect(); } document.addEventListener("mousedown", handlePointerDown); document.addEventListener("touchstart", handlePointerDown); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); return () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("touchstart", handlePointerDown); window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; }, [closeSelect, isOpen, updatePosition]); function handleTriggerClick(): void { if (isOpen) { closeSelect(); return; } openSelect(); } function handleKeyDown(event: React.KeyboardEvent): void { switch (event.key) { case "ArrowDown": { event.preventDefault(); if (!isOpen) { openSelect(); return; } setHighlightedValue((currentValue) => getAdjacentValue(options, currentValue, 1)); return; } case "ArrowUp": { event.preventDefault(); if (!isOpen) { openSelect(); return; } setHighlightedValue((currentValue) => getAdjacentValue(options, currentValue, -1)); return; } case "Home": { if (!isOpen) { return; } event.preventDefault(); setHighlightedValue((currentValue) => getBoundaryValue(options, "first", currentValue)); return; } case "End": { if (!isOpen) { return; } event.preventDefault(); setHighlightedValue((currentValue) => getBoundaryValue(options, "last", currentValue)); return; } case "Enter": case " ": { event.preventDefault(); if (!isOpen) { openSelect(); return; } commitValue(highlightedValue); return; } case "Escape": { if (!isOpen) { return; } event.preventDefault(); closeSelect(); return; } case "Tab": { closeSelect(); return; } default: return; } } const field = (
{label ? ( {label} ) : null} {name ? : null}
); const popup = portalRoot && isOpen && position ? createPortal(
{options.map((option, index) => { const isSelected = option.value === selectedValue; const isHighlighted = option.value === highlightedValue; return ( ); })}
, portalRoot, ) : null; return ( <> {field} {popup} ); }