refactor: remove manual refresh and search controls
This commit is contained in:
@@ -92,7 +92,7 @@ test('complete ZIP backup preflights and replace-restores task, habit history, c
|
||||
await confirm.getByRole('button', { name: '确认', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('数据已恢复')
|
||||
|
||||
const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`)
|
||||
const restoredTasksResponse = await request.get('/api/v1/tasks?limit=100')
|
||||
expect(restoredTasksResponse.ok()).toBeTruthy()
|
||||
const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }>
|
||||
expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1)
|
||||
|
||||
@@ -161,7 +161,6 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a
|
||||
const sectionHeaders = [...page.querySelectorAll<HTMLElement>('.settings-group > header')]
|
||||
const rows = [...page.querySelectorAll<HTMLElement>('.settings-row')]
|
||||
const menu = main.querySelector<HTMLElement>('.topbar > .icon')!.getBoundingClientRect()
|
||||
const refresh = main.querySelector<HTMLElement>('.settings-refresh')!.getBoundingClientRect()
|
||||
const rect = page.getBoundingClientRect()
|
||||
const mainRect = main.getBoundingClientRect()
|
||||
return {
|
||||
@@ -171,7 +170,7 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a
|
||||
headingSize: getComputedStyle(heading).fontSize,
|
||||
sectionHeights: sectionHeaders.map(header => header.getBoundingClientRect().height),
|
||||
rowHeights: rows.map(row => row.getBoundingClientRect().height),
|
||||
controlsOverlap: !(menu.right <= refresh.left || refresh.right <= menu.left || menu.bottom <= refresh.top || refresh.bottom <= menu.top),
|
||||
menuWidth: menu.width,
|
||||
}
|
||||
})
|
||||
expect(layout.bodyOverflow).toBe(0)
|
||||
@@ -188,7 +187,8 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a
|
||||
}
|
||||
expect(layout.sectionHeights.every(height => height === 44)).toBeTruthy()
|
||||
expect(layout.rowHeights.every(height => height >= (isMobileContract ? 64 : 62))).toBeTruthy()
|
||||
expect(layout.controlsOverlap).toBe(false)
|
||||
expect(layout.menuWidth).toBeGreaterThanOrEqual(44)
|
||||
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
|
||||
|
||||
const exportButton = page.getByRole('button', { name: '导出 ZIP' })
|
||||
const fileInput = page.locator('input[type=file]')
|
||||
|
||||
@@ -32,6 +32,15 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
||||
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { name: habitName, kind: 'numeric', target: 8, max_value: 8, schedule_type: 'daily' })
|
||||
expect(habitResponse.ok(), await habitResponse.text()).toBeTruthy()
|
||||
const habitId = (await habitResponse.json()).id as string
|
||||
await page.route('**/api/v1/today/environment', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
date: { solar_date: '2026-09-19', weekday: '星期六', lunar: '八月初九' },
|
||||
weather: { status: 'fresh', temperature_c: 26.4, text: '多云', observed_at: '2026-09-19T20:45:00+08:00' },
|
||||
gold: { status: 'fresh', contract: 'Au99.99', price_cny_per_gram: 835.62, market_date: '2026-09-18' },
|
||||
}),
|
||||
}))
|
||||
await page.goto('/')
|
||||
const taskRowLocator = page.locator('#today-tasks .task-row').filter({ hasText: taskTitle })
|
||||
const habitRowLocator = page.locator('.today-habit-row').filter({ hasText: habitName })
|
||||
@@ -59,7 +68,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
||||
await expect(taskRowLocator).toHaveCount(1)
|
||||
await expect(habitRowLocator).toHaveCount(1)
|
||||
await expect(page.locator('.today-environment')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '刷新当前页面' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
|
||||
await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1)
|
||||
await expect(page.locator('.today-environment__gold')).toContainText('Au99.99')
|
||||
const metrics = await page.evaluate(({ taskId, habitId }) => {
|
||||
@@ -136,7 +145,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
||||
insideContent,
|
||||
weatherText: weather.innerText,
|
||||
goldText: gold.innerText,
|
||||
goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label'),
|
||||
goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label') ?? '',
|
||||
summaries,
|
||||
styles: {
|
||||
mainPaddingLeft: getComputedStyle(main).paddingLeft,
|
||||
|
||||
@@ -200,56 +200,3 @@ test('Habits and Countdowns use reduced headers, compact rows, and continuous ar
|
||||
expect(archiveMetrics.buttons.every(item => item.width >= 44 && item.height >= 44)).toBeTruthy()
|
||||
await expectNoHorizontalOverflow(page, 'countdowns', testInfo)
|
||||
})
|
||||
|
||||
test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and desktop-wide', async ({ page }, testInfo) => {
|
||||
await page.goto('/')
|
||||
await openSidebarView(page, '备忘录')
|
||||
const toolbar = page.locator('.memo-toolbar')
|
||||
const scope = page.locator('.memo-scope')
|
||||
const toggle = page.getByRole('button', { name: '展开搜索备忘录' })
|
||||
const collapsedGeometry = { toolbar: await box(toolbar), scope: await box(scope), toggle: await box(toggle) }
|
||||
console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-collapsed] ${JSON.stringify(collapsedGeometry)}`)
|
||||
expect(collapsedGeometry.toggle.width).toBeGreaterThanOrEqual(44)
|
||||
expect(collapsedGeometry.toggle.height).toBeGreaterThanOrEqual(44)
|
||||
expect(Math.abs(collapsedGeometry.scope.y - collapsedGeometry.toggle.y)).toBeLessThanOrEqual(4)
|
||||
expect(collapsedGeometry.toolbar.height).toBeLessThanOrEqual(52)
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await toggle.click()
|
||||
const closeToggle = page.getByRole('button', { name: '收起搜索备忘录' })
|
||||
const input = page.getByRole('textbox', { name: '搜索备忘录' })
|
||||
await expect(closeToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(`保留查询-${testInfo.project.name}`)
|
||||
const openPanel = page.locator('#memo-search-panel')
|
||||
const openMetrics = await openPanel.evaluate(element => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return { x: rect.x, right: rect.right, width: rect.width, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth }
|
||||
})
|
||||
console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-open] ${JSON.stringify(openMetrics)}`)
|
||||
expect(openMetrics.x).toBeGreaterThanOrEqual(0)
|
||||
expect(openMetrics.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1)
|
||||
expect(openMetrics.scrollWidth).toBeLessThanOrEqual(openMetrics.clientWidth)
|
||||
|
||||
await closeToggle.click()
|
||||
await expect(openPanel).toBeHidden()
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await toggle.click()
|
||||
await expect(input).toHaveValue(`保留查询-${testInfo.project.name}`)
|
||||
await expect(input).toBeFocused()
|
||||
await expectNoHorizontalOverflow(page, 'memo-mobile', testInfo)
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(openPanel).toBeVisible()
|
||||
const desktopMetrics = await openPanel.evaluate(element => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return { viewport: { width: innerWidth, height: innerHeight }, x: rect.x, y: rect.y, width: rect.width, display: style.display, hidden: (element as HTMLElement).hidden, documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth }
|
||||
})
|
||||
console.log(`[qa-metrics][${testInfo.project.name}][memo-desktop] ${JSON.stringify(desktopMetrics)}`)
|
||||
expect(desktopMetrics.hidden).toBeFalsy()
|
||||
expect(desktopMetrics.width).toBeGreaterThanOrEqual(320)
|
||||
expect(desktopMetrics.documentOverflow).toBe(0)
|
||||
await expect(toggle).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -36,7 +36,8 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n
|
||||
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
|
||||
for (const [title, days] of [['未来一天任务', 1], ['未来三天任务', 3]] as const) {
|
||||
const createdTitles = ['未来一天任务', '未来三天任务'] as const
|
||||
for (const [title, days] of [[createdTitles[0], 1], [createdTitles[1], 3]] as const) {
|
||||
const response = await mutate(request, baseURL!, '/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
data: { title, list_id: inbox.id, due_at: futureDueAt(days), due_has_time: false },
|
||||
@@ -55,8 +56,9 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n
|
||||
await expect(header.getByRole('switch', { name: '显示已完成' })).toBeVisible()
|
||||
await expect(page.locator('.list-section-heading')).toContainText('任务')
|
||||
await expect(page.getByRole('button', { name: '调整顺序' })).toHaveCount(0)
|
||||
await expect(page.locator('.task-row')).toHaveCount(2)
|
||||
await expect(page.locator('.task-row')).toContainText(['未来一天任务', '未来三天任务'])
|
||||
const createdRows = page.locator('.task-row').filter({ hasText: /未来一天任务|未来三天任务/ })
|
||||
await expect(createdRows).toHaveCount(2)
|
||||
await expect(createdRows).toContainText([...createdTitles])
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
@@ -67,14 +69,12 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n
|
||||
viewportWidth: innerWidth,
|
||||
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
header: rect('.list-page-context'),
|
||||
search: rect('.list-search-reveal'),
|
||||
section: rect('.list-section-heading'),
|
||||
list: rect('.task-list'),
|
||||
}
|
||||
})
|
||||
expect(geometry.overflow).toBe(0)
|
||||
expect(geometry.header.left).toBeCloseTo(geometry.search.left, 0)
|
||||
expect(geometry.search.left).toBeCloseTo(geometry.section.left, 0)
|
||||
expect(geometry.header.left).toBeCloseTo(geometry.section.left, 0)
|
||||
expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0)
|
||||
expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0)
|
||||
if (geometry.viewportWidth <= 720) {
|
||||
|
||||
@@ -114,14 +114,14 @@ test('boolean habits complete in place while paused and unscheduled rows explain
|
||||
await expect(unscheduledRow.getByRole('button', { name: '今天未安排' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('approved polish keeps search state, dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => {
|
||||
test('approved polish keeps dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => {
|
||||
const suffix = `${testInfo.project.name}-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const bootstrapResponse = await request.get('/api/v1/bootstrap')
|
||||
expect(bootstrapResponse.ok()).toBeTruthy()
|
||||
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
|
||||
const taskTitle = `搜索保留-${suffix}-这是用于检查长标题和右侧控件不会裁切的任务标题`
|
||||
const taskTitle = `长标题任务-${suffix}-这是用于检查长标题和右侧控件不会裁切的任务标题`
|
||||
const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
data: { title: taskTitle, list_id: inbox.id },
|
||||
@@ -145,26 +145,7 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
||||
|
||||
await page.goto('/')
|
||||
await openSidebarView(page, '收集箱')
|
||||
const mobileGeometry = await page.evaluate(() => {
|
||||
const main = document.querySelector('main.list-main')!.getBoundingClientRect()
|
||||
const header = document.querySelector('.list-page-context')!.getBoundingClientRect()
|
||||
const filter = document.querySelector('.list-inline-filter')!.getBoundingClientRect()
|
||||
const search = document.querySelector('.list-search-reveal')!.getBoundingClientRect()
|
||||
const toggle = document.querySelector('.list-search-reveal .task-search-toggle')!.getBoundingClientRect()
|
||||
return { main: { left: main.left, right: main.right }, header: { left: header.left, right: header.right, bottom: header.bottom }, filter: { left: filter.left, bottom: filter.bottom }, search: { left: search.left, right: search.right, top: search.top }, toggle: { left: toggle.left, top: toggle.top } }
|
||||
})
|
||||
expect(mobileGeometry.header.left - mobileGeometry.main.left).toBeCloseTo(29, 0)
|
||||
expect(mobileGeometry.main.right - mobileGeometry.header.right).toBeCloseTo(29, 0)
|
||||
expect(mobileGeometry.search.left).toBeGreaterThanOrEqual(mobileGeometry.header.left)
|
||||
expect(mobileGeometry.search.right).toBeLessThanOrEqual(mobileGeometry.header.right)
|
||||
expect(mobileGeometry.toggle.top).toBeGreaterThanOrEqual(mobileGeometry.header.bottom - 1)
|
||||
expect(mobileGeometry.toggle.left).toBeGreaterThanOrEqual(mobileGeometry.filter.left)
|
||||
const searchToggle = page.getByRole('button', { name: '展开搜索任务' })
|
||||
await expect(searchToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await searchToggle.click()
|
||||
const searchInput = page.getByRole('textbox', { name: '搜索任务' })
|
||||
await expect(searchInput).toBeFocused()
|
||||
await searchInput.fill(`搜索保留-${suffix}`)
|
||||
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
|
||||
const taskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
||||
await expect(taskRow).toHaveCount(1)
|
||||
await expectHeightInRange(taskRow, 57, 59)
|
||||
@@ -180,12 +161,6 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
||||
await expect(taskDetail.getByRole('button', { name: '保存更改' })).toBeEnabled()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(taskDetail).toBeHidden()
|
||||
await searchInput.press('Escape')
|
||||
const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' })
|
||||
await expect(collapsedToggle).toBeFocused()
|
||||
await expect(collapsedToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await collapsedToggle.click()
|
||||
await expect(searchInput).toHaveValue(`搜索保留-${suffix}`)
|
||||
|
||||
await bottomTab(page, '习惯').click()
|
||||
const mobileHabitGeometry = await page.evaluate(() => {
|
||||
@@ -261,22 +236,5 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq
|
||||
const desktopInbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
|
||||
await expect(desktopInbox).toBeInViewport()
|
||||
await desktopInbox.click()
|
||||
await expect(page.getByRole('button', { name: /展开搜索任务|收起搜索任务/ })).toBeHidden()
|
||||
const desktopSearch = page.getByRole('textbox', { name: '搜索任务' })
|
||||
await expect(desktopSearch).toBeVisible()
|
||||
const desktopPanel = page.locator('.list-search-reveal')
|
||||
const desktopBox = await desktopPanel.boundingBox()
|
||||
expect(desktopBox).not.toBeNull()
|
||||
expect(desktopBox!.width).toBeGreaterThanOrEqual(320)
|
||||
const desktopHeader = page.locator('.list-page-context')
|
||||
const desktopList = page.locator('.task-list')
|
||||
const desktopGeometry = await Promise.all([desktopHeader, desktopPanel, desktopList].map(async locator => locator.boundingBox()))
|
||||
for (const box of desktopGeometry) expect(box).not.toBeNull()
|
||||
expect(Math.abs(desktopGeometry[0]!.x - desktopGeometry[1]!.x)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(desktopGeometry[1]!.x - desktopGeometry[2]!.x)).toBeLessThanOrEqual(1)
|
||||
expect(desktopGeometry[0]!.width).toBeLessThanOrEqual(900)
|
||||
await desktopSearch.fill(`搜索保留-${suffix}`)
|
||||
const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
||||
await expect(desktopTaskRow).toHaveCount(1)
|
||||
await expectNotClipped(desktopTaskRow)
|
||||
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -30,13 +30,13 @@ test('inbox and task composer match approved visuals', async ({ page, request, b
|
||||
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
|
||||
await createTask(request, baseURL!, '整理本周工作记录', inbox.id)
|
||||
await createTask(request, baseURL!, '检查 Dodo 备份', inbox.id)
|
||||
await createTask(request, baseURL!, '周末买水果和牛奶', inbox.id)
|
||||
const seededTitles = ['整理本周工作记录', '检查 Dodo 备份', '周末买水果和牛奶'] as const
|
||||
for (const title of seededTitles) await createTask(request, baseURL!, title, inbox.id)
|
||||
|
||||
await page.goto('/')
|
||||
await openInbox(page)
|
||||
await expect(page.locator('.task-row')).toHaveCount(3)
|
||||
const seededRows = page.locator('.task-row').filter({ hasText: /整理本周工作记录|检查 Dodo 备份|周末买水果和牛奶/ })
|
||||
await expect(seededRows).toHaveCount(3)
|
||||
await expect(page).toHaveScreenshot('inbox.png', { fullPage: true })
|
||||
|
||||
await page.getByRole('button', { name: '添加任务' }).click()
|
||||
|
||||
+19
-141
@@ -2,15 +2,14 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
|
||||
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
|
||||
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
|
||||
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
|
||||
Settings, Trash2, X, Repeat2, StickyNote,
|
||||
} from 'lucide-vue-next'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||
import { deriveMemoShellState } from './lib/app-shell-state'
|
||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
@@ -76,21 +75,9 @@ const listReorderPlacement = ref<'before' | 'after'>('after')
|
||||
let listHandleLongPressTimer: number | undefined
|
||||
let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
|
||||
let suppressListClickId = ''
|
||||
const query = ref('')
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const taskSearchToggle = ref<HTMLButtonElement | null>(null)
|
||||
const mobileSearchOpen = ref(false)
|
||||
const searchPullDistance = ref(0)
|
||||
let searchTouchStartY: number | null = null
|
||||
const taskSearchAvailable = computed(() => ['tasks', 'today', 'upcoming', 'trash'].includes(activeView.value))
|
||||
const searchRevealStyle = computed(() => ({
|
||||
'--search-pull': `${searchPullDistance.value}px`,
|
||||
'--search-pull-opacity': String(Math.min(1, searchPullDistance.value / 56)),
|
||||
}))
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const taskDueNowMs = useTaskDueClock()
|
||||
const mobileSidebar = ref(false)
|
||||
const sidebarCollapsed = ref(false)
|
||||
@@ -116,7 +103,7 @@ const todayEnvironmentLoading = ref(false)
|
||||
const todayEnvironmentError = ref(false)
|
||||
const todayEnvironmentDateKey = ref('')
|
||||
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 taskReorderAvailable = computed(() => activeView.value === 'tasks' && totalPages.value === 1 && taskTree.value.length > 1)
|
||||
const expandedFolders = ref(new Set<string>())
|
||||
const justCompletedTaskIds = ref(new Set<string>())
|
||||
const completionExitingTaskIds = ref(new Set<string>())
|
||||
@@ -377,22 +364,14 @@ const visibleTasks = computed(() => {
|
||||
return Boolean(dueToday || completedToday)
|
||||
})
|
||||
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8))
|
||||
return query.value.trim() ? filterTasks(result, query.value) : result
|
||||
return result
|
||||
})
|
||||
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
|
||||
const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value))
|
||||
const taskTree = computed(() => filteredTaskTree.value)
|
||||
const taskTree = computed(() => groupTaskTree(visibleTasks.value))
|
||||
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
|
||||
let searchTimer: number | undefined
|
||||
watch(composeDueAt, (value) => {
|
||||
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
|
||||
})
|
||||
watch(query, () => {
|
||||
if (searchTimer) window.clearTimeout(searchTimer)
|
||||
if (!isTaskView(activeView.value)) return
|
||||
page.value = 1
|
||||
searchTimer = window.setTimeout(() => loadAll(), 250)
|
||||
})
|
||||
watch(taskReorderAvailable, () => {
|
||||
if (!taskReorderAvailable.value) taskReorderMode.value = false
|
||||
})
|
||||
@@ -571,8 +550,7 @@ async function loadOverdueTasks(request = beginLatestRequest('tasks')) {
|
||||
}
|
||||
async function loadTasksPage(request = beginLatestRequest('tasks')) {
|
||||
const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) })
|
||||
if (query.value) params.set('q', query.value)
|
||||
else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
|
||||
if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
|
||||
if (activeView.value === 'today') {
|
||||
params.set('due_from', isoAtLocalDayOffset(0))
|
||||
params.set('due_to', isoAtLocalDayOffset(1))
|
||||
@@ -587,9 +565,9 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) {
|
||||
if (!isLatestRequest('tasks', request)) return
|
||||
tasks.value = data.items ?? []
|
||||
totalTasks.value = data.total ?? tasks.value.length
|
||||
if (activeView.value === 'upcoming' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
|
||||
if (activeView.value === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
|
||||
if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && (showCompleted.value || query.value)) {
|
||||
if (activeView.value === 'upcoming' && !showCompleted.value) taskOpenTotal.value = totalTasks.value
|
||||
if (activeView.value === 'tasks' && !showCompleted.value) taskOpenTotal.value = totalTasks.value
|
||||
if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && showCompleted.value) {
|
||||
const token = ++taskOpenTotalLoadToken
|
||||
const listId = activeList.value
|
||||
const openParams = new URLSearchParams({ page: '1', page_size: '1', completed: 'false' })
|
||||
@@ -603,7 +581,7 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) {
|
||||
)
|
||||
}
|
||||
hiddenCompletedTaskCount.value = 0
|
||||
if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) {
|
||||
if (!showCompleted.value && activeView.value !== 'trash' && tasks.value.length === 0) {
|
||||
const completedParams = new URLSearchParams(params)
|
||||
completedParams.set('page', '1')
|
||||
completedParams.set('page_size', '1')
|
||||
@@ -671,18 +649,6 @@ async function refreshAll() {
|
||||
navigationLoaded.value = false
|
||||
await loadAll()
|
||||
}
|
||||
async function refreshCurrentView() {
|
||||
if (refreshing.value || loading.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
if (activeView.value === 'habits') await habitComposer.value?.refreshHabits()
|
||||
else if (activeView.value === 'settings') await habitComposer.value?.refreshSettings()
|
||||
else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()])
|
||||
else await refreshAll()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
type TrashPage = { items?: Task[]; total?: number }
|
||||
async function loadTrashPage() {
|
||||
return api(`/trash?page=${page.value}&page_size=${pageSize}`) as Promise<TrashPage>
|
||||
@@ -712,8 +678,6 @@ async function switchView(view: View, listId?: string) {
|
||||
activeView.value = view
|
||||
taskOpenTotal.value = null
|
||||
++taskOpenTotalLoadToken
|
||||
if (!query.value) mobileSearchOpen.value = false
|
||||
searchPullDistance.value = 0
|
||||
if (view !== 'trash') beginLatestRequest('trash')
|
||||
if (!isTaskView(view)) {
|
||||
beginLatestRequest('tasks')
|
||||
@@ -768,9 +732,9 @@ async function patchTask(task: Task, patch: Partial<Task>, updateSelected = true
|
||||
return result.value ?? false
|
||||
}
|
||||
const taskMutationNavigation = ref(0)
|
||||
type TaskViewContext = { navigation: number; view: View; listId: string; page: number; query: string; showCompleted: boolean }
|
||||
const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value })
|
||||
const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted
|
||||
type TaskViewContext = { navigation: number; view: View; listId: string; page: number; showCompleted: boolean }
|
||||
const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, showCompleted: showCompleted.value })
|
||||
const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.showCompleted === right.showCompleted
|
||||
async function reconcileCurrentViewAfterTaskMutation(options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) {
|
||||
await reconcileCurrentTaskView({
|
||||
capture: currentTaskViewContext,
|
||||
@@ -1509,76 +1473,6 @@ function nextPage() {
|
||||
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||||
}
|
||||
|
||||
function isMobileSearchLayout() {
|
||||
return window.matchMedia('(max-width: 930px)').matches
|
||||
}
|
||||
function openTaskSearch() {
|
||||
if (!taskSearchAvailable.value) return
|
||||
if (isMobileSearchLayout()) mobileSearchOpen.value = true
|
||||
searchPullDistance.value = 0
|
||||
void nextTick(() => searchInput.value?.focus())
|
||||
}
|
||||
function closeTaskSearch() {
|
||||
if (!isMobileSearchLayout()) return
|
||||
mobileSearchOpen.value = false
|
||||
searchPullDistance.value = 0
|
||||
void nextTick(() => taskSearchToggle.value?.focus())
|
||||
}
|
||||
function toggleTaskSearch() {
|
||||
if (mobileSearchOpen.value) closeTaskSearch()
|
||||
else openTaskSearch()
|
||||
}
|
||||
function handleTaskSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !isMobileSearchLayout()) return
|
||||
event.preventDefault()
|
||||
closeTaskSearch()
|
||||
}
|
||||
function startSearchPull(event: TouchEvent) {
|
||||
if (!taskSearchAvailable.value || !isMobileSearchLayout()) return
|
||||
const target = event.target as Element | null
|
||||
if (target?.closest('input, textarea, select, button, .task-row, .habit-row, .countdown-row')) return
|
||||
const main = event.currentTarget as HTMLElement
|
||||
const shell = main.closest('.shell') as HTMLElement | null
|
||||
if (!isAtSearchPullOrigin(main.scrollTop, shell?.scrollTop ?? 0, window.scrollY) || event.touches.length !== 1) return
|
||||
searchTouchStartY = event.touches[0].clientY
|
||||
searchPullDistance.value = 0
|
||||
}
|
||||
function moveSearchPull(event: TouchEvent) {
|
||||
if (searchTouchStartY === null || event.touches.length !== 1) return
|
||||
const deltaY = event.touches[0].clientY - searchTouchStartY
|
||||
if (mobileSearchOpen.value) {
|
||||
searchPullDistance.value = Math.min(0, deltaY)
|
||||
if (deltaY < 0 && !query.value.trim()) event.preventDefault()
|
||||
return
|
||||
}
|
||||
searchPullDistance.value = clampSearchPullDistance(deltaY)
|
||||
if (deltaY > 0) event.preventDefault()
|
||||
}
|
||||
function finishSearchPull(event: TouchEvent) {
|
||||
if (searchTouchStartY === null) return
|
||||
const endY = event.changedTouches[0]?.clientY ?? searchTouchStartY
|
||||
const deltaY = endY - searchTouchStartY
|
||||
searchTouchStartY = null
|
||||
if (!mobileSearchOpen.value && shouldRevealSearchAfterPull(deltaY)) openTaskSearch()
|
||||
else if (mobileSearchOpen.value && shouldHideSearchAfterSwipe(deltaY, query.value)) {
|
||||
mobileSearchOpen.value = false
|
||||
searchInput.value?.blur()
|
||||
}
|
||||
searchPullDistance.value = 0
|
||||
}
|
||||
function cancelSearchPull() {
|
||||
searchTouchStartY = null
|
||||
searchPullDistance.value = 0
|
||||
}
|
||||
function handleTaskSearchShortcut(event: KeyboardEvent) {
|
||||
if (!isSearchShortcut(event) || !taskSearchAvailable.value) return
|
||||
event.preventDefault()
|
||||
openTaskSearch()
|
||||
}
|
||||
function handleSearchBlur() {
|
||||
if (!query.value.trim()) mobileSearchOpen.value = false
|
||||
}
|
||||
|
||||
function handleViewportResize() {
|
||||
compactLayout.value = window.innerWidth <= 930
|
||||
handleArchivedListViewportChange()
|
||||
@@ -1587,7 +1481,6 @@ function handleViewportResize() {
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||
document.addEventListener('keydown', handleArchivedListEscape)
|
||||
document.addEventListener('keydown', handleTaskSearchShortcut)
|
||||
window.addEventListener('resize', handleViewportResize)
|
||||
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
|
||||
window.addEventListener('focus', handleTodayEnvironmentResume)
|
||||
@@ -1598,7 +1491,6 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||
document.removeEventListener('keydown', handleArchivedListEscape)
|
||||
document.removeEventListener('keydown', handleTaskSearchShortcut)
|
||||
window.removeEventListener('resize', handleViewportResize)
|
||||
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
|
||||
window.removeEventListener('focus', handleTodayEnvironmentResume)
|
||||
@@ -1690,19 +1582,10 @@ onUnmounted(() => {
|
||||
</AppSheet>
|
||||
</aside>
|
||||
|
||||
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
|
||||
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
|
||||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
||||
<div v-if="!['today','tasks','upcoming','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||||
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
|
||||
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
||||
</div>
|
||||
<button v-if="activeView==='settings'" class="icon topbar-refresh settings-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新设置" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
||||
<div v-if="taskSearchAvailable && !['tasks','upcoming'].includes(activeView)" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
|
||||
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-search-panel" @click="toggleTaskSearch"><Search/></button>
|
||||
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
|
||||
<label id="task-search-panel" class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务…" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
|
||||
</div>
|
||||
</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" />
|
||||
@@ -1721,11 +1604,6 @@ onUnmounted(() => {
|
||||
<div><h1 class="list-page-title" :title="activeName">{{ activeName }}</h1><p class="list-page-summary">{{ taskOpenTotal === null ? '待完成统计暂不可用' : `还有 ${taskOpenTotal} 项待完成` }}</p></div>
|
||||
<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />
|
||||
</section>
|
||||
<div v-if="activeView==='tasks' || activeView==='upcoming'" class="list-search-reveal" :class="{'mobile-search-open':mobileSearchOpen}">
|
||||
<label id="task-list-search-panel" class="list-search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务…" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
|
||||
<button v-if="query" class="list-search-clear" type="button" @click="query=''">清除搜索</button>
|
||||
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-list-search-panel" @click="toggleTaskSearch"><Search/></button>
|
||||
</div>
|
||||
<template v-if="activeView==='today'">
|
||||
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
|
||||
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '›' : '⌄' }}</span></button>
|
||||
@@ -1738,20 +1616,20 @@ onUnmounted(() => {
|
||||
<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">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '›' : '⌄' }}</span></button>
|
||||
</template>
|
||||
<div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||||
<div v-if="activeView==='trash'" 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'" 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="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==='tasks' && totalPages > 1" class="list-page-meta"><span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项</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>
|
||||
<section :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined">
|
||||
<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,'task-row--trash':activeView==='trash','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="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="taskReorderMode" class="drag-handle task-drag-handle" :disabled="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>
|
||||
<div class="task-main" :role="activeView==='trash' ? undefined : 'button'" :tabindex="activeView==='trash' ? undefined : 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>
|
||||
</article>
|
||||
</template>
|
||||
<div v-if="activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
|
||||
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
|
||||
<div v-if="activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
|
||||
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
|
||||
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '›' : '⌄' }}</span></button>
|
||||
|
||||
+50
-349
@@ -1,11 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import MemoPanel from './MemoPanel.vue'
|
||||
|
||||
const source = readFileSync('src/MemoPanel.vue', 'utf8')
|
||||
const memoCss = readFileSync('src/memo.css', 'utf8')
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||
|
||||
@@ -18,6 +14,9 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||
function panelElement<T extends Element>(host: HTMLElement, selector: string) {
|
||||
return host.querySelector<T>(selector) ?? document.querySelector<T>(selector)
|
||||
}
|
||||
async function answerDialog(confirm: boolean) {
|
||||
await nextTick()
|
||||
const selector = confirm ? '.app-dialog button[type="submit"]' : '.app-dialog .secondary'
|
||||
@@ -39,47 +38,6 @@ async function mount(request: RequestMock, onNotice?: (message: string) => void,
|
||||
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||
|
||||
describe('MemoPanel', () => {
|
||||
it('uses controlled mobile visibility, preserves query, and restores focus for Escape and toggle close', async () => {
|
||||
const originalWidth = window.innerWidth
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
||||
const request = vi.fn(async (): Promise<unknown> => ({ items: [], total: 0 }))
|
||||
const { host } = await mount(request)
|
||||
const toggle = host.querySelector<HTMLButtonElement>('[aria-label="展开搜索备忘录"]')!
|
||||
const panel = host.querySelector<HTMLElement>('#memo-search-panel')!
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(panel.hidden).toBe(true)
|
||||
|
||||
toggle.click(); await nextTick()
|
||||
const input = host.querySelector<HTMLInputElement>('#memo-search-input')!
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(panel.hidden).toBe(false)
|
||||
expect(document.activeElement).toBe(input)
|
||||
input.value = '保留'; input.dispatchEvent(new Event('input'))
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await nextTick()
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(panel.hidden).toBe(true)
|
||||
expect(input.value).toBe('保留')
|
||||
expect(document.activeElement).toBe(toggle)
|
||||
|
||||
toggle.click(); await nextTick()
|
||||
expect(document.activeElement).toBe(input)
|
||||
toggle.click(); await nextTick()
|
||||
expect(panel.hidden).toBe(true)
|
||||
expect(input.value).toBe('保留')
|
||||
expect(document.activeElement).toBe(toggle)
|
||||
expect(host.querySelector('[data-scope="active"]')?.getAttribute('aria-selected')).toBe('true')
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||
})
|
||||
|
||||
it('keeps desktop search visible and at least 320px wide while mobile fills without overflow', () => {
|
||||
expect(source).toContain('const mobileLayout = ref(window.innerWidth <= 930)')
|
||||
expect(source).toContain(':hidden="mobileLayout && !mobileSearchOpen"')
|
||||
expect(source).not.toContain('mobileSearchOpen || Boolean(query)')
|
||||
expect(memoCss).toMatch(/\.memo-search-panel\{[^}]*flex:0 1 420px;[^}]*width:min\(420px,100%\);[^}]*min-width:320px/)
|
||||
expect(memoCss).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.memo-search-panel\{[^}]*width:100%;[^}]*min-width:0/)
|
||||
expect(memoCss).toContain('.memo-search-panel[hidden]{display:none}')
|
||||
expect(memoCss).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.memo-search\{[^}]*width:100%;[^}]*min-width:0/)
|
||||
})
|
||||
|
||||
it('loads active memos in server order and appends the next 50', async () => {
|
||||
const request = vi.fn(async (path: string) => path.includes('page=2')
|
||||
@@ -88,22 +46,13 @@ describe('MemoPanel', () => {
|
||||
const { host } = await mount(request)
|
||||
expect(request.mock.calls[0][0]).toContain('/memos?scope=active')
|
||||
expect(request.mock.calls[0][0]).toContain('page_size=50')
|
||||
expect(request.mock.calls[0][0]).not.toContain('q=')
|
||||
expect(host.querySelector('[aria-label="搜索备忘录"]')).toBeNull()
|
||||
expect(host.querySelectorAll('.memo-row')).toHaveLength(1)
|
||||
host.querySelector<HTMLButtonElement>('.memo-load-more')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['第一条', '第二页'])
|
||||
})
|
||||
|
||||
it('searches title and body through the server and has a search empty state', async () => {
|
||||
vi.useFakeTimers()
|
||||
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
|
||||
const { host } = await mount(request)
|
||||
const input = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
input.value = '咖啡'; input.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(260); await nextTick()
|
||||
expect(request.mock.calls.at(-1)?.[0]).toContain('q=%E5%92%96%E5%95%A1')
|
||||
expect(host.textContent).toContain('没有匹配的备忘录')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('emits detail state and restores the actual triggering row after a clean close', async () => {
|
||||
const details: boolean[] = []
|
||||
@@ -114,23 +63,26 @@ describe('MemoPanel', () => {
|
||||
const row = host.querySelector<HTMLButtonElement>('.memo-row')!
|
||||
row.focus(); row.click(); await flush()
|
||||
expect(details).toEqual([true])
|
||||
expect(document.activeElement).toBe(host.querySelector('[aria-label="备忘录标题"]'))
|
||||
expect(document.activeElement).toBe(panelElement(host, '[aria-label="备忘录标题"]'))
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
expect(details).toEqual([true, false])
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
|
||||
it('falls back to the search field when the triggering row disconnects during the close patch', async () => {
|
||||
it('does not focus a removed triggering row when detail closes', async () => {
|
||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||
? { ...item, content: '正文' }
|
||||
: { items: [item], total: 1 })
|
||||
const { host } = await mount(request)
|
||||
const row = host.querySelector<HTMLButtonElement>('.memo-row')!
|
||||
row.click(); await flush()
|
||||
row.focus(); row.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
row.remove()
|
||||
await flush()
|
||||
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
||||
expect(row.isConnected).toBe(false)
|
||||
expect(document.activeElement).not.toBe(row)
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
expect(host.querySelector('[aria-label="搜索备忘录"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -146,12 +98,12 @@ describe('MemoPanel', () => {
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||
if (failure) pending.reject(failure)
|
||||
else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 })
|
||||
await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
expect(host.textContent).not.toContain('迟到保存失败')
|
||||
expect(notices).toEqual([])
|
||||
})
|
||||
@@ -168,11 +120,11 @@ describe('MemoPanel', () => {
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click(); await flush()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-reload')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||
reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -194,7 +146,7 @@ describe('MemoPanel', () => {
|
||||
if (_name !== 'restore') await answerDialog(true)
|
||||
await flush()
|
||||
expect(notices).toEqual([message])
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not move focus or close detail when dirty close is cancelled', async () => {
|
||||
@@ -205,10 +157,10 @@ describe('MemoPanel', () => {
|
||||
const { host } = await mount(request, undefined, (open) => details.push(open))
|
||||
const row = host.querySelector<HTMLButtonElement>('.memo-row')!
|
||||
row.click(); await flush()
|
||||
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
const content = panelElement<HTMLTextAreaElement>(host, '[aria-label="备忘录正文"]')!
|
||||
content.value = '未保存'; content.dispatchEvent(new Event('input')); content.focus(); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(false)
|
||||
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).not.toBeNull()
|
||||
expect(details).toEqual([true])
|
||||
expect(document.activeElement).toBe(content)
|
||||
})
|
||||
@@ -220,7 +172,7 @@ describe('MemoPanel', () => {
|
||||
const desktop = await mount(request)
|
||||
desktop.host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await Promise.resolve(); await nextTick()
|
||||
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(false)
|
||||
expect(desktop.host.querySelector('.memo-editor')?.hasAttribute('aria-modal')).toBe(false)
|
||||
expect(panelElement(desktop.host, '.memo-editor')?.hasAttribute('aria-modal')).toBe(false)
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
||||
window.dispatchEvent(new Event('resize')); await nextTick()
|
||||
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
|
||||
@@ -268,14 +220,14 @@ describe('MemoPanel', () => {
|
||||
rows[1].click()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
pending.resolve({ ...other, content: '不应打开' }); await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
|
||||
const scopePending = deferred<unknown>()
|
||||
request.mockImplementation((path: string): Promise<unknown> => path === '/memos/m1' ? scopePending.promise : Promise.resolve({ items: [item], total: 1 }))
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush()
|
||||
scopePending.resolve({ ...item, content: '也不应打开' }); await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates outstanding detail requests after delete and restore', async () => {
|
||||
@@ -297,12 +249,12 @@ describe('MemoPanel', () => {
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||
if (lifecycle === 'delete') {
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.danger-text')!.click()
|
||||
await answerDialog(true)
|
||||
} else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||
} else panelElement<HTMLButtonElement>(host, '.memo-editor footer .secondary')!.click()
|
||||
await flush()
|
||||
pending.resolve({ ...scopedItem, title: '不应重新打开', content: '迟到详情' }); await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
vi.restoreAllMocks(); unmount()
|
||||
}
|
||||
})
|
||||
@@ -328,7 +280,7 @@ describe('MemoPanel', () => {
|
||||
second.unmount()
|
||||
afterUnmount.resolve({ ...item, content: '卸载后迟到' })
|
||||
await flush()
|
||||
expect(second.host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(second.host, '.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('retries the same page after load-more fails', async () => {
|
||||
@@ -346,89 +298,6 @@ describe('MemoPanel', () => {
|
||||
expect(request.mock.calls.map(([path]) => path).filter((path) => path.includes('page=2'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not commit an old reset when query changes before the next debounce', async () => {
|
||||
vi.useFakeTimers()
|
||||
const oldReset = deferred<unknown>()
|
||||
const nextReset = deferred<unknown>()
|
||||
const request = vi.fn((path: string): Promise<unknown> => {
|
||||
if (path.includes('q=old')) return oldReset.promise
|
||||
if (path.includes('q=next')) return nextReset.promise
|
||||
return Promise.resolve({ items: [item], total: 51 })
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
|
||||
search.value = 'old'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
expect(host.querySelector<HTMLButtonElement>('.memo-load-more')?.textContent).toBe('正在加载…')
|
||||
|
||||
search.value = 'next'; search.dispatchEvent(new Event('input'))
|
||||
await nextTick()
|
||||
expect(host.querySelector<HTMLButtonElement>('.memo-load-more')?.textContent).toBe('加载更多')
|
||||
|
||||
oldReset.resolve({ items: [{ ...item, id: 'old', title: '旧查询结果' }], total: 1 })
|
||||
await flush()
|
||||
expect(host.textContent).not.toContain('旧查询结果')
|
||||
expect(host.querySelector('.memo-row strong')?.textContent).toBe('第一条')
|
||||
expect(request.mock.calls.some(([path]) => path.includes('q=next'))).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
nextReset.resolve({ items: [{ ...item, id: 'next', title: '新查询结果' }], total: 1 })
|
||||
await flush()
|
||||
expect(host.querySelector('.memo-row strong')?.textContent).toBe('新查询结果')
|
||||
})
|
||||
|
||||
it('does not surface an old reset failure when query changes before the next debounce', async () => {
|
||||
vi.useFakeTimers()
|
||||
const oldReset = deferred<unknown>()
|
||||
const request = vi.fn((path: string): Promise<unknown> => path.includes('q=old')
|
||||
? oldReset.promise
|
||||
: Promise.resolve({ items: [item], total: 51 }))
|
||||
const { host } = await mount(request)
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
|
||||
search.value = 'old'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
search.value = 'next'; search.dispatchEvent(new Event('input'))
|
||||
await nextTick()
|
||||
oldReset.reject(new Error('旧查询失败'))
|
||||
await flush()
|
||||
|
||||
expect(host.textContent).not.toContain('旧查询失败')
|
||||
expect(host.querySelector<HTMLButtonElement>('.memo-load-more')?.textContent).toBe('加载更多')
|
||||
expect(request.mock.calls.some(([path]) => path.includes('q=next'))).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks load-more during a reset and retries a failed reset from page one', async () => {
|
||||
vi.useFakeTimers()
|
||||
const reset = deferred<unknown>()
|
||||
let resetAttempts = 0
|
||||
const request = vi.fn((path: string): Promise<unknown> => {
|
||||
if (path.includes('q=reset')) {
|
||||
resetAttempts += 1
|
||||
return resetAttempts === 1 ? reset.promise : Promise.resolve({ items: [], total: 0 })
|
||||
}
|
||||
return Promise.resolve({ items: [item], total: 51 })
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
search.value = 'reset'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
|
||||
const loadMore = host.querySelector<HTMLButtonElement>('.memo-load-more')!
|
||||
expect(loadMore.disabled).toBe(true)
|
||||
loadMore.click(); await flush()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
reset.reject(new Error('重置失败')); await flush()
|
||||
expect(loadMore.disabled).toBe(true)
|
||||
loadMore.click(); await flush()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
|
||||
host.querySelector<HTMLButtonElement>('.memo-error .link')!.click(); await flush()
|
||||
expect(request.mock.calls.at(-1)?.[0]).toContain('q=reset')
|
||||
expect(request.mock.calls.at(-1)?.[0]).toContain('page=1')
|
||||
})
|
||||
|
||||
it('uses id DESC when saved memos have the same updated_at', async () => {
|
||||
const higherId = { ...item, id: 'm2', title: '较高 ID' }
|
||||
@@ -440,7 +309,7 @@ describe('MemoPanel', () => {
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '同时间更新'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
request.mockResolvedValueOnce({ ...item, title: '同时间更新', content: '正文', version: 2 })
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click(); await flush()
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['较高 ID', '同时间更新'])
|
||||
})
|
||||
|
||||
@@ -450,103 +319,21 @@ describe('MemoPanel', () => {
|
||||
: { items: [item], total: 1 })
|
||||
const { host } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
const content = panelElement<HTMLTextAreaElement>(host, '[aria-label="备忘录正文"]')!
|
||||
content.value = 'a'.repeat(120); content.dispatchEvent(new Event('input')); await nextTick()
|
||||
request.mockResolvedValueOnce({ ...item, content: 'a'.repeat(120), version: 2 })
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click(); await flush()
|
||||
expect(host.querySelector('.memo-row__excerpt')).toBeNull()
|
||||
expect(host.querySelector('.memo-row')?.textContent).not.toContain('a'.repeat(120))
|
||||
|
||||
content.value = 'b'.repeat(121); content.dispatchEvent(new Event('input')); await nextTick()
|
||||
request.mockResolvedValueOnce({ ...item, content: 'b'.repeat(121), version: 3 })
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click(); await flush()
|
||||
expect(host.querySelector('.memo-row__excerpt')).toBeNull()
|
||||
expect(host.querySelector('.memo-row')?.textContent).not.toContain('b'.repeat(121))
|
||||
})
|
||||
|
||||
it('updates from the save response, reorders rows, and removes a search mismatch', async () => {
|
||||
const older = { ...item, id: 'm2', title: '较旧', updated_at: '2026-09-10T02:00:00Z' }
|
||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||
? { ...item, content: '旧正文' }
|
||||
: { items: [item, older], total: 2 })
|
||||
const { host } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '最新'; title.dispatchEvent(new Event('input'))
|
||||
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
content.value = 'x'.repeat(121); content.dispatchEvent(new Event('input')); await nextTick()
|
||||
request.mockResolvedValueOnce({ ...item, title: '最新', content: 'x'.repeat(121), updated_at: '2026-09-14T02:00:00Z', version: 2 })
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['最新', '较旧'])
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('最新')
|
||||
expect(host.querySelector('.memo-row__excerpt')).toBeNull()
|
||||
expect(host.querySelector('.memo-row')?.textContent).not.toContain('x'.repeat(121))
|
||||
|
||||
vi.useFakeTimers()
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
request.mockResolvedValueOnce({ items: [{ ...item, title: '最新', excerpt: '匹配', updated_at: '2026-09-14T02:00:00Z', version: 2 }], total: 1 })
|
||||
search.value = '匹配'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
title.value = '不相关'; title.dispatchEvent(new Event('input'))
|
||||
content.value = ''; content.dispatchEvent(new Event('input')); await nextTick()
|
||||
request.mockResolvedValueOnce({ ...item, title: '不相关', content: '', updated_at: '2026-09-15T02:00:00Z', version: 3 })
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
expect(host.querySelectorAll('.memo-row')).toHaveLength(0)
|
||||
expect(host.textContent).toContain('没有匹配的备忘录')
|
||||
expect(host.querySelector('.memo-load-more')).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a late save to the committed list while a new query is still debouncing', async () => {
|
||||
vi.useFakeTimers()
|
||||
const save = deferred<unknown>()
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (path === '/memos/m1' && options?.method === 'PATCH') return save.promise
|
||||
if (path === '/memos/m1') return Promise.resolve({ ...item, content: '旧正文' })
|
||||
return Promise.resolve({ items: [item], total: 2 })
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '保存后的标题'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
search.value = '另一个查询'; search.dispatchEvent(new Event('input')); await nextTick()
|
||||
save.resolve({ ...item, title: '保存后的标题', content: '旧正文', version: 2, updated_at: '2026-09-14T02:00:00Z' })
|
||||
await flush()
|
||||
|
||||
expect(host.querySelector('.memo-row strong')?.textContent).toBe('保存后的标题')
|
||||
expect(host.querySelector('.memo-load-more')).not.toBeNull()
|
||||
expect(request.mock.calls.some(([path]) => path.includes('q=%E5%8F%A6%E4%B8%80%E4%B8%AA%E6%9F%A5%E8%AF%A2'))).toBe(false)
|
||||
})
|
||||
|
||||
it('applies a late save to the last committed list after the new query fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const save = deferred<unknown>()
|
||||
const failedQuery = deferred<unknown>()
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (path === '/memos/m1' && options?.method === 'PATCH') return save.promise
|
||||
if (path === '/memos/m1') return Promise.resolve({ ...item, content: '旧正文' })
|
||||
if (path.includes('q=failed')) return failedQuery.promise
|
||||
return Promise.resolve({ items: [item], total: 2 })
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '迟到但已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
search.value = 'failed'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
failedQuery.reject(new Error('新查询失败')); await flush()
|
||||
save.resolve({ ...item, title: '迟到但已保存', content: '旧正文', version: 2, updated_at: '2026-09-14T02:00:00Z' })
|
||||
await flush()
|
||||
|
||||
expect(host.textContent).toContain('新查询失败')
|
||||
expect(host.querySelector('.memo-row strong')?.textContent).toBe('迟到但已保存')
|
||||
expect(host.querySelector('.memo-load-more')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not let a late save for memo A overwrite the editor after switching to memo B', async () => {
|
||||
const save = deferred<unknown>()
|
||||
@@ -561,7 +348,7 @@ describe('MemoPanel', () => {
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = 'A 已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click()
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||
@@ -589,7 +376,7 @@ describe('MemoPanel', () => {
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const firstTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click()
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
@@ -623,7 +410,7 @@ describe('MemoPanel', () => {
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const firstTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click()
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
@@ -636,7 +423,7 @@ describe('MemoPanel', () => {
|
||||
expect(host.textContent).not.toContain('旧版本冲突')
|
||||
expect(host.textContent).not.toContain('旧保存失败')
|
||||
expect(host.textContent).not.toContain('版本冲突:草稿已保留')
|
||||
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||
expect(panelElement(host, '.memo-reload')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not let an old save finally unlock a newer A save', async () => {
|
||||
@@ -661,13 +448,13 @@ describe('MemoPanel', () => {
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const firstTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click()
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
const saveButton = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||
const saveButton = panelElement<HTMLButtonElement>(host, '.memo-save')!
|
||||
expect(saveButton.disabled).toBe(false)
|
||||
saveButton.click(); await nextTick()
|
||||
expect(saveButton.textContent).toContain('正在保存')
|
||||
@@ -677,82 +464,12 @@ describe('MemoPanel', () => {
|
||||
expect(saveButton.disabled).toBe(true)
|
||||
expect(saveButton.textContent).toContain('正在保存')
|
||||
expect(reopenedTitle.value).toBe('A 新草稿')
|
||||
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||
expect(panelElement(host, '.memo-reload')).toBeNull()
|
||||
|
||||
newSave.resolve({ ...item, title: 'A 新草稿', content: 'A 正文', version: 2 }); await flush()
|
||||
expect(saveButton.textContent).toBe('保存')
|
||||
})
|
||||
|
||||
it('inserts a matching saved draft once using the committed search and server order', async () => {
|
||||
vi.useFakeTimers()
|
||||
const older = { ...item, id: 'm0', title: '咖啡旧记', updated_at: '2026-09-10T02:00:00Z' }
|
||||
const newer = { ...item, id: 'm9', title: '咖啡新记', updated_at: '2026-09-15T02:00:00Z' }
|
||||
const created = { ...item, id: 'm5', title: '咖啡创建', content: '', updated_at: '2026-09-14T02:00:00Z' }
|
||||
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => options?.method === 'POST'
|
||||
? created
|
||||
: { items: [newer, older], total: 50 })
|
||||
const { host, vm } = await mount(request)
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
search.value = '咖啡'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
await vm.createMemo(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '咖啡创建'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['咖啡新记', '咖啡创建', '咖啡旧记'])
|
||||
expect(host.querySelector('.memo-load-more')).not.toBeNull()
|
||||
expect(title.value).toBe('咖啡创建')
|
||||
})
|
||||
|
||||
it('does not insert or count a saved draft that misses the committed search', async () => {
|
||||
vi.useFakeTimers()
|
||||
const created = { ...item, id: 'new', title: '茶记录', content: '', updated_at: '2026-09-14T03:00:00Z' }
|
||||
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => options?.method === 'POST'
|
||||
? created
|
||||
: { items: [{ ...item, title: '咖啡记录' }], total: 1 })
|
||||
const notice = vi.fn()
|
||||
const { host, vm } = await mount(request, notice)
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
search.value = '咖啡'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
await vm.createMemo(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '茶记录'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['咖啡记录'])
|
||||
expect(host.querySelector('.memo-load-more')).toBeNull()
|
||||
expect(title.value).toBe('茶记录')
|
||||
expect(notice).toHaveBeenCalledWith('备忘录已创建')
|
||||
})
|
||||
|
||||
it('binds draft insertion to committed criteria when query changes during save', async () => {
|
||||
vi.useFakeTimers()
|
||||
const create = deferred<unknown>()
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (options?.method === 'POST') return create.promise
|
||||
if (path.includes('q=next')) return Promise.resolve({ items: [{ ...item, id: 'next', title: 'next result' }], total: 1 })
|
||||
return Promise.resolve({ items: [item], total: 1 })
|
||||
})
|
||||
const notice = vi.fn()
|
||||
const { host, vm } = await mount(request, notice)
|
||||
await vm.createMemo(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = 'next created'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
search.value = 'next'; search.dispatchEvent(new Event('input')); await nextTick()
|
||||
create.resolve({ ...item, id: 'new', title: 'next created', content: '', updated_at: '2026-09-14T03:00:00Z' })
|
||||
await flush()
|
||||
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toContain('next created')
|
||||
expect(title.value).toBe('next created')
|
||||
expect(notice).toHaveBeenCalledWith('备忘录已创建')
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['next result'])
|
||||
})
|
||||
|
||||
it('does not double-count a saved draft when the server returns an id already in the list', async () => {
|
||||
const duplicate = { ...item, title: '服务端重复 ID', content: '新正文', version: 2, updated_at: '2026-09-14T03:00:00Z' }
|
||||
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => options?.method === 'POST'
|
||||
@@ -762,33 +479,13 @@ describe('MemoPanel', () => {
|
||||
await vm.createMemo(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = duplicate.title; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
panelElement<HTMLButtonElement>(host, '.memo-save')!.click(); await flush()
|
||||
|
||||
expect(host.querySelectorAll('.memo-row')).toHaveLength(1)
|
||||
expect(host.querySelector('.memo-row strong')?.textContent).toBe('服务端重复 ID')
|
||||
expect(host.querySelector('.memo-load-more')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not insert a saved draft into a newly committed query', async () => {
|
||||
vi.useFakeTimers()
|
||||
const create = deferred<unknown>()
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (options?.method === 'POST') return create.promise
|
||||
if (path.includes('q=next')) return Promise.resolve({ items: [{ ...item, id: 'next', title: 'next result' }], total: 1 })
|
||||
return Promise.resolve({ items: [item], total: 1 })
|
||||
})
|
||||
const { host, vm } = await mount(request)
|
||||
await vm.createMemo(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = 'next created'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||
search.value = 'next'; search.dispatchEvent(new Event('input'))
|
||||
await vi.advanceTimersByTimeAsync(250); await flush()
|
||||
create.resolve({ ...item, id: 'new', title: 'next created', content: '', updated_at: '2026-09-14T03:00:00Z' }); await flush()
|
||||
|
||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['next result'])
|
||||
})
|
||||
|
||||
it('keeps memo B open when memo A deletion finishes late and removes only A from its committed list', async () => {
|
||||
const remove = deferred<unknown>()
|
||||
@@ -801,7 +498,7 @@ describe('MemoPanel', () => {
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
panelElement<HTMLButtonElement>(host, '.danger-text')!.click()
|
||||
await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
remove.resolve(undefined); await flush()
|
||||
@@ -810,16 +507,20 @@ describe('MemoPanel', () => {
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||
})
|
||||
|
||||
it('falls back to search after deletion removes the opening row during nextTick', async () => {
|
||||
it('does not focus the removed row after deletion', async () => {
|
||||
const request = vi.fn(async (path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (path === '/memos/m1' && options?.method === 'DELETE') return undefined
|
||||
if (path === '/memos/m1') return { ...item, content: '正文' }
|
||||
return { items: [item], total: 1 }
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await answerDialog(true)
|
||||
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
||||
const row = host.querySelector<HTMLButtonElement>('.memo-row')!
|
||||
row.focus(); row.click(); await flush()
|
||||
panelElement<HTMLButtonElement>(host, '.danger-text')!.click(); await answerDialog(true)
|
||||
expect(row.isConnected).toBe(false)
|
||||
expect(document.activeElement).not.toBe(row)
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
expect(host.querySelector('[aria-label="搜索备忘录"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens an empty local draft from the FAB without POST or list changes and ignores trash creation', async () => {
|
||||
@@ -836,7 +537,7 @@ describe('MemoPanel', () => {
|
||||
const callsInTrash = request.mock.calls.length
|
||||
await vm.createMemo(); await flush()
|
||||
expect(request.mock.calls).toHaveLength(callsInTrash)
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses AppDialog for dirty draft creation and honors cancel then confirm', async () => {
|
||||
@@ -865,7 +566,7 @@ describe('MemoPanel', () => {
|
||||
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||
expect(request.mock.calls.at(-1)?.[0]).toContain('scope=trash')
|
||||
await vm.createMemo(); await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
expect(panelElement(host, '.memo-editor')).toBeNull()
|
||||
host.querySelector<HTMLButtonElement>('[data-scope="active"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
|
||||
const callsBeforeCreate = request.mock.calls.length
|
||||
await vm.createMemo(); await flush()
|
||||
|
||||
+15
-36
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Archive, FileText, Search, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Archive, FileText } from 'lucide-vue-next'
|
||||
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
||||
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
@@ -10,7 +10,6 @@ type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = defineProps<{ request: RequestFn }>()
|
||||
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash']; detail: [open: boolean] }>()
|
||||
const scope = ref<'active' | 'trash'>('active')
|
||||
const query = ref('')
|
||||
const items = ref<MemoListItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
@@ -21,25 +20,21 @@ const selected = ref<MemoEditorValue | null>(null)
|
||||
const selectedToken = ref(0)
|
||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const searchToggle = ref<HTMLButtonElement | null>(null)
|
||||
const mobileLayout = ref(window.innerWidth <= 930)
|
||||
const mobileDetail = ref(mobileLayout.value)
|
||||
const mobileSearchOpen = ref(false)
|
||||
let detailOpener: HTMLElement | null = null
|
||||
let timer: number | undefined
|
||||
let generation = 0
|
||||
let committedGeneration = 0
|
||||
let detailGeneration = 0
|
||||
type MemoCriteria = { scope: 'active' | 'trash'; query: string }
|
||||
type MemoCriteria = { scope: 'active' | 'trash' }
|
||||
const saveContexts = new Map<number, { criteria: MemoCriteria; generation: number }>()
|
||||
const lifecycleContexts = new Map<number, { criteria: MemoCriteria; generation: number }>()
|
||||
function currentCriteria(): MemoCriteria { return { scope: scope.value, query: query.value.trim() } }
|
||||
function criteriaEqual(left: MemoCriteria, right: MemoCriteria) { return left.scope === right.scope && left.query === right.query }
|
||||
function currentCriteria(): MemoCriteria { return { scope: scope.value } }
|
||||
function criteriaEqual(left: MemoCriteria, right: MemoCriteria) { return left.scope === right.scope }
|
||||
const committedCriteria = ref<MemoCriteria>(currentCriteria())
|
||||
const criteriaMatch = computed(() => criteriaEqual(committedCriteria.value, currentCriteria()))
|
||||
const hasMore = computed(() => criteriaMatch.value && items.value.length < total.value)
|
||||
const emptyCopy = computed(() => query.value.trim() ? '没有匹配的备忘录' : scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
|
||||
const emptyCopy = computed(() => scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
|
||||
|
||||
async function load(reset = true) {
|
||||
const token = ++generation
|
||||
@@ -49,7 +44,7 @@ async function load(reset = true) {
|
||||
else refreshing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams({ scope: criteria.scope, q: criteria.query, page: String(requestedPage), page_size: '50' })
|
||||
const params = new URLSearchParams({ scope: criteria.scope, page: String(requestedPage), page_size: '50' })
|
||||
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
|
||||
if (token !== generation || !criteriaEqual(criteria, currentCriteria())) return
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
@@ -75,7 +70,7 @@ async function loadMore() {
|
||||
refreshing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(nextPage), page_size: '50' })
|
||||
const params = new URLSearchParams({ scope: scope.value, page: String(nextPage), page_size: '50' })
|
||||
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
|
||||
if (token !== generation) return
|
||||
items.value = [...items.value, ...data.items]
|
||||
@@ -91,7 +86,7 @@ function closeDetail() {
|
||||
emit('detail', false)
|
||||
const opener = detailOpener
|
||||
detailOpener = null
|
||||
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||
void nextTick(() => opener?.isConnected && opener.focus())
|
||||
}
|
||||
function showConfirm(options: AppDialogOptions) {
|
||||
return appDialog.value?.show(options).then((result) => result === true) ?? Promise.resolve(false)
|
||||
@@ -122,13 +117,11 @@ async function createMemo() {
|
||||
const token = ++detailGeneration
|
||||
selectedToken.value = token
|
||||
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
||||
detailOpener = searchInput.value
|
||||
detailOpener = null
|
||||
emit('detail', true)
|
||||
}
|
||||
function matchesCriteria(memo: MemoRecord, criteria: MemoCriteria) {
|
||||
if (criteria.scope !== 'active') return false
|
||||
const needle = criteria.query.toLocaleLowerCase()
|
||||
return !needle || memo.title.toLocaleLowerCase().includes(needle) || memo.content.toLocaleLowerCase().includes(needle)
|
||||
function matchesCriteria(_memo: MemoRecord, criteria: MemoCriteria) {
|
||||
return criteria.scope === 'active'
|
||||
}
|
||||
function compareMemos(left: MemoListItem, right: MemoListItem) {
|
||||
const updated = Date.parse(right.updated_at) - Date.parse(left.updated_at)
|
||||
@@ -178,27 +171,15 @@ function removeItem(id: string, selectionToken: number) {
|
||||
emit('detail', false)
|
||||
const opener = detailOpener
|
||||
detailOpener = null
|
||||
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||
void nextTick(() => opener?.isConnected && opener.focus())
|
||||
}
|
||||
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
|
||||
function openMobileSearch() { mobileSearchOpen.value = true; void nextTick(() => searchInput.value?.focus()) }
|
||||
function closeMobileSearch() { mobileSearchOpen.value = false; void nextTick(() => searchToggle.value?.focus()) }
|
||||
function handleSearchKeydown(event: KeyboardEvent) { if (event.key === 'Escape' && mobileLayout.value) { event.preventDefault(); closeMobileSearch() } }
|
||||
function clearSearch() { query.value = ''; void nextTick(() => searchInput.value?.focus()) }
|
||||
function updateLayout() {
|
||||
mobileLayout.value = window.innerWidth <= 930
|
||||
mobileDetail.value = mobileLayout.value
|
||||
}
|
||||
watch(query, () => {
|
||||
generation += 1
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
error.value = ''
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = window.setTimeout(() => void load(), 250)
|
||||
})
|
||||
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
|
||||
onUnmounted(() => { detailGeneration += 1; generation += 1; window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
|
||||
onUnmounted(() => { detailGeneration += 1; generation += 1; window.removeEventListener('resize', updateLayout) })
|
||||
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
|
||||
</script>
|
||||
|
||||
@@ -207,12 +188,10 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
||||
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
|
||||
<div class="memo-toolbar">
|
||||
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
|
||||
<button ref="searchToggle" class="memo-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索备忘录' : '展开搜索备忘录'" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
|
||||
<div id="memo-search-panel" class="memo-search-panel" :class="{'is-open':mobileSearchOpen}" :hidden="mobileLayout && !mobileSearchOpen"><label class="memo-search"><Search/><input id="memo-search-input" ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…" @keydown="handleSearchKeydown"></label><button v-if="query" type="button" class="memo-search-clear" aria-label="清空搜索" @click="clearSearch"><X/></button></div>
|
||||
</div>
|
||||
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
||||
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
||||
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角添加按钮新建一条'}}</span></div>
|
||||
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角添加按钮新建一条'}}</span></div>
|
||||
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
||||
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
||||
</div>
|
||||
|
||||
@@ -539,7 +539,7 @@ function closeHabitDetail() {
|
||||
else habitArchiveToggle.value?.focus()
|
||||
})
|
||||
}
|
||||
defineExpose({ openHabitComposer, refreshHabits: loadHabits, refreshSettings: loadSettings })
|
||||
defineExpose({ openHabitComposer })
|
||||
async function archiveHabit(h: Habit) {
|
||||
if (busy.value) return
|
||||
if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return
|
||||
|
||||
@@ -91,9 +91,9 @@ describe('Today environment integration', () => {
|
||||
expect(overdue).toBeGreaterThan(filter)
|
||||
expect(main.match(/>今天<\/h1>/g)).toHaveLength(1)
|
||||
expect(main).toContain("<div v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
|
||||
expect(main).toContain("<div v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\" class=\"topbar-actions\">")
|
||||
expect(main).not.toContain('class="topbar-actions"')
|
||||
expect(main).not.toContain('class="topbar-filter"')
|
||||
expect(main).toContain('aria-label="刷新当前页面"')
|
||||
expect(main).not.toContain('aria-label="刷新当前页面"')
|
||||
})
|
||||
|
||||
it('keeps the editorial environment layout readable at desktop and narrow widths', () => {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clampSearchPullDistance,
|
||||
isSearchShortcut,
|
||||
isAtSearchPullOrigin,
|
||||
shouldHideSearchAfterSwipe,
|
||||
shouldRevealSearchAfterPull,
|
||||
} from './mobile-search'
|
||||
|
||||
describe('mobile task search gestures', () => {
|
||||
it('clamps downward pull distance without treating upward motion as pull', () => {
|
||||
expect(clampSearchPullDistance(-20)).toBe(0)
|
||||
expect(clampSearchPullDistance(24)).toBe(24)
|
||||
expect(clampSearchPullDistance(100)).toBe(72)
|
||||
})
|
||||
|
||||
it('reveals only after crossing the pull threshold', () => {
|
||||
expect(shouldRevealSearchAfterPull(55)).toBe(false)
|
||||
expect(shouldRevealSearchAfterPull(56)).toBe(true)
|
||||
})
|
||||
|
||||
it('requires every relevant scroll container to be at the top', () => {
|
||||
expect(isAtSearchPullOrigin(0, 0, 0)).toBe(true)
|
||||
expect(isAtSearchPullOrigin(0, 12, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides only after an upward swipe when the query is empty', () => {
|
||||
expect(shouldHideSearchAfterSwipe(-36, '')).toBe(true)
|
||||
expect(shouldHideSearchAfterSwipe(-35, '')).toBe(false)
|
||||
expect(shouldHideSearchAfterSwipe(-80, 'dodo')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes command/control K without hijacking plain typing', () => {
|
||||
expect(isSearchShortcut({ key: 'k', metaKey: true, ctrlKey: false })).toBe(true)
|
||||
expect(isSearchShortcut({ key: 'K', metaKey: false, ctrlKey: true })).toBe(true)
|
||||
expect(isSearchShortcut({ key: 'k', metaKey: false, ctrlKey: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
export const SEARCH_PULL_THRESHOLD = 56
|
||||
export const SEARCH_PULL_LIMIT = 72
|
||||
export const SEARCH_HIDE_SWIPE_THRESHOLD = 36
|
||||
|
||||
export function clampSearchPullDistance(distance: number) {
|
||||
return Math.min(SEARCH_PULL_LIMIT, Math.max(0, distance))
|
||||
}
|
||||
|
||||
export function shouldRevealSearchAfterPull(distance: number) {
|
||||
return distance >= SEARCH_PULL_THRESHOLD
|
||||
}
|
||||
|
||||
export function isAtSearchPullOrigin(...scrollPositions: number[]) {
|
||||
return scrollPositions.every((position) => position <= 0)
|
||||
}
|
||||
|
||||
export function shouldHideSearchAfterSwipe(deltaY: number, query: string) {
|
||||
return !query.trim() && deltaY <= -SEARCH_HIDE_SWIPE_THRESHOLD
|
||||
}
|
||||
|
||||
export function isSearchShortcut(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>) {
|
||||
return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k'
|
||||
}
|
||||
@@ -1,35 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
type TaskFixture = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
list_name?: string
|
||||
subtasks?: SearchTask[]
|
||||
subtasks?: TaskFixture[]
|
||||
}
|
||||
|
||||
const tasks: SearchTask[] = [
|
||||
const tasks: TaskFixture[] = [
|
||||
{ id: '1', title: 'Write release notes', description: 'mention **API**', parent_id: null, completed: false },
|
||||
{ id: '2', title: 'Check links', description: '', parent_id: '1', completed: false },
|
||||
{ id: '3', title: 'Buy milk', description: '', parent_id: null, completed: true },
|
||||
]
|
||||
|
||||
describe('task utilities', () => {
|
||||
it('filters task titles and markdown descriptions case-insensitively', () => {
|
||||
expect(filterTasks(tasks, 'api').map((task) => task.id)).toEqual(['1'])
|
||||
expect(filterTasks(tasks, 'WRITE').map((task) => task.id)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('keeps a one-level subtask tree without duplicating children', () => {
|
||||
expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }])
|
||||
})
|
||||
|
||||
it('preserves subtasks already nested by the task API', () => {
|
||||
const child: SearchTask = { id: 'nested-child', title: 'Nested child', parent_id: 'nested-parent' }
|
||||
const parent: SearchTask = { id: 'nested-parent', title: 'Nested parent', parent_id: null, subtasks: [child] }
|
||||
const child: TaskFixture = { id: 'nested-child', title: 'Nested child', parent_id: 'nested-parent' }
|
||||
const parent: TaskFixture = { id: 'nested-parent', title: 'Nested parent', parent_id: null, subtasks: [child] }
|
||||
expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }])
|
||||
})
|
||||
|
||||
@@ -116,14 +111,6 @@ describe('task utilities', () => {
|
||||
expect(applyMarkdownFormat('work', 0, 4, 'task')).toEqual({ value: '- [ ] work', start: 6, end: 10 })
|
||||
})
|
||||
|
||||
it('filters titles, descriptions, and list names', () => {
|
||||
const searchable: SearchTask[] = [
|
||||
...tasks,
|
||||
{ id: '4', title: 'Plan', list_name: '工作清单' },
|
||||
]
|
||||
expect(filterTasks(searchable, '工作').map((task) => task.id)).toEqual(['4'])
|
||||
})
|
||||
|
||||
it('defaults new tasks to today without a time', () => {
|
||||
const due = defaultTaskDueAt(new Date(2026, 8, 8, 21, 30))
|
||||
expect(due).toBe('2026-09-08')
|
||||
|
||||
@@ -72,18 +72,6 @@ export function renderMarkdown(markdown = '') {
|
||||
return markdownRenderer.render(markdown)
|
||||
}
|
||||
|
||||
export function filterTasks<T extends MinimalTask>(tasks: T[], query: string) {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return tasks
|
||||
return tasks.filter((task) => {
|
||||
const haystack = [
|
||||
task.title,
|
||||
task.description ?? '',
|
||||
task.list_name ?? '',
|
||||
].join(' ')
|
||||
return haystack.toLowerCase().includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
|
||||
const children = new Map<string, T[]>()
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-search-toggle{display:none}.memo-search-panel{flex:0 1 420px;width:min(420px,100%);min-width:320px;display:flex;align-items:center;gap:4px}.memo-search-clear{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent;color:var(--muted)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;flex:1 1 auto;width:100%;min-width:0;display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;border:0;background:var(--surface-raised);padding:9px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row time{grid-column:2;grid-row:1;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;gap:8px}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-search-toggle{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);color:var(--text-secondary)}.memo-search-panel{grid-column:1/-1;width:100%;min-width:0;max-height:0;opacity:0;overflow:hidden;pointer-events:none;display:flex;transition:max-height .18s ease,opacity .15s ease}.memo-search-panel.is-open{max-height:44px;opacity:1;pointer-events:auto}.memo-search-panel[hidden]{display:none}.memo-search{width:100%;min-width:0}.memo-row{height:74px;min-height:74px;max-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;border:0;background:var(--surface-raised);padding:9px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row time{grid-column:2;grid-row:1;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:block}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-row{height:74px;min-height:74px;max-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const app = readFileSync('src/App.vue', 'utf8')
|
||||
const memo = readFileSync('src/MemoPanel.vue', 'utf8')
|
||||
const css = readFileSync('src/style.css', 'utf8')
|
||||
const memoCss = readFileSync('src/memo.css', 'utf8')
|
||||
|
||||
const source = `${app}\n${memo}`
|
||||
const styles = `${css}\n${memoCss}`
|
||||
|
||||
describe('refresh and search control removal', () => {
|
||||
it('removes every page-level refresh control and its dead state', () => {
|
||||
expect(source).not.toContain('RefreshCw')
|
||||
expect(source).not.toContain('topbar-refresh')
|
||||
expect(source).not.toContain('refreshCurrentView')
|
||||
expect(source).not.toContain('刷新当前页面')
|
||||
expect(source).not.toContain('刷新设置')
|
||||
expect(styles).not.toContain('topbar-refresh')
|
||||
})
|
||||
|
||||
it('removes task and memo search UI together with interaction state', () => {
|
||||
expect(source).not.toContain('搜索任务')
|
||||
expect(source).not.toContain('搜索备忘录')
|
||||
expect(source).not.toContain('task-search-toggle')
|
||||
expect(source).not.toContain('memo-search-toggle')
|
||||
expect(source).not.toContain('mobileSearchOpen')
|
||||
expect(source).not.toContain('handleTaskSearchShortcut')
|
||||
expect(source).not.toContain('startSearchPull')
|
||||
expect(source).not.toContain('const query = ref(')
|
||||
expect(styles).not.toContain('task-search-toggle')
|
||||
expect(styles).not.toContain('memo-search-toggle')
|
||||
})
|
||||
})
|
||||
+11
-31
File diff suppressed because one or more lines are too long
+33
-39
@@ -160,7 +160,8 @@ describe('approved Settings 01 paper ledger', () => {
|
||||
expect(app).not.toContain("'settings-main':activeView==='settings'")
|
||||
expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"")
|
||||
expect(app).toContain("v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
|
||||
expect(app).toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
|
||||
expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
|
||||
expect(app).not.toContain('aria-label="刷新当前页面"')
|
||||
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
|
||||
expect(mvpPanel).toContain('<h2>数据与恢复</h2><small>完整备份</small>')
|
||||
expect(mvpPanel).toContain("<h2>登录设备</h2><small>{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}</small>")
|
||||
@@ -175,7 +176,7 @@ describe('approved Settings 01 paper ledger', () => {
|
||||
expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}')
|
||||
expect(css).toContain('.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid #e8e0d5;')
|
||||
expect(css).toContain('.settings-topbar{margin-bottom:0}')
|
||||
expect(css).toContain('.topbar>.settings-refresh{grid-area:filter}')
|
||||
expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}')
|
||||
expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}')
|
||||
expect(css).toContain('.settings-sections{width:100%;padding-top:23px;padding-bottom:0}')
|
||||
expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}')
|
||||
@@ -288,7 +289,7 @@ describe('solid cream material system', () => {
|
||||
})
|
||||
|
||||
it('keeps controls solid, focus visible, mobile targets safe, and motion reducible', () => {
|
||||
expect(css).toContain('input,select,textarea,.search{background:var(--surface-raised)')
|
||||
expect(css).toContain('input,select,textarea{background:var(--surface-raised)')
|
||||
expect(css).toContain(':focus-visible{outline:3px solid var(--focus-ring)')
|
||||
expect(css).toContain('@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(56px + var(--safe-area-bottom));')
|
||||
expect(css).toContain('border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom)')
|
||||
@@ -414,13 +415,18 @@ describe('approved UI detail direction', () => {
|
||||
expect(countdownPanel).toContain('<p>{{primaryDate(focusItem)}}</p>')
|
||||
})
|
||||
|
||||
it('uses the real Memo search state in its toggle name', () => {
|
||||
expect(readFileSync('src/MemoPanel.vue', 'utf8')).toContain(':aria-label="mobileSearchOpen ? \'收起搜索备忘录\' : \'展开搜索备忘录\'"')
|
||||
it('does not expose task or memo search controls', () => {
|
||||
expect(app).not.toContain('task-search-panel')
|
||||
expect(app).not.toContain('mobileSearchOpen')
|
||||
expect(readFileSync('src/MemoPanel.vue', 'utf8')).not.toContain('mobileSearchOpen')
|
||||
expect(readFileSync('src/MemoPanel.vue', 'utf8')).not.toContain('搜索备忘录')
|
||||
})
|
||||
|
||||
it('keeps refresh neutral and at least 44px', () => {
|
||||
expect(css).toContain('.topbar-refresh{width:44px;height:44px;min-width:44px;border-radius:50%;color:var(--text-secondary)}')
|
||||
expect(css).not.toContain('.topbar-refresh:hover:not(:disabled){background:#fff7eb;color:var(--accent)}')
|
||||
it('does not expose manual refresh controls', () => {
|
||||
expect(app).not.toContain('topbar-refresh')
|
||||
expect(app).not.toContain('refreshCurrentView')
|
||||
expect(app).not.toContain('aria-label="刷新当前页面"')
|
||||
expect(css).not.toContain('.topbar-refresh{')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -751,7 +757,7 @@ describe('task and habit row decoration', () => {
|
||||
|
||||
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("const taskReorderAvailable = computed(() => activeView.value === 'tasks' && 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"')
|
||||
@@ -934,20 +940,15 @@ describe('task and habit row decoration', () => {
|
||||
const topbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>', app.indexOf('<header class="topbar"')))
|
||||
expect(topbar.match(/<CompletedFilterPill/g) ?? []).toHaveLength(0)
|
||||
expect(topbar).not.toContain('topbar-filter')
|
||||
expect(app).toContain("v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\"")
|
||||
expect(app).not.toContain("v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\" class=\"topbar-actions\"")
|
||||
expect(app).toContain('class="today-inline-filter"')
|
||||
expect(app).toContain('class="list-inline-filter"')
|
||||
expect(mvpPanel).toContain('class="completed-filter-pill habit-inline-filter"')
|
||||
expect(app).toContain('v-model="showCompleted"')
|
||||
expect(app).toContain('class="topbar-actions"')
|
||||
expect(app).toContain('topbar-refresh')
|
||||
expect(app).toContain('aria-label="刷新当前页面"')
|
||||
expect(app).toContain('@click="refreshCurrentView"')
|
||||
expect(app).toContain(':class="{spinning:refreshing}"')
|
||||
expect(app).toContain(':disabled="refreshing || loading"')
|
||||
expect(app).toContain("else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()])")
|
||||
expect(app).toContain("if (activeView.value === 'habits') await habitComposer.value?.refreshHabits()")
|
||||
expect(mvpPanel).toContain('defineExpose({ openHabitComposer, refreshHabits: loadHabits, refreshSettings: loadSettings })')
|
||||
expect(app).not.toContain('class="topbar-actions"')
|
||||
expect(app).not.toContain('topbar-refresh')
|
||||
expect(app).not.toContain('aria-label="刷新当前页面"')
|
||||
expect(app).not.toContain('refreshCurrentView')
|
||||
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"')
|
||||
@@ -976,26 +977,18 @@ describe('task and habit row decoration', () => {
|
||||
expect(css).not.toContain('@media(max-width:930px){main{scrollbar-width:none}')
|
||||
})
|
||||
|
||||
it('uses a full-width search row and hides it behind mobile pull-to-reveal', () => {
|
||||
it('uses the compact two-column topbar without search or refresh affordances', () => {
|
||||
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" "search search search";')
|
||||
expect(css).toContain('.search-reveal{grid-area:search;width:100%;min-width:0}')
|
||||
expect(css).toContain('.topbar .search{width:100%;margin:0}')
|
||||
expect(css).toContain('.topbar{display:grid;grid-template-columns:44px minmax(0,1fr);grid-template-areas:"menu title";')
|
||||
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{justify-self:end;')
|
||||
expect(css).toContain('.topbar-actions{grid-area:filter;justify-self:end;display:flex;align-items:center;gap:8px}')
|
||||
expect(css).toContain('.topbar-refresh{width:44px;height:44px;')
|
||||
expect(css).toContain('.topbar-refresh.spinning svg{animation:topbar-refresh-spin .7s linear infinite}')
|
||||
expect(app.indexOf('class="topbar-actions"')).toBeLessThan(app.indexOf('class="search-reveal"'))
|
||||
expect(app).toContain('@touchstart="startSearchPull"')
|
||||
expect(app).toContain('@touchmove="moveSearchPull"')
|
||||
expect(app).toContain('@touchend="finishSearchPull"')
|
||||
expect(app).toContain('ref="searchInput"')
|
||||
expect(app).toContain('class="search-pull-hint"')
|
||||
expect(css).toContain('.search-reveal{max-height:0;opacity:0;overflow:hidden;visibility:hidden;pointer-events:none;')
|
||||
expect(css).toContain('.search-reveal.mobile-search-open{max-height:52px;opacity:1;visibility:visible;pointer-events:auto;transform:none;overflow:visible}')
|
||||
expect(css).toContain('.search-reveal.mobile-search-pulling{max-height:var(--search-pull);')
|
||||
expect(app).not.toContain('task-search-panel')
|
||||
expect(app).not.toContain('search-reveal')
|
||||
expect(app).not.toContain('startSearchPull')
|
||||
expect(app).not.toContain('searchInput')
|
||||
expect(app).not.toContain('topbar-refresh')
|
||||
expect(css).not.toContain('.search-reveal{')
|
||||
expect(css).not.toContain('.topbar-refresh{')
|
||||
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('width:min(260px,100%)')
|
||||
@@ -1018,7 +1011,7 @@ describe('task and habit row decoration', () => {
|
||||
})
|
||||
|
||||
it('uses a compact hidden-completed notice only on Today', () => {
|
||||
expect(app).toContain("v-if=\"activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading\" class=\"today-filtered-empty-note\"")
|
||||
expect(app).toContain("v-if=\"activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading\" class=\"today-filtered-empty-note\"")
|
||||
expect(app).toContain('已隐藏已完成任务')
|
||||
expect(app).toContain('@click="showCompleted=true">显示</button>')
|
||||
expect(app).toContain("v-else-if=\"!visibleTasks.length&&!loading\" class=\"empty\"")
|
||||
@@ -1026,9 +1019,10 @@ describe('task and habit row decoration', () => {
|
||||
expect(css).toContain('@media(max-width:930px){.today-filtered-empty-note{min-height:44px;')
|
||||
})
|
||||
|
||||
it('distinguishes hidden completed items from true and search empty states', () => {
|
||||
it('distinguishes hidden completed items from the true empty state', () => {
|
||||
expect(app).toContain('const hiddenCompletedTaskCount = ref(0)')
|
||||
expect(app).toContain("query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静'")
|
||||
expect(app).toContain("hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静'")
|
||||
expect(app).not.toContain('没有匹配的任务')
|
||||
expect(mvpPanel).toContain("!showCompleted && todayHabits.length ? '已完成的习惯已隐藏。'")
|
||||
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
|
||||
})
|
||||
|
||||
@@ -49,14 +49,12 @@ describe('task-list open summary reconciliation', () => {
|
||||
})
|
||||
|
||||
describe('approved five-detail polish', () => {
|
||||
it('uses an independent accessible task-search panel toggle', () => {
|
||||
expect(app).toContain(":aria-label=\"mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'\"")
|
||||
expect(app).toContain('aria-controls="task-search-panel"')
|
||||
expect(app).toContain('@keydown="handleTaskSearchKeydown"')
|
||||
expect(app).not.toContain('mobileSearchOpen || Boolean(query)')
|
||||
expect(css).toMatch(/\.task-search-toggle\{[^}]*width:44px;[^}]*height:44px/)
|
||||
expect(css).toMatch(/@media\(min-width:931px\)\{[^}]*\.task-search-toggle\{display:none\}/)
|
||||
expect(css).toMatch(/@media\(min-width:931px\)\{[\s\S]*?\.search-reveal\{[^}]*min-width:320px/)
|
||||
it('does not expose task-search controls or styles', () => {
|
||||
expect(app).not.toContain('mobileSearchOpen')
|
||||
expect(app).not.toContain('task-search-panel')
|
||||
expect(app).not.toContain('handleTaskSearchKeydown')
|
||||
expect(css).not.toContain('.task-search-toggle')
|
||||
expect(css).not.toContain('.search-reveal{')
|
||||
})
|
||||
|
||||
it('keeps the countdown focus compact and detail titles unique', () => {
|
||||
@@ -85,7 +83,8 @@ describe('approved five-detail polish', () => {
|
||||
expect(app).toContain("v-if=\"activeView==='tasks' && totalPages > 1\" class=\"list-page-meta\"")
|
||||
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
|
||||
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
|
||||
expect(app).toContain('v-if="query" class="list-search-clear"')
|
||||
expect(app).not.toContain('class="list-search-clear"')
|
||||
expect(app).not.toContain('task-search-panel')
|
||||
expect(app).not.toContain("activeView==='tasks' && (totalPages > 1 || totalTasks > 0)")
|
||||
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"")
|
||||
const taskTopbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>'))
|
||||
@@ -95,9 +94,9 @@ describe('approved five-detail polish', () => {
|
||||
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
|
||||
expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--muted);font-size:13px}')
|
||||
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
|
||||
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
|
||||
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-search-reveal,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
|
||||
expect(css).toContain('.list-search-reveal{position:relative;min-height:44px;margin-bottom:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center}')
|
||||
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
|
||||
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
|
||||
expect(css).not.toContain('.list-search-reveal{')
|
||||
expect(css).toContain('@media(max-width:720px){')
|
||||
expect(css).not.toContain('top:-62px')
|
||||
expect(css).toContain('.list-inline-filter .completed-filter-pill__track,.habit-inline-filter .completed-filter-pill__track{width:31px;height:18px;background:#d8d0c5}')
|
||||
|
||||
Reference in New Issue
Block a user