From 9cabee4d6d88397970a00fa76dc5466bbd17a372 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 30 May 2026 20:00:44 +0800 Subject: [PATCH] feat: implement collapsible sidebar navigation with icons and active item detection --- web/components/app-sidebar.tsx | 1 + web/components/nav-main.tsx | 79 +++++++++++++++++++----------- web/components/ui/collapsible.tsx | 21 ++++++++ web/lib/navigation-active.test.mjs | 65 ++++++++++++++++++++++++ web/lib/navigation-active.ts | 24 +++++++++ web/lib/navigation.tsx | 8 ++- 6 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 web/components/ui/collapsible.tsx create mode 100644 web/lib/navigation-active.test.mjs create mode 100644 web/lib/navigation-active.ts diff --git a/web/components/app-sidebar.tsx b/web/components/app-sidebar.tsx index 4205c04..e8dba5b 100644 --- a/web/components/app-sidebar.tsx +++ b/web/components/app-sidebar.tsx @@ -65,6 +65,7 @@ export function AppSidebar({ ...props }: ComponentProps) { {navSections.map((section) => ( ({ ...item, diff --git a/web/components/nav-main.tsx b/web/components/nav-main.tsx index 6165ef3..f32da46 100644 --- a/web/components/nav-main.tsx +++ b/web/components/nav-main.tsx @@ -1,21 +1,35 @@ "use client" +import { ChevronRightIcon } from "lucide-react" import Link from "next/link" import { usePathname } from "next/navigation" +import { useEffect, useState } from "react" import { - SidebarGroupLabel, + dashboardNavSectionHasActiveItem, + isDashboardNavItemActive, +} from "@/lib/navigation-active" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { SidebarGroup, - SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, } from "@/components/ui/sidebar" export function NavMain({ + icon, title, items, }: { + icon?: React.ReactNode title: string items: ReadonlyArray<{ title: string @@ -24,38 +38,45 @@ export function NavMain({ }> }) { const pathname = usePathname() + const hasActiveItem = dashboardNavSectionHasActiveItem(items, pathname) + const [open, setOpen] = useState(true) - const isActive = (itemUrl: string) => { - const normalizePath = (path: string) => - path !== "/" ? path.replace(/\/+$/, "") : path - const currentPath = normalizePath(pathname) - const targetPath = normalizePath(itemUrl) - - if (targetPath === "/" || targetPath === "/dashboard") { - return currentPath === targetPath + useEffect(() => { + if (hasActiveItem) { + setOpen(true) } - return currentPath === targetPath || currentPath.startsWith(targetPath + "/") - } + }, [hasActiveItem]) return ( - {title} - - - {items.map((item) => ( - - } - isActive={isActive(item.url)} - > - {item.icon} - {item.title} - - - ))} - - + + } + > + }> + {icon} + {title} + + + + + {items.map((item) => ( + + } + isActive={isDashboardNavItemActive(pathname, item.url)} + > + {item.title} + + + ))} + + + + ) } diff --git a/web/components/ui/collapsible.tsx b/web/components/ui/collapsible.tsx new file mode 100644 index 0000000..488fb33 --- /dev/null +++ b/web/components/ui/collapsible.tsx @@ -0,0 +1,21 @@ +"use client" + +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" + +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return +} + +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + + ) +} + +function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/web/lib/navigation-active.test.mjs b/web/lib/navigation-active.test.mjs new file mode 100644 index 0000000..56efa9c --- /dev/null +++ b/web/lib/navigation-active.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import ts from "typescript" +import { readFile } from "node:fs/promises" +import vm from "node:vm" + +async function loadModule() { + const source = await readFile( + new URL("./navigation-active.ts", import.meta.url), + "utf8" + ) + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.CommonJS, + }, + fileName: "navigation-active.ts", + }) + const sandbox = { + exports: {}, + module: { exports: {} }, + } + sandbox.exports = sandbox.module.exports + vm.runInNewContext(compiled.outputText, sandbox) + return sandbox.module.exports +} + +describe("isDashboardNavItemActive", () => { + it("matches exact dashboard paths and nested child routes", async () => { + const { isDashboardNavItemActive } = await loadModule() + + assert.equal(isDashboardNavItemActive("/dashboard/tickets", "/dashboard/tickets"), true) + assert.equal( + isDashboardNavItemActive("/dashboard/tickets/123", "/dashboard/tickets"), + true + ) + assert.equal( + isDashboardNavItemActive("/dashboard/tickets-extra", "/dashboard/tickets"), + false + ) + }) + + it("only marks the dashboard home item active on the exact dashboard path", async () => { + const { isDashboardNavItemActive } = await loadModule() + + assert.equal(isDashboardNavItemActive("/dashboard", "/dashboard"), true) + assert.equal(isDashboardNavItemActive("/dashboard/tickets", "/dashboard"), false) + }) +}) + +describe("dashboardNavSectionHasActiveItem", () => { + it("detects whether a sidebar group contains the current route", async () => { + const { dashboardNavSectionHasActiveItem } = await loadModule() + const items = [ + { url: "/dashboard/tags" }, + { url: "/dashboard/quick-replies" }, + ] + + assert.equal( + dashboardNavSectionHasActiveItem(items, "/dashboard/quick-replies/create"), + true + ) + assert.equal(dashboardNavSectionHasActiveItem(items, "/dashboard/users"), false) + }) +}) diff --git a/web/lib/navigation-active.ts b/web/lib/navigation-active.ts new file mode 100644 index 0000000..bbd12fb --- /dev/null +++ b/web/lib/navigation-active.ts @@ -0,0 +1,24 @@ +export type DashboardNavActiveItem = { + url: string +} + +function normalizePath(path: string) { + return path !== "/" ? path.replace(/\/+$/, "") : path +} + +export function isDashboardNavItemActive(pathname: string, itemUrl: string) { + const currentPath = normalizePath(pathname) + const targetPath = normalizePath(itemUrl) + + if (targetPath === "/" || targetPath === "/dashboard") { + return currentPath === targetPath + } + return currentPath === targetPath || currentPath.startsWith(targetPath + "/") +} + +export function dashboardNavSectionHasActiveItem( + items: ReadonlyArray, + pathname: string +) { + return items.some((item) => isDashboardNavItemActive(pathname, item.url)) +} diff --git a/web/lib/navigation.tsx b/web/lib/navigation.tsx index ec5758a..e5f6abe 100644 --- a/web/lib/navigation.tsx +++ b/web/lib/navigation.tsx @@ -37,6 +37,7 @@ export type DashboardNavItemConfig = Omit & { export type DashboardNavSectionConfig = { titleKey: string; + icon: ReactNode; items: DashboardNavItemConfig[]; }; @@ -57,12 +58,13 @@ function navItemVisible( export function filterDashboardNavForSession( permissions: readonly string[] | undefined, roles: readonly string[] | undefined, -): { titleKey: string; items: DashboardNavMenuItem[] }[] { +): { titleKey: string; icon: ReactNode; items: DashboardNavMenuItem[] }[] { const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false; const permissionSet = new Set(permissions ?? []); return dashboardNavSections .map((section) => ({ titleKey: section.titleKey, + icon: section.icon, items: section.items .filter((item) => navItemVisible(item, superAdmin, permissionSet)) .map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon })), @@ -94,6 +96,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [ // }, { titleKey: "nav.receptionCenter", + icon: , items: [ { titleKey: "nav.overview", @@ -134,6 +137,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [ }, { titleKey: "nav.agentConfig", + icon: , items: [ { titleKey: "nav.tags", @@ -169,6 +173,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [ }, { titleKey: "nav.aiCapabilities", + icon: , items: [ { titleKey: "nav.knowledge", @@ -210,6 +215,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [ }, { titleKey: "nav.system", + icon: , items: [ { titleKey: "nav.users",