@edux-design/modals 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @edux-design/modals
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Minor changes to z-indexes
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Initial modal package with centered dialog primitives, token-based sizing, stories, and tests.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @edux-design/modals
2
+
3
+ Centered modal primitives built on top of Radix Dialog and Edux design tokens.
4
+
5
+ Exports:
6
+
7
+ - `Modal`
8
+ - `ModalTrigger`
9
+ - `ModalClose`
10
+ - `ModalPortal`
11
+ - `ModalOverlay`
12
+ - `ModalContent`
13
+ - `ModalHeader`
14
+ - `ModalBody`
15
+ - `ModalFooter`
16
+ - `ModalTitle`
17
+ - `ModalDescription`
18
+
19
+ ## Usage
20
+
21
+ ```jsx
22
+ import {
23
+ Modal,
24
+ ModalTrigger,
25
+ ModalContent,
26
+ ModalHeader,
27
+ ModalBody,
28
+ ModalFooter,
29
+ ModalClose,
30
+ ModalTitle,
31
+ ModalDescription,
32
+ } from "@edux-design/modals";
33
+ import { Button } from "@edux-design/buttons";
34
+
35
+ export function PublishModal() {
36
+ return (
37
+ <Modal>
38
+ <ModalTrigger asChild>
39
+ <Button variant="primary">Open Modal</Button>
40
+ </ModalTrigger>
41
+ <ModalContent size="medium">
42
+ <ModalHeader>
43
+ <ModalTitle>Publish changes</ModalTitle>
44
+ <ModalDescription>
45
+ Review content before making it visible.
46
+ </ModalDescription>
47
+ </ModalHeader>
48
+ <ModalBody>
49
+ <p>Modal body content.</p>
50
+ </ModalBody>
51
+ <ModalFooter>
52
+ <ModalClose asChild>
53
+ <Button variant="secondary">Cancel</Button>
54
+ </ModalClose>
55
+ <Button variant="primary">Publish</Button>
56
+ </ModalFooter>
57
+ </ModalContent>
58
+ </Modal>
59
+ );
60
+ }
61
+ ```
62
+
63
+ `ModalContent` accepts `size`, `backdropProps`, `backDropProps`, `className`, and `contentProps`. Storybook examples live in `src/demos/Modal.stories.jsx`.
package/dist/index.js ADDED
@@ -0,0 +1,219 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // src/index.js
30
+ var index_exports = {};
31
+ __export(index_exports, {
32
+ Modal: () => Modal,
33
+ ModalBody: () => ModalBody,
34
+ ModalClose: () => ModalClose,
35
+ ModalContent: () => ModalContent,
36
+ ModalDescription: () => ModalDescription,
37
+ ModalFooter: () => ModalFooter,
38
+ ModalHeader: () => ModalHeader,
39
+ ModalOverlay: () => ModalOverlay,
40
+ ModalPortal: () => ModalPortal,
41
+ ModalTitle: () => ModalTitle,
42
+ ModalTrigger: () => ModalTrigger
43
+ });
44
+ module.exports = __toCommonJS(index_exports);
45
+
46
+ // src/elements/Modal.jsx
47
+ var import_react = __toESM(require("react"));
48
+ var DialogPrimitive = __toESM(require("@radix-ui/react-dialog"));
49
+ var import_utils = require("@edux-design/utils");
50
+ var MODAL_OVERLAY_Z_INDEX = 2147483645;
51
+ var MODAL_CONTENT_Z_INDEX = 2147483646;
52
+ var getSizeClasses = (size) => {
53
+ switch (size) {
54
+ case "small":
55
+ return "w-[min(92vw,420px)]";
56
+ case "large":
57
+ return "w-[min(92vw,840px)]";
58
+ case "full":
59
+ return "w-[min(96vw,1200px)] h-[min(92vh,900px)]";
60
+ case "medium":
61
+ default:
62
+ return "w-[min(92vw,640px)]";
63
+ }
64
+ };
65
+ var Modal = ({
66
+ open,
67
+ defaultOpen,
68
+ onOpenChange,
69
+ onOpen,
70
+ onClose,
71
+ children,
72
+ ...props
73
+ }) => {
74
+ const handleOpenChange = (0, import_react.useCallback)(
75
+ (nextOpen) => {
76
+ onOpenChange == null ? void 0 : onOpenChange(nextOpen);
77
+ if (nextOpen) {
78
+ onOpen == null ? void 0 : onOpen();
79
+ return;
80
+ }
81
+ onClose == null ? void 0 : onClose();
82
+ },
83
+ [onClose, onOpen, onOpenChange]
84
+ );
85
+ return /* @__PURE__ */ import_react.default.createElement(
86
+ DialogPrimitive.Root,
87
+ {
88
+ open,
89
+ defaultOpen,
90
+ onOpenChange: handleOpenChange,
91
+ ...props
92
+ },
93
+ children
94
+ );
95
+ };
96
+ var ModalTrigger = DialogPrimitive.Trigger;
97
+ var ModalClose = DialogPrimitive.Close;
98
+ var ModalPortal = DialogPrimitive.Portal;
99
+ var ModalOverlay = (0, import_react.forwardRef)(
100
+ ({ className, style, ...props }, ref) => /* @__PURE__ */ import_react.default.createElement(
101
+ DialogPrimitive.Overlay,
102
+ {
103
+ ref,
104
+ className: (0, import_utils.cx)(
105
+ "fixed inset-0 bg-bg-focus-overlay opacity-70",
106
+ "data-[state=open]:animate-drawer-fade-in data-[state=closed]:animate-drawer-fade-out",
107
+ className
108
+ ),
109
+ style: { zIndex: MODAL_OVERLAY_Z_INDEX, ...style },
110
+ ...props
111
+ }
112
+ )
113
+ );
114
+ ModalOverlay.displayName = "ModalOverlay";
115
+ var ModalContent = (0, import_react.forwardRef)(
116
+ ({
117
+ children,
118
+ size = "medium",
119
+ className,
120
+ style,
121
+ backdropProps,
122
+ backDropProps,
123
+ contentProps = {},
124
+ ...restProps
125
+ }, ref) => {
126
+ const resolvedBackdropProps = backdropProps ?? backDropProps ?? {};
127
+ return /* @__PURE__ */ import_react.default.createElement(ModalPortal, null, /* @__PURE__ */ import_react.default.createElement(
128
+ ModalOverlay,
129
+ {
130
+ ...resolvedBackdropProps,
131
+ className: (0, import_utils.cx)(resolvedBackdropProps.className),
132
+ style: { ...resolvedBackdropProps.style }
133
+ }
134
+ ), /* @__PURE__ */ import_react.default.createElement(
135
+ DialogPrimitive.Content,
136
+ {
137
+ ref,
138
+ className: (0, import_utils.cx)(
139
+ "fixed left-1/2 top-1/2 max-h-[92vh] -translate-x-1/2 -translate-y-1/2",
140
+ "bg-bg-base text-fg-base shadow-xl outline-none rounded-2xl border border-border-subtle",
141
+ "flex flex-col overflow-hidden",
142
+ "data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out",
143
+ getSizeClasses(size),
144
+ className
145
+ ),
146
+ style: { zIndex: MODAL_CONTENT_Z_INDEX, ...style },
147
+ ...contentProps,
148
+ ...restProps
149
+ },
150
+ children
151
+ ));
152
+ }
153
+ );
154
+ ModalContent.displayName = "ModalContent";
155
+ var ModalHeader = (0, import_react.forwardRef)(({ className, ...props }, ref) => /* @__PURE__ */ import_react.default.createElement(
156
+ "div",
157
+ {
158
+ ref,
159
+ className: (0, import_utils.cx)(
160
+ "flex flex-col gap-4 px-20 py-16 border-b border-border-subtle",
161
+ className
162
+ ),
163
+ ...props
164
+ }
165
+ ));
166
+ ModalHeader.displayName = "ModalHeader";
167
+ var ModalBody = (0, import_react.forwardRef)(({ className, ...props }, ref) => /* @__PURE__ */ import_react.default.createElement(
168
+ "div",
169
+ {
170
+ ref,
171
+ className: (0, import_utils.cx)("flex-1 overflow-auto px-20 py-16", className),
172
+ ...props
173
+ }
174
+ ));
175
+ ModalBody.displayName = "ModalBody";
176
+ var ModalFooter = (0, import_react.forwardRef)(({ className, ...props }, ref) => /* @__PURE__ */ import_react.default.createElement(
177
+ "div",
178
+ {
179
+ ref,
180
+ className: (0, import_utils.cx)(
181
+ "flex items-center justify-end gap-8 px-20 py-16 border-t border-border-subtle",
182
+ className
183
+ ),
184
+ ...props
185
+ }
186
+ ));
187
+ ModalFooter.displayName = "ModalFooter";
188
+ var ModalTitle = (0, import_react.forwardRef)(({ className, ...props }, ref) => /* @__PURE__ */ import_react.default.createElement(
189
+ DialogPrimitive.Title,
190
+ {
191
+ ref,
192
+ className: (0, import_utils.cx)("text-lg font-semibold text-fg-base", className),
193
+ ...props
194
+ }
195
+ ));
196
+ ModalTitle.displayName = "ModalTitle";
197
+ var ModalDescription = (0, import_react.forwardRef)(({ className, ...props }, ref) => /* @__PURE__ */ import_react.default.createElement(
198
+ DialogPrimitive.Description,
199
+ {
200
+ ref,
201
+ className: (0, import_utils.cx)("text-sm text-fg-subtle", className),
202
+ ...props
203
+ }
204
+ ));
205
+ ModalDescription.displayName = "ModalDescription";
206
+ // Annotate the CommonJS export names for ESM import in node:
207
+ 0 && (module.exports = {
208
+ Modal,
209
+ ModalBody,
210
+ ModalClose,
211
+ ModalContent,
212
+ ModalDescription,
213
+ ModalFooter,
214
+ ModalHeader,
215
+ ModalOverlay,
216
+ ModalPortal,
217
+ ModalTitle,
218
+ ModalTrigger
219
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,173 @@
1
+ // src/elements/Modal.jsx
2
+ import React, { forwardRef, useCallback } from "react";
3
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
4
+ import { cx } from "@edux-design/utils";
5
+ var MODAL_OVERLAY_Z_INDEX = 2147483645;
6
+ var MODAL_CONTENT_Z_INDEX = 2147483646;
7
+ var getSizeClasses = (size) => {
8
+ switch (size) {
9
+ case "small":
10
+ return "w-[min(92vw,420px)]";
11
+ case "large":
12
+ return "w-[min(92vw,840px)]";
13
+ case "full":
14
+ return "w-[min(96vw,1200px)] h-[min(92vh,900px)]";
15
+ case "medium":
16
+ default:
17
+ return "w-[min(92vw,640px)]";
18
+ }
19
+ };
20
+ var Modal = ({
21
+ open,
22
+ defaultOpen,
23
+ onOpenChange,
24
+ onOpen,
25
+ onClose,
26
+ children,
27
+ ...props
28
+ }) => {
29
+ const handleOpenChange = useCallback(
30
+ (nextOpen) => {
31
+ onOpenChange == null ? void 0 : onOpenChange(nextOpen);
32
+ if (nextOpen) {
33
+ onOpen == null ? void 0 : onOpen();
34
+ return;
35
+ }
36
+ onClose == null ? void 0 : onClose();
37
+ },
38
+ [onClose, onOpen, onOpenChange]
39
+ );
40
+ return /* @__PURE__ */ React.createElement(
41
+ DialogPrimitive.Root,
42
+ {
43
+ open,
44
+ defaultOpen,
45
+ onOpenChange: handleOpenChange,
46
+ ...props
47
+ },
48
+ children
49
+ );
50
+ };
51
+ var ModalTrigger = DialogPrimitive.Trigger;
52
+ var ModalClose = DialogPrimitive.Close;
53
+ var ModalPortal = DialogPrimitive.Portal;
54
+ var ModalOverlay = forwardRef(
55
+ ({ className, style, ...props }, ref) => /* @__PURE__ */ React.createElement(
56
+ DialogPrimitive.Overlay,
57
+ {
58
+ ref,
59
+ className: cx(
60
+ "fixed inset-0 bg-bg-focus-overlay opacity-70",
61
+ "data-[state=open]:animate-drawer-fade-in data-[state=closed]:animate-drawer-fade-out",
62
+ className
63
+ ),
64
+ style: { zIndex: MODAL_OVERLAY_Z_INDEX, ...style },
65
+ ...props
66
+ }
67
+ )
68
+ );
69
+ ModalOverlay.displayName = "ModalOverlay";
70
+ var ModalContent = forwardRef(
71
+ ({
72
+ children,
73
+ size = "medium",
74
+ className,
75
+ style,
76
+ backdropProps,
77
+ backDropProps,
78
+ contentProps = {},
79
+ ...restProps
80
+ }, ref) => {
81
+ const resolvedBackdropProps = backdropProps ?? backDropProps ?? {};
82
+ return /* @__PURE__ */ React.createElement(ModalPortal, null, /* @__PURE__ */ React.createElement(
83
+ ModalOverlay,
84
+ {
85
+ ...resolvedBackdropProps,
86
+ className: cx(resolvedBackdropProps.className),
87
+ style: { ...resolvedBackdropProps.style }
88
+ }
89
+ ), /* @__PURE__ */ React.createElement(
90
+ DialogPrimitive.Content,
91
+ {
92
+ ref,
93
+ className: cx(
94
+ "fixed left-1/2 top-1/2 max-h-[92vh] -translate-x-1/2 -translate-y-1/2",
95
+ "bg-bg-base text-fg-base shadow-xl outline-none rounded-2xl border border-border-subtle",
96
+ "flex flex-col overflow-hidden",
97
+ "data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out",
98
+ getSizeClasses(size),
99
+ className
100
+ ),
101
+ style: { zIndex: MODAL_CONTENT_Z_INDEX, ...style },
102
+ ...contentProps,
103
+ ...restProps
104
+ },
105
+ children
106
+ ));
107
+ }
108
+ );
109
+ ModalContent.displayName = "ModalContent";
110
+ var ModalHeader = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
111
+ "div",
112
+ {
113
+ ref,
114
+ className: cx(
115
+ "flex flex-col gap-4 px-20 py-16 border-b border-border-subtle",
116
+ className
117
+ ),
118
+ ...props
119
+ }
120
+ ));
121
+ ModalHeader.displayName = "ModalHeader";
122
+ var ModalBody = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
123
+ "div",
124
+ {
125
+ ref,
126
+ className: cx("flex-1 overflow-auto px-20 py-16", className),
127
+ ...props
128
+ }
129
+ ));
130
+ ModalBody.displayName = "ModalBody";
131
+ var ModalFooter = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
132
+ "div",
133
+ {
134
+ ref,
135
+ className: cx(
136
+ "flex items-center justify-end gap-8 px-20 py-16 border-t border-border-subtle",
137
+ className
138
+ ),
139
+ ...props
140
+ }
141
+ ));
142
+ ModalFooter.displayName = "ModalFooter";
143
+ var ModalTitle = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
144
+ DialogPrimitive.Title,
145
+ {
146
+ ref,
147
+ className: cx("text-lg font-semibold text-fg-base", className),
148
+ ...props
149
+ }
150
+ ));
151
+ ModalTitle.displayName = "ModalTitle";
152
+ var ModalDescription = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ React.createElement(
153
+ DialogPrimitive.Description,
154
+ {
155
+ ref,
156
+ className: cx("text-sm text-fg-subtle", className),
157
+ ...props
158
+ }
159
+ ));
160
+ ModalDescription.displayName = "ModalDescription";
161
+ export {
162
+ Modal,
163
+ ModalBody,
164
+ ModalClose,
165
+ ModalContent,
166
+ ModalDescription,
167
+ ModalFooter,
168
+ ModalHeader,
169
+ ModalOverlay,
170
+ ModalPortal,
171
+ ModalTitle,
172
+ ModalTrigger
173
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@edux-design/modals",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "sideEffects": [
6
+ "**/*.css"
7
+ ],
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
14
+ }
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,@radix-ui/react-dialog",
19
+ "dev": "tsup src/index.js --watch --format esm,cjs --external react,react-dom,@edux-design/utils,@radix-ui/react-dialog",
20
+ "generate:component": "turbo gen react-component",
21
+ "check-types": "tsc --noEmit"
22
+ },
23
+ "peerDependencies": {
24
+ "react": ">=18",
25
+ "react-dom": ">=18",
26
+ "@edux-design/utils": "*",
27
+ "@radix-ui/react-dialog": "1.1.5"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.9.2",
31
+ "tailwindcss": "^4.1.12",
32
+ "postcss": "^8.5.6",
33
+ "tsup": "^8.5.0",
34
+ "@tailwindcss/cli": "^4.1.12",
35
+ "@tailwindcss/postcss": "^4.1.12"
36
+ }
37
+ }
@@ -0,0 +1,168 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ Modal,
4
+ ModalTrigger,
5
+ ModalContent,
6
+ ModalHeader,
7
+ ModalBody,
8
+ ModalFooter,
9
+ ModalClose,
10
+ ModalTitle,
11
+ ModalDescription,
12
+ } from "../index";
13
+ import { Button } from "@edux-design/buttons";
14
+
15
+ const meta = {
16
+ title: "Components/Modal",
17
+ component: Modal,
18
+ parameters: {
19
+ layout: "centered",
20
+ docs: {
21
+ description: {
22
+ component: `
23
+ Centered **Modal** built on Radix Dialog primitives.
24
+
25
+ Use for confirmations, forms, and focused blocking workflows.
26
+ `,
27
+ },
28
+ },
29
+ },
30
+ argTypes: {
31
+ size: {
32
+ control: "select",
33
+ options: ["small", "medium", "large", "full"],
34
+ description: "Width preset for the modal content.",
35
+ table: { defaultValue: { summary: "medium" } },
36
+ },
37
+ },
38
+ };
39
+
40
+ export default meta;
41
+
42
+ export const Basic = {
43
+ render: (args) => (
44
+ <Modal>
45
+ <ModalTrigger asChild>
46
+ <Button variant="primary">Open Modal</Button>
47
+ </ModalTrigger>
48
+ <ModalContent {...args}>
49
+ <ModalHeader>
50
+ <ModalTitle>Publish changes</ModalTitle>
51
+ <ModalDescription>
52
+ Review your changes before making them visible to learners.
53
+ </ModalDescription>
54
+ </ModalHeader>
55
+ <ModalBody>
56
+ <p className="text-sm text-fg-subtle">
57
+ This modal supports title, description, body, and footer regions.
58
+ </p>
59
+ </ModalBody>
60
+ <ModalFooter>
61
+ <ModalClose asChild>
62
+ <Button variant="secondary">Cancel</Button>
63
+ </ModalClose>
64
+ <Button variant="primary">Publish</Button>
65
+ </ModalFooter>
66
+ </ModalContent>
67
+ </Modal>
68
+ ),
69
+ args: {
70
+ size: "medium",
71
+ },
72
+ };
73
+
74
+ export const Controlled = {
75
+ render: (args) => {
76
+ const [open, setOpen] = useState(false);
77
+
78
+ return (
79
+ <Modal open={open} onOpenChange={setOpen}>
80
+ <ModalTrigger asChild>
81
+ <Button variant="primary">
82
+ {open ? "Close Modal" : "Open Modal"}
83
+ </Button>
84
+ </ModalTrigger>
85
+ <ModalContent {...args}>
86
+ <ModalHeader>
87
+ <ModalTitle>Controlled modal</ModalTitle>
88
+ <ModalDescription>
89
+ State is managed externally via open and onOpenChange.
90
+ </ModalDescription>
91
+ </ModalHeader>
92
+ <ModalBody>
93
+ <p className="text-sm text-fg-subtle">
94
+ Use this variant when the parent controls the workflow.
95
+ </p>
96
+ </ModalBody>
97
+ <ModalFooter>
98
+ <Button variant="bare" onClick={() => setOpen(false)}>
99
+ Dismiss
100
+ </Button>
101
+ </ModalFooter>
102
+ </ModalContent>
103
+ </Modal>
104
+ );
105
+ },
106
+ args: {
107
+ size: "medium",
108
+ },
109
+ };
110
+
111
+ export const Sizes = {
112
+ render: () => (
113
+ <div className="flex flex-wrap gap-12">
114
+ {["small", "medium", "large", "full"].map((size) => (
115
+ <Modal key={size}>
116
+ <ModalTrigger asChild>
117
+ <Button variant="secondary">Size: {size}</Button>
118
+ </ModalTrigger>
119
+ <ModalContent size={size}>
120
+ <ModalHeader>
121
+ <ModalTitle>{size} modal</ModalTitle>
122
+ <ModalDescription>
123
+ Demonstrates the {size} content width preset.
124
+ </ModalDescription>
125
+ </ModalHeader>
126
+ <ModalBody>
127
+ <p className="text-sm text-fg-subtle">
128
+ These presets use existing spacing, border, shadow, and
129
+ foreground/background tokens.
130
+ </p>
131
+ </ModalBody>
132
+ </ModalContent>
133
+ </Modal>
134
+ ))}
135
+ </div>
136
+ ),
137
+ };
138
+
139
+ export const CustomBackdrop = {
140
+ render: (args) => (
141
+ <Modal>
142
+ <ModalTrigger asChild>
143
+ <Button variant="primary">Open With Backdrop</Button>
144
+ </ModalTrigger>
145
+ <ModalContent
146
+ {...args}
147
+ backdropProps={{
148
+ className: "bg-bg-primary-subtle opacity-80",
149
+ }}
150
+ >
151
+ <ModalHeader>
152
+ <ModalTitle>Custom backdrop</ModalTitle>
153
+ <ModalDescription>
154
+ Backdrop styles can be customized via backdropProps.
155
+ </ModalDescription>
156
+ </ModalHeader>
157
+ <ModalBody>
158
+ <p className="text-sm text-fg-subtle">
159
+ Override color and opacity without changing the modal surface.
160
+ </p>
161
+ </ModalBody>
162
+ </ModalContent>
163
+ </Modal>
164
+ ),
165
+ args: {
166
+ size: "medium",
167
+ },
168
+ };
@@ -0,0 +1,176 @@
1
+ import React, { forwardRef, useCallback } from "react";
2
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
3
+ import { cx } from "@edux-design/utils";
4
+
5
+ const MODAL_OVERLAY_Z_INDEX = 2147483645;
6
+ const MODAL_CONTENT_Z_INDEX = 2147483646;
7
+
8
+ const getSizeClasses = (size) => {
9
+ switch (size) {
10
+ case "small":
11
+ return "w-[min(92vw,420px)]";
12
+ case "large":
13
+ return "w-[min(92vw,840px)]";
14
+ case "full":
15
+ return "w-[min(96vw,1200px)] h-[min(92vh,900px)]";
16
+ case "medium":
17
+ default:
18
+ return "w-[min(92vw,640px)]";
19
+ }
20
+ };
21
+
22
+ export const Modal = ({
23
+ open,
24
+ defaultOpen,
25
+ onOpenChange,
26
+ onOpen,
27
+ onClose,
28
+ children,
29
+ ...props
30
+ }) => {
31
+ const handleOpenChange = useCallback(
32
+ (nextOpen) => {
33
+ onOpenChange?.(nextOpen);
34
+ if (nextOpen) {
35
+ onOpen?.();
36
+ return;
37
+ }
38
+ onClose?.();
39
+ },
40
+ [onClose, onOpen, onOpenChange]
41
+ );
42
+
43
+ return (
44
+ <DialogPrimitive.Root
45
+ open={open}
46
+ defaultOpen={defaultOpen}
47
+ onOpenChange={handleOpenChange}
48
+ {...props}
49
+ >
50
+ {children}
51
+ </DialogPrimitive.Root>
52
+ );
53
+ };
54
+
55
+ export const ModalTrigger = DialogPrimitive.Trigger;
56
+ export const ModalClose = DialogPrimitive.Close;
57
+ export const ModalPortal = DialogPrimitive.Portal;
58
+
59
+ export const ModalOverlay = forwardRef(
60
+ ({ className, style, ...props }, ref) => (
61
+ <DialogPrimitive.Overlay
62
+ ref={ref}
63
+ className={cx(
64
+ "fixed inset-0 bg-bg-focus-overlay opacity-70",
65
+ "data-[state=open]:animate-drawer-fade-in data-[state=closed]:animate-drawer-fade-out",
66
+ className
67
+ )}
68
+ style={{ zIndex: MODAL_OVERLAY_Z_INDEX, ...style }}
69
+ {...props}
70
+ />
71
+ )
72
+ );
73
+
74
+ ModalOverlay.displayName = "ModalOverlay";
75
+
76
+ export const ModalContent = forwardRef(
77
+ (
78
+ {
79
+ children,
80
+ size = "medium",
81
+ className,
82
+ style,
83
+ backdropProps,
84
+ backDropProps,
85
+ contentProps = {},
86
+ ...restProps
87
+ },
88
+ ref
89
+ ) => {
90
+ const resolvedBackdropProps = backdropProps ?? backDropProps ?? {};
91
+
92
+ return (
93
+ <ModalPortal>
94
+ <ModalOverlay
95
+ {...resolvedBackdropProps}
96
+ className={cx(resolvedBackdropProps.className)}
97
+ style={{ ...resolvedBackdropProps.style }}
98
+ />
99
+ <DialogPrimitive.Content
100
+ ref={ref}
101
+ className={cx(
102
+ "fixed left-1/2 top-1/2 max-h-[92vh] -translate-x-1/2 -translate-y-1/2",
103
+ "bg-bg-base text-fg-base shadow-xl outline-none rounded-2xl border border-border-subtle",
104
+ "flex flex-col overflow-hidden",
105
+ "data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out",
106
+ getSizeClasses(size),
107
+ className
108
+ )}
109
+ style={{ zIndex: MODAL_CONTENT_Z_INDEX, ...style }}
110
+ {...contentProps}
111
+ {...restProps}
112
+ >
113
+ {children}
114
+ </DialogPrimitive.Content>
115
+ </ModalPortal>
116
+ );
117
+ }
118
+ );
119
+
120
+ ModalContent.displayName = "ModalContent";
121
+
122
+ export const ModalHeader = forwardRef(({ className, ...props }, ref) => (
123
+ <div
124
+ ref={ref}
125
+ className={cx(
126
+ "flex flex-col gap-4 px-20 py-16 border-b border-border-subtle",
127
+ className
128
+ )}
129
+ {...props}
130
+ />
131
+ ));
132
+
133
+ ModalHeader.displayName = "ModalHeader";
134
+
135
+ export const ModalBody = forwardRef(({ className, ...props }, ref) => (
136
+ <div
137
+ ref={ref}
138
+ className={cx("flex-1 overflow-auto px-20 py-16", className)}
139
+ {...props}
140
+ />
141
+ ));
142
+
143
+ ModalBody.displayName = "ModalBody";
144
+
145
+ export const ModalFooter = forwardRef(({ className, ...props }, ref) => (
146
+ <div
147
+ ref={ref}
148
+ className={cx(
149
+ "flex items-center justify-end gap-8 px-20 py-16 border-t border-border-subtle",
150
+ className
151
+ )}
152
+ {...props}
153
+ />
154
+ ));
155
+
156
+ ModalFooter.displayName = "ModalFooter";
157
+
158
+ export const ModalTitle = forwardRef(({ className, ...props }, ref) => (
159
+ <DialogPrimitive.Title
160
+ ref={ref}
161
+ className={cx("text-lg font-semibold text-fg-base", className)}
162
+ {...props}
163
+ />
164
+ ));
165
+
166
+ ModalTitle.displayName = "ModalTitle";
167
+
168
+ export const ModalDescription = forwardRef(({ className, ...props }, ref) => (
169
+ <DialogPrimitive.Description
170
+ ref={ref}
171
+ className={cx("text-sm text-fg-subtle", className)}
172
+ {...props}
173
+ />
174
+ ));
175
+
176
+ ModalDescription.displayName = "ModalDescription";
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import {
4
+ Modal,
5
+ ModalContent,
6
+ ModalHeader,
7
+ ModalBody,
8
+ ModalFooter,
9
+ ModalTitle,
10
+ ModalDescription,
11
+ } from "./Modal";
12
+
13
+ let lastRootProps;
14
+
15
+ vi.mock("@radix-ui/react-dialog", () => {
16
+ const Passthrough = ({ children, ...props }) => (
17
+ <div {...props}>{children}</div>
18
+ );
19
+ const Buttonish = ({ children, ...props }) => (
20
+ <button type="button" {...props}>
21
+ {children}
22
+ </button>
23
+ );
24
+ const Content = React.forwardRef((props, ref) => (
25
+ <div ref={ref} data-testid="radix-content" {...props} />
26
+ ));
27
+ Content.displayName = "RadixContent";
28
+ const Overlay = React.forwardRef((props, ref) => (
29
+ <div ref={ref} data-testid="radix-overlay" {...props} />
30
+ ));
31
+ Overlay.displayName = "RadixOverlay";
32
+ const Root = ({ children, ...props }) => {
33
+ lastRootProps = props;
34
+ return <div {...props}>{children}</div>;
35
+ };
36
+
37
+ return {
38
+ Root,
39
+ Trigger: Buttonish,
40
+ Portal: Passthrough,
41
+ Overlay,
42
+ Content,
43
+ Close: Buttonish,
44
+ Title: Passthrough,
45
+ Description: Passthrough,
46
+ };
47
+ });
48
+
49
+ describe("ModalContent", () => {
50
+ it("renders overlay with default styling and supports custom backdrop props", () => {
51
+ render(
52
+ <Modal>
53
+ <ModalContent
54
+ size="large"
55
+ backdropProps={{ className: "custom-backdrop" }}
56
+ >
57
+ Body
58
+ </ModalContent>
59
+ </Modal>
60
+ );
61
+
62
+ const overlay = screen.getByTestId("radix-overlay");
63
+ expect(overlay).toHaveClass("bg-bg-focus-overlay");
64
+ expect(overlay).toHaveClass("custom-backdrop");
65
+ expect(overlay).toHaveStyle({ zIndex: "2147483645" });
66
+
67
+ const content = screen.getByTestId("radix-content");
68
+ expect(content).toHaveClass("left-1/2");
69
+ expect(content).toHaveClass("w-[min(92vw,840px)]");
70
+ expect(content).toHaveStyle({ zIndex: "2147483646" });
71
+ });
72
+
73
+ it("composes header, body, footer, title, and description sections", () => {
74
+ render(
75
+ <Modal>
76
+ <ModalContent>
77
+ <ModalHeader>
78
+ <ModalTitle>Header</ModalTitle>
79
+ <ModalDescription>Details</ModalDescription>
80
+ </ModalHeader>
81
+ <ModalBody>Body</ModalBody>
82
+ <ModalFooter>Footer</ModalFooter>
83
+ </ModalContent>
84
+ </Modal>
85
+ );
86
+
87
+ expect(screen.getByText("Header")).toHaveClass("font-semibold");
88
+ expect(screen.getByText("Details")).toHaveClass("text-fg-subtle");
89
+ expect(screen.getByText("Body")).toHaveClass("overflow-auto");
90
+ expect(screen.getByText("Footer")).toHaveClass("border-t");
91
+ });
92
+ });
93
+
94
+ describe("Modal", () => {
95
+ it("fires onOpen and onClose from onOpenChange", () => {
96
+ const onOpen = vi.fn();
97
+ const onClose = vi.fn();
98
+
99
+ render(
100
+ <Modal onOpen={onOpen} onClose={onClose}>
101
+ <ModalContent>Body</ModalContent>
102
+ </Modal>
103
+ );
104
+
105
+ lastRootProps.onOpenChange(true);
106
+ lastRootProps.onOpenChange(false);
107
+
108
+ expect(onOpen).toHaveBeenCalledTimes(1);
109
+ expect(onClose).toHaveBeenCalledTimes(1);
110
+ });
111
+ });
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export {
2
+ Modal,
3
+ ModalTrigger,
4
+ ModalClose,
5
+ ModalPortal,
6
+ ModalOverlay,
7
+ ModalContent,
8
+ ModalHeader,
9
+ ModalBody,
10
+ ModalFooter,
11
+ ModalTitle,
12
+ ModalDescription,
13
+ } from "./elements/Modal";