Latch a Quest log removal as Remove is activated and keep focus in its confirmation
CI / ci (push) Successful in 3m0s
CI / ci (pull_request) Successful in 2m42s

The pending state reached the confirmation only on a later render, so
Keep it, Escape, the scrim or a second Remove straight after Remove could
still close the confirmation or send a second DELETE. QuestLogSheet now
latches the removal synchronously in a ref, refuses dismissal and a
repeated confirmation while it is set, and renders busy from it until
the removal settles.

A focused button that becomes disabled loses focus to the page, outside
the dialog. Confirm now moves focus to its dialog when the focused
control is disabled by busy or dismissible changing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 04:32:18 -04:00
co-authored by Claude Opus 5.5
parent 5b334c9181
commit c6fb09a785
3 changed files with 75 additions and 21 deletions
+38 -8
View File
@@ -654,7 +654,11 @@ describe('DiscoverPage', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('keeps the confirmation open while a removal is under way: Keep it, Escape and the scrim do nothing', async () => {
it.each([
['Keep it', (confirm: HTMLElement) => fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))],
['Escape', (confirm: HTMLElement) => fireEvent.keyDown(confirm, { key: 'Escape' })],
['the scrim', (confirm: HTMLElement) => fireEvent.click(confirm.previousElementSibling!)],
])('keeps the confirmation and sends one removal once Remove is activated: %s and a second Remove at once do nothing', async (_, dismiss) => {
const archive = deferred()
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
await renderPage()
@@ -662,20 +666,46 @@ describe('DiscoverPage', () => {
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Remove' }))
expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
const keepIt = within(confirm).getByRole('button', { name: 'Keep it' })
expect(keepIt).toBeDisabled()
const removeButton = within(confirm).getByRole('button', { name: 'Remove' })
// Straight after Remove, without waiting for the pending request to render.
fireEvent.click(removeButton)
fireEvent.click(removeButton)
dismiss(confirm)
expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
fireEvent.click(keepIt)
fireEvent.keyDown(confirm, { key: 'Escape' })
fireEvent.click(confirm.previousElementSibling!)
expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
expect(within(confirm).getByRole('button', { name: 'Keep it' })).toBeDisabled()
dismiss(confirm)
expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
server.questLogDinners.dinners = [GUMBO]
await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' })))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
expect(names(sheet)).toEqual(['Shrimp gumbo'])
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 () => {
const archive = deferred()
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
await renderPage()
const sheet = await openSheet(2)
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
const removeButton = within(confirm).getByRole('button', { name: 'Remove' })
removeButton.focus()
fireEvent.click(removeButton)
expect(removeButton).toBeDisabled()
expect(confirm).toHaveFocus()
expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
expect(confirm).toHaveFocus()
server.questLogDinners.dinners = [GUMBO]
await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' })))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
expect(within(sheet).getByRole('button', { name: 'Remove Shrimp gumbo' })).toHaveFocus()
})
it('keeps a dinner whose removal the server refuses, saying why', async () => {
+27 -11
View File
@@ -42,6 +42,11 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
const removed = useRef<{ id: string; index: number } | null>(null)
const removeButtons = useRef(new Map<string, HTMLButtonElement>())
const sheetRef = useRef<HTMLDivElement>(null)
// Whether a removal has been sent and not yet settled. The ref is set as
// Remove is activated, so a dismissal or a second Remove that arrives before
// the next render is already refused; the state renders it.
const sending = useRef(false)
const [sendingShown, setSendingShown] = useState(false)
// Once a removal's confirmation has closed and the dinner has left the list,
// focus goes to the remove button of the dinner now in its place, or of the
@@ -57,11 +62,29 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
else sheetRef.current!.closest<HTMLElement>('[role="dialog"]')!.focus()
}, [removing, dinners])
function setSending(value: boolean) {
sending.current = value
setSendingShown(value)
}
function cancelRemove() {
if (sending.current) return
remove.reset()
setRemoving(null)
}
function confirmRemove() {
if (sending.current) return
setSending(true)
remove.mutate(removing!, {
onSuccess: (_data, dinner) => {
removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) }
setRemoving(null)
},
onSettled: () => setSending(false),
})
}
return (
<>
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
@@ -108,21 +131,14 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
<Confirm
open={removing !== null}
title={`Remove ${removing?.name ?? ''}?`}
confirmLabel={remove.isPending ? 'Removing…' : 'Remove'}
confirmLabel={sendingShown ? 'Removing…' : 'Remove'}
cancelLabel="Keep it"
busy={remove.isPending}
busy={sendingShown}
// Once the removal is sent, it stands: the confirmation stays until it settles.
dismissible={!remove.isPending}
dismissible={!sendingShown}
error={remove.isError ? `Couldn't remove it. ${remove.error.message}` : null}
onCancel={cancelRemove}
onConfirm={() =>
remove.mutate(removing!, {
onSuccess: (_data, dinner) => {
removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) }
setRemoving(null)
},
})
}
onConfirm={confirmRemove}
>
It leaves your Quest log for good.
</Confirm>
+10 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, type ReactNode } from 'react'
import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from 'react'
import { useBlocker } from 'react-router'
import { Button } from '../ui/Button.tsx'
import { Sheet } from '../ui/Sheet.tsx'
@@ -58,12 +58,20 @@ export function Confirm({
onConfirm,
}: 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.
useLayoutEffect(() => {
const dialog = sheetRef.current?.closest<HTMLElement>('[role="dialog"]')
const active = document.activeElement
if (dialog && active instanceof HTMLElement && active.matches(':disabled') && dialog.contains(active)) dialog.focus()
}, [busy, dismissible])
function dismiss() {
if (dismissible) onCancel()
}
return (
<Sheet open={open} onClose={dismiss} aria-labelledby={titleId}>
<div className={styles.sheet}>
<div ref={sheetRef} className={styles.sheet}>
<h2 id={titleId} className={styles.title}>
{title}
</h2>