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:
105
apps/app/src/components-v2/layout/AppSidebar.vue
Normal file
105
apps/app/src/components-v2/layout/AppSidebar.vue
Normal 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>
|
||||
112
apps/app/src/components-v2/layout/SidebarHeader.vue
Normal file
112
apps/app/src/components-v2/layout/SidebarHeader.vue
Normal 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>
|
||||
167
apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts
Normal file
167
apps/app/src/components-v2/layout/__tests__/AppSidebar.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user