UNPKG

@flanksource/clicky-ui

Version:

Flanksource Clicky UI — React component library built on shadcn/ui with light/dark and density theming.

237 lines (236 loc) 9.75 kB
import { jsx, Fragment, jsxs } from "react/jsx-runtime"; import { useState, useMemo, createElement } from "react"; import { cn } from "../lib/utils.js"; import { Icon } from "./Icon.js"; import { TreeNode } from "./TreeNode.js"; function countTreeEdges(roots, getChildren) { let total = 0; const stack = [...roots]; while (stack.length > 0) { const node = stack.pop(); const children = getChildren(node) ?? []; total += children.length; stack.push(...children); } return total; } function collectTreeSearchText(value, excluded = /* @__PURE__ */ new Set(), seen = /* @__PURE__ */ new Set()) { if (value == null || excluded.has(value)) return ""; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { return String(value); } if (typeof value !== "object") return ""; if (seen.has(value)) return ""; seen.add(value); if (Array.isArray(value)) { return value.map((entry) => collectTreeSearchText(entry, excluded, seen)).filter(Boolean).join(" "); } const preferredKeys = ["label", "name", "title", "text", "plain", "id", "content"]; const record = value; const chunks = []; for (const key of preferredKeys) { if (!(key in record)) continue; const text = collectTreeSearchText(record[key], excluded, seen); if (text) chunks.push(text); } for (const [key, entry] of Object.entries(record)) { if (key === "children" || preferredKeys.includes(key)) continue; const text = collectTreeSearchText(entry, excluded, seen); if (text) chunks.push(text); } return chunks.join(" "); } function nodeSearchText(node, children) { const excluded = /* @__PURE__ */ new Set(); if (children) excluded.add(children); return collectTreeSearchText(node, excluded).trim().toLowerCase(); } function filterTreeRoots(roots, getChildren, getKey, query, isSecondary, getSearchText) { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { return { roots, filteredChildren: null, forcedOpenKeys: null }; } const filteredChildren = /* @__PURE__ */ new Map(); const forcedOpenKeys = /* @__PURE__ */ new Set(); function matchText(node, children) { if (getSearchText) return getSearchText(node).trim().toLowerCase(); const searchChildren = children.filter((c) => !(isSecondary == null ? void 0 : isSecondary(c))); return nodeSearchText(node, searchChildren); } function visit(node) { if (isSecondary == null ? void 0 : isSecondary(node)) return false; const key = getKey(node); const children = getChildren(node) ?? []; const childrenAllSecondary = children.length > 0 && children.every((child) => isSecondary == null ? void 0 : isSecondary(child)); const matches = matchText(node, children).includes(normalizedQuery); const visibleChildren = []; for (const child of children) { if (visit(child)) visibleChildren.push(child); } if (matches) { if (children.length > 0) { filteredChildren.set(key, children); if (!childrenAllSecondary) forcedOpenKeys.add(key); } return true; } if (visibleChildren.length > 0) { filteredChildren.set(key, visibleChildren); forcedOpenKeys.add(key); return true; } return false; } return { roots: roots.filter((root) => visit(root)), filteredChildren, forcedOpenKeys }; } function Tree({ roots, empty, className, showControls = true, expandAll: controlledExpandAll, onExpandAllChange, toolbarClassName, getSearchText, ...nodeProps }) { const [internalExpandAll, setInternalExpandAll] = useState(null); const [filterQuery, setFilterQuery] = useState(""); const basePaddingPx = nodeProps.basePaddingPx ?? 8; const isControlled = onExpandAllChange !== void 0; const expandAll = isControlled ? controlledExpandAll ?? null : internalExpandAll; const totalEdges = useMemo( () => countTreeEdges(roots, nodeProps.getChildren), [roots, nodeProps.getChildren] ); const showFilter = totalEdges > 20; const activeFilter = showFilter ? filterQuery : ""; const filteredTree = useMemo( () => filterTreeRoots( roots, nodeProps.getChildren, nodeProps.getKey, activeFilter, nodeProps.isSecondary, getSearchText ), [ roots, nodeProps.getChildren, nodeProps.getKey, activeFilter, nodeProps.isSecondary, getSearchText ] ); const treeRoots = filteredTree.roots; const filteredChildren = filteredTree.filteredChildren; const forcedOpenKeys = filteredTree.forcedOpenKeys; const effectiveGetChildren = filteredChildren ? (node) => filteredChildren.get(nodeProps.getKey(node)) ?? [] : nodeProps.getChildren; const effectiveExpandAll = expandAll; const effectiveEmpty = activeFilter.trim() ? /* @__PURE__ */ jsx("div", { className: "px-3 py-4 text-sm text-muted-foreground", children: "No matching tree nodes." }) : empty; const showControlButtons = showControls && roots.length > 0; const showVirtualRow = showFilter || showControlButtons; const setExpandAll = (next) => { if (isControlled) onExpandAllChange == null ? void 0 : onExpandAllChange(next); else setInternalExpandAll(next); }; if (treeRoots.length === 0 && !showVirtualRow) return /* @__PURE__ */ jsx(Fragment, { children: effectiveEmpty ?? null }); return /* @__PURE__ */ jsx("div", { className: cn("flex flex-col min-h-0", className), children: /* @__PURE__ */ jsxs("div", { role: "tree", className: "min-h-0 flex-1 overflow-auto", children: [ showVirtualRow && /* @__PURE__ */ jsxs( "div", { role: "presentation", className: cn( "sticky top-0 z-10 flex items-center gap-1.5 border-b border-border/70 bg-background/95 py-1 pr-2 text-sm backdrop-blur supports-[backdrop-filter]:bg-background/80", toolbarClassName ), style: { paddingLeft: `${basePaddingPx}px` }, children: [ showFilter ? /* @__PURE__ */ jsx(Icon, { name: "codicon:search", className: "w-3 shrink-0 text-xs text-muted-foreground" }) : /* @__PURE__ */ jsx("span", { className: "w-3 shrink-0", "aria-hidden": true }), showFilter ? /* @__PURE__ */ jsxs("label", { className: "flex min-w-0 flex-1 items-center gap-2 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground shadow-sm", children: [ /* @__PURE__ */ jsx( "input", { type: "search", value: filterQuery, onChange: (event) => setFilterQuery(event.target.value), placeholder: "Filter tree", className: "h-5 w-full border-0 bg-transparent p-0 text-xs outline-none placeholder:text-muted-foreground", "aria-label": "Filter tree nodes" } ), filterQuery && /* @__PURE__ */ jsx( "button", { type: "button", onClick: () => setFilterQuery(""), className: "inline-flex items-center rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground", "aria-label": "Clear tree filter", title: "Clear tree filter", children: /* @__PURE__ */ jsx(Icon, { name: "codicon:close", className: "text-xs" }) } ) ] }) : /* @__PURE__ */ jsx("span", { className: "flex-1" }), showControlButtons && /* @__PURE__ */ jsxs("div", { className: "ml-auto flex items-center gap-1", children: [ /* @__PURE__ */ jsx( "button", { type: "button", onClick: () => setExpandAll(true), "aria-pressed": effectiveExpandAll === true, "aria-label": "Expand all", title: "Expand all", disabled: activeFilter.trim().length > 0, className: cn( "inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50", effectiveExpandAll === true && "bg-accent text-accent-foreground" ), children: /* @__PURE__ */ jsx(Icon, { name: "codicon:expand-all", className: "text-sm" }) } ), /* @__PURE__ */ jsx( "button", { type: "button", onClick: () => setExpandAll(false), "aria-pressed": effectiveExpandAll === false, "aria-label": "Collapse all", title: "Collapse all", disabled: activeFilter.trim().length > 0, className: cn( "inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50", effectiveExpandAll === false && "bg-accent text-accent-foreground" ), children: /* @__PURE__ */ jsx(Icon, { name: "codicon:collapse-all", className: "text-sm" }) } ) ] }) ] } ), treeRoots.length === 0 ? effectiveEmpty ?? null : /* @__PURE__ */ jsx(Fragment, { children: treeRoots.map((root) => /* @__PURE__ */ createElement( TreeNode, { ...nodeProps, key: nodeProps.getKey(root), node: root, expandAll: effectiveExpandAll, forcedOpenKeys, getChildren: effectiveGetChildren } )) }) ] }) }); } export { Tree }; //# sourceMappingURL=Tree.js.map