152 lines
5 KiB
TypeScript
152 lines
5 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { BottomDialog, Checkbox, SearchBar, p as P } from 'web-components'
|
|
import { Check, ChevronDown } from 'lucide-react'
|
|
|
|
export interface MultiSelectOption {
|
|
label: string
|
|
value: string | number
|
|
disabled?: boolean
|
|
}
|
|
|
|
interface MultiSelectProps {
|
|
label?: string
|
|
topLabel?: string
|
|
options: MultiSelectOption[]
|
|
value: (string | number)[]
|
|
onValueChange: (v: (string | number)[]) => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
onSearch?: (q: string) => void
|
|
error?: boolean
|
|
className?: string
|
|
testID?: string
|
|
// Tolerated for screen compatibility (RN BottomDropdown leftovers) — ignored on web
|
|
multiple?: boolean
|
|
triggerComponent?: any
|
|
}
|
|
|
|
export function MultiSelect({
|
|
label,
|
|
topLabel,
|
|
options,
|
|
value,
|
|
onValueChange,
|
|
placeholder,
|
|
disabled = false,
|
|
onSearch,
|
|
error = false,
|
|
className,
|
|
testID,
|
|
}: MultiSelectProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
|
|
const selectedSet = useMemo(() => new Set(value.map((v) => String(v))), [value])
|
|
|
|
const displayText = useMemo(() => {
|
|
if (value.length === 0) return placeholder ?? label ?? 'Select...'
|
|
if (value.length === 1) {
|
|
const found = options.find((o) => String(o.value) === String(value[0]))
|
|
return found ? found.label : (placeholder ?? label ?? 'Select...')
|
|
}
|
|
return `${value.length} selected`
|
|
}, [value, options, placeholder, label])
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase()
|
|
if (!q) return options
|
|
return options.filter((o) => o.label.toLowerCase().includes(q))
|
|
}, [options, query])
|
|
|
|
const toggle = (optionValue: string | number) => {
|
|
const key = String(optionValue)
|
|
if (selectedSet.has(key)) {
|
|
onValueChange(value.filter((v) => String(v) !== key))
|
|
} else {
|
|
onValueChange([...value, optionValue])
|
|
}
|
|
}
|
|
|
|
const heading = topLabel ?? label
|
|
|
|
return (
|
|
<div className={className} data-testid={testID}>
|
|
{heading ? (
|
|
<P className="mb-1 text-sm text-gray-500 font-medium">{heading}</P>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setOpen(true)}
|
|
className={`flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-50 ${error ? 'border-red-500' : 'border-gray-300'}`}
|
|
>
|
|
<span className={`truncate ${value.length === 0 ? 'text-gray-400' : 'text-gray-800'}`}>
|
|
{displayText}
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
|
|
</button>
|
|
<BottomDialog open={open} onClose={() => setOpen(false)}>
|
|
<div className="p-4">
|
|
<P className="text-lg font-semibold mb-4">{heading ?? placeholder ?? 'Select'}</P>
|
|
<SearchBar
|
|
placeholder="Search..."
|
|
value={query}
|
|
onChange={(q) => {
|
|
setQuery(q)
|
|
onSearch?.(q)
|
|
}}
|
|
onSearch={onSearch}
|
|
className="mb-3"
|
|
/>
|
|
<div className="overflow-y-auto" style={{ maxHeight: 320 }}>
|
|
{filtered.map((option) => {
|
|
const checked = selectedSet.has(String(option.value))
|
|
return (
|
|
<div
|
|
key={String(option.value)}
|
|
data-testid="multiselect-option"
|
|
onClick={() => {
|
|
if (!option.disabled) toggle(option.value)
|
|
}}
|
|
className={`flex flex-row items-center p-3 border-b border-gray-100 ${option.disabled ? 'opacity-50' : 'cursor-pointer hover:bg-gray-50'}`}
|
|
>
|
|
<span onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={checked}
|
|
onPress={() => {
|
|
if (!option.disabled) toggle(option.value)
|
|
}}
|
|
/>
|
|
</span>
|
|
<span className="ml-3 text-sm text-gray-800 flex-1">{option.label}</span>
|
|
{checked ? <Check className="h-4 w-4 text-blue-600" /> : null}
|
|
</div>
|
|
)
|
|
})}
|
|
{filtered.length === 0 ? (
|
|
<P className="text-center text-gray-500 text-sm p-4">No options found</P>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-row gap-2 mt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => onValueChange([])}
|
|
className="flex-1 p-3 rounded-lg bg-gray-100 text-gray-700 text-center font-semibold text-sm"
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="flex-1 p-3 rounded-lg bg-blue-500 text-white text-center font-semibold text-sm"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</BottomDialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default MultiSelect
|