Files
dashboards/components/CategoryTreeSelect.tsx
T

284 lines
8.3 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 {
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>
);
}