Sidebar becomes a mobile drawer below 768px

The sidebar was flex:0 0 15rem inside .app__body unconditionally, so
on a narrow viewport it and the main content just fought over space.
Below 768px it now leaves the flex flow entirely (position: fixed,
translated off-canvas by default) and slides in as a drawer instead,
leaving .app__main the full width.

- App.vue: a ☰ button in the header (hidden above the breakpoint)
  opens it; a backdrop tap, the drawer's own close button, or any
  navigation (route.fullPath watcher) closes it. State lives in
  App.vue since the toggle button is in the header, not the sidebar.
- AppSidebar.vue: a close button (✕), only visible in the drawer,
  emitting 'close'.
- Desktop (>=768px) layout and behaviour is untouched -- the drawer
  CSS and the toggle button both live behind the same media query.

Drag-and-drop is untouched, as asked -- this only affects layout, and
the sidebar's inbox draggable works the same as before either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 20:54:18 +01:00
co-authored by Claude Sonnet 5
parent acfc35af4c
commit 89a95f1885
4 changed files with 134 additions and 3 deletions
+9
View File
@@ -58,6 +58,15 @@ it also works as a project switcher from anywhere. `<RouterView
:key="route.path">` remounts the view on every path change so switching
projects always does a fresh load.
Below `768px` (`style.css`'s one layout breakpoint) the sidebar becomes an
off-canvas drawer instead of sitting beside the page: `position: fixed`,
translated out of view by default, slid in via a `.sidebar--open` class.
`App.vue` owns the `drawerOpen` state -- a `☰` button in the header (hidden
above the breakpoint) opens it; a backdrop tap, the drawer's own `✕` (which
`AppSidebar` emits `close` for), or any navigation (a `route.fullPath`
watcher) closes it. Above the breakpoint `drawerOpen` just goes unused, since
nothing renders the button that would set it.
`/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking
to each project (title + card count), with a **"Create a project" tile**
styled to match sitting last in the same grid (creating one stays on the
+28 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue'
import PasskeyNotice from './components/PasskeyNotice.vue'
@@ -19,6 +19,14 @@ const route = useRoute()
// profile) and stays mounted as you navigate between them.
const showSidebar = computed(() => auth.isAuthenticated && route.meta.requiresAuth === true)
// On narrow viewports the sidebar becomes an off-canvas drawer (see
// .sidebar's mobile rules in style.css), opened via the header's "menu"
// button; on wide viewports drawerOpen is never set, so it's inert (the
// button that sets it is itself hidden there). Close it on every navigation
// so it doesn't stay open over the page it was just used to get to.
const drawerOpen = ref(false)
watch(() => route.fullPath, () => (drawerOpen.value = false))
async function onLogout() {
auth.logout()
projects.reset()
@@ -31,7 +39,20 @@ async function onLogout() {
<template>
<div class="app">
<header class="app__bar">
<span class="app__brand">Projects</span>
<div class="app__bar-left">
<button
v-if="showSidebar"
type="button"
class="app__menu-toggle"
aria-label="Open menu"
aria-haspopup="true"
:aria-expanded="drawerOpen"
@click="drawerOpen = true"
>
&#9776;
</button>
<span class="app__brand">Projects</span>
</div>
<div v-if="auth.isAuthenticated" class="app__account">
<RouterLink to="/profile" class="app__email">{{ auth.user?.email }}</RouterLink>
@@ -42,7 +63,11 @@ async function onLogout() {
<PasskeyNotice />
<div class="app__body">
<AppSidebar v-if="showSidebar" />
<!-- Mobile only (see style.css): dims the page and closes the drawer
on tap, while the sidebar is a fixed off-canvas panel over it. -->
<div v-if="showSidebar && drawerOpen" class="app__drawer-backdrop" @click="drawerOpen = false" />
<AppSidebar v-if="showSidebar" :class="{ 'sidebar--open': drawerOpen }" @close="drawerOpen = false" />
<main class="app__main" :class="{ 'app__main--wide': route.meta.wide }">
<!-- Key by path so navigating between projects via the sidebar remounts
+7
View File
@@ -16,6 +16,11 @@ const inbox = useInboxStore()
const router = useRouter()
const route = useRoute()
// Only meaningful on mobile, where the sidebar is a drawer (see style.css) --
// its own close button emits this to tell App.vue to close it (App.vue also
// closes it directly on a backdrop tap or any navigation).
const emit = defineEmits<{ close: [] }>()
const loadError = ref<string | null>(null)
onMounted(() => {
@@ -96,6 +101,8 @@ async function onInboxChange(change: ColumnChange) {
<template>
<aside class="sidebar">
<button type="button" class="sidebar__close" aria-label="Close menu" @click="emit('close')">&#10005;</button>
<nav class="sidebar__nav">
<RouterLink :to="{ name: 'dashboard' }" class="sidebar__link" active-class="sidebar__link--active">
<svg class="sidebar__icon" viewBox="0 0 16 16" aria-hidden="true">
+90
View File
@@ -62,10 +62,36 @@ body {
border-bottom: 1px solid var(--border);
}
.app__bar-left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.app__brand {
font-weight: 600;
}
/* Opens the sidebar drawer on mobile (see the sidebar's own mobile rules
below) -- hidden here, shown only under that same breakpoint. */
.app__menu-toggle {
display: none;
flex: none;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 8px;
padding: 0.35rem 0.55rem;
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.app__menu-toggle:hover {
border-color: var(--muted);
}
.app__account {
display: flex;
align-items: center;
@@ -118,6 +144,70 @@ body {
background: var(--surface);
}
/* Only shown once .sidebar becomes a drawer, under the mobile breakpoint
below -- hidden here. */
.sidebar__close {
display: none;
position: absolute;
top: 0.75rem;
right: 0.75rem;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
padding: 0.3rem 0.5rem;
border-radius: 6px;
}
.sidebar__close:hover {
background: var(--bg);
color: var(--text);
}
/* --- mobile: sidebar becomes an off-canvas drawer ---------------------- */
@media (max-width: 768px) {
.app__menu-toggle {
display: inline-flex;
}
.app__body {
padding: 1rem;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 200;
width: 85vw;
max-width: 20rem;
height: 100vh;
border: none;
border-radius: 0;
border-right: 1px solid var(--border);
padding-top: 2.75rem;
transform: translateX(-100%);
transition: transform 0.2s ease;
}
.sidebar--open {
transform: translateX(0);
}
.sidebar__close {
display: block;
}
.app__drawer-backdrop {
position: fixed;
inset: 0;
z-index: 150;
background: rgba(0, 0, 0, 0.45);
}
}
.sidebar__nav {
display: flex;
flex-direction: column;