fix: sheet Escape & focus management, detail a11y, 44px touch targets
ci / gitleaks (push) Successful in 44s
ci / docker (push) Successful in 5m43s

- AppSheet: Escape closes any sheet branch; non-modal/inline sheets move
  focus into panel on open; modal<->inline transition keeps overlay stack
- Task detail: initial focus on close button; note textarea aria-label
- Global sr-only status region for toast announcements (visual toast hidden
  from AT to avoid double reading)
- Focus view: drop duplicate topbar h1 (page keeps its own single h1)
- Archived lists toggle hidden until loaded (no more 'archived 0' flash)
- Touch: date-clear/time-remove 44px; edit/preview switch hit area 44px
  with 3px gap; calendar filter row gets a scroll-right mask hint
This commit is contained in:
2026-09-23 08:29:07 +08:00
parent 64f25a39c0
commit 28a97d9f5a
8 changed files with 44 additions and 23 deletions
+8 -5
View File
@@ -45,6 +45,7 @@ const password = ref('')
const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([])
const archivedListsLoaded = ref(false)
const archivedListsExpanded = ref(false)
const archivedListAction = ref<TaskList | null>(null)
const archivedListsToggle = ref<HTMLButtonElement | null>(null)
@@ -1360,6 +1361,7 @@ async function deleteFolder(item: FolderItem) {
}
async function loadArchivedLists() {
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
archivedListsLoaded.value = true
}
async function restoreList(item: TaskList) {
archivedListAction.value = null
@@ -1756,7 +1758,7 @@ onUnmounted(() => {
</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 draggable-list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="listEditorTarget?.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<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>
<button v-if="archivedListsLoaded" 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>
@@ -1803,7 +1805,7 @@ onUnmounted(() => {
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div v-if="!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
<div v-if="!['today','tasks','upcoming','trash','habits','settings','focus'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
</header>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" />
@@ -1877,7 +1879,7 @@ onUnmounted(() => {
</main>
<div v-if="selectedTask || habitDetailOpen || calendarDetailOpen" class="pane-resizer pane-resizer--detail" role="separator" :aria-label="detailSeparatorLabel" aria-orientation="vertical" aria-valuemin="300" :aria-valuemax="detailMaxWidth" :aria-valuenow="detailWidth" tabindex="0" @pointerdown="startPaneResize('detail',$event)" @lostpointercapture="handlePaneLostPointerCapture" @keydown="resizePaneWithKeyboard('detail',$event)" />
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" initial-focus="button[aria-label='关闭详情']" :close-on-scrim="compactLayout" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
<fieldset class="detail-form" :disabled="taskDetailBusy">
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
@@ -1898,7 +1900,7 @@ onUnmounted(() => {
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button><button type="button" aria-label="粗体" title="粗体" @click="formatTaskNote('bold')"><Bold/></button><button type="button" aria-label="斜体" title="斜体" @click="formatTaskNote('italic')"><Italic/></button><button type="button" aria-label="无序列表" title="无序列表" @click="formatTaskNote('bullet')"><List/></button><button type="button" aria-label="有序列表" title="有序列表" @click="formatTaskNote('ordered')"><ListOrdered/></button><button type="button" aria-label="待办" title="待办" @click="formatTaskNote('task')"><ListChecks/></button><button type="button" aria-label="链接" title="链接" @click="formatTaskNote('link')"><Link/></button><button type="button" aria-label="行内代码" title="行内代码" @click="formatTaskNote('code')"><Code/></button><button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatTaskNote('codeblock')">{ }</button><button type="button" aria-label="引用" title="引用" @click="formatTaskNote('quote')"><Quote/></button>
</div>
<textarea ref="taskNoteEditor" v-model="selectedTask.description" rows="9" placeholder="写备注选中文字后可用上方工具栏添加格式" @keydown="handleTaskNoteShortcut"/>
<textarea ref="taskNoteEditor" v-model="selectedTask.description" rows="9" aria-label="任务备注" placeholder="写备注选中文字后可用上方工具栏添加格式" @keydown="handleTaskNoteShortcut"/>
</div>
<div v-else class="markdown-preview" :class="{'markdown-preview-empty':!selectedTask.description.trim()}" v-html="selectedTask.description.trim() ? renderMarkdown(selectedTask.description) : '<p>暂无备注,切回编辑开始书写。</p>'"/>
</section>
@@ -1934,7 +1936,8 @@ onUnmounted(() => {
</div>
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="creatingTask" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="creatingTask || !composeTitle.trim() || !composeListId">{{ creatingTask ? '正在添加…' : '添加任务' }}</button></footer>
</AppSheet>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div class="sr-only" role="status" aria-live="polite">{{ notice }}</div>
<Transition name="toast"><div v-if="notice" class="toast" aria-hidden="true">{{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="trashAction" class="trash-action-mask" @click.self="closeTrashAction()"><span ref="trashMenu" class="trash-action-menu" role="menu" aria-label="回收站任务操作" @keydown.esc.stop="closeTrashAction()" @keydown.tab.prevent="moveTrashMenuFocus($event.shiftKey?-1:1)" @keydown.down.prevent="moveTrashMenuFocus(1)" @keydown.up.prevent="moveTrashMenuFocus(-1)"><span class="trash-action-title" :title="trashAction.title">{{trashAction.title}}</span><button role="menuitem" @click="requestRestoreTask"><ArchiveRestore/>恢复</button><button role="menuitem" class="danger-text" @click="requestPurgeTask"><Trash2/>永久删除</button></span></span>