feat(gui-v2): port AppTopbar + useBreadcrumb to TypeScript

- useBreadcrumb composable: pure toBreadcrumbItems() helper + thin
  useRoute() wrapper; route-driven, no prop coupling
- AppTopbar: hamburger→setMobileOpen, theme/density toggles→shell store,
  PrimeVue Breadcrumb/OverlayBadge/Popover/Avatar/Menu; replaces all
  manual document.mousedown listeners with PrimeVue built-in dismissal;
  notifications stubbed (useNotificationStore is a toast queue, not a
  feed — TODO TECH-WS-GUI-REDESIGN); sign-out→authStore.logout()
- Unit tests: 10 breadcrumb + 6 AppTopbar assertions (16 total, all pass)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 20:51:48 +02:00
parent 23e1262f9c
commit 7489301195
4 changed files with 908 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import type { ComputedRef } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single breadcrumb item. `to` is absent for the current (last) item. */
export interface BreadcrumbItem {
label: string
to?: RouteLocationRaw
}
/**
* Minimal shape we need from a matched route record.
* Using a structural type keeps the pure helper testable without
* requiring full RouteLocationMatched fixtures in unit tests.
* RouteLocationMatched is structurally assignable to this interface so
* route.matched (RouteLocationMatched[]) passes the type-checker.
*/
export interface BreadcrumbRouteRecord {
meta?: Record<string, unknown> & {
breadcrumb?: string
title?: string
}
name?: string | symbol | null
path?: string
}
// ---------------------------------------------------------------------------
// Pure helper — testable without a live router
// ---------------------------------------------------------------------------
/**
* Convert an array of matched route records into breadcrumb items.
*
* Rules:
* - Records without `meta.breadcrumb` OR `meta.title` are filtered out.
* - Label = `meta.breadcrumb` if set, else `meta.title`.
* - The last item is current — no `to` link.
* - All preceding items carry a `to` pointing at their `path` (or `name`
* when path is absent).
*/
export function toBreadcrumbItems(matched: readonly BreadcrumbRouteRecord[]): BreadcrumbItem[] {
const eligible = matched.filter(
r => r.meta?.breadcrumb !== undefined || r.meta?.title !== undefined,
)
return eligible.map((record, index): BreadcrumbItem => {
const label = (record.meta?.breadcrumb ?? record.meta?.title) as string
const isLast = index === eligible.length - 1
if (isLast)
return { label }
// Prefer path; fall back to name when path is absent/empty
const to: RouteLocationRaw
= record.path
? record.path
: { name: record.name as string }
return { label, to }
})
}
// ---------------------------------------------------------------------------
// Composable — thin wrapper around useRoute()
// ---------------------------------------------------------------------------
export interface UseBreadcrumbReturn {
items: ComputedRef<BreadcrumbItem[]>
}
/**
* 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.
*/
export function useBreadcrumb(): UseBreadcrumbReturn {
const route = useRoute()
const items = computed<BreadcrumbItem[]>(() =>
toBreadcrumbItems(route.matched),
)
return { items }
}