d146e45e2f
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.
216 lines
6.7 KiB
TypeScript
Vendored
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>
|
|
)
|
|
}
|