Files
chaos-api/web/default/src/components/ui/combobox-input.tsx
T
t0ng7u d146e45e2f ⚖️ chore(web/default): add reusable copyright header tooling
Add a Bun script to apply and normalize AGPL copyright headers across the default frontend source files.

The script keeps headers idempotent, upgrades existing headers to the 2023-2026 QuantumNous range, and is exposed through `bun run copyright` for future maintenance.
2026-05-09 11:35:07 +08:00

216 lines
6.7 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
export type ComboboxInputOption = {
value: string
label: string
icon?: React.ReactNode
}
interface ComboboxInputProps {
options: ComboboxInputOption[]
value?: string
onValueChange: (value: string) => void
placeholder?: string
emptyText?: string
className?: string
id?: string
}
export function ComboboxInput({
options,
value = '',
onValueChange,
placeholder = 'Select or type...',
emptyText = 'No option found.',
className,
id,
}: ComboboxInputProps) {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const listRef = React.useRef<HTMLUListElement>(null)
const filteredOptions = React.useMemo(() => {
if (!value.trim()) return options
const search = value.toLowerCase().trim()
return options.filter(
(option) =>
option.label.toLowerCase().includes(search) ||
option.value.toLowerCase().includes(search)
)
}, [options, value])
// Reset highlight when filtered options change
React.useEffect(() => {
setHighlightedIndex(-1)
}, [filteredOptions])
// Handle click outside to close
React.useEffect(() => {
if (!open) return
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const handleSelect = (selectedValue: string) => {
onValueChange(selectedValue)
setOpen(false)
inputRef.current?.focus()
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
setOpen(true)
return
}
if (!open) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : 0
)
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredOptions.length - 1
)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex].value)
} else {
// No highlighted option, just close the dropdown and keep current value
setOpen(false)
}
break
case 'Escape':
e.preventDefault()
setOpen(false)
break
}
}
// Scroll highlighted item into view
React.useEffect(() => {
if (highlightedIndex < 0 || !listRef.current) return
const item = listRef.current.children[highlightedIndex] as HTMLElement
item?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex])
const showDropdown = open && (filteredOptions.length > 0 || value.trim())
return (
<div ref={containerRef} className='relative'>
<Input
ref={inputRef}
id={id}
type='text'
role='combobox'
aria-expanded={open}
aria-haspopup='listbox'
aria-autocomplete='list'
autoComplete='off'
placeholder={placeholder}
value={value}
onChange={(e) => {
onValueChange(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className={cn('pr-9', className)}
/>
<ChevronsUpDown className='pointer-events-none absolute top-1/2 right-3 size-4 shrink-0 -translate-y-1/2 opacity-50' />
{showDropdown && (
<div className='bg-popover text-popover-foreground absolute top-full z-100 mt-1 w-full rounded-md border shadow-md'>
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
className={cn(
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
index === highlightedIndex &&
'bg-accent text-accent-foreground',
value === option.value && 'font-medium'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur
handleSelect(option.value)
}}
>
<Check
className={cn(
'size-4 shrink-0',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.icon && <span>{option.icon}</span>}
<span className='truncate'>{option.label}</span>
</li>
))}
</ul>
) : (
<div className='px-2 py-6 text-center text-sm'>
{emptyText}
{value.trim() && (
<div className='text-muted-foreground mt-1 text-xs'>
{t('Press Enter to use "{{value}}"', { value: value.trim() })}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}