fix(gui-v2): mount Drawer only on mobile (v-if) + shared Tailwind breakpoint

CRITICAL: replace `lg:hidden` on PrimeVue Drawer with `v-if="isMobile"` so the
teleported portal/overlay is never created on desktop viewports regardless of
mobileOpen state. Replace useMediaQuery raw string in SidebarHeader with
useBreakpoints(breakpointsTailwind).smaller('lg') shared by both components.
Add desktop/mobile comments; adapt tests to useBreakpoints mock; add
Drawer-absent-on-desktop and aside w-16/w-64 width-class assertions (21 tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 20:41:50 +02:00
parent f0f9cb7e36
commit 23e1262f9c
4 changed files with 141 additions and 19 deletions

View File

@@ -13,6 +13,12 @@
* <aside> — a small amount of template duplication kept intentional for
* readability (no render-fn or dynamic component indirection needed at this scale).
*
* The Drawer is conditionally MOUNTED (v-if="isMobile") rather than hidden via
* a CSS class. PrimeVue Drawer teleports its overlay to <body> via an internal
* Portal; a `lg:hidden` class on the component node does NOT suppress the
* teleported overlay. By unmounting on desktop we guarantee no modal backdrop /
* focus-trapped dialog can appear on wide viewports regardless of mobileOpen state.
*
* nav groups arrive as a prop — this file must NOT import @/navigation directly
* (components-v2 import boundary; the layout page supplies nav data).
*
@@ -32,6 +38,7 @@
*/
import { computed } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Drawer from 'primevue/drawer'
import { useShellUiStore } from '@/stores/useShellUiStore'
import type { V2NavGroup } from '@/types/v2/nav'
@@ -49,6 +56,12 @@ defineProps<{
const shell = useShellUiStore()
// Shared breakpoint signal — must match SidebarHeader and the CSS `lg:` boundary (1024px).
// useBreakpoints(breakpointsTailwind).smaller('lg') is true below 1024px, equivalent to
// max-width: 1023px. Both components import from the same Tailwind constant so the
// desktop/mobile boundary cannot drift.
const isMobile = useBreakpoints(breakpointsTailwind).smaller('lg')
/**
* Writable computed so we can use v-model:visible on PrimeVue Drawer
* without mutating the store ref directly.
@@ -63,7 +76,9 @@ const mobileVisible = computed<boolean>({
<!--
DESKTOP: permanent <aside>, hidden below lg, flex column above lg.
Width transitions between w-64 (expanded) and w-16 (collapsed).
`hidden lg:flex` is effective here because <aside> is not teleported.
-->
<!-- desktop -->
<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'"
@@ -77,19 +92,27 @@ const mobileVisible = computed<boolean>({
</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.
MOBILE: PrimeVue Drawer, conditionally MOUNTED only when isMobile is true.
v-if (not v-show / CSS) is required because PrimeVue Drawer teleports its
overlay to <body> a CSS class on the component node does NOT reach the
teleported portal. Unmounting on desktop ensures the overlay and focus-trap
are never created on wide viewports, regardless of shell.mobileOpen state.
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.
-->
<!-- mobile -->
<Drawer
v-if="isMobile"
v-model:visible="mobileVisible"
position="left"
class="!w-64 lg:hidden"
class="!w-64"
:pt="{
content: { class: 'flex flex-col p-0 overflow-hidden h-full' },
header: { class: 'hidden' },