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
17 changes: 9 additions & 8 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# TODO

- [x] Inspect existing numeric validation helper (`src/lib/validateNumber.ts`).
- [x] Verify usage/edit/new pages already import and use the helper.
- [ ] Update helper tests (`src/lib/__tests__/validateNumber.test.ts`) to cover required edge cases for both ranges.
- [ ] Adjust `src/app/usage/page.test.tsx` to assert validation message is surfaced through `TextField` error UI for non-integer requests.
- [ ] Update `README.md` with validation rule summary (price: >=0 int; requests: >=1 int).
- [ ] Run `npm run lint`, `npm run typecheck`, `npm test`, `npm run test:coverage`.
- [ ] Ensure coverage threshold (>=95%) for helper + changed pages.
- [ ] Commit with message: `refactor(forms): extract shared numeric-field validation helper`.
- [ ] Implement `dismissOnBackdrop?: boolean` prop and accessible backdrop click cancel in `src/components/ConfirmDialog.tsx` (guard clicks so only backdrop—not panel—cancels).
- [ ] Update JSDoc documentation for the new prop.
- [ ] Extend tests in `src/components/__tests__/ConfirmDialog.test.tsx`:
- [ ] backdrop click cancels only when enabled
- [ ] backdrop click does not cancel when prop is off
- [ ] clicks inside dialog panel do not cancel (when enabled)
- [ ] Escape handling remains unchanged
- [ ] Run `npm test`, `npm run lint`, `npm run typecheck`, and `npm run build`.
- [ ] Capture/record npm test output and add short a11y note in final summary.

16 changes: 16 additions & 0 deletions src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type Props = {
description?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/**
* When true, clicking the backdrop (outside the dialog panel) calls `onCancel`.
* Escape and the Cancel button always dismiss the dialog.
*/
dismissOnBackdrop?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
Expand Down Expand Up @@ -43,6 +48,7 @@ export function ConfirmDialog({
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
dismissOnBackdrop = false,
onConfirm,
onCancel,
}: Props) {
Expand Down Expand Up @@ -122,6 +128,15 @@ export function ConfirmDialog({
}
};

const handleBackdropMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (!dismissOnBackdrop) return;

// Only dismiss when the user clicked directly on the backdrop, not on the dialog panel.
if (event.target !== event.currentTarget) return;

onCancel();
};

if (!open) return null;
return (
<div
Expand All @@ -132,6 +147,7 @@ export function ConfirmDialog({
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
onMouseDown={handleBackdropMouseDown}
className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
Expand Down
51 changes: 49 additions & 2 deletions src/components/__tests__/ConfirmDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { ConfirmDialog } from "../ConfirmDialog";

function ConfirmDialogHarness() {
function ConfirmDialogHarness({
dismissOnBackdrop = false,
}: {
dismissOnBackdrop?: boolean;
}) {
const [open, setOpen] = useState(false);
const onCancel = () => setOpen(false);

return (
<>
<button type="button" onClick={() => setOpen(true)}>
Expand All @@ -14,13 +20,15 @@ function ConfirmDialogHarness() {
open={open}
title="Delete project"
description="This action cannot be undone."
dismissOnBackdrop={dismissOnBackdrop}
onConfirm={jest.fn()}
onCancel={() => setOpen(false)}
onCancel={onCancel}
/>
</>
);
}


const openDialog = () => {
const trigger = screen.getByRole("button", { name: /open dialog/i });
trigger.focus();
Expand Down Expand Up @@ -217,4 +225,43 @@ describe("ConfirmDialog", () => {
fireEvent.keyDown(dialog, { key: "Tab" });
expect(cancelButton).toHaveFocus();
});

it("does not cancel on backdrop click when dismissOnBackdrop is off", () => {
render(<ConfirmDialogHarness dismissOnBackdrop={false} />);

const { dialog, cancelButton } = openDialog();
const backdrop = dialog.parentElement as HTMLElement;

const onCancelSpy = jest.spyOn(cancelButton, "click");

fireEvent.mouseDown(backdrop);
expect(screen.getByRole("dialog", { name: /delete project/i })).toBeInTheDocument();

onCancelSpy.mockRestore();
});

it("cancels on backdrop click when dismissOnBackdrop is on", () => {
render(<ConfirmDialogHarness dismissOnBackdrop={true} />);

const { dialog } = openDialog();
const backdrop = dialog.parentElement as HTMLElement;

fireEvent.mouseDown(backdrop);

expect(screen.queryByRole("dialog", { name: /delete project/i })).not.toBeInTheDocument();
});

it("does not cancel when clicking inside the dialog panel", () => {
render(<ConfirmDialogHarness dismissOnBackdrop={true} />);

const { dialog, cancelButton } = openDialog();

fireEvent.mouseDown(cancelButton);

expect(screen.getByRole("dialog", { name: /delete project/i })).toBeInTheDocument();
// sanity: Cancel still works
fireEvent.click(cancelButton);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

Loading