Files
chaos-api/web/default/src/components/multi-select.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

151 lines
4.9 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 { Command as CommandPrimitive } from 'cmdk'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Command, CommandGroup, CommandItem } from '@/components/ui/command'
export type Option = {
label: string
value: string
}
interface MultiSelectProps {
options: Option[]
selected: string[]
onChange: (values: string[]) => void
placeholder?: string
className?: string
}
export function MultiSelect({
options,
selected,
onChange,
placeholder,
className,
}: MultiSelectProps) {
const { t } = useTranslation()
const resolvedPlaceholder = placeholder ?? t('Select items...')
const inputRef = React.useRef<HTMLInputElement>(null)
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState('')
const handleUnselect = (value: string) => {
onChange(selected.filter((s) => s !== value))
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
onChange(selected.slice(0, -1))
}
}
if (e.key === 'Escape') {
input.blur()
}
}
}
const selectables = options.filter(
(option) => !selected.includes(option.value)
)
return (
<Command
onKeyDown={handleKeyDown}
className={`overflow-visible bg-transparent ${className || ''}`}
>
<div className='group border-input ring-offset-background focus-within:ring-ring rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2'>
<div className='flex flex-wrap gap-1'>
{selected.map((value) => {
const option = options.find((o) => o.value === value)
return (
<Badge key={value} variant='secondary'>
{option?.label || value}
<Button
variant='ghost'
size='icon-sm'
aria-label='Remove'
className='ml-1 size-auto p-0'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(value)
}
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={() => handleUnselect(value)}
>
<X
className='text-muted-foreground hover:text-foreground h-3 w-3'
aria-hidden='true'
/>
</Button>
</Badge>
)
})}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={selected.length === 0 ? resolvedPlaceholder : ''}
className='placeholder:text-muted-foreground flex-1 bg-transparent outline-none'
/>
</div>
</div>
<div className='relative'>
{open && selectables.length > 0 ? (
<div className='bg-popover text-popover-foreground animate-in absolute top-0 z-10 w-full rounded-md border shadow-md outline-none'>
<CommandGroup className='h-full max-h-60 overflow-auto'>
{selectables.map((option) => {
return (
<CommandItem
key={option.value}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onSelect={() => {
setInputValue('')
onChange([...selected, option.value])
}}
className='cursor-pointer'
>
{option.label}
</CommandItem>
)
})}
</CommandGroup>
</div>
) : null}
</div>
</Command>
)
}