feat(gui-v2): port SidebarNav to TypeScript

Ports crewli-starter's sidebar nav into the SPA as production TS:
V2NavGroup/V2NavItem types, a pure toV2NavGroups adapter wrapped by
useV2Nav(items) (composables zone can't import @/navigation, so the
v1 nav array is passed in — the layout supplies orgNavItems in Task 7),
a pure isNavItemActive helper, and SidebarNav.vue (props-only,
router-driven nav, route-based active state, collapsed mode, main.css
translated to Tailwind inline). 16 unit tests. Icon import is
allowed via the components-foundation bridge (no eslint-disable).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 19:23:10 +02:00
parent 4e9eeb99c4
commit 8a8e419ed1
6 changed files with 397 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import Icon from '@/components/Icon.vue'
import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
defineProps<{
groups: V2NavGroup[]
collapsed: boolean
}>()
const router = useRouter()
const route = useRoute()
function navigate(item: V2NavItem): void {
router.push(item.to)
}
function checkActive(item: V2NavItem): boolean {
return isNavItemActive(item, route.name)
}
</script>
<template>
<!--
.nav flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]
.nav-group mt-[18px] (only from the second group onward, via CSS sibling selector
Tailwind cannot express `+ .nav-group { margin-top }` on a class without
a group-based trick; using first:mt-0 / not-first:mt-[18px] via the
:not(:first-child) pseudo equivalent `[&:not(:first-child)]:mt-[18px]`)
.nav-label text-[11px] font-semibold text-surface-500 dark:text-surface-400
uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden
.nav-item flex items-center gap-3 py-[var(--nav-y,9px)] px-2.5 rounded-[var(--radius,6px)]
text-surface-600 dark:text-surface-300 text-[13.5px] font-medium
transition-[background,color] duration-150 whitespace-nowrap relative
cursor-pointer border-0 bg-transparent w-full text-left min-w-0
.nav-item:hover hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0
.nav-item.active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold
.nav-item.active::before the left accent bar inexpressible in Tailwind without a plugin
(position:absolute left:-10px, custom width:3px, height:18px,
background:primary, transform:translateY(-50%)). Using <style scoped>.
.nav-item .iconify text-[18px] flex-shrink-0 (handled by Icon's own sizing via :size)
.count → ms-auto text-[11px] font-semibold bg-surface-100 dark:bg-surface-800
text-surface-500 dark:text-surface-400 px-1.5 py-px rounded-full
.nav-item.active .count → bg-primary-600 dark:bg-primary-500 text-white
-->
<nav class="flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]">
<div
v-for="(group, gi) in groups"
:key="gi"
class="[&:not(:first-child)]:mt-[18px]"
>
<!-- Group label: hidden in collapsed mode -->
<div
v-if="group.label && !collapsed"
class="text-[11px] font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden"
>
{{ group.label }}
</div>
<button
v-for="item in group.items"
:key="item.id"
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium transition-[background,color] duration-150 whitespace-nowrap relative cursor-pointer border-0 bg-transparent w-full text-left min-w-0"
:class="[
collapsed ? 'justify-center px-0' : 'px-2.5',
checkActive(item)
? 'nav-item-active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold'
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0',
]"
:title="collapsed ? item.label : undefined"
:aria-label="item.label"
@click="navigate(item)"
>
<Icon
:name="item.icon"
:size="18"
class="flex-shrink-0"
/>
<!-- Text label: hidden in collapsed mode -->
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ item.label }}
</span>
<!-- Count badge: hidden in collapsed mode -->
<span
v-if="item.count != null && !collapsed"
class="ms-auto text-[11px] font-semibold px-1.5 py-px rounded-full"
:class="[
checkActive(item)
? 'bg-primary-600 dark:bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400',
]"
>
{{ item.count }}
</span>
</button>
</div>
</nav>
</template>
<style scoped>
/*
* The active left-accent bar (.nav-item.active::before in crewli-starter main.css).
* position:absolute with left:-10px, specific width/height, and transform:translateY(-50%)
* cannot be expressed with Tailwind utilities alone — no utility maps to a
* pseudo-element with these exact values, and arbitrary-value pseudo variants
* like before:content-[''] with left-[-10px] do not compose with transform/height
* reliably across Tailwind v3/v4. A <style scoped> pseudo-element is the
* correct Tailwind-escape-hatch per RFC (customization order: Tailwind →
* pt API → Aura → <style scoped> last resort with comment).
*/
.nav-item-active::before {
content: "";
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
background: var(--p-primary-500, theme('colors.primary.500', #6366f1));
border-radius: 0 3px 3px 0;
}
</style>

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
import type { V2NavItem } from '@/types/v2/nav'
function makeItem(name: string): V2NavItem {
return { id: name, label: name, icon: 'tabler-home', to: { name } }
}
describe('isNavItemActive', () => {
it('returns true when the current route name exactly matches', () => {
expect(isNavItemActive(makeItem('dashboard'), 'dashboard')).toBe(true)
})
it('returns false when the current route name differs', () => {
expect(isNavItemActive(makeItem('dashboard'), 'events')).toBe(false)
})
it('returns false when currentRouteName is null', () => {
expect(isNavItemActive(makeItem('dashboard'), null)).toBe(false)
})
it('returns false when currentRouteName is undefined', () => {
expect(isNavItemActive(makeItem('dashboard'), undefined)).toBe(false)
})
it('returns true for nested routes: item name is a prefix of current route (dash-separated)', () => {
// e.g. item "organisation" should be active on route "organisation-settings"
expect(isNavItemActive(makeItem('organisation'), 'organisation-settings')).toBe(true)
})
it('does not match partial-word prefixes: "org" should NOT match "organisation-settings"', () => {
expect(isNavItemActive(makeItem('org'), 'organisation-settings')).toBe(false)
})
it('handles symbol route names: returns false (not string — no match)', () => {
const sym = Symbol('dashboard')
expect(isNavItemActive(makeItem('dashboard'), sym)).toBe(false)
})
it('item with to as { name: string } matches correctly', () => {
const item: V2NavItem = { id: 'events', label: 'Events', icon: 'tabler-calendar', to: { name: 'events' } }
expect(isNavItemActive(item, 'events')).toBe(true)
})
})

View File

@@ -0,0 +1,37 @@
import type { V2NavItem } from '@/types/v2/nav'
/**
* Pure helper — determines whether a V2NavItem is "active" given the current
* route name.
*
* Active rules (simplest correct definition):
* 1. Exact match: currentRouteName === item.to.name
* 2. Prefix match for nested routes: currentRouteName starts with
* item.to.name + '-' (e.g. item "organisation" is active on
* "organisation-settings"). The dash boundary prevents "org" from
* spuriously matching "organisation-settings".
*
* Only `to` values that are a plain object with a string `name` property are
* compared — string/array `to` values always return false (router-push
* style not used in v1 nav).
*/
export function isNavItemActive(
item: V2NavItem,
currentRouteName: string | symbol | null | undefined,
): boolean {
if (typeof currentRouteName !== 'string')
return false
const to = item.to
if (typeof to !== 'object' || to === null || Array.isArray(to))
return false
const itemName = (to as { name?: unknown }).name
if (typeof itemName !== 'string')
return false
return currentRouteName === itemName
|| currentRouteName.startsWith(`${itemName}-`)
}