import * as React from 'react' import * as ReactDOM from 'react-dom' import { Check, ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' type NativeSelectProps = React.ComponentProps<'select'> type SelectOption = { value: string label: React.ReactNode disabled: boolean } type MenuPlacement = 'top' | 'bottom' function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) { if (Array.isArray(value)) { return value[0] == null ? '' : String(value[0]) } return value == null ? '' : String(value) } function extractOptions(children: React.ReactNode) { const options: SelectOption[] = [] React.Children.forEach(children, (child) => { if (!React.isValidElement(child) || child.type !== 'option') { return } const props = child.props as React.OptionHTMLAttributes & { children?: React.ReactNode } options.push({ value: normalizeValue(props.value), label: props.children, disabled: Boolean(props.disabled), }) }) return options } function getFirstEnabledIndex(options: SelectOption[]) { return options.findIndex((option) => !option.disabled) } function getLastEnabledIndex(options: SelectOption[]) { for (let index = options.length - 1; index >= 0; index -= 1) { if (!options[index]?.disabled) { return index } } return -1 } function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) { if (options.length === 0) { return -1 } let index = currentIndex for (let step = 0; step < options.length; step += 1) { index = (index + direction + options.length) % options.length if (!options[index]?.disabled) { return index } } return -1 } const Select = React.forwardRef( ( { children, className, defaultValue, disabled = false, id, onBlur, onClick, onFocus, onKeyDown, value, ...props }, forwardedRef, ) => { const options = React.useMemo(() => extractOptions(children), [children]) const isControlled = value !== undefined const initialValue = React.useMemo(() => { if (defaultValue !== undefined) { return normalizeValue(defaultValue) } return options[0]?.value ?? '' }, [defaultValue, options]) const [internalValue, setInternalValue] = React.useState(initialValue) const [open, setOpen] = React.useState(false) const [highlightedIndex, setHighlightedIndex] = React.useState(-1) const [menuPlacement, setMenuPlacement] = React.useState('bottom') const [menuStyle, setMenuStyle] = React.useState(null) const wrapperRef = React.useRef(null) const triggerRef = React.useRef(null) const nativeSelectRef = React.useRef(null) const menuRef = React.useRef(null) const optionRefs = React.useRef>([]) const menuId = React.useId() const currentValue = isControlled ? normalizeValue(value) : internalValue const selectedIndex = options.findIndex((option) => option.value === currentValue) const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null React.useEffect(() => { if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) { setInternalValue(options[0]?.value ?? '') } }, [internalValue, isControlled, options]) const updateMenuPosition = React.useCallback(() => { const trigger = triggerRef.current if (!trigger) { return } const rect = trigger.getBoundingClientRect() const viewportPadding = 12 const gutter = 6 const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320) const spaceBelow = window.innerHeight - rect.bottom - viewportPadding const spaceAbove = rect.top - viewportPadding const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320)) const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2) const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding) setMenuPlacement(openToTop ? 'top' : 'bottom') setMenuStyle( openToTop ? { left, width, maxHeight, bottom: window.innerHeight - rect.top + gutter, } : { left, width, maxHeight, top: rect.bottom + gutter, }, ) }, [options.length]) const setOpenWithHighlight = React.useCallback( (nextOpen: boolean, preferredIndex?: number) => { if (disabled) { return } if (nextOpen) { const fallbackIndex = preferredIndex ?? (selectedIndex >= 0 && !options[selectedIndex]?.disabled ? selectedIndex : getFirstEnabledIndex(options)) setHighlightedIndex(fallbackIndex) updateMenuPosition() setOpen(true) return } setOpen(false) }, [disabled, options, selectedIndex, updateMenuPosition], ) const commitValue = React.useCallback( (nextIndex: number) => { const option = options[nextIndex] const nativeSelect = nativeSelectRef.current if (!option || option.disabled) { return } if (!isControlled) { setInternalValue(option.value) } if (nativeSelect && currentValue !== option.value) { nativeSelect.value = option.value nativeSelect.dispatchEvent(new Event('change', { bubbles: true })) } setOpen(false) window.requestAnimationFrame(() => { triggerRef.current?.focus() }) }, [currentValue, isControlled, options], ) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { onKeyDown?.(event as unknown as React.KeyboardEvent) if (event.defaultPrevented || disabled) { return } switch (event.key) { case 'ArrowDown': { event.preventDefault() if (!open) { const nextIndex = selectedIndex >= 0 ? getNextEnabledIndex(options, selectedIndex, 1) : getFirstEnabledIndex(options) setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options)) return } setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1)) return } case 'ArrowUp': { event.preventDefault() if (!open) { const nextIndex = selectedIndex >= 0 ? getNextEnabledIndex(options, selectedIndex, -1) : getLastEnabledIndex(options) setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options)) return } setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1)) return } case 'Home': { event.preventDefault() const firstIndex = getFirstEnabledIndex(options) if (!open) { setOpenWithHighlight(true, firstIndex) return } setHighlightedIndex(firstIndex) return } case 'End': { event.preventDefault() const lastIndex = getLastEnabledIndex(options) if (!open) { setOpenWithHighlight(true, lastIndex) return } setHighlightedIndex(lastIndex) return } case 'Enter': case ' ': { event.preventDefault() if (!open) { setOpenWithHighlight(true) return } if (highlightedIndex >= 0) { commitValue(highlightedIndex) } return } case 'Escape': { if (!open) { return } event.preventDefault() setOpen(false) return } case 'Tab': { setOpen(false) return } default: return } }, [commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight], ) React.useLayoutEffect(() => { if (!open) { return } updateMenuPosition() }, [open, updateMenuPosition]) React.useEffect(() => { if (!open) { return } const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) { return } setOpen(false) } const handleWindowChange = () => updateMenuPosition() document.addEventListener('mousedown', handlePointerDown) window.addEventListener('resize', handleWindowChange) window.addEventListener('scroll', handleWindowChange, true) return () => { document.removeEventListener('mousedown', handlePointerDown) window.removeEventListener('resize', handleWindowChange) window.removeEventListener('scroll', handleWindowChange, true) } }, [open, updateMenuPosition]) React.useEffect(() => { if (!open || highlightedIndex < 0) { return } optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' }) }, [highlightedIndex, open]) const triggerClasses = cn( 'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/40 data-[state=open]:bg-card data-[state=open]:shadow-[0_18px_40px_rgb(15_23_42_/_0.14)]', className, ) const menu = open && menuStyle ? ReactDOM.createPortal(
{options.map((option, index) => { const selected = option.value === currentValue const highlighted = index === highlightedIndex return ( ) })}
, document.body, ) : null return (
{menu}
) }, ) Select.displayName = 'Select' export { Select }