Keep focus in a confirmation when a disabled button drops it to the page
CI / ci (push) Successful in 2m57s
CI / ci (pull_request) Successful in 2m40s

Confirm moved focus to its dialog only when the focused control stayed
focused while disabled. A browser that moves focus to the page as it
disables the control left the confirmation open with focus outside it.
Confirm now also takes focus back to the dialog when busy or dismissible
changes and focus is on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 04:39:55 -04:00
co-authored by Claude Opus 5.5
parent c6fb09a785
commit ba83ad6df0
2 changed files with 22 additions and 5 deletions
+17 -2
View File
@@ -1,7 +1,7 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, useParams } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { stubAnimate, type FakeAnimation } from '../test/animate.ts'
@@ -685,7 +685,22 @@ describe('DiscoverPage', () => {
expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1'])
})
it('keeps focus in the confirmation, off its disabled buttons, while a removal is under way', async () => {
it.each([
['keeps a disabled button focused', false],
['moves focus off a disabled button to the page', true],
])('keeps focus in the confirmation, off its disabled buttons, while a removal is under way when the browser %s', async (_, fixup) => {
// Some browsers move focus from a control to the page as it is disabled.
const setAttribute = Element.prototype.setAttribute
if (fixup) {
vi.spyOn(Element.prototype, 'setAttribute').mockImplementation(function (this: Element, name, value) {
// jsdom ignores blur() on a disabled control, so focus leaves just before.
if (name === 'disabled' && this === document.activeElement && this instanceof HTMLElement) this.blur()
setAttribute.call(this, name, value)
})
}
onTestFinished(() => {
vi.restoreAllMocks()
})
const archive = deferred()
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
await renderPage()
+5 -3
View File
@@ -59,12 +59,14 @@ export function Confirm({
}: ConfirmProps) {
const titleId = useId()
const sheetRef = useRef<HTMLDivElement>(null)
// A focused button that becomes disabled loses focus to the page, outside
// the dialog; the dialog takes it instead.
// A focused button that becomes disabled either keeps focus while disabled or
// loses it to the page, depending on the browser; the dialog takes it instead.
useLayoutEffect(() => {
const dialog = sheetRef.current?.closest<HTMLElement>('[role="dialog"]')
if (!dialog) return
const active = document.activeElement
if (dialog && active instanceof HTMLElement && active.matches(':disabled') && dialog.contains(active)) dialog.focus()
const lost = active === null || active === document.body || (dialog.contains(active) && active.matches(':disabled'))
if (lost) dialog.focus()
}, [busy, dismissible])
function dismiss() {
if (dismissible) onCancel()