Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,19 @@ what the old inline form submitted: same fields, same validation, same
idempotency and version guards. Screens whose whole job is capture keep their
form on the page: **Daily entry**, **Water**, recording an expense, and
adding lines to a draft order.

**Confirmation (#135)** — the dialog a one-way action asks through before it
runs. Submitting a day, confirming or cancelling an order, depleting or
archiving a flock: each states what is about to happen and waits. **Cancel**
is what the keyboard lands on, so a stray Enter never takes the action, and
Escape or a click outside means no. The ones that **undo or retire** something
— void, cancel a draft, deplete, archive — colour their button red, the only
red fill in the app. Submitting a day and confirming an order are just as
irreversible but are the ordinary path through the week, so they stay
aubergine: a red button on the most routine action would say nothing.

**Void reason (#135)** — voiding a daily entry, a payment, or a confirmed
order asks in the same dialog, plus a required **Reason**. The reason is
stored with the void and shown wherever the voided record appears, so it is
the record of why. An empty reason is refused in place and what has been
typed stays put.
10 changes: 9 additions & 1 deletion web/src/components/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ interface DialogProps {
* the one they were filling in any more.
*/
focusKey?: unknown;
/**
* Id of the element describing what the dialog is for, read out after its
* name. Matters where the prose IS the content — a confirmation's whole job
* is to say what is about to happen, and focus lands on a button, so without
* this a screen reader announces the title and the control and nothing else.
*/
describedBy?: string;
children: ReactNode;
}

Expand All @@ -55,7 +62,7 @@ interface DialogProps {
//
// Portalled to <body> so the backdrop covers the whole viewport regardless of
// where it is mounted in the shell grid.
export function Dialog({ open, title, onClose, focusKey, children }: DialogProps) {
export function Dialog({ open, title, onClose, focusKey, describedBy, children }: DialogProps) {
const panelRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const returnFocusTo = useRef<Element | null>(null);
Expand Down Expand Up @@ -159,6 +166,7 @@ export function Dialog({ open, title, onClose, focusKey, children }: DialogProps
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={describedBy}
ref={panelRef}
tabIndex={-1}
>
Expand Down
216 changes: 216 additions & 0 deletions web/src/components/useConfirm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useConfirm } from "./useConfirm";

// A realistic host: real triggers, so focus has somewhere to return to, and the
// settled value is reported out rather than scraped from the DOM — that is what
// callers actually consume, and it survives the host unmounting.
function Host({
onSettle = () => {},
destructive = false,
}: { onSettle?: (value: boolean | string | null) => void; destructive?: boolean } = {}) {
const { confirm, askReason, confirmDialog } = useConfirm();
return (
<>
<button
onClick={() => void confirm({
title: "Deplete this flock?",
body: "The flock stops accepting new entries.",
confirmLabel: "Deplete flock",
destructive,
}).then(onSettle)}
>
deplete
</button>
<button
onClick={() => void askReason({
title: "Void this order?",
body: "The allocated stock returns to the lots it came from.",
confirmLabel: "Void order",
destructive,
}).then(onSettle)}
>
void
</button>
{confirmDialog}
</>
);
}

const openConfirm = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("button", { name: "deplete" }));
const openReason = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("button", { name: "void" }));

describe("useConfirm", () => {
it("renders no dialog until something is asked", () => {
render(<Host />);
expect(screen.queryByRole("dialog")).toBeNull();
});

it("resolves true when the action is taken", async () => {
const user = userEvent.setup();
const onSettle = vi.fn();
render(<Host onSettle={onSettle} />);
await openConfirm(user);

expect(screen.getByRole("dialog")).toHaveAccessibleName("Deplete this flock?");
await user.click(screen.getByRole("button", { name: "Deplete flock" }));

await waitFor(() => expect(onSettle).toHaveBeenCalledWith(true));
expect(screen.queryByRole("dialog")).toBeNull();
});

it("resolves false on Cancel, Escape and a backdrop click", async () => {
const user = userEvent.setup();
const onSettle = vi.fn();
render(<Host onSettle={onSettle} />);

await openConfirm(user);
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(onSettle).toHaveBeenNthCalledWith(1, false));

await openConfirm(user);
await user.keyboard("{Escape}");
await waitFor(() => expect(onSettle).toHaveBeenNthCalledWith(2, false));

await openConfirm(user);
await user.click(document.querySelector(".dialog-backdrop")!);
await waitFor(() => expect(onSettle).toHaveBeenNthCalledWith(3, false));
});

it("focuses Cancel on a yes/no, so a stray Enter cannot take the action", async () => {
const user = userEvent.setup();
render(<Host />);
await openConfirm(user);
expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
});

it("focuses the reason field instead, where there is one to fill in", async () => {
const user = userEvent.setup();
render(<Host />);
await openReason(user);
expect(screen.getByLabelText("Reason *")).toHaveFocus();
});

it("resolves the trimmed reason", async () => {
const user = userEvent.setup();
const onSettle = vi.fn();
render(<Host onSettle={onSettle} />);
await openReason(user);

await user.type(screen.getByLabelText("Reason *"), " miscounted the tray ");
await user.click(screen.getByRole("button", { name: "Void order" }));

await waitFor(() => expect(onSettle).toHaveBeenCalledWith("miscounted the tray"));
});

it("keeps the dialog open on a blank reason and never resolves empty", async () => {
const user = userEvent.setup();
const onSettle = vi.fn();
render(<Host onSettle={onSettle} />);
await openReason(user);

// Whitespace only: window.prompt's own check ran after it had closed, which
// is the failure this replaces — the typed text has to survive the error.
await user.type(screen.getByLabelText("Reason *"), " ");
await user.click(screen.getByRole("button", { name: "Void order" }));

expect(await screen.findByText("A reason is required.")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(onSettle).not.toHaveBeenCalled();

// The error clears as soon as they start fixing it, not on the next submit.
await user.type(screen.getByLabelText("Reason *"), "wrong lot");
expect(screen.queryByText("A reason is required.")).toBeNull();

await user.click(screen.getByRole("button", { name: "Void order" }));
await waitFor(() => expect(onSettle).toHaveBeenCalledWith("wrong lot"));
});

it("describes the dialog with the consequence, not just the title", async () => {
const user = userEvent.setup();
render(<Host />);
await openConfirm(user);

// Focus goes straight to a button, so without an accessible description a
// screen reader announces the question and the control and never what the
// action actually does — which is the only reason the dialog exists.
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
"The flock stops accepting new entries.");
});

it("wires the blank-reason error to the field and puts the cursor back in it", async () => {
const user = userEvent.setup();
render(<Host />);
await openReason(user);

const field = screen.getByLabelText("Reason *");
expect(field).toHaveAttribute("aria-invalid", "false");
expect(field).not.toHaveAttribute("aria-describedby");

await user.click(screen.getByRole("button", { name: "Void order" }));

// Announced rather than merely displayed: a screen reader reaches the
// message through the field, and focus is moved back to hear it.
const error = screen.getByText("A reason is required.");
expect(field).toHaveAttribute("aria-invalid", "true");
expect(field).toHaveAttribute("aria-describedby", error.id);
expect(field).toHaveFocus();

await user.keyboard("lot was double-counted");
expect(field).toHaveAttribute("aria-invalid", "false");
expect(field).not.toHaveAttribute("aria-describedby");
});

it("paints the action red only when the caller says it is destructive", async () => {
const user = userEvent.setup();
const { unmount } = render(<Host destructive />);
await openConfirm(user);
expect(screen.getByRole("button", { name: "Deplete flock" })).toHaveClass("btn-danger");
unmount();

render(<Host />);
await openConfirm(user);
expect(screen.getByRole("button", { name: "Deplete flock" })).not.toHaveClass("btn-danger");
});

it("clears a stale reason rather than carrying it into the next question", async () => {
const user = userEvent.setup();
render(<Host />);

await openReason(user);
await user.type(screen.getByLabelText("Reason *"), "typed then abandoned");
await user.click(screen.getByRole("button", { name: "Cancel" }));

await openReason(user);
expect(screen.getByLabelText("Reason *")).toHaveValue("");
});

it("settles a pending question when another is asked over it", async () => {
const user = userEvent.setup();
const onSettle = vi.fn();
render(<Host onSettle={onSettle} />);

await openConfirm(user);
// Nothing in the app can do this while the modal has focus, but a stranded
// promise would hang its caller for ever, so it must not depend on that.
await openReason(user);

await waitFor(() => expect(onSettle).toHaveBeenCalledWith(false));
expect(screen.getByRole("dialog")).toHaveAccessibleName("Void this order?");
});

it("settles a pending question when the screen unmounts under it", async () => {
const user = userEvent.setup();
const onSettle = vi.fn();
const { unmount } = render(<Host onSettle={onSettle} />);

await openReason(user);
unmount();

// null, not false: askReason's caller reads null as "they backed out".
await waitFor(() => expect(onSettle).toHaveBeenCalledWith(null));
});
});
Loading
Loading