Files
crewli-old/apps/app/src/components-v2/layout/SidebarNav.vue
bert.hausmans 864cc558e2 feat(layout): Plan 2.5 P4 — WorkspaceSwitcher no-sub + SidebarNav APP_NAVIGATION
Per RFC-WS-PRIMEVUE-PLAN-2-5 §4 AD-2.5-W1 and AD-2.5-B1, §5.4 Fix 4.

Changes:
- WorkspaceSwitcher: sub field removed from template, WorkspaceDisplay
  type, and buildDisplay derivation. Stories did not carry sub args
  (auto-derived from seeded org.role); no WithSub story existed. New
  regression spec (WorkspaceSwitcher.spec.ts) locks the no-sub render.
- SidebarNav: now consumes APP_NAVIGATION from src/config/navigation.ts
  as the single source of truth (shared with breadcrumb derivation in
  useNavBreadcrumb). The groups: V2NavGroup[] prop is removed; render
  walks top-level NavItems (branch nodes render label-heading + children;
  leaf nodes render as rows; items without routeName render as
  non-clickable dormant placeholders). Previous nav data source:
  groups prop fed by useV2Nav(orgNavItems) in OrganizerLayoutV2.
- APP_NAVIGATION expanded with 7 entries to preserve visual sidebar
  continuity (Evenementen at top-level + Beheer branch with 5 children).
  All new entries use routeName: undefined until the corresponding v2
  page lands (TODOs noted per entry); only Dashboard maps to v2-dashboard.
- AppSidebar: groups prop removed; passes only :collapsed to SidebarNav.
- OrganizerLayoutV2: useV2Nav(orgNavItems) plumbing retired; the layout
  now renders <AppSidebar /> with no nav-data wiring.
- Tests: AppSidebar.spec drops the "passes groups prop to SidebarNav"
  assertion; OrganizerLayoutV2.spec drops the "forwards orgNavItems"
  assertion. New WorkspaceSwitcher no-sub regression spec (+2 tests).
- Storybook: SidebarNav.stories and AppSidebar.stories updated to no
  longer thread navFixture/groups; WithActiveItem pushes v2-dashboard.

Position of WorkspaceSwitcher (Fix 3), workspace dropdown panel (Fix 5),
and AppBreadcrumb wiring (Fix 2) remain unchanged in P4 — both lands in
P5. The legacy useBreadcrumb composable also remains untouched until P5
(atomic with AppTopbar refactor).

Orphans flagged for follow-up cleanup (intentionally not deleted in P4):
useV2Nav composable + spec, V2NavGroup/V2NavItem types, sidebarNavActive
helper + spec, navFixture in stories/v2/_helpers.ts.

Suite delta: 575 → 575 (+2 WorkspaceSwitcher no-sub spec, -1 AppSidebar
groups-prop assertion, -1 OrganizerLayoutV2 groups-forward assertion).
vue-tsc clean. Scoped ESLint clean (0 errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:14:31 +02:00

222 lines
7.6 KiB
Vue

<script setup lang="ts">
/**
* SidebarNav — registry-driven sidebar (AD-2.5-B1 / Plan 2.5 P4).
*
* Reads `APP_NAVIGATION` directly from `@/config/navigation`. This is the
* single source of truth for sidebar items and breadcrumb derivation
* (the breadcrumb side is served by `useNavBreadcrumb` over the same
* registry). Prior to P4, this component received a `groups` prop the
* parent layout built from `@/navigation/vertical`; the prop chain
* (OrganizerLayoutV2 → AppSidebar → SidebarNav) is removed.
*
* Render contract:
* - Top-level leaf nodes (routeName set, no children) render as a row.
* - Top-level branch nodes (children set) render their label as a
* section heading followed by their children as rows.
* - Nodes without a `routeName` render as a non-clickable row
* (label-only) — dormant placeholders until their v2 page lands.
*/
import { useRoute } from 'vue-router'
import type { RouteLocationRaw } from 'unplugin-vue-router'
import Icon from '@/components/Icon.vue'
import { APP_NAVIGATION, type NavItem } from '@/config/navigation'
defineProps<{
collapsed: boolean
}>()
const route = useRoute()
function isActive(item: NavItem): boolean {
if (!item.routeName)
return false
const current = route.name
if (typeof current !== 'string')
return false
return current === item.routeName
|| current.startsWith(`${item.routeName}-`)
}
// NavItem.routeName values come from APP_NAVIGATION and are always valid
// named routes from the RouteNamedMap when present — the cast to the typed
// RouterLink RouteLocationRaw is sound. Items without routeName render via
// the non-clickable branch (see template) so this function is never called
// for them.
function itemTo(item: NavItem): RouteLocationRaw {
return { name: item.routeName } as RouteLocationRaw
}
</script>
<template>
<nav class="flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]">
<template
v-for="topItem in APP_NAVIGATION"
:key="topItem.key"
>
<!-- Branch node: section label + children -->
<div
v-if="topItem.children"
class="[&:not(:first-child)]:mt-[18px]"
>
<div
v-if="!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"
>
{{ topItem.label }}
</div>
<template
v-for="child in topItem.children"
:key="child.key"
>
<!-- Leaf with route -->
<RouterLink
v-if="child.routeName"
v-slot="{ href, navigate }"
:to="itemTo(child)"
custom
>
<a
:href="href"
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 w-full text-start min-w-0"
:class="[
collapsed ? 'justify-center px-0' : 'px-2.5',
isActive(child)
? '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',
]"
:aria-current="isActive(child) ? 'page' : undefined"
:aria-label="child.label"
:title="collapsed ? child.label : undefined"
@click="navigate"
>
<Icon
v-if="child.icon"
:name="child.icon"
:size="18"
class="flex-shrink-0"
/>
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ child.label }}
</span>
</a>
</RouterLink>
<!-- Leaf without route: dormant placeholder -->
<div
v-else
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium whitespace-nowrap relative w-full text-start min-w-0 text-surface-400 dark:text-surface-500 cursor-not-allowed"
:class="collapsed ? 'justify-center px-0' : 'px-2.5'"
:aria-disabled="true"
:title="collapsed ? child.label : undefined"
>
<Icon
v-if="child.icon"
:name="child.icon"
:size="18"
class="flex-shrink-0"
/>
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ child.label }}
</span>
</div>
</template>
</div>
<!-- Top-level leaf with route -->
<RouterLink
v-else-if="topItem.routeName"
v-slot="{ href, navigate }"
:to="itemTo(topItem)"
custom
>
<a
:href="href"
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 w-full text-start min-w-0"
:class="[
collapsed ? 'justify-center px-0' : 'px-2.5',
isActive(topItem)
? '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',
]"
:aria-current="isActive(topItem) ? 'page' : undefined"
:aria-label="topItem.label"
:title="collapsed ? topItem.label : undefined"
@click="navigate"
>
<Icon
v-if="topItem.icon"
:name="topItem.icon"
:size="18"
class="flex-shrink-0"
/>
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ topItem.label }}
</span>
</a>
</RouterLink>
<!-- Top-level leaf without route: dormant placeholder -->
<div
v-else
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium whitespace-nowrap relative w-full text-start min-w-0 text-surface-400 dark:text-surface-500 cursor-not-allowed"
:class="collapsed ? 'justify-center px-0' : 'px-2.5'"
:aria-disabled="true"
:title="collapsed ? topItem.label : undefined"
>
<Icon
v-if="topItem.icon"
:name="topItem.icon"
:size="18"
class="flex-shrink-0"
/>
<span
v-if="!collapsed"
class="overflow-hidden text-ellipsis min-w-0"
>
{{ topItem.label }}
</span>
</div>
</template>
</nav>
</template>
<style scoped>
/*
* The active left-accent bar (.nav-item.active::before in crewli-starter main.css).
* The bar sits at left:-10px — outside the row boundary. The parent <nav> is an
* overflow-y-auto scroll container which clips cross-axis overflow, so a Tailwind
* before: utility would be clipped by that parent overflow context. A <style scoped>
* pseudo-element keeps the exception self-contained (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>