Files
dashboards/apps/super-admin/src/components/MultiSelectDropdown.tsx
T
Alireza HassaniandCursor f566387c61 Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 13:48:53 +03:30

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>
)
}