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() 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 archive = deferred()
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise }) const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise })
await renderPage() await renderPage()
@@ -662,20 +666,46 @@ describe('DiscoverPage', () => {
fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' }) const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Remove' })) const removeButton = within(confirm).getByRole('button', { name: 'Remove' })
expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled() // Straight after Remove, without waiting for the pending request to render.
const keepIt = within(confirm).getByRole('button', { name: 'Keep it' }) fireEvent.click(removeButton)
expect(keepIt).toBeDisabled() fireEvent.click(removeButton)
dismiss(confirm)
expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
fireEvent.click(keepIt) expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled()
fireEvent.keyDown(confirm, { key: 'Escape' }) expect(within(confirm).getByRole('button', { name: 'Keep it' })).toBeDisabled()
fireEvent.click(confirm.previousElementSibling!) dismiss(confirm)
expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm) expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm)
server.questLogDinners.dinners = [GUMBO] server.questLogDinners.dinners = [GUMBO]
await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' }))) await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' })))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument())
expect(names(sheet)).toEqual(['Shrimp gumbo']) 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 () => { 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 removed = useRef<{ id: string; index: number } | null>(null)
const removeButtons = useRef(new Map<string, HTMLButtonElement>()) const removeButtons = useRef(new Map<string, HTMLButtonElement>())
const sheetRef = useRef<HTMLDivElement>(null) 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, // 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 // 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() else sheetRef.current!.closest<HTMLElement>('[role="dialog"]')!.focus()
}, [removing, dinners]) }, [removing, dinners])
function setSending(value: boolean) {
sending.current = value
setSendingShown(value)
}
function cancelRemove() { function cancelRemove() {
if (sending.current) return
remove.reset() remove.reset()
setRemoving(null) 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 ( return (
<> <>
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}> <Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
@@ -108,21 +131,14 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
<Confirm <Confirm
open={removing !== null} open={removing !== null}
title={`Remove ${removing?.name ?? ''}?`} title={`Remove ${removing?.name ?? ''}?`}
confirmLabel={remove.isPending ? 'Removing…' : 'Remove'} confirmLabel={sendingShown ? 'Removing…' : 'Remove'}
cancelLabel="Keep it" cancelLabel="Keep it"
busy={remove.isPending} busy={sendingShown}
// Once the removal is sent, it stands: the confirmation stays until it settles. // 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} error={remove.isError ? `Couldn't remove it. ${remove.error.message}` : null}
onCancel={cancelRemove} onCancel={cancelRemove}
onConfirm={() => onConfirm={confirmRemove}
remove.mutate(removing!, {
onSuccess: (_data, dinner) => {
removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) }
setRemoving(null)
},
})
}
> >
It leaves your Quest log for good. It leaves your Quest log for good.
</Confirm> </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 { useBlocker } from 'react-router'
import { Button } from '../ui/Button.tsx' import { Button } from '../ui/Button.tsx'
import { Sheet } from '../ui/Sheet.tsx' import { Sheet } from '../ui/Sheet.tsx'
@@ -58,12 +58,20 @@ export function Confirm({
onConfirm, onConfirm,
}: ConfirmProps) { }: ConfirmProps) {
const titleId = useId() 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() { function dismiss() {
if (dismissible) onCancel() if (dismissible) onCancel()
} }
return ( return (
<Sheet open={open} onClose={dismiss} aria-labelledby={titleId}> <Sheet open={open} onClose={dismiss} aria-labelledby={titleId}>
<div className={styles.sheet}> <div ref={sheetRef} className={styles.sheet}>
<h2 id={titleId} className={styles.title}> <h2 id={titleId} className={styles.title}>
{title} {title}
</h2> </h2>