Files
dashboards/components/catalog/CatalogCategorySelect.tsx
T
2026-10-03 23:17:36 +03:30

249 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
categoryOptionLabel,
type MeshkeeEntityCategory,
} from "@/services/meshkee-catalog";
import styles from "./CatalogCategorySelect.module.css";
type TreeNode = MeshkeeEntityCategory & { children: TreeNode[] };
function buildTree(categories: MeshkeeEntityCategory[]): TreeNode[] {
const byId = new Map<string, TreeNode>();
for (const category of categories) {
byId.set(category.id, { ...category, children: [] });
}
const roots: TreeNode[] = [];
for (const node of byId.values()) {
if (node.parentId && byId.has(node.parentId)) {
byId.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: TreeNode[]) => {
nodes.sort((a, b) =>
categoryOptionLabel(a).localeCompare(categoryOptionLabel(b), undefined, {
sensitivity: "base",
}),
);
for (const node of nodes) sortNodes(node.children);
};
sortNodes(roots);
return roots;
}
function matchesQuery(category: MeshkeeEntityCategory, q: string) {
if (!q) return true;
const hay = [category.name, category.nameFa ?? "", category.description ?? ""]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
if (!q) return nodes;
const result: TreeNode[] = [];
for (const node of nodes) {
const children = filterTree(node.children, q);
if (matchesQuery(node, q) || children.length) {
result.push({ ...node, children });
}
}
return result;
}
function collectExpandIds(nodes: TreeNode[], into: Set<string>) {
for (const node of nodes) {
if (node.children.length) {
into.add(node.id);
collectExpandIds(node.children, into);
}
}
}
export default function CatalogCategorySelect({
categories,
value,
onChange,
disabled = false,
label = "Category",
optional = false,
placeholder = "Select category",
}: {
categories: MeshkeeEntityCategory[];
value: string;
onChange: (id: string) => void;
disabled?: boolean;
label?: string;
optional?: boolean;
placeholder?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const tree = useMemo(() => buildTree(categories), [categories]);
const q = query.trim().toLowerCase();
const visibleTree = useMemo(() => filterTree(tree, q), [tree, q]);
const selected = useMemo(
() => categories.find((item) => item.id === value) ?? null,
[categories, value],
);
useEffect(() => {
if (!q) return;
const next = new Set<string>();
collectExpandIds(visibleTree, next);
setExpanded(next);
}, [q, visibleTree]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
setQuery("");
}
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function select(id: string) {
onChange(id);
setOpen(false);
setQuery("");
}
function toggleExpand(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function renderNodes(nodes: TreeNode[], depth: number) {
return nodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = q ? true : expanded.has(node.id);
const active = value === node.id;
return (
<li key={node.id} className={styles.node}>
<div
className={`${styles.row}${active ? ` ${styles.rowActive}` : ""}`}
style={{ paddingInlineStart: `${10 + depth * 18}px` }}
>
{hasChildren ? (
<button
type="button"
className={`${styles.expand} ${isExpanded ? styles.expandOpen : ""}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
onClick={() => toggleExpand(node.id)}
>
<span aria-hidden>{isExpanded ? "−" : "+"}</span>
</button>
) : (
<span
className={`${styles.expandSpacer}${depth > 0 ? ` ${styles.leafMark}` : ""}`}
aria-hidden
/>
)}
<button
type="button"
className={styles.option}
disabled={disabled}
onClick={() => select(node.id)}
>
<span className={styles.optionLabel} dir="auto">
{categoryOptionLabel(node)}
</span>
</button>
</div>
{hasChildren && isExpanded ? (
<ul className={styles.branch}>{renderNodes(node.children, depth + 1)}</ul>
) : null}
</li>
);
});
}
return (
<div className={styles.root} ref={rootRef}>
<span className={styles.label}>
{label}
{optional ? (
<em className={styles.optional}> (optional)</em>
) : null}
</span>
<div className={styles.control}>
<input
value={open ? query : selected ? categoryOptionLabel(selected) : ""}
disabled={disabled}
placeholder={placeholder}
aria-expanded={open}
aria-haspopup="listbox"
onFocus={() => {
setOpen(true);
setQuery("");
}}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onClick={() => setOpen(true)}
/>
<button
type="button"
className={`${styles.caret} ${open ? styles.caretOpen : ""}`}
aria-label={open ? "Close categories" : "Open categories"}
disabled={disabled}
onClick={() => {
setOpen((prev) => !prev);
setQuery("");
}}
/>
</div>
{open ? (
<div className={styles.dropdown} role="listbox">
{optional ? (
<button
type="button"
className={`${styles.clearOption}${!value ? ` ${styles.rowActive}` : ""}`}
disabled={disabled}
onClick={() => select("")}
>
No category
</button>
) : null}
{visibleTree.length ? (
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
) : (
<p className={styles.empty}>No categories found</p>
)}
</div>
) : null}
</div>
);
}