freshyo/apps/admin-web/src/components/MultiSelect.tsx
2026-09-12 09:06:33 +05:30

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