"use client" import * as React from "react" import { Check, ChevronsUpDown, Search, X } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { createPortal } from "react-dom" interface ComboboxProps { value?: string onValueChange?: (value: string) => void placeholder?: string searchPlaceholder?: string emptyText?: string options: Array<{ value: string; label: string }> className?: string } export function Combobox({ value, onValueChange, placeholder = "Select option...", searchPlaceholder = "Search...", emptyText = "No option found.", options, className, }: ComboboxProps) { const [open, setOpen] = React.useState(false) const [search, setSearch] = React.useState("") const [mounted, setMounted] = React.useState(false) const containerRef = React.useRef(null) const triggerRef = React.useRef(null) const dropdownRef = React.useRef(null) const inputRef = React.useRef(null) // Track if component is mounted for SSR compatibility React.useEffect(() => { setMounted(true) return () => setMounted(false) }, []) const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(search.toLowerCase())) const selectedOption = options.find((option) => option.value === value) const resetSearch = () => { setSearch("") if (inputRef.current) { inputRef.current.focus() } } React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) && dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { setOpen(false) setSearch("") } } const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpen(false) setSearch("") } } if (open) { document.addEventListener("mousedown", handleClickOutside) document.addEventListener("keydown", handleEscape) } return () => { document.removeEventListener("mousedown", handleClickOutside) document.removeEventListener("keydown", handleEscape) } }, [open]) // Position the dropdown when it opens React.useEffect(() => { if (open && triggerRef.current && dropdownRef.current && mounted) { const updatePosition = () => { if (!triggerRef.current || !dropdownRef.current) return const triggerRect = triggerRef.current.getBoundingClientRect() const dropdownWidth = triggerRect.width // Calculate available space const viewportHeight = window.innerHeight const spaceBelow = viewportHeight - triggerRect.bottom - 10 const spaceAbove = triggerRect.top - 10 // Determine if dropdown should appear above or below const showAbove = spaceBelow < 200 && spaceAbove > spaceBelow // Set dropdown position dropdownRef.current.style.position = "fixed" dropdownRef.current.style.width = `${dropdownWidth}px` dropdownRef.current.style.left = `${triggerRect.left}px` if (showAbove) { dropdownRef.current.style.bottom = `${viewportHeight - triggerRect.top + 5}px` dropdownRef.current.style.top = "auto" // Set a fixed height for the dropdown dropdownRef.current.style.maxHeight = `${Math.min(spaceAbove, 300)}px` } else { dropdownRef.current.style.top = `${triggerRect.bottom + 5}px` dropdownRef.current.style.bottom = "auto" // Set a fixed height for the dropdown dropdownRef.current.style.maxHeight = `${Math.min(spaceBelow, 300)}px` } } updatePosition() window.addEventListener("resize", updatePosition) window.addEventListener("scroll", updatePosition, true) return () => { window.removeEventListener("resize", updatePosition) window.removeEventListener("scroll", updatePosition, true) } } }, [open, mounted]) // Generate a longer list of options for testing scrolling const testOptions = React.useMemo(() => { if (options.length < 10) { return [ ...options, { value: "angular", label: "Angular" }, { value: "svelte", label: "Svelte" }, { value: "vue", label: "Vue" }, { value: "ember", label: "Ember" }, { value: "backbone", label: "Backbone" }, { value: "jquery", label: "jQuery" }, { value: "polymer", label: "Polymer" }, { value: "meteor", label: "Meteor" }, { value: "aurelia", label: "Aurelia" }, { value: "mithril", label: "Mithril" }, { value: "preact", label: "Preact" }, { value: "alpine", label: "Alpine.js" }, { value: "lit", label: "Lit" }, { value: "solid", label: "Solid" }, { value: "qwik", label: "Qwik" }, { value: "marko", label: "Marko" }, { value: "stimulus", label: "Stimulus" }, { value: "riot", label: "Riot.js" }, { value: "hyperapp", label: "Hyperapp" }, { value: "inferno", label: "Inferno" }, ] } return options }, [options]) return (
{mounted && open && createPortal(
{search ? ( ) : ( )} setSearch(e.target.value)} className="flex h-full w-full bg-transparent py-2 px-2 text-sm outline-none placeholder:text-muted-foreground" autoFocus />
{filteredOptions.length === 0 ? (
{emptyText}
) : ( filteredOptions.map((option) => (
{ onValueChange?.(option.value) setOpen(false) setSearch("") }} > {option.label}
)) )}
, document.body, )}
) }