feat: collapse archived lists in sidebar
ci / gitleaks (push) Successful in 6s
ci / docker (push) Successful in 3m32s

This commit is contained in:
2026-09-09 22:23:12 +08:00
parent d49c81212d
commit 63f066681f
5 changed files with 241 additions and 18 deletions
+93 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { import {
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder, ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search, Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
@@ -11,6 +11,7 @@ import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion' import { createCompletionPulse } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { nextDialogFocusIndex } from './lib/list-purge' import { nextDialogFocusIndex } from './lib/list-purge'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue' import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue' import FloatingAddButton from './components/FloatingAddButton.vue'
@@ -31,6 +32,12 @@ const password = ref('')
const folders = ref<FolderItem[]>([]) const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([]) const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([]) const archivedLists = ref<TaskList[]>([])
const archivedListsExpanded = ref(false)
const archivedListAction = ref<TaskList | null>(null)
const archivedListsToggle = ref<HTMLButtonElement | null>(null)
const archivedMenu = ref<HTMLElement | null>(null)
const archivedMenuStyle = ref<Record<string, string>>({})
let archivedListActionTrigger: HTMLElement | null = null
const purgeListTarget = ref<TaskList | null>(null) const purgeListTarget = ref<TaskList | null>(null)
const purgeListSubmitting = ref(false) const purgeListSubmitting = ref(false)
const purgeListError = ref('') const purgeListError = ref('')
@@ -809,19 +816,76 @@ async function loadArchivedLists() {
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] } try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
} }
async function restoreList(item: TaskList) { async function restoreList(item: TaskList) {
archivedListAction.value = null
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) } try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
finally { focusArchivedListTrigger() }
} }
function openPurgeList(item: TaskList, trigger?: EventTarget | null) { function toggleArchivedLists() {
if (!archivedLists.value.length) return
archivedListsExpanded.value = !archivedListsExpanded.value
closeArchivedListAction(false)
}
function focusArchivedListTrigger() {
const target = resolveArchivedMenuFocusTarget(archivedListActionTrigger, archivedListsToggle.value)
archivedListActionTrigger = null
nextTick(() => target?.focus())
}
function updateArchivedMenuPosition() {
if (!archivedListAction.value || !archivedListActionTrigger || !window.matchMedia('(min-width: 931px)').matches) return
const triggerRect = archivedListActionTrigger.getBoundingClientRect()
const menuRect = archivedMenu.value?.getBoundingClientRect()
const position = positionArchivedMenu(triggerRect, menuRect?.width || 164, menuRect?.height || 102, window.innerWidth, window.innerHeight)
archivedMenuStyle.value = { left: `${position.left}px`, top: `${position.top}px` }
}
function closeArchivedListAction(restoreFocus = true) {
if (!archivedListAction.value) return
archivedListAction.value = null
if (restoreFocus) focusArchivedListTrigger()
else archivedListActionTrigger = null
}
function toggleArchivedListAction(item: TaskList, trigger?: EventTarget | null) {
closeSidebarAction()
if (archivedListAction.value?.id === item.id) {
closeArchivedListAction()
return
}
archivedListActionTrigger = trigger instanceof HTMLElement ? trigger : null
archivedListAction.value = item
archivedMenuStyle.value = {}
nextTick(() => {
updateArchivedMenuPosition()
archivedMenu.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
})
}
function handleArchivedListViewportChange() {
if (archivedListAction.value && window.matchMedia('(min-width: 931px)').matches) updateArchivedMenuPosition()
}
function handleArchivedListOutsidePointer(event: PointerEvent) {
if (!archivedListAction.value || !window.matchMedia('(min-width: 931px)').matches) return
const target = event.target
if (target instanceof Element && !target.closest('.archived-row-menu,.archived-row-actions')) closeArchivedListAction(false)
}
function handleArchivedListEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && archivedListAction.value) closeArchivedListAction()
}
function openPurgeList(item: TaskList) {
purgeListTrigger = archivedListActionTrigger
purgeListTarget.value = item purgeListTarget.value = item
purgeListError.value = '' purgeListError.value = ''
purgeListTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement as HTMLElement | null archivedListAction.value = null
archivedListActionTrigger = null
nextTick(() => purgeCancelButton.value?.focus()) nextTick(() => purgeCancelButton.value?.focus())
} }
function focusPurgeListTrigger() {
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
purgeListTrigger = null
nextTick(() => target?.focus())
}
function closePurgeList() { function closePurgeList() {
if (purgeListSubmitting.value) return if (purgeListSubmitting.value) return
purgeListTarget.value = null purgeListTarget.value = null
purgeListError.value = '' purgeListError.value = ''
nextTick(() => purgeListTrigger?.focus()) focusPurgeListTrigger()
} }
function handlePurgeDialogKeydown(event: KeyboardEvent) { function handlePurgeDialogKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList() if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
@@ -841,6 +905,7 @@ async function confirmPurgeList() {
await api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' }) await api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })
archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId) archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)
purgeListTarget.value = null purgeListTarget.value = null
focusPurgeListTrigger()
toast('清单已永久删除') toast('清单已永久删除')
} catch (reason) { } catch (reason) {
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败' purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
@@ -850,6 +915,7 @@ async function confirmPurgeList() {
} }
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null } function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) { function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
closeArchivedListAction(false)
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item } sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
sidebarCreateOpen.value = false sidebarCreateOpen.value = false
} }
@@ -1006,7 +1072,19 @@ function nextPage() {
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
} }
onMounted(bootstrap) onMounted(() => {
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
document.addEventListener('keydown', handleArchivedListEscape)
window.addEventListener('resize', handleArchivedListViewportChange)
document.addEventListener('scroll', handleArchivedListViewportChange, true)
void bootstrap()
})
onUnmounted(() => {
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
document.removeEventListener('keydown', handleArchivedListEscape)
window.removeEventListener('resize', handleArchivedListViewportChange)
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
})
</script> </script>
<template> <template>
@@ -1022,7 +1100,7 @@ onMounted(bootstrap)
</div> </div>
<div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'mobile-sidebar-open': mobileSidebar }"> <div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'mobile-sidebar-open': mobileSidebar }">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" /> <div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeSidebarAction()"> <aside class="sidebar" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
<div class="brand-row"><div class="brand small">do<span>do</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div> <div class="brand-row"><div class="brand small">do<span>do</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
<nav class="primary-nav"> <nav class="primary-nav">
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button> <button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
@@ -1038,10 +1116,12 @@ onMounted(bootstrap)
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div> <div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
</div> </div>
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div> <div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<template v-if="archivedLists.length"> <div class="archived-lists">
<div class="section-title"><span>已归档清单</span></div> <button ref="archivedListsToggle" class="archived-lists-toggle" :class="{ empty: archivedLists.length === 0 }" :aria-expanded="archivedLists.length > 0 && archivedListsExpanded" aria-controls="archived-task-lists" :disabled="archivedLists.length === 0" @click="toggleArchivedLists"><ChevronRight :class="{ expanded: archivedListsExpanded }"/><span>已归档 {{ archivedLists.length }}</span></button>
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button><button class="danger-text" aria-label="永久删除清单" @click="openPurgeList(list,$event.currentTarget)"><Trash2/>永久删除</button></span></div> <div id="archived-task-lists" v-show="archivedListsExpanded" class="archived-list-items">
</template> <div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><span class="archived-row-label" :title="list.name">{{list.name}}</span><span class="archived-row-menu"><button class="archived-row-menu-trigger" :aria-label="`${list.name}操作`" aria-haspopup="menu" :aria-expanded="archivedListAction?.id===list.id" @click="toggleArchivedListAction(list,$event.currentTarget)"><Ellipsis/></button></span></div>
</div>
</div>
</div> </div>
<nav class="sidebar-management" aria-label="管理"> <nav class="sidebar-management" aria-label="管理">
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button> <button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
@@ -1154,6 +1234,9 @@ onMounted(bootstrap)
</Transition> </Transition>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition> <Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div> <div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
<Teleport to="body">
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
</Teleport>
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList"> <div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown"> <section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3> <h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './archived-list-menu'
describe('resolveArchivedMenuFocusTarget', () => {
it('returns the archived row trigger while it remains connected', () => {
const trigger = { isConnected: true } as HTMLElement
const fallback = { isConnected: true } as HTMLButtonElement
expect(resolveArchivedMenuFocusTarget(trigger, fallback)).toBe(trigger)
})
it('falls back to the archived-list disclosure after the row is removed', () => {
const trigger = { isConnected: false } as HTMLElement
const fallback = { isConnected: true } as HTMLButtonElement
expect(resolveArchivedMenuFocusTarget(trigger, fallback)).toBe(fallback)
})
it('returns null when neither focus target is available', () => {
expect(resolveArchivedMenuFocusTarget(null, null)).toBeNull()
})
})
describe('positionArchivedMenu', () => {
it('places the desktop menu below the trigger when it fits', () => {
expect(positionArchivedMenu({ left: 180, right: 224, top: 100, bottom: 144 }, 164, 102, 1280, 800)).toEqual({ left: 60, top: 150 })
})
it('flips above when the menu does not fit below', () => {
expect(positionArchivedMenu({ left: 4, right: 48, top: 730, bottom: 774 }, 164, 102, 320, 800)).toEqual({ left: 8, top: 622 })
})
it('clamps below-placement when the trigger is above the viewport', () => {
expect(positionArchivedMenu({ left: 180, right: 224, top: -200, bottom: -156 }, 164, 102, 1280, 800)).toEqual({ left: 60, top: 8 })
})
it('clamps above-placement when the trigger is below the viewport', () => {
expect(positionArchivedMenu({ left: 180, right: 224, top: 850, bottom: 894 }, 164, 102, 1280, 800)).toEqual({ left: 60, top: 690 })
})
it.each([
{ menuHeight: 104, label: 'nearly fills' },
{ menuHeight: 130, label: 'exceeds' },
])('uses the top margin when the menu $label the available height', ({ menuHeight }) => {
expect(positionArchivedMenu({ left: 180, right: 224, top: 50, bottom: 94 }, 164, menuHeight, 1280, 120)).toEqual({ left: 60, top: 8 })
})
})
+27
View File
@@ -0,0 +1,27 @@
export type ArchivedMenuRect = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
export function positionArchivedMenu(
trigger: ArchivedMenuRect,
menuWidth: number,
menuHeight: number,
viewportWidth: number,
viewportHeight: number,
gap = 6,
margin = 8,
): { left: number; top: number } {
const left = Math.min(Math.max(margin, trigger.right - menuWidth), viewportWidth - menuWidth - margin)
const below = trigger.bottom + gap
const preferredTop = below + menuHeight <= viewportHeight - margin
? below
: trigger.top - menuHeight - gap
const maxTop = Math.max(margin, viewportHeight - menuHeight - margin)
const top = Math.min(Math.max(margin, preferredTop), maxTop)
return { left, top }
}
export function resolveArchivedMenuFocusTarget(
trigger: HTMLElement | null,
fallback: HTMLButtonElement | null,
): HTMLElement | null {
return trigger?.isConnected ? trigger : fallback
}
File diff suppressed because one or more lines are too long
+73 -8
View File
@@ -80,6 +80,71 @@ describe('completion feedback motion', () => {
}) })
}) })
describe('archived task-list disclosure', () => {
it('keeps one collapsed disclosure row even when the archive is empty', () => {
expect(app).toContain('const archivedListsExpanded = ref(false)')
expect(app).toContain('class="archived-lists-toggle"')
expect(app).toContain('已归档 {{ archivedLists.length }}')
expect(app).toContain(':aria-expanded="archivedLists.length > 0 && archivedListsExpanded"')
expect(app).toContain('aria-controls="archived-task-lists"')
expect(app).toContain(':disabled="archivedLists.length === 0"')
expect(app).toContain('id="archived-task-lists"')
})
it('renders compact archived rows with a restore and purge menu', () => {
expect(app).toContain('v-show="archivedListsExpanded"')
expect(app).toContain('class="list-row archived-row"')
expect(app).toContain('class="archived-row-menu-trigger"')
expect(app).toContain('恢复清单')
expect(app).toContain('永久删除清单')
expect(css).toContain('.archived-lists-toggle{min-height:44px;')
expect(css).toContain('.archived-row{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;')
expect(css).toContain('.archived-row-actions{position:fixed;z-index:70;')
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
expect(css).toContain('.archived-row-actions{position:fixed;z-index:61;left:0;right:0;top:auto;bottom:0;')
})
it('preserves the expanded state while restoring or deleting the last list', () => {
const restoreBlock = app.slice(app.indexOf('async function restoreList'), app.indexOf('function openPurgeList'))
const purgeBlock = app.slice(app.indexOf('async function confirmPurgeList'), app.indexOf('function toggleSidebarCreate'))
expect(restoreBlock).not.toContain('archivedListsExpanded.value = false')
expect(purgeBlock).not.toContain('archivedListsExpanded.value = false')
})
it('keeps archived and ordinary sidebar action menus mutually exclusive', () => {
const archivedActionBlock = app.slice(app.indexOf('function toggleArchivedListAction'), app.indexOf('function openPurgeList'))
const sidebarActionBlock = app.slice(app.indexOf('function openSidebarAction'), app.indexOf('function runSidebarCreate'))
expect(archivedActionBlock).toContain('closeSidebarAction()')
expect(sidebarActionBlock).toContain('closeArchivedListAction(false)')
})
it('closes the desktop archived menu from outside pointer input without replacing the mobile mask', () => {
expect(app).toContain("document.addEventListener('pointerdown', handleArchivedListOutsidePointer)")
expect(app).toContain("window.matchMedia('(min-width: 931px)').matches")
expect(app).toContain("target.closest('.archived-row-menu,.archived-row-actions')) closeArchivedListAction(false)")
expect(app).toContain('<Teleport to="body">')
expect(app).toContain('class="archived-action-mask" @click.self="closeArchivedListAction()"')
expect(app).toContain("document.addEventListener('scroll', handleArchivedListViewportChange, true)")
expect(app).toContain("window.addEventListener('resize', handleArchivedListViewportChange)")
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
})
it('restores archive menu focus after Escape and actions, with a fallback after row removal', () => {
expect(app).toContain('const archivedListsToggle = ref<HTMLButtonElement | null>(null)')
expect(app).toContain('let archivedListActionTrigger: HTMLElement | null = null')
expect(app).toContain("document.addEventListener('keydown', handleArchivedListEscape)")
expect(app).toContain('resolveArchivedMenuFocusTarget(archivedListActionTrigger, archivedListsToggle.value)')
expect(app).toContain('ref="archivedListsToggle"')
expect(app).toContain("@click=\"toggleArchivedListAction(list,$event.currentTarget)\"")
expect(app).toContain('purgeListTrigger = archivedListActionTrigger')
expect(app).toContain('focusArchivedListTrigger()')
})
it('disables archive disclosure motion for reduced-motion users', () => {
expect(css).toContain('@media(prefers-reduced-motion:reduce){.archived-lists-toggle svg,.archived-list-items{transition:none}}')
})
})
describe('mobile sheet contract', () => { describe('mobile sheet contract', () => {
it('uses shared roles for details, creation and secondary actions', () => { it('uses shared roles for details, creation and secondary actions', () => {
expect(app).toContain('class="task-compose-mask app-sheet-mask"') expect(app).toContain('class="task-compose-mask app-sheet-mask"')
@@ -629,7 +694,7 @@ describe('sidebar information hierarchy', () => {
expect(app).toContain('aria-label="打开清单操作"') expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"') expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"') expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeSidebarAction()"') expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null') expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
expect(app).not.toContain('aria-label="重命名文件夹" @click="renameEntity') expect(app).not.toContain('aria-label="重命名文件夹" @click="renameEntity')
expect(app).not.toContain('aria-label="重命名清单" @click="renameEntity') expect(app).not.toContain('aria-label="重命名清单" @click="renameEntity')
@@ -724,22 +789,22 @@ describe('sidebar layout', () => {
expect(css).not.toContain('.list-row.list-dragging{touch-action:none}') expect(css).not.toContain('.list-row.list-dragging{touch-action:none}')
}) })
it('only restores archived lists from the explicit restore action', () => { it('only restores archived lists from the explicit restore menu action', () => {
expect(app).toContain('class="list-row-main archived-row-label"') expect(app).toContain('class="archived-row-label"')
expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"') expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"')
expect(app).toContain('aria-label="恢复清单" @click="restoreList(list)"') expect(app).toContain('@click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单')
}) })
it('loads archived lists during bootstrap so archived rows are visible after refresh', () => { it('loads archived lists during bootstrap so archived rows are visible after refresh', () => {
expect(app).toContain('expandedFolders.value = new Set(folders.value.map((folder) => folder.id))\n await loadArchivedLists()\n void preloadCountdowns()\n await loadRestoredView()') expect(app).toContain('expandedFolders.value = new Set(folders.value.map((folder) => folder.id))\n await loadArchivedLists()\n void preloadCountdowns()\n await loadRestoredView()')
}) })
it('styles archived rows through the new list-row-main structure', () => { it('styles archived rows through the compact disclosure structure', () => {
expect(css).toContain('.archived-row .list-row-main>svg{color:#c9a45c}') expect(css).toContain('.archived-row-label{min-width:0;overflow:hidden;text-overflow:ellipsis;')
}) })
it('offers permanent deletion only beside archived lists', () => { it('offers permanent deletion only inside archived list menus', () => {
expect(app).toContain('aria-label="永久删除清单" @click="openPurgeList(list,$event.currentTarget)"') expect(app).toContain('@click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单')
expect(app).toContain("api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })") expect(app).toContain("api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })")
expect(app).not.toMatch(/list\.is_inbox[^\n]*openPurgeList/) expect(app).not.toMatch(/list\.is_inbox[^\n]*openPurgeList/)
}) })