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>
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 `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>
|