Behive Tech Registry

Confirm Dialog

v0.2.0

TODO: Add component description.

pnpm add @behivetech/molecules.confirm-dialog

registry/behivetech/molecules/confirm-dialog · depends on @behivetech/atoms.button, @behivetech/atoms.modal, @behivetech/get-class-name

confirm-dialog.tsx
1import { type ReactNode } from "react";
2import { getClassName } from "@behivetech/get-class-name";
3import { Modal } from "@behivetech/atoms.modal";
4import { Button } from "@behivetech/atoms.button";
5import styles from "./confirm-dialog.module.scss";
6
7export interface ConfirmDialogProps {
8 /** Whether the dialog is open */
9 open: boolean;
10 /** Called when the dialog should close (cancel, confirm, backdrop click, Escape) */
11 onOpenChange: (open: boolean) => void;
12 /** Dialog title */
13 title: string;
14 /** Confirmation message/body content */
15 message: ReactNode;
16 /** Confirm button label; defaults to "Confirm" */
17 confirmLabel?: string;
18 /** Cancel button label; defaults to "Cancel" */
19 cancelLabel?: string;
20 /** Called when the confirm button is clicked, before the dialog closes */
21 onConfirm: () => void;
22 /** Renders the confirm button as a destructive action; defaults to "default" */
23 variant?: "default" | "danger";
24}
25
26/**
27 * A `Modal` pre-wired with a message and confirm/cancel actions, for "are you
28 * sure?" moments — e.g. confirming a delete.
29 */
30export const ConfirmDialog = ({
31 open,
32 onOpenChange,
33 title,
34 message,
35 confirmLabel = "Confirm",
36 cancelLabel = "Cancel",
37 onConfirm,
38 variant = "default",
39}: ConfirmDialogProps) => {
40 const [, getChildClass] = getClassName({
41 rootClass: "BHT__ConfirmDialog",
42 styles,
43 });
44
45 if (!open) return null;
46
47 return (
48 <Modal title={title} onClose={() => onOpenChange(false)}>
49 <p className={getChildClass("message")}>{message}</p>
50 <div className={getChildClass("actions")}>
51 <Button variant="ghost" onClick={() => onOpenChange(false)}>
52 {cancelLabel}
53 </Button>
54 <Button
55 variant={variant === "danger" ? "danger" : "primary"}
56 onClick={() => {
57 onConfirm();
58 onOpenChange(false);
59 }}
60 >
61 {confirmLabel}
62 </Button>
63 </div>
64 </Modal>
65 );
66};
confirm-dialog.module.scss
1// Content of a Material Design 3 basic dialog; the dialog itself is Modal.
2
3.BHT__ConfirmDialog {
4 &__message {
5 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));
6 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);
7 font-weight: var(--md-sys-typescale-body-medium-weight, 400);
8 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);
9 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);
10 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
11 margin: 0 0 var(--spacing-5, 1.25rem);
12 }
13
14 &__actions {
15 display: flex;
16 justify-content: flex-end;
17 gap: var(--spacing-3, 0.75rem);
18 }
19}
index.ts
1export { ConfirmDialog } from "./confirm-dialog.js";
2export type { ConfirmDialogProps } from "./confirm-dialog.js";
confirm-dialog.composition.tsx
1import { useState } from "react";
2import { ConfirmDialog } from "./confirm-dialog.js";
3
4export const BasicConfirmDialog = () => {
5 const [open, setOpen] = useState(false);
6
7 return (
8 <>
9 <button type="button" onClick={() => setOpen(true)}>
10 Delete page
11 </button>
12 <ConfirmDialog
13 open={open}
14 onOpenChange={setOpen}
15 title="Delete page"
16 message='Are you sure you want to delete "About Us"? This cannot be undone.'
17 confirmLabel="Delete"
18 variant="danger"
19 onConfirm={() => console.log("confirmed delete")}
20 />
21 </>
22 );
23};
confirm-dialog.spec.tsx
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { ConfirmDialog } from "./confirm-dialog.js";
4
5describe("ConfirmDialog", () => {
6 it("renders nothing when closed", () => {
7 render(
8 <ConfirmDialog
9 open={false}
10 onOpenChange={() => {}}
11 title="Delete file"
12 message="Are you sure?"
13 onConfirm={() => {}}
14 />,
15 );
16 expect(screen.queryByText("Are you sure?")).toBeNull();
17 });
18
19 it("renders the title and message when open", () => {
20 render(
21 <ConfirmDialog
22 open
23 onOpenChange={() => {}}
24 title="Delete file"
25 message="Are you sure?"
26 onConfirm={() => {}}
27 />,
28 );
29 expect(screen.getByText("Delete file")).toBeTruthy();
30 expect(screen.getByText("Are you sure?")).toBeTruthy();
31 });
32
33 it("calls onConfirm and closes when the confirm button is clicked", async () => {
34 const handleConfirm = vi.fn();
35 const handleOpenChange = vi.fn();
36 const user = userEvent.setup();
37 render(
38 <ConfirmDialog
39 open
40 onOpenChange={handleOpenChange}
41 title="Delete file"
42 message="Are you sure?"
43 onConfirm={handleConfirm}
44 />,
45 );
46 await user.click(screen.getByRole("button", { name: "Confirm" }));
47 expect(handleConfirm).toHaveBeenCalledTimes(1);
48 expect(handleOpenChange).toHaveBeenCalledWith(false);
49 });
50
51 it("closes without calling onConfirm when cancel is clicked", async () => {
52 const handleConfirm = vi.fn();
53 const handleOpenChange = vi.fn();
54 const user = userEvent.setup();
55 render(
56 <ConfirmDialog
57 open
58 onOpenChange={handleOpenChange}
59 title="Delete file"
60 message="Are you sure?"
61 onConfirm={handleConfirm}
62 />,
63 );
64 await user.click(screen.getByRole("button", { name: "Cancel" }));
65 expect(handleConfirm).not.toHaveBeenCalled();
66 expect(handleOpenChange).toHaveBeenCalledWith(false);
67 });
68
69 it("uses custom confirm/cancel labels", () => {
70 render(
71 <ConfirmDialog
72 open
73 onOpenChange={() => {}}
74 title="Delete file"
75 message="Are you sure?"
76 onConfirm={() => {}}
77 confirmLabel="Delete"
78 cancelLabel="Keep it"
79 />,
80 );
81 expect(screen.getByRole("button", { name: "Delete" })).toBeTruthy();
82 expect(screen.getByRole("button", { name: "Keep it" })).toBeTruthy();
83 });
84});