feat: collapse archived lists in sidebar
This commit is contained in:
+93
-10
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
|
||||
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 { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
import CountdownPanel from './CountdownPanel.vue'
|
||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||
@@ -31,6 +32,12 @@ const password = ref('')
|
||||
const folders = ref<FolderItem[]>([])
|
||||
const lists = 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 purgeListSubmitting = ref(false)
|
||||
const purgeListError = ref('')
|
||||
@@ -809,19 +816,76 @@ async function loadArchivedLists() {
|
||||
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
|
||||
}
|
||||
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) }
|
||||
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
|
||||
purgeListError.value = ''
|
||||
purgeListTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement as HTMLElement | null
|
||||
archivedListAction.value = null
|
||||
archivedListActionTrigger = null
|
||||
nextTick(() => purgeCancelButton.value?.focus())
|
||||
}
|
||||
function focusPurgeListTrigger() {
|
||||
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
|
||||
purgeListTrigger = null
|
||||
nextTick(() => target?.focus())
|
||||
}
|
||||
function closePurgeList() {
|
||||
if (purgeListSubmitting.value) return
|
||||
purgeListTarget.value = null
|
||||
purgeListError.value = ''
|
||||
nextTick(() => purgeListTrigger?.focus())
|
||||
focusPurgeListTrigger()
|
||||
}
|
||||
function handlePurgeDialogKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
|
||||
@@ -841,6 +905,7 @@ async function confirmPurgeList() {
|
||||
await api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })
|
||||
archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)
|
||||
purgeListTarget.value = null
|
||||
focusPurgeListTrigger()
|
||||
toast('清单已永久删除')
|
||||
} catch (reason) {
|
||||
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
|
||||
@@ -850,6 +915,7 @@ async function confirmPurgeList() {
|
||||
}
|
||||
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
|
||||
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||
closeArchivedListAction(false)
|
||||
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
|
||||
sidebarCreateOpen.value = false
|
||||
}
|
||||
@@ -1006,7 +1072,19 @@ function nextPage() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -1022,7 +1100,7 @@ onMounted(bootstrap)
|
||||
</div>
|
||||
<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" />
|
||||
<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>
|
||||
<nav class="primary-nav">
|
||||
<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>
|
||||
<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="section-title"><span>已归档清单</span></div>
|
||||
<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>
|
||||
</template>
|
||||
<div class="archived-lists">
|
||||
<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 id="archived-task-lists" v-show="archivedListsExpanded" class="archived-list-items">
|
||||
<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>
|
||||
<nav class="sidebar-management" aria-label="管理">
|
||||
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
@@ -1154,6 +1234,9 @@ onMounted(bootstrap)
|
||||
</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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user