diff --git a/frontend/e2e/trash-grouped-layout.spec.ts b/frontend/e2e/trash-grouped-layout.spec.ts index b404c38..c33ee36 100644 --- a/frontend/e2e/trash-grouped-layout.spec.ts +++ b/frontend/e2e/trash-grouped-layout.spec.ts @@ -50,7 +50,7 @@ test('Trash uses compact deadline groups without exposing child-level actions', for (const title of [overdueTitle, futureTitle, undatedTitle]) { const row = page.locator('.task-row--trash').filter({ hasText: title }) await expect(row).toHaveCount(1) - await expect(row.getByRole('button', { name: '恢复' })).toBeVisible() + await expect(row.getByRole('button', { name: '恢复' })).toHaveCount(0) await expect(row.getByRole('button', { name: '打开任务操作' })).toBeVisible() expect(await row.locator('.task-check').count()).toBe(0) } @@ -62,10 +62,12 @@ test('Trash uses compact deadline groups without exposing child-level actions', await actionTrigger.press('Enter') const menu = page.getByRole('menu', { name: '回收站任务操作' }) await expect(menu).toBeVisible() - await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() - await menu.getByRole('menuitem', { name: '永久删除' }).press('Tab') + await expect(menu.getByRole('menuitem', { name: '恢复', exact: true })).toBeFocused() + await menu.getByRole('menuitem', { name: '恢复', exact: true }).press('Tab') await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() await menu.getByRole('menuitem', { name: '永久删除' }).press('Shift+Tab') + await expect(menu.getByRole('menuitem', { name: '恢复', exact: true })).toBeFocused() + await menu.getByRole('menuitem', { name: '恢复', exact: true }).press('ArrowUp') await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() await menu.getByRole('menuitem', { name: '永久删除' }).press('Escape') await expect(menu).toBeHidden() diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index 0a8ed76..7b32110 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -106,13 +106,14 @@ test('task rows use the body for detail and Trash keeps distinct actions', async const restoreRow = await taskRow(page, restoreTitle) const purgeRow = await taskRow(page, purgeTitle) for (const deletedRow of [restoreRow, purgeRow]) { - await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible() + await expect(deletedRow.getByRole('button', { name: '恢复' })).toHaveCount(0) await expect(deletedRow.getByRole('button', { name: '打开任务操作' })).toBeVisible() await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role') await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex') expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0) } - await restoreRow.getByRole('button', { name: '恢复' }).click() + await restoreRow.getByRole('button', { name: '打开任务操作' }).click() + await page.getByRole('menuitem', { name: '恢复', exact: true }).click() await expect(restoreRow).toHaveCount(0) await purgeRow.getByRole('button', { name: '打开任务操作' }).click() await page.getByRole('menuitem', { name: '永久删除' }).click() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index accaf1d..04881e5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1172,7 +1172,12 @@ function openTrashAction(task: Task, event?: Event) { trashAction.value = task nextTick(() => trashMenu.value?.querySelector('[role=menuitem]')?.focus()) } -function focusTrashMenuItem() { trashMenu.value?.querySelector('[role=menuitem]')?.focus() } +function moveTrashMenuFocus(step: number) { + const items = [...(trashMenu.value?.querySelectorAll('[role=menuitem]') ?? [])] + if (!items.length) return + const activeIndex = items.indexOf(document.activeElement as HTMLElement) + items[(activeIndex + step + items.length) % items.length]?.focus() +} function focusTrashActionTrigger(trigger: HTMLElement | null) { nextTick(() => { if (trigger?.isConnected) trigger.focus() @@ -1185,6 +1190,13 @@ function closeTrashAction(restoreFocus = true) { trashActionTrigger = null if (restoreFocus) focusTrashActionTrigger(trigger) } +async function requestRestoreTask() { + const task = trashAction.value + const trigger = trashActionTrigger + closeTrashAction(false) + if (task) await restoreTask(task) + focusTrashActionTrigger(trigger) +} async function requestPurgeTask() { const task = trashAction.value const trigger = trashActionTrigger @@ -1785,7 +1797,7 @@ onUnmounted(() => {
{{node.task.title}}含 {{node.subtasks.length}} 个子任务,整组处理
- +
@@ -1873,7 +1885,7 @@ onUnmounted(() => {
{{notice}}
- {{trashAction.title}} + {{trashAction.title}} diff --git a/frontend/src/style.css b/frontend/src/style.css index 0dc3abf..51e2461 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -125,9 +125,9 @@ main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empt .trash-page-context{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;padding:7px 0 21px;border-bottom:1px solid #e8e0d5} .trash-page-title{margin:0;font-size:34px;line-height:1.1;font-weight:700;letter-spacing:-1.2px}.trash-page-summary{margin:8px 0 0;color:var(--muted);font-size:13px}.trash-page-count{min-height:44px;display:flex;align-items:center;color:var(--muted);font-size:12px} .trash-groups{display:grid}.trash-group{display:grid}.trash-group-heading{min-height:44px;padding-top:11px;display:flex;align-items:center;gap:7px;border-bottom:1px solid #e8e0d5}.trash-group-heading h2{margin:0;font-size:13px;font-weight:750}.trash-group-heading span{color:var(--muted);font-size:12px} -.trash-list>.task-row,.trash-list>.task-row:has(>.task-tail .task-due--timed){height:62px;min-height:62px;max-height:62px}.trash-list .task-main{display:grid;align-content:center;gap:3px}.trash-list .task-main strong{font-size:14px;font-weight:620}.trash-list .task-main .meta{padding:0;font-size:11px}.trash-list .task-main .meta-item{display:inline-flex;align-items:center;gap:4px}.trash-list .task-main .meta-item svg{width:13px;height:13px}.trash-list .task-actions{display:flex;align-items:center;gap:0}.trash-list .restore{min-height:44px}.trash-list .trash-more{width:44px;height:44px;color:var(--muted);opacity:1}.trash-list .trash-more svg{width:18px;height:18px}.trash-action-mask{position:fixed;z-index:90;inset:0;display:flex;align-items:flex-end;justify-content:center;background:rgba(45,38,31,.18)}.trash-action-menu{width:min(320px,calc(100vw - 24px));margin:12px;padding:8px;display:grid;gap:4px;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-panel);box-shadow:var(--highlight-inner),var(--shadow-raised)}.trash-action-title{min-width:0;padding:9px 10px 7px;color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trash-action-menu button{min-height:44px;width:100%;display:flex;align-items:center;gap:9px;padding:0 10px;border:0;border-radius:var(--radius-control);background:transparent;text-align:left}.trash-action-menu button:hover{background:#fff3ee} -@media(max-width:720px){main:has(>.trash-page-context){padding-left:29px;padding-right:29px;padding-bottom:calc(78px + var(--safe-area-bottom))}main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:100%}.trash-page-context{padding-top:2px;padding-bottom:17px}.trash-page-title{font-size:24px}.trash-page-summary{font-size:12px;line-height:1.5}.trash-group-heading{padding-top:9px}.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) minmax(38px,auto) auto;gap:6px}.trash-list .task-tail{display:flex;max-width:64px;padding-left:0;font-size:11px}.trash-list .task-due--timed .task-due__absolute,.trash-list .task-due--timed .task-due__separator{display:none}.trash-list .restore{min-width:68px;padding-inline:10px}} -@media(max-width:390px){main:has(>.trash-page-context){padding-left:17px;padding-right:17px}.trash-page-context{gap:10px}.trash-page-summary{max-width:230px}.trash-list .task-actions{margin-left:6px}.trash-list .restore{min-width:68px;padding-inline:10px}} +.trash-list>.task-row,.trash-list>.task-row:has(>.task-tail .task-due--timed){height:62px;min-height:62px;max-height:62px}.trash-list .task-main{display:grid;align-content:center;gap:3px}.trash-list .task-main strong{font-size:14px;font-weight:620}.trash-list .task-main .meta{padding:0;font-size:11px}.trash-list .task-main .meta-item{display:inline-flex;align-items:center;gap:4px}.trash-list .task-main .meta-item svg{width:13px;height:13px}.trash-list .task-actions{display:flex;align-items:center;gap:0}.trash-list .trash-more{width:44px;height:44px;color:var(--muted);opacity:1}.trash-list .trash-more svg{width:18px;height:18px}.trash-action-mask{position:fixed;z-index:90;inset:0;display:flex;align-items:flex-end;justify-content:center;background:rgba(45,38,31,.18)}.trash-action-menu{width:min(320px,calc(100vw - 24px));margin:12px;padding:8px;display:grid;gap:4px;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-panel);box-shadow:var(--highlight-inner),var(--shadow-raised)}.trash-action-title{min-width:0;padding:9px 10px 7px;color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trash-action-menu button{min-height:44px;width:100%;display:flex;align-items:center;gap:9px;padding:0 10px;border:0;border-radius:var(--radius-control);background:transparent;text-align:left}.trash-action-menu button:hover{background:#fff3ee} +@media(max-width:720px){main:has(>.trash-page-context){padding-left:29px;padding-right:29px;padding-bottom:calc(78px + var(--safe-area-bottom))}main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:100%}.trash-page-context{padding-top:2px;padding-bottom:17px}.trash-page-title{font-size:24px}.trash-page-summary{font-size:12px;line-height:1.5}.trash-group-heading{padding-top:9px}.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) minmax(38px,auto) auto;gap:6px}.trash-list .task-tail{display:flex;max-width:64px;padding-left:0;font-size:11px}.trash-list .task-due--timed .task-due__absolute,.trash-list .task-due--timed .task-due__separator{display:none}} +@media(max-width:390px){main:has(>.trash-page-context){padding-left:17px;padding-right:17px}.trash-page-context{gap:10px}.trash-page-summary{max-width:230px}.trash-list .task-actions{margin-left:6px}} /* Approved task-list and full-Habits 01 parity. */ main.list-main{background:#fdfaf3} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 8f6b338..c9e0559 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -34,7 +34,7 @@ describe('unified task due display', () => { expect(app.match(/