Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Check, ChevronDown } from 'lucide-react'
|
|
import styles from './MultiSelectDropdown.module.css'
|
|
|
|
export interface MultiSelectOption<T extends string | number = number> {
|
|
value: T
|
|
label: string
|
|
depth?: number
|
|
}
|
|
|
|
interface MultiSelectDropdownProps<T extends string | number = number> {
|
|
options: MultiSelectOption<T>[]
|
|
value: T[]
|
|
onChange: (value: T[]) => void
|
|
placeholder?: string
|
|
searchPlaceholder?: string
|
|
disabled?: boolean
|
|
id?: string
|
|
}
|
|
|
|
export function MultiSelectDropdown<T extends string | number = number>({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = 'Select options',
|
|
searchPlaceholder = 'Search...',
|
|
disabled = false,
|
|
id,
|
|
}: MultiSelectDropdownProps<T>) {
|
|
const [open, setOpen] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const selectedLabels = useMemo(
|
|
() =>
|
|
options
|
|
.filter((option) => value.includes(option.value))
|
|
.map((option) => option.label),
|
|
[options, value],
|
|
)
|
|
|
|
const filteredOptions = useMemo(() => {
|
|
const query = searchQuery.trim().toLowerCase()
|
|
if (!query) return options
|
|
return options.filter((o) => o.label.toLowerCase().includes(query))
|
|
}, [options, searchQuery])
|
|
|
|
const triggerLabel = useMemo(() => {
|
|
if (selectedLabels.length === 0) return placeholder
|
|
if (selectedLabels.length <= 2) return selectedLabels.join(', ')
|
|
return `${selectedLabels.slice(0, 2).join(', ')} +${selectedLabels.length - 2}`
|
|
}, [placeholder, selectedLabels])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setSearchQuery('')
|
|
|
|
const t = window.setTimeout(() => {
|
|
searchInputRef.current?.focus()
|
|
}, 0)
|
|
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
|
|
function handleEscape(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
document.addEventListener('keydown', handleEscape)
|
|
return () => {
|
|
window.clearTimeout(t)
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
document.removeEventListener('keydown', handleEscape)
|
|
}
|
|
}, [open])
|
|
|
|
function toggleOption(optionValue: T) {
|
|
if (value.includes(optionValue)) {
|
|
onChange(value.filter((item) => item !== optionValue))
|
|
return
|
|
}
|
|
onChange([...value, optionValue])
|
|
}
|
|
|
|
return (
|
|
<div className={styles.wrapper} ref={containerRef}>
|
|
<button
|
|
id={id}
|
|
type="button"
|
|
className={`${styles.trigger} ${open ? styles.triggerOpen : ''} ${
|
|
selectedLabels.length === 0 ? styles.triggerPlaceholder : ''
|
|
}`}
|
|
onClick={() => !disabled && setOpen((prev) => !prev)}
|
|
disabled={disabled}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
>
|
|
<span className={styles.triggerText}>{triggerLabel}</span>
|
|
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
|
</button>
|
|
|
|
{open && (
|
|
<ul className={styles.dropdown} role="listbox" aria-multiselectable="true">
|
|
<li className={styles.searchRow}>
|
|
<input
|
|
ref={searchInputRef}
|
|
className={styles.searchInput}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
aria-label="Search options"
|
|
disabled={disabled}
|
|
/>
|
|
</li>
|
|
|
|
{filteredOptions.length === 0 ? (
|
|
<li className={styles.empty}>{options.length === 0 ? 'No options available.' : 'No matching options.'}</li>
|
|
) : (
|
|
filteredOptions.map((option) => {
|
|
const selected = value.includes(option.value)
|
|
return (
|
|
<li key={String(option.value)}>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={selected}
|
|
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
|
style={{ paddingLeft: `${10 + (option.depth ?? 0) * 18}px` }}
|
|
onClick={() => toggleOption(option.value)}
|
|
>
|
|
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
|
|
{selected && <Check size={12} strokeWidth={3} />}
|
|
</span>
|
|
<span>{option.label}</span>
|
|
</button>
|
|
</li>
|
|
)
|
|
})
|
|
)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|