@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
JavaScript
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