[verified] redesign compact list editor
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m59s

This commit is contained in:
2026-09-21 10:59:41 +08:00
parent 7f8b435164
commit d925a043dc
3 changed files with 144 additions and 116 deletions
+120 -81
View File
@@ -9,7 +9,7 @@ import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, def
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { captureListDragPointer, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { deriveMemoShellState } from './lib/app-shell-state'
import { clampDesktopPaneWidth, getDesktopPaneMax, readDesktopPaneWidth, writeDesktopPaneWidth, type DesktopPane } from './lib/desktop-shell-resize'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
@@ -69,7 +69,11 @@ const selectedTask = ref<Task | null>(null)
const taskSelectionGeneration = ref(0)
const sidebarCreateOpen = ref(false)
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
const listMoveMenuOpen = ref(false)
const listEditorTarget = ref<TaskList | null>(null)
const listEditorName = ref('')
const listEditorFolderId = ref('')
const listEditorError = ref('')
const listEditorBusy = ref(false)
const sidebarActionFolderListCount = computed(() => {
const action = sidebarAction.value
if (!action || action.kind !== 'folders') return 0
@@ -1336,31 +1340,14 @@ async function createList(folderId: string | null = null) {
const name = (await askText('新建清单', '清单名称', '', '创建'))?.trim(); if (!name) return
try { const item = await api('/lists', { method: 'POST', body: JSON.stringify({ name, folder_id: folderId }) }); lists.value.push(item); await switchView('tasks', item.id); toast('清单已创建') } catch (reason) { fail(reason) }
}
async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
const name = (await askText('重命名', kind === 'lists' ? '清单名称' : '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
async function renameFolder(item: FolderItem) {
const name = (await askText('重命名', '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/folders/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
}
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (kind === 'lists') {
if (!(await confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复'))) return
try {
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
await api(`/${kind}/${item.id}`, { method: 'DELETE' })
await loadArchivedLists()
await refreshAll()
if (wasCurrentList) {
selectedTask.value = null
mobileDetail.value = false
const inboxId = lists.value.find((list) => list.is_inbox)?.id || ''
await switchView('tasks', inboxId)
}
toast('清单已归档')
} catch (reason) { fail(reason) }
return
}
async function deleteFolder(item: FolderItem) {
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
if (answer === null) return
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
try { await api(`/folders/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
}
async function loadArchivedLists() {
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
@@ -1456,6 +1443,15 @@ async function confirmPurgeList() {
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
closeArchivedListAction(false)
if (kind === 'lists') {
sidebarAction.value = null
listEditorTarget.value = item as TaskList
listEditorName.value = item.name
listEditorFolderId.value = (item as TaskList).folder_id ?? ''
listEditorError.value = ''
sidebarCreateOpen.value = false
return
}
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
sidebarCreateOpen.value = false
}
@@ -1463,7 +1459,76 @@ function runSidebarCreate(kind: 'folder' | 'list') {
sidebarCreateOpen.value = false
kind === 'folder' ? void createFolder() : void createList(null)
}
function closeSidebarAction() { sidebarAction.value = null; listMoveMenuOpen.value = false }
function closeSidebarAction() { sidebarAction.value = null }
function closeListEditor() {
if (listEditorBusy.value) return
listEditorTarget.value = null
listEditorError.value = ''
}
async function saveListEditor() {
const item = listEditorTarget.value
if (!item || listEditorBusy.value) return
const normalized = normalizeRequiredName(listEditorName.value)
if (normalized.error) { listEditorError.value = normalized.error; return }
const name = normalized.value
const folderId = listEditorFolderId.value || null
listEditorBusy.value = true
listEditorError.value = ''
let nameSaved = false
try {
if (name !== item.name) {
const updated = await api(`/lists/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) })
Object.assign(item, updated)
nameSaved = true
}
if (folderId !== item.folder_id) {
const previous = lists.value
const result = moveListToScope(previous, item.id, folderId)
lists.value = result.items
try {
await api(`/lists/${item.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
} catch (reason) {
lists.value = previous
throw reason
}
}
listEditorTarget.value = null
toast('清单已更新')
} catch (reason) {
if (nameSaved) {
await refreshAll()
const current = lists.value.find((list) => list.id === item.id)
if (current) {
listEditorTarget.value = current
listEditorName.value = current.name
listEditorFolderId.value = current.folder_id ?? ''
}
listEditorError.value = '名称已保存,但移动文件夹失败,请重试。'
} else listEditorError.value = reason instanceof Error ? reason.message : '保存失败'
} finally { listEditorBusy.value = false }
}
async function archiveListFromEditor() {
const item = listEditorTarget.value
if (!item || listEditorBusy.value) return
if (!(await confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复'))) return
listEditorBusy.value = true
try {
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
await api(`/lists/${item.id}`, { method: 'DELETE' })
listEditorTarget.value = null
await loadArchivedLists()
await refreshAll()
if (wasCurrentList) {
selectedTask.value = null
mobileDetail.value = false
const inboxId = lists.value.find((list) => list.is_inbox)?.id || ''
await switchView('tasks', inboxId)
}
toast('清单已归档')
} catch (reason) {
listEditorError.value = reason instanceof Error ? reason.message : '归档失败'
} finally { listEditorBusy.value = false }
}
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function beginListDrag(list: TaskList, pointer: ListDragPointer) {
@@ -1577,24 +1642,7 @@ function cancelListDrag() {
listDropFolderId.value = undefined
listReorderTarget.value = ''
}
function openListMoveMenu() { listMoveMenuOpen.value = true }
function closeListMoveMenu() { listMoveMenuOpen.value = false }
function moveListFromMenu(folderId: string | null) {
const item = sidebarAction.value?.kind === 'lists' ? sidebarAction.value.item as TaskList : null
if (!item) return
listMoveMenuOpen.value = false
void persistListMove(item, folderId)
closeSidebarAction()
}
function canMoveListWithinScope(item: TaskList, direction: 'up' | 'down') {
return getAdjacentListMove(lists.value, item.id, direction) !== null
}
function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
const move = getAdjacentListMove(lists.value, item.id, direction)
if (!move) return
void persistListMove(item, item.folder_id, move.targetId, move.placement)
closeSidebarAction()
}
async function scrollToTaskPageStart() {
await nextTick()
taskListElement.value?.scrollIntoView({ block: 'start' })
@@ -1694,9 +1742,9 @@ onUnmounted(() => {
<div class="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block" :data-folder-id="folder.id">
<div class="folder-row" :class="{'list-drop-target':listDrag&&listDropFolderId===folder.id&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></button><span class="row-actions"><button aria-label="打开文件夹操作" :aria-expanded="sidebarAction?.item.id===folder.id" @click="openSidebarAction('folders',folder)"><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 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="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 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>
<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="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 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>
<div id="archived-task-lists" v-show="archivedListsExpanded" class="archived-list-items">
@@ -1710,42 +1758,33 @@ onUnmounted(() => {
</nav>
<AppSheet :open="Boolean(sidebarAction)" variant="actions" panel-class="sidebar-action-sheet" :label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined" initial-focus=".app-sheet__header button" @close="closeSidebarAction">
<template v-if="sidebarAction">
<template v-if="!listMoveMenuOpen">
<header class="app-sheet__header sidebar-action-header">
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
<button class="icon" :aria-label="`关闭${sidebarAction.kind==='folders'?'文件夹':'清单'}操作`" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<section class="sidebar-action-group" aria-label="常用操作">
<span class="sidebar-action-group-title">常用操作</span>
<button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/><span>重命名</span></button>
<button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/><span>新建清单</span></button>
</section>
<section v-if="sidebarAction.kind==='lists'" class="sidebar-action-group" aria-label="整理清单">
<span class="sidebar-action-group-title">整理清单</span>
<button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')"><ChevronDown class="sidebar-action-up"/><span>上移</span></button>
<button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')"><ChevronDown/><span>下移</span></button>
<button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/><span>{{(sidebarAction.item as TaskList).folder_id?'更改所在文件夹':'移动到文件夹'}}</span><ChevronRight class="sidebar-action-chevron"/></button>
</section>
<section class="sidebar-action-danger">
<span>{{sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'}}</span>
<button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/><span>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</span></button>
</section>
</div>
</template>
<template v-if="listMoveMenuOpen">
<header class="app-sheet__header sidebar-action-move-view">
<button class="sidebar-action-move-back" aria-label="返回清单操作" @click="closeListMoveMenu"><ChevronRight/></button>
<div><span class="sidebar-action-kind">清单位置</span><b class="sidebar-action-move-title">选择目标位置</b></div>
<button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<div class="list-move-menu" role="menu" aria-label="选择目标文件夹">
<button role="menuitem" :class="{'list-move-current':!(sidebarAction.item as TaskList).folder_id}" :disabled="!(sidebarAction.item as TaskList).folder_id" @click="moveListFromMenu(null)"><ListTodo/><span>我的清单</span><Check v-if="!(sidebarAction.item as TaskList).folder_id"/></button>
<button v-for="folder in folders" :key="folder.id" role="menuitem" :class="{'list-move-current':(sidebarAction.item as TaskList).folder_id===folder.id}" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)"><Folder/><span>{{folder.name}}</span><Check v-if="(sidebarAction.item as TaskList).folder_id===folder.id"/></button>
</div>
</div>
</template>
<header class="app-sheet__header sidebar-action-header">
<div><span class="sidebar-action-kind">文件夹</span><b>{{sidebarAction.item.name}}</b></div>
<button class="icon" aria-label="关闭文件夹操作" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<section class="sidebar-action-group" aria-label="常用操作">
<span class="sidebar-action-group-title">常用操作</span>
<button @click="renameFolder(sidebarAction.item as FolderItem);closeSidebarAction()"><Pencil/><span>重命名</span></button>
<button @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/><span>新建清单</span></button>
</section>
<section class="sidebar-action-danger">
<span>删除后,其中 {{sidebarActionFolderListCount}} 个清单会移到“我的清单”</span>
<button class="danger" @click="deleteFolder(sidebarAction.item as FolderItem);closeSidebarAction()"><Trash2/><span>删除文件夹</span></button>
</section>
</div>
</template>
</AppSheet>
<AppSheet :open="Boolean(listEditorTarget)" variant="actions" panel-class="list-editor-sheet" title-id="list-editor-title" initial-focus=".list-editor-name" :busy="listEditorBusy" @close="closeListEditor" @submit.prevent="saveListEditor">
<template v-if="listEditorTarget">
<header class="app-sheet__header list-editor-header"><div><h2 id="list-editor-title">编辑清单</h2></div><button class="icon" type="button" :disabled="listEditorBusy" aria-label="关闭编辑清单" @click="closeListEditor"><X/></button></header>
<div class="app-sheet__body list-editor-form">
<label>清单名称<input v-model="listEditorName" class="list-editor-name" autocomplete="off" maxlength="80" :aria-invalid="Boolean(listEditorError)" @input="listEditorError=''"/><small>名称最多 80 个字符</small></label>
<label>所在文件夹<select v-model="listEditorFolderId"><option value="">不放入文件夹</option><option v-for="folder in folders" :key="folder.id" :value="folder.id">{{folder.name}}</option></select></label>
<p v-if="listEditorError" class="list-editor-error" role="alert">{{listEditorError}}</p>
<section class="list-editor-danger"><div><b>归档清单</b><small>任务会保留,可从“已归档”恢复</small></div><button type="button" class="danger-button" :disabled="listEditorBusy" @click="archiveListFromEditor"><ArchiveRestore/>归档</button></section>
</div>
<footer class="app-sheet__footer list-editor-footer"><button type="button" class="secondary" :disabled="listEditorBusy" @click="closeListEditor">取消</button><button class="primary-small" :disabled="listEditorBusy || !listEditorName.trim()">{{listEditorBusy?'正在保存…':'保存更改'}}</button></footer>
</template>
</AppSheet>
</aside>
File diff suppressed because one or more lines are too long
+22 -34
View File
@@ -1596,39 +1596,31 @@ describe('sidebar information hierarchy', () => {
expect(css).toContain('width:3px;')
})
it('groups folder and list editing actions into a clear compact hierarchy', () => {
it('uses the selected compact editor for list name, folder, and archive actions', () => {
expect(app).toContain('aria-label="打开文件夹操作"')
expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('panel-class="sidebar-action-sheet"')
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
expect(app).toContain('class="sidebar-action-kind"')
expect(app).toContain('class="sidebar-action-group"')
expect(app).toContain('class="sidebar-action-group-title"')
expect(app).toContain('class="sidebar-action-danger"')
expect(app).toContain("sidebarAction.kind==='folders'?'文件夹':'清单'")
expect(app).toContain("sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'")
const deleteEntityBlock = app.slice(app.indexOf('async function deleteEntity'), app.indexOf('async function loadArchivedLists'))
expect(deleteEntityBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
expect(deleteEntityBlock).not.toContain("askText(`归档清单")
expect(css).toContain('.sidebar-action-sheet{width:min(320px,calc(100vw - 24px));')
expect(css).toContain('.sidebar-action-group{display:grid;gap:2px;')
expect(css).toContain('.sidebar-action-danger{border-top:1px solid')
expect(app).toContain('panel-class="list-editor-sheet"')
expect(app).toContain('id="list-editor-title"')
expect(app).toContain('v-model="listEditorName"')
expect(app).toContain('v-model="listEditorFolderId"')
expect(app).toContain('>所在文件夹<')
expect(app).toContain("{{listEditorBusy?'正在保存…':'保存更改'}}")
expect(app).toContain('任务会保留,可从“已归档”恢复')
expect(app).not.toContain('class="list-editor-position"')
expect(app).not.toContain('aria-label="上移清单"')
expect(app).not.toContain('aria-label="下移清单"')
const archiveListBlock = app.slice(app.indexOf('async function archiveListFromEditor'), app.indexOf('function toggleFolder'))
expect(archiveListBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
expect(archiveListBlock).not.toContain("askText(`归档清单")
expect(css).toContain('.list-editor-sheet{width:min(460px,calc(100vw - 24px));')
expect(css).toContain('.list-editor-form{display:grid;gap:17px;padding:18px 20px}')
expect(css).toContain('.list-editor-danger{border-top:1px solid var(--border-cream);')
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
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')
})
it('uses a dedicated second step for choosing a list destination', () => {
expect(app).toContain('<template v-if="listMoveMenuOpen">')
expect(app).toContain('aria-label="返回清单操作"')
expect(app).toContain('class="sidebar-action-move-title"')
expect(app).toContain('选择目标位置')
expect(app).toContain('role="menu" aria-label="选择目标文件夹"')
expect(app).toContain("'list-move-current':")
expect(css).toContain('.sidebar-action-move-back{min-height:44px;')
expect(css).toContain('.list-move-menu{display:grid;gap:2px;padding:0}')
})
})
describe('quiet index sidebar parity', () => {
@@ -1727,7 +1719,7 @@ describe('sidebar layout', () => {
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
})
it('uses the handle-only touch contract and exposes same-scope move controls', () => {
it('uses the handle-only touch contract while keeping drag organization available', () => {
expect(app).not.toContain('@pointerdown="startListLongPress(list, $event)"')
expect(app).not.toContain('function startListLongPress')
expect(app).not.toContain('listLongPressTimer')
@@ -1742,14 +1734,10 @@ describe('sidebar layout', () => {
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
expect(app).toContain('listHandlePending = undefined')
expect(app).toContain('aria-label="上移清单"')
expect(app).toContain('aria-label="下移清单"')
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'up')")
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'down')")
expect(app).toContain('aria-label="移动到文件夹"')
expect(app).toContain('role="menu"')
expect(app).toContain('role="menuitem"')
expect(app).toContain('移出文件夹')
expect(app).not.toContain('aria-label="上移清单"')
expect(app).not.toContain('aria-label="下移清单"')
expect(app).not.toContain('class="list-editor-position"')
expect(app).toContain('v-model="listEditorFolderId"')
})
it('shows drag lift, folder highlighting, and insertion targets', () => {