From 11e8daf67c2a0ac89522e55a98aaf315fef1779e Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 04:27:32 -0300 Subject: [PATCH] feat: [US-065] - Searchable combobox component Co-Authored-By: Claude Opus 4.5 --- src/components/editor/Combobox.tsx | 263 +++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/components/editor/Combobox.tsx diff --git a/src/components/editor/Combobox.tsx b/src/components/editor/Combobox.tsx new file mode 100644 index 0000000..b84b7bb --- /dev/null +++ b/src/components/editor/Combobox.tsx @@ -0,0 +1,263 @@ +'use client' + +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' + +export type ComboboxItem = { + id: string + label: string + color?: string + badge?: string +} + +type ComboboxProps = { + items: ComboboxItem[] + value: string | undefined + onChange: (id: string) => void + placeholder?: string + onAddNew?: () => void +} + +export default function Combobox({ + items, + value, + onChange, + placeholder = 'Select...', + onAddNew, +}: ComboboxProps) { + const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below') + + const containerRef = useRef(null) + const inputRef = useRef(null) + const listRef = useRef(null) + + const selectedItem = useMemo( + () => items.find((item) => item.id === value), + [items, value] + ) + + const filteredItems = useMemo( + () => + items.filter((item) => + item.label.toLowerCase().includes(search.toLowerCase()) + ), + [items, search] + ) + + const totalOptions = filteredItems.length + (onAddNew ? 1 : 0) + + const updateDropdownPosition = useCallback(() => { + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const spaceBelow = window.innerHeight - rect.bottom + const spaceAbove = rect.top + setDropdownPosition(spaceBelow < 200 && spaceAbove > spaceBelow ? 'above' : 'below') + }, []) + + const open = useCallback(() => { + setIsOpen(true) + setSearch('') + setHighlightedIndex(0) + updateDropdownPosition() + }, [updateDropdownPosition]) + + const close = useCallback(() => { + setIsOpen(false) + setSearch('') + }, []) + + const selectItem = useCallback( + (id: string) => { + onChange(id) + close() + }, + [onChange, close] + ) + + // Close on outside click + useEffect(() => { + if (!isOpen) return + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + close() + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, close]) + + // Scroll highlighted item into view + useEffect(() => { + if (!isOpen || !listRef.current) return + const items = listRef.current.querySelectorAll('[data-combobox-item]') + const highlighted = items[highlightedIndex] + if (highlighted) { + highlighted.scrollIntoView({ block: 'nearest' }) + } + }, [highlightedIndex, isOpen]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') { + e.preventDefault() + open() + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex((prev) => (prev + 1) % totalOptions) + break + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions) + break + case 'Enter': + e.preventDefault() + if (highlightedIndex < filteredItems.length) { + selectItem(filteredItems[highlightedIndex].id) + } else if (onAddNew && highlightedIndex === filteredItems.length) { + onAddNew() + close() + } + break + case 'Escape': + e.preventDefault() + close() + break + } + }, + [isOpen, open, close, highlightedIndex, filteredItems, totalOptions, selectItem, onAddNew] + ) + + return ( +
+
{ + if (isOpen) { + close() + } else { + open() + setTimeout(() => inputRef.current?.focus(), 0) + } + }} + > + {isOpen ? ( + { + setSearch(e.target.value) + setHighlightedIndex(0) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full bg-transparent outline-none placeholder-zinc-400 dark:placeholder-zinc-500" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {selectedItem ? ( + + {selectedItem.color && ( + + )} + {selectedItem.badge && ( + + {selectedItem.badge} + + )} + {selectedItem.label} + + ) : ( + placeholder + )} + + )} + + + +
+ + {isOpen && ( +
    + {filteredItems.length === 0 && !onAddNew && ( +
  • + No results found +
  • + )} + + {filteredItems.map((item, index) => ( +
  • selectItem(item.id)} + onMouseEnter={() => setHighlightedIndex(index)} + > + {item.color && ( + + )} + {item.badge && ( + + {item.badge} + + )} + {item.label} +
  • + ))} + + {onAddNew && ( +
  • { + onAddNew() + close() + }} + onMouseEnter={() => setHighlightedIndex(filteredItems.length)} + > + + + + Add new... +
  • + )} +
+ )} +
+ ) +}