11<script setup lang="ts">
2- import { computed , ref } from ' vue'
2+ import { computed , onUnmounted , ref , watch } from ' vue'
33import { useRoute } from ' vue-router'
4+ import { registerEscapeHandler } from ' ../../composables/useEscapeStack'
45import { useFeatureFlagStore } from ' ../../store/featureFlagStore'
56import { useWorkspaceStore } from ' ../../store/workspaceStore'
67import type { FeatureFlags } from ' ../../types/feature-flags'
@@ -33,6 +34,35 @@ const featureFlags = useFeatureFlagStore()
3334const workspace = useWorkspaceStore ()
3435
3536const sidebarCollapsed = ref (false )
37+ const mobileOpen = ref (false )
38+
39+ function closeMobileMenu() {
40+ mobileOpen .value = false
41+ }
42+
43+ function toggleMobileMenu() {
44+ mobileOpen .value = ! mobileOpen .value
45+ }
46+
47+ // Lock body scroll and register Escape handler while mobile menu is open
48+ watch (mobileOpen , (isOpen , _ , onCleanup ) => {
49+ if (! isOpen ) return
50+
51+ document .body .style .overflow = ' hidden'
52+ const unregisterEscape = registerEscapeHandler (closeMobileMenu )
53+
54+ onCleanup (() => {
55+ document .body .style .overflow = ' '
56+ unregisterEscape ()
57+ })
58+ })
59+
60+ onUnmounted (() => {
61+ // Safety: restore scroll if component unmounts while open
62+ if (mobileOpen .value ) {
63+ document .body .style .overflow = ' '
64+ }
65+ })
3666
3767const navCatalog: NavItem [] = [
3868 {
@@ -221,13 +251,22 @@ function toggleSidebar() {
221251 */
222252defineExpose ({
223253 availableNavItems ,
254+ mobileOpen ,
255+ toggleMobileMenu ,
256+ closeMobileMenu ,
224257})
225258 </script >
226259
227260<template >
261+ <div
262+ v-if =" mobileOpen"
263+ class =" td-sidebar-overlay"
264+ aria-hidden =" true"
265+ @click =" closeMobileMenu"
266+ />
228267 <aside
229268 class =" td-sidebar"
230- :class =" { 'td-sidebar--collapsed': sidebarCollapsed }"
269+ :class =" { 'td-sidebar--collapsed': sidebarCollapsed, 'td-sidebar--mobile-open': mobileOpen }"
231270 role =" navigation"
232271 aria-label =" Main navigation"
233272 >
@@ -253,6 +292,7 @@ defineExpose({
253292 class =" td-nav-item"
254293 :class =" { 'td-nav-item--active': isActiveRoute(item.path) }"
255294 :aria-current =" isActiveRoute(item.path) ? 'page' : undefined"
295+ @click =" closeMobileMenu"
256296 >
257297 <span class =" td-nav-item__icon" >{{ item.icon }}</span >
258298 <span v-if =" !sidebarCollapsed" class =" td-nav-item__label" >{{ item.label }}</span >
@@ -272,6 +312,7 @@ defineExpose({
272312 class =" td-nav-item td-nav-item--secondary"
273313 :class =" { 'td-nav-item--active': isActiveRoute(item.path) }"
274314 :aria-current =" isActiveRoute(item.path) ? 'page' : undefined"
315+ @click =" closeMobileMenu"
275316 >
276317 <span class =" td-nav-item__icon" >{{ item.icon }}</span >
277318 <span v-if =" !sidebarCollapsed" class =" td-nav-item__label" >{{ item.label }}</span >
@@ -490,4 +531,38 @@ defineExpose({
490531 flex-direction : column ;
491532 gap : 1px ;
492533}
534+
535+ /* ─── Mobile overlay ─── */
536+ .td-sidebar-overlay {
537+ display : none ;
538+ }
539+
540+ /* ─── Mobile: sidebar off-canvas ─── */
541+ @media (max-width : 640px ) {
542+ .td-sidebar {
543+ position : fixed ;
544+ top : 0 ;
545+ left : 0 ;
546+ bottom : 0 ;
547+ transform : translateX (-100% );
548+ transition : transform 0.25s ease ;
549+ z-index : 50 ;
550+ }
551+
552+ .td-sidebar--mobile-open {
553+ transform : translateX (0 );
554+ }
555+
556+ .td-sidebar-overlay {
557+ display : block ;
558+ position : fixed ;
559+ inset : 0 ;
560+ background : rgba (0 , 0 , 0 , 0.5 );
561+ z-index : 45 ;
562+ }
563+
564+ .td-nav-item {
565+ min-height : 44px ;
566+ }
567+ }
493568 </style >
0 commit comments