@varialkit/modal 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/docs.md ADDED
@@ -0,0 +1,78 @@
1
+ # Modal
2
+
3
+ Modal presents focused content or tasks in a layer above the page.
4
+ Use it for critical decisions, forms, or previews that require user attention.
5
+
6
+ ## How to Use
7
+
8
+ ```tsx
9
+ import { Modal } from "@solara/modal";
10
+
11
+ export function Example() {
12
+ return (
13
+ <Modal isOpen onClose={() => {}}>
14
+ <Modal.Header onClose={() => {}}>
15
+ <Modal.Title>Invite teammates</Modal.Title>
16
+ <Modal.Subhead>Invite teammates</Modal.Subhead>
17
+ </Modal.Header>
18
+ <Modal.Content>Modal content goes here.</Modal.Content>
19
+ <Modal.Footer
20
+ primaryButton={{ label: "Send invites", onClick: () => {} }}
21
+ secondaryButton={{ label: "Cancel", onClick: () => {} }}
22
+ />
23
+ </Modal>
24
+ );
25
+ }
26
+ ```
27
+
28
+ ## Best Practices
29
+
30
+ - Keep titles short and action-oriented.
31
+ - Reserve modals for critical, focused tasks.
32
+ - Provide a clear dismissal path.
33
+
34
+ ## Props
35
+
36
+ ### Modal
37
+
38
+ | Prop | Type | Default | Description |
39
+ | --- | --- | --- | --- |
40
+ | `isOpen` | `boolean` | _Required_ | Controls visibility. |
41
+ | `onClose` | `() => void` | _Required_ | Close handler. |
42
+ | `closeOnOverlayClick` | `boolean` | `true` | Close on overlay click. |
43
+ | `closeOnEscape` | `boolean` | `true` | Close on Escape. |
44
+ | `size` | `"sm" \| "md" \| "lg" \| "xl" \| "fullscreen"` | `"md"` | Modal width. |
45
+ | `radius` | `"none" \| "sm" \| "md" \| "lg"` | `"md"` | Modal border-radius. |
46
+ | `overlayClassName` | `string` | | Extra overlay class. |
47
+ | `allowContentOverflow` | `boolean` | `false` | Allow content to overflow. |
48
+ | `className` | `string` | | Custom class name. |
49
+
50
+ ### Modal.Header
51
+
52
+ | Prop | Type | Default | Description |
53
+ | --- | --- | --- | --- |
54
+ | `showCloseButton` | `boolean` | `true` | Show close icon. |
55
+ | `closeButtonAriaLabel` | `string` | `"Close modal"` | Close button aria label. |
56
+ | `rightContent` | `ReactNode` | | Right-side header content. |
57
+ | `transparent` | `boolean` | `false` | Overlay header style. |
58
+ | `onClose` | `() => void` | _Required_ | Close handler. |
59
+
60
+ ### Modal.Title
61
+
62
+ A `h2` element for the modal title. Should be used for short, attention-grabbing titles.
63
+
64
+ ### Modal.Subhead
65
+
66
+ A `h3` element for the modal subhead. Use this to provide additional context or information that supports the main title. It appears directly below the `Modal.Title`.
67
+
68
+ ### Modal.Content
69
+
70
+ The main content area of the modal. This is where the primary information or task-related components should be placed. It automatically handles vertical scrolling for oversized content.
71
+
72
+ ### Modal.Footer
73
+
74
+ | Prop | Type | Default | Description |
75
+ | --- | --- | --- | --- |
76
+ | `primaryButton` | `ModalButtonProps` | | The primary action button, typically for confirming an action. |
77
+ | `secondaryButton` | `ModalButtonProps` | | The secondary action button, often used for cancellation. |
78
+ | `dangerButton` | `ModalButtonProps` | | A destructive action button, styled to indicate a potentially harmful action. |
@@ -0,0 +1 @@
1
+ export { stories } from "../examples";
package/examples.tsx ADDED
@@ -0,0 +1,194 @@
1
+ import React from "react";
2
+ import { Modal } from "./src/Modal";
3
+ import type { ModalProps } from "./src/Modal.types";
4
+ import { Button } from "@solara/button";
5
+
6
+ type ModalStoryProps = ModalProps & {
7
+ headerTransparent?: boolean;
8
+ showRightContent?: boolean;
9
+ radius?: "none" | "sm" | "md" | "lg";
10
+ };
11
+
12
+ const ModalPlayground = (props: ModalStoryProps) => {
13
+ const isControlled = typeof props.isOpen === "boolean";
14
+ const [open, setOpen] = React.useState(props.isOpen ?? false);
15
+
16
+ React.useEffect(() => {
17
+ if (isControlled) {
18
+ setOpen(props.isOpen as boolean);
19
+ }
20
+ }, [isControlled, props.isOpen]);
21
+
22
+ return (
23
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
24
+ <Button label="Open modal" onClick={() => setOpen(true)} />
25
+ <Modal
26
+ isOpen={open}
27
+ onClose={() => setOpen(false)}
28
+ closeOnOverlayClick={props.closeOnOverlayClick}
29
+ closeOnEscape={props.closeOnEscape}
30
+ size={props.size}
31
+ radius={props.radius}
32
+ allowContentOverflow={props.allowContentOverflow}
33
+ >
34
+ <Modal.Header
35
+ onClose={() => setOpen(false)}
36
+ transparent={props.headerTransparent}
37
+ rightContent={
38
+ props.showRightContent ? (
39
+ <span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>
40
+ Updated today
41
+ </span>
42
+ ) : null
43
+ }
44
+ >
45
+ <Modal.Title>Invite teammates</Modal.Title>
46
+ <Modal.Subhead>This is a subhead.</Modal.Subhead>
47
+ </Modal.Header>
48
+ <Modal.Content>
49
+ <p style={{ marginTop: 0 }}>
50
+ Collect email addresses and assign a role. Invitees will receive a link to join the
51
+ project.
52
+ </p>
53
+ <ul style={{ marginBottom: 0 }}>
54
+ <li>Owner: full access</li>
55
+ <li>Editor: can update content</li>
56
+ <li>Viewer: read-only access</li>
57
+ </ul>
58
+ </Modal.Content>
59
+ <Modal.Footer
60
+ primaryButton={{
61
+ label: "Send invites",
62
+ onClick: () => setOpen(false),
63
+ }}
64
+ secondaryButton={{
65
+ label: "Cancel",
66
+ onClick: () => setOpen(false),
67
+ }}
68
+ />
69
+ </Modal>
70
+ </div>
71
+ );
72
+ };
73
+
74
+ const ModalHeaderStory = () => {
75
+ const [open, setOpen] = React.useState(false);
76
+ return (
77
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
78
+ <Button label="Open modal" onClick={() => setOpen(true)} />
79
+ <Modal isOpen={open} onClose={() => setOpen(false)} closeOnOverlayClick closeOnEscape>
80
+ <Modal.Header
81
+ onClose={() => setOpen(false)}
82
+ rightContent={
83
+ <span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>
84
+ Header only
85
+ </span>
86
+ }
87
+ >
88
+ <Modal.Title>Header example</Modal.Title>
89
+ </Modal.Header>
90
+ </Modal>
91
+ </div>
92
+ );
93
+ };
94
+
95
+ const ModalFooterStory = () => {
96
+ const [open, setOpen] = React.useState(false);
97
+ return (
98
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
99
+ <Button label="Open modal" onClick={() => setOpen(true)} />
100
+ <Modal isOpen={open} onClose={() => setOpen(false)} closeOnOverlayClick closeOnEscape>
101
+ <Modal.Footer
102
+ primaryButton={{ label: "Confirm", onClick: () => setOpen(false) }}
103
+ secondaryButton={{ label: "Cancel", onClick: () => setOpen(false) }}
104
+ />
105
+ </Modal>
106
+ </div>
107
+ );
108
+ };
109
+
110
+ export const stories = {
111
+ playground: {
112
+ title: "Playground",
113
+ description: "Adjust the Modal props to explore behavior.",
114
+ render: (props: ModalStoryProps) => <ModalPlayground {...props} />,
115
+ controls: [
116
+ { name: "size", type: "select", options: ["sm", "md", "lg", "xl", "fullscreen"] },
117
+ { name: "radius", type: "select", options: ["none", "sm", "md", "lg"] },
118
+ { name: "closeOnOverlayClick", type: "boolean" },
119
+ { name: "closeOnEscape", type: "boolean" },
120
+ { name: "allowContentOverflow", type: "boolean" },
121
+ { name: "headerTransparent", type: "boolean" },
122
+ { name: "showRightContent", type: "boolean" },
123
+ { name: "isOpen", type: "boolean" },
124
+ ],
125
+ initialProps: {
126
+ size: "md",
127
+ radius: "md",
128
+ closeOnOverlayClick: true,
129
+ closeOnEscape: true,
130
+ allowContentOverflow: false,
131
+ headerTransparent: false,
132
+ showRightContent: true,
133
+ isOpen: false,
134
+ },
135
+ },
136
+ header: {
137
+ title: "Header",
138
+ description: "Header-only configuration with close and right content.",
139
+ showProps: false,
140
+ render: () => <ModalHeaderStory />,
141
+ code: `import React from "react";
142
+ import { Button } from "@solara/button";
143
+ import { Modal } from "@solara/modal";
144
+
145
+ export function Example() {
146
+ const [open, setOpen] = React.useState(false);
147
+
148
+ return (
149
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
150
+ <Button label="Open modal" onClick={() => setOpen(true)} />
151
+ <Modal isOpen={open} onClose={() => setOpen(false)} closeOnOverlayClick closeOnEscape>
152
+ <Modal.Header
153
+ onClose={() => setOpen(false)}
154
+ rightContent={
155
+ <span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>
156
+ Header only
157
+ </span>
158
+ }
159
+ >
160
+ <Modal.Title>Header example</Modal.Title>
161
+ </Modal.Header>
162
+ </Modal>
163
+ </div>
164
+ );
165
+ }
166
+ `,
167
+ },
168
+ footer: {
169
+ title: "Footer",
170
+ description: "Footer-only configuration with primary/secondary actions.",
171
+ showProps: false,
172
+ render: () => <ModalFooterStory />,
173
+ code: `import React from "react";
174
+ import { Button } from "@solara/button";
175
+ import { Modal } from "@solara/modal";
176
+
177
+ export function Example() {
178
+ const [open, setOpen] = React.useState(false);
179
+
180
+ return (
181
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
182
+ <Button label="Open modal" onClick={() => setOpen(true)} />
183
+ <Modal isOpen={open} onClose={() => setOpen(false)} closeOnOverlayClick closeOnEscape>
184
+ <Modal.Footer
185
+ primaryButton={{ label: "Confirm", onClick: () => setOpen(false) }}
186
+ secondaryButton={{ label: "Cancel", onClick: () => setOpen(false) }}
187
+ />
188
+ </Modal>
189
+ </div>
190
+ );
191
+ }
192
+ `,
193
+ },
194
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@varialkit/modal",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples/index.tsx"
10
+ },
11
+ "dependencies": {
12
+ "@varialkit/button": "0.1.1"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "docs.md",
17
+ "examples",
18
+ "examples.tsx"
19
+ ],
20
+ "peerDependencies": {
21
+ "react": "^19.0.0",
22
+ "react-dom": "^19.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "19.0.10",
26
+ "react": "19.0.0",
27
+ "react-dom": "19.0.0"
28
+ }
29
+ }
package/src/Modal.scss ADDED
@@ -0,0 +1,284 @@
1
+ @keyframes solara-modal-fade-in {
2
+ from {
3
+ opacity: 0;
4
+ }
5
+
6
+ to {
7
+ opacity: 1;
8
+ }
9
+ }
10
+
11
+ @keyframes solara-modal-slide-in {
12
+ from {
13
+ opacity: 0;
14
+ transform: translateY(14px) scale(0.98);
15
+ }
16
+
17
+ to {
18
+ opacity: 1;
19
+ transform: translateY(0) scale(1);
20
+ }
21
+ }
22
+
23
+ .solara-modal__overlay {
24
+ position: fixed;
25
+ inset: 0;
26
+ background-color: rgba(var(--overlay-backdrop-rgb), var(--overlay-backdrop-opacity));
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ padding: calc(var(--space-4) * var(--spacing-multiplier));
31
+ z-index: 3000;
32
+ backdrop-filter: blur(var(--overlay-backdrop-blur));
33
+ animation: solara-modal-fade-in 0.2s ease-out;
34
+
35
+ @media (prefers-reduced-motion: reduce) {
36
+ animation: none;
37
+ }
38
+
39
+ // Remove padding when the modal is in fullscreen mode.
40
+ &--fullscreen {
41
+ padding: 0;
42
+ }
43
+ }
44
+
45
+ .solara-modal {
46
+ --modal-surface-color: var(--color-surface-100);
47
+ --modal-surface-color-rgb: var(--color-surface-100-rgb);
48
+ --surface-border-color: var(--color-divider-secondary);
49
+ --surface-border-color-rgb: var(--color-divider-secondary-rgb);
50
+ --surface-opacity: 1;
51
+ --surface-blur: 0px;
52
+ --surface-shadow: var(--shadow-md);
53
+ background-color: rgba(var(--modal-surface-color-rgb), var(--surface-opacity));
54
+ border: 1px solid var(--surface-border-color);
55
+ box-shadow: var(--surface-shadow);
56
+ backdrop-filter: blur(var(--surface-blur));
57
+ --modal-border-radius: calc(var(--radius-4) * var(--radius-multiplier));
58
+ border-radius: var(--modal-border-radius);
59
+ width: 100%;
60
+ // Default width is smaller now.
61
+ max-width: 60vw;
62
+ max-height: calc(100vh - 2rem);
63
+ display: flex;
64
+ flex-direction: column;
65
+ overflow: hidden;
66
+ animation: solara-modal-slide-in 0.24s ease-out;
67
+
68
+ @media (prefers-reduced-motion: reduce) {
69
+ animation: none;
70
+ }
71
+ }
72
+
73
+ .solara-modal--overflow {
74
+ overflow: visible;
75
+ }
76
+
77
+ .solara-modal--size-sm {
78
+ max-width: 28rem;
79
+ }
80
+
81
+ .solara-modal--size-md {
82
+ max-width: 36rem;
83
+ }
84
+
85
+ .solara-modal--size-lg {
86
+ max-width: 48rem;
87
+ }
88
+
89
+ .solara-modal--size-xl {
90
+ max-width: 64rem;
91
+ }
92
+
93
+ // Fullscreen variant fills the entire viewport.
94
+ .solara-modal--size-fullscreen {
95
+ width: 100vw;
96
+ max-width: 100vw;
97
+ height: 100vh;
98
+ max-height: 100vh;
99
+ border-radius: 0;
100
+ }
101
+
102
+ .solara-modal--radius-none {
103
+ --modal-border-radius: 0;
104
+ }
105
+
106
+ .solara-modal--radius-sm {
107
+ --modal-border-radius: calc(var(--radius-2) * var(--radius-multiplier));
108
+ }
109
+
110
+ .solara-modal--radius-md {
111
+ --modal-border-radius: calc(var(--radius-4) * var(--radius-multiplier));
112
+ }
113
+
114
+ .solara-modal--radius-lg {
115
+ --modal-border-radius: calc(var(--radius-8) * var(--radius-multiplier));
116
+ }
117
+
118
+ @media (max-width: 640px) {
119
+ .solara-modal {
120
+ max-width: calc(100vw - 1rem);
121
+ max-height: calc(100vh - 1rem);
122
+ }
123
+ }
124
+
125
+ .solara-modal__header {
126
+ padding: calc(var(--space-4) * var(--spacing-multiplier)) calc(var(--space-5) * var(--spacing-multiplier));
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: space-between;
130
+ border-bottom: 1px solid var(--surface-border-color);
131
+ gap: calc(var(--space-3) * var(--spacing-multiplier));
132
+ min-height: 60px;
133
+ border-top-left-radius: var(--modal-border-radius);
134
+ border-top-right-radius: var(--modal-border-radius);
135
+ }
136
+
137
+ .solara-modal__header--transparent {
138
+ background: transparent;
139
+ border-bottom: none;
140
+ position: absolute;
141
+ top: 0;
142
+ left: 0;
143
+ right: 0;
144
+ z-index: 5;
145
+ }
146
+
147
+ .solara-modal__header-content {
148
+ flex: 1;
149
+ min-width: 0;
150
+ // Vertically center the title and subhead.
151
+ display: flex;
152
+ flex-direction: column;
153
+ justify-content: center;
154
+ }
155
+
156
+ .solara-modal__header-actions {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
160
+ }
161
+
162
+ .solara-modal__title {
163
+ margin: 0;
164
+ font-size: var(--font-size-h5-scaled);
165
+ line-height: var(--line-height-body-scaled);
166
+ font-weight: 600;
167
+ color: var(--color-text-primary);
168
+ }
169
+
170
+ // Subhead provides additional context to the title.
171
+ .solara-modal__subhead {
172
+ margin: 0;
173
+ font-size: var(--font-size-sm-scaled);
174
+ line-height: var(--line-height-body-scaled);
175
+ font-weight: 400;
176
+ color: var(--color-text-secondary);
177
+ }
178
+
179
+ .solara-modal__close {
180
+ color: var(--color-text-secondary);
181
+ transition: color 0.2s ease, background-color 0.2s ease;
182
+ border-radius: 9999px;
183
+ width: 32px;
184
+ height: 32px;
185
+ display: flex;
186
+ align-items: center;
187
+ justify-content: center;
188
+ background: none;
189
+ border: none;
190
+ cursor: pointer;
191
+ padding: 0;
192
+ }
193
+
194
+ .solara-modal__close:hover,
195
+ .solara-modal__close:focus-visible {
196
+ color: var(--color-text-primary);
197
+ background-color: var(--color-surface-200);
198
+ outline: none;
199
+ }
200
+
201
+ .solara-modal__content {
202
+ padding: calc(var(--space-4) * var(--spacing-multiplier)) calc(var(--space-5) * var(--spacing-multiplier));
203
+ overflow-y: auto;
204
+ overflow-x: visible;
205
+ flex: 1;
206
+ color: var(--color-text-secondary);
207
+ }
208
+
209
+ .solara-modal__content--overflow {
210
+ overflow: visible;
211
+ }
212
+
213
+ .solara-modal__footer {
214
+ padding: calc(var(--space-4) * var(--spacing-multiplier)) calc(var(--space-5) * var(--spacing-multiplier));
215
+ border-top: 1px solid var(--surface-border-color);
216
+ background-color: rgba(var(--modal-surface-color-rgb), var(--surface-opacity));
217
+ border-bottom-left-radius: var(--modal-border-radius);
218
+ border-bottom-right-radius: var(--modal-border-radius);
219
+ }
220
+
221
+ :root[data-surface-default="translucent"] .solara-modal,
222
+ :root[data-surface-modal="translucent"] .solara-modal,
223
+ .solara-modal[data-surface-style="translucent"] {
224
+ --surface-opacity: var(--opacity-translucent-medium);
225
+ --surface-blur: 0px;
226
+ --modal-surface-color-rgb: var(--surface-translucent-tint-rgb);
227
+ }
228
+
229
+ :root[data-surface-default="glass"] .solara-modal,
230
+ :root[data-surface-modal="glass"] .solara-modal,
231
+ .solara-modal[data-surface-style="glass"] {
232
+ --surface-opacity: var(--opacity-translucent-heavy);
233
+ --surface-blur: var(--blur-medium);
234
+ --surface-border-color: rgba(var(--surface-border-color-rgb), var(--surface-border-alpha-glass));
235
+ --modal-surface-color-rgb: var(--surface-translucent-tint-rgb);
236
+ }
237
+
238
+ .solara-modal[data-surface-style="solid"] {
239
+ --surface-opacity: 1;
240
+ --surface-blur: 0px;
241
+ --surface-border-color: var(--color-divider-secondary);
242
+ }
243
+
244
+ :root[data-surface-modal="solid"] .solara-modal {
245
+ --surface-opacity: 1;
246
+ --surface-blur: 0px;
247
+ --surface-border-color: var(--color-divider-secondary);
248
+ }
249
+
250
+ :root[data-surface-default="translucent"] .solara-modal__footer,
251
+ :root[data-surface-modal="translucent"] .solara-modal__footer,
252
+ .solara-modal[data-surface-style="translucent"] .solara-modal__footer,
253
+ :root[data-surface-default="glass"] .solara-modal__footer,
254
+ :root[data-surface-modal="glass"] .solara-modal__footer,
255
+ .solara-modal[data-surface-style="glass"] .solara-modal__footer {
256
+ background-color: transparent;
257
+ }
258
+
259
+ :root[data-surface-default="translucent"] .solara-modal,
260
+ :root[data-surface-modal="translucent"] .solara-modal,
261
+ .solara-modal[data-surface-style="translucent"],
262
+ :root[data-surface-default="glass"] .solara-modal,
263
+ :root[data-surface-modal="glass"] .solara-modal,
264
+ .solara-modal[data-surface-style="glass"] {
265
+ overflow: hidden;
266
+ }
267
+
268
+ .solara-modal__footer-actions {
269
+ display: flex;
270
+ justify-content: flex-end;
271
+ gap: calc(var(--space-3) * var(--spacing-multiplier));
272
+ flex-wrap: wrap;
273
+ }
274
+
275
+ @media (max-width: 640px) {
276
+ .solara-modal__footer-actions {
277
+ flex-direction: column;
278
+ width: 100%;
279
+ }
280
+
281
+ .solara-modal__footer-actions>* {
282
+ width: 100%;
283
+ }
284
+ }
package/src/Modal.tsx ADDED
@@ -0,0 +1,293 @@
1
+ "use client";
2
+
3
+ import React, { createContext, forwardRef, useEffect, useMemo, useRef } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { Button } from "@solara/button";
6
+ import type {
7
+ ModalButtonProps,
8
+ ModalContentProps,
9
+ ModalFooterProps,
10
+ ModalHeaderProps,
11
+ ModalProps,
12
+ ModalRadius,
13
+ ModalTitleProps,
14
+ ModalSubheadProps,
15
+ } from "./Modal.types";
16
+ import "./Modal.scss";
17
+
18
+ const classNames = (...classes: Array<string | undefined | false | null>) =>
19
+ classes.filter(Boolean).join(" ");
20
+
21
+ const ModalContext = createContext<{ allowContentOverflow?: boolean }>({});
22
+
23
+ const CloseIcon = () => (
24
+ <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">
25
+ <path
26
+ d="M5 5l10 10M15 5L5 15"
27
+ stroke="currentColor"
28
+ strokeWidth="1.6"
29
+ strokeLinecap="round"
30
+ />
31
+ </svg>
32
+ );
33
+
34
+ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
35
+ (
36
+ {
37
+ className,
38
+ children,
39
+ showCloseButton = true,
40
+ closeButtonAriaLabel = "Close modal",
41
+ rightContent,
42
+ transparent = false,
43
+ onClose,
44
+ ...props
45
+ },
46
+ ref
47
+ ) => (
48
+ <div
49
+ className={classNames(
50
+ "solara-modal__header",
51
+ transparent ? "solara-modal__header--transparent" : undefined,
52
+ className
53
+ )}
54
+ ref={ref}
55
+ {...props}
56
+ >
57
+ <div className="solara-modal__header-content">{children}</div>
58
+ <div className="solara-modal__header-actions">
59
+ {rightContent}
60
+ {showCloseButton ? (
61
+ <button
62
+ type="button"
63
+ onClick={onClose}
64
+ aria-label={closeButtonAriaLabel}
65
+ className="solara-modal__close"
66
+ >
67
+ <CloseIcon />
68
+ </button>
69
+ ) : null}
70
+ </div>
71
+ </div>
72
+ )
73
+ );
74
+
75
+ const ModalTitle = forwardRef<HTMLHeadingElement, ModalTitleProps>(
76
+ ({ className, ...props }, ref) => (
77
+ <h2 className={classNames("solara-modal__title", className)} ref={ref} {...props} />
78
+ )
79
+ );
80
+
81
+ /**
82
+ * A subhead for the modal, rendered as an `h3` element.
83
+ * This should be used for providing additional context to the modal's title.
84
+ */
85
+ const ModalSubhead = forwardRef<HTMLHeadingElement, ModalSubheadProps>(
86
+ ({ className, ...props }, ref) => (
87
+ <h3
88
+ className={classNames("solara-modal__subhead", className)}
89
+ ref={ref}
90
+ {...props}
91
+ />
92
+ )
93
+ );
94
+
95
+ const ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(
96
+ ({ className, ...props }, ref) => {
97
+ const { allowContentOverflow } = React.useContext(ModalContext);
98
+
99
+ return (
100
+ <div
101
+ className={classNames(
102
+ "solara-modal__content",
103
+ allowContentOverflow ? "solara-modal__content--overflow" : undefined,
104
+ className
105
+ )}
106
+ ref={ref}
107
+ {...props}
108
+ />
109
+ );
110
+ }
111
+ );
112
+
113
+ const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(
114
+ ({ className, primaryButton, secondaryButton, dangerButton, ...props }, ref) => {
115
+ const renderButton = (button: ModalButtonProps, kind: "primary" | "secondary" | "danger") => {
116
+ const variant = kind === "primary" ? "primary" : "default";
117
+ const destructive = kind === "danger";
118
+ const isLoading = Boolean(button.loading);
119
+ const label = isLoading && button.loadingText ? button.loadingText : button.label;
120
+ const style = button.fullWidth ? { width: "100%" } : undefined;
121
+
122
+ return (
123
+ <Button
124
+ key={button.label}
125
+ type="button"
126
+ variant={variant}
127
+ destructive={destructive}
128
+ radius={button.radius}
129
+ onClick={button.onClick}
130
+ disabled={button.disabled || isLoading}
131
+ style={style}
132
+ label={label}
133
+ />
134
+ );
135
+ };
136
+
137
+ return (
138
+ <div className={classNames("solara-modal__footer", className)} ref={ref} {...props}>
139
+ <div className="solara-modal__footer-actions">
140
+ {secondaryButton ? renderButton(secondaryButton, "secondary") : null}
141
+ {dangerButton ? renderButton(dangerButton, "danger") : null}
142
+ {primaryButton ? renderButton(primaryButton, "primary") : null}
143
+ </div>
144
+ </div>
145
+ );
146
+ }
147
+ );
148
+
149
+ const ModalComponent = forwardRef<HTMLDivElement, ModalProps>(
150
+ (
151
+ {
152
+ isOpen,
153
+ onClose,
154
+ children,
155
+ closeOnOverlayClick = true,
156
+ closeOnEscape = true,
157
+ size = "md",
158
+ radius = "md",
159
+ className,
160
+ overlayClassName,
161
+ allowContentOverflow = false,
162
+ surfaceStyle,
163
+ style,
164
+ ...props
165
+ },
166
+ ref
167
+ ) => {
168
+ const modalRef = useRef<HTMLDivElement>(null);
169
+ React.useImperativeHandle(ref, () => modalRef.current as HTMLDivElement);
170
+
171
+ useEffect(() => {
172
+ if (!isOpen) return;
173
+ const originalOverflow = document.body.style.overflow;
174
+ document.body.style.overflow = "hidden";
175
+ return () => {
176
+ document.body.style.overflow = originalOverflow;
177
+ };
178
+ }, [isOpen]);
179
+
180
+ useEffect(() => {
181
+ if (!isOpen || !closeOnEscape) return;
182
+ const handleEscape = (event: KeyboardEvent) => {
183
+ if (event.key === "Escape") onClose();
184
+ };
185
+ document.addEventListener("keydown", handleEscape);
186
+ return () => document.removeEventListener("keydown", handleEscape);
187
+ }, [isOpen, closeOnEscape, onClose]);
188
+
189
+ const contextValue = useMemo(
190
+ () => ({
191
+ allowContentOverflow,
192
+ }),
193
+ [allowContentOverflow]
194
+ );
195
+
196
+ if (!isOpen) return null;
197
+
198
+ // Surface styling is controlled via data attributes so theme defaults remain centralized.
199
+ const surfaceAttributes: Record<string, string | undefined> = {};
200
+ const surfaceStyleVars: React.CSSProperties = {};
201
+
202
+ if (surfaceStyle) {
203
+ if (typeof surfaceStyle === "string") {
204
+ surfaceAttributes["data-surface-style"] = surfaceStyle;
205
+ } else {
206
+ surfaceAttributes["data-surface-style"] = "custom";
207
+ if (surfaceStyle.opacity !== undefined) {
208
+ (surfaceStyleVars as any)["--surface-opacity"] =
209
+ surfaceStyle.opacity.toString();
210
+ }
211
+ if (surfaceStyle.blur !== undefined) {
212
+ (surfaceStyleVars as any)["--surface-blur"] = `${surfaceStyle.blur}px`;
213
+ }
214
+ if (surfaceStyle.borderColor !== undefined) {
215
+ (surfaceStyleVars as any)["--surface-border-color"] = surfaceStyle.borderColor;
216
+ }
217
+ if (surfaceStyle.shadow !== undefined) {
218
+ (surfaceStyleVars as any)["--surface-shadow"] = surfaceStyle.shadow;
219
+ }
220
+ }
221
+ }
222
+
223
+ return createPortal(
224
+ <div
225
+ className={classNames(
226
+ "solara-modal__overlay",
227
+ // When the modal is fullscreen, we apply a specific class to the overlay
228
+ // to remove the padding and allow the modal to fill the entire viewport.
229
+ size === "fullscreen" ? "solara-modal__overlay--fullscreen" : undefined,
230
+ overlayClassName
231
+ )}
232
+ onClick={closeOnOverlayClick ? onClose : undefined}
233
+ >
234
+ <div
235
+ ref={modalRef}
236
+ className={classNames(
237
+ "solara-modal",
238
+ `solara-modal--size-${size}`,
239
+ `solara-modal--radius-${radius}`,
240
+ allowContentOverflow ? "solara-modal--overflow" : undefined,
241
+ className
242
+ )}
243
+ style={{ ...surfaceStyleVars, ...style }}
244
+ role="dialog"
245
+ aria-modal="true"
246
+ onClick={(event) => event.stopPropagation()}
247
+ {...surfaceAttributes}
248
+ {...props}
249
+ >
250
+ <ModalContext.Provider value={contextValue}>{children}</ModalContext.Provider>
251
+ </div>
252
+ </div>,
253
+ document.body
254
+ );
255
+ }
256
+ );
257
+
258
+ ModalComponent.displayName = "Modal";
259
+ ModalHeader.displayName = "Modal.Header";
260
+ ModalTitle.displayName = "Modal.Title";
261
+ ModalSubhead.displayName = "Modal.Subhead";
262
+ ModalContent.displayName = "Modal.Content";
263
+ ModalFooter.displayName = "Modal.Footer";
264
+
265
+ interface ModalCompoundComponent
266
+ extends React.ForwardRefExoticComponent<ModalProps & React.RefAttributes<HTMLDivElement>> {
267
+ Header: typeof ModalHeader;
268
+ Title: typeof ModalTitle;
269
+ Subhead: typeof ModalSubhead;
270
+ Content: typeof ModalContent;
271
+ Footer: typeof ModalFooter;
272
+ }
273
+
274
+ const Modal = Object.assign(ModalComponent, {
275
+ Header: ModalHeader,
276
+ Title: ModalTitle,
277
+ Subhead: ModalSubhead,
278
+ Content: ModalContent,
279
+ Footer: ModalFooter,
280
+ });
281
+
282
+ export { Modal, ModalHeader, ModalTitle, ModalSubhead, ModalContent, ModalFooter };
283
+ export type {
284
+ ModalProps,
285
+ ModalHeaderProps,
286
+ ModalTitleProps,
287
+ ModalSubheadProps,
288
+ ModalContentProps,
289
+ ModalFooterProps,
290
+ ModalButtonProps,
291
+ };
292
+
293
+ export default Modal as ModalCompoundComponent;
@@ -0,0 +1,78 @@
1
+ import type React from "react";
2
+
3
+ export type ModalSize = "sm" | "md" | "lg" | "xl" | "fullscreen";
4
+
5
+ export interface ModalButtonProps {
6
+ label: string;
7
+ variant?: "primary" | "secondary" | "danger";
8
+ onClick: (event?: React.MouseEvent<HTMLButtonElement>) => void;
9
+ disabled?: boolean;
10
+ loading?: boolean;
11
+ loadingText?: string;
12
+ fullWidth?: boolean;
13
+ radius?: "default" | "none" | "full";
14
+ }
15
+
16
+ export interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
17
+ /** Whether to show the close button. */
18
+ showCloseButton?: boolean;
19
+ /** Aria label for the close button. */
20
+ closeButtonAriaLabel?: string;
21
+ /** Content to be rendered on the right side of the header. */
22
+ rightContent?: React.ReactNode;
23
+ /** Renders the header as a transparent overlay above content. */
24
+ transparent?: boolean;
25
+ /** Called when the close button is pressed. */
26
+ onClose: () => void;
27
+ }
28
+
29
+ export interface ModalTitleProps extends React.HTMLAttributes<HTMLHeadingElement> { }
30
+
31
+ export interface ModalSubheadProps extends React.HTMLAttributes<HTMLHeadingElement> { }
32
+
33
+ export interface ModalContentProps extends React.HTMLAttributes<HTMLDivElement> { }
34
+
35
+ export interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
36
+ /** Primary button configuration. */
37
+ primaryButton?: ModalButtonProps;
38
+ /** Secondary button configuration. */
39
+ secondaryButton?: ModalButtonProps;
40
+ /** Danger button configuration. */
41
+ dangerButton?: ModalButtonProps;
42
+ }
43
+
44
+ export type ModalRadius = "none" | "sm" | "md" | "lg";
45
+
46
+ export type ModalSurfaceStyle =
47
+ | "solid"
48
+ | "translucent"
49
+ | "glass"
50
+ | {
51
+ opacity?: number;
52
+ blur?: number;
53
+ borderColor?: string;
54
+ shadow?: string;
55
+ };
56
+
57
+ export interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
58
+ /** Whether the modal is open. */
59
+ isOpen: boolean;
60
+ /** Callback when modal is requested to close. */
61
+ onClose: () => void;
62
+ /** Whether to close the modal when clicking outside. */
63
+ closeOnOverlayClick?: boolean;
64
+ /** Whether to close the modal when pressing escape. */
65
+ closeOnEscape?: boolean;
66
+ /** Size of the modal. */
67
+ size?: ModalSize;
68
+ /** Radius of the modal */
69
+ radius?: ModalRadius;
70
+ /** Controls surface material treatment without changing layout tokens. */
71
+ surfaceStyle?: ModalSurfaceStyle;
72
+ /** Custom class name for the modal overlay. */
73
+ overlayClassName?: string;
74
+ /** Whether to allow content overflow (useful for dropdowns). */
75
+ allowContentOverflow?: boolean;
76
+ /** Modal children content. */
77
+ children?: React.ReactNode;
78
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { Modal } from "./Modal";
2
+ export type {
3
+ ModalProps,
4
+ ModalHeaderProps,
5
+ ModalTitleProps,
6
+ ModalContentProps,
7
+ ModalFooterProps,
8
+ ModalButtonProps,
9
+ ModalSize,
10
+ } from "./Modal.types";