feat: organize lists with drag and drop
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m22s

This commit is contained in:
2026-09-09 20:28:29 +08:00
parent 30705e1cec
commit 0e291ddb9c
8 changed files with 679 additions and 8 deletions
+145 -7
View File
@@ -9,6 +9,7 @@ import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, group
import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
@@ -39,6 +40,14 @@ const activeView = ref<View>(restoredNavigation.view)
const selectedTask = ref<Task | null>(null)
const sidebarCreateOpen = ref(false)
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
const listMoveMenuOpen = ref(false)
const listDrag = ref<{ id: string; pointerId: number; startX: number; startY: number; offsetY: number; lastX: number; lastY: number } | null>(null)
const listDropFolderId = ref<string | null | undefined>(undefined)
const listReorderTarget = ref('')
const listReorderPlacement = ref<'before' | 'after'>('after')
let listHandleLongPressTimer: number | undefined
let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
let suppressListClickId = ''
const query = ref('')
const error = ref('')
const notice = ref('')
@@ -792,8 +801,137 @@ function runSidebarCreate(kind: 'folder' | 'list') {
sidebarCreateOpen.value = false
kind === 'folder' ? void createFolder() : void createList(null)
}
function closeSidebarAction() { sidebarAction.value = null }
function closeSidebarAction() { sidebarAction.value = null; listMoveMenuOpen.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) {
if (list.is_inbox) return
clearListHandlePress()
listDrag.value = { id: list.id, pointerId: pointer.pointerId, startX: pointer.clientX, startY: pointer.clientY, offsetY: 0, lastX: pointer.clientX, lastY: pointer.clientY }
listDropFolderId.value = list.folder_id
listReorderTarget.value = list.id
captureListDragPointer(pointer)
}
function startListHandlePress(list: TaskList, event: PointerEvent) {
const pointer = snapshotListDragPointer(event)
if (event.pointerType !== 'touch') {
beginListDrag(list, pointer)
return
}
clearListHandlePress()
listHandlePending = { id: list.id, pointer }
listHandleLongPressTimer = window.setTimeout(() => beginListDrag(list, pointer), 450)
}
function moveListHandle(list: TaskList, event: PointerEvent) {
if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId) {
if (hasExceededLongPressMovement(
{ x: listHandlePending.pointer.clientX, y: listHandlePending.pointer.clientY },
{ x: event.clientX, y: event.clientY },
5,
)) clearListHandlePress()
return
}
moveListDrag(list, event)
}
function clearListHandlePress() {
if (listHandleLongPressTimer) window.clearTimeout(listHandleLongPressTimer)
listHandleLongPressTimer = undefined
listHandlePending = undefined
}
function resolveListDrop(event: PointerEvent) {
const activeId = listDrag.value?.id
const elements = document.elementsFromPoint(event.clientX, event.clientY)
const row = elements.map((element) => element.closest<HTMLElement>('[data-list-id]')).find((element) => element?.dataset.listId !== activeId)
if (row?.dataset.listId) {
const target = lists.value.find((item) => item.id === row.dataset.listId)
if (target && !target.is_inbox) {
listDropFolderId.value = target.folder_id
listReorderTarget.value = target.id
const rect = row.getBoundingClientRect()
listReorderPlacement.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
return
}
}
const folder = elements.map((element) => element.closest<HTMLElement>('[data-folder-id]')).find(Boolean)
if (folder?.dataset.folderId) {
listDropFolderId.value = folder.dataset.folderId
listReorderTarget.value = ''
expandedFolders.value = new Set(expandedFolders.value).add(folder.dataset.folderId)
return
}
if (elements.some((element) => element.closest('.list-root-drop'))) {
listDropFolderId.value = null
listReorderTarget.value = ''
}
}
function moveListDrag(list: TaskList, event: PointerEvent) {
const drag = listDrag.value
if (!drag || drag.id !== list.id) return
drag.offsetY = event.clientY - drag.startY
drag.lastX = event.clientX
drag.lastY = event.clientY
resolveListDrop(event)
}
async function persistListMove(list: TaskList, folderId: string | null, targetId?: string, placement: 'before' | 'after' = 'after') {
if (list.is_inbox) return
const previous = lists.value
const result = moveListToScope(previous, list.id, folderId, targetId, placement)
if (result.items === previous) return
lists.value = result.items
if (folderId) expandedFolders.value = new Set(expandedFolders.value).add(folderId)
try {
if (list.folder_id !== folderId) {
await api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
} else {
await api('/lists/reorder', { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
}
toast(folderId ? '清单已移动' : '清单已移出文件夹')
} catch (reason) {
lists.value = previous
fail(reason)
}
}
function finishListDrag(list: TaskList, event: PointerEvent) {
clearListHandlePress()
const drag = listDrag.value
if (!drag || drag.id !== list.id) return
const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 5
const folderId = listDropFolderId.value ?? null
const targetId = listReorderTarget.value && listReorderTarget.value !== list.id ? listReorderTarget.value : undefined
const placement = listReorderPlacement.value
cancelListDrag()
if (!moved && folderId === list.folder_id && !targetId) return
suppressListClickId = list.id
window.setTimeout(() => { if (suppressListClickId === list.id) suppressListClickId = '' }, 400)
void persistListMove(list, folderId, targetId, placement)
}
function selectListUnlessDragged(list: TaskList) {
if (suppressListClickId === list.id) { suppressListClickId = ''; return }
void switchView('tasks', list.id)
}
function cancelListDrag() {
clearListHandlePress()
listDrag.value = null
listDropFolderId.value = undefined
listReorderTarget.value = ''
}
function openListMoveMenu() { listMoveMenuOpen.value = !listMoveMenuOpen.value }
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()
}
function formatDue(value: string | null, hasTime = true) {
if (!value) return ''
const date = new Date(value)
@@ -837,13 +975,13 @@ onMounted(bootstrap)
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
</nav>
<div class="section-title"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block">
<div class="folder-row"><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" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><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="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" :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" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><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="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></span></div>
@@ -853,7 +991,7 @@ onMounted(bootstrap)
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
</nav>
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><template v-if="sidebarAction.kind==='lists'"><button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')">上移</button><button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')">下移</button><button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/>移动到文件夹</button><div v-if="listMoveMenuOpen" class="list-move-menu" role="menu" aria-label="选择目标文件夹"><button v-if="(sidebarAction.item as TaskList).folder_id" role="menuitem" @click="moveListFromMenu(null)">移出文件夹</button><button v-for="folder in folders" :key="folder.id" role="menuitem" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)">{{folder.name}}</button></div></template><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
</aside>
<main>
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer } from './list-drag'
type ListItem = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
const rows: ListItem[] = [
{ id: 'inbox', folder_id: null, name: '收集箱', is_inbox: true },
{ id: 'a', folder_id: 'work', name: 'A', is_inbox: false },
{ id: 'b', folder_id: 'work', name: 'B', is_inbox: false },
{ id: 'c', folder_id: null, name: 'C', is_inbox: false },
]
describe('list drag ordering', () => {
it('moves a list into a folder and appends it to that scope', () => {
const result = moveListToScope(rows, 'c', 'work')
expect(result.items.map((item) => [item.id, item.folder_id])).toEqual([
['inbox', null], ['a', 'work'], ['b', 'work'], ['c', 'work'],
])
expect(result.orderedIds).toEqual(['a', 'b', 'c'])
})
it('moves a list out to My Lists without including Inbox in ordering', () => {
const result = moveListToScope(rows, 'a', null)
expect(result.items.map((item) => [item.id, item.folder_id])).toEqual([
['inbox', null], ['b', 'work'], ['c', null], ['a', null],
])
expect(result.orderedIds).toEqual(['c', 'a'])
})
it('sorts within one folder before a target', () => {
const result = moveListToScope(rows, 'b', 'work', 'a', 'before')
expect(result.items.filter((item) => item.folder_id === 'work').map((item) => item.id)).toEqual(['b', 'a'])
expect(result.orderedIds).toEqual(['b', 'a'])
})
it('does not allow Inbox to move', () => {
const result = moveListToScope(rows, 'inbox', 'work')
expect(result.items).toBe(rows)
expect(result.orderedIds).toEqual([])
})
it('finds only legal adjacent moves inside the current folder scope', () => {
expect(getAdjacentListMove(rows, 'a', 'up')).toBeNull()
expect(getAdjacentListMove(rows, 'a', 'down')).toEqual({ targetId: 'b', placement: 'after' })
expect(getAdjacentListMove(rows, 'b', 'up')).toEqual({ targetId: 'a', placement: 'before' })
expect(getAdjacentListMove(rows, 'b', 'down')).toBeNull()
expect(getAdjacentListMove(rows, 'c', 'up')).toBeNull()
expect(getAdjacentListMove(rows, 'inbox', 'down')).toBeNull()
})
it('captures the pointer from the stable pointerdown target after currentTarget is cleared', () => {
const setPointerCapture = vi.fn()
const row = { setPointerCapture } as unknown as Element
const event = {
pointerId: 7,
clientX: 120,
clientY: 240,
currentTarget: row,
} as unknown as PointerEvent
const pointer = snapshotListDragPointer(event)
Object.defineProperty(event, 'currentTarget', { value: null })
captureListDragPointer(pointer)
expect(pointer).toMatchObject({ pointerId: 7, clientX: 120, clientY: 240, captureTarget: row })
expect(setPointerCapture).toHaveBeenCalledOnce()
expect(setPointerCapture).toHaveBeenCalledWith(7)
})
it('cancels a pending long press from cumulative client-coordinate movement', () => {
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 14, y: 23 }, 5)).toBe(false)
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 14, y: 24 }, 5)).toBe(true)
expect(hasExceededLongPressMovement({ x: 10, y: 20 }, { x: 4, y: 20 }, 5)).toBe(true)
})
})
+74
View File
@@ -0,0 +1,74 @@
export type DraggableList = {
id: string
folder_id: string | null
is_inbox: boolean
}
export type ListDragPointer = {
pointerId: number
clientX: number
clientY: number
captureTarget: Element | null
}
export function snapshotListDragPointer(event: PointerEvent): ListDragPointer {
return {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
captureTarget: event.currentTarget as Element | null,
}
}
export function captureListDragPointer(pointer: ListDragPointer) {
try { pointer.captureTarget?.setPointerCapture(pointer.pointerId) } catch { /* synthetic events */ }
}
export function hasExceededLongPressMovement(
start: { x: number; y: number },
current: { x: number; y: number },
threshold: number,
) {
return Math.hypot(current.x - start.x, current.y - start.y) > threshold
}
export function getAdjacentListMove<T extends DraggableList>(
items: T[],
sourceId: string,
direction: 'up' | 'down',
): { targetId: string; placement: 'before' | 'after' } | null {
const source = items.find((item) => item.id === sourceId)
if (!source || source.is_inbox) return null
const scope = items.filter((item) => !item.is_inbox && item.folder_id === source.folder_id)
const index = scope.findIndex((item) => item.id === sourceId)
const target = scope[index + (direction === 'up' ? -1 : 1)]
if (!target) return null
return { targetId: target.id, placement: direction === 'up' ? 'before' : 'after' }
}
export function moveListToScope<T extends DraggableList>(
items: T[],
sourceId: string,
folderId: string | null,
targetId?: string,
placement: 'before' | 'after' = 'after',
) {
const source = items.find((item) => item.id === sourceId)
if (!source || source.is_inbox) return { items, orderedIds: [] as string[] }
const withoutSource = items.filter((item) => item.id !== sourceId)
const moved = { ...source, folder_id: folderId }
const scope = withoutSource.filter((item) => !item.is_inbox && item.folder_id === folderId)
const targetIndex = targetId ? scope.findIndex((item) => item.id === targetId) : -1
const insertAt = targetIndex < 0 ? scope.length : targetIndex + (placement === 'after' ? 1 : 0)
scope.splice(insertAt, 0, moved)
const iterator = scope[Symbol.iterator]()
const next = withoutSource.map((item) => {
if (item.is_inbox || item.folder_id !== folderId) return item
return iterator.next().value!
})
const remaining = [...iterator]
if (remaining.length) next.push(...remaining)
return { items: next, orderedIds: scope.map((item) => item.id) }
}
File diff suppressed because one or more lines are too long
+48
View File
@@ -676,6 +676,54 @@ describe('sidebar layout', () => {
expect(app).toContain(':title="list.name" :aria-label="list.name"')
})
it('supports dragging movable lists into folders, out to My Lists, and within one scope', () => {
expect(app).toContain('class="section-title list-root-drop"')
expect(app).toContain(':data-folder-id="folder.id"')
expect(app).toContain(':data-list-id="list.id"')
expect(app).toContain('class="list-drag-handle"')
expect(app).toContain("api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })")
expect(app).toContain("} else {\n await api('/lists/reorder'")
expect(app).toContain('lists.value = previous')
expect(app).toContain('expandedFolders.value = new Set(expandedFolders.value).add(folderId)')
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
})
it('uses the handle-only touch contract and exposes same-scope move controls', () => {
expect(app).not.toContain('@pointerdown="startListLongPress(list, $event)"')
expect(app).not.toContain('function startListLongPress')
expect(app).not.toContain('listLongPressTimer')
expect(app).toContain('@pointerdown.stop="startListHandlePress(list,$event)"')
expect(app).toContain('listHandlePending = { id: list.id, pointer }')
expect(app).toContain('window.setTimeout(() => beginListDrag(list, pointer), 450)')
expect(app).toContain('@pointermove.stop="moveListHandle(list,$event)"')
expect(app).toContain("if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId)")
expect(app).toContain('hasExceededLongPressMovement(')
expect(app).toContain('clearListHandlePress()')
expect(app).toContain('moveListDrag(list, event)')
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('移出文件夹')
})
it('shows drag lift, folder highlighting, and insertion targets', () => {
expect(css).toContain('.list-row.list-dragging{')
expect(css).toContain('.folder-row.list-drop-target{')
expect(css).toContain('.list-root-drop.list-drop-target{')
expect(css).toContain('.list-row.list-reorder-target{')
expect(css).toContain('.list-drag-handle{width:44px;min-width:44px;height:44px;')
expect(css).toContain('.list-row{touch-action:pan-y}')
expect(css).not.toContain('.list-drag-handle{display:none}')
expect(css).not.toContain('.list-row.list-dragging{touch-action:none}')
})
it('only restores archived lists from the explicit restore action', () => {
expect(app).toContain('class="list-row-main archived-row-label"')
expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"')