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>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user