feat(11-03): responsive shells and storage rows

- App.vue: mobile header with hamburger button; slide-in overlay drawer
  with Teleport backdrop, translate-x-0/-translate-x-full transition;
  drawer state owned by App.vue (D-04/D-05); route-change auto-close
- AdminLayout.vue: matching responsive treatment — hamburger, backdrop,
  drawer, route-change close (RESP-05)
- StorageBrowser.vue: responsive grid templates
  (mobile: grid-cols-[2rem_1fr_6rem], sm: +modified, md: all 5 cols);
  Size hidden below md, Modified hidden below sm; action buttons get
  min-w-[36px] min-h-[36px] touch targets (RESP-02, RESP-03)
- Tests: drawer open/close/backdrop-close/route-change behaviour;
  admin drawer; responsive column and touch-target class assertions
This commit is contained in:
curo1305
2026-06-16 21:22:59 +02:00
parent 83cdf28231
commit d914761120
5 changed files with 332 additions and 18 deletions
@@ -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({