import { Ref, useImperativeHandle, useRef, useState } from "react"; import { SelectChangeEvent, SelectHandle, SelectOption } from "./types"; import { autoUpdate, offset, Placement, useFloating, size as floatingUiSize, flip, useClick, useDismiss, useRole, useListNavigation, useTypeahead, useInteractions, FloatingPortal, FloatingFocusManager, } from "@floating-ui/react"; import { ThemeColor } from "../../../types"; import clsx from "clsx"; import { sizeVariant } from "../text/Input"; import { Dialog } from "../../dialog"; import { Button } from "../../button"; interface SelectProps { id?: string; className?: string; name: string; variant?: "normal" | "ghost"; color?: ThemeColor; size?: "small" | "medium" | "large" | "custom"; placeholder?: string; disabled?: boolean; options: SelectOption[]; value: V | null; onChange: (event: SelectChangeEvent) => void; placement?: Placement; floatingMinWidth?: number; zIndex?: number; dialogClassName?: string; ref?: Ref; onFocus?: () => void; onBlur?: () => void; } export function Select({ id, className, name, variant = "normal", zIndex, color = "yellow", size = "medium", placeholder = "Select ...", disabled = false, options, value, onChange, floatingMinWidth = 130, placement = "bottom", onFocus, onBlur, dialogClassName, ref, }: SelectProps) { const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(null); const selectedIndex = options.findIndex((option) => option.value === value); const selectedOption = selectedIndex !== -1 ? options[selectedIndex] : null; const { refs, floatingStyles, context } = useFloating({ placement, open: isOpen, onOpenChange: setIsOpen, whileElementsMounted: autoUpdate, middleware: [ offset(5), flip({ padding: 10 }), floatingUiSize({ apply({ rects, elements, availableHeight }) { Object.assign(elements.floating.style, { width: `${Math.max(floatingMinWidth, rects.reference.width)}px`, }); const firstChild = elements.floating.firstElementChild as HTMLElement; if (firstChild) { firstChild.style.maxHeight = `${Math.min(availableHeight, 320)}px`; } }, padding: 10, }), ], }); const listRef = useRef<(HTMLElement | null)[]>([]); const listContentRef = useRef(options.map((option) => option.label)); listContentRef.current = options.map((option) => option.label); const isTypingRef = useRef(false); const click = useClick(context, { event: "mousedown", enabled: !disabled }); const dismiss = useDismiss(context); const role = useRole(context, { role: "select" }); const listNav = useListNavigation(context, { listRef, activeIndex, selectedIndex, onNavigate: setActiveIndex, loop: true, enabled: !disabled, }); const typeahead = useTypeahead(context, { listRef: listContentRef, activeIndex, selectedIndex: selectedIndex === -1 ? null : selectedIndex, onMatch: isOpen ? setActiveIndex : (index) => handleSelect(index ?? -1), onTypingChange(isTyping) { isTypingRef.current = isTyping; }, enabled: !disabled, }); useImperativeHandle( ref, () => ({ focus: () => { (refs.domReference.current as HTMLElement | null)?.focus(); }, }), [refs.domReference], ); const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ dismiss, role, listNav, typeahead, click, ]); function handleSelect(index: number) { const option = options[index]; onChange({ type: "select-one", name, value: option ? option.value : null, }); setIsOpen(false); } return ( <>
{selectedOption ? ( <> {selectedOption.icon && } {selectedOption.label} ) : ( <>{placeholder} )}
{isOpen && (
{options.map((option, i) => { const isActive = i === activeIndex; const isSelected = i === selectedIndex; return ( ); })}
)} ); }