fix: sheet Escape & focus management, detail a11y, 44px touch targets
- 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:
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUpdated, ref, watch } from 'vue'
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-vue-next'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { formatApiErrorDetail } from './lib/mvp-utils'
|
||||
@@ -19,6 +19,10 @@ const selectedDay=ref(new Date(new Date().getFullYear(),new Date().getMonth(),ne
|
||||
const selected=ref<CalendarEvent|null>(null),manageOpen=ref(false),formOpen=ref(false),editing=ref<Subscription|null>(null),busyId=ref('')
|
||||
const form=ref<Form>({name:'',url:'',color:'#f15a29',enabled:true})
|
||||
const appDialog=ref<{show:(options:AppDialogOptions)=>Promise<boolean|string|null>}|null>(null)
|
||||
const filtersEl=ref<HTMLElement|null>(null),filtersScrollHint=ref(false)
|
||||
const syncFiltersHint=()=>{const el=filtersEl.value;filtersScrollHint.value=!!el&&el.scrollLeft+el.clientWidth<el.scrollWidth-4}
|
||||
onMounted(syncFiltersHint)
|
||||
onUpdated(syncFiltersHint)
|
||||
const request=async(path:string,options:RequestInit={})=>{const headers:Record<string,string>={...(options.headers as Record<string,string>||{})};if(options.body)headers['Content-Type']='application/json';Object.assign(headers,csrfHeader(options.method));const response=await fetch('/api/v1'+path,{credentials:'include',...options,headers});if(!response.ok){const body=await response.json().catch(()=>({}));throw new Error(formatApiErrorDetail((body as {detail?:unknown}).detail??body))}return response.status===204?null:response.json()}
|
||||
const key=(date:Date)=>`${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
|
||||
const startOfWeek=(value:Date)=>{const date=new Date(value.getFullYear(),value.getMonth(),value.getDate());date.setDate(date.getDate()-((date.getDay()+6)%7));return date}
|
||||
@@ -67,7 +71,7 @@ onMounted(()=>void load())
|
||||
<p v-if="error" class="inline-error" role="alert">{{error}}</p>
|
||||
<div class="calendar-toolbar"><button aria-label="上一周" @click="moveWeek(-1)"><ChevronLeft/></button><button class="calendar-today" aria-label="回到今天" @click="today">今天</button><strong>{{weekLabel}}</strong><button aria-label="下一周" @click="moveWeek(1)"><ChevronRight/></button></div>
|
||||
<div class="calendar-week-strip" role="group" aria-label="选择日期"><button v-for="day in weekDays" :key="key(day)" class="calendar-week-day" :class="{'is-selected':key(day)===selectedDayKey,'is-today':isToday(day)}" :data-day="key(day)" :aria-label="`${displayDay(key(day))}${dayEventCount(day)?`,${dayEventCount(day)}个日程`:',无日程'}`" :aria-pressed="key(day)===selectedDayKey" @click="selectDay(day)"><small>{{weekDayLabel(day)}}</small><b>{{day.getDate()}}</b><i v-if="dayEventCount(day)" aria-hidden="true">{{dayEventCount(day)}}</i></button></div>
|
||||
<div v-if="subscriptions.length" class="calendar-filters" aria-label="筛选日历源"><label v-for="source in subscriptions" :key="source.id"><input type="checkbox" :aria-label="`筛选${source.name}`" :checked="!hiddenSources.has(source.id)" @change="toggleFilter(source.id)"><i :style="{background:source.color}"/>{{source.name}}</label></div>
|
||||
<div v-if="subscriptions.length" ref="filtersEl" class="calendar-filters" :class="{'has-scroll-hint':filtersScrollHint}" aria-label="筛选日历源" @scroll.passive="syncFiltersHint"><label v-for="source in subscriptions" :key="source.id"><input type="checkbox" :aria-label="`筛选${source.name}`" :checked="!hiddenSources.has(source.id)" @change="toggleFilter(source.id)"><i :style="{background:source.color}"/>{{source.name}}</label></div>
|
||||
<div v-if="visibleEvents.length" class="calendar-agenda"><section><h2>{{selectedDayLabel}} · {{visibleEvents.length}} 个日程</h2><button v-for="event in visibleEvents" :key="eventKey(event)" :data-event-id="event.id" class="calendar-event-row" @click="selected=event"><i :style="{background:eventColor(event)}"/><time>{{displayTime(event)}}</time><span><b>{{eventTitle(event)}}</b><small>{{eventSource(event)}}<template v-if="event.location"> · {{event.location}}</template></small></span><ChevronRight/></button></section></div>
|
||||
<div v-else-if="!loading" class="calendar-empty"><CalendarDays/><b>这一天还没有日程</b><span>{{subscriptions.length?'可以选择本周其他日期或检查来源筛选':'先添加一个 iCal 日历订阅'}}</span><button v-if="!subscriptions.length" class="primary-small" @click="manageOpen=true;openCreate()">添加日历源</button></div>
|
||||
<AppSheet :open="Boolean(selected)" :modal="compactLayout" inline-target=".shell" variant="detail" panel-class="calendar-event-detail" title-id="calendar-event-title" initial-focus="button[aria-label='关闭日程详情']" :close-on-scrim="compactLayout" @close="selected=null"><template v-if="selected"><header class="app-sheet__header"><h3 id="calendar-event-title">{{eventTitle(selected)}}</h3><button aria-label="关闭日程详情" @click="selected=null"><X/></button></header><div class="app-sheet__body"><div class="calendar-event-detail__meta"><span>{{displayDay(localDayKey(eventStart(selected)))}}</span><span>{{displayTime(selected)}}<template v-if="eventEnd(selected) && !selected.all_day"> - {{displayTime({...selected,starts_at:eventEnd(selected)})}}</template></span><span><i :style="{background:eventColor(selected)}"/>{{eventSource(selected)}}</span><span v-if="selected.location">{{selected.location}}</span></div><section v-if="selected.description" class="calendar-event-detail__content"><p>{{selected.description}}</p></section><p v-else class="calendar-event-detail__empty">没有备注</p></div></template></AppSheet>
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('pomodoro integration', () => {
|
||||
|
||||
it('renders the focus panel without the shared FAB and includes accessible stage controls', () => {
|
||||
expect(app).toContain("<PomodoroPanel v-else-if=\"activeView==='focus'\"")
|
||||
expect(app).not.toContain("'focus'].includes(activeView)")
|
||||
expect(app).toContain("'focus'].includes(activeView)")
|
||||
expect(css).toContain('.pomodoro-controls button{min-height:44px')
|
||||
expect(css).toContain('@media(prefers-reduced-motion:reduce)')
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('Today environment integration', () => {
|
||||
expect(filter).toBeGreaterThan(remaining)
|
||||
expect(overdue).toBeGreaterThan(filter)
|
||||
expect(main.match(/>今天<\/h1>/g)).toHaveLength(1)
|
||||
expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
|
||||
expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','trash','habits','settings','focus'].includes(activeView)\" class=\"topbar-title\"><h1")
|
||||
expect(main).not.toContain('class="topbar-actions"')
|
||||
expect(main).not.toContain('class="topbar-filter"')
|
||||
expect(main).not.toContain('aria-label="刷新当前页面"')
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -39,6 +39,13 @@ function focusables() {
|
||||
.filter(isVisibleFocusable)
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
if (props.busy || (props.modal && !isTopOverlay(overlayId))) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
if (!props.modal || event.key !== 'Tab' || !isTopOverlay(overlayId)) return
|
||||
const controls = focusables()
|
||||
if (!controls.length) { event.preventDefault(); panel.value?.focus(); return }
|
||||
@@ -54,19 +61,26 @@ function focusIntoPanel() {
|
||||
}
|
||||
}
|
||||
async function activate() {
|
||||
if (!props.open || !props.modal || overlayId) return
|
||||
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
||||
layerZIndex.value = overlayZIndex(overlayId)
|
||||
if (!props.open) return
|
||||
if (!props.modal && overlayId) deactivate()
|
||||
if (props.modal) {
|
||||
if (!overlayId) {
|
||||
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
||||
layerZIndex.value = overlayZIndex(overlayId)
|
||||
}
|
||||
await nextTick()
|
||||
if (props.open && overlayId) focusIntoPanel()
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
if (!props.open || !props.modal || !overlayId) return
|
||||
focusIntoPanel()
|
||||
if (props.open) focusIntoPanel()
|
||||
}
|
||||
function deactivate() {
|
||||
if (overlayId) popOverlay(overlayId)
|
||||
overlayId = null
|
||||
}
|
||||
watch([() => props.open, () => props.modal], ([open, modal]) => {
|
||||
if (open && modal) void activate()
|
||||
watch([() => props.open, () => props.modal], ([open]) => {
|
||||
if (open) void activate()
|
||||
else deactivate()
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(deactivate)
|
||||
@@ -83,11 +97,11 @@ onBeforeUnmount(deactivate)
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<Teleport v-else-if="inlineTarget" :to="inlineTarget">
|
||||
<component v-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" :aria-busy="busy || undefined" v-bind="$attrs">
|
||||
<component v-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" :aria-busy="busy || undefined" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
||||
<slot />
|
||||
</component>
|
||||
</Teleport>
|
||||
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs">
|
||||
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -160,7 +160,7 @@ describe('approved Settings 01 paper ledger', () => {
|
||||
it('moves Settings identity into the body and suppresses the duplicate shell title', () => {
|
||||
expect(app).not.toContain("'settings-main':activeView==='settings'")
|
||||
expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"")
|
||||
expect(app).toContain("v-if=\"!['today','tasks','upcoming','trash','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
|
||||
expect(app).toContain("v-if=\"!['today','tasks','upcoming','trash','habits','settings','focus'].includes(activeView)\" class=\"topbar-title\"")
|
||||
expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
|
||||
expect(app).not.toContain('aria-label="刷新当前页面"')
|
||||
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
|
||||
|
||||
Reference in New Issue
Block a user