302 lines
9.0 KiB
TypeScript
302 lines
9.0 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { useT } from '../i18n/useT'
|
|
import type { Category } from '../types/category'
|
|
import { getAncestorIds, getChildren } from '../utils/categories'
|
|
import { Tooltip } from './Tooltip'
|
|
import treeStyles from './CategoryTree.module.css'
|
|
import styles from './CategoryTreeSelect.module.css'
|
|
|
|
interface CategoryTreeSelectProps {
|
|
categories: Category[]
|
|
value: string
|
|
onChange: (value: string) => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
id?: string
|
|
/** Overlay floats over content; inFlow expands the parent (use inside modals). */
|
|
dropdownVariant?: 'overlay' | 'inFlow'
|
|
}
|
|
|
|
function categoryLabel(category: Category, isFa: boolean) {
|
|
if (isFa) {
|
|
return category.nameFa
|
|
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
|
|
: category.nameEn
|
|
}
|
|
return category.nameEn
|
|
? `${category.nameEn}${category.nameFa ? ` / ${category.nameFa}` : ''}`
|
|
: category.nameFa
|
|
}
|
|
|
|
function categoryMatches(category: Category, query: string) {
|
|
const q = query.trim().toLowerCase()
|
|
if (!q) return true
|
|
return (
|
|
category.nameEn.toLowerCase().includes(q) ||
|
|
category.nameFa.toLowerCase().includes(q) ||
|
|
category.nameFa.includes(query.trim())
|
|
)
|
|
}
|
|
|
|
function visibleIdsForQuery(categories: Category[], query: string): Set<string> | null {
|
|
const q = query.trim()
|
|
if (!q) return null
|
|
|
|
const matched = categories.filter((category) => categoryMatches(category, q))
|
|
const visible = new Set<string>()
|
|
|
|
for (const category of matched) {
|
|
visible.add(category.id)
|
|
for (const ancestorId of getAncestorIds(categories, category.id)) {
|
|
visible.add(ancestorId)
|
|
}
|
|
}
|
|
|
|
return visible
|
|
}
|
|
|
|
function TreeNodes({
|
|
categories,
|
|
parentId,
|
|
value,
|
|
expandedIds,
|
|
visibleIds,
|
|
forceExpanded,
|
|
onToggle,
|
|
onSelect,
|
|
}: {
|
|
categories: Category[]
|
|
parentId: string | null
|
|
value: string
|
|
expandedIds: Set<string>
|
|
visibleIds: Set<string> | null
|
|
forceExpanded: boolean
|
|
onToggle: (id: string) => void
|
|
onSelect: (id: string) => void
|
|
}) {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const isFa = locale === 'fa'
|
|
const nodes = getChildren(categories, parentId).filter(
|
|
(category) => !visibleIds || visibleIds.has(category.id),
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{nodes.map((category) => {
|
|
const childCount = getChildren(categories, category.id).some(
|
|
(child) => !visibleIds || visibleIds.has(child.id),
|
|
)
|
|
const expanded = forceExpanded || expandedIds.has(category.id)
|
|
const selected = value === category.id
|
|
const primary = isFa ? category.nameFa || category.nameEn : category.nameEn
|
|
const secondary = isFa
|
|
? category.nameFa && category.nameEn && category.nameFa !== category.nameEn
|
|
? category.nameEn
|
|
: ''
|
|
: category.nameFa
|
|
|
|
return (
|
|
<div key={category.id}>
|
|
<div className={`${styles.row} ${selected ? styles.rowSelected : ''}`}>
|
|
{childCount ? (
|
|
<Tooltip
|
|
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={styles.toggleBtn}
|
|
aria-label={
|
|
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
|
|
}
|
|
aria-expanded={expanded}
|
|
onClick={() => onToggle(category.id)}
|
|
>
|
|
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
|
|
<ChevronRight size={16} />
|
|
</span>
|
|
</button>
|
|
</Tooltip>
|
|
) : (
|
|
<span className={styles.toggleSpacer} />
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={styles.selectBtn}
|
|
onClick={() => onSelect(category.id)}
|
|
>
|
|
<span className={styles.name}>{primary}</span>
|
|
{secondary ? (
|
|
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
|
|
· {secondary}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
{childCount ? (
|
|
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
|
|
<div className={treeStyles.childrenInner}>
|
|
<TreeNodes
|
|
categories={categories}
|
|
parentId={category.id}
|
|
value={value}
|
|
expandedIds={expandedIds}
|
|
visibleIds={visibleIds}
|
|
forceExpanded={forceExpanded}
|
|
onToggle={onToggle}
|
|
onSelect={onSelect}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function CategoryTreeSelect({
|
|
categories,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
disabled = false,
|
|
id,
|
|
dropdownVariant = 'overlay',
|
|
}: CategoryTreeSelectProps) {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const isFa = locale === 'fa'
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
const selected = categories.find((category) => category.id === value)
|
|
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
|
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
|
|
|
const visibleIds = useMemo(
|
|
() => visibleIdsForQuery(categories, query),
|
|
[categories, query],
|
|
)
|
|
|
|
useEffect(() => {
|
|
setOpen(false)
|
|
setQuery('')
|
|
setExpandedIds(new Set())
|
|
}, [categories])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function 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])
|
|
|
|
useEffect(() => {
|
|
if (!value) return
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev)
|
|
for (const ancestorId of getAncestorIds(categories, value)) {
|
|
next.add(ancestorId)
|
|
}
|
|
return next
|
|
})
|
|
}, [categories, value])
|
|
|
|
function handleToggle(id: string) {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function selectOption(id: string) {
|
|
onChange(id)
|
|
setOpen(false)
|
|
setQuery('')
|
|
}
|
|
|
|
const searching = query.trim().length > 0
|
|
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
|
|
|
return (
|
|
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
|
|
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
|
<Search size={16} className={styles.searchIcon} aria-hidden />
|
|
<input
|
|
id={id}
|
|
type="text"
|
|
className={styles.input}
|
|
disabled={disabled}
|
|
placeholder={selected ? selectedLabel : searchPlaceholder}
|
|
value={open ? query : selectedLabel}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
if (!open) setOpen(true)
|
|
}}
|
|
onClick={() => {
|
|
if (!disabled) setOpen(true)
|
|
}}
|
|
autoComplete="off"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-autocomplete="list"
|
|
/>
|
|
{value && !open && !disabled ? (
|
|
<button
|
|
type="button"
|
|
className={styles.clearBtn}
|
|
onClick={() => onChange('')}
|
|
aria-label={t('categories.select.clear')}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
) : null}
|
|
<ChevronDown
|
|
size={16}
|
|
className={`${styles.fieldChevron} ${open ? styles.fieldChevronOpen : ''}`}
|
|
aria-hidden
|
|
/>
|
|
</div>
|
|
|
|
{open && !disabled ? (
|
|
<div
|
|
className={[
|
|
styles.panel,
|
|
dropdownVariant === 'inFlow' ? styles.panelInFlow : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
{!hasVisible ? (
|
|
<p className={styles.noResults}>{t('categories.select.empty')}</p>
|
|
) : (
|
|
<TreeNodes
|
|
categories={categories}
|
|
parentId={null}
|
|
value={value}
|
|
expandedIds={expandedIds}
|
|
visibleIds={visibleIds}
|
|
forceExpanded={searching}
|
|
onToggle={handleToggle}
|
|
onSelect={selectOption}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|