developing #10

Merged
GHMiranda merged 64 commits from developing into master 2026-01-25 00:37:11 +00:00
1 changed files with 263 additions and 0 deletions
Showing only changes of commit 11e8daf67c - Show all commits

View File

@ -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>
)
}