import * as React from 'react'; import {createPortal} from 'react-dom'; import {cn} from '@/lib/utils'; import {placeUnder} from '@/lib/popup-position'; import {ChevronDown, Check} from 'lucide-react'; export interface SelectOption { value: string; label: string; } export interface SelectProps { value?: string; onChange?: (value: string) => void; children?: React.ReactNode; className?: string; disabled?: boolean; placeholder?: string; } const SelectContext = React.createContext<{ value?: string; onChange?: (value: string) => void; open: boolean; setOpen: (open: boolean) => void; } | null>(null); const useSelectContext = () => { const context = React.useContext(SelectContext); if (!context) { throw new Error('Select components must be used within a Select'); } return context; }; const Select = React.forwardRef( ({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, ref) => { const [open, setOpen] = React.useState(false); const [internalValue, setInternalValue] = React.useState(value); const containerRef = React.useRef(null); const buttonRef = React.useRef(null); const popupRef = React.useRef(null); const [popupStyle, setPopupStyle] = React.useState({}); // The forwarded ref points at the trigger, as it does on DropdownMenuTrigger. React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement); const displayValue = React.useMemo(() => { const currentValue = value ?? internalValue; if (!currentValue) return placeholder; // Extract label from children const options = React.Children.toArray(children); const selectedOption = options.find((child) => { if (React.isValidElement(child) && child.type === SelectOption) { return child.props.value === currentValue; } return false; }); if (React.isValidElement(selectedOption)) { return selectedOption.props.children; } return currentValue; }, [value, internalValue, children, placeholder]); React.useEffect(() => { setInternalValue(value); }, [value]); // The popup is portalled to the body so an ancestor with overflow-hidden // (a dialog card, a scroll container) cannot clip it. React.useLayoutEffect(() => { if (!open) return; const updatePosition = () => { const trigger = buttonRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); setPopupStyle({ position: 'fixed', left: rect.left, width: rect.width, backgroundColor: 'var(--popover)', ...placeUnder(rect, window.innerHeight), }); }; updatePosition(); window.addEventListener('scroll', updatePosition, true); window.addEventListener('resize', updatePosition); return () => { window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); }; }, [open]); React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; if (containerRef.current?.contains(target) || popupRef.current?.contains(target)) return; setOpen(false); }; if (open) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [open]); const handleChange = (newValue: string) => { setInternalValue(newValue); onChange?.(newValue); setOpen(false); }; return (
{open && createPortal(
{children}
, document.body, )}
); } ); Select.displayName = 'Select'; export interface SelectOptionProps { value: string; children: React.ReactNode; className?: string; disabled?: boolean; } const SelectOption = React.forwardRef( ({ className, children, value: optionValue, disabled, ...props }, ref) => { const { value, onChange } = useSelectContext(); const isSelected = value === optionValue; return (
{ if (!disabled) { onChange?.(optionValue); } }} {...props} > {children} {isSelected && }
); } ); SelectOption.displayName = 'SelectOption'; export { Select, SelectOption };