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,95 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
// ---------------------------------------------------------------------------
// Local discriminated union for v1 nav entries (no `any`)
// ---------------------------------------------------------------------------
interface V1NavHeading {
heading: string
}
interface V1NavLink {
title: string
to: { name: string }
icon: { icon: string }
count?: number
}
export type V1NavEntry = V1NavHeading | V1NavLink
function isHeading(entry: V1NavEntry): entry is V1NavHeading {
return 'heading' in entry
}
// ---------------------------------------------------------------------------
// Pure adapter — exported so it can be unit-tested without mounting
// ---------------------------------------------------------------------------
/**
* Folds a flat v1 nav array into V2NavGroup[].
*
* - A `{ heading }` entry closes the current group and opens a new one.
* - Items before the first heading are placed in a leading group with label ''.
* - A pure function with no side-effects.
*/
export function toV2NavGroups(items: readonly V1NavEntry[]): V2NavGroup[] {
if (items.length === 0)
return []
const groups: V2NavGroup[] = []
let current: V2NavGroup | null = null
for (const entry of items) {
if (isHeading(entry)) {
// Close current group (if any) and start a new named group.
if (current !== null)
groups.push(current)
current = { label: entry.heading, items: [] }
}
else {
// Ensure there is a current group (leading ungrouped section).
if (current === null)
current = { label: '', items: [] }
const navItem: V2NavItem = {
id: entry.to.name,
label: entry.title,
icon: entry.icon.icon,
to: { name: entry.to.name },
...(entry.count !== undefined ? { count: entry.count } : {}),
}
current.items.push(navItem)
}
}
// Flush the last open group.
if (current !== null)
groups.push(current)
return groups
}
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/**
* Wraps toV2NavGroups in a computed ref.
*
* Accepts the raw v1 nav items as a parameter so the composable (composables
* boundary zone) does not need to import from @/navigation (navigation zone).
* Call-sites in layouts/pages — which ARE allowed to import navigation —
* pass orgNavItems directly:
*
* import { orgNavItems } from '@/navigation/vertical'
* const { groups } = useV2Nav(orgNavItems)
*/
export function useV2Nav(items: readonly V1NavEntry[]): { groups: ComputedRef<V2NavGroup[]> } {
const groups = computed(() => toV2NavGroups(items))
return { groups }
}