fix(schedule): hand focus to the heading when a late readback lands
This commit is contained in:
1 parent
ac4f99652e
commit
8d0540af50
3 files changed
+43
-8
No files matched your search
@@ -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' }))
|
||||
|
||||
Reference in new issue
Block a user