mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 17:07:46 +08:00
feat(frontend): add collapsible navigation sidebar
Persist the desktop sidebar state, provide accessible compact navigation tooltips, and cover the collapsed navigation markup with a focused component test.
This commit is contained in:
@@ -1,64 +1,98 @@
|
||||
<template>
|
||||
<nav class="sidebar-nav w-full px-3">
|
||||
<div
|
||||
v-for="(group, index) in items"
|
||||
:key="index"
|
||||
class="space-y-1 mb-5"
|
||||
<TooltipProvider :delay-duration="150">
|
||||
<nav
|
||||
class="sidebar-nav w-full"
|
||||
:class="collapsed ? 'px-2' : 'px-3'"
|
||||
:data-collapsed="collapsed"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div
|
||||
v-if="group.title"
|
||||
class="px-2.5 pb-1 flex items-center gap-2"
|
||||
:class="index > 0 ? 'pt-1' : ''"
|
||||
v-for="(group, index) in items"
|
||||
:key="index"
|
||||
:class="[
|
||||
collapsed ? 'mb-1 space-y-0' : 'mb-5 space-y-1',
|
||||
collapsed && index > 0 ? 'border-t border-[#3d3929]/10 pt-1 dark:border-white/10' : ''
|
||||
]"
|
||||
:data-sidebar-group-divider="collapsed && index > 0 ? '' : undefined"
|
||||
>
|
||||
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<span class="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.1em]">{{ group.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="space-y-0.5">
|
||||
<template
|
||||
v-for="item in group.items"
|
||||
:key="item.href"
|
||||
<!-- Section Header -->
|
||||
<div
|
||||
v-if="group.title && !collapsed"
|
||||
class="flex items-center gap-2 px-2.5 pb-1"
|
||||
:class="index > 0 ? 'pt-1' : ''"
|
||||
>
|
||||
<RouterLink
|
||||
:to="item.href"
|
||||
class="group relative flex items-center justify-between px-2.5 py-2 rounded-lg transition-all duration-200"
|
||||
:class="[
|
||||
isItemActive(item.href)
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
@pointerenter="schedulePrefetch(item.href)"
|
||||
@pointerleave="cancelScheduledPrefetch(item.href)"
|
||||
@pointerdown="prefetchNow(item.href)"
|
||||
@focus="prefetchNow(item.href)"
|
||||
@click="handleNavigate(item.href)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 transition-colors duration-200"
|
||||
:class="isItemActive(item.href) ? 'text-primary' : 'text-muted-foreground/70 group-hover:text-foreground'"
|
||||
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
|
||||
/>
|
||||
<span class="text-[13px] tracking-tight">{{ item.name }}</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<span class="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.1em]">{{ group.title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Active Indicator -->
|
||||
<div
|
||||
v-if="isItemActive(item.href)"
|
||||
class="w-1 h-1 rounded-full bg-primary"
|
||||
/>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<!-- Links -->
|
||||
<div class="space-y-0.5">
|
||||
<template
|
||||
v-for="item in group.items"
|
||||
:key="item.href"
|
||||
>
|
||||
<Tooltip
|
||||
:open="collapsed && openTooltipHref === item.href"
|
||||
@update:open="handleTooltipOpenChange(item.href, $event)"
|
||||
>
|
||||
<TooltipTrigger as-child>
|
||||
<RouterLink
|
||||
:to="item.href"
|
||||
class="group relative flex items-center rounded-lg"
|
||||
:class="[
|
||||
collapsed
|
||||
? 'h-9 justify-center px-0 transition-colors duration-150'
|
||||
: 'justify-between px-2.5 py-2 transition-all duration-200',
|
||||
isItemActive(item.href)
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
]"
|
||||
:aria-label="collapsed ? item.name : undefined"
|
||||
@pointerenter="schedulePrefetch(item.href)"
|
||||
@pointerleave="cancelScheduledPrefetch(item.href)"
|
||||
@pointerdown="prefetchNow(item.href)"
|
||||
@focus="prefetchNow(item.href)"
|
||||
@click="handleNavigate(item.href)"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 shrink-0 transition-colors duration-200"
|
||||
:class="isItemActive(item.href) ? 'text-primary' : 'text-muted-foreground/70 group-hover:text-foreground'"
|
||||
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
|
||||
/>
|
||||
<span
|
||||
v-if="!collapsed"
|
||||
class="truncate text-[13px] tracking-tight"
|
||||
>{{ item.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Active Indicator -->
|
||||
<div
|
||||
v-if="isItemActive(item.href)"
|
||||
class="h-1 w-1 shrink-0 rounded-full bg-primary"
|
||||
:class="collapsed ? 'absolute right-1.5' : ''"
|
||||
/>
|
||||
</RouterLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
v-if="collapsed"
|
||||
side="right"
|
||||
:side-offset="10"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ item.name }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, type Component } from 'vue'
|
||||
import { onBeforeUnmount, ref, type Component } from 'vue'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export interface NavigationItem {
|
||||
name: string
|
||||
@@ -76,6 +110,7 @@ const props = defineProps<{
|
||||
items: NavigationGroup[]
|
||||
activePath?: string
|
||||
isActive?: (href: string) => boolean
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -86,6 +121,19 @@ const emit = defineEmits<{
|
||||
const HOVER_PREFETCH_DELAY_MS = 100
|
||||
let scheduledPrefetchHref: string | null = null
|
||||
let scheduledPrefetchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const openTooltipHref = ref<string | null>(null)
|
||||
|
||||
function handleTooltipOpenChange(href: string, open: boolean) {
|
||||
if (!props.collapsed) {
|
||||
openTooltipHref.value = null
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
openTooltipHref.value = href
|
||||
} else if (openTooltipHref.value === href) {
|
||||
openTooltipHref.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelScheduledPrefetch(href?: string) {
|
||||
if (href && scheduledPrefetchHref !== href) return
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||
|
||||
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
const TestIcon = defineComponent({
|
||||
name: 'TestIcon',
|
||||
setup: () => () => h('svg', { 'data-testid': 'nav-icon' }),
|
||||
})
|
||||
|
||||
const RouterLinkStub = defineComponent({
|
||||
name: 'RouterLink',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
to: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h('a', { ...attrs, href: props.to }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('SidebarNav', () => {
|
||||
it('switches from labelled navigation to accessible icon-only links', async () => {
|
||||
const collapsed = ref(false)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
|
||||
const app = createApp({
|
||||
setup: () => () => h(SidebarNav, {
|
||||
items: [{
|
||||
title: 'Overview',
|
||||
items: [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: TestIcon },
|
||||
{ name: 'Usage', href: '/usage', icon: TestIcon },
|
||||
],
|
||||
}, {
|
||||
title: 'Management',
|
||||
items: [
|
||||
{ name: 'Settings', href: '/settings', icon: TestIcon },
|
||||
],
|
||||
}],
|
||||
activePath: '/dashboard',
|
||||
collapsed: collapsed.value,
|
||||
}),
|
||||
})
|
||||
app.component('RouterLink', RouterLinkStub)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
expect(root.querySelector('nav')?.dataset.collapsed).toBe('false')
|
||||
expect(root.textContent).toContain('Overview')
|
||||
expect(root.textContent).toContain('Management')
|
||||
expect(root.textContent).toContain('Dashboard')
|
||||
expect(root.querySelectorAll('[data-sidebar-group-divider]')).toHaveLength(0)
|
||||
const expandedDashboardLink = root.querySelector('a[href="/dashboard"]')
|
||||
expect(expandedDashboardLink?.hasAttribute('aria-label')).toBe(false)
|
||||
expect(expandedDashboardLink?.classList.contains('py-2')).toBe(true)
|
||||
expect(expandedDashboardLink?.classList.contains('h-9')).toBe(false)
|
||||
|
||||
collapsed.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('nav')?.dataset.collapsed).toBe('true')
|
||||
expect(root.textContent).not.toContain('Overview')
|
||||
expect(root.textContent).not.toContain('Management')
|
||||
expect(root.textContent).not.toContain('Dashboard')
|
||||
expect(root.querySelectorAll('[data-testid="nav-icon"]')).toHaveLength(3)
|
||||
expect(root.querySelectorAll('[data-sidebar-group-divider]')).toHaveLength(1)
|
||||
const collapsedDashboardLink = root.querySelector('a[href="/dashboard"]')
|
||||
expect(collapsedDashboardLink?.getAttribute('aria-label')).toBe('Dashboard')
|
||||
expect(collapsedDashboardLink?.classList.contains('h-9')).toBe(true)
|
||||
expect(collapsedDashboardLink?.classList.contains('py-2')).toBe(false)
|
||||
expect(root.querySelector('a[href="/usage"]')?.getAttribute('aria-label')).toBe('Usage')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user