284 lines
8.3 KiB
TypeScript
284 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
categoryLabel,
|
||
type MeshkeeCategory,
|
||
} from "@/services/meshkee-categories";
|
||
import styles from "./CategoryTreeSelect.module.css";
|
||
|
||
type TreeNode = MeshkeeCategory & { children: TreeNode[] };
|
||
|
||
function buildTree(categories: MeshkeeCategory[]): 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) =>
|
||
a.sortOrder - b.sortOrder ||
|
||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||
);
|
||
for (const node of nodes) sortNodes(node.children);
|
||
};
|
||
sortNodes(roots);
|
||
return roots;
|
||
}
|
||
|
||
function matchesQuery(category: MeshkeeCategory, q: string) {
|
||
if (!q) return true;
|
||
const hay = [category.name, category.nameFa ?? "", category.slug, ...category.tags]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
return hay.includes(q);
|
||
}
|
||
|
||
/** Keep ancestors of matches so the tree path stays visible while searching. */
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
type Props = {
|
||
categories: MeshkeeCategory[];
|
||
value: string[];
|
||
onChange: (ids: string[]) => void;
|
||
disabled?: boolean;
|
||
label?: string;
|
||
/** multi (default) or single select */
|
||
selection?: "single" | "multiple";
|
||
/** overlay floats; inFlow expands inside the parent (better in modals) */
|
||
dropdownVariant?: "overlay" | "inFlow";
|
||
placeholder?: string;
|
||
};
|
||
|
||
export default function CategoryTreeSelect({
|
||
categories,
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
label = "Category",
|
||
selection = "multiple",
|
||
dropdownVariant = "overlay",
|
||
placeholder = "Search categories…",
|
||
}: Props) {
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState("");
|
||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||
const single = selection === "single";
|
||
|
||
const tree = useMemo(() => buildTree(categories), [categories]);
|
||
const q = query.trim().toLowerCase();
|
||
const visibleTree = useMemo(() => filterTree(tree, q), [tree, q]);
|
||
|
||
const byId = useMemo(() => {
|
||
const map = new Map<string, MeshkeeCategory>();
|
||
for (const category of categories) map.set(category.id, category);
|
||
return map;
|
||
}, [categories]);
|
||
|
||
const selected = value
|
||
.map((id) => byId.get(id))
|
||
.filter((item): item is MeshkeeCategory => Boolean(item));
|
||
const selectedLabel = selected[0] ? categoryLabel(selected[0]) : "";
|
||
|
||
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);
|
||
if (single) setQuery("");
|
||
}
|
||
}
|
||
function onKey(event: KeyboardEvent) {
|
||
if (event.key === "Escape") {
|
||
setOpen(false);
|
||
if (single) setQuery("");
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", onPointerDown);
|
||
document.addEventListener("keydown", onKey);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onPointerDown);
|
||
document.removeEventListener("keydown", onKey);
|
||
};
|
||
}, [open, single]);
|
||
|
||
function pick(id: string) {
|
||
if (single) {
|
||
onChange([id]);
|
||
setOpen(false);
|
||
setQuery("");
|
||
return;
|
||
}
|
||
const next = value.includes(id)
|
||
? value.filter((item) => item !== id)
|
||
: [...value, id];
|
||
onChange(next);
|
||
}
|
||
|
||
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.includes(node.id);
|
||
|
||
return (
|
||
<li key={node.id} className={styles.node}>
|
||
<div
|
||
className={styles.row}
|
||
style={{ paddingInlineStart: `${8 + depth * 16}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} aria-hidden />
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={`${styles.option} ${active ? styles.optionActive : ""}`}
|
||
disabled={disabled}
|
||
onClick={() => pick(node.id)}
|
||
>
|
||
{!single ? (
|
||
<span className={styles.check} aria-hidden>
|
||
{active ? "✓" : ""}
|
||
</span>
|
||
) : null}
|
||
<span className={styles.optionLabel}>{categoryLabel(node)}</span>
|
||
{node.slug ? (
|
||
<span className={styles.optionSlug}>/{node.slug}</span>
|
||
) : null}
|
||
</button>
|
||
</div>
|
||
{hasChildren && isExpanded ? (
|
||
<ul className={styles.list}>{renderNodes(node.children, depth + 1)}</ul>
|
||
) : null}
|
||
</li>
|
||
);
|
||
});
|
||
}
|
||
|
||
const inputValue = open || !single ? query : selectedLabel;
|
||
|
||
return (
|
||
<div className={styles.root} ref={rootRef}>
|
||
<div className={styles.picker}>
|
||
<label className={styles.field}>
|
||
<span>{label}</span>
|
||
<div className={styles.control}>
|
||
<input
|
||
value={inputValue}
|
||
disabled={disabled}
|
||
placeholder={
|
||
single && selectedLabel && !open ? selectedLabel : placeholder
|
||
}
|
||
aria-expanded={open}
|
||
aria-haspopup="listbox"
|
||
onFocus={() => setOpen(true)}
|
||
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)}
|
||
/>
|
||
</div>
|
||
</label>
|
||
|
||
{open ? (
|
||
<div
|
||
className={`${styles.dropdown} ${
|
||
dropdownVariant === "inFlow" ? styles.dropdownInFlow : ""
|
||
}`}
|
||
role="listbox"
|
||
aria-multiselectable={!single}
|
||
>
|
||
{visibleTree.length ? (
|
||
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
|
||
) : (
|
||
<p className={styles.empty}>No categories found</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{!single && selected.length ? (
|
||
<div className={styles.chips}>
|
||
{selected.map((category) => (
|
||
<button
|
||
key={category.id}
|
||
type="button"
|
||
className={styles.chip}
|
||
disabled={disabled}
|
||
title="Remove category"
|
||
onClick={() => pick(category.id)}
|
||
>
|
||
{categoryLabel(category)}
|
||
<span aria-hidden>×</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|