Initial commit: dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user