fix(schedule): hand focus to the heading when a late readback lands

This commit is contained in:
Chinesezjc committed 2026-10-20 19:47:46 +08:00
1 parent ac4f99652e
commit 8d0540af50
3 files changed
+43 -8

No files matched your search

+2 -2
View File
@@ -1318,8 +1318,8 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
expect(remaining.map(task => task.title)).toContain('Keep this running task')
await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 })
// The trigger left with the rows it removed, so the page heading owns the
// focus after the confirmation closes (a browser-level check: jsdom does
// not move focus to the document body when a focused control unmounts).
// focus after the confirmation closes; this confirms the heading fallback
// in a real browser.
await expect.poll(() => page.evaluate(() => document.activeElement?.tagName), { timeout: 15_000 })
.toBe('H1')
// The page follows the acknowledged removal: no ended row or cleanup action
@@ -70,6 +70,9 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
// Set when the confirmation opens: its settled removal can hide the trigger
// before the Modal returns focus, and so can another client's cleanup.
const clearFocusPending = useRef(false)
// Set when the removal settles: a readback superseded by `schedule/changed`
// can publish its rows after the dialog closed, removing the trigger then.
const clearSettled = useRef(false)
const confirming = records.find(record => record.id === confirmId)
// The bulk action's target and count come from the complete catalog, not from
// the filtered rows: it removes every ended task, whatever the view shows.
@@ -124,13 +127,26 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
// The Modal restores focus to the element it captured when it opened; the
// trigger can be removed or rebuilt while the confirmation is open, so the
// page points focus at the current control, or the heading when it is gone.
// A settled removal whose readback lands later unmounts the trigger after the
// dialog closed, which drops focus to the document body; that handoff is
// caught by the same effect watching the ended count.
useEffect(() => {
if (clearConfirm || !clearFocusPending.current) return
clearFocusPending.current = false
const trigger = clearTriggerRef.current
const target = trigger?.isConnected === true ? trigger : headingRef.current
target?.focus()
}, [clearConfirm])
if (clearConfirm) return
if (clearFocusPending.current) {
clearFocusPending.current = false
const trigger = clearTriggerRef.current
if (trigger?.isConnected === true) {
trigger.focus()
return
}
clearSettled.current = false
headingRef.current?.focus()
return
}
if (!clearSettled.current || endedCount > 0 || document.activeElement !== document.body) return
clearSettled.current = false
headingRef.current?.focus()
}, [clearConfirm, endedCount])
const confirmClearEnded = (): void => {
// The confirmation button stays focusable while the removal runs, so a
@@ -138,6 +154,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
if (clearing) return
setClearing(true)
void onClearEnded().finally(() => {
clearSettled.current = true
setClearing(false)
setClearConfirm(false)
})
@@ -1085,6 +1085,24 @@ describe('clearing inactive tasks', () => {
expect(document.activeElement).toBe(rebuilt)
})
it('moves focus to the heading when a late readback removes the trigger', async () => {
const pending = Promise.withResolvers<undefined>()
const onClearEnded = vi.fn(() => pending.promise)
const h = mount({ records: [at, endedTask] }, en, { onClearEnded })
const trigger = screen.getByRole('button', { name: en['clear.action'] })
trigger.focus()
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: en['clear.confirm'] }))
// The removal settles while the catalog still lists the ended row: the
// dialog closes and focus returns to the trigger.
await act(async () => { pending.resolve(undefined) })
expect(document.activeElement).toBe(trigger)
// A readback superseded by `schedule/changed` publishes afterwards and takes
// the trigger with its row; focus must not stay on the document body.
act(() => { h.update({ records: [at] }) })
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 }))
})
it('closes only the confirmation on Escape while a task detail is open', () => {
mount({ records: [at, endedTask] })
fireEvent.click(screen.getByRole('button', { name: 'Review release' }))