feat: implement collapsible sidebar navigation with icons and active item detection

This commit is contained in:
mlogclub
2026-05-30 20:00:44 +08:00
parent 86ff71596d
commit 9cabee4d6d
6 changed files with 168 additions and 30 deletions
+1
View File
@@ -65,6 +65,7 @@ export function AppSidebar({ ...props }: ComponentProps<typeof Sidebar>) {
{navSections.map((section) => ( {navSections.map((section) => (
<NavMain <NavMain
key={section.titleKey} key={section.titleKey}
icon={section.icon}
title={t(section.titleKey)} title={t(section.titleKey)}
items={section.items.map((item) => ({ items={section.items.map((item) => ({
...item, ...item,
+50 -29
View File
@@ -1,21 +1,35 @@
"use client" "use client"
import { ChevronRightIcon } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useEffect, useState } from "react"
import { import {
SidebarGroupLabel, dashboardNavSectionHasActiveItem,
isDashboardNavItemActive,
} from "@/lib/navigation-active"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
SidebarGroup, SidebarGroup,
SidebarGroupContent,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
export function NavMain({ export function NavMain({
icon,
title, title,
items, items,
}: { }: {
icon?: React.ReactNode
title: string title: string
items: ReadonlyArray<{ items: ReadonlyArray<{
title: string title: string
@@ -24,38 +38,45 @@ export function NavMain({
}> }>
}) { }) {
const pathname = usePathname() const pathname = usePathname()
const hasActiveItem = dashboardNavSectionHasActiveItem(items, pathname)
const [open, setOpen] = useState(true)
const isActive = (itemUrl: string) => { useEffect(() => {
const normalizePath = (path: string) => if (hasActiveItem) {
path !== "/" ? path.replace(/\/+$/, "") : path setOpen(true)
const currentPath = normalizePath(pathname)
const targetPath = normalizePath(itemUrl)
if (targetPath === "/" || targetPath === "/dashboard") {
return currentPath === targetPath
} }
return currentPath === targetPath || currentPath.startsWith(targetPath + "/") }, [hasActiveItem])
}
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel> <SidebarMenu>
<SidebarGroupContent> <Collapsible
<SidebarMenu> open={open}
{items.map((item) => ( onOpenChange={setOpen}
<SidebarMenuItem key={item.title}> className="group/collapsible"
<SidebarMenuButton render={<SidebarMenuItem />}
tooltip={item.title} >
render={<Link href={item.url} />} <CollapsibleTrigger render={<SidebarMenuButton tooltip={title} />}>
isActive={isActive(item.url)} {icon}
> <span>{title}</span>
{item.icon} <ChevronRightIcon className="ml-auto transition-transform duration-200 group-data-open/collapsible:rotate-90" />
<span>{item.title}</span> </CollapsibleTrigger>
</SidebarMenuButton> <CollapsibleContent>
</SidebarMenuItem> <SidebarMenuSub>
))} {items.map((item) => (
</SidebarMenu> <SidebarMenuSubItem key={item.title}>
</SidebarGroupContent> <SidebarMenuSubButton
render={<Link href={item.url} />}
isActive={isDashboardNavItemActive(pathname, item.url)}
>
<span>{item.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenu>
</SidebarGroup> </SidebarGroup>
) )
} }
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+65
View File
@@ -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)
})
})
+24
View File
@@ -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<DashboardNavActiveItem>,
pathname: string
) {
return items.some((item) => isDashboardNavItemActive(pathname, item.url))
}
+7 -1
View File
@@ -37,6 +37,7 @@ export type DashboardNavItemConfig = Omit<DashboardNavMenuItem, "title"> & {
export type DashboardNavSectionConfig = { export type DashboardNavSectionConfig = {
titleKey: string; titleKey: string;
icon: ReactNode;
items: DashboardNavItemConfig[]; items: DashboardNavItemConfig[];
}; };
@@ -57,12 +58,13 @@ function navItemVisible(
export function filterDashboardNavForSession( export function filterDashboardNavForSession(
permissions: readonly string[] | undefined, permissions: readonly string[] | undefined,
roles: 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 superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
const permissionSet = new Set(permissions ?? []); const permissionSet = new Set(permissions ?? []);
return dashboardNavSections return dashboardNavSections
.map((section) => ({ .map((section) => ({
titleKey: section.titleKey, titleKey: section.titleKey,
icon: section.icon,
items: section.items items: section.items
.filter((item) => navItemVisible(item, superAdmin, permissionSet)) .filter((item) => navItemVisible(item, superAdmin, permissionSet))
.map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon })), .map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon })),
@@ -94,6 +96,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
// }, // },
{ {
titleKey: "nav.receptionCenter", titleKey: "nav.receptionCenter",
icon: <BotMessageSquareIcon />,
items: [ items: [
{ {
titleKey: "nav.overview", titleKey: "nav.overview",
@@ -134,6 +137,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
}, },
{ {
titleKey: "nav.agentConfig", titleKey: "nav.agentConfig",
icon: <UserCogIcon />,
items: [ items: [
{ {
titleKey: "nav.tags", titleKey: "nav.tags",
@@ -169,6 +173,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
}, },
{ {
titleKey: "nav.aiCapabilities", titleKey: "nav.aiCapabilities",
icon: <BrainCircuitIcon />,
items: [ items: [
{ {
titleKey: "nav.knowledge", titleKey: "nav.knowledge",
@@ -210,6 +215,7 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
}, },
{ {
titleKey: "nav.system", titleKey: "nav.system",
icon: <ShieldCheckIcon />,
items: [ items: [
{ {
titleKey: "nav.users", titleKey: "nav.users",