@varialkit/modal 0.1.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/docs.md +78 -0
- package/examples/index.tsx +1 -0
- package/examples.tsx +194 -0
- package/package.json +29 -0
- package/src/Modal.scss +284 -0
- package/src/Modal.tsx +293 -0
- package/src/Modal.types.ts +78 -0
- package/src/index.ts +10 -0
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.0",
|
|
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.0"
|
|
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
|
+
}
|