Restore RTL tree lines in category dropdowns, require product category on add, and improve chart bar density with Y-axis tooltips. Co-authored-by: Cursor <cursoragent@cursor.com>
297 lines
8.7 KiB
TypeScript
297 lines
8.7 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { ChevronDown, Search, X } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { useT } from '../i18n/useT'
|
|
import type { UserProductCategoryOption } from '../services/userProductsService'
|
|
import styles from './CategorySearchSelect.module.css'
|
|
|
|
export interface FlatCategoryOption extends UserProductCategoryOption {
|
|
depth: number
|
|
}
|
|
|
|
interface CategorySearchSelectProps {
|
|
options: UserProductCategoryOption[]
|
|
value: string
|
|
onChange: (value: string) => void
|
|
disabled?: boolean
|
|
placeholder?: string
|
|
id?: string
|
|
allowEmpty?: boolean
|
|
dropdownVariant?: 'overlay' | 'inFlow'
|
|
}
|
|
|
|
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
|
|
if (isFa) {
|
|
return category.nameFa?.trim() || category.name
|
|
}
|
|
return category.name || category.nameFa?.trim() || ''
|
|
}
|
|
|
|
export function flattenCategoryTree(
|
|
categories: UserProductCategoryOption[],
|
|
): FlatCategoryOption[] {
|
|
const byParent = new Map<string | null, UserProductCategoryOption[]>()
|
|
|
|
for (const category of categories) {
|
|
const parentKey = category.parentId
|
|
const list = byParent.get(parentKey) ?? []
|
|
list.push(category)
|
|
byParent.set(parentKey, list)
|
|
}
|
|
|
|
for (const list of byParent.values()) {
|
|
list.sort((a, b) => {
|
|
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
|
|
return aLabel
|
|
})
|
|
}
|
|
|
|
const result: FlatCategoryOption[] = []
|
|
const ids = new Set(categories.map((item) => item.id))
|
|
|
|
function walk(parentId: string | null, depth: number) {
|
|
const children = byParent.get(parentId) ?? []
|
|
for (const child of children) {
|
|
result.push({ ...child, depth })
|
|
walk(child.id, depth + 1)
|
|
}
|
|
}
|
|
|
|
walk(null, 0)
|
|
|
|
for (const category of categories) {
|
|
if (category.parentId && !ids.has(category.parentId)) {
|
|
if (!result.some((item) => item.id === category.id)) {
|
|
result.push({ ...category, depth: 0 })
|
|
walk(category.id, 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function filterCategoryTree(
|
|
flat: FlatCategoryOption[],
|
|
query: string,
|
|
): FlatCategoryOption[] {
|
|
const q = query.trim().toLowerCase()
|
|
if (!q) return flat
|
|
|
|
const byId = new Map(flat.map((item) => [item.id, item]))
|
|
const matchedIds = new Set<string>()
|
|
|
|
for (const item of flat) {
|
|
const nameEn = item.name.toLowerCase()
|
|
const nameFa = (item.nameFa || '').toLowerCase()
|
|
if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
|
|
matchedIds.add(item.id)
|
|
let parentId = item.parentId
|
|
while (parentId) {
|
|
matchedIds.add(parentId)
|
|
parentId = byId.get(parentId)?.parentId ?? null
|
|
}
|
|
}
|
|
}
|
|
|
|
return flat.filter((item) => matchedIds.has(item.id))
|
|
}
|
|
|
|
function isLastChildInGroup(items: FlatCategoryOption[], index: number): boolean {
|
|
const current = items[index]
|
|
if (current.depth === 0) return false
|
|
const next = items[index + 1]
|
|
return !next || next.depth < current.depth
|
|
}
|
|
|
|
function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) {
|
|
if (depth === 0) return null
|
|
|
|
return (
|
|
<div className={styles.treeGutters} aria-hidden>
|
|
{Array.from({ length: depth }, (_, level) => {
|
|
const isBranchLevel = level === depth - 1
|
|
return (
|
|
<span
|
|
key={level}
|
|
className={[
|
|
styles.treeGutter,
|
|
isBranchLevel ? styles.treeGutterBranch : styles.treeGutterContinue,
|
|
isBranchLevel && isLast ? styles.treeGutterBranchLast : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CategorySearchSelect({
|
|
options,
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
placeholder,
|
|
id,
|
|
allowEmpty = false,
|
|
dropdownVariant = 'overlay',
|
|
}: CategorySearchSelectProps) {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const isFa = locale === 'fa'
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
const flat = useMemo(() => flattenCategoryTree(options), [options])
|
|
const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
|
|
|
|
const selected = flat.find((option) => option.id === value)
|
|
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
|
const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCategory')
|
|
|
|
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 (disabled) {
|
|
setOpen(false)
|
|
setQuery('')
|
|
}
|
|
}, [disabled])
|
|
|
|
function selectOption(nextId: string) {
|
|
onChange(nextId)
|
|
setOpen(false)
|
|
setQuery('')
|
|
}
|
|
|
|
const dropdownClassName = [
|
|
styles.dropdown,
|
|
dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
return (
|
|
<div
|
|
className={styles.wrapper}
|
|
ref={containerRef}
|
|
dir={isFa ? 'rtl' : 'ltr'}
|
|
>
|
|
<div
|
|
className={[
|
|
styles.inputWrap,
|
|
open ? styles.inputWrapOpen : '',
|
|
disabled ? styles.inputWrapDisabled : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
<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)
|
|
}}
|
|
onFocus={() => {
|
|
if (!disabled) setOpen(true)
|
|
}}
|
|
autoComplete="off"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-autocomplete="list"
|
|
/>
|
|
{allowEmpty && value && !open && !disabled ? (
|
|
<button
|
|
type="button"
|
|
className={styles.clearBtn}
|
|
onClick={() => onChange('')}
|
|
aria-label={t('myProducts.fields.clearCategory')}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
) : null}
|
|
<ChevronDown
|
|
size={16}
|
|
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
aria-hidden
|
|
/>
|
|
</div>
|
|
|
|
{open && !disabled ? (
|
|
<ul className={dropdownClassName} role="listbox">
|
|
{filtered.length === 0 ? (
|
|
<li className={styles.noResults}>{t('myProducts.fields.noCategories')}</li>
|
|
) : (
|
|
filtered.map((option, index) => {
|
|
const isLastChild = isLastChildInGroup(filtered, index)
|
|
return (
|
|
<li
|
|
key={option.id}
|
|
className={[
|
|
styles.optionItem,
|
|
isLastChild ? styles.optionItemGroupEnd : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={[
|
|
styles.option,
|
|
option.depth === 0 ? styles.optionParent : styles.optionChild,
|
|
value === option.id ? styles.optionSelected : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
onClick={() => selectOption(option.id)}
|
|
role="option"
|
|
aria-selected={value === option.id}
|
|
>
|
|
{option.depth > 0 ? (
|
|
<CategoryTreeGutters depth={option.depth} isLast={isLastChild} />
|
|
) : null}
|
|
<span className={styles.optionBody}>
|
|
<span className={styles.optionLabel}>
|
|
{categoryLabel(option, isFa)}
|
|
</span>
|
|
{!isFa && option.nameFa ? (
|
|
<span className={styles.optionSecondary}>{option.nameFa}</span>
|
|
) : null}
|
|
{isFa && option.name && option.name !== option.nameFa ? (
|
|
<span className={styles.optionSecondary} dir="ltr">
|
|
{option.name}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
)
|
|
})
|
|
)}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|