feat: add explicit reorder modes
This commit is contained in:
+12
-5
@@ -109,6 +109,7 @@ const todayTaskCompleted = ref(0)
|
|||||||
const todayHabitTotal = ref(0)
|
const todayHabitTotal = ref(0)
|
||||||
const todayHabitCompleted = ref(0)
|
const todayHabitCompleted = ref(0)
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
||||||
|
const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1)
|
||||||
const expandedFolders = ref(new Set<string>())
|
const expandedFolders = ref(new Set<string>())
|
||||||
const justCompletedTaskIds = ref(new Set<string>())
|
const justCompletedTaskIds = ref(new Set<string>())
|
||||||
const completionExitingTaskIds = ref(new Set<string>())
|
const completionExitingTaskIds = ref(new Set<string>())
|
||||||
@@ -128,6 +129,7 @@ const taskSwipeOffsets = ref<Record<string, number>>({})
|
|||||||
const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
||||||
const taskReorderTarget = ref('')
|
const taskReorderTarget = ref('')
|
||||||
const taskReorderBlocked = ref(false)
|
const taskReorderBlocked = ref(false)
|
||||||
|
const taskReorderMode = ref(false)
|
||||||
const taskComposeOpen = ref(false)
|
const taskComposeOpen = ref(false)
|
||||||
const composeTitle = ref('')
|
const composeTitle = ref('')
|
||||||
const composeTitleError = ref('')
|
const composeTitleError = ref('')
|
||||||
@@ -387,6 +389,9 @@ watch(query, () => {
|
|||||||
page.value = 1
|
page.value = 1
|
||||||
searchTimer = window.setTimeout(() => loadAll(), 250)
|
searchTimer = window.setTimeout(() => loadAll(), 250)
|
||||||
})
|
})
|
||||||
|
watch(taskReorderAvailable, () => {
|
||||||
|
if (!taskReorderAvailable.value) taskReorderMode.value = false
|
||||||
|
})
|
||||||
watch(showCompleted, (value) => {
|
watch(showCompleted, (value) => {
|
||||||
writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)
|
writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)
|
||||||
if (isTaskView(activeView.value)) { page.value = 1; loadAll() }
|
if (isTaskView(activeView.value)) { page.value = 1; loadAll() }
|
||||||
@@ -648,6 +653,8 @@ async function loadTrash() {
|
|||||||
async function switchView(view: View, listId?: string) {
|
async function switchView(view: View, listId?: string) {
|
||||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
|
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||||
taskMutationNavigation.value += 1
|
taskMutationNavigation.value += 1
|
||||||
|
taskReorderMode.value = false
|
||||||
|
cancelTaskReorder()
|
||||||
activeView.value = view
|
activeView.value = view
|
||||||
if (!query.value) mobileSearchOpen.value = false
|
if (!query.value) mobileSearchOpen.value = false
|
||||||
searchPullDistance.value = 0
|
searchPullDistance.value = 0
|
||||||
@@ -724,7 +731,7 @@ function isInteractiveTarget(target: EventTarget | null) {
|
|||||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
||||||
}
|
}
|
||||||
function startTaskReorder(task: Task, event: PointerEvent) {
|
function startTaskReorder(task: Task, event: PointerEvent) {
|
||||||
if (activeView.value === 'trash' || loading.value || query.value || totalPages.value > 1) return
|
if (!taskReorderMode.value || !taskReorderAvailable.value || loading.value) return
|
||||||
taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 }
|
taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 }
|
||||||
taskReorderTarget.value = task.id
|
taskReorderTarget.value = task.id
|
||||||
taskReorderBlocked.value = false
|
taskReorderBlocked.value = false
|
||||||
@@ -1504,12 +1511,12 @@ onUnmounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title"><ListTodo/>任务</span><span class="today-section-summary">{{totalTasks}} 项</span><ChevronRight v-if="todaySectionCollapse.tasks" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title"><ListTodo/>任务</span><span class="today-section-summary">{{totalTasks}} 项</span><ChevronRight v-if="todaySectionCollapse.tasks" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="activeView!=='today'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
<div v-if="activeView!=='today'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
|
||||||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||||
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : undefined">
|
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : undefined">
|
||||||
<template v-for="node in taskTree" :key="node.task.id">
|
<template v-for="node in taskTree" :key="node.task.id">
|
||||||
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||||
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
||||||
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
||||||
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
|
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
|
||||||
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>
|
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>
|
||||||
@@ -1530,7 +1537,7 @@ onUnmounted(() => {
|
|||||||
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}">
|
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}">
|
||||||
<div class="detail-head"><span>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
<div class="detail-head"><span>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||||
<div class="detail-form">
|
<div class="detail-form">
|
||||||
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
<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>
|
||||||
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||||
<label class="task-detail-due-row">截止时间<input class="task-detail-due-input task-detail-field-input" :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value"></label>
|
<label class="task-detail-due-row">截止时间<input class="task-detail-due-input task-detail-field-input" :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value"></label>
|
||||||
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||||
@@ -1570,7 +1577,7 @@ onUnmounted(() => {
|
|||||||
<Transition name="task-compose">
|
<Transition name="task-compose">
|
||||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
||||||
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
||||||
<header class="app-sheet__header"><div><small>NEW TASK</small><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||||
<div class="app-sheet__body">
|
<div class="app-sheet__body">
|
||||||
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
|
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
|
||||||
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label></div>
|
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label></div>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const habitPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
|||||||
const habitSwipeOffsets = ref<Record<string, number>>({})
|
const habitSwipeOffsets = ref<Record<string, number>>({})
|
||||||
const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
||||||
const habitReorderTarget = ref('')
|
const habitReorderTarget = ref('')
|
||||||
|
const habitReorderMode = ref(false)
|
||||||
const justCompletedHabitIds = ref(new Set<string>())
|
const justCompletedHabitIds = ref(new Set<string>())
|
||||||
const completionExitingHabitIds = ref(new Set<string>())
|
const completionExitingHabitIds = ref(new Set<string>())
|
||||||
function setHabitCompletionExiting(id: string, active: boolean) {
|
function setHabitCompletionExiting(id: string, active: boolean) {
|
||||||
@@ -89,11 +90,15 @@ const markHabitJustCompleted = createCompletionPulse(
|
|||||||
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
||||||
const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value : todayHabits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id)))
|
const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value : todayHabits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id)))
|
||||||
const visibleHabits = computed(() => props.showCompleted ? habits.value : habits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id)))
|
const visibleHabits = computed(() => props.showCompleted ? habits.value : habits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id)))
|
||||||
|
const habitReorderAvailable = computed(() => props.view === 'habits' && visibleHabits.value.length > 1)
|
||||||
const todayHabitSummary = computed(() => ({
|
const todayHabitSummary = computed(() => ({
|
||||||
total: todayHabits.value.length,
|
total: todayHabits.value.length,
|
||||||
completed: todayHabits.value.filter((item) => isDone(item, todayKey.value)).length,
|
completed: todayHabits.value.filter((item) => isDone(item, todayKey.value)).length,
|
||||||
}))
|
}))
|
||||||
watch(todayHabitSummary, (value) => emit('summary', value), { immediate: true })
|
watch(todayHabitSummary, (value) => emit('summary', value), { immediate: true })
|
||||||
|
watch(habitReorderAvailable, (available) => {
|
||||||
|
if (!available) habitReorderMode.value = false
|
||||||
|
})
|
||||||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
async function request(path: string, options: RequestInit = {}) {
|
async function request(path: string, options: RequestInit = {}) {
|
||||||
@@ -121,7 +126,7 @@ function isInteractiveTarget(target: EventTarget | null) {
|
|||||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
||||||
}
|
}
|
||||||
function startHabitReorder(h: Habit, event: PointerEvent) {
|
function startHabitReorder(h: Habit, event: PointerEvent) {
|
||||||
if (busy.value) return
|
if (!habitReorderMode.value || !habitReorderAvailable.value || busy.value) return
|
||||||
habitReorder.value = { id: h.id, startY: event.clientY, offsetY: 0 }
|
habitReorder.value = { id: h.id, startY: event.clientY, offsetY: 0 }
|
||||||
habitReorderTarget.value = h.id
|
habitReorderTarget.value = h.id
|
||||||
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
|
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
|
||||||
@@ -692,7 +697,7 @@ onBeforeUnmount(() => {
|
|||||||
<Transition name="task-compose">
|
<Transition name="task-compose">
|
||||||
<div v-if="habitComposerOpen" class="task-compose-mask app-sheet-mask" @click.self="closeHabitComposer">
|
<div v-if="habitComposerOpen" class="task-compose-mask app-sheet-mask" @click.self="closeHabitComposer">
|
||||||
<form class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="saveHabit" @keydown.esc="closeHabitComposer">
|
<form class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="saveHabit" @keydown.esc="closeHabitComposer">
|
||||||
<header class="app-sheet__header"><div><small>{{ editingHabit ? 'EDIT HABIT' : 'NEW HABIT' }}</small><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
|
<header class="app-sheet__header"><div><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
|
||||||
<div class="app-sheet__body">
|
<div class="app-sheet__body">
|
||||||
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
|
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
|
||||||
<label>习惯名称<input ref="habitNameInput" v-model="habitName" placeholder="例如:每天喝水 8 杯" aria-label="新习惯名称" :aria-invalid="Boolean(habitErrors.name)" aria-describedby="habit-name-error"><small v-if="habitErrors.name" id="habit-name-error" class="field-error" role="alert">{{ habitErrors.name }}</small></label>
|
<label>习惯名称<input ref="habitNameInput" v-model="habitName" placeholder="例如:每天喝水 8 杯" aria-label="新习惯名称" :aria-invalid="Boolean(habitErrors.name)" aria-describedby="habit-name-error"><small v-if="habitErrors.name" id="habit-name-error" class="field-error" role="alert">{{ habitErrors.name }}</small></label>
|
||||||
@@ -708,10 +713,11 @@ onBeforeUnmount(() => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||||||
<!-- 习惯列表支持整行滑动记录。 -->
|
<!-- 习惯列表支持整行滑动记录。 -->
|
||||||
<div v-if="view === 'habits'" class="habit-list">
|
<div v-if="view === 'habits'" class="habit-list">
|
||||||
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||||
<button class="drag-handle habit-drag-handle" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
|
<button v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
|
||||||
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
||||||
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
|
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
|
||||||
<span class="habit-name">{{ h.name }}</span>
|
<span class="habit-name">{{ h.name }}</span>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -554,6 +554,45 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(mvpPanel.match(/'--reorder-y': `\$\{habitReorder\?\.id === h\.id \? habitReorder\.offsetY : 0\}px`/g)?.length).toBe(1)
|
expect(mvpPanel.match(/'--reorder-y': `\$\{habitReorder\?\.id === h\.id \? habitReorder\.offsetY : 0\}px`/g)?.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows task drag handles only in an explicit available reorder mode', () => {
|
||||||
|
expect(app).toContain('const taskReorderMode = ref(false)')
|
||||||
|
expect(app).toContain("const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1)")
|
||||||
|
expect(app).toContain('class="soft-button reorder-mode-toggle task-reorder-toggle"')
|
||||||
|
expect(app).toContain("{{ taskReorderMode ? '完成' : '调整顺序' }}")
|
||||||
|
expect(app).toContain('v-if="taskReorderMode" class="drag-handle task-drag-handle"')
|
||||||
|
expect(app).toContain('if (!taskReorderAvailable.value) taskReorderMode.value = false')
|
||||||
|
expect(app).toContain('taskReorderMode.value = false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows habit drag handles only in explicit available reorder mode and never changes Today habits', () => {
|
||||||
|
expect(mvpPanel).toContain('const habitReorderMode = ref(false)')
|
||||||
|
expect(mvpPanel).toContain("const habitReorderAvailable = computed(() => props.view === 'habits' && visibleHabits.value.length > 1)")
|
||||||
|
expect(mvpPanel).toContain('v-if="habitReorderAvailable" class="habit-reorder-toolbar"')
|
||||||
|
expect(mvpPanel).toContain('class="soft-button reorder-mode-toggle habit-reorder-toggle"')
|
||||||
|
expect(mvpPanel).toContain("{{ habitReorderMode ? '完成' : '调整顺序' }}")
|
||||||
|
expect(mvpPanel).toContain('v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle"')
|
||||||
|
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
|
||||||
|
expect(todayHabits).not.toContain('reorder-mode-toggle')
|
||||||
|
expect(todayHabits).not.toContain('habit-drag-handle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses the task row completion control in task details', () => {
|
||||||
|
const detailTitle = app.slice(app.indexOf('<div class="detail-title">'), app.indexOf('</div>', app.indexOf('<div class="detail-title">')))
|
||||||
|
expect(detailTitle).toContain('class="task-check detail-task-check"')
|
||||||
|
expect(detailTitle).toContain(':aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`"')
|
||||||
|
expect(detailTitle).toContain(':aria-pressed="selectedTask.completed"')
|
||||||
|
expect(detailTitle).toContain('<span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span>')
|
||||||
|
expect(detailTitle).not.toContain('class="check large"')
|
||||||
|
expect(css).toContain('.detail-title .detail-task-check{width:44px;height:44px;flex:0 0 44px;')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes English eyebrows from task and habit composers', () => {
|
||||||
|
expect(app).not.toContain('<small>NEW TASK</small>')
|
||||||
|
expect(mvpPanel).not.toContain("<small>{{ editingHabit ? 'EDIT HABIT' : 'NEW HABIT' }}</small>")
|
||||||
|
expect(app).toContain('<h2 id="task-compose-title">{{ taskComposeTitle }}</h2>')
|
||||||
|
expect(mvpPanel).toContain('<h2 id="habit-compose-title">{{ habitComposerTitle }}</h2>')
|
||||||
|
})
|
||||||
|
|
||||||
it('exposes complete titles on every ellipsized visible task title node', () => {
|
it('exposes complete titles on every ellipsized visible task title node', () => {
|
||||||
expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(2)
|
expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(2)
|
||||||
expect(app).not.toContain('<strong :title="subtask.title">{{subtask.title}}</strong>')
|
expect(app).not.toContain('<strong :title="subtask.title">{{subtask.title}}</strong>')
|
||||||
@@ -777,7 +816,7 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(mvpPanel).toContain('v-for="h in visibleTodayHabits"')
|
expect(mvpPanel).toContain('v-for="h in visibleTodayHabits"')
|
||||||
expect(mvpPanel).toContain('v-for="h in visibleHabits"')
|
expect(mvpPanel).toContain('v-for="h in visibleHabits"')
|
||||||
expect(mvpPanel).not.toContain(':disabled="!showCompleted"')
|
expect(mvpPanel).not.toContain(':disabled="!showCompleted"')
|
||||||
expect(mvpPanel).toContain('if (busy.value) return')
|
expect(mvpPanel).toContain('if (!habitReorderMode.value || !habitReorderAvailable.value || busy.value) return')
|
||||||
expect(mvpPanel).not.toContain('if (busy.value || !props.showCompleted) return')
|
expect(mvpPanel).not.toContain('if (busy.value || !props.showCompleted) return')
|
||||||
expect(mvpPanel).toContain('const reorderedVisible = moveItemWithinScope(visiblePrevious')
|
expect(mvpPanel).toContain('const reorderedVisible = moveItemWithinScope(visiblePrevious')
|
||||||
expect(mvpPanel).toContain('const next = mergeReorderedSubset(previous, reorderedVisible)')
|
expect(mvpPanel).toContain('const next = mergeReorderedSubset(previous, reorderedVisible)')
|
||||||
|
|||||||
Reference in New Issue
Block a user