@edux-design/modals 0.1.3 → 0.2.0

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.
package/dist/index.mjs CHANGED
@@ -10,6 +10,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
10
10
  import { cx } from "@edux-design/utils";
11
11
  import { IconButton } from "@edux-design/buttons";
12
12
  import { Close as Close2 } from "@edux-design/icons";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
13
14
  var MODAL_OVERLAY_Z_INDEX = 2147483645;
14
15
  var MODAL_CONTENT_Z_INDEX = 2147483646;
15
16
  var ModalContext = createContext({ hasOnClose: false });
@@ -66,40 +67,40 @@ var Modal = ({
66
67
  );
67
68
  const handleOpenChange = useCallback(
68
69
  (nextOpen) => {
69
- onOpenChange == null ? void 0 : onOpenChange(nextOpen);
70
+ onOpenChange?.(nextOpen);
70
71
  if (nextOpen) {
71
- onOpen == null ? void 0 : onOpen();
72
+ onOpen?.();
72
73
  return;
73
74
  }
74
- onClose == null ? void 0 : onClose();
75
+ onClose?.();
75
76
  },
76
77
  [onClose, onOpen, onOpenChange]
77
78
  );
78
- return /* @__PURE__ */ React.createElement(ModalContext.Provider, { value: contextValue }, /* @__PURE__ */ React.createElement(
79
+ return /* @__PURE__ */ jsx(ModalContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx(
79
80
  DialogPrimitive.Root,
80
81
  {
81
82
  open,
82
83
  defaultOpen,
83
84
  onOpenChange: handleOpenChange,
84
- ...props
85
- },
86
- children
87
- ));
85
+ ...props,
86
+ children
87
+ }
88
+ ) });
88
89
  };
89
90
  var ModalTrigger = DialogPrimitive.Trigger;
90
91
  var ModalClose = DialogPrimitive.Close;
91
92
  var ModalPortal = DialogPrimitive.Portal;
92
- var ModalHeaderCloseButton = () => /* @__PURE__ */ React.createElement(ModalClose, { asChild: true }, /* @__PURE__ */ React.createElement(
93
+ var ModalHeaderCloseButton = () => /* @__PURE__ */ jsx(ModalClose, { asChild: true, children: /* @__PURE__ */ jsx(
93
94
  IconButton,
94
95
  {
95
96
  size: "small",
96
97
  isBare: true,
97
- icon: /* @__PURE__ */ React.createElement(Close2, { "aria-hidden": "true" }),
98
+ icon: /* @__PURE__ */ jsx(Close2, { "aria-hidden": "true" }),
98
99
  "aria-label": MODAL_CLOSE_LABEL
99
100
  }
100
- ));
101
+ ) });
101
102
  var ModalOverlay = forwardRef(
102
- ({ className, style, ...props }, ref) => /* @__PURE__ */ React.createElement(
103
+ ({ className, style, ...props }, ref) => /* @__PURE__ */ jsx(
103
104
  DialogPrimitive.Overlay,
104
105
  {
105
106
  ref,
@@ -123,50 +124,56 @@ var ModalContent = forwardRef(
123
124
  backdropProps,
124
125
  backDropProps,
125
126
  contentProps = {},
127
+ portalProps = {},
126
128
  ...restProps
127
129
  }, ref) => {
128
130
  const resolvedBackdropProps = backdropProps ?? backDropProps ?? {};
129
131
  const { className: backdropClassName, style: backdropStyle, ...overlayProps } = resolvedBackdropProps;
130
- return /* @__PURE__ */ React.createElement(ModalPortal, null, /* @__PURE__ */ React.createElement(
131
- ModalOverlay,
132
- {
133
- ...overlayProps,
134
- className: cx(backdropClassName),
135
- style: { ...backdropStyle }
136
- }
137
- ), /* @__PURE__ */ React.createElement(
138
- DialogPrimitive.Content,
139
- {
140
- ref,
141
- className: cx(
142
- MODAL_SURFACE_CLASSES,
143
- getSizeClasses(size),
144
- className
145
- ),
146
- style: { zIndex: MODAL_CONTENT_Z_INDEX, ...style },
147
- ...contentProps,
148
- ...restProps
149
- },
150
- children
151
- ));
132
+ return /* @__PURE__ */ jsxs(ModalPortal, { ...portalProps, children: [
133
+ /* @__PURE__ */ jsx(
134
+ ModalOverlay,
135
+ {
136
+ ...overlayProps,
137
+ className: cx(backdropClassName),
138
+ style: { ...backdropStyle }
139
+ }
140
+ ),
141
+ /* @__PURE__ */ jsx(
142
+ DialogPrimitive.Content,
143
+ {
144
+ ref,
145
+ className: cx(
146
+ MODAL_SURFACE_CLASSES,
147
+ getSizeClasses(size),
148
+ className
149
+ ),
150
+ style: { zIndex: MODAL_CONTENT_Z_INDEX, ...style },
151
+ ...contentProps,
152
+ ...restProps,
153
+ children
154
+ }
155
+ )
156
+ ] });
152
157
  }
153
158
  );
154
159
  ModalContent.displayName = "ModalContent";
155
160
  var ModalHeader = forwardRef(({ className, children, ...props }, ref) => {
156
161
  const { hasOnClose } = useContext(ModalContext);
157
- return /* @__PURE__ */ React.createElement(
162
+ return /* @__PURE__ */ jsxs(
158
163
  "div",
159
164
  {
160
165
  ref,
161
166
  className: cx(MODAL_HEADER_CLASSES, className),
162
- ...props
163
- },
164
- /* @__PURE__ */ React.createElement("div", { className: MODAL_HEADER_CONTENT_CLASSES }, children),
165
- hasOnClose ? /* @__PURE__ */ React.createElement(ModalHeaderCloseButton, null) : null
167
+ ...props,
168
+ children: [
169
+ /* @__PURE__ */ jsx("div", { className: MODAL_HEADER_CONTENT_CLASSES, children }),
170
+ hasOnClose ? /* @__PURE__ */ jsx(ModalHeaderCloseButton, {}) : null
171
+ ]
172
+ }
166
173
  );
167
174
  });
168
175
  ModalHeader.displayName = "ModalHeader";
169
- var ModalBody = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
176
+ var ModalBody = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
170
177
  "div",
171
178
  {
172
179
  ref,
@@ -175,7 +182,7 @@ var ModalBody = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ Rea
175
182
  }
176
183
  ));
177
184
  ModalBody.displayName = "ModalBody";
178
- var ModalFooter = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
185
+ var ModalFooter = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
179
186
  "div",
180
187
  {
181
188
  ref,
@@ -184,7 +191,7 @@ var ModalFooter = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ R
184
191
  }
185
192
  ));
186
193
  ModalFooter.displayName = "ModalFooter";
187
- var ModalTitle = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
194
+ var ModalTitle = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
188
195
  DialogPrimitive.Title,
189
196
  {
190
197
  ref,
@@ -193,7 +200,7 @@ var ModalTitle = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ Re
193
200
  }
194
201
  ));
195
202
  ModalTitle.displayName = "ModalTitle";
196
- var ModalDescription = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
203
+ var ModalDescription = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
197
204
  DialogPrimitive.Description,
198
205
  {
199
206
  ref,
@@ -202,7 +209,152 @@ var ModalDescription = forwardRef(({ className, ...props }, ref) => /* @__PURE__
202
209
  }
203
210
  ));
204
211
  ModalDescription.displayName = "ModalDescription";
212
+
213
+ // src/elements/ConfirmationDialog.tsx
214
+ import { forwardRef as forwardRef2, useId, useRef, useState } from "react";
215
+ import { Button } from "@edux-design/buttons";
216
+ import { cx as cx2 } from "@edux-design/utils";
217
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
218
+ var ConfirmationDialog = forwardRef2(function ConfirmationDialog2({
219
+ open,
220
+ defaultOpen = false,
221
+ onOpenChange,
222
+ modal = true,
223
+ title,
224
+ description,
225
+ children,
226
+ trigger,
227
+ onConfirm,
228
+ onCancel,
229
+ confirmLabel = "Confirm",
230
+ cancelLabel = "Cancel",
231
+ pendingLabel = "Working\u2026",
232
+ pending = false,
233
+ error,
234
+ confirmDisabled = false,
235
+ cancelDisabled = false,
236
+ dismissible = true,
237
+ returnFocusRef,
238
+ isDanger = false,
239
+ allowDismissWhilePending = false,
240
+ size = "small",
241
+ contentProps = {},
242
+ backdropProps,
243
+ portalProps,
244
+ confirmButtonProps = {},
245
+ cancelButtonProps = {}
246
+ }, ref) {
247
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
248
+ const resolvedOpen = open ?? uncontrolledOpen;
249
+ const cancelRef = useRef(null);
250
+ const previousFocusRef = useRef(null);
251
+ const statusId = useId();
252
+ const locked = !dismissible || pending && !allowDismissWhilePending;
253
+ const hasBody = children != null || Boolean(error) || pending;
254
+ const {
255
+ onClick: onCancelClick,
256
+ ref: actionRef,
257
+ ...cancelProps
258
+ } = cancelButtonProps;
259
+ const { onClick: onConfirmClick, ...confirmProps } = confirmButtonProps;
260
+ const requestOpen = (next) => {
261
+ if (!next && locked) return;
262
+ if (open === void 0) setUncontrolledOpen(next);
263
+ onOpenChange?.(next);
264
+ };
265
+ return /* @__PURE__ */ jsxs2(Modal, { open: resolvedOpen, onOpenChange: requestOpen, modal, children: [
266
+ trigger && /* @__PURE__ */ jsx2(ModalTrigger, { asChild: true, children: trigger }),
267
+ /* @__PURE__ */ jsxs2(
268
+ ModalContent,
269
+ {
270
+ ...contentProps,
271
+ ref,
272
+ size,
273
+ backdropProps,
274
+ portalProps,
275
+ ...!description && !contentProps["aria-describedby"] ? { "aria-describedby": void 0 } : {},
276
+ "aria-busy": pending || void 0,
277
+ className: cx2("motion-reduce:animate-none", contentProps.className),
278
+ onOpenAutoFocus: (event) => {
279
+ previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
280
+ contentProps.onOpenAutoFocus?.(event);
281
+ if (!event.defaultPrevented) {
282
+ event.preventDefault();
283
+ if (!locked && !cancelDisabled) cancelRef.current?.focus();
284
+ else if (event.target instanceof HTMLElement) event.target.focus();
285
+ }
286
+ },
287
+ onCloseAutoFocus: (event) => {
288
+ contentProps.onCloseAutoFocus?.(event);
289
+ const target = returnFocusRef?.current ?? (!trigger ? previousFocusRef.current : null);
290
+ if (!event.defaultPrevented && target?.isConnected) {
291
+ event.preventDefault();
292
+ target.focus();
293
+ }
294
+ },
295
+ onEscapeKeyDown: (event) => {
296
+ contentProps.onEscapeKeyDown?.(event);
297
+ if (locked) event.preventDefault();
298
+ },
299
+ onInteractOutside: (event) => {
300
+ contentProps.onInteractOutside?.(event);
301
+ if (locked) event.preventDefault();
302
+ },
303
+ children: [
304
+ /* @__PURE__ */ jsxs2(ModalHeader, { className: !hasBody ? "border-b-0" : void 0, children: [
305
+ /* @__PURE__ */ jsx2(ModalTitle, { children: title }),
306
+ description && /* @__PURE__ */ jsx2(ModalDescription, { children: description })
307
+ ] }),
308
+ hasBody && /* @__PURE__ */ jsxs2(ModalBody, { className: "min-h-0 text-sm text-fg-base", children: [
309
+ children,
310
+ error && /* @__PURE__ */ jsx2("div", { role: "alert", className: "mt-8 text-fg-danger-emphasis", children: error }),
311
+ pending && /* @__PURE__ */ jsx2("p", { id: statusId, role: "status", className: "sr-only", children: pendingLabel })
312
+ ] }),
313
+ /* @__PURE__ */ jsxs2(ModalFooter, { className: "flex-wrap", children: [
314
+ /* @__PURE__ */ jsx2(
315
+ Button,
316
+ {
317
+ ...cancelProps,
318
+ ref: (node) => {
319
+ cancelRef.current = node;
320
+ if (typeof actionRef === "function") actionRef(node);
321
+ else if (actionRef) actionRef.current = node;
322
+ },
323
+ type: "button",
324
+ size: "small",
325
+ isSecondary: true,
326
+ disabled: locked || cancelDisabled,
327
+ onClick: (event) => {
328
+ onCancelClick?.(event);
329
+ if (!event.defaultPrevented) onCancel?.(event);
330
+ if (!event.defaultPrevented) requestOpen(false);
331
+ },
332
+ children: cancelLabel
333
+ }
334
+ ),
335
+ /* @__PURE__ */ jsx2(
336
+ Button,
337
+ {
338
+ ...confirmProps,
339
+ type: "button",
340
+ size: "small",
341
+ isDanger,
342
+ disabled: pending || confirmDisabled,
343
+ onClick: (event) => {
344
+ onConfirmClick?.(event);
345
+ if (!event.defaultPrevented) onConfirm(event);
346
+ },
347
+ children: pending ? pendingLabel : confirmLabel
348
+ }
349
+ )
350
+ ] })
351
+ ]
352
+ }
353
+ )
354
+ ] });
355
+ });
205
356
  export {
357
+ ConfirmationDialog,
206
358
  Modal,
207
359
  ModalBody,
208
360
  ModalClose,
@@ -0,0 +1,117 @@
1
+ # ConfirmationDialog
2
+
3
+ Import `ConfirmationDialog` and `ConfirmationDialogProps` from `@edux-design/modals`.
4
+ It composes the existing Modal root, surface, title/description and sections, retaining
5
+ its focus trap, overlay, size presets and high overlay stacking order.
6
+
7
+ ```tsx
8
+ const [open, setOpen] = useState(false);
9
+ const [pending, setPending] = useState(false);
10
+ const [error, setError] = useState("");
11
+
12
+ <ConfirmationDialog
13
+ open={open}
14
+ onOpenChange={setOpen}
15
+ title="Remove this upload?"
16
+ description="This permanently removes the unpublished font file."
17
+ trigger={<Button>Review removal</Button>}
18
+ confirmLabel="Remove upload"
19
+ pendingLabel="Removing…"
20
+ isDanger
21
+ pending={pending}
22
+ error={error}
23
+ onConfirm={async () => {
24
+ setPending(true);
25
+ setError("");
26
+ try {
27
+ if (await removeUpload()) setOpen(false);
28
+ else setError("The upload could not be removed. Please try again.");
29
+ } catch {
30
+ setError("The upload could not be removed. Please try again.");
31
+ } finally {
32
+ setPending(false);
33
+ }
34
+ }}
35
+ >
36
+ <p>{filename}</p>
37
+ </ConfirmationDialog>;
38
+ ```
39
+
40
+ `trigger` is optional for an editor/API-opened request. A supplied trigger is a
41
+ single ref-forwarding element composed with `ModalTrigger asChild`, preserving its
42
+ existing handlers and ref. Triggerless dialogs remember the previously focused
43
+ connected element; supplied triggers use Radix return focus. Use an explicit
44
+ `returnFocusRef` when deleting that origin, and the preventable close-autofocus hook
45
+ when the host intentionally focuses the editor.
46
+
47
+ `size` defaults to `small`; `medium`, `large` and `full` retain existing Modal bounds.
48
+ `backdropProps` forwards the overlay's native attributes/classes.
49
+ `portalProps` forwards Radix Dialog Portal options, including a theme-scoped container.
50
+ The additive `ModalContent portalProps` capability is also public for ordinary Modal
51
+ consumers; default Modal portal behaviour is unchanged.
52
+
53
+ Both confirmations leave mutation policy in the host. They never await, interpret,
54
+ catch, or auto-close after `onConfirm`: synchronous commands, promises, false results,
55
+ blocking document errors and retriable service errors can keep their own lifecycle.
56
+ An async handler must catch its own rejection and update its own `pending`/`error`.
57
+ Nothing schedules asynchronous component state after the callback.
58
+
59
+ `open` is authoritative when supplied. Omit it and use `defaultOpen` for an
60
+ uncontrolled presentation; `onOpenChange(next)` reports user requests in either mode.
61
+ Confirm does not close even in uncontrolled mode: use controlled state when success
62
+ must close the presentation. `onCancel(event)` only reports the Cancel button;
63
+ Escape and outside dismissal report through `onOpenChange`, without pretending to
64
+ be a Cancel activation.
65
+
66
+ Actions use Edux `Button`, default to `type="button"`, and wrap on narrow surfaces.
67
+ The default confirm action is neutral. Set `isDanger` for destructive actions.
68
+ `confirmDisabled` and `cancelDisabled` independently disable their respective
69
+ buttons. An error renders a `role="alert"`; an error alone does not disable retry.
70
+ For a stale editor/document error, the host also sets `confirmDisabled`.
71
+
72
+ While `pending`, both actions are disabled, the surface has `aria-busy`, and
73
+ `pendingLabel` (default “Working…”) replaces the confirm text and is exposed as a
74
+ status. Escape, outside pointer/focus dismissal, Cancel, and trigger toggle requests
75
+ cannot close the presentation. `allowDismissWhilePending` explicitly unlocks user
76
+ cancellation while confirm remains disabled; use it only when the host permits that
77
+ policy. `dismissible={false}` locks user dismissal regardless of pending. A controlled
78
+ host can always set `open={false}`, for example after confirmed success. There is no
79
+ implicit header X action to bypass these rules.
80
+
81
+ Safe initial focus goes to Cancel. If Cancel is unavailable, focus stays on the
82
+ surface rather than choosing the destructive action. Native `contentProps`
83
+ `onOpenAutoFocus`/`onCloseAutoFocus` can call `event.preventDefault()` to replace the
84
+ focus policy; these hooks run first. `returnFocusRef` supplies an explicit return
85
+ target when the originating row might disappear. For an editor command that places
86
+ its own selection/focus, prevent close autofocus so automatic restoration cannot
87
+ undo that command. A missing or disconnected return target is not focused.
88
+
89
+ `contentProps` forwards native attributes, style/className, keyboard/pointer events,
90
+ and Radix focus/dismissal hooks. The surface ref is an `HTMLDivElement`.
91
+ `confirmButtonProps`/`cancelButtonProps` forward native button attributes, refs and
92
+ mouse/keyboard handlers. Their click handlers run before `onConfirm`/`onCancel`;
93
+ `preventDefault()` vetoes the corresponding action, and `stopPropagation()` remains
94
+ available for editable/sortable row adapters. Forced action type, disabled state and
95
+ pending guards take precedence. Caller dismissal hooks run first, then the internal
96
+ lock always prevents dismissal while locked; a handler cannot accidentally undo it.
97
+
98
+ Each surface is a labelled `dialog`, with a meaningful description when provided.
99
+ Confirmation is a decision, so it uses ordinary buttons rather than menu navigation.
100
+ It does not automatically claim urgent `alertdialog` semantics. A caller may set
101
+ `contentProps.role="alertdialog"` when that semantic is appropriate. Error messages
102
+ have alert semantics separately. IDs remain unique across instances.
103
+
104
+ Use current semantic surface/foreground/border/action/danger tokens. There are no
105
+ new tokens or package-owned palettes, and theme switching does not change geometry.
106
+ Pass `portalProps.container` when a brand/mode scope is below the document root;
107
+ put `brand--blue`/`brand--teal` and `theme--light`/`theme--dark` on that same scope.
108
+ Default portals inherit the application root theme. Native reduced-motion rules
109
+ apply; the compositions also disable their surface animation for reduced motion.
110
+ The primitives keep collision/viewport bounds, content scrolls, and footer actions
111
+ wrap. Domain checks, permissions, editor resolver registration, persistence, source
112
+ warnings, server mutation and cancellation remain local.
113
+
114
+ Storybook: `Modals/ConfirmationDialog` includes destructive and neutral actions,
115
+ bodyless content, warnings, async pending/error/retry, blocking errors, narrow content,
116
+ and light/dark blue/teal scopes. Component tests exercise real Radix focus and dismissal,
117
+ not mocked primitives. Public type/runtime fixtures live under `test/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edux-design/modals",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -8,18 +8,11 @@
8
8
  "types": "./dist/index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
+ "types": "./dist/index.d.ts",
11
12
  "import": "./dist/index.mjs",
12
- "require": "./dist/index.js",
13
- "types": "./dist/index.d.ts"
13
+ "require": "./dist/index.js"
14
14
  }
15
15
  },
16
- "scripts": {
17
- "lint": "eslint . --max-warnings 0",
18
- "build": "tsup src/index.js --format esm,cjs --external react,react-dom,@edux-design/utils,@edux-design/buttons,@edux-design/icons,@radix-ui/react-dialog",
19
- "dev": "tsup src/index.js --watch --format esm,cjs --external react,react-dom,@edux-design/utils,@edux-design/buttons,@edux-design/icons,@radix-ui/react-dialog",
20
- "generate:component": "turbo gen react-component",
21
- "check-types": "tsc --noEmit"
22
- },
23
16
  "dependencies": {
24
17
  "@edux-design/buttons": "*",
25
18
  "@edux-design/icons": "*"
@@ -30,12 +23,12 @@
30
23
  "@edux-design/utils": "*",
31
24
  "@radix-ui/react-dialog": "1.1.5"
32
25
  },
33
- "devDependencies": {
34
- "typescript": "^5.9.2",
35
- "tailwindcss": "^4.1.12",
36
- "postcss": "^8.5.6",
37
- "tsup": "^8.5.0",
38
- "@tailwindcss/cli": "^4.1.12",
39
- "@tailwindcss/postcss": "^4.1.12"
40
- }
26
+ "files": [
27
+ "dist",
28
+ "docs",
29
+ "README.md",
30
+ "CHANGELOG.md",
31
+ "LICENSE",
32
+ "LICENSE.md"
33
+ ]
41
34
  }