Unify category tree pickers and prefer free-text technical AI fields.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
838e84056e
commit
8465a29ce1
@@ -1,9 +1,6 @@
|
||||
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 { useMemo } from 'react'
|
||||
import type { UserProductCategoryOption } from '../services/userProductsService'
|
||||
import styles from './CategorySearchSelect.module.css'
|
||||
import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
|
||||
|
||||
export interface FlatCategoryOption extends UserProductCategoryOption {
|
||||
depth: number
|
||||
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
|
||||
dropdownVariant?: 'overlay' | 'inFlow'
|
||||
}
|
||||
|
||||
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
|
||||
if (isFa) {
|
||||
return category.nameFa?.trim() || category.name
|
||||
}
|
||||
return category.name || category.nameFa?.trim() || ''
|
||||
}
|
||||
|
||||
/** @deprecated Prefer CategoryTreeSelect; kept for callers that flatten options. */
|
||||
export function flattenCategoryTree(
|
||||
categories: UserProductCategoryOption[],
|
||||
): FlatCategoryOption[] {
|
||||
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
|
||||
}
|
||||
|
||||
for (const list of byParent.values()) {
|
||||
list.sort((a, b) => {
|
||||
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
|
||||
return aLabel
|
||||
})
|
||||
list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
|
||||
}
|
||||
|
||||
const result: FlatCategoryOption[] = []
|
||||
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
|
||||
function walk(parentId: string | null, depth: number) {
|
||||
const children = byParent.get(parentId) ?? []
|
||||
for (const child of children) {
|
||||
if (!ids.has(child.id)) continue
|
||||
result.push({ ...child, depth })
|
||||
walk(child.id, depth + 1)
|
||||
}
|
||||
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
|
||||
|
||||
walk(null, 0)
|
||||
|
||||
const rooted = new Set(result.map((item) => item.id))
|
||||
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)
|
||||
}
|
||||
if (!rooted.has(category.id)) {
|
||||
result.push({ ...category, depth: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
|
||||
return options.map((option) => ({
|
||||
id: option.id,
|
||||
parentId: option.parentId,
|
||||
nameEn: option.name,
|
||||
nameFa: option.nameFa?.trim() || '',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Marketplace categories — same collapsible tree UX as business dashboards. */
|
||||
export function CategorySearchSelect({
|
||||
options,
|
||||
value,
|
||||
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
|
||||
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(' ')
|
||||
const categories = useMemo(() => toTreeItems(options), [options])
|
||||
|
||||
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>
|
||||
<CategoryTreeSelect
|
||||
id={id}
|
||||
categories={categories}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
allowEmpty={allowEmpty}
|
||||
dropdownVariant={dropdownVariant}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.childrenWrap {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.28s ease;
|
||||
}
|
||||
|
||||
.childrenWrap.expanded {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.childrenInner {
|
||||
overflow: hidden;
|
||||
margin-inline-start: 28px;
|
||||
margin-bottom: 10px;
|
||||
padding-inline-start: 22px;
|
||||
padding-inline-end: 8px;
|
||||
border-inline-start: 1px solid rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wrapperOpen {
|
||||
z-index: 130;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--field-height);
|
||||
padding: 0 var(--field-padding-x);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrapOpen,
|
||||
.inputWrap:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--field-padding-y) 0;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fieldChevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fieldChevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-height: min(320px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
background: var(--elevated-surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14);
|
||||
}
|
||||
|
||||
.panelOverlay {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.panelInFlow {
|
||||
position: static;
|
||||
margin-top: 6px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
.noneOption {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
text-align: start;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.noneOption:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.noneOptionSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
text-align: start;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.rowSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.rowSelected:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.16);
|
||||
}
|
||||
|
||||
.toggleBtn {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toggleBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.toggleSpacer {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.selectBtn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
text-align: start;
|
||||
color: inherit;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nameSecondary {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rowSelected .nameSecondary {
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 16px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Minus, Plus, Search, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import treeStyles from './CategoryTree.module.css'
|
||||
import styles from './CategoryTreeSelect.module.css'
|
||||
|
||||
/** Minimal category shape for the shared tree picker. */
|
||||
export interface CategoryTreeItem {
|
||||
id: string
|
||||
parentId: string | null
|
||||
nameEn: string
|
||||
nameFa: string
|
||||
}
|
||||
|
||||
interface CategoryTreeSelectProps {
|
||||
categories: CategoryTreeItem[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
/** Show empty option + clear control. */
|
||||
allowEmpty?: boolean
|
||||
/** Label for the empty option when `allowEmpty` is true. */
|
||||
emptyLabel?: string
|
||||
/** Overlay floats over content; inFlow expands the parent. */
|
||||
dropdownVariant?: 'overlay' | 'inFlow'
|
||||
}
|
||||
|
||||
function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
|
||||
return categories
|
||||
.filter((category) => category.parentId === parentId)
|
||||
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
|
||||
}
|
||||
|
||||
function getAncestorIds(categories: CategoryTreeItem[], id: string) {
|
||||
const byId = new Map(categories.map((category) => [category.id, category]))
|
||||
const ids: string[] = []
|
||||
let current = byId.get(id)
|
||||
|
||||
while (current?.parentId) {
|
||||
ids.push(current.parentId)
|
||||
current = byId.get(current.parentId)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
function categoryLabel(category: CategoryTreeItem, 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: CategoryTreeItem, 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: CategoryTreeItem[],
|
||||
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: CategoryTreeItem[]
|
||||
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)}
|
||||
>
|
||||
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={styles.toggleSpacer} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.selectBtn}
|
||||
onClick={() => onSelect(category.id)}
|
||||
>
|
||||
<span
|
||||
className={styles.name}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
>
|
||||
{primary}
|
||||
</span>
|
||||
{secondary ? (
|
||||
<span
|
||||
className={styles.nameSecondary}
|
||||
dir={isFa ? 'ltr' : 'rtl'}
|
||||
lang={isFa ? 'en' : 'fa'}
|
||||
>
|
||||
· {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,
|
||||
allowEmpty = false,
|
||||
emptyLabel,
|
||||
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 emptyOptionLabel = emptyLabel ?? t('categories.select.none')
|
||||
|
||||
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(toggleId: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(toggleId)) next.delete(toggleId)
|
||||
else next.add(toggleId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectOption(nextId: string) {
|
||||
onChange(nextId)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const searching = query.trim().length > 0
|
||||
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
|
||||
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"
|
||||
/>
|
||||
{allowEmpty && 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 : styles.panelOverlay,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{allowEmpty ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
|
||||
onClick={() => selectOption('')}
|
||||
>
|
||||
{emptyOptionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
@@ -396,6 +396,13 @@ const en = {
|
||||
'otp.signingIn': 'Signing in...',
|
||||
'otp.error.signIn': 'Unable to sign in with SMS code.',
|
||||
|
||||
'categories.tooltip.expand': 'Expand',
|
||||
'categories.tooltip.collapse': 'Collapse',
|
||||
'categories.select.search': 'Search categories…',
|
||||
'categories.select.none': '— No category —',
|
||||
'categories.select.empty': 'No categories found',
|
||||
'categories.select.clear': 'Clear selection',
|
||||
|
||||
'common.close': 'Close',
|
||||
'common.resendIn': 'Resend code in {seconds}s',
|
||||
'common.resend': 'Resend SMS code',
|
||||
@@ -800,6 +807,13 @@ const fa: Record<MessageKey, string> = {
|
||||
'otp.signingIn': 'در حال ورود...',
|
||||
'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
|
||||
|
||||
'categories.tooltip.expand': 'باز کردن',
|
||||
'categories.tooltip.collapse': 'بستن',
|
||||
'categories.select.search': 'جستجوی دستهبندیها…',
|
||||
'categories.select.none': '— بدون دستهبندی —',
|
||||
'categories.select.empty': 'دستهبندیای یافت نشد',
|
||||
'categories.select.clear': 'پاک کردن انتخاب',
|
||||
|
||||
'common.close': 'بستن',
|
||||
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
|
||||
'common.resend': 'ارسال مجدد کد پیامکی',
|
||||
|
||||
Reference in New Issue
Block a user