feat(layout): Plan 2.5 P1 foundation — APP_NAVIGATION + walkNavTree + AppBreadcrumb

Per RFC-WS-PRIMEVUE-PLAN-2-5 §8 step 1. Foundation scaffolding only —
no shell fixes, no Public Sans removal, no useShellUiStore changes
(P2–P6 scope).

Implements:
- theme darkModeSelector verified at '.dark' (already correct in
  plugins/primevue/index.ts — config site is here, not theme.ts).
- src/config/navigation.ts: APP_NAVIGATION registry per AD-2.5-B1
  (Dashboard entry only — v2-dashboard is the only v2 route today).
- src/composables/useBreadcrumb.ts: walkNavTree pure helper +
  useNavBreadcrumb composable per AD-2.5-B1. The legacy meta-based
  useBreadcrumb is preserved (consumed by AppTopbar, P1 may not
  touch AppTopbar); P4 retires it and renames useNavBreadcrumb.
- src/components-v2/layout/AppBreadcrumb.vue: layout primitive
  wrapping PrimeVue Breadcrumb, consuming useNavBreadcrumb.
- Tests: walkNavTree (4 specs, co-located), AppBreadcrumb mount
  (2 specs, tests/component/layouts/).

Suite 564 → 570 (+6, all new specs green). vue-tsc clean. Scoped
ESLint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 07:23:16 +02:00
parent a4ca887d32
commit 59007e60e0
6 changed files with 282 additions and 4 deletions

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
/**
* AppBreadcrumb — layout primitive per RFC-WS-PRIMEVUE-PLAN-2-5 AD-2.5-B1.
*
* Consumes `useNavBreadcrumb` (registry-driven; data derives from
* APP_NAVIGATION + the current route name). Wired into AppTopbar in P5
* (RFC §5.2 Fix 2) — standalone in P1.
*
* Naming note: imports `useNavBreadcrumb` (not the literal `useBreadcrumb`
* the AD specifies) because the legacy meta-based `useBreadcrumb` is still
* consumed by AppTopbar; P1 may not touch AppTopbar (P4P6 scope). P4
* retires the legacy API and renames `useNavBreadcrumb` → `useBreadcrumb`.
*/
import { computed } from 'vue'
import Breadcrumb from 'primevue/breadcrumb'
import { useNavBreadcrumb } from '@/composables/useBreadcrumb'
const crumbs = useNavBreadcrumb()
const items = computed(() =>
crumbs.value.map(c => ({
label: c.label,
route: c.routeName ? { name: c.routeName } : undefined,
})),
)
</script>
<template>
<Breadcrumb
:model="items"
:pt="{
root: { class: 'border-none p-0 bg-transparent' },
list: { class: 'gap-1' },
}"
>
<template #item="{ item }">
<RouterLink
v-if="item.route"
:to="item.route"
class="text-sm text-surface-600 hover:text-surface-900 dark:text-surface-400 dark:hover:text-surface-100 transition-colors"
>
{{ item.label }}
</RouterLink>
<span
v-else
class="text-sm font-medium text-surface-900 dark:text-surface-100"
>
{{ item.label }}
</span>
</template>
</Breadcrumb>
</template>

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { toBreadcrumbItems } from '@/composables/useBreadcrumb'
import { toBreadcrumbItems, walkNavTree } from '@/composables/useBreadcrumb'
import type { BreadcrumbRouteRecord } from '@/composables/useBreadcrumb'
import type { NavItem } from '@/config/navigation'
// ---------------------------------------------------------------------------
// Fixtures
@@ -149,3 +150,53 @@ describe('toBreadcrumbItems', () => {
expect(items[0].label).toBe('Dashboard')
})
})
// ---------------------------------------------------------------------------
// AD-2.5-B1 — walkNavTree (registry-driven breadcrumb)
// Local FIXTURE keeps these specs independent of APP_NAVIGATION content.
// ---------------------------------------------------------------------------
const FIXTURE: NavItem[] = [
{ key: 'a', label: 'A', routeName: 'a' },
{
key: 'b',
label: 'B',
children: [
{ key: 'b.1', label: 'B-1', routeName: 'b-1' },
{
key: 'b.2',
label: 'B-2',
children: [
{ key: 'b.2.1', label: 'B-2-1', routeName: 'b-2-1' },
],
},
],
},
]
describe('walkNavTree (AD-2.5-B1)', () => {
it('returns single-entry chain for a top-level leaf', () => {
expect(walkNavTree(FIXTURE, 'a')).toEqual([
{ label: 'A', routeName: 'a' },
])
})
it('returns full chain for a nested leaf', () => {
expect(walkNavTree(FIXTURE, 'b-1')).toEqual([
{ label: 'B', routeName: undefined },
{ label: 'B-1', routeName: 'b-1' },
])
})
it('returns full chain for a deeply nested leaf', () => {
expect(walkNavTree(FIXTURE, 'b-2-1')).toEqual([
{ label: 'B', routeName: undefined },
{ label: 'B-2', routeName: undefined },
{ label: 'B-2-1', routeName: 'b-2-1' },
])
})
it('returns empty array for an unmatched route', () => {
expect(walkNavTree(FIXTURE, 'nonexistent')).toEqual([])
})
})

View File

@@ -2,9 +2,10 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router'
import type { ComputedRef } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { APP_NAVIGATION, type NavItem } from '@/config/navigation'
// ---------------------------------------------------------------------------
// Types
// Types — legacy (meta-based, consumed by AppTopbar pre-P4)
// ---------------------------------------------------------------------------
/** A single breadcrumb item. `to` is absent for the current (last) item. */
@@ -81,8 +82,11 @@ export interface UseBreadcrumbReturn {
* Composable that derives breadcrumb items from the current route's
* `matched` array. Reactive — updates automatically on navigation.
*
* Composables zone may NOT import from @/navigation — do not add
* route-level nav helpers here.
* NOTE (AD-2.5-B1): this `route.matched` + `meta` derivation is the
* pre-Plan-2.5 mechanism, still consumed by AppTopbar. P4 (RFC §8
* step 4) migrates AppTopbar to `useNavBreadcrumb` below; after that,
* this legacy function and its types should be retired and
* `useNavBreadcrumb` renamed to `useBreadcrumb` as the single SoT.
*/
export function useBreadcrumb(): UseBreadcrumbReturn {
const route = useRoute()
@@ -93,3 +97,66 @@ export function useBreadcrumb(): UseBreadcrumbReturn {
return { items }
}
// ---------------------------------------------------------------------------
// AD-2.5-B1 — registry-driven breadcrumb (Plan 2.5 P1 foundation)
// ---------------------------------------------------------------------------
/**
* Breadcrumb item derived from APP_NAVIGATION. Distinct from the legacy
* `BreadcrumbItem` above which carries a `RouteLocationRaw to` field;
* this shape carries a route NAME, mapped at render time to
* `{ name: routeName }`. Both shapes reconcile in P4 when AppTopbar
* migrates and the legacy API is removed.
*/
export interface NavBreadcrumbItem {
label: string
routeName?: string
}
/**
* Walks a NavItem tree to find the chain from root to the leaf whose
* `routeName` matches the given name. Returns an empty array if no match.
*
* AD-2.5-B1: pure function — unit-testable without router or component mount.
*/
export function walkNavTree(
tree: NavItem[],
routeName: string,
acc: NavBreadcrumbItem[] = [],
): NavBreadcrumbItem[] {
for (const node of tree) {
const next = [...acc, { label: node.label, routeName: node.routeName }]
if (node.routeName === routeName)
return next
if (node.children) {
const childMatch = walkNavTree(node.children, routeName, next)
if (childMatch.length > 0)
return childMatch
}
}
return []
}
/**
* Returns the breadcrumb chain for the current route, derived from
* APP_NAVIGATION. Empty array when `route.name` is unset or no match.
*
* AD-2.5-B1. Transitional name: in P4 (RFC §8 step 4), AppTopbar
* migrates from the legacy `useBreadcrumb` above to this composable;
* after that, rename this to `useBreadcrumb` and drop the legacy API.
*/
export function useNavBreadcrumb(): ComputedRef<NavBreadcrumbItem[]> {
const route = useRoute()
return computed<NavBreadcrumbItem[]>(() => {
if (!route.name)
return []
return walkNavTree(APP_NAVIGATION, String(route.name))
})
}

View File

@@ -0,0 +1,44 @@
// AD-2.5-B1: central navigation registry.
// Single source of truth for sidebar rendering AND breadcrumb derivation.
// Future extensions (role filtering, feature flags, dynamic ordering) are
// explicit follow-up RFCs — DO NOT add those fields here without one.
//
// As of Plan 2.5 P1, only one v2 route exists (/v2/dashboard → v2-dashboard).
// As pages-v2/ grows, add an entry per route here so both the sidebar (via
// SidebarNav, wired in P4) and the breadcrumb (via useNavBreadcrumb) update
// from one place.
export interface NavItem {
/** Stable identifier, dot-namespaced (e.g., 'events.volunteers'). */
key: string
/** Display string shown in sidebar and breadcrumb. */
label: string
/** Vue-router route name; leaf nodes only. Branch nodes omit this. */
routeName?: string
/**
* Icon name in the project's `tabler-X` (dash-prefixed) convention,
* passed to `<Icon name="tabler-X" />`. Note: this deviates from the
* AD-2.5-B1 example which uses Iconify-standard `tabler:X` — the
* codebase's Icon.vue / iconify plugin pipeline expects dash-prefixed.
*/
icon?: string
/** Submodule nesting. */
children?: NavItem[]
/** Exclude from sidebar but allow breadcrumb walk to traverse. */
hidden?: boolean
}
export const APP_NAVIGATION: NavItem[] = [
{
key: 'dashboard',
label: 'Dashboard',
routeName: 'v2-dashboard',
icon: 'tabler-home',
},
]