215 lines
6.8 KiB
TypeScript
215 lines
6.8 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { ChevronDown, Loader2, Search, X } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { useT } from '../i18n/useT'
|
|
import styles from './SearchableSelect.module.css'
|
|
|
|
export const PRODUCT_SEARCH_DEBOUNCE_MS = 350
|
|
|
|
export interface ProductSearchOption {
|
|
id: string
|
|
title: string
|
|
nameFa: string
|
|
}
|
|
|
|
interface ProductSearchSelectProps {
|
|
options: ProductSearchOption[]
|
|
value: string
|
|
onChange: (value: string) => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
id?: string
|
|
minSearchLength?: number
|
|
isSearching?: boolean
|
|
/**
|
|
* When set, typing is debounced and the parent fetches results.
|
|
* Local filtering is skipped.
|
|
*/
|
|
onQueryChange?: (query: string) => void
|
|
debounceMs?: number
|
|
'aria-label'?: string
|
|
}
|
|
|
|
function formatProductLabel(option: ProductSearchOption, preferFa: boolean) {
|
|
if (preferFa && option.nameFa) {
|
|
return option.title ? `${option.nameFa} / ${option.title}` : option.nameFa
|
|
}
|
|
return option.nameFa ? `${option.title} / ${option.nameFa}` : option.title
|
|
}
|
|
|
|
export function ProductSearchSelect({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = 'Search products...',
|
|
disabled = false,
|
|
id,
|
|
minSearchLength = 3,
|
|
isSearching = false,
|
|
onQueryChange,
|
|
debounceMs = PRODUCT_SEARCH_DEBOUNCE_MS,
|
|
'aria-label': ariaLabel,
|
|
}: ProductSearchSelectProps) {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const preferFa = locale === 'fa'
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const onQueryChangeRef = useRef(onQueryChange)
|
|
onQueryChangeRef.current = onQueryChange
|
|
|
|
const selected = options.find((option) => option.id === value)
|
|
const selectedLabel = selected ? formatProductLabel(selected, preferFa) : ''
|
|
const trimmedQuery = query.trim()
|
|
const canSearch = trimmedQuery.length >= minSearchLength
|
|
|
|
useEffect(() => {
|
|
const cb = onQueryChangeRef.current
|
|
if (!cb) return
|
|
if (!canSearch) {
|
|
cb('')
|
|
return
|
|
}
|
|
const timer = window.setTimeout(() => cb(trimmedQuery), debounceMs)
|
|
return () => window.clearTimeout(timer)
|
|
}, [canSearch, trimmedQuery, debounceMs])
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!canSearch) return []
|
|
if (onQueryChange) return options
|
|
const q = trimmedQuery.toLowerCase()
|
|
return options.filter(
|
|
(option) =>
|
|
option.title.toLowerCase().includes(q) ||
|
|
option.nameFa.includes(trimmedQuery),
|
|
)
|
|
}, [canSearch, onQueryChange, options, trimmedQuery])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onClickOutside = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
setQuery('')
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', onClickOutside)
|
|
return () => document.removeEventListener('mousedown', onClickOutside)
|
|
}, [open])
|
|
|
|
function selectOption(id: string) {
|
|
onChange(id)
|
|
setOpen(false)
|
|
setQuery('')
|
|
onQueryChangeRef.current?.('')
|
|
}
|
|
|
|
function handleFocus() {
|
|
if (!disabled) setOpen(true)
|
|
}
|
|
|
|
const showDropdown = open && (canSearch || trimmedQuery.length > 0)
|
|
|
|
return (
|
|
<div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}>
|
|
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
|
<Search size={16} className={styles.searchIcon} />
|
|
<input
|
|
ref={inputRef}
|
|
id={id}
|
|
type="text"
|
|
className={styles.input}
|
|
placeholder={selected ? selectedLabel : placeholder}
|
|
value={open ? query : selectedLabel}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
if (!open) setOpen(true)
|
|
if (value) onChange('')
|
|
}}
|
|
onFocus={handleFocus}
|
|
disabled={disabled}
|
|
aria-label={ariaLabel ?? placeholder}
|
|
dir={preferFa ? 'rtl' : 'ltr'}
|
|
/>
|
|
{value && !open && (
|
|
<button
|
|
type="button"
|
|
className={styles.clearBtn}
|
|
onClick={() => onChange('')}
|
|
aria-label={t('storeItems.search.clear')}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
{isSearching && open ? (
|
|
<Loader2 size={16} className={`${styles.chevron} ${styles.spin}`} />
|
|
) : (
|
|
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
|
)}
|
|
</div>
|
|
|
|
{showDropdown && canSearch && (
|
|
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox">
|
|
{isSearching && filtered.length === 0 ? (
|
|
<li className={styles.noResults}>{t('storeItems.search.searching')}</li>
|
|
) : filtered.length === 0 ? (
|
|
<li className={styles.noResults}>{t('storeItems.search.noMatch')}</li>
|
|
) : (
|
|
filtered.map((option) => (
|
|
<li key={option.id}>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={option.id === value}
|
|
className={`${styles.option} ${option.id === value ? styles.optionSelected : ''}`}
|
|
onClick={() => selectOption(option.id)}
|
|
>
|
|
{preferFa && option.nameFa ? (
|
|
<>
|
|
<span className={styles.optionFa} dir="rtl">
|
|
{option.nameFa}
|
|
</span>
|
|
{option.title && (
|
|
<>
|
|
<span className={styles.optionSep}>/</span>
|
|
<span className={styles.optionEn} dir="rtl">
|
|
{option.title}
|
|
</span>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className={styles.optionEn} dir={preferFa ? 'rtl' : 'ltr'}>
|
|
{option.title}
|
|
</span>
|
|
{option.nameFa && (
|
|
<>
|
|
<span className={styles.optionSep}>/</span>
|
|
<span className={styles.optionFa} dir="rtl">
|
|
{option.nameFa}
|
|
</span>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
)}
|
|
|
|
{open && trimmedQuery.length > 0 && trimmedQuery.length < minSearchLength && (
|
|
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}>
|
|
<li className={styles.noResults}>
|
|
{t('storeItems.search.minChars', { count: minSearchLength })}
|
|
</li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|