feat: unify completed filters in topbar
This commit is contained in:
@@ -15,6 +15,7 @@ import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/arch
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
import CountdownPanel from './CountdownPanel.vue'
|
||||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||
import CalendarPicker from './components/CalendarPicker.vue'
|
||||
|
||||
type FolderItem = { id: string; name: string }
|
||||
@@ -1180,17 +1181,17 @@ onUnmounted(() => {
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<header class="topbar" :class="{ 'today-topbar': activeView==='today' }">
|
||||
<header class="topbar">
|
||||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
||||
<div><h1>{{ activeName }}</h1></div>
|
||||
<div class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||||
<CompletedFilterPill v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" v-model="showCompleted" class="topbar-filter" />
|
||||
<label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||
</header>
|
||||
<template v-if="['habits','settings'].includes(activeView)">
|
||||
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @update:show-completed="showCompleted = $event" @changed="refreshAll" @notice="toast" />
|
||||
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
|
||||
</template>
|
||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||
<template v-else>
|
||||
<div v-if="activeView==='today'" class="list-toolbar today-completed-toolbar"><label><input v-model="showCompleted" type="checkbox"> 显示已完成</label></div>
|
||||
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
|
||||
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="scrollTodaySection('today-tasks')">
|
||||
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong></span>
|
||||
@@ -1212,7 +1213,7 @@ onUnmounted(() => {
|
||||
</section>
|
||||
<h3 id="today-tasks" class="section-heading today-section-anchor"><ListTodo/>任务</h3>
|
||||
</template>
|
||||
<div v-if="activeView!=='today'" class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><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><button v-if="query" class="link" @click="query=''">清除搜索</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 class="task-list" :class="{loading}">
|
||||
<template v-for="node in taskTree" :key="node.task.id">
|
||||
|
||||
@@ -15,7 +15,6 @@ const emit = defineEmits<{
|
||||
changed: []
|
||||
notice: [message: string]
|
||||
summary: [value: { total: number; completed: number }]
|
||||
'update:showCompleted': [value: boolean]
|
||||
}>()
|
||||
const habits = ref<Habit[]>([])
|
||||
const archivedHabits = ref<Habit[]>([])
|
||||
@@ -524,8 +523,7 @@ onBeforeUnmount(() => {
|
||||
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
||||
<template v-if="view === 'habits' || view === 'today-habits'">
|
||||
<header v-if="view === 'habits'" class="view-intro">
|
||||
<div><small>把想坚持的事,变成每天的日常</small></div>
|
||||
<div class="habit-toolbar"><label><input :checked="showCompleted" type="checkbox" @change="emit('update:showCompleted', ($event.target as HTMLInputElement).checked)"> 显示已完成</label></div>
|
||||
<small>把想坚持的事,变成每天的日常</small>
|
||||
</header>
|
||||
|
||||
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
import CompletedFilterPill from './CompletedFilterPill.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
async function mountPill(initial = false, disabled = false) {
|
||||
const host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
const value = ref(initial)
|
||||
const updates: boolean[] = []
|
||||
const app = createApp({
|
||||
setup: () => () => h(CompletedFilterPill, {
|
||||
modelValue: value.value,
|
||||
disabled,
|
||||
'onUpdate:modelValue': (next: boolean) => { updates.push(next); value.value = next },
|
||||
}),
|
||||
})
|
||||
app.mount(host)
|
||||
cleanups.push(() => { app.unmount(); host.remove() })
|
||||
await nextTick()
|
||||
return { button: host.querySelector('button')!, updates, value }
|
||||
}
|
||||
|
||||
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
||||
|
||||
describe('CompletedFilterPill', () => {
|
||||
it.each([false, true])('reflects modelValue %s as switch state', async (initial) => {
|
||||
const { button } = await mountPill(initial)
|
||||
expect(button.getAttribute('role')).toBe('switch')
|
||||
expect(button.getAttribute('aria-checked')).toBe(String(initial))
|
||||
expect(button.getAttribute('aria-label')).toBe('显示已完成')
|
||||
expect(button.textContent).toContain('显示已完成')
|
||||
expect(button.querySelector('.completed-filter-pill__track')?.getAttribute('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('toggles once when the whole pill is clicked', async () => {
|
||||
const { button, updates } = await mountPill(false)
|
||||
button.click()
|
||||
expect(updates).toEqual([true])
|
||||
})
|
||||
|
||||
it.each(['Enter', ' '])('toggles with %s through native button activation', async (key) => {
|
||||
const { button, updates } = await mountPill(false)
|
||||
button.focus()
|
||||
button.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
button.click()
|
||||
expect(updates).toEqual([true])
|
||||
})
|
||||
|
||||
it('does not emit while disabled', async () => {
|
||||
const { button, updates } = await mountPill(false, true)
|
||||
expect(button.disabled).toBe(true)
|
||||
button.click()
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
}>(), { disabled: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
function toggle() {
|
||||
if (!props.disabled) emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="completed-filter-pill"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
aria-label="显示已完成"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="completed-filter-pill__label">显示已完成</span>
|
||||
<span class="completed-filter-pill__track" aria-hidden="true">
|
||||
<span class="completed-filter-pill__thumb"></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
File diff suppressed because one or more lines are too long
+32
-20
@@ -7,6 +7,7 @@ const app = readFileSync('src/App.vue', 'utf8')
|
||||
const mvpPanel = readFileSync('src/MvpPanel.vue', 'utf8')
|
||||
const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8')
|
||||
const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8')
|
||||
const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8')
|
||||
|
||||
describe('mobile navigation styles', () => {
|
||||
it('renames the bottom More tab to a direct Settings tab', () => {
|
||||
@@ -419,35 +420,46 @@ describe('task and habit row decoration', () => {
|
||||
expect(app).toContain('if (restoredNavigation.view === \'tasks\' && restoredNavigation.listId)')
|
||||
})
|
||||
|
||||
it('uses one positive completed-item preference across task and habit views', () => {
|
||||
it('uses one topbar pill and keeps App as the only preference owner', () => {
|
||||
expect(app).toContain("import CompletedFilterPill from './components/CompletedFilterPill.vue'")
|
||||
expect(app.match(/<CompletedFilterPill/g)).toHaveLength(1)
|
||||
expect(app).toContain("v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\"")
|
||||
expect(app).toContain('v-model="showCompleted"')
|
||||
expect(app).toContain("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)")
|
||||
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
|
||||
expect(app).toContain(':show-completed="showCompleted"')
|
||||
expect(app).toContain('@update:show-completed="showCompleted = $event"')
|
||||
expect(app).not.toContain('@update:show-completed')
|
||||
expect(mvpPanel).toContain('showCompleted: boolean')
|
||||
expect(mvpPanel).toContain("'update:showCompleted': [value: boolean]")
|
||||
expect(mvpPanel).not.toContain('HIDE_COMPLETED_HABITS_STORAGE_KEY')
|
||||
expect(mvpPanel).not.toContain('dodo.hide-completed-habits')
|
||||
expect(mvpPanel).not.toContain("'update:showCompleted': [value: boolean]")
|
||||
expect(completedFilterPill).not.toContain('localStorage')
|
||||
})
|
||||
|
||||
it('shows one positive switch before Today content and no switch inside today habits', () => {
|
||||
expect(app).toContain('class="list-toolbar today-completed-toolbar"')
|
||||
expect(app.indexOf('class="list-toolbar today-completed-toolbar"')).toBeLessThan(app.indexOf('class="today-board"'))
|
||||
expect(app).toContain('<input v-model="showCompleted" type="checkbox"> 显示已完成')
|
||||
it('removes duplicate completed filters while preserving habit completion buttons', () => {
|
||||
expect(app).not.toContain('today-completed-toolbar')
|
||||
expect(app).not.toContain('<input v-model="showCompleted" type="checkbox"> 显示已完成')
|
||||
expect(mvpPanel).not.toContain('class="habit-toolbar"')
|
||||
expect(mvpPanel).not.toMatch(/type="checkbox"[^>]*显示已完成/)
|
||||
expect(mvpPanel.match(/class="task-check habit-check"/g)?.length).toBe(2)
|
||||
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
|
||||
expect(todayHabits).not.toContain('type="checkbox"')
|
||||
expect(todayHabits).not.toContain('completed-filter-pill')
|
||||
expect(todayHabits).not.toContain('显示已完成')
|
||||
expect(todayHabits).not.toContain('隐藏已完成')
|
||||
})
|
||||
|
||||
it('adds top spacing only to the Today completed toolbar before mobile overrides', () => {
|
||||
const todayToolbarRule = '.today-completed-toolbar{margin-top:8px}'
|
||||
const todayToolbarIndex = css.indexOf(todayToolbarRule)
|
||||
const mobileBreakpointIndex = css.indexOf('@media(max-width:930px)')
|
||||
expect(css.match(/\.today-completed-toolbar\{/g)).toHaveLength(1)
|
||||
expect(todayToolbarIndex).toBeGreaterThan(css.indexOf('.list-toolbar{'))
|
||||
expect(todayToolbarIndex).toBeLessThan(mobileBreakpointIndex)
|
||||
expect(css.slice(mobileBreakpointIndex)).not.toContain('.today-completed-toolbar{')
|
||||
it('keeps the pill at the topbar right and wraps only search when space is narrow', () => {
|
||||
expect(css).toContain('main{container-type:inline-size;')
|
||||
expect(css).toContain('.topbar{display:grid;grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter";')
|
||||
expect(css).toContain('.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) minmax(180px,260px) auto;grid-template-areas:"menu title search filter"}')
|
||||
expect(css).toContain('.topbar-title{grid-area:title;min-width:0;width:100%}')
|
||||
expect(css).toContain('.topbar h1{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;')
|
||||
expect(css).toContain('.topbar-filter{grid-area:filter;justify-self:end;')
|
||||
expect(app.indexOf('class="topbar-filter"')).toBeLessThan(app.indexOf('class="search"'))
|
||||
expect(css).toContain('@container(max-width:640px){.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" ". search search";grid-template-rows:auto auto;')
|
||||
expect(css).toContain('.topbar>.icon{grid-area:menu}.topbar-title{grid-area:title}.topbar-filter{grid-area:filter}.topbar .search{grid-area:search;grid-row:2;justify-self:end;width:min(260px,100%);')
|
||||
expect(css).toContain('@media(max-width:930px){.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" ". search search";grid-template-rows:auto auto;')
|
||||
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
|
||||
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
|
||||
expect(css).not.toContain('.today-completed-toolbar{margin-top:8px}')
|
||||
expect(css).not.toContain('.habit-toolbar{')
|
||||
})
|
||||
|
||||
it('filters habits with the shared preference and disables sorting while completed items are hidden', () => {
|
||||
@@ -457,7 +469,7 @@ describe('task and habit row decoration', () => {
|
||||
expect(mvpPanel).toContain('v-for="h in visibleHabits"')
|
||||
expect(mvpPanel).toContain(':disabled="!showCompleted"')
|
||||
expect(mvpPanel).toContain('if (busy.value || !props.showCompleted) return')
|
||||
expect(css).toContain('.habit-toolbar{')
|
||||
expect(css).not.toContain('.habit-toolbar{')
|
||||
})
|
||||
|
||||
it('distinguishes hidden completed items from true and search empty states', () => {
|
||||
|
||||
Reference in New Issue
Block a user