feat(gui-v2): decompose AppSidebar into SidebarHeader + AppSidebar

Ports crewli-starter's monolithic AppSidebar.vue into two typed production
components: SidebarHeader (the .brand block) and AppSidebar (composing
SidebarHeader + SidebarNav + WorkspaceSwitcher). AppSidebar renders a
permanent <aside> on desktop (lg+) and a PrimeVue Drawer on mobile, both
wired to useShellUiStore for collapse/mobile state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 20:29:18 +02:00
parent d479d35881
commit f0f9cb7e36
4 changed files with 579 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
/**
* AppSidebar — composes SidebarHeader + SidebarNav + WorkspaceSwitcher.
*
* Two rendering modes (RFC §4):
*
* DESKTOP (≥ lg / 1024px): a permanent <aside> (hidden lg:flex column).
* Width responds to sidebarCollapsed: expanded → w-64 (256px), collapsed → w-16 (64px).
* These match crewli-starter's --sidebar-w / --sidebar-w-collapsed CSS variables.
*
* MOBILE (< lg): a PrimeVue <Drawer> bound to shell.mobileOpen via a writable
* computed (`mobileVisible`). The Drawer contains the same 3 children as the
* <aside> — a small amount of template duplication kept intentional for
* readability (no render-fn or dynamic component indirection needed at this scale).
*
* nav groups arrive as a prop — this file must NOT import @/navigation directly
* (components-v2 import boundary; the layout page supplies nav data).
*
* Deliberate simplification: crewli-starter's bespoke Teleport tooltip (shown in
* collapsed mode for nav items) is NOT ported here. SidebarNav already provides
* native `:title` tooltips in collapsed mode, which is functionally equivalent
* for keyboard/mouse users and avoids the bespoke fixed-position Teleport mechanism.
* Tracked for re-evaluation if custom tooltip styling is required later.
*
* CSS translation (main.css → Tailwind):
* .sidebar (desktop) → flex flex-col overflow-hidden bg-[var(--p-surface-card)]
* border-e border-[var(--p-content-border-color)] z-40
* transition-[width] duration-200
* w-64 (expanded) | w-16 (collapsed)
* .sidebar (mobile) → full-height column inside the Drawer
* .drawer-open (blur) → handled by parent layout; not in scope here
*/
import { computed } from 'vue'
import Drawer from 'primevue/drawer'
import { useShellUiStore } from '@/stores/useShellUiStore'
import type { V2NavGroup } from '@/types/v2/nav'
import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
import SidebarNav from '@/components-v2/layout/SidebarNav.vue'
import WorkspaceSwitcher from '@/components-v2/layout/WorkspaceSwitcher.vue'
defineProps<{
/**
* Nav groups passed in from the layout — components-v2 files may NOT
* import from @/navigation; the parent layout supplies this.
*/
groups: V2NavGroup[]
}>()
const shell = useShellUiStore()
/**
* Writable computed so we can use v-model:visible on PrimeVue Drawer
* without mutating the store ref directly.
*/
const mobileVisible = computed<boolean>({
get: () => shell.mobileOpen,
set: (v: boolean) => shell.setMobileOpen(v),
})
</script>
<template>
<!--
DESKTOP: permanent <aside>, hidden below lg, flex column above lg.
Width transitions between w-64 (expanded) and w-16 (collapsed).
-->
<aside
class="hidden lg:flex flex-col overflow-hidden bg-[var(--p-surface-card)] border-e border-[var(--p-content-border-color)] z-40 transition-[width] duration-200 flex-shrink-0"
:class="shell.sidebarCollapsed ? 'w-16' : 'w-64'"
>
<SidebarHeader />
<SidebarNav
:groups="groups"
:collapsed="shell.sidebarCollapsed"
/>
<WorkspaceSwitcher :collapsed="shell.sidebarCollapsed" />
</aside>
<!--
MOBILE: PrimeVue Drawer rendered below lg. The Drawer's own overlay handles
backdrop / close-on-outside-click. position="left" gives the sidebar-style
panel. pt (passthrough) removes Drawer's default padding so our children
control their own spacing exactly as on desktop.
The children are intentionally repeated (not factored into a slot or
render-fn) the 3-component block is short and the duplication is
preferable to the indirection of a dynamic component or slot wiring.
-->
<Drawer
v-model:visible="mobileVisible"
position="left"
class="!w-64 lg:hidden"
:pt="{
content: { class: 'flex flex-col p-0 overflow-hidden h-full' },
header: { class: 'hidden' },
}"
>
<SidebarHeader />
<SidebarNav
:groups="groups"
:collapsed="false"
/>
<WorkspaceSwitcher :collapsed="false" />
</Drawer>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* SidebarHeader — ported from crewli-starter AppSidebar.vue `.brand` block.
*
* Renders the logo mark ("C"), "Crewli" wordmark, "Beta" pill, and the
* sidebar collapse-toggle button. All state is read from / written to
* useShellUiStore — no props needed.
*
* Collapse-button behaviour (mirrors crewli-starter `handleCollapse`):
* - Mobile (<lg, i.e. max-width: 1023px): the drawer is the active sidebar
* → call setMobileOpen(false) to close it.
* - Desktop (>=lg): toggle the persistent sidebar between expanded/collapsed
* → call toggleSidebar().
*
* CSS translation (main.css → Tailwind):
* .brand → flex items-center gap-[10px] h-[var(--topbar-h,56px)]
* px-4 border-b border-[var(--p-content-border-color)]
* flex-shrink-0 relative
* .brand.collapsed → justify-center (via :class when sidebarCollapsed)
* .mark → w-8 h-8 flex-shrink-0 rounded-lg
* bg-gradient-to-br from-primary-500 to-primary-700
* inline-flex items-center justify-center
* text-white font-bold text-sm
* .wordmark → font-bold text-base tracking-[-0.01em] whitespace-nowrap
* .pill → text-[9px] font-semibold bg-primary-50 dark:bg-primary-950
* text-primary-600 dark:text-primary-400 px-1.5 py-px
* rounded-full uppercase tracking-[0.05em]
* .sidebar-collapse → ms-auto w-7 h-7 border-0 bg-transparent
* text-[var(--p-text-muted-color)] rounded-[var(--p-border-radius)]
* inline-flex items-center justify-center
* transition-[background,color] duration-150
* hover:bg-[var(--p-content-hover-background)]
* hover:text-[var(--p-text-color)]
*
* <style scoped> is used for the inset box-shadow on .mark because
* Tailwind has no inset directional shadow utility at this granularity
* (per RFC §7.4 — last resort, with justification comment).
*/
import { useMediaQuery } from '@vueuse/core'
import Icon from '@/components/Icon.vue'
import { useShellUiStore } from '@/stores/useShellUiStore'
const shell = useShellUiStore()
// lg breakpoint = 1024px; mobile is anything below that (max-width: 1023px)
const isMobile = useMediaQuery('(max-width: 1023px)')
function handleCollapseClick(): void {
if (isMobile.value) {
// Mobile: the Drawer is the active sidebar — close it
shell.setMobileOpen(false)
}
else {
// Desktop: toggle the persistent sidebar width
shell.toggleSidebar()
}
}
</script>
<template>
<div
class="flex items-center gap-[10px] h-[56px] border-b border-[var(--p-content-border-color)] flex-shrink-0 relative transition-[padding,justify-content] duration-200"
:class="shell.sidebarCollapsed ? 'justify-center px-0' : 'px-4'"
>
<!-- Logo mark: "C" in a gradient square -->
<span class="mark w-8 h-8 flex-shrink-0 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 inline-flex items-center justify-center text-white font-bold text-sm">
C
</span>
<!-- Wordmark + Beta pill: hidden when sidebar is collapsed -->
<span
v-if="!shell.sidebarCollapsed"
class="font-bold text-base tracking-[-0.01em] whitespace-nowrap text-[var(--p-text-color)]"
>
Crewli
</span>
<span
v-if="!shell.sidebarCollapsed"
class="text-[9px] font-semibold bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 px-1.5 py-px rounded-full uppercase tracking-[0.05em]"
>
Beta
</span>
<!-- Collapse-toggle button: always visible; acts as expand affordance when collapsed -->
<button
class="ms-auto w-7 h-7 border-0 bg-transparent text-[var(--p-text-muted-color)] rounded-[var(--p-border-radius)] inline-flex items-center justify-center transition-[background,color] duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] cursor-pointer"
:class="shell.sidebarCollapsed ? '!ms-0' : ''"
:aria-label="shell.sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
type="button"
@click="handleCollapseClick"
>
<Icon
:name="shell.sidebarCollapsed ? 'tabler-chevron-right' : 'tabler-chevron-left'"
:size="16"
/>
</button>
</div>
</template>
<style scoped>
/*
* The .mark inset box-shadow (inset 0 -2px 0 rgba(0,0,0,0.08)) has no
* Tailwind equivalent — Tailwind's shadow utilities only support outer shadows
* and cannot express inset directional shadows at this opacity. Last resort per
* RFC §7.4.
*/
.mark {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 8%);
}
</style>

View File

@@ -0,0 +1,167 @@
/**
* AppSidebar.spec.ts — unit tests for AppSidebar composition and mobile wiring.
*
* Strategy: mount with @vue/test-utils stubs for all heavy children (SidebarHeader,
* SidebarNav, WorkspaceSwitcher, Drawer) so we test only:
* 1. Renders the 3 child components (SidebarHeader, SidebarNav, WorkspaceSwitcher).
* 2. Passes `groups` prop to SidebarNav.
* 3. Mobile Drawer v-model:visible wires to shell.mobileOpen (get path).
* 4. Drawer close (v-model:visible = false) calls shell.setMobileOpen(false).
*
* Stubs: Drawer is stubbed with a simple slot passthrough so we can inspect
* whether its `visible` prop is correctly bound to the store.
*/
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useShellUiStore } from '@/stores/useShellUiStore'
import AppSidebar from '@/components-v2/layout/AppSidebar.vue'
import type { V2NavGroup } from '@/types/v2/nav'
// ---------------------------------------------------------------------------
// A minimal nav group fixture
// ---------------------------------------------------------------------------
const testGroups: V2NavGroup[] = [
{
label: 'Main',
items: [
{ id: 'dashboard', label: 'Dashboard', icon: 'tabler-home', to: { name: 'dashboard' } },
],
},
]
// ---------------------------------------------------------------------------
// Stubs
// ---------------------------------------------------------------------------
/**
* DrawerStub exposes the same `visible` prop as PrimeVue Drawer and renders
* its default slot so child components are mounted. It also emits
* `update:visible` when the close method would fire, which mirrors the
* v-model:visible contract.
*/
const DrawerStub = {
name: 'Drawer',
props: ['visible', 'position', 'pt'],
emits: ['update:visible'],
template: '<div class="drawer-stub" :data-visible="visible"><slot /></div>',
}
const globalStubs = {
Drawer: DrawerStub,
SidebarHeader: { name: 'SidebarHeader', template: '<div class="sidebar-header-stub" />' },
SidebarNav: {
name: 'SidebarNav',
props: ['groups', 'collapsed'],
template: '<div class="sidebar-nav-stub" :data-collapsed="collapsed" :data-groups-count="groups.length" />',
},
WorkspaceSwitcher: {
name: 'WorkspaceSwitcher',
props: ['collapsed'],
template: '<div class="workspace-switcher-stub" />',
},
}
function mountSidebar(groups: V2NavGroup[] = testGroups) {
return mount(AppSidebar, {
props: { groups },
global: {
plugins: [createPinia()],
stubs: globalStubs,
},
})
}
describe('AppSidebar', () => {
beforeEach(() => setActivePinia(createPinia()))
// -------------------------------------------------------------------------
// Child composition
// -------------------------------------------------------------------------
it('renders SidebarHeader', () => {
const wrapper = mountSidebar()
expect(wrapper.find('.sidebar-header-stub').exists()).toBe(true)
})
it('renders SidebarNav', () => {
const wrapper = mountSidebar()
expect(wrapper.find('.sidebar-nav-stub').exists()).toBe(true)
})
it('renders WorkspaceSwitcher', () => {
const wrapper = mountSidebar()
expect(wrapper.find('.workspace-switcher-stub').exists()).toBe(true)
})
it('passes groups prop to SidebarNav', () => {
const wrapper = mountSidebar()
const nav = wrapper.find('.sidebar-nav-stub')
// Our stub renders data-groups-count from the groups prop
expect(nav.attributes('data-groups-count')).toBe(String(testGroups.length))
})
// -------------------------------------------------------------------------
// Mobile Drawer wiring
// -------------------------------------------------------------------------
it('Drawer visible is true when shell.mobileOpen is true', async () => {
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = true
await wrapper.vm.$nextTick()
// The Drawer stub renders data-visible from its visible prop
const drawer = wrapper.find('.drawer-stub')
expect(drawer.attributes('data-visible')).toBe('true')
})
it('Drawer visible is false when shell.mobileOpen is false', async () => {
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = false
await wrapper.vm.$nextTick()
const drawer = wrapper.find('.drawer-stub')
expect(drawer.attributes('data-visible')).toBe('false')
})
it('emitting update:visible false from Drawer calls shell.setMobileOpen(false)', async () => {
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = true
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
// Simulate the Drawer closing (v-model:visible setter)
await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', false)
expect(setMobileOpenSpy).toHaveBeenCalledWith(false)
})
it('emitting update:visible true from Drawer calls shell.setMobileOpen(true)', async () => {
const wrapper = mountSidebar()
const shell = useShellUiStore()
shell.mobileOpen = false
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
await wrapper.findComponent(DrawerStub).vm.$emit('update:visible', true)
expect(setMobileOpenSpy).toHaveBeenCalledWith(true)
})
})

View File

@@ -0,0 +1,195 @@
/**
* SidebarHeader.spec.ts — unit tests for the non-trivial logic in SidebarHeader.
*
* Strategy: mount with @vue/test-utils + createPinia. Child components (Icon)
* and @vueuse/core's useMediaQuery are stubbed so we test only:
* 1. Collapse-button desktop path → shell.toggleSidebar() called.
* 2. Collapse-button mobile path → shell.setMobileOpen(false) called.
* 3. Collapsed state hides wordmark + pill; keep collapse button.
*
* The unit project (src/ __tests__ glob) runs in happy-dom with globals: true
* so `describe/it/expect/vi` are available without explicit imports.
*/
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useShellUiStore } from '@/stores/useShellUiStore'
// ---------------------------------------------------------------------------
// Mock @vueuse/core so we can control `isMobile` per test
// ---------------------------------------------------------------------------
// We expose a reactive ref that individual tests can flip.
const mockIsMobileRef = ref(false)
vi.mock('@vueuse/core', () => ({
useMediaQuery: () => mockIsMobileRef,
}))
// ---------------------------------------------------------------------------
// Import component AFTER mock is set up
// ---------------------------------------------------------------------------
// eslint-disable-next-line import/first -- intentional: mock must be declared first
import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
// ---------------------------------------------------------------------------
// Stubs — keep tests fast and focused on logic, not child rendering
// ---------------------------------------------------------------------------
const globalStubs = {
// Icon renders nothing; we just need the collapse button to be present
Icon: { template: '<span class="icon-stub" />' },
}
function mountHeader() {
return mount(SidebarHeader, {
global: {
plugins: [createPinia()],
stubs: globalStubs,
},
})
}
describe('SidebarHeader', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Reset mobile mock to desktop default before each test
mockIsMobileRef.value = false
})
// -------------------------------------------------------------------------
// Collapse button — desktop path
// -------------------------------------------------------------------------
it('desktop: collapse button calls toggleSidebar', async () => {
const wrapper = mountHeader()
const shell = useShellUiStore()
const toggleSpy = vi.spyOn(shell, 'toggleSidebar')
mockIsMobileRef.value = false
const btn = wrapper.find('button[aria-label]')
await btn.trigger('click')
expect(toggleSpy).toHaveBeenCalledOnce()
})
it('desktop: collapse button does NOT call setMobileOpen', async () => {
const wrapper = mountHeader()
const shell = useShellUiStore()
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
mockIsMobileRef.value = false
const btn = wrapper.find('button[aria-label]')
await btn.trigger('click')
expect(setMobileOpenSpy).not.toHaveBeenCalled()
})
// -------------------------------------------------------------------------
// Collapse button — mobile path
// -------------------------------------------------------------------------
it('mobile: collapse button calls setMobileOpen(false)', async () => {
mockIsMobileRef.value = true
const wrapper = mountHeader()
const shell = useShellUiStore()
shell.mobileOpen = true // simulate open drawer
const setMobileOpenSpy = vi.spyOn(shell, 'setMobileOpen')
const btn = wrapper.find('button[aria-label]')
await btn.trigger('click')
expect(setMobileOpenSpy).toHaveBeenCalledWith(false)
})
it('mobile: collapse button does NOT call toggleSidebar', async () => {
mockIsMobileRef.value = true
const wrapper = mountHeader()
const shell = useShellUiStore()
const toggleSpy = vi.spyOn(shell, 'toggleSidebar')
const btn = wrapper.find('button[aria-label]')
await btn.trigger('click')
expect(toggleSpy).not.toHaveBeenCalled()
})
// -------------------------------------------------------------------------
// Collapsed state hides wordmark + pill
// -------------------------------------------------------------------------
it('expanded: wordmark and pill are visible', () => {
const wrapper = mountHeader()
const shell = useShellUiStore()
shell.sidebarCollapsed = false
// The wordmark text appears directly in a <span>
expect(wrapper.text()).toContain('Crewli')
expect(wrapper.text()).toContain('Beta')
})
it('collapsed: wordmark and pill are hidden', async () => {
const wrapper = mountHeader()
const shell = useShellUiStore()
shell.sidebarCollapsed = true
// Give Vue a tick to re-render after the store mutation
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Crewli')
expect(wrapper.text()).not.toContain('Beta')
})
it('collapsed: collapse button is still rendered', async () => {
const wrapper = mountHeader()
const shell = useShellUiStore()
shell.sidebarCollapsed = true
await wrapper.vm.$nextTick()
const btn = wrapper.find('button[aria-label]')
expect(btn.exists()).toBe(true)
})
it('collapsed: collapse button has aria-label "Expand sidebar"', async () => {
const wrapper = mountHeader()
const shell = useShellUiStore()
shell.sidebarCollapsed = true
await wrapper.vm.$nextTick()
const btn = wrapper.find('button[aria-label]')
expect(btn.attributes('aria-label')).toBe('Expand sidebar')
})
it('expanded: collapse button has aria-label "Collapse sidebar"', () => {
const wrapper = mountHeader()
const shell = useShellUiStore()
shell.sidebarCollapsed = false
const btn = wrapper.find('button[aria-label]')
expect(btn.attributes('aria-label')).toBe('Collapse sidebar')
})
})