refactor: unify mobile list rows
This commit is contained in:
@@ -23,6 +23,10 @@ const habitName = ref('')
|
||||
const habitType = ref<'boolean' | 'numeric'>('boolean')
|
||||
const habitTarget = ref(1)
|
||||
const habitComposerOpen = ref(false)
|
||||
const selectedHabit = ref<Habit | null>(null)
|
||||
const habitDetailClickSuppressed = ref(false)
|
||||
const habitDetailSheet = ref<HTMLElement | null>(null)
|
||||
let habitDetailOpener: HTMLElement | null = null
|
||||
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||
const habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
|
||||
const habitNameInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -210,6 +214,10 @@ async function applyHabitSwipe(h: Habit, deltaX: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function suppressHabitDetailClick() {
|
||||
habitDetailClickSuppressed.value = true
|
||||
window.setTimeout(() => { habitDetailClickSuppressed.value = false }, 0)
|
||||
}
|
||||
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
const start = habitSwipeStart.value
|
||||
habitSwipeStart.value = null
|
||||
@@ -223,6 +231,7 @@ async function finishHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
? ((deltaX > 0 && current < (h.target ?? 1)) || (deltaX < 0 && current > 0))
|
||||
: (isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0)
|
||||
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||||
suppressHabitDetailClick()
|
||||
await applyHabitSwipe(h, deltaX)
|
||||
}
|
||||
}
|
||||
@@ -238,6 +247,7 @@ async function finishHabitPointer(h: Habit, event: PointerEvent) {
|
||||
? ((deltaX > 0 && current < (h.target ?? 1)) || (deltaX < 0 && current > 0))
|
||||
: (isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0)
|
||||
if (expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||||
suppressHabitDetailClick()
|
||||
void applyHabitSwipe(h, deltaX)
|
||||
}
|
||||
}
|
||||
@@ -281,11 +291,22 @@ function openHabitComposer(origin?: { x: number; y: number }) {
|
||||
void nextTick(() => habitNameInput.value?.focus())
|
||||
}
|
||||
function closeHabitComposer() { habitComposerOpen.value = false }
|
||||
function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
|
||||
if (habitDetailClickSuppressed.value) return
|
||||
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
|
||||
selectedHabit.value = h
|
||||
void nextTick(() => habitDetailSheet.value?.focus())
|
||||
}
|
||||
function closeHabitDetail() {
|
||||
selectedHabit.value = null
|
||||
void nextTick(() => habitDetailOpener?.focus())
|
||||
}
|
||||
defineExpose({ openHabitComposer })
|
||||
async function archiveHabit(h: Habit) {
|
||||
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
|
||||
await safe(async () => {
|
||||
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
||||
selectedHabit.value = null
|
||||
await loadHabits()
|
||||
emit('notice', '习惯已归档')
|
||||
})
|
||||
@@ -433,15 +454,23 @@ onBeforeUnmount(() => {
|
||||
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 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" :disabled="hideCompletedHabits" 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" :aria-label="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">
|
||||
<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>
|
||||
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
|
||||
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
|
||||
</div>
|
||||
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
|
||||
</article>
|
||||
<div v-if="!habits.length && !busy" class="empty-panel">还没有习惯,从一件容易坚持的小事开始。</div>
|
||||
</div>
|
||||
<Transition name="countdown-detail">
|
||||
<div v-if="selectedHabit" class="habit-detail-mask" @click.self="closeHabitDetail">
|
||||
<article ref="habitDetailSheet" class="habit-detail-sheet" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
||||
<header><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
||||
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
||||
<footer><button type="button" class="danger-text" @click="archiveHabit(selectedHabit)"><Trash2/>归档习惯</button></footer>
|
||||
</article>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- 设置与数据 -->
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -30,6 +30,27 @@ describe('mobile navigation styles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile list row language', () => {
|
||||
it('uses one quiet bordered row surface for tasks, habits, and countdowns on mobile', () => {
|
||||
expect(css).toContain('@media(max-width:930px){.task-row,.habit-row,.countdown-row{')
|
||||
expect(css).toMatch(/@media\(max-width:930px\)\{\.task-row,\.habit-row,\.countdown-row\{[^}]*background:#fff;[^}]*border:1px solid var\(--line\);[^}]*border-radius:13px;[^}]*box-shadow:none/)
|
||||
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}')
|
||||
expect(css).toContain('.task-main strong,.habit-name,.countdown-main>b{font-size:14px;')
|
||||
expect(css).toContain('.meta,.habit-progress,.countdown-main>small,.countdown-state small{font-size:11px;')
|
||||
})
|
||||
|
||||
it('keeps destructive habit actions in a detail sheet instead of the list row', () => {
|
||||
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-mask"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-sheet"')
|
||||
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(mvpPanel).toContain('ref="habitDetailSheet"')
|
||||
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
||||
})
|
||||
})
|
||||
|
||||
describe('task and habit row decoration', () => {
|
||||
it('removes the redundant habit refresh action', () => {
|
||||
expect(mvpPanel).not.toContain('<RefreshCw />刷新')
|
||||
|
||||
Reference in New Issue
Block a user