developing #10
|
|
@ -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