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>
222 lines
7.6 KiB
Vue
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>
|