Files
crewli-old/apps/app/src/components-v2/layout/SidebarNav.vue
bert.hausmans ac36dfe9b7 feat(layout): Plan 2.5 P5 — shell parity fixes 1–5 + useBreadcrumb retire
Per RFC-WS-PRIMEVUE-PLAN-2-5 §5.1–§5.5 plus the AD-2.5-W1 option-A
supersession (no sub on dropdown items either, accepted divergence).

Atomic changes:
- AppTopbar: brand block (gradient "C" mark + Crewli wordmark) removed
  per Fix 1; the #start slot now renders <AppBreadcrumb /> per Fix 2.
  Legacy meta-based useBreadcrumb consumption (breadcrumbModel computed,
  vue-router useRouter import, command-based PrimeVue Breadcrumb model)
  is gone; AppBreadcrumb owns the registry-driven path. Dead
  topbar-mark-shadow scoped CSS rule deleted.
- AppBreadcrumb: import updated to the renamed useBreadcrumb.
- AppSidebar: docstring updated to make the Fix 3 vertical order
  (Header → Nav → Switcher, switcher bottom-anchored) explicit. No
  template change needed: SidebarNav's root <nav class="flex-1"> already
  fills available column space, naturally pushing WorkspaceSwitcher to
  the bottom (two flex-1 siblings would split the column 50/50 and
  compress the nav — a separate spacer element is structurally wrong).
- WorkspaceSwitcher: dropdown panel restructured per crewli-starter
  reference. Semantic class markers (.popover-head/.title/.link/.list/
  .opt/.is-current/.ws-logo/.name/.check-mark/.foot) added alongside
  Tailwind utilities so specs assert structure with stable selectors.
  Footer buttons wired to placeholder createWorkspace / inviteUser
  handlers (console.warn + TODO) until the flows ship. Manage link
  stays a non-navigating label (no v2-workspaces-manage route yet).
  No sub line on any dropdown row (AD-2.5-W1 option A).

Atomic legacy useBreadcrumb retirement (planned since P1):
- Legacy route-meta-driven useBreadcrumb + toBreadcrumbItems +
  BreadcrumbRouteRecord types deleted entirely (only AppTopbar
  consumed it, and that consumption is gone after Fix 2).
- useNavBreadcrumb → useBreadcrumb (single SoT for breadcrumb chain).
- NavBreadcrumbItem → BreadcrumbItem.
- AppBreadcrumb.vue import updated to the new name.
- SidebarNav.vue docstring reference scrubbed to the new name.
- useBreadcrumb.spec.ts: 10 legacy toBreadcrumbItems specs removed;
  4 walkNavTree specs retained.

AppTopbar.spec.ts:
- vue-router mock simplified (route.matched no longer relevant).
- AppBreadcrumb stubbed in #start; legacy command-vs-route assertion
  removed; new spec verifies AppBreadcrumb is rendered.

WorkspaceSwitcher.spec.ts: 5 new dropdown specs (header / row count /
current-row checkmark / footer buttons / no-sub on rows).

Suite delta: 557 → 552 (−5 net: −10 legacy toBreadcrumbItems specs,
+5 Fix 5 dropdown specs, −1 obsolete AppTopbar breadcrumb-model spec,
+1 new AppTopbar AppBreadcrumb-presence spec).

vue-tsc clean. Scoped ESLint clean (0 errors). All 3 re-grep checks
returned 0 hits (useNavBreadcrumb/NavBreadcrumbItem, topbar brand
selectors, standalone "sub" identifier in WorkspaceSwitcher — only
documentation comments referencing the no-sub state remain, which
describe absence by design).

Manual smoke skipped (Auto Mode); coverage from the post-edit specs
includes AppBreadcrumb-in-#start, dropdown structure, and trigger
no-sub. Recommend Bert run `pnpm --filter crewli-app dev` and verify
the 6 checks listed in the prompt before merging.

Known divergence from crewli-starter (accepted):
- Dropdown rows are ~16px shorter than crewli-starter (no sub line).
  Tracked as WORKSPACE-DROPDOWN-SUB-CONTENT for a future RFC with
  the required backend scope (organisations.type enum + metrics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:22:33 +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 `useBreadcrumb` 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>