import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' interface CollectionComboboxProps { /** Current value. Empty string means "Uncategorized". */ value: string onChange: (value: string) => void /** Presets + known-in-use tags, already merged and deduped by the caller. */ options: string[] placeholder?: string allowUncategorized?: boolean className?: string disabled?: boolean } /** * Dependency-free creatable combobox for collection tags. Lets you pick an * existing option (preset or already-in-use) or type a brand new one — the * "+ Create" row only appears when the (normalized) input doesn't already * match something. Actual normalization (trim/lowercase/length cap) happens * server-side via sanitizeCollectionName; this just lowercases for the * match-check and display so duplicates-by-case don't look like real options. */ export default function CollectionCombobox({ value, onChange, options, placeholder = 'Uncategorized', allowUncategorized = true, className = '', disabled = false, }: CollectionComboboxProps) { const [query, setQuery] = useState(value) const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) const dropdownRef = useRef(null) const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null) useEffect(() => { setQuery(value) }, [value]) useEffect(() => { function handleClickOutside(e: MouseEvent) { const target = e.target as Node // The list is portaled to , so it is NOT inside containerRef — check both, // otherwise mousedown on an option closes the list before its click can land. const insideInput = containerRef.current?.contains(target) const insideList = dropdownRef.current?.contains(target) if (!insideInput && !insideList) { setIsOpen(false) setQuery(value) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [value]) /** * The list renders in a portal with fixed positioning rather than absolutely inside * the input's own box. Every ancestor that sets `overflow` other than visible would * otherwise clip it: in the Knowledge Base table each cell carries `truncate` * (overflow:hidden), which cropped the list to a single row's height, and the modal * body is an `overflow-y-auto` scroller the list could not escape either. Fixed * positioning against the viewport sidesteps both. */ const reposition = useCallback(() => { const input = containerRef.current if (!input) return const rect = input.getBoundingClientRect() const MAX_HEIGHT = 224 // matches max-h-56 const GAP = 4 const spaceBelow = window.innerHeight - rect.bottom // Flip above the input when the list would run off the bottom of the viewport // and there is more headroom up top. const flipUp = spaceBelow < MAX_HEIGHT + GAP && rect.top > spaceBelow setPosition({ left: rect.left, top: flipUp ? Math.max(GAP, rect.top - GAP - MAX_HEIGHT) : rect.bottom + GAP, width: rect.width, }) }, []) useLayoutEffect(() => { if (!isOpen) return reposition() // `true` for capture so scrolling in any ancestor scroller keeps the list attached // to its input rather than leaving it stranded mid-air. window.addEventListener('scroll', reposition, true) window.addEventListener('resize', reposition) return () => { window.removeEventListener('scroll', reposition, true) window.removeEventListener('resize', reposition) } }, [isOpen, reposition]) const normalizedQuery = query.trim().toLowerCase() const filtered = normalizedQuery ? options.filter((o) => o.toLowerCase().includes(normalizedQuery)) : options const exactMatch = options.find((o) => o.toLowerCase() === normalizedQuery) const showCreateOption = normalizedQuery.length > 0 && !exactMatch const commit = (val: string) => { onChange(val) setQuery(val) setIsOpen(false) } return (
{ setQuery(e.target.value) setIsOpen(true) }} onFocus={() => setIsOpen(true)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() const trimmed = query.trim() if (!trimmed) { if (allowUncategorized) commit('') return } commit(exactMatch ?? trimmed.toLowerCase()) } else if (e.key === 'Escape') { setIsOpen(false) setQuery(value) } }} placeholder={placeholder} className="w-full rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary disabled:opacity-50" /> {isOpen && !disabled && position && createPortal(
{allowUncategorized && ( )} {filtered.map((opt) => ( ))} {showCreateOption && ( )} {filtered.length === 0 && !showCreateOption && (
No matches
)}
, document.body )}
) }