gbs-add-block 1.2.12 → 1.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +4 -11
  2. package/index.cjs +12 -2
  3. package/package.json +1 -1
  4. package/source/beta-components/dialog/README.md +39 -0
  5. package/source/beta-components/dialog/__tests__/core.test.ts +86 -0
  6. package/source/beta-components/dialog/core/api.ts +34 -0
  7. package/source/beta-components/dialog/core/dialog.ts +11 -0
  8. package/source/beta-components/dialog/core/index.ts +7 -0
  9. package/source/beta-components/dialog/core/store.ts +81 -0
  10. package/source/beta-components/dialog/core/types.ts +59 -0
  11. package/source/beta-components/dialog/index.ts +7 -0
  12. package/source/beta-components/dialog/react/Dialog.tsx +279 -0
  13. package/source/beta-components/dialog/react/DialogHost.tsx +45 -0
  14. package/source/beta-components/dialog/react/icons.tsx +51 -0
  15. package/source/beta-components/dialog/react/locale.ts +8 -0
  16. package/source/beta-components/dialog/react/props.ts +13 -0
  17. package/source/beta-components/dialog/styles.css +278 -0
  18. package/source/beta-components/fileuploader/README.md +99 -0
  19. package/source/beta-components/fileuploader/__tests__/core.test.ts +395 -0
  20. package/source/beta-components/fileuploader/core/format.ts +79 -0
  21. package/source/beta-components/fileuploader/core/index.ts +26 -0
  22. package/source/beta-components/fileuploader/core/store.ts +432 -0
  23. package/source/beta-components/fileuploader/core/transport.ts +145 -0
  24. package/source/beta-components/fileuploader/core/types.ts +126 -0
  25. package/source/beta-components/fileuploader/core/validate.ts +83 -0
  26. package/source/beta-components/fileuploader/index.ts +7 -0
  27. package/source/beta-components/fileuploader/react/FileRow.tsx +268 -0
  28. package/source/beta-components/fileuploader/react/FileUploader.tsx +515 -0
  29. package/source/beta-components/fileuploader/react/icons.tsx +80 -0
  30. package/source/beta-components/fileuploader/react/locale.ts +33 -0
  31. package/source/beta-components/fileuploader/react/props.ts +31 -0
  32. package/source/beta-components/fileuploader/react/useFileUploader.ts +32 -0
  33. package/source/beta-components/fileuploader/styles.css +395 -0
  34. package/source/beta-components/input/README.md +44 -0
  35. package/source/beta-components/input/__tests__/core.test.ts +75 -0
  36. package/source/beta-components/input/core/count.ts +14 -0
  37. package/source/beta-components/input/core/index.ts +11 -0
  38. package/source/beta-components/input/core/otp.ts +64 -0
  39. package/source/beta-components/input/core/types.ts +14 -0
  40. package/source/beta-components/input/index.ts +8 -0
  41. package/source/beta-components/input/react/Input.tsx +219 -0
  42. package/source/beta-components/input/react/OtpInput.tsx +256 -0
  43. package/source/beta-components/input/react/dom.ts +10 -0
  44. package/source/beta-components/input/react/icons.tsx +35 -0
  45. package/source/beta-components/input/react/locale.ts +9 -0
  46. package/source/beta-components/input/react/props.ts +6 -0
  47. package/source/beta-components/input/styles.css +296 -0
  48. package/source/beta-components/modal/README.md +51 -0
  49. package/source/beta-components/modal/__tests__/core.test.ts +55 -0
  50. package/source/beta-components/modal/core/dismiss.ts +29 -0
  51. package/source/beta-components/modal/core/index.ts +3 -0
  52. package/source/beta-components/modal/core/types.ts +27 -0
  53. package/source/beta-components/modal/index.ts +5 -0
  54. package/source/beta-components/modal/react/Modal.tsx +238 -0
  55. package/source/beta-components/modal/react/icons.tsx +19 -0
  56. package/source/beta-components/modal/react/locale.ts +5 -0
  57. package/source/beta-components/modal/react/props.ts +17 -0
  58. package/source/beta-components/modal/styles.css +235 -0
  59. package/source/beta-components/textarea/README.md +39 -0
  60. package/source/beta-components/textarea/__tests__/core.test.ts +38 -0
  61. package/source/beta-components/textarea/core/count.ts +14 -0
  62. package/source/beta-components/textarea/core/index.ts +4 -0
  63. package/source/beta-components/textarea/core/size.ts +23 -0
  64. package/source/beta-components/textarea/core/types.ts +17 -0
  65. package/source/beta-components/textarea/index.ts +5 -0
  66. package/source/beta-components/textarea/react/Textarea.tsx +209 -0
  67. package/source/beta-components/textarea/react/locale.ts +5 -0
  68. package/source/beta-components/textarea/react/props.ts +4 -0
  69. package/source/beta-components/textarea/styles.css +159 -0
package/README.md CHANGED
@@ -1,23 +1,16 @@
1
- # GBS Building Blocks 2.0 (v1.2.12)
1
+ # GBS Building Blocks 2.0 (v1.2.14)
2
2
 
3
3
  Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
4
4
 
5
5
  ## Documentation
6
6
 
7
- For detailed documentation on usage and props, Please visit: [Building Block Documentation v2.0](https://gramprokit.vercel.app)
7
+ For detailed documentation on usage and props, Please visit: [Building Block Documentation](https://gramprokit.vercel.app)
8
8
 
9
9
  ## Beta Components
10
10
 
11
- DataGrid and Combobox use the redesigned core API and design system. Install them with the beta flag:
11
+ New Beta Components are avilable for testing and feedback, Please visit: [Building Block Documentation v2.0.0 beta](https://gramprokit.vercel.app/2.0.0-beta)
12
12
 
13
- ```bash
14
- npx gbs-add-block -a DataGrid -beta
15
- npx gbs-add-block -a Combobox -beta
16
- npx gbs-add-block -a DatePicker -beta
17
- npx gbs-add-block -a Toaster -beta
18
- ```
19
-
20
- ## What's New 🎉 (Ver 1.2.12)
13
+ ## What's New 🎉 (Ver 1.2.14)
21
14
 
22
15
  - Update Candidate for next major change 2.0.0
23
16
 
package/index.cjs CHANGED
@@ -35,10 +35,20 @@ const CONFIG = {
35
35
  "UsePaginatedData",
36
36
  "UseUploader",
37
37
  ],
38
- betaComponents: ["DataGrid", "Combobox", "DatePicker"],
38
+ betaComponents: [
39
+ "DataGrid",
40
+ "Combobox",
41
+ "DatePicker",
42
+ "Toaster",
43
+ "FileUploader",
44
+ "Dialog",
45
+ "Input",
46
+ "Modal",
47
+ "Textarea",
48
+ ],
39
49
  // Define component dependencies
40
50
  dependencies: {
41
- FormRenderer: ["Select", "MultiSelect", "Input", "DatePicker", "toaster"],
51
+ FormRenderer: ["Select", "MultiSelect", "Input", "DatePicker"],
42
52
  },
43
53
  docs: "https://gramprokit.vercel.app/",
44
54
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.12",
3
+ "version": "1.2.14",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,39 @@
1
+ # Dialog
2
+
3
+ Alert, confirm and prompt dialogs for React 19 that you can `await`, styled to
4
+ match the rest of the library. No runtime dependencies besides React.
5
+
6
+ ## Setup
7
+
8
+ ```ts
9
+ import { dialog, DialogHost } from "@/components/dialog";
10
+ import "@/components/dialog/styles.css";
11
+ ```
12
+
13
+ Mount `<DialogHost />` once near the root, then:
14
+
15
+ ```tsx
16
+ if (await dialog.confirm({ title: "Delete 3 invoices?", intent: "danger", confirmLabel: "Delete" })) {
17
+ await deleteInvoices();
18
+ }
19
+
20
+ const name = await dialog.prompt({ title: "Rename", defaultValue: "report.pdf", required: true });
21
+ await dialog.alert("Export finished");
22
+ ```
23
+
24
+ - `alert` resolves when closed; `confirm` resolves `true`/`false`; `prompt` resolves the text or `null`.
25
+ - `onConfirm` may be async: the dialog shows progress, and if it throws, shows the error and stays open.
26
+ - Requests queue and show one at a time. Where there is no document (a server render) they resolve as canceled.
27
+ - `<Dialog open onClose>` is the same dialog as a regular controlled component.
28
+
29
+ ## Options
30
+
31
+ `title`, `description`, `intent` (`default` `info` `success` `warning` `danger`),
32
+ `icon`, `confirmLabel`, `cancelLabel`, `dismissible`, `size` (`sm` `md`),
33
+ `onConfirm`. Prompt adds `defaultValue`, `placeholder`, `inputLabel`, `inputType`,
34
+ `required` and `validate`.
35
+
36
+ ## Headless use
37
+
38
+ `createDialogStore()` and `createDialogApi(store)` build separate instances; pass
39
+ the store to `<DialogHost store={store} />`. The core has no React imports.
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createDialogApi } from "../core/api";
3
+ import { dialog, dialogStore } from "../core/dialog";
4
+ import { createDialogStore, resultFor } from "../core/store";
5
+
6
+ describe("results", () => {
7
+ it("maps an answer to each kind's value", () => {
8
+ expect(resultFor("confirm", true)).toBe(true);
9
+ expect(resultFor("confirm", false)).toBe(false);
10
+ expect(resultFor("prompt", true, "Ada")).toBe("Ada");
11
+ expect(resultFor("prompt", true)).toBe("");
12
+ expect(resultFor("prompt", false, "ignored")).toBeNull();
13
+ expect(resultFor("alert", true)).toBeUndefined();
14
+ });
15
+ });
16
+
17
+ describe("queue", () => {
18
+ it("shows one dialog at a time, in order", async () => {
19
+ const store = createDialogStore();
20
+ const api = createDialogApi(store);
21
+
22
+ const first = api.confirm("Delete the file?");
23
+ const second = api.prompt({ title: "Rename", defaultValue: "report.pdf" });
24
+ const [a, b] = store.getSnapshot().queue;
25
+ expect(a).toMatchObject({ kind: "confirm", options: { title: "Delete the file?" } });
26
+ expect(b).toMatchObject({ kind: "prompt", options: { defaultValue: "report.pdf" } });
27
+
28
+ store.resolve(a.id, true);
29
+ expect(await first).toBe(true);
30
+ expect(store.getSnapshot().queue.map((item) => item.id)).toEqual([b.id]);
31
+
32
+ store.resolve(b.id, true, "summary.pdf");
33
+ expect(await second).toBe("summary.pdf");
34
+ expect(store.getSnapshot().queue).toHaveLength(0);
35
+ });
36
+
37
+ it("notifies subscribers and ignores unknown or repeated answers", async () => {
38
+ const store = createDialogStore();
39
+ const listener = vi.fn();
40
+ store.subscribe(listener);
41
+
42
+ const answer = store.open("alert", { title: "Saved" });
43
+ const [request] = store.getSnapshot().queue;
44
+ store.resolve("dialog-404", true);
45
+ store.resolve(request.id, true);
46
+ store.resolve(request.id, false);
47
+
48
+ expect(await answer).toBeUndefined();
49
+ expect(listener).toHaveBeenCalledTimes(2);
50
+ });
51
+
52
+ it("cancels everything with dismissAll", async () => {
53
+ const store = createDialogStore();
54
+ const api = createDialogApi(store);
55
+ const results = Promise.all([api.confirm("One?"), api.prompt("Two?"), api.alert("Three")]);
56
+
57
+ api.dismissAll();
58
+ expect(await results).toEqual([false, null, undefined]);
59
+ expect(store.getSnapshot().queue).toHaveLength(0);
60
+ });
61
+
62
+ it("keeps the snapshot object until something changes", () => {
63
+ const store = createDialogStore();
64
+ const before = store.getSnapshot();
65
+ expect(store.getSnapshot()).toBe(before);
66
+ void store.open("alert", { title: "Hi" });
67
+ expect(store.getSnapshot()).not.toBe(before);
68
+ });
69
+ });
70
+
71
+ describe("where nothing can be shown", () => {
72
+ it("answers as a cancel at once", async () => {
73
+ const store = createDialogStore();
74
+ const api = createDialogApi(store, () => false);
75
+ expect(await api.confirm("Sure?")).toBe(false);
76
+ expect(await api.prompt("Name?")).toBeNull();
77
+ expect(await api.alert("Hi")).toBeUndefined();
78
+ expect(store.getSnapshot().queue).toHaveLength(0);
79
+ });
80
+
81
+ it("does so for the default dialog on a server, where there is no document", async () => {
82
+ expect(typeof document).toBe("undefined");
83
+ expect(await dialog.confirm("Rendered on the server")).toBe(false);
84
+ expect(dialogStore.getSnapshot().queue).toHaveLength(0);
85
+ });
86
+ });
@@ -0,0 +1,34 @@
1
+ import type { DialogStore } from "./store";
2
+ import type { AlertOptions, ConfirmOptions, PromptOptions } from "./types";
3
+
4
+ export interface DialogApi {
5
+ /** Resolves when the user presses OK (or dismisses it). */
6
+ alert(options: AlertOptions | string): Promise<void>;
7
+ /** Resolves `true` on Confirm, `false` on Cancel, Escape or a backdrop click. */
8
+ confirm(options: ConfirmOptions | string): Promise<boolean>;
9
+ /** Resolves with the entered text, or `null` when canceled. */
10
+ prompt(options: PromptOptions | string): Promise<string | null>;
11
+ dismissAll(): void;
12
+ }
13
+
14
+ /**
15
+ * The `dialog` API for a store. `canShow` lets the default instance answer
16
+ * right away where nobody could see a dialog, such as during a server render.
17
+ */
18
+ export function createDialogApi(store: DialogStore, canShow: () => boolean = () => true): DialogApi {
19
+ return {
20
+ alert(options) {
21
+ if (!canShow()) return Promise.resolve();
22
+ return store.open("alert", typeof options === "string" ? { title: options } : options);
23
+ },
24
+ confirm(options) {
25
+ if (!canShow()) return Promise.resolve(false);
26
+ return store.open("confirm", typeof options === "string" ? { title: options } : options);
27
+ },
28
+ prompt(options) {
29
+ if (!canShow()) return Promise.resolve(null);
30
+ return store.open("prompt", typeof options === "string" ? { title: options } : options);
31
+ },
32
+ dismissAll: () => store.dismissAll(),
33
+ };
34
+ }
@@ -0,0 +1,11 @@
1
+ import { createDialogApi } from "./api";
2
+ import { createDialogStore } from "./store";
3
+
4
+ /** The store behind `dialog`, and behind any `<DialogHost />` without a `store` prop. */
5
+ export const dialogStore = createDialogStore();
6
+
7
+ /**
8
+ * Ask the user something from anywhere and `await` the answer. Where there is
9
+ * no document (a server render), calls settle at once as a cancel.
10
+ */
11
+ export const dialog = createDialogApi(dialogStore, () => typeof document !== "undefined");
@@ -0,0 +1,7 @@
1
+ // Framework-free: the dialog queue and the promise API run without React.
2
+ export { createDialogApi } from "./api";
3
+ export type { DialogApi } from "./api";
4
+ export { createDialogStore, resultFor } from "./store";
5
+ export type { DialogStore } from "./store";
6
+ export { dialog, dialogStore } from "./dialog";
7
+ export type * from "./types";
@@ -0,0 +1,81 @@
1
+ import type {
2
+ AlertOptions,
3
+ ConfirmOptions,
4
+ DialogKind,
5
+ DialogRequest,
6
+ DialogSnapshot,
7
+ PromptOptions,
8
+ } from "./types";
9
+
10
+ export interface DialogStore {
11
+ subscribe(listener: () => void): () => void;
12
+ getSnapshot(): DialogSnapshot;
13
+ getServerSnapshot(): DialogSnapshot;
14
+ open(kind: "alert", options: AlertOptions): Promise<void>;
15
+ open(kind: "confirm", options: ConfirmOptions): Promise<boolean>;
16
+ open(kind: "prompt", options: PromptOptions): Promise<string | null>;
17
+ /** Closes a request and settles its promise. */
18
+ resolve(id: string, confirmed: boolean, value?: string): void;
19
+ /** Cancels every open and waiting dialog. */
20
+ dismissAll(): void;
21
+ }
22
+
23
+ const EMPTY: DialogSnapshot = { queue: [] };
24
+
25
+ /** What each kind's promise resolves with. */
26
+ export function resultFor(kind: DialogKind, confirmed: boolean, value = ""): boolean | string | null | undefined {
27
+ if (kind === "confirm") return confirmed;
28
+ if (kind === "prompt") return confirmed ? value : null;
29
+ return undefined;
30
+ }
31
+
32
+ /**
33
+ * A queue of dialogs, one on screen at a time, with no React. Each request is a
34
+ * promise that settles when the user answers, so callers can simply `await` it.
35
+ */
36
+ export function createDialogStore(): DialogStore {
37
+ let snapshot: DialogSnapshot = EMPTY;
38
+ let counter = 0;
39
+ const listeners = new Set<() => void>();
40
+ const settlers = new Map<string, (result: unknown) => void>();
41
+
42
+ function commit(queue: readonly DialogRequest[]) {
43
+ snapshot = { queue };
44
+ for (const listener of listeners) listener();
45
+ }
46
+
47
+ function open(kind: DialogKind, options: AlertOptions | ConfirmOptions | PromptOptions) {
48
+ const id = `dialog-${++counter}`;
49
+ return new Promise<unknown>((resolve) => {
50
+ settlers.set(id, resolve);
51
+ commit([...snapshot.queue, { id, kind, options } as DialogRequest]);
52
+ });
53
+ }
54
+
55
+ function resolve(id: string, confirmed: boolean, value?: string) {
56
+ const request = snapshot.queue.find((item) => item.id === id);
57
+ const settle = settlers.get(id);
58
+ if (!request || !settle) return;
59
+ settlers.delete(id);
60
+ commit(snapshot.queue.filter((item) => item.id !== id));
61
+ settle(resultFor(request.kind, confirmed, value));
62
+ }
63
+
64
+ function dismissAll() {
65
+ for (const request of snapshot.queue) resolve(request.id, false);
66
+ }
67
+
68
+ return {
69
+ subscribe(listener) {
70
+ listeners.add(listener);
71
+ return () => {
72
+ listeners.delete(listener);
73
+ };
74
+ },
75
+ getSnapshot: () => snapshot,
76
+ getServerSnapshot: () => EMPTY,
77
+ open: open as DialogStore["open"],
78
+ resolve,
79
+ dismissAll,
80
+ };
81
+ }
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type DialogKind = "alert" | "confirm" | "prompt";
4
+ export type DialogIntent = "default" | "info" | "success" | "warning" | "danger";
5
+ export type DialogSize = "sm" | "md";
6
+
7
+ export interface DialogBaseOptions {
8
+ title: ReactNode;
9
+ description?: ReactNode;
10
+ /** Sets the icon and the confirm button's color. `danger` also focuses Cancel first. */
11
+ intent?: DialogIntent;
12
+ /** Replaces the intent's icon; `null` hides it. */
13
+ icon?: ReactNode;
14
+ confirmLabel?: ReactNode;
15
+ /** Escape and a backdrop click count as Cancel. Default true. */
16
+ dismissible?: boolean;
17
+ size?: DialogSize;
18
+ }
19
+
20
+ export interface AlertOptions extends DialogBaseOptions {
21
+ /** Runs on OK. The dialog waits while it runs and shows its error if it throws. */
22
+ onConfirm?(): void | Promise<void>;
23
+ }
24
+
25
+ export interface ConfirmOptions extends DialogBaseOptions {
26
+ cancelLabel?: ReactNode;
27
+ /** Runs on Confirm. The dialog waits while it runs and shows its error if it throws. */
28
+ onConfirm?(): void | Promise<void>;
29
+ }
30
+
31
+ export interface PromptOptions extends DialogBaseOptions {
32
+ cancelLabel?: ReactNode;
33
+ defaultValue?: string;
34
+ placeholder?: string;
35
+ inputLabel?: ReactNode;
36
+ inputType?: "text" | "email" | "password" | "number" | "url" | "tel";
37
+ required?: boolean;
38
+ /** Return a message to block confirming. */
39
+ validate?(value: string): string | null | undefined;
40
+ /** Runs with the value on Confirm. The dialog waits while it runs and shows its error if it throws. */
41
+ onConfirm?(value: string): void | Promise<void>;
42
+ }
43
+
44
+ export type DialogRequest =
45
+ | { id: string; kind: "alert"; options: AlertOptions }
46
+ | { id: string; kind: "confirm"; options: ConfirmOptions }
47
+ | { id: string; kind: "prompt"; options: PromptOptions };
48
+
49
+ export interface DialogSnapshot {
50
+ /** The first request is on screen; the rest wait their turn. */
51
+ queue: readonly DialogRequest[];
52
+ }
53
+
54
+ export interface DialogLocaleText {
55
+ ok: string;
56
+ confirm: string;
57
+ cancel: string;
58
+ required: string;
59
+ }
@@ -0,0 +1,7 @@
1
+ export { Dialog } from "./react/Dialog";
2
+ export type { DialogAction, DialogProps } from "./react/Dialog";
3
+ export { DialogHost } from "./react/DialogHost";
4
+ export type { DialogHostProps } from "./react/DialogHost";
5
+ export { defaultDialogText } from "./react/locale";
6
+ export type { DialogSlot } from "./react/props";
7
+ export * from "./core";
@@ -0,0 +1,279 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useEffectEvent,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type CSSProperties,
11
+ type MouseEvent,
12
+ type ReactNode,
13
+ } from "react";
14
+ import type {
15
+ DialogIntent,
16
+ DialogKind,
17
+ DialogLocaleText,
18
+ PromptOptions,
19
+ } from "../core/types";
20
+ import { DangerIcon, InfoIcon, SpinnerIcon, SuccessIcon, WarningIcon } from "./icons";
21
+ import { defaultDialogText } from "./locale";
22
+ import { cx, type DialogSlot } from "./props";
23
+
24
+ /** Matches the CSS transition, so content stays mounted while the dialog animates out. */
25
+ const EXIT_DURATION = 200;
26
+
27
+ const INTENT_ICONS: Record<DialogIntent, ReactNode> = {
28
+ default: null,
29
+ info: <InfoIcon />,
30
+ success: <SuccessIcon />,
31
+ warning: <WarningIcon />,
32
+ danger: <DangerIcon />,
33
+ };
34
+
35
+ export type DialogAction = "confirm" | "cancel";
36
+
37
+ export interface DialogProps extends Omit<PromptOptions, "onConfirm"> {
38
+ open: boolean;
39
+ /** `alert` has one button, `confirm` two, `prompt` adds a text field. Default `confirm`. */
40
+ kind?: DialogKind;
41
+ /**
42
+ * Runs on Confirm, with the prompt's value. While its promise is pending the
43
+ * buttons show progress; if it throws, the error message is shown and the
44
+ * dialog stays open.
45
+ */
46
+ onConfirm?(value: string): void | Promise<void>;
47
+ /** The user answered. Set `open` to false here. */
48
+ onClose?(action: DialogAction, value: string): void;
49
+ /** The exit animation finished. */
50
+ onExited?(): void;
51
+ id?: string;
52
+ className?: string;
53
+ classNames?: Partial<Record<DialogSlot, string>>;
54
+ style?: CSSProperties;
55
+ localeText?: Partial<DialogLocaleText>;
56
+ }
57
+
58
+ const outside = (event: MouseEvent<HTMLDialogElement>) => {
59
+ if (event.target !== event.currentTarget) return false;
60
+ const rect = event.currentTarget.getBoundingClientRect();
61
+ const { clientX: x, clientY: y } = event;
62
+ return x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
63
+ };
64
+
65
+ /** An alert, confirm or prompt dialog. For the promise API, mount `<DialogHost />` and call `dialog.confirm()`. */
66
+ export function Dialog(props: DialogProps) {
67
+ const {
68
+ open,
69
+ kind = "confirm",
70
+ title,
71
+ description,
72
+ intent = "default",
73
+ icon,
74
+ confirmLabel,
75
+ cancelLabel,
76
+ dismissible = true,
77
+ size = "sm",
78
+ defaultValue = "",
79
+ placeholder,
80
+ inputLabel,
81
+ inputType = "text",
82
+ required = false,
83
+ validate,
84
+ onConfirm,
85
+ onClose,
86
+ onExited,
87
+ id: idProp,
88
+ className,
89
+ classNames,
90
+ style,
91
+ localeText,
92
+ } = props;
93
+
94
+ const reactId = useId();
95
+ const id = idProp ?? reactId;
96
+ const text = useMemo(() => ({ ...defaultDialogText, ...localeText }), [localeText]);
97
+ const dialogRef = useRef<HTMLDialogElement>(null);
98
+ const pressStartedOutside = useRef(false);
99
+
100
+ const [value, setValue] = useState(defaultValue);
101
+ const [error, setError] = useState<string | null>(null);
102
+ const [pending, setPending] = useState(false);
103
+
104
+ // Reset each time it opens, and keep content mounted through the exit transition.
105
+ const [previousOpen, setPreviousOpen] = useState(open);
106
+ const [closing, setClosing] = useState(false);
107
+ if (previousOpen !== open) {
108
+ setPreviousOpen(open);
109
+ setClosing(!open);
110
+ if (open) {
111
+ setValue(defaultValue);
112
+ setError(null);
113
+ setPending(false);
114
+ }
115
+ }
116
+
117
+ const exited = useEffectEvent(() => onExited?.());
118
+ useEffect(() => {
119
+ if (!closing) return;
120
+ const timer = setTimeout(() => {
121
+ setClosing(false);
122
+ exited();
123
+ }, EXIT_DURATION);
124
+ return () => clearTimeout(timer);
125
+ }, [closing]);
126
+
127
+ useEffect(() => {
128
+ const element = dialogRef.current;
129
+ if (!element) return;
130
+ if (open && !element.open) {
131
+ element.showModal();
132
+ element.querySelector<HTMLElement>("[data-autofocus]")?.focus();
133
+ } else if (!open && element.open) {
134
+ element.close();
135
+ }
136
+ }, [open]);
137
+
138
+ const confirm = async () => {
139
+ if (pending || !open) return;
140
+ if (kind === "prompt") {
141
+ const message = required && !value.trim() ? text.required : validate?.(value);
142
+ if (message) {
143
+ setError(message);
144
+ return;
145
+ }
146
+ }
147
+ if (onConfirm) {
148
+ setPending(true);
149
+ setError(null);
150
+ try {
151
+ await onConfirm(value);
152
+ } catch (reason) {
153
+ setError(reason instanceof Error ? reason.message : String(reason));
154
+ setPending(false);
155
+ return;
156
+ }
157
+ setPending(false);
158
+ }
159
+ onClose?.("confirm", value);
160
+ };
161
+
162
+ const cancel = () => {
163
+ if (pending || !open) return;
164
+ onClose?.("cancel", value);
165
+ };
166
+
167
+ const mounted = open || closing;
168
+ const resolvedIcon = icon !== undefined ? icon : INTENT_ICONS[intent];
169
+ // A destructive confirm starts on Cancel, so a stray Enter doesn't delete anything.
170
+ const focusCancel = kind === "confirm" && intent === "danger";
171
+ const errorId = `${id}-error`;
172
+
173
+ return (
174
+ <dialog
175
+ ref={dialogRef}
176
+ id={id}
177
+ role="alertdialog"
178
+ aria-labelledby={`${id}-title`}
179
+ aria-describedby={description ? `${id}-description` : undefined}
180
+ className={cx("dl-root", classNames?.root, className)}
181
+ style={style}
182
+ data-intent={intent}
183
+ data-size={size}
184
+ data-state={open ? "open" : "closed"}
185
+ onCancel={(event) => {
186
+ event.preventDefault();
187
+ if (dismissible) cancel();
188
+ }}
189
+ onPointerDown={(event) => {
190
+ pressStartedOutside.current = outside(event);
191
+ }}
192
+ onClick={(event) => {
193
+ if (dismissible && pressStartedOutside.current && outside(event)) cancel();
194
+ pressStartedOutside.current = false;
195
+ }}
196
+ >
197
+ {mounted && (
198
+ <form
199
+ className="dl-form"
200
+ noValidate
201
+ onSubmit={(event) => {
202
+ event.preventDefault();
203
+ void confirm();
204
+ }}
205
+ >
206
+ <div className={cx("dl-body", classNames?.body)}>
207
+ {resolvedIcon != null && (
208
+ <span className={cx("dl-icon", classNames?.icon)} aria-hidden="true">
209
+ {resolvedIcon}
210
+ </span>
211
+ )}
212
+ <div className="dl-text">
213
+ <h2 id={`${id}-title`} className={cx("dl-title", classNames?.title)}>
214
+ {title}
215
+ </h2>
216
+ {description && (
217
+ <div id={`${id}-description`} className={cx("dl-description", classNames?.description)}>
218
+ {description}
219
+ </div>
220
+ )}
221
+ {kind === "prompt" && (
222
+ <label className="dl-field">
223
+ {inputLabel && <span className="dl-label">{inputLabel}</span>}
224
+ <input
225
+ className={cx("dl-input", classNames?.input)}
226
+ type={inputType}
227
+ value={value}
228
+ placeholder={placeholder}
229
+ required={required}
230
+ readOnly={pending}
231
+ data-autofocus=""
232
+ aria-invalid={error ? true : undefined}
233
+ aria-describedby={error ? errorId : undefined}
234
+ onChange={(event) => {
235
+ setValue(event.target.value);
236
+ if (error) setError(null);
237
+ }}
238
+ />
239
+ </label>
240
+ )}
241
+ {error && (
242
+ <p id={errorId} className="dl-error" role="alert">
243
+ {error}
244
+ </p>
245
+ )}
246
+ </div>
247
+ </div>
248
+
249
+ <div className={cx("dl-actions", classNames?.actions)}>
250
+ {kind !== "alert" && (
251
+ <button
252
+ type="button"
253
+ className={cx("dl-button", classNames?.cancel)}
254
+ data-variant="secondary"
255
+ data-autofocus={focusCancel ? "" : undefined}
256
+ aria-disabled={pending || undefined}
257
+ onClick={cancel}
258
+ >
259
+ {cancelLabel ?? text.cancel}
260
+ </button>
261
+ )}
262
+ <button
263
+ type="submit"
264
+ className={cx("dl-button", classNames?.confirm)}
265
+ data-variant={intent === "danger" ? "danger" : "primary"}
266
+ data-autofocus={!focusCancel && kind !== "prompt" ? "" : undefined}
267
+ // aria-disabled rather than disabled, so focus stays on the button while it works.
268
+ aria-disabled={pending || undefined}
269
+ aria-busy={pending || undefined}
270
+ >
271
+ {pending && <SpinnerIcon />}
272
+ {confirmLabel ?? (kind === "alert" ? text.ok : text.confirm)}
273
+ </button>
274
+ </div>
275
+ </form>
276
+ )}
277
+ </dialog>
278
+ );
279
+ }