feat: unify completed filters in topbar
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m30s

This commit is contained in:
2026-09-10 12:16:45 +08:00
parent 1bc481d2e4
commit bf61e69776
6 changed files with 133 additions and 33 deletions
+6 -5
View File
@@ -15,6 +15,7 @@ import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/arch
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue' import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue' import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue' import CalendarPicker from './components/CalendarPicker.vue'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
@@ -1180,17 +1181,17 @@ onUnmounted(() => {
</aside> </aside>
<main> <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> <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> <label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
</header> </header>
<template v-if="['habits','settings'].includes(activeView)"> <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> </template>
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" /> <CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else> <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="今日进度"> <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')"> <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> <span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong></span>
@@ -1212,7 +1213,7 @@ onUnmounted(() => {
</section> </section>
<h3 id="today-tasks" class="section-heading today-section-anchor"><ListTodo/>任务</h3> <h3 id="today-tasks" class="section-heading today-section-anchor"><ListTodo/>任务</h3>
</template> </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> <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}"> <section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id"> <template v-for="node in taskTree" :key="node.task.id">
+1 -3
View File
@@ -15,7 +15,6 @@ const emit = defineEmits<{
changed: [] changed: []
notice: [message: string] notice: [message: string]
summary: [value: { total: number; completed: number }] summary: [value: { total: number; completed: number }]
'update:showCompleted': [value: boolean]
}>() }>()
const habits = ref<Habit[]>([]) const habits = ref<Habit[]>([])
const archivedHabits = ref<Habit[]>([]) const archivedHabits = ref<Habit[]>([])
@@ -524,8 +523,7 @@ onBeforeUnmount(() => {
<!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 --> <!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 -->
<template v-if="view === 'habits' || view === 'today-habits'"> <template v-if="view === 'habits' || view === 'today-habits'">
<header v-if="view === 'habits'" class="view-intro"> <header v-if="view === 'habits'" class="view-intro">
<div><small>把想坚持的事变成每天的日常</small></div> <small>把想坚持的事变成每天的日常</small>
<div class="habit-toolbar"><label><input :checked="showCompleted" type="checkbox" @change="emit('update:showCompleted', ($event.target as HTMLInputElement).checked)"> 显示已完成</label></div>
</header> </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
View File
@@ -7,6 +7,7 @@ const app = readFileSync('src/App.vue', 'utf8')
const mvpPanel = readFileSync('src/MvpPanel.vue', 'utf8') const mvpPanel = readFileSync('src/MvpPanel.vue', 'utf8')
const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8') const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8')
const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8') const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8')
const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8')
describe('mobile navigation styles', () => { describe('mobile navigation styles', () => {
it('renames the bottom More tab to a direct Settings tab', () => { 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)') 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("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)")
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)") expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
expect(app).toContain(':show-completed="showCompleted"') 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('showCompleted: boolean')
expect(mvpPanel).toContain("'update:showCompleted': [value: boolean]") expect(mvpPanel).not.toContain("'update:showCompleted': [value: boolean]")
expect(mvpPanel).not.toContain('HIDE_COMPLETED_HABITS_STORAGE_KEY') expect(completedFilterPill).not.toContain('localStorage')
expect(mvpPanel).not.toContain('dodo.hide-completed-habits')
}) })
it('shows one positive switch before Today content and no switch inside today habits', () => { it('removes duplicate completed filters while preserving habit completion buttons', () => {
expect(app).toContain('class="list-toolbar today-completed-toolbar"') expect(app).not.toContain('today-completed-toolbar')
expect(app.indexOf('class="list-toolbar today-completed-toolbar"')).toBeLessThan(app.indexOf('class="today-board"')) expect(app).not.toContain('<input v-model="showCompleted" type="checkbox"> 显示已完成')
expect(app).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('<!-- 完整习惯列表')) 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('显示已完成')
expect(todayHabits).not.toContain('隐藏已完成')
}) })
it('adds top spacing only to the Today completed toolbar before mobile overrides', () => { it('keeps the pill at the topbar right and wraps only search when space is narrow', () => {
const todayToolbarRule = '.today-completed-toolbar{margin-top:8px}' expect(css).toContain('main{container-type:inline-size;')
const todayToolbarIndex = css.indexOf(todayToolbarRule) expect(css).toContain('.topbar{display:grid;grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter";')
const mobileBreakpointIndex = css.indexOf('@media(max-width:930px)') 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.match(/\.today-completed-toolbar\{/g)).toHaveLength(1) expect(css).toContain('.topbar-title{grid-area:title;min-width:0;width:100%}')
expect(todayToolbarIndex).toBeGreaterThan(css.indexOf('.list-toolbar{')) expect(css).toContain('.topbar h1{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;')
expect(todayToolbarIndex).toBeLessThan(mobileBreakpointIndex) expect(css).toContain('.topbar-filter{grid-area:filter;justify-self:end;')
expect(css.slice(mobileBreakpointIndex)).not.toContain('.today-completed-toolbar{') 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', () => { 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('v-for="h in visibleHabits"')
expect(mvpPanel).toContain(':disabled="!showCompleted"') expect(mvpPanel).toContain(':disabled="!showCompleted"')
expect(mvpPanel).toContain('if (busy.value || !props.showCompleted) return') 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', () => { it('distinguishes hidden completed items from true and search empty states', () => {