entity-react-modals 1.0.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/LICENSE +21 -0
- package/README.md +327 -0
- package/dist/index.cjs +353 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +65 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +345 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +99 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bazil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# entity-react-modals
|
|
2
|
+
|
|
3
|
+
Zero-dependency React modal library with spring physics animations powered by the Web Animations API.
|
|
4
|
+
|
|
5
|
+
- No animation libraries required — uses native WAAPI
|
|
6
|
+
- Compound component API for flexible composition
|
|
7
|
+
- 5 built-in spring presets + custom spring configs
|
|
8
|
+
- Controlled and uncontrolled modes
|
|
9
|
+
- Portal-rendered, accessible, and tree-shakable
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install entity-react-modals
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import {
|
|
21
|
+
Modal,
|
|
22
|
+
ModalTrigger,
|
|
23
|
+
ModalContent,
|
|
24
|
+
ModalHeader,
|
|
25
|
+
ModalBody,
|
|
26
|
+
ModalFooter,
|
|
27
|
+
ModalClose,
|
|
28
|
+
} from 'entity-react-modals';
|
|
29
|
+
import 'entity-react-modals/styles.css';
|
|
30
|
+
|
|
31
|
+
function App() {
|
|
32
|
+
return (
|
|
33
|
+
<Modal>
|
|
34
|
+
<ModalTrigger>
|
|
35
|
+
<button>Open Modal</button>
|
|
36
|
+
</ModalTrigger>
|
|
37
|
+
<ModalContent>
|
|
38
|
+
<ModalHeader>
|
|
39
|
+
<h2>Hello</h2>
|
|
40
|
+
<ModalClose />
|
|
41
|
+
</ModalHeader>
|
|
42
|
+
<ModalBody>
|
|
43
|
+
<p>This modal uses spring physics for smooth animations.</p>
|
|
44
|
+
</ModalBody>
|
|
45
|
+
<ModalFooter>
|
|
46
|
+
<ModalClose>Cancel</ModalClose>
|
|
47
|
+
<button>Confirm</button>
|
|
48
|
+
</ModalFooter>
|
|
49
|
+
</ModalContent>
|
|
50
|
+
</Modal>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Components
|
|
56
|
+
|
|
57
|
+
| Component | Purpose |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `Modal` | Root provider. Manages open/close state and passes config to children via context. |
|
|
60
|
+
| `ModalTrigger` | Wraps an element to open the modal on click. Clones child and injects `onClick`. |
|
|
61
|
+
| `ModalContent` | The dialog panel. Renders via portal to `document.body`. Handles animations, backdrop, escape, click-outside. |
|
|
62
|
+
| `ModalHeader` | Layout wrapper for the header section. |
|
|
63
|
+
| `ModalBody` | Layout wrapper for the body content. |
|
|
64
|
+
| `ModalFooter` | Layout wrapper for the footer actions. |
|
|
65
|
+
| `ModalClose` | Button that closes the modal. Defaults to `×` character. |
|
|
66
|
+
|
|
67
|
+
## Props
|
|
68
|
+
|
|
69
|
+
### Modal
|
|
70
|
+
|
|
71
|
+
| Prop | Type | Default | Description |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| `children` | `ReactNode` | — | Must contain `ModalContent` and optionally `ModalTrigger`. |
|
|
74
|
+
| `open` | `boolean` | — | Controlled open state. |
|
|
75
|
+
| `defaultOpen` | `boolean` | `false` | Initial open state for uncontrolled mode. |
|
|
76
|
+
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes. |
|
|
77
|
+
| `spring` | `SpringPreset \| SpringConfig` | `'default'` | Spring animation config. |
|
|
78
|
+
| `backdropColor` | `string` | `'rgba(0,0,0,0.6)'` | Backdrop background color. |
|
|
79
|
+
| `backdropBlur` | `number` | `4` | Backdrop blur radius in px. |
|
|
80
|
+
| `size` | `'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Panel max-width preset. |
|
|
81
|
+
| `animationDuration` | `number` | — | Override computed spring duration in ms. |
|
|
82
|
+
|
|
83
|
+
### ModalContent
|
|
84
|
+
|
|
85
|
+
Props set on `ModalContent` override those set on `Modal` for that specific content panel.
|
|
86
|
+
|
|
87
|
+
| Prop | Type | Default | Description |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| `children` | `ReactNode` | — | Modal panel content. |
|
|
90
|
+
| `size` | `'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Panel max-width. |
|
|
91
|
+
| `closeOnBackdropClick` | `boolean` | `true` | Close when clicking the backdrop. |
|
|
92
|
+
| `closeOnEscape` | `boolean` | `true` | Close on Escape key press. |
|
|
93
|
+
| `backdropColor` | `string` | — | Override backdrop color. |
|
|
94
|
+
| `backdropBlur` | `number` | — | Override backdrop blur. |
|
|
95
|
+
| `spring` | `SpringPreset \| SpringConfig` | — | Override spring config. |
|
|
96
|
+
| `animationDuration` | `number` | — | Override animation duration. |
|
|
97
|
+
|
|
98
|
+
### ModalClose
|
|
99
|
+
|
|
100
|
+
| Prop | Type | Default | Description |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| `children` | `ReactNode` | `'×'` | Button content. |
|
|
103
|
+
| `className` | `string` | — | Additional CSS class. |
|
|
104
|
+
|
|
105
|
+
### ModalHeader, ModalBody, ModalFooter
|
|
106
|
+
|
|
107
|
+
| Prop | Type | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `children` | `ReactNode` | Section content. |
|
|
110
|
+
| `className` | `string` | Additional CSS class. |
|
|
111
|
+
|
|
112
|
+
## Sizes
|
|
113
|
+
|
|
114
|
+
| Size | Max Width |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `sm` | 400px |
|
|
117
|
+
| `md` | 560px |
|
|
118
|
+
| `lg` | 720px |
|
|
119
|
+
| `xl` | 900px |
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<ModalContent size="lg">...</ModalContent>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Spring Presets
|
|
126
|
+
|
|
127
|
+
| Preset | Stiffness | Damping | Mass | Feel |
|
|
128
|
+
|---|---|---|---|---|
|
|
129
|
+
| `default` | 100 | 10 | 1 | Moderate bounce, balanced |
|
|
130
|
+
| `gentle` | 100 | 20 | 1 | Critically damped, no overshoot |
|
|
131
|
+
| `wobbly` | 200 | 10 | 1 | Playful, lots of bounce |
|
|
132
|
+
| `stiff` | 400 | 30 | 1 | Snappy, quick settle |
|
|
133
|
+
| `slow` | 50 | 20 | 1 | Heavy, deliberate |
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
<ModalContent spring="wobbly">...</ModalContent>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Custom Spring Config
|
|
140
|
+
|
|
141
|
+
Pass a `SpringConfig` object to fine-tune the animation:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<ModalContent spring={{ stiffness: 300, damping: 15, mass: 0.8 }}>
|
|
145
|
+
...
|
|
146
|
+
</ModalContent>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Parameter | Description |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `stiffness` | Spring stiffness. Higher = faster response. |
|
|
152
|
+
| `damping` | Damping force. Higher = less oscillation. |
|
|
153
|
+
| `mass` | Mass of the animated object. Lower = lighter feel. |
|
|
154
|
+
|
|
155
|
+
Spring config can also be set on the `Modal` root and it applies to all content panels:
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
<Modal spring={{ stiffness: 300, damping: 15, mass: 0.8 }}>
|
|
159
|
+
<ModalTrigger>...</ModalTrigger>
|
|
160
|
+
<ModalContent>...</ModalContent>
|
|
161
|
+
</Modal>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Controlled Mode
|
|
165
|
+
|
|
166
|
+
Manage open/close state externally with `useState`:
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
function ControlledModal() {
|
|
170
|
+
const [open, setOpen] = useState(false);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<>
|
|
174
|
+
<button onClick={() => setOpen(true)}>Open</button>
|
|
175
|
+
<Modal open={open} onOpenChange={setOpen}>
|
|
176
|
+
<ModalContent>
|
|
177
|
+
<ModalHeader>
|
|
178
|
+
<h2>Controlled</h2>
|
|
179
|
+
<ModalClose />
|
|
180
|
+
</ModalHeader>
|
|
181
|
+
<ModalBody>
|
|
182
|
+
<p>State is managed by the parent.</p>
|
|
183
|
+
<p>Status: {open ? 'Open' : 'Closed'}</p>
|
|
184
|
+
</ModalBody>
|
|
185
|
+
<ModalFooter>
|
|
186
|
+
<button onClick={() => setOpen(false)}>Close</button>
|
|
187
|
+
</ModalFooter>
|
|
188
|
+
</ModalContent>
|
|
189
|
+
</Modal>
|
|
190
|
+
</>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Uncontrolled Mode
|
|
196
|
+
|
|
197
|
+
Use `defaultOpen` for simple cases where you don't need external state:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
<Modal defaultOpen={false}>
|
|
201
|
+
<ModalTrigger>
|
|
202
|
+
<button>Open</button>
|
|
203
|
+
</ModalTrigger>
|
|
204
|
+
<ModalContent>
|
|
205
|
+
<ModalHeader>
|
|
206
|
+
<h2>Uncontrolled</h2>
|
|
207
|
+
<ModalClose />
|
|
208
|
+
</ModalHeader>
|
|
209
|
+
<ModalBody>
|
|
210
|
+
<p>Managed internally.</p>
|
|
211
|
+
</ModalBody>
|
|
212
|
+
</ModalContent>
|
|
213
|
+
</Modal>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Backdrop Customization
|
|
217
|
+
|
|
218
|
+
Customize the backdrop color and blur intensity:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
{/* Dark backdrop with heavy blur */}
|
|
222
|
+
<ModalContent backdropColor="rgba(0, 0, 0, 0.8)" backdropBlur={20}>
|
|
223
|
+
...
|
|
224
|
+
</ModalContent>
|
|
225
|
+
|
|
226
|
+
{/* Colored backdrop */}
|
|
227
|
+
<ModalContent backdropColor="rgba(99, 102, 241, 0.4)" backdropBlur={8}>
|
|
228
|
+
...
|
|
229
|
+
</ModalContent>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Disabling Close Behaviors
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
{/* Disable close on backdrop click */}
|
|
236
|
+
<ModalContent closeOnBackdropClick={false}>
|
|
237
|
+
...
|
|
238
|
+
</ModalContent>
|
|
239
|
+
|
|
240
|
+
{/* Disable close on Escape key */}
|
|
241
|
+
<ModalContent closeOnEscape={false}>
|
|
242
|
+
...
|
|
243
|
+
</ModalContent>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Multiple Modals
|
|
247
|
+
|
|
248
|
+
Each `Modal` provider is independent. Nest multiple modals freely:
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
function App() {
|
|
252
|
+
return (
|
|
253
|
+
<>
|
|
254
|
+
<Modal>
|
|
255
|
+
<ModalTrigger><button>First</button></ModalTrigger>
|
|
256
|
+
<ModalContent>
|
|
257
|
+
<ModalHeader><h2>First Modal</h2><ModalClose /></ModalHeader>
|
|
258
|
+
<ModalBody>Content</ModalBody>
|
|
259
|
+
</ModalContent>
|
|
260
|
+
</Modal>
|
|
261
|
+
|
|
262
|
+
<Modal>
|
|
263
|
+
<ModalTrigger><button>Second</button></ModalTrigger>
|
|
264
|
+
<ModalContent size="lg" spring="gentle">
|
|
265
|
+
<ModalHeader><h2>Second Modal</h2><ModalClose /></ModalHeader>
|
|
266
|
+
<ModalBody>Different size and spring</ModalBody>
|
|
267
|
+
</ModalContent>
|
|
268
|
+
</Modal>
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## CSS Custom Properties
|
|
275
|
+
|
|
276
|
+
Override these in your CSS to theme the modals globally:
|
|
277
|
+
|
|
278
|
+
```css
|
|
279
|
+
:root {
|
|
280
|
+
--rm-backdrop-bg: rgba(0, 0, 0, 0.6);
|
|
281
|
+
--rm-panel-bg: #ffffff;
|
|
282
|
+
--rm-panel-radius: 12px;
|
|
283
|
+
--rm-panel-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
|
284
|
+
--rm-close-color: #3b82f6;
|
|
285
|
+
--rm-close-hover: #2563eb;
|
|
286
|
+
--rm-header-border: #e2e8f0;
|
|
287
|
+
--rm-footer-border: #e2e8f0;
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Accessibility
|
|
292
|
+
|
|
293
|
+
- `role="dialog"` and `aria-modal="true"` on the panel
|
|
294
|
+
- `aria-label="Close"` on the close button
|
|
295
|
+
- Focus trapped within the modal via backdrop click and escape handling
|
|
296
|
+
- Respects `prefers-reduced-motion: reduce` — animations are skipped entirely
|
|
297
|
+
|
|
298
|
+
## TypeScript
|
|
299
|
+
|
|
300
|
+
All component props and spring types are fully typed and exported:
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
import type {
|
|
304
|
+
ModalProps,
|
|
305
|
+
ModalContentProps,
|
|
306
|
+
ModalTriggerProps,
|
|
307
|
+
ModalCloseProps,
|
|
308
|
+
ModalHeaderProps,
|
|
309
|
+
ModalBodyProps,
|
|
310
|
+
ModalFooterProps,
|
|
311
|
+
SpringConfig,
|
|
312
|
+
SpringPreset,
|
|
313
|
+
} from 'entity-react-modals';
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Tree Shaking
|
|
317
|
+
|
|
318
|
+
The package uses the `exports` field with conditional ESM/CJS builds and marks CSS as the only side effect. Bundlers will tree-shake unused components automatically.
|
|
319
|
+
|
|
320
|
+
```tsx
|
|
321
|
+
// Only imports what you use
|
|
322
|
+
import { Modal, ModalContent } from 'entity-react-modals';
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## License
|
|
326
|
+
|
|
327
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
var reactDom = require('react-dom');
|
|
6
|
+
|
|
7
|
+
// src/Modal.tsx
|
|
8
|
+
var ModalContext = react.createContext(null);
|
|
9
|
+
function useModalContext() {
|
|
10
|
+
const ctx = react.useContext(ModalContext);
|
|
11
|
+
if (!ctx) throw new Error("Modal compound components must be used within <Modal>");
|
|
12
|
+
return ctx;
|
|
13
|
+
}
|
|
14
|
+
function Modal({
|
|
15
|
+
children,
|
|
16
|
+
open: controlledOpen,
|
|
17
|
+
defaultOpen = false,
|
|
18
|
+
onOpenChange,
|
|
19
|
+
spring,
|
|
20
|
+
backdropColor,
|
|
21
|
+
backdropBlur,
|
|
22
|
+
size,
|
|
23
|
+
animationDuration
|
|
24
|
+
}) {
|
|
25
|
+
const [internalOpen, setInternalOpen] = react.useState(defaultOpen);
|
|
26
|
+
const isControlled = controlledOpen !== void 0;
|
|
27
|
+
const open = isControlled ? controlledOpen : internalOpen;
|
|
28
|
+
const onClose = react.useCallback(() => {
|
|
29
|
+
if (isControlled) {
|
|
30
|
+
onOpenChange?.(false);
|
|
31
|
+
} else {
|
|
32
|
+
setInternalOpen(false);
|
|
33
|
+
onOpenChange?.(false);
|
|
34
|
+
}
|
|
35
|
+
}, [isControlled, onOpenChange]);
|
|
36
|
+
const onOpen = react.useCallback(() => {
|
|
37
|
+
if (isControlled) {
|
|
38
|
+
onOpenChange?.(true);
|
|
39
|
+
} else {
|
|
40
|
+
setInternalOpen(true);
|
|
41
|
+
onOpenChange?.(true);
|
|
42
|
+
}
|
|
43
|
+
}, [isControlled, onOpenChange]);
|
|
44
|
+
const value = {
|
|
45
|
+
open,
|
|
46
|
+
onClose,
|
|
47
|
+
onOpen,
|
|
48
|
+
spring,
|
|
49
|
+
backdropColor,
|
|
50
|
+
backdropBlur,
|
|
51
|
+
size,
|
|
52
|
+
animationDuration
|
|
53
|
+
};
|
|
54
|
+
return /* @__PURE__ */ jsxRuntime.jsx(ModalContext.Provider, { value, children });
|
|
55
|
+
}
|
|
56
|
+
function ModalTrigger({ children }) {
|
|
57
|
+
const { onOpen } = useModalContext();
|
|
58
|
+
if (!react.isValidElement(children)) {
|
|
59
|
+
return /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: onOpen, children });
|
|
60
|
+
}
|
|
61
|
+
return react.cloneElement(children, {
|
|
62
|
+
onClick: (e) => {
|
|
63
|
+
onOpen();
|
|
64
|
+
const existing = children.props.onClick;
|
|
65
|
+
if (typeof existing === "function") existing(e);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/spring.ts
|
|
71
|
+
var PRESETS = {
|
|
72
|
+
default: { stiffness: 100, damping: 10, mass: 1 },
|
|
73
|
+
gentle: { stiffness: 100, damping: 20, mass: 1 },
|
|
74
|
+
wobbly: { stiffness: 200, damping: 10, mass: 1 },
|
|
75
|
+
stiff: { stiffness: 400, damping: 30, mass: 1 },
|
|
76
|
+
slow: { stiffness: 50, damping: 20, mass: 1 }
|
|
77
|
+
};
|
|
78
|
+
var VELOCITY_THRESHOLD = 1e-3;
|
|
79
|
+
var DISPLACEMENT_THRESHOLD = 1e-3;
|
|
80
|
+
var MAX_SIMULATION_STEPS = 1200;
|
|
81
|
+
var FIXED_DT = 1 / 120;
|
|
82
|
+
function resolveSpringConfig(input) {
|
|
83
|
+
if (!input) return PRESETS.default;
|
|
84
|
+
if (typeof input === "string") return PRESETS[input] ?? PRESETS.default;
|
|
85
|
+
return { ...PRESETS.default, ...input };
|
|
86
|
+
}
|
|
87
|
+
function isAtRest(value, velocity, target) {
|
|
88
|
+
return Math.abs(velocity) < VELOCITY_THRESHOLD && Math.abs(value - target) < DISPLACEMENT_THRESHOLD;
|
|
89
|
+
}
|
|
90
|
+
function generateSpringKeyframes(from, to, config, options) {
|
|
91
|
+
const { stiffness, damping, mass } = config;
|
|
92
|
+
const maxSteps = options?.maxDuration ? Math.min(MAX_SIMULATION_STEPS, Math.ceil(options.maxDuration / 1e3 / FIXED_DT)) : MAX_SIMULATION_STEPS;
|
|
93
|
+
let value = from;
|
|
94
|
+
let velocity = 0;
|
|
95
|
+
const positions = [from];
|
|
96
|
+
let steps = 0;
|
|
97
|
+
while (steps < maxSteps) {
|
|
98
|
+
if (isAtRest(value, velocity, to)) {
|
|
99
|
+
positions.push(to);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const displacement = value - to;
|
|
103
|
+
const springForce = -stiffness * displacement;
|
|
104
|
+
const dampingForce = -damping * velocity;
|
|
105
|
+
const acceleration = (springForce + dampingForce) / mass;
|
|
106
|
+
velocity += acceleration * FIXED_DT;
|
|
107
|
+
value += velocity * FIXED_DT;
|
|
108
|
+
positions.push(value);
|
|
109
|
+
steps++;
|
|
110
|
+
}
|
|
111
|
+
if (steps >= maxSteps) {
|
|
112
|
+
positions.push(to);
|
|
113
|
+
}
|
|
114
|
+
const duration = steps * FIXED_DT * 1e3;
|
|
115
|
+
return { keyframes: positions, duration };
|
|
116
|
+
}
|
|
117
|
+
function springToWAAPIKeyframes(fromValue, toValue, property, config, options) {
|
|
118
|
+
const { keyframes: positions, duration } = generateSpringKeyframes(fromValue, toValue, config, options);
|
|
119
|
+
const waapiKeyframes = positions.map((pos, i) => {
|
|
120
|
+
const offset = i / (positions.length - 1);
|
|
121
|
+
if (property === "opacity") {
|
|
122
|
+
return { opacity: String(pos), offset };
|
|
123
|
+
}
|
|
124
|
+
const scale = 0.95 + 0.05 * pos;
|
|
125
|
+
return {
|
|
126
|
+
transform: `scale(${scale})`,
|
|
127
|
+
opacity: String(pos),
|
|
128
|
+
offset
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
return { keyframes: waapiKeyframes, duration };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/animate.ts
|
|
135
|
+
function prefersReducedMotion() {
|
|
136
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
137
|
+
}
|
|
138
|
+
var EXIT_MAX_DURATION = 400;
|
|
139
|
+
function animateIn(backdropEl, panelEl, config) {
|
|
140
|
+
if (prefersReducedMotion()) {
|
|
141
|
+
backdropEl.style.opacity = "1";
|
|
142
|
+
panelEl.style.opacity = "1";
|
|
143
|
+
panelEl.style.transform = "scale(1)";
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const springConfig = resolveSpringConfig(config?.spring);
|
|
147
|
+
const { keyframes: backdropKF, duration: backdropDur } = springToWAAPIKeyframes(0, 1, "opacity", {
|
|
148
|
+
...springConfig,
|
|
149
|
+
stiffness: springConfig.stiffness * 0.8
|
|
150
|
+
});
|
|
151
|
+
const { keyframes: panelKF, duration: panelDur } = springToWAAPIKeyframes(0, 1, "transform", springConfig);
|
|
152
|
+
const backdropAnim = backdropEl.animate(backdropKF, {
|
|
153
|
+
duration: backdropDur,
|
|
154
|
+
easing: "linear",
|
|
155
|
+
fill: "forwards"
|
|
156
|
+
});
|
|
157
|
+
const panelAnim = panelEl.animate(panelKF, {
|
|
158
|
+
duration: panelDur,
|
|
159
|
+
easing: "linear",
|
|
160
|
+
fill: "forwards"
|
|
161
|
+
});
|
|
162
|
+
return [backdropAnim, panelAnim];
|
|
163
|
+
}
|
|
164
|
+
function animateOut(backdropEl, panelEl, config) {
|
|
165
|
+
if (prefersReducedMotion()) {
|
|
166
|
+
return Promise.resolve();
|
|
167
|
+
}
|
|
168
|
+
const springConfig = resolveSpringConfig(config?.spring);
|
|
169
|
+
const exitConfig = {
|
|
170
|
+
stiffness: springConfig.stiffness * 2,
|
|
171
|
+
damping: springConfig.damping * 1.5,
|
|
172
|
+
mass: springConfig.mass
|
|
173
|
+
};
|
|
174
|
+
const { keyframes: backdropKF, duration: backdropDur } = springToWAAPIKeyframes(1, 0, "opacity", {
|
|
175
|
+
...exitConfig,
|
|
176
|
+
stiffness: exitConfig.stiffness * 0.8
|
|
177
|
+
}, { maxDuration: EXIT_MAX_DURATION });
|
|
178
|
+
const { keyframes: panelKF, duration: panelDur } = springToWAAPIKeyframes(1, 0, "transform", exitConfig, {
|
|
179
|
+
maxDuration: EXIT_MAX_DURATION
|
|
180
|
+
});
|
|
181
|
+
const backdropAnim = backdropEl.animate(backdropKF, {
|
|
182
|
+
duration: backdropDur,
|
|
183
|
+
easing: "linear",
|
|
184
|
+
fill: "forwards"
|
|
185
|
+
});
|
|
186
|
+
const panelAnim = panelEl.animate(panelKF, {
|
|
187
|
+
duration: panelDur,
|
|
188
|
+
easing: "linear",
|
|
189
|
+
fill: "forwards"
|
|
190
|
+
});
|
|
191
|
+
return Promise.all([backdropAnim.finished, panelAnim.finished]).then(() => {
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function useEscapeKey(enabled, onEscape) {
|
|
195
|
+
react.useEffect(() => {
|
|
196
|
+
if (!enabled) return;
|
|
197
|
+
function handleKeyDown(e) {
|
|
198
|
+
if (e.key === "Escape") onEscape();
|
|
199
|
+
}
|
|
200
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
201
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
202
|
+
}, [enabled, onEscape]);
|
|
203
|
+
}
|
|
204
|
+
function useClickOutside(enabled, onClick) {
|
|
205
|
+
const panelRef = react.useRef(null);
|
|
206
|
+
const handleClick = react.useCallback(
|
|
207
|
+
(e) => {
|
|
208
|
+
if (!enabled || !panelRef.current) return;
|
|
209
|
+
if (!panelRef.current.contains(e.target)) {
|
|
210
|
+
onClick();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
[enabled, onClick]
|
|
214
|
+
);
|
|
215
|
+
react.useEffect(() => {
|
|
216
|
+
if (!enabled) return;
|
|
217
|
+
document.addEventListener("mousedown", handleClick);
|
|
218
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
219
|
+
}, [enabled, handleClick]);
|
|
220
|
+
return panelRef;
|
|
221
|
+
}
|
|
222
|
+
var SIZE_MAP = {
|
|
223
|
+
sm: "400px",
|
|
224
|
+
md: "560px",
|
|
225
|
+
lg: "720px",
|
|
226
|
+
xl: "900px"
|
|
227
|
+
};
|
|
228
|
+
var SAFETY_UNMOUNT_MS = 600;
|
|
229
|
+
function ModalContent({
|
|
230
|
+
children,
|
|
231
|
+
size: sizeProp,
|
|
232
|
+
closeOnBackdropClick = true,
|
|
233
|
+
closeOnEscape = true,
|
|
234
|
+
backdropColor,
|
|
235
|
+
backdropBlur,
|
|
236
|
+
spring,
|
|
237
|
+
animationDuration
|
|
238
|
+
}) {
|
|
239
|
+
const ctx = useModalContext();
|
|
240
|
+
const size = sizeProp ?? ctx.size ?? "md";
|
|
241
|
+
const resolvedBackdropColor = backdropColor ?? ctx.backdropColor ?? "rgba(0, 0, 0, 0.6)";
|
|
242
|
+
const resolvedBackdropBlur = backdropBlur ?? ctx.backdropBlur ?? 4;
|
|
243
|
+
const resolvedSpring = spring ?? ctx.spring;
|
|
244
|
+
const [render, setRender] = react.useState(ctx.open);
|
|
245
|
+
const backdropRef = react.useRef(null);
|
|
246
|
+
const panelRef = react.useRef(null);
|
|
247
|
+
const animatingRef = react.useRef(false);
|
|
248
|
+
const prevOpen = react.useRef(ctx.open);
|
|
249
|
+
const safetyTimerRef = react.useRef(null);
|
|
250
|
+
const clearSafetyTimer = react.useCallback(() => {
|
|
251
|
+
if (safetyTimerRef.current !== null) {
|
|
252
|
+
clearTimeout(safetyTimerRef.current);
|
|
253
|
+
safetyTimerRef.current = null;
|
|
254
|
+
}
|
|
255
|
+
}, []);
|
|
256
|
+
const handleClose = react.useCallback(() => {
|
|
257
|
+
if (animatingRef.current) return;
|
|
258
|
+
ctx.onClose();
|
|
259
|
+
}, [ctx]);
|
|
260
|
+
useEscapeKey(closeOnEscape && ctx.open, handleClose);
|
|
261
|
+
const clickOutsideRef = useClickOutside(closeOnBackdropClick && ctx.open, handleClose);
|
|
262
|
+
react.useEffect(() => {
|
|
263
|
+
if (ctx.open && !prevOpen.current) {
|
|
264
|
+
clearSafetyTimer();
|
|
265
|
+
setRender(true);
|
|
266
|
+
animatingRef.current = true;
|
|
267
|
+
requestAnimationFrame(() => {
|
|
268
|
+
requestAnimationFrame(() => {
|
|
269
|
+
if (backdropRef.current && panelRef.current) {
|
|
270
|
+
animateIn(backdropRef.current, panelRef.current, {
|
|
271
|
+
spring: resolvedSpring});
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
animatingRef.current = false;
|
|
274
|
+
}, 500);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
} else if (!ctx.open && prevOpen.current) {
|
|
279
|
+
animatingRef.current = true;
|
|
280
|
+
if (backdropRef.current) {
|
|
281
|
+
backdropRef.current.style.pointerEvents = "none";
|
|
282
|
+
}
|
|
283
|
+
const unmount = () => {
|
|
284
|
+
clearSafetyTimer();
|
|
285
|
+
setRender(false);
|
|
286
|
+
animatingRef.current = false;
|
|
287
|
+
};
|
|
288
|
+
safetyTimerRef.current = setTimeout(unmount, SAFETY_UNMOUNT_MS);
|
|
289
|
+
if (backdropRef.current && panelRef.current) {
|
|
290
|
+
animateOut(backdropRef.current, panelRef.current, {
|
|
291
|
+
spring: resolvedSpring}).then(unmount);
|
|
292
|
+
} else {
|
|
293
|
+
unmount();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
prevOpen.current = ctx.open;
|
|
297
|
+
return clearSafetyTimer;
|
|
298
|
+
}, [ctx.open, resolvedSpring, animationDuration, clearSafetyTimer]);
|
|
299
|
+
if (!render) return null;
|
|
300
|
+
const setRefs = (el) => {
|
|
301
|
+
clickOutsideRef.current = el;
|
|
302
|
+
panelRef.current = el;
|
|
303
|
+
};
|
|
304
|
+
return reactDom.createPortal(
|
|
305
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
306
|
+
"div",
|
|
307
|
+
{
|
|
308
|
+
className: "rm-backdrop",
|
|
309
|
+
ref: backdropRef,
|
|
310
|
+
style: {
|
|
311
|
+
background: resolvedBackdropColor,
|
|
312
|
+
backdropFilter: `blur(${resolvedBackdropBlur}px)`,
|
|
313
|
+
WebkitBackdropFilter: `blur(${resolvedBackdropBlur}px)`
|
|
314
|
+
},
|
|
315
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
316
|
+
"div",
|
|
317
|
+
{
|
|
318
|
+
ref: setRefs,
|
|
319
|
+
className: `rm-panel rm-panel--${size}`,
|
|
320
|
+
role: "dialog",
|
|
321
|
+
"aria-modal": "true",
|
|
322
|
+
style: { maxWidth: SIZE_MAP[size] },
|
|
323
|
+
children
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
),
|
|
328
|
+
document.body
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
function ModalClose({ children = "\xD7", className }) {
|
|
332
|
+
const { onClose } = useModalContext();
|
|
333
|
+
return /* @__PURE__ */ jsxRuntime.jsx("button", { className: `rm-close ${className ?? ""}`, onClick: onClose, "aria-label": "Close", children });
|
|
334
|
+
}
|
|
335
|
+
function ModalHeader({ children, className }) {
|
|
336
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `rm-header ${className ?? ""}`, children });
|
|
337
|
+
}
|
|
338
|
+
function ModalBody({ children, className }) {
|
|
339
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `rm-body ${className ?? ""}`, children });
|
|
340
|
+
}
|
|
341
|
+
function ModalFooter({ children, className }) {
|
|
342
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `rm-footer ${className ?? ""}`, children });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
exports.Modal = Modal;
|
|
346
|
+
exports.ModalBody = ModalBody;
|
|
347
|
+
exports.ModalClose = ModalClose;
|
|
348
|
+
exports.ModalContent = ModalContent;
|
|
349
|
+
exports.ModalFooter = ModalFooter;
|
|
350
|
+
exports.ModalHeader = ModalHeader;
|
|
351
|
+
exports.ModalTrigger = ModalTrigger;
|
|
352
|
+
//# sourceMappingURL=index.cjs.map
|
|
353
|
+
//# sourceMappingURL=index.cjs.map
|