import React, { useEffect, useRef, useState } from "react" import { motion } from "framer-motion" import { CheckIcon, ChevronDown, Search, X } from "lucide-react" import { cn } from "../../design-lib/utils" import { Input } from "./input" type OptionProps = { value: string label: string } type ComboBoxProps = { placeholder: string onSelect?: (value: string) => void children: React.ReactElement[] defaultValue?: string emptyMessage?: string errored?: boolean clearable?: boolean "aria-label"?: string disabled?: boolean className?: string width?: string | number } export const ComboboxOption: React.FC = () => { return null } export const ComboBox: React.FC = ({ placeholder, onSelect, defaultValue, emptyMessage, errored, clearable = true, "aria-label": ariaLabel = "combobox", disabled, className, width, children, }) => { const [open, setOpen] = useState(false) const [inputValue, setInputValue] = useState("") const [filteredOptions, setFilteredOptions] = useState([]) const [activeIndex, setActiveIndex] = useState("0") // For keyboard navigation const [isKeyboardActive, setIsKeyboardActive] = useState(false) const containerRef = useRef(null) const inputRef = useRef(null) const [selectedValue, setSelectedValue] = useState(defaultValue) const ulRef = useRef(null) const activeItemRef = useRef(null) const options = React.Children.map(children, (child) => { if (React.isValidElement(child)) { return { label: child.props.label, value: child.props.value } } return null }).filter(Boolean) as OptionProps[] const revertToSelectedValue = () => { const selectedOption = options.find( (option) => option.value === selectedValue ) if (selectedOption) { setInputValue(selectedOption.label) } } useEffect(() => { const defaultValueLabel = options.find( (option) => option.value === defaultValue )?.label if (defaultValueLabel) { setInputValue(defaultValueLabel) } }, [defaultValue]) useEffect(() => { setFilteredOptions( options.filter((option) => option.label.toLowerCase().includes(inputValue.toLowerCase()) ) ) }, [inputValue]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) ) { setOpen(false) revertToSelectedValue() } } document.addEventListener("mousedown", handleClickOutside) return () => { document.removeEventListener("mousedown", handleClickOutside) } }, [revertToSelectedValue]) // Scroll to the active item when using the keyboard useEffect(() => { if (ulRef.current && activeItemRef.current) { ulRef.current.scrollTop = activeItemRef.current.offsetTop + activeItemRef.current.clientHeight - ulRef.current.clientHeight + 10 } // this useEffect dependency is checking the type of activeIndex is a work around to solve the glittering when scrolling with the mouse }, [typeof activeIndex === "number" ? activeIndex : null]) const handleKeyDown = (e: React.KeyboardEvent) => { setIsKeyboardActive(true) if (e.key === "Escape") { setOpen(false) revertToSelectedValue() } else if (e.key === "ArrowDown") { e.preventDefault() setActiveIndex((prev) => (Number(prev) + 1) % filteredOptions.length) } else if (e.key === "ArrowUp") { e.preventDefault() setActiveIndex( (prev) => (Number(prev) - 1 + filteredOptions.length) % filteredOptions.length ) } else if (e.key === "Enter" && Number(activeIndex) >= 0) { handleSelect(filteredOptions[Number(activeIndex)].value) } // set isKeyboardActive to false after the keydown event has finished setTimeout(() => { setIsKeyboardActive(false) }, 300) } const handleSelect = (value: string) => { const selectedOption = options.find((option) => option.value === value) if (!selectedOption) { return } setInputValue(selectedOption.label) setSelectedValue(selectedOption.value) setActiveIndex(0) setOpen(false) onSelect?.(value) } return (
= 0 ? `option-${activeIndex}` : undefined } placeholder={placeholder} value={inputValue} errored={errored} onKeyDown={handleKeyDown} className={cn("min-h-full w-full rounded-md !pl-10", { "pointer-events-none cursor-not-allowed opacity-disabled": disabled, })} onClick={() => { if (inputValue.length === 0) { setOpen(true) } }} onChange={(e) => { setActiveIndex(0) setInputValue(e.target.value) if (inputValue.length > -1) { setOpen(true) } }} prefix={} suffix={ inputValue.length > 0 && clearable ? ( { setInputValue("") inputRef.current?.focus() setOpen(false) }} /> ) : ( ) } disablePrefixStyling disableSuffixStyling onFocus={() => { if (inputValue.length > 0) { inputRef.current?.select() } }} ref={inputRef} /> {open && ( = 0 ? `option-${activeIndex}` : undefined } className="absolute z-30 mt-3 max-h-[200px] w-full overflow-y-scroll rounded-lg bg-background p-2 shadow-popover" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2, ease: "easeOut", }} ref={ulRef} > {filteredOptions?.length === 0 ? ( {emptyMessage ?? "No results"} ) : ( filteredOptions.map((option, index) => (
  • handleSelect(option.value)} className={`flex items-center justify-between rounded-md px-4 py-2 text-sm ${ Number(activeIndex) === index ? "bg-gray-7" : "" }`} tabIndex={0} onMouseEnter={() => { if (!isKeyboardActive) { // converting index to string is a workaround to solve glittering when scrolling with the mouse setActiveIndex(String(index)) } }} > {option.label} {selectedValue === option.value && ( )}
  • )) )}
    )}
    ) }