chore: merge executor worktree (worktree-agent-ae52d6537a4569901)
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
---
|
||||
phase: 11-visual-design-responsive-layout-cleanup
|
||||
plan: 3
|
||||
subsystem: frontend/responsive
|
||||
tags: [responsive, layout, drawer, touch-targets, RESP-01, RESP-02, RESP-03, RESP-05]
|
||||
dependency_graph:
|
||||
requires: [11-02]
|
||||
provides: [responsive-user-shell, responsive-admin-shell, responsive-storage-rows, touch-targets]
|
||||
affects:
|
||||
- frontend/src/App.vue
|
||||
- frontend/src/layouts/AdminLayout.vue
|
||||
- frontend/src/components/storage/StorageBrowser.vue
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Hamburger button + slide-in overlay drawer with Teleport backdrop (translate-x-0/-translate-x-full)"
|
||||
- "Drawer state owned by layout root (App.vue / AdminLayout.vue), not sidebar component (D-04/D-05)"
|
||||
- "Route-change watcher closes drawer automatically on navigation"
|
||||
- "Responsive grid-cols variants: mobile base, sm (+modified), md (all 5 columns)"
|
||||
- "Touch target floor: min-w-[36px] min-h-[36px] on action buttons"
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- frontend/src/App.vue
|
||||
- frontend/src/layouts/AdminLayout.vue
|
||||
- frontend/src/components/storage/StorageBrowser.vue
|
||||
- frontend/src/__tests__/keyboard.test.js
|
||||
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js
|
||||
decisions:
|
||||
- "Drawer state in App.vue ref (not Pinia, not AppSidebar) — satisfies D-04/D-05 pitfall constraint"
|
||||
- "Backdrop teleported to <body> via <Teleport to='body'> — consistent with Phase 10 modal/toast pattern"
|
||||
- "Grid templates use mobile-first responsive variants instead of a fixed 5-column layout — prevents horizontal overflow at 375px"
|
||||
- "Touch targets applied via min-w/min-h classes at all breakpoints, removed with md:min-w-0 md:min-h-0 at desktop — desktop appearance unchanged"
|
||||
metrics:
|
||||
duration_minutes: 9
|
||||
tasks_completed: 8
|
||||
files_created: 0
|
||||
files_modified: 5
|
||||
completed_date: "2026-06-16"
|
||||
---
|
||||
|
||||
# Phase 11 Plan 3: Responsive Shells & Storage Rows Summary
|
||||
|
||||
Implemented mobile-first responsive shells for both user and admin layouts using hamburger-triggered slide-in overlay drawers, and made StorageBrowser rows fit small viewports without horizontal overflow.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Tasks 1-3 — App.vue: user layout responsive shell (RESP-01)
|
||||
|
||||
`frontend/src/App.vue` updated:
|
||||
|
||||
- **Drawer state:** `drawerOpen = ref(false)` owned by `App.vue` — satisfies D-04/D-05 constraint (never put in `AppSidebar`).
|
||||
- **Route-change close:** `watch(() => route.fullPath, ...)` sets `drawerOpen.value = false` on every navigation so link taps auto-close the drawer.
|
||||
- **Mobile header:** `<header class="lg:hidden fixed ...">` contains the hamburger button (`data-test="hamburger-btn"`) and the DocuVault wordmark. Only shown below `lg`.
|
||||
- **Backdrop:** `<Teleport to="body">` wraps a semi-transparent overlay `<div>` that appears when `drawerOpen` is true and calls `drawerOpen = false` on click. Uses `data-test="drawer-backdrop"`.
|
||||
- **Sidebar wrapper:** `fixed inset-y-0 left-0 z-50` positioning for mobile, `lg:static lg:z-auto lg:translate-x-0` for desktop. Transition: `translate-x-0` (open) / `-translate-x-full` (closed) via `transition-transform duration-200`. Attribute `data-test="app-sidebar-wrapper"`.
|
||||
- **Main content:** `pt-[53px] lg:pt-0` offset on `<main>` so mobile content doesn't hide under the fixed header.
|
||||
|
||||
### Task 4 — AdminLayout.vue: admin layout responsive shell (RESP-05)
|
||||
|
||||
`frontend/src/layouts/AdminLayout.vue` mirrors the user layout pattern exactly:
|
||||
|
||||
- `drawerOpen = ref(false)` + `watch(() => route.fullPath, ...)` for auto-close on navigation.
|
||||
- Mobile header shows "DocuVault" + "Admin" label with `data-test="admin-hamburger-btn"`.
|
||||
- Teleport backdrop with `data-test="admin-drawer-backdrop"`.
|
||||
- Sidebar wrapper with `data-test="admin-sidebar-wrapper"` and identical transition classes.
|
||||
- `useRoute` import added; no `useRouter` needed (AdminLayout itself doesn't navigate).
|
||||
|
||||
### Tasks 5-6 — StorageBrowser.vue: responsive grid and touch targets (RESP-02, RESP-03)
|
||||
|
||||
`frontend/src/components/storage/StorageBrowser.vue` updated:
|
||||
|
||||
**Responsive grid templates** (replaces fixed `grid-cols-[2rem_1fr_6rem_8rem_6rem]` everywhere):
|
||||
|
||||
| Breakpoint | Grid template | Visible columns |
|
||||
|---|---|---|
|
||||
| Default (< sm, 375px) | `grid-cols-[2rem_1fr_6rem]` | icon, name, actions |
|
||||
| sm (640px+) | `sm:grid-cols-[2rem_1fr_8rem_6rem]` | + modified date |
|
||||
| md (768px+) | `md:grid-cols-[2rem_1fr_6rem_8rem_6rem]` | + size |
|
||||
|
||||
Applied to: list header row, new-folder input row, folder rows, file rows, skeleton rows.
|
||||
|
||||
Added `data-test="list-header"` to the column header row for testability.
|
||||
|
||||
**Touch targets** (RESP-03, satisfies 36px minimum):
|
||||
|
||||
All inline action buttons (Rename, Delete for folders; Share, Move, Delete for files) now have:
|
||||
- `min-w-[36px] min-h-[36px]` — enforces 36×36px minimum hit area on mobile
|
||||
- `md:min-w-0 md:min-h-0` — removes the override at desktop so padding-only sizing applies
|
||||
- `flex items-center justify-center` — keeps icon centered within the larger target
|
||||
|
||||
### Task 7 — Tests
|
||||
|
||||
**`frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`** updated:
|
||||
|
||||
- Replaced old `grid-cols-[2rem_1fr_6rem_8rem_6rem]` assertion (now broken by responsive refactor) with two tests: mobile base class `grid-cols-[2rem_1fr_6rem]` and md breakpoint class `md:grid-cols-[2rem_1fr_6rem_8rem_6rem]`.
|
||||
- Added new `RESP-02/RESP-03` describe block (8 tests): list header mobile/md classes, folder row, file row, `hidden md:block` size column, `hidden sm:block` modified column, file action button `min-w-[36px]`/`min-h-[36px]`, folder action button touch targets.
|
||||
|
||||
**`frontend/src/__tests__/keyboard.test.js`** extended:
|
||||
|
||||
- Added `RESP-01: App drawer` describe (2 tests): hamburger open/backdrop-close behavior via stub component; route-change watcher closes drawer.
|
||||
- Added `RESP-05: AdminLayout drawer` describe (3 tests): admin hamburger renders, admin backdrop-close, admin route-change watcher.
|
||||
- `afterEach` import added; `nextTick` import added.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm test`: 30 test files, 233 tests pass (219 baseline + 14 new from this plan)
|
||||
- `npm run build`: succeeds — 5 JS chunks + main bundle, no new errors
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — no network endpoints, auth paths, or schema changes in this plan. All changes are frontend layout/presentation only.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `frontend/src/App.vue`: modified — hamburger header + drawer + Teleport backdrop
|
||||
- `frontend/src/layouts/AdminLayout.vue`: modified — admin responsive shell
|
||||
- `frontend/src/components/storage/StorageBrowser.vue`: modified — responsive grid + touch targets
|
||||
- `frontend/src/__tests__/keyboard.test.js`: modified — drawer tests added
|
||||
- `frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js`: modified — responsive assertions
|
||||
- Commit `d914761` exists in git log: confirmed
|
||||
- All 30 test files pass: confirmed
|
||||
- Build succeeds: confirmed
|
||||
+44
-3
@@ -2,8 +2,41 @@
|
||||
<AuthLayout v-if="route.meta.layout === 'auth'" />
|
||||
<router-view v-else-if="route.matched.some(r => r.meta.requiresAdmin)" />
|
||||
<div v-else class="flex h-screen overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<!-- Mobile-only top header with hamburger -->
|
||||
<header class="lg:hidden fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-200">
|
||||
<button
|
||||
@click="drawerOpen = true"
|
||||
aria-label="Open navigation"
|
||||
data-test="hamburger-btn"
|
||||
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-base font-bold text-indigo-600 tracking-tight">DocuVault</span>
|
||||
</header>
|
||||
|
||||
<!-- Drawer backdrop (mobile only) — teleported to body to guarantee stacking -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="drawerOpen"
|
||||
class="lg:hidden fixed inset-0 z-40 bg-black/40"
|
||||
data-test="drawer-backdrop"
|
||||
@click="drawerOpen = false"
|
||||
></div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Sidebar: always visible on lg+; slides in as overlay drawer below lg -->
|
||||
<div
|
||||
class="fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-in-out lg:static lg:z-auto lg:translate-x-0 shrink-0"
|
||||
:class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
data-test="app-sidebar-wrapper"
|
||||
>
|
||||
<AppSidebar />
|
||||
</div>
|
||||
|
||||
<main class="flex-1 overflow-y-auto pt-[53px] lg:pt-0">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
@@ -12,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import AuthLayout from './layouts/AuthLayout.vue'
|
||||
@@ -24,6 +57,14 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const topicsStore = useTopicsStore()
|
||||
|
||||
// Drawer state owned by App.vue (per D-04/D-05 — not in AppSidebar)
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
// Close drawer on route change (navigation tap closes the drawer automatically)
|
||||
watch(() => route.fullPath, () => {
|
||||
drawerOpen.value = false
|
||||
})
|
||||
|
||||
function getFileManagerInstance() {
|
||||
return router.currentRoute.value.matched.find(r => r.instances?.default)?.instances?.default ?? null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
import FileManagerView from '../views/FileManagerView.vue'
|
||||
|
||||
vi.mock('../api/client.js', () => ({
|
||||
@@ -157,3 +158,130 @@ describe('Gap 4: getFileManagerInstance resolves to actual component, not Router
|
||||
expect(typeof instance.focusSearch).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RESP-01: Responsive shell drawer tests (App.vue / AdminLayout.vue)
|
||||
// These tests exercise the drawer open/close logic defined in App.vue and
|
||||
// AdminLayout.vue without mounting the full shell (which requires auth).
|
||||
// Instead, they validate the pure ref+watch behaviour via a minimal component
|
||||
// that mirrors the drawer state implementation.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
vi.mock('../stores/cloudConnections.js', () => ({
|
||||
useCloudConnectionsStore: () => ({
|
||||
connections: [],
|
||||
loading: false,
|
||||
fetchConnections: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('RESP-01: App drawer — open, backdrop close, route-change close', () => {
|
||||
it('hamburger button element can be rendered via data-test attribute', async () => {
|
||||
// Verify the data-test attribute we rely on in App.vue is consistent
|
||||
const { defineComponent, ref, h } = await import('vue')
|
||||
const Stub = defineComponent({
|
||||
setup() {
|
||||
const drawerOpen = ref(false)
|
||||
return { drawerOpen }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button data-test="hamburger-btn" @click="drawerOpen = true">Menu</button>
|
||||
<div v-if="drawerOpen" data-test="drawer-backdrop" @click="drawerOpen = false"></div>
|
||||
<div data-test="app-sidebar-wrapper" :class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"></div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
const w = mount(Stub)
|
||||
expect(w.find('[data-test="hamburger-btn"]').exists()).toBe(true)
|
||||
// Initially drawer closed
|
||||
expect(w.find('[data-test="drawer-backdrop"]').exists()).toBe(false)
|
||||
expect(w.find('[data-test="app-sidebar-wrapper"]').classes()).toContain('-translate-x-full')
|
||||
// Click hamburger
|
||||
await w.find('[data-test="hamburger-btn"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(w.find('[data-test="drawer-backdrop"]').exists()).toBe(true)
|
||||
expect(w.find('[data-test="app-sidebar-wrapper"]').classes()).toContain('translate-x-0')
|
||||
// Click backdrop to close
|
||||
await w.find('[data-test="drawer-backdrop"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(w.find('[data-test="drawer-backdrop"]').exists()).toBe(false)
|
||||
expect(w.find('[data-test="app-sidebar-wrapper"]').classes()).toContain('-translate-x-full')
|
||||
})
|
||||
|
||||
it('drawer state closes on route fullPath change (watch behaviour)', async () => {
|
||||
const { defineComponent, ref, watch } = await import('vue')
|
||||
// Simulate the watch pattern from App.vue
|
||||
const routePath = ref('/')
|
||||
const drawerOpen = ref(false)
|
||||
watch(() => routePath.value, () => { drawerOpen.value = false })
|
||||
|
||||
drawerOpen.value = true
|
||||
expect(drawerOpen.value).toBe(true)
|
||||
// Simulate navigation
|
||||
routePath.value = '/settings'
|
||||
await nextTick()
|
||||
expect(drawerOpen.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RESP-05: AdminLayout drawer — same pattern as user drawer', () => {
|
||||
it('admin hamburger button renders with data-test="admin-hamburger-btn"', async () => {
|
||||
const { defineComponent, ref } = await import('vue')
|
||||
const Stub = defineComponent({
|
||||
setup() {
|
||||
const drawerOpen = ref(false)
|
||||
return { drawerOpen }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button data-test="admin-hamburger-btn" @click="drawerOpen = true">Menu</button>
|
||||
<div v-if="drawerOpen" data-test="admin-drawer-backdrop" @click="drawerOpen = false"></div>
|
||||
<div data-test="admin-sidebar-wrapper" :class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"></div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
const w = mount(Stub)
|
||||
expect(w.find('[data-test="admin-hamburger-btn"]').exists()).toBe(true)
|
||||
await w.find('[data-test="admin-hamburger-btn"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(w.find('[data-test="admin-drawer-backdrop"]').exists()).toBe(true)
|
||||
expect(w.find('[data-test="admin-sidebar-wrapper"]').classes()).toContain('translate-x-0')
|
||||
})
|
||||
|
||||
it('admin drawer closes on backdrop tap', async () => {
|
||||
const { defineComponent, ref } = await import('vue')
|
||||
const Stub = defineComponent({
|
||||
setup() {
|
||||
const drawerOpen = ref(false)
|
||||
return { drawerOpen }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button data-test="admin-hamburger-btn" @click="drawerOpen = true">Menu</button>
|
||||
<div v-if="drawerOpen" data-test="admin-drawer-backdrop" @click="drawerOpen = false"></div>
|
||||
<div data-test="admin-sidebar-wrapper" :class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"></div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
const w = mount(Stub)
|
||||
await w.find('[data-test="admin-hamburger-btn"]').trigger('click')
|
||||
await nextTick()
|
||||
await w.find('[data-test="admin-drawer-backdrop"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(w.find('[data-test="admin-drawer-backdrop"]').exists()).toBe(false)
|
||||
expect(w.find('[data-test="admin-sidebar-wrapper"]').classes()).toContain('-translate-x-full')
|
||||
})
|
||||
|
||||
it('admin drawer closes on route change (watch behaviour)', async () => {
|
||||
const { ref, watch } = await import('vue')
|
||||
const routePath = ref('/admin')
|
||||
const drawerOpen = ref(true)
|
||||
watch(() => routePath.value, () => { drawerOpen.value = false })
|
||||
|
||||
expect(drawerOpen.value).toBe(true)
|
||||
routePath.value = '/admin/users'
|
||||
await nextTick()
|
||||
expect(drawerOpen.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
</div>
|
||||
|
||||
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none">
|
||||
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none" data-test="list-header">
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
<span class="text-right hidden md:block">Size</span>
|
||||
@@ -43,7 +43,7 @@
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
|
||||
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
|
||||
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||
<AppIcon name="folder" class="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
@@ -68,7 +68,7 @@
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="`f-${folder.id}`"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
|
||||
:class="{
|
||||
'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id,
|
||||
'bg-gray-50': renamingId === folder.id,
|
||||
@@ -100,11 +100,11 @@
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<button @click.stop="startRename(folder)" title="Rename"
|
||||
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
|
||||
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center">
|
||||
<AppIcon name="pencil" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
|
||||
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
|
||||
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center">
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -116,7 +116,7 @@
|
||||
v-for="file in files"
|
||||
:key="`d-${file.id}`"
|
||||
:draggable="mode === 'local'"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
|
||||
:class="{ 'opacity-50': draggingFile?.id === file.id }"
|
||||
@click="draggingFile ? null : $emit('file-open', file)"
|
||||
@dragstart="mode === 'local' ? onFileDragStart(file, $event) : null"
|
||||
@@ -147,20 +147,20 @@
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<button @click.stop="$emit('file-share', file)" title="Share"
|
||||
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
|
||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center">
|
||||
<AppIcon name="share" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="openFolderPicker(file.id, $event)"
|
||||
title="Move to folder"
|
||||
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
|
||||
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
|
||||
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center">
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -171,7 +171,7 @@
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="`sk-${n}`"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100"
|
||||
>
|
||||
<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>
|
||||
<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>
|
||||
|
||||
@@ -46,12 +46,23 @@ describe('UX-02: StorageBrowser shows skeleton rows during loading', () => {
|
||||
expect(pulseEls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('skeleton row grid matches grid-cols-[2rem_1fr_6rem_8rem_6rem]', () => {
|
||||
it('skeleton row has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { loading: true, folders: [], files: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const skeletonGrids = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]')
|
||||
// Responsive grid: mobile base class (replaces old fixed grid-cols-[2rem_1fr_6rem_8rem_6rem])
|
||||
const skeletonGrids = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem\\]')
|
||||
expect(skeletonGrids.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('skeleton row has md breakpoint full grid-cols-[2rem_1fr_6rem_8rem_6rem] class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { loading: true, folders: [], files: [] },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
// Responsive grid: full-desktop class at md breakpoint
|
||||
const skeletonGrids = wrapper.findAll('.md\\:grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]')
|
||||
expect(skeletonGrids.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -68,6 +79,94 @@ const SAMPLE_FILE = {
|
||||
|
||||
const SAMPLE_FOLDER = { id: 'folder-1', name: 'Archive', doc_count: 0 }
|
||||
|
||||
describe('RESP-02/RESP-03: StorageBrowser responsive column classes and touch targets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('list header has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const header = wrapper.find('[data-test="list-header"]')
|
||||
expect(header.exists()).toBe(true)
|
||||
expect(header.classes()).toContain('grid-cols-[2rem_1fr_6rem]')
|
||||
})
|
||||
|
||||
it('list header has md breakpoint full-column class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const header = wrapper.find('[data-test="list-header"]')
|
||||
expect(header.classes()).toContain('md:grid-cols-[2rem_1fr_6rem_8rem_6rem]')
|
||||
})
|
||||
|
||||
it('folder row has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const folderRows = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem\\]')
|
||||
// At least the folder row and header exist
|
||||
expect(folderRows.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('file row has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const fileRows = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem\\]')
|
||||
expect(fileRows.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('size column is hidden below md (has hidden md:block classes)', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const sizeSpans = wrapper.findAll('.hidden.md\\:block')
|
||||
expect(sizeSpans.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('modified column is hidden below sm (has hidden sm:block classes)', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const modifiedSpans = wrapper.findAll('.hidden.sm\\:block')
|
||||
expect(modifiedSpans.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('file action buttons have min-w-[36px] touch target class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const actionBtns = wrapper.findAll('button[title="Delete"], button[title="Share"]')
|
||||
expect(actionBtns.length).toBeGreaterThanOrEqual(1)
|
||||
for (const btn of actionBtns) {
|
||||
expect(btn.classes()).toContain('min-w-[36px]')
|
||||
expect(btn.classes()).toContain('min-h-[36px]')
|
||||
}
|
||||
})
|
||||
|
||||
it('folder action buttons have min-w-[36px] touch target class', () => {
|
||||
const wrapper = mount(StorageBrowser, {
|
||||
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
|
||||
global: { stubs: globalStubs },
|
||||
})
|
||||
const renameBtns = wrapper.findAll('button[title="Rename"]')
|
||||
expect(renameBtns.length).toBeGreaterThanOrEqual(1)
|
||||
for (const btn of renameBtns) {
|
||||
expect(btn.classes()).toContain('min-w-[36px]')
|
||||
expect(btn.classes()).toContain('min-h-[36px]')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<AdminSidebar />
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<!-- Mobile-only top header with hamburger (admin) -->
|
||||
<header class="lg:hidden fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-4 py-3 bg-white border-b border-gray-200">
|
||||
<button
|
||||
@click="drawerOpen = true"
|
||||
aria-label="Open admin navigation"
|
||||
data-test="admin-hamburger-btn"
|
||||
class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-base font-bold text-indigo-600 tracking-tight">DocuVault</span>
|
||||
<span class="text-xs font-semibold text-indigo-500">Admin</span>
|
||||
</header>
|
||||
|
||||
<!-- Drawer backdrop (mobile only) — teleported to body to guarantee stacking -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="drawerOpen"
|
||||
class="lg:hidden fixed inset-0 z-40 bg-black/40"
|
||||
data-test="admin-drawer-backdrop"
|
||||
@click="drawerOpen = false"
|
||||
></div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Sidebar: always visible on lg+; slides in as overlay drawer below lg -->
|
||||
<div
|
||||
class="fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-in-out lg:static lg:z-auto lg:translate-x-0 shrink-0"
|
||||
:class="drawerOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
data-test="admin-sidebar-wrapper"
|
||||
>
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
<main class="flex-1 overflow-y-auto pt-[53px] lg:pt-0">
|
||||
<div class="p-8 max-w-5xl mx-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
@@ -10,5 +44,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AdminSidebar from '../components/admin/AdminSidebar.vue'
|
||||
|
||||
// Drawer state owned by AdminLayout (per D-04/D-05 — not in AdminSidebar)
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Close drawer on route change (navigation tap closes the drawer automatically)
|
||||
watch(() => route.fullPath, () => {
|
||||
drawerOpen.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user