3 changed files with 75 additions and 21 deletions
Showing only changes of commit c6fb09a785 - Show all commits
+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>