feat: [US-065] - Searchable combobox component
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1f7bd321a2
commit
11e8daf67c
|
|
@ -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<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(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 (
|
||||||
|
<div ref={containerRef} className="relative w-full">
|
||||||
|
<div
|
||||||
|
className="flex w-full cursor-pointer items-center rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (isOpen) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
open()
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
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()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={selectedItem ? '' : 'text-zinc-400 dark:text-zinc-500'}>
|
||||||
|
{selectedItem ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{selectedItem.color && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-full border border-zinc-300 dark:border-zinc-600"
|
||||||
|
style={{ backgroundColor: selectedItem.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedItem.badge && (
|
||||||
|
<span className="rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||||
|
{selectedItem.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedItem.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
placeholder
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
className={`ml-auto h-4 w-4 shrink-0 text-zinc-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
className={`absolute z-50 max-h-48 w-full overflow-auto rounded border border-zinc-300 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-800 ${
|
||||||
|
dropdownPosition === 'above' ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filteredItems.length === 0 && !onAddNew && (
|
||||||
|
<li className="px-2 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
No results found
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
data-combobox-item
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 px-2 py-1.5 text-sm ${
|
||||||
|
highlightedIndex === index
|
||||||
|
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
|
||||||
|
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700'
|
||||||
|
} ${item.id === value ? 'font-medium' : ''}`}
|
||||||
|
onClick={() => selectItem(item.id)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
{item.color && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 shrink-0 rounded-full border border-zinc-300 dark:border-zinc-600"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.badge && (
|
||||||
|
<span className="shrink-0 rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{onAddNew && (
|
||||||
|
<li
|
||||||
|
data-combobox-item
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 border-t border-zinc-200 px-2 py-1.5 text-sm dark:border-zinc-700 ${
|
||||||
|
highlightedIndex === filteredItems.length
|
||||||
|
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
|
||||||
|
: 'text-blue-600 hover:bg-zinc-100 dark:text-blue-400 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onAddNew()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(filteredItems.length)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<span>Add new...</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue