Quest log sheet on Discover #109
@@ -618,6 +618,66 @@ describe('DiscoverPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps focus in the open Quest log after a removal: on the next row, the one before the last, then the sheet', async () => {
|
||||||
|
const archive = (id: string) => () => {
|
||||||
|
server.questLogDinners.dinners = server.questLogDinners.dinners.filter((dinner) => dinner !== byId[id])
|
||||||
|
return jsonResponse(200, { ...byId[id]!, status: 'archived' })
|
||||||
|
}
|
||||||
|
const byId: Record<string, DinnerBody> = { q1: PAD_THAI, q2: GUMBO, q3: RISOTTO }
|
||||||
|
const server = stubQuestLog([PAD_THAI, GUMBO, RISOTTO], {
|
||||||
|
'DELETE /api/v1/dinners/q1': archive('q1'),
|
||||||
|
'DELETE /api/v1/dinners/q2': archive('q2'),
|
||||||
|
'DELETE /api/v1/dinners/q3': archive('q3'),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
const sheet = await openSheet(3)
|
||||||
|
|
||||||
|
async function removeAndExpectFocus(name: string, expected: () => HTMLElement) {
|
||||||
|
within(sheet).getByRole('button', { name: `Remove ${name}` }).focus()
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: `Remove ${name}` }))
|
||||||
|
fireEvent.click(within(screen.getByRole('dialog', { name: `Remove ${name}?` })).getByRole('button', { name: 'Remove' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: `Remove ${name}?` })).not.toBeInTheDocument())
|
||||||
|
expect(expected()).toHaveFocus()
|
||||||
|
expect(sheet).toContainElement(document.activeElement as HTMLElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A middle row: focus goes to the row now in its place.
|
||||||
|
await removeAndExpectFocus('Shrimp gumbo', () => within(sheet).getByRole('button', { name: 'Remove Mushroom risotto' }))
|
||||||
|
// The last row: focus goes to the row before it.
|
||||||
|
await removeAndExpectFocus('Mushroom risotto', () => within(sheet).getByRole('button', { name: 'Remove Pad thai' }))
|
||||||
|
// The only row: focus goes to the sheet itself.
|
||||||
|
await removeAndExpectFocus('Pad thai', () => sheet)
|
||||||
|
expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// From there Escape still reaches the Quest log, and closes it.
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||||
|
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 () => {
|
||||||
|
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?' })
|
||||||
|
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()
|
||||||
|
|
||||||
|
fireEvent.click(keepIt)
|
||||||
|
fireEvent.keyDown(confirm, { key: 'Escape' })
|
||||||
|
fireEvent.click(confirm.previousElementSibling!)
|
||||||
|
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'])
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a dinner whose removal the server refuses, saying why', async () => {
|
it('keeps a dinner whose removal the server refuses, saying why', async () => {
|
||||||
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => errorResponse(500, 'internal') })
|
const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => errorResponse(500, 'internal') })
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
import { useId, useState } from 'react'
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
@@ -37,6 +37,25 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
|
|||||||
const remove = useRemoveFromQuestLog()
|
const remove = useRemoveFromQuestLog()
|
||||||
// The dinner whose removal is being confirmed.
|
// The dinner whose removal is being confirmed.
|
||||||
const [removing, setRemoving] = useState<QuestLogDinner | null>(null)
|
const [removing, setRemoving] = useState<QuestLogDinner | null>(null)
|
||||||
|
// The dinner just removed and its place in the list, until focus has
|
||||||
|
// moved on from it.
|
||||||
|
const removed = useRef<{ id: string; index: number } | null>(null)
|
||||||
|
const removeButtons = useRef(new Map<string, HTMLButtonElement>())
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// one before it when it was the last, or to the Quest log sheet itself when
|
||||||
|
// none is left. Otherwise it would be left on the removed dinner's button,
|
||||||
|
// which the confirmation hands it back to, and which is then gone.
|
||||||
|
useEffect(() => {
|
||||||
|
const done = removed.current
|
||||||
|
if (removing !== null || done === null || dinners.some((d) => d.id === done.id)) return
|
||||||
|
removed.current = null
|
||||||
|
const next = dinners[Math.min(done.index, dinners.length - 1)]
|
||||||
|
if (next) removeButtons.current.get(next.id)!.focus()
|
||||||
|
else sheetRef.current!.closest<HTMLElement>('[role="dialog"]')!.focus()
|
||||||
|
}, [removing, dinners])
|
||||||
|
|
||||||
function cancelRemove() {
|
function cancelRemove() {
|
||||||
remove.reset()
|
remove.reset()
|
||||||
@@ -46,7 +65,7 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
<Sheet open={open} onClose={onClose} 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}>
|
||||||
Quest log
|
Quest log
|
||||||
</h2>
|
</h2>
|
||||||
@@ -67,7 +86,15 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
|
|||||||
meta={details(dinner)}
|
meta={details(dinner)}
|
||||||
onClick={() => navigate(`/dinner/${encodeURIComponent(dinner.id)}`)}
|
onClick={() => navigate(`/dinner/${encodeURIComponent(dinner.id)}`)}
|
||||||
trailing={
|
trailing={
|
||||||
<IconButton label={`Remove ${dinner.name}`} tone="neutral" onClick={() => setRemoving(dinner)}>
|
<IconButton
|
||||||
|
ref={(button) => {
|
||||||
|
if (button) removeButtons.current.set(dinner.id, button)
|
||||||
|
else removeButtons.current.delete(dinner.id)
|
||||||
|
}}
|
||||||
|
label={`Remove ${dinner.name}`}
|
||||||
|
tone="neutral"
|
||||||
|
onClick={() => setRemoving(dinner)}
|
||||||
|
>
|
||||||
<Icon icon={Trash2} />
|
<Icon icon={Trash2} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
}
|
}
|
||||||
@@ -84,9 +111,18 @@ export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
|
|||||||
confirmLabel={remove.isPending ? 'Removing…' : 'Remove'}
|
confirmLabel={remove.isPending ? 'Removing…' : 'Remove'}
|
||||||
cancelLabel="Keep it"
|
cancelLabel="Keep it"
|
||||||
busy={remove.isPending}
|
busy={remove.isPending}
|
||||||
|
// Once the removal is sent, it stands: the confirmation stays until it settles.
|
||||||
|
dismissible={!remove.isPending}
|
||||||
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={() => remove.mutate(removing!, { onSuccess: () => setRemoving(null) })}
|
onConfirm={() =>
|
||||||
|
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>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ type ConfirmProps = {
|
|||||||
confirmLabel: string
|
confirmLabel: string
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
|
// When false, the cancel button, Escape and the scrim do nothing, so the
|
||||||
|
// sheet stays until the action settles.
|
||||||
|
dismissible?: boolean
|
||||||
error?: string | null
|
error?: string | null
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
@@ -49,13 +52,17 @@ export function Confirm({
|
|||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel = 'Cancel',
|
cancelLabel = 'Cancel',
|
||||||
busy = false,
|
busy = false,
|
||||||
|
dismissible = true,
|
||||||
error = null,
|
error = null,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ConfirmProps) {
|
}: ConfirmProps) {
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
|
function dismiss() {
|
||||||
|
if (dismissible) onCancel()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
<Sheet open={open} onClose={dismiss} aria-labelledby={titleId}>
|
||||||
<div className={styles.sheet}>
|
<div className={styles.sheet}>
|
||||||
<h2 id={titleId} className={styles.title}>
|
<h2 id={titleId} className={styles.title}>
|
||||||
{title}
|
{title}
|
||||||
@@ -67,7 +74,7 @@ export function Confirm({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button variant="outline" onClick={onCancel}>
|
<Button variant="outline" disabled={!dismissible} onClick={onCancel}>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busy} onClick={onConfirm}>
|
<Button disabled={busy} onClick={onConfirm}>
|
||||||
|
|||||||
Reference in New Issue
Block a user