@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
273 lines (272 loc) • 9.7 kB
JavaScript
"use client";
import Icon from "../../Icon/Icon.mjs";
import { Tooltip } from "../../base-ui/Tooltip/Tooltip.mjs";
import ScrollShadow from "../../ScrollShadow/ScrollShadow.mjs";
import { collectNavItems, findActiveBranch, resolveActiveHref } from "./active.mjs";
import { useConsoleShellState } from "../ConsoleShell/context.mjs";
import { useLocalStorage, usePrefersReducedMotion } from "../hooks.mjs";
import { styles } from "./style.mjs";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
import { ChevronDown, Play } from "lucide-react";
//#region src/dashboard/ConsoleNav/ConsoleNav.tsx
const PANEL_TRANSITION_MS = 160;
const RAIL_TRANSITION_MS = 200;
/** Older builds stored the closed group keys as an array. */
function readOverrides(value) {
if (Array.isArray(value)) return Object.fromEntries(value.map((key) => [String(key), false]));
return value && typeof value === "object" ? value : {};
}
function scrollActiveIntoView(container, behavior = "auto") {
const link = container.querySelector("[aria-current=\"page\"]") ?? [...container.querySelectorAll("button[data-active=\"true\"]")].at(-1);
if (!link) return;
const containerRect = container.getBoundingClientRect();
const linkRect = link.getBoundingClientRect();
if (linkRect.top >= containerRect.top && linkRect.bottom <= containerRect.bottom) return;
container.scrollBy({
behavior,
top: linkRect.top - containerRect.top - (containerRect.height - linkRect.height) / 2
});
}
function renderAnchor(context, linkProps, onFollow) {
if (context.renderLink) return context.renderLink(linkProps);
return /* @__PURE__ */ jsx("a", {
"aria-current": linkProps["aria-current"],
"aria-label": linkProps["aria-label"],
className: linkProps.className,
"data-active": linkProps["data-active"],
"data-collapsed": linkProps["data-collapsed"],
"data-indent": linkProps["data-indent"],
href: linkProps.href,
rel: linkProps.external ? "noreferrer" : void 0,
target: linkProps.external ? "_blank" : void 0,
title: linkProps.title,
onClick: (event) => {
if (context.onNavigate && !linkProps.external) event.preventDefault();
onFollow();
},
children: linkProps.children
});
}
function NavLink({ active, collapsed, context, href, icon, indent = 0, item, name, overlay = false }) {
const shell = useConsoleShellState();
const label = item?.label ?? name;
const follow = () => {
context.onNavigate?.(href);
shell?.closeNavigation();
};
const link = renderAnchor(context, {
"aria-current": active ? "page" : void 0,
"aria-label": collapsed ? name : void 0,
"children": overlay ? null : /* @__PURE__ */ jsxs(Fragment$1, { children: [
icon ? /* @__PURE__ */ jsx(Icon, {
icon,
size: 18
}) : null,
/* @__PURE__ */ jsx("span", {
className: styles.itemLabel,
children: label
}),
item?.badge ? /* @__PURE__ */ jsx("span", {
className: styles.badge,
children: item.badge
}) : null,
item?.badge ? /* @__PURE__ */ jsx("span", {
"aria-hidden": true,
className: styles.dot
}) : null
] }),
"className": overlay ? styles.railLink : styles.item,
"data-active": active,
"data-collapsed": collapsed,
"data-indent": indent,
"external": item?.external,
href,
"onClick": follow,
"title": collapsed ? void 0 : label
}, follow);
if (!icon && !overlay) return link;
return /* @__PURE__ */ jsx(Tooltip, {
disabled: !collapsed,
placement: "right",
title: name,
children: link
});
}
function Fold({ children, folded }) {
return /* @__PURE__ */ jsx("div", {
"aria-hidden": folded || void 0,
className: styles.fold,
"data-folded": folded,
inert: folded || void 0,
children: /* @__PURE__ */ jsx("div", { children })
});
}
function ConsoleNav({ collapsed: collapsedProp, defaultExpanded = "all", groups = [], items = [], label = "Primary", onNavigate, pathname, renderLink, storageKey }) {
const id = useId();
const navRef = useRef(null);
const shell = useConsoleShellState();
const collapsed = collapsedProp ?? shell?.collapsed ?? false;
const reducedMotion = usePrefersReducedMotion();
const [storedOverrides, setStoredOverrides] = useLocalStorage(storageKey, {});
const overrides = readOverrides(storedOverrides);
const context = {
onNavigate,
renderLink
};
const activeHref = resolveActiveHref(pathname, [...items, ...collectNavItems(groups)]);
const activeBranch = findActiveBranch(pathname, groups, activeHref);
const [trackedPathname, setTrackedPathname] = useState(pathname);
if (trackedPathname !== pathname) {
setTrackedPathname(pathname);
if (activeBranch.some((key) => overrides[key] === false)) {
const next = { ...overrides };
for (const key of activeBranch) delete next[key];
setStoredOverrides(next);
}
}
const isExpanded = (group) => overrides[group.key] ?? (activeBranch.includes(group.key) || (group.defaultExpanded ?? defaultExpanded === "all"));
const toggleGroup = useCallback((key, expanded) => {
setStoredOverrides((current) => ({
...readOverrides(current),
[key]: !expanded
}));
}, [setStoredOverrides]);
useEffect(() => {
const container = navRef.current;
if (!container) return;
scrollActiveIntoView(container);
if (reducedMotion) return;
const timer = window.setTimeout(() => scrollActiveIntoView(container), PANEL_TRANSITION_MS);
return () => window.clearTimeout(timer);
}, [
activeHref,
pathname,
reducedMotion
]);
useEffect(() => {
const container = navRef.current;
if (!container) return;
const timer = window.setTimeout(() => scrollActiveIntoView(container, reducedMotion ? "auto" : "smooth"), reducedMotion ? 0 : RAIL_TRANSITION_MS);
return () => window.clearTimeout(timer);
}, [collapsed, reducedMotion]);
const renderItems = (list, indent, groupLabel, foldable) => list.map((item) => {
const link = /* @__PURE__ */ jsx(NavLink, {
active: item.href === activeHref,
collapsed,
context,
href: item.href,
icon: item.icon,
indent: item.icon ? Math.max(0, indent - 1) : indent,
item,
name: groupLabel ? `${groupLabel} / ${item.label}` : item.label
}, item.href);
if (item.icon || !foldable) return link;
return /* @__PURE__ */ jsx(Fold, {
folded: collapsed,
children: link
}, item.href);
});
const renderGroup = (group, level, indent, underRailIcon) => {
const expanded = isExpanded(group);
const panelId = `${id}-${group.key}`;
const childIndent = indent + (group.icon ? 1 : 0);
const active = activeBranch.includes(group.key);
const railIcon = level === 0 && Boolean(group.icon);
const onRail = collapsed && !underRailIcon;
const open = onRail ? !railIcon : expanded;
const railHref = railIcon ? group.href ?? collectNavItems([group])[0]?.href : void 0;
const railEmpty = !railIcon && !collectNavItems([group]).some((item) => item.icon);
const header = /* @__PURE__ */ jsxs("button", {
"aria-controls": panelId,
"aria-expanded": open,
"aria-hidden": onRail && railIcon || void 0,
className: styles.groupHeader,
"data-active": active,
"data-icon": Boolean(group.icon),
"data-indent": indent,
"data-level": level,
tabIndex: onRail && railIcon ? -1 : void 0,
type: "button",
onClick: () => toggleGroup(group.key, expanded),
children: [
group.icon ? /* @__PURE__ */ jsx(Icon, {
icon: group.icon,
size: 16
}) : null,
/* @__PURE__ */ jsx("span", {
className: styles.itemLabel,
"data-label": "",
children: group.label
}),
level === 0 ? /* @__PURE__ */ jsx(Icon, {
className: styles.chevron,
"data-expanded": expanded,
icon: ChevronDown,
size: 14
}) : /* @__PURE__ */ jsx("span", {
className: styles.indicator,
"data-expanded": expanded,
children: /* @__PURE__ */ jsx(Play, {
fill: "currentColor",
size: 7,
strokeWidth: 1
})
})
]
});
let head = header;
if (railIcon) head = /* @__PURE__ */ jsxs("div", {
className: styles.groupHead,
children: [header, onRail && railHref ? /* @__PURE__ */ jsx(NavLink, {
collapsed: true,
overlay: true,
active: activeBranch[0] === group.key,
context,
href: railHref,
name: group.label
}) : null]
});
else if (!underRailIcon) head = /* @__PURE__ */ jsx(Fold, {
folded: onRail,
children: header
});
return /* @__PURE__ */ jsxs("div", {
className: styles.group,
"data-icon": Boolean(group.icon),
"data-level": level,
"data-rail-empty": railEmpty,
children: [head, /* @__PURE__ */ jsx("div", {
"aria-hidden": !open,
className: styles.groupPanel,
"data-expanded": open,
"data-instant": reducedMotion,
id: panelId,
inert: open ? void 0 : true,
children: /* @__PURE__ */ jsxs("div", {
className: styles.groupItems,
children: [renderItems(group.items ?? [], childIndent, group.label, !(underRailIcon || railIcon)), (group.groups ?? []).map((child) => renderGroup(child, level + 1, childIndent, underRailIcon || railIcon))]
})
})]
}, group.key);
};
return /* @__PURE__ */ jsxs(ScrollShadow, {
"aria-label": label,
as: "nav",
className: styles.nav,
"data-collapsed": collapsed,
orientation: "vertical",
ref: navRef,
size: 4,
children: [items.length > 0 ? /* @__PURE__ */ jsx("div", {
className: styles.topItems,
"data-divided": groups.length > 0,
"data-rail-empty": !items.some((item) => item.icon),
children: renderItems(items, 0, void 0, true)
}) : null, groups.map((group) => renderGroup(group, 0, 0, false))]
});
}
ConsoleNav.displayName = "ConsoleNav";
//#endregion
export { ConsoleNav as default };
//# sourceMappingURL=ConsoleNav.mjs.map