react-modal-port 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/package.json +18 -4
- package/readme.md +221 -118
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.2
|
|
4
|
+
|
|
5
|
+
Documentation only; the library code is unchanged.
|
|
6
|
+
|
|
7
|
+
- README rewritten: it leads with the headless approach (you own markup, styles, accessibility and animation; the library handles launching, stacking, resolving and per-modal state), adds a "How it works" overview, a single quick start, an `await confirm()` recipe, a `launchModal` and types reference, and a Next.js section. Every snippet type-checks.
|
|
8
|
+
- The `<dialog>` and animation recipes now mention `overflow: clip` and `focus({ preventScroll: true })`, which keep slide-in animations from being cancelled.
|
|
9
|
+
- Better npm description and keywords.
|
|
10
|
+
|
|
11
|
+
## 1.0.1
|
|
12
|
+
|
|
13
|
+
Documentation only; the library code is unchanged.
|
|
14
|
+
|
|
15
|
+
- README links to the new CodePen 2.0 demos: [usage examples](https://codepen.io/oliverwehn/pen/018e18fb-0f15-724c-8fdf-4f47406f37d9) and [animation examples](https://codepen.io/oliverwehn/pen/01a0fb4d-2c07-7c34-af66-1ac02a634b22).
|
|
16
|
+
- The demos in `demo/` are ported to the CodePen 2.0 editor (`package.json` with esm.sh packages, `.jsx` via the Babel block). The animation examples gained a stacked-modal deck and fixed drawer and bottom-sheet enter animations.
|
|
17
|
+
|
|
3
18
|
## 1.0.0
|
|
4
19
|
|
|
5
20
|
A full rework for correctness, type safety and packaging. See [MIGRATION.md](./MIGRATION.md) for upgrade steps.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-modal-port",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Headless modal management for React 19: launch type-safe modals from anywhere, stack them, resolve them async and keep per-modal state. You own the UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -46,11 +46,25 @@
|
|
|
46
46
|
},
|
|
47
47
|
"keywords": [
|
|
48
48
|
"react",
|
|
49
|
-
"react-
|
|
49
|
+
"react-19",
|
|
50
50
|
"modal",
|
|
51
51
|
"dialog",
|
|
52
|
+
"modal-manager",
|
|
53
|
+
"modal-stack",
|
|
54
|
+
"headless",
|
|
55
|
+
"unstyled",
|
|
56
|
+
"hooks",
|
|
57
|
+
"react-hooks",
|
|
52
58
|
"typescript",
|
|
53
|
-
"
|
|
59
|
+
"type-safe",
|
|
60
|
+
"confirm",
|
|
61
|
+
"promise",
|
|
62
|
+
"async",
|
|
63
|
+
"state-management",
|
|
64
|
+
"dialog-element",
|
|
65
|
+
"a11y",
|
|
66
|
+
"server-components",
|
|
67
|
+
"nextjs"
|
|
54
68
|
],
|
|
55
69
|
"author": "Oliver Greuter-Wehn",
|
|
56
70
|
"license": "MIT",
|
package/readme.md
CHANGED
|
@@ -1,63 +1,98 @@
|
|
|
1
1
|
# react-modal-port
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/react-modal-port)
|
|
4
|
+
[](https://github.com/oliverwehn/react-modal-port/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
- **Launch from anywhere.** `useModal()` returns a stable `launchModal` function that can be called from components, effects or other modals.
|
|
7
|
-
- **One outlet.** `<ModalPort>` renders the top modal of a stack, wherever you put it (or into a portal).
|
|
8
|
-
- **Type-safe.** Resolvers and props are checked against the modal component's props.
|
|
9
|
-
- **Async-aware stacking.** A modal closes when one of its resolvers settles, even if other modals were stacked on top in the meantime.
|
|
7
|
+
**Headless modal management for React.** You own the modal: markup, styles, accessibility and animation. react-modal-port owns the logic: launching modals from anywhere, stacking them, resolving them (async included) and keeping per-modal state, with every launch type-checked against your component's props.
|
|
10
8
|
|
|
11
|
-
**
|
|
12
|
-
|
|
13
|
-
**Demos:** [Basics](https://codepen.io/oliverwehn/pen/YzMyoBr) (confirm, stacking, async resolvers, programmatic close) · [Animated modals](https://github.com/oliverwehn/react-modal-port/tree/main/demo/codepen-animated) (dialog, drawer, bottom sheet)
|
|
9
|
+
**Demos on CodePen:** [Usage examples](https://codepen.io/oliverwehn/pen/018e18fb-0f15-724c-8fdf-4f47406f37d9) · [Animation examples](https://codepen.io/oliverwehn/pen/01a0fb4d-2c07-7c34-af66-1ac02a634b22) (sources in [`demo/`](./demo))
|
|
14
10
|
|
|
15
11
|
---
|
|
16
12
|
|
|
13
|
+
- [Headless by design](#headless-by-design)
|
|
14
|
+
- [How it works](#how-it-works)
|
|
17
15
|
- [Installation](#installation)
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
16
|
+
- [Quick start](#quick-start)
|
|
17
|
+
- [Type-safe launches](#type-safe-launches)
|
|
18
|
+
- [Recipe: `await confirm()`](#recipe-await-confirm)
|
|
19
|
+
- [Async resolvers](#async-resolvers)
|
|
20
|
+
- [Dismissible modals](#dismissible-modals)
|
|
21
|
+
- [Stacked modals and modal state](#stacked-modals-and-modal-state)
|
|
22
|
+
- [Accessible backdrop with native `<dialog>`](#accessible-backdrop-with-native-dialog)
|
|
24
23
|
- [Animating modals in and out](#animating-modals-in-and-out)
|
|
24
|
+
- [Next.js and Server Components](#nextjs-and-server-components)
|
|
25
25
|
- [API reference](#api-reference)
|
|
26
26
|
|
|
27
|
+
## Headless by design
|
|
28
|
+
|
|
29
|
+
Most modal libraries give you a modal *component* and ask you to bend your design system around it. react-modal-port is the opposite: it ships **no markup and no styles**. Your modals are ordinary React components, and the library manages *which* modal is shown, *when* it closes and *what state it keeps*.
|
|
30
|
+
|
|
31
|
+
| react-modal-port handles | You decide |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| Launching a modal from any component, hook or other modal | What modals and backdrops look like |
|
|
34
|
+
| A stack of modals, rendered at one place in your app | Markup, styling and your design system's dialog component |
|
|
35
|
+
| Resolving: a modal stays open until its resolver has run (or its promise has settled), then exactly that modal closes | Focus handling, scroll locking and other accessibility details |
|
|
36
|
+
| Per-modal state that survives while other modals cover it | Enter and exit animations |
|
|
37
|
+
| Type-checking every launch against the modal's props | Where in the DOM modals render (in place or via a portal) |
|
|
38
|
+
|
|
39
|
+
Because the backdrop is your component too, react-modal-port works with a native `<dialog>`, with your component library's dialog, or with a plain `<div>`.
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
1. **`<ModalProvider>`** holds the modal stack. Wrap your app in it.
|
|
44
|
+
2. **`<ModalPort>`** is the outlet: it renders the top modal of the stack inside your **backdrop** component.
|
|
45
|
+
3. **`launchModal(Component, resolvers, props)`**, from the `useModal()` hook, pushes a modal onto the stack.
|
|
46
|
+
4. **Resolvers** are the modal's function props that *end* it. When the modal calls one, your function runs, and then that modal is removed. Any other function you pass in `props` is just a callback and leaves the modal open.
|
|
47
|
+
5. **Modal state** (`useModalState()`) belongs to one modal. It is kept while other modals are stacked on top and can be handed on to them.
|
|
48
|
+
|
|
27
49
|
## Installation
|
|
28
50
|
|
|
29
51
|
```bash
|
|
30
52
|
npm install react-modal-port
|
|
31
53
|
```
|
|
32
54
|
|
|
33
|
-
|
|
55
|
+
Requires React 19. Upgrading from 0.x? See [MIGRATION.md](./MIGRATION.md).
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
A modal is a plain component. Its resolvers, here `onConfirm` and `onCancel`, are normal props:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
// confirm-modal.tsx
|
|
63
|
+
export type ConfirmModalProps = {
|
|
64
|
+
question: string;
|
|
65
|
+
onConfirm: () => void;
|
|
66
|
+
onCancel: () => void;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function ConfirmModal({ question, onConfirm, onCancel }: ConfirmModalProps) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
|
72
|
+
<h2 id="confirm-title">{question}</h2>
|
|
73
|
+
<button type="button" onClick={onCancel}>Cancel</button>
|
|
74
|
+
<button type="button" onClick={onConfirm}>Confirm</button>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
34
79
|
|
|
35
|
-
|
|
80
|
+
Add the provider and the port once, with a backdrop of your own:
|
|
36
81
|
|
|
37
82
|
```tsx
|
|
83
|
+
// app.tsx
|
|
38
84
|
import type { ReactNode } from 'react';
|
|
39
85
|
import { ModalPort, ModalProvider, type ModalPortRenderProps } from 'react-modal-port';
|
|
40
86
|
|
|
41
87
|
function Backdrop({ children, onBackdropClick }: ModalPortRenderProps) {
|
|
42
88
|
return (
|
|
43
|
-
<div
|
|
44
|
-
onClick={onBackdropClick}
|
|
45
|
-
style={{
|
|
46
|
-
position: 'fixed',
|
|
47
|
-
inset: 0,
|
|
48
|
-
zIndex: 1000,
|
|
49
|
-
display: 'flex',
|
|
50
|
-
alignItems: 'center',
|
|
51
|
-
justifyContent: 'center',
|
|
52
|
-
background: 'rgba(0, 0, 0, 0.65)',
|
|
53
|
-
}}
|
|
54
|
-
>
|
|
89
|
+
<div className="backdrop" onClick={onBackdropClick}>
|
|
55
90
|
{children}
|
|
56
91
|
</div>
|
|
57
92
|
);
|
|
58
93
|
}
|
|
59
94
|
|
|
60
|
-
export function
|
|
95
|
+
export function App({ children }: { children: ReactNode }) {
|
|
61
96
|
return (
|
|
62
97
|
<ModalProvider>
|
|
63
98
|
{children}
|
|
@@ -67,129 +102,151 @@ export function RootLayout({ children }: { children: ReactNode }) {
|
|
|
67
102
|
}
|
|
68
103
|
```
|
|
69
104
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
## Launching modals
|
|
73
|
-
|
|
74
|
-
A modal is a plain component. The functions it calls to finish are its **resolvers**.
|
|
105
|
+
Launch the modal from anywhere inside the provider:
|
|
75
106
|
|
|
76
107
|
```tsx
|
|
77
|
-
|
|
78
|
-
question: string;
|
|
79
|
-
decideYay: () => void;
|
|
80
|
-
decideNay: () => void;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export function DecisionModal({ question, decideYay, decideNay }: DecisionModalProps) {
|
|
84
|
-
return (
|
|
85
|
-
<div role="dialog" aria-modal="true" aria-labelledby="decision-title">
|
|
86
|
-
<h2 id="decision-title">{question}</h2>
|
|
87
|
-
<button type="button" onClick={decideYay}>Yay</button>
|
|
88
|
-
<button type="button" onClick={decideNay}>Nay</button>
|
|
89
|
-
</div>
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Launch it with `launchModal(Component, resolvers, props?, options?)`:
|
|
95
|
-
|
|
96
|
-
```tsx
|
|
97
|
-
import { useState } from 'react';
|
|
108
|
+
// delete-button.tsx
|
|
98
109
|
import { useModal } from 'react-modal-port';
|
|
99
|
-
import {
|
|
110
|
+
import { ConfirmModal } from './confirm-modal';
|
|
100
111
|
|
|
101
|
-
export function
|
|
112
|
+
export function DeleteButton({ onDelete }: { onDelete: () => void }) {
|
|
102
113
|
const launchModal = useModal();
|
|
103
|
-
const [decision, setDecision] = useState<boolean | null>(null);
|
|
104
|
-
|
|
105
|
-
const ask = () => {
|
|
106
|
-
launchModal(
|
|
107
|
-
DecisionModal,
|
|
108
|
-
{ decideYay: () => setDecision(true), decideNay: () => setDecision(false) },
|
|
109
|
-
{ question: 'Ship it?' },
|
|
110
|
-
);
|
|
111
|
-
};
|
|
112
114
|
|
|
113
115
|
return (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() =>
|
|
119
|
+
launchModal(
|
|
120
|
+
ConfirmModal,
|
|
121
|
+
{ onConfirm: onDelete, onCancel: () => {} }, // resolvers: each one closes the modal
|
|
122
|
+
{ question: 'Delete this file?' }, // the remaining props
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
>
|
|
126
|
+
Delete
|
|
127
|
+
</button>
|
|
118
128
|
);
|
|
119
129
|
}
|
|
120
130
|
```
|
|
121
131
|
|
|
132
|
+
That's the whole setup. Styling `.backdrop` and `.modal` is up to you; for a ready-made accessible backdrop, see [Accessible backdrop with native `<dialog>`](#accessible-backdrop-with-native-dialog).
|
|
133
|
+
|
|
134
|
+
## Type-safe launches
|
|
135
|
+
|
|
136
|
+
`launchModal(Component, resolvers, props?, options?)` reads the modal's props from `Component` and checks the other arguments against them:
|
|
137
|
+
|
|
122
138
|
| Argument | Description |
|
|
123
139
|
| --- | --- |
|
|
124
|
-
| `Component` | The modal component.
|
|
125
|
-
| `resolvers` |
|
|
126
|
-
| `props` | The modal's remaining props. Required when
|
|
127
|
-
| `options` | `{ onDismiss }
|
|
140
|
+
| `Component` | The modal component. |
|
|
141
|
+
| `resolvers` | The modal's function props that close it. The modal is removed after the resolver returns, or after its promise settles. Only the first resolver call counts; later calls are ignored. |
|
|
142
|
+
| `props` | The modal's remaining props. Required when required props remain, otherwise optional. |
|
|
143
|
+
| `options` | `{ onDismiss }`, see [Dismissible modals](#dismissible-modals). |
|
|
128
144
|
|
|
129
|
-
TypeScript catches the
|
|
145
|
+
TypeScript catches the usual mistakes:
|
|
130
146
|
|
|
131
147
|
```tsx
|
|
132
|
-
|
|
133
|
-
launchModal(
|
|
148
|
+
const noop = () => {};
|
|
149
|
+
launchModal(ConfirmModal, { onConfirm: noop, onCancle: noop }, { question: '?' }); // ✗ unknown resolver "onCancle"
|
|
150
|
+
launchModal(ConfirmModal, { onConfirm: noop, onCancel: noop }); // ✗ prop "question" is missing
|
|
151
|
+
launchModal(ConfirmModal, { onConfirm: noop, onCancel: noop }, { question: 42 }); // ✗ "question" must be a string
|
|
134
152
|
```
|
|
135
153
|
|
|
136
|
-
A function prop that is *not* listed in `resolvers` can be passed in `props`. It is then just a callback and does not close the modal.
|
|
137
|
-
|
|
138
154
|
`launchModal` returns a handle, `{ id, close() }`. `close()` removes that modal without calling a resolver, for example after a timeout.
|
|
139
155
|
|
|
140
|
-
##
|
|
156
|
+
## Recipe: `await confirm()`
|
|
141
157
|
|
|
142
|
-
|
|
158
|
+
Because resolvers are plain functions, a promise-based API is a few lines on top of `launchModal`:
|
|
143
159
|
|
|
144
160
|
```tsx
|
|
161
|
+
import { useCallback } from 'react';
|
|
162
|
+
import { useModal } from 'react-modal-port';
|
|
163
|
+
import { ConfirmModal } from './confirm-modal';
|
|
164
|
+
|
|
165
|
+
export function useConfirm() {
|
|
166
|
+
const launchModal = useModal();
|
|
167
|
+
return useCallback(
|
|
168
|
+
(question: string) =>
|
|
169
|
+
new Promise<boolean>((resolve) => {
|
|
170
|
+
launchModal(
|
|
171
|
+
ConfirmModal,
|
|
172
|
+
{ onConfirm: () => resolve(true), onCancel: () => resolve(false) },
|
|
173
|
+
{ question },
|
|
174
|
+
{ onDismiss: () => resolve(false) },
|
|
175
|
+
);
|
|
176
|
+
}),
|
|
177
|
+
[launchModal],
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// In a component:
|
|
182
|
+
// const confirm = useConfirm();
|
|
183
|
+
// if (await confirm('Discard your changes?')) discard();
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Async resolvers
|
|
187
|
+
|
|
188
|
+
When a resolver returns a promise, the modal stays open until the promise settles:
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// SaveModal is your component with these props; saveDraft() is your API call.
|
|
192
|
+
type SaveModalProps = { onSave: () => Promise<void>; onCancel: () => void };
|
|
193
|
+
|
|
145
194
|
launchModal(SaveModal, {
|
|
146
|
-
onSave: async (
|
|
147
|
-
await
|
|
195
|
+
onSave: async () => {
|
|
196
|
+
await saveDraft(); // the modal is still shown while this runs
|
|
148
197
|
},
|
|
198
|
+
onCancel: () => {},
|
|
149
199
|
});
|
|
150
200
|
```
|
|
151
201
|
|
|
152
|
-
- **Fulfilled:** the modal closes.
|
|
153
|
-
- **Rejected or thrown:** the modal stays open and the promise returned to the modal component rejects with the same error
|
|
202
|
+
- **Fulfilled:** the modal closes. Only *this* modal is removed, even if other modals were stacked on top of it in the meantime.
|
|
203
|
+
- **Rejected or thrown:** the modal stays open, and the promise returned to the modal component rejects with the same error. The modal can show the error and let the user try again.
|
|
154
204
|
|
|
155
|
-
##
|
|
205
|
+
## Dismissible modals
|
|
156
206
|
|
|
157
|
-
Pass `onDismiss` in the options to
|
|
207
|
+
Pass `onDismiss` in the options to let users close a modal from outside its content:
|
|
158
208
|
|
|
159
209
|
```tsx
|
|
160
|
-
|
|
210
|
+
// In DeleteButton from the quick start:
|
|
211
|
+
launchModal(
|
|
212
|
+
ConfirmModal,
|
|
213
|
+
{ onConfirm: onDelete, onCancel: () => {} },
|
|
214
|
+
{ question: 'Delete this file?' },
|
|
215
|
+
{ onDismiss: () => console.log('dismissed') },
|
|
216
|
+
);
|
|
161
217
|
```
|
|
162
218
|
|
|
163
|
-
|
|
219
|
+
Your backdrop then receives `onBackdropClick`. It calls `onDismiss` and closes the modal, but only for clicks on the backdrop itself, not for clicks inside the modal. For modals launched without `onDismiss`, `onBackdropClick` is `undefined`, so the backdrop can tell whether the current modal is dismissible and wire Escape the same way.
|
|
164
220
|
|
|
165
|
-
##
|
|
221
|
+
## Stacked modals and modal state
|
|
166
222
|
|
|
167
|
-
|
|
223
|
+
A modal can launch another one. The port shows the top modal and returns to the previous one when the top one closes.
|
|
168
224
|
|
|
169
|
-
|
|
225
|
+
Only the top modal is mounted, so component state (`useState`) inside a covered modal is lost. For anything that should outlive a nested modal, use **modal state**: `useModalState()` gives each modal its own state, which is kept while other modals cover it and discarded when the modal closes. Since it's ordinary data, the modal can also hand it on to the next modal.
|
|
226
|
+
|
|
227
|
+
Here a name form opens the confirmation from the quick start on top of itself. The confirmation gets the name from the form's modal state, and confirming resolves the form with it as well:
|
|
170
228
|
|
|
171
229
|
```tsx
|
|
172
230
|
import { useModal, useModalState } from 'react-modal-port';
|
|
173
231
|
import { ConfirmModal } from './confirm-modal';
|
|
174
232
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
export function AskForNameModal({ provideName }: AskForNameProps) {
|
|
233
|
+
export function AskForNameModal({ provideName }: { provideName: (name: string) => void }) {
|
|
178
234
|
const launchModal = useModal();
|
|
179
235
|
const [state, setState] = useModalState<{ name: string }>();
|
|
180
236
|
const name = state?.name ?? '';
|
|
181
237
|
|
|
182
|
-
const
|
|
238
|
+
const next = () =>
|
|
183
239
|
launchModal(
|
|
184
240
|
ConfirmModal,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
241
|
+
{
|
|
242
|
+
onConfirm: () => provideName(name), // resolves this form too, with its state
|
|
243
|
+
onCancel: () => {}, // back to the form, input intact
|
|
244
|
+
},
|
|
245
|
+
{ question: `Call you “${name}”?` }, // modal state handed on as a prop
|
|
188
246
|
);
|
|
189
|
-
};
|
|
190
247
|
|
|
191
248
|
return (
|
|
192
|
-
<div role="dialog" aria-modal="true" aria-labelledby="name-title">
|
|
249
|
+
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="name-title">
|
|
193
250
|
<h2 id="name-title">How should we call you?</h2>
|
|
194
251
|
<input
|
|
195
252
|
aria-label="Name"
|
|
@@ -199,17 +256,17 @@ export function AskForNameModal({ provideName }: AskForNameProps) {
|
|
|
199
256
|
setState((prev) => ({ ...prev, name: value }));
|
|
200
257
|
}}
|
|
201
258
|
/>
|
|
202
|
-
<button type="button" onClick={
|
|
259
|
+
<button type="button" onClick={next}>Next</button>
|
|
203
260
|
</div>
|
|
204
261
|
);
|
|
205
262
|
}
|
|
206
263
|
```
|
|
207
264
|
|
|
208
|
-
`setState` accepts a new state object or an updater function, like React's
|
|
265
|
+
`setState` accepts a new state object or an updater function, like React's `setState`.
|
|
209
266
|
|
|
210
|
-
##
|
|
267
|
+
## Accessible backdrop with native `<dialog>`
|
|
211
268
|
|
|
212
|
-
|
|
269
|
+
Accessibility stays in your hands so it can match your design system. The native `<dialog>` element does most of the work: opened with `showModal()`, it renders in the top layer, makes the rest of the page inert, traps focus and fires `cancel` on Escape.
|
|
213
270
|
|
|
214
271
|
```tsx
|
|
215
272
|
import { useEffect, useRef } from 'react';
|
|
@@ -230,7 +287,7 @@ export function DialogBackdrop({ children, onBackdropClick }: ModalPortRenderPro
|
|
|
230
287
|
className="modal-backdrop"
|
|
231
288
|
onClick={onBackdropClick}
|
|
232
289
|
onCancel={(event) => {
|
|
233
|
-
event.preventDefault(); // let the library decide whether the modal closes
|
|
290
|
+
event.preventDefault(); // Escape: let the library decide whether the modal closes
|
|
234
291
|
onBackdropClick?.(event);
|
|
235
292
|
}}
|
|
236
293
|
>
|
|
@@ -241,22 +298,30 @@ export function DialogBackdrop({ children, onBackdropClick }: ModalPortRenderPro
|
|
|
241
298
|
```
|
|
242
299
|
|
|
243
300
|
```css
|
|
301
|
+
.modal-backdrop {
|
|
302
|
+
/* clip, not hidden: focus() can't scroll a clipped dialog, which matters for
|
|
303
|
+
modals that animate in from off-screen */
|
|
304
|
+
overflow: clip;
|
|
305
|
+
}
|
|
306
|
+
|
|
244
307
|
/* Lock page scroll while a modal is open */
|
|
245
308
|
body:has(dialog.modal-backdrop[open]) { overflow: hidden; }
|
|
246
309
|
```
|
|
247
310
|
|
|
248
|
-
Give each modal an accessible name (`aria-labelledby` or `aria-label`)
|
|
311
|
+
Give each modal an accessible name (`aria-labelledby` or `aria-label`). The dialog stays open while stacked modals swap inside it, so move focus into each modal when it mounts.
|
|
249
312
|
|
|
250
313
|
## Animating modals in and out
|
|
251
314
|
|
|
252
|
-
**Enter:** every modal remounts when it becomes the top of the stack,
|
|
315
|
+
**Enter:** every modal remounts when it becomes the top of the stack, including when it reappears after the modal above it closes. A CSS animation on the modal's root element therefore plays each time:
|
|
253
316
|
|
|
254
317
|
```css
|
|
255
318
|
.modal { animation: pop-in 250ms ease-out both; }
|
|
256
319
|
@keyframes pop-in { from { opacity: 0; transform: scale(0.96); } }
|
|
257
320
|
```
|
|
258
321
|
|
|
259
|
-
|
|
322
|
+
If the animation moves the modal in from off-screen (a drawer or a bottom sheet), focus it with `element.focus({ preventScroll: true })`. A plain `focus()` scrolls the off-screen element into view and cancels the slide.
|
|
323
|
+
|
|
324
|
+
**Exit:** a modal stays mounted until its resolver settles, so a resolver that first awaits an exit animation gets a clean exit. A small wrapper around `launchModal` applies this to every resolver and to `onDismiss`:
|
|
260
325
|
|
|
261
326
|
```tsx
|
|
262
327
|
import { useModal, type LaunchModal } from 'react-modal-port';
|
|
@@ -289,35 +354,73 @@ export function useAnimatedModal(): LaunchModal {
|
|
|
289
354
|
}
|
|
290
355
|
```
|
|
291
356
|
|
|
292
|
-
The backdrop receives `modalId` and `stackSize`, so `playExit` can check that the modal is the one on screen, and fade the backdrop out too when it is the last one. The [
|
|
357
|
+
The backdrop receives `modalId` and `stackSize`, so `playExit` can check that the modal is the one on screen, and fade the backdrop out too when it is the last one. The [animation examples](https://codepen.io/oliverwehn/pen/01a0fb4d-2c07-7c34-af66-1ac02a634b22) have a complete version using the Web Animations API, with a drawer, a bottom sheet, stacked modals shown as a deck, and support for `prefers-reduced-motion` (source in [`demo/codepen-animated/`](./demo/codepen-animated)).
|
|
358
|
+
|
|
359
|
+
## Next.js and Server Components
|
|
360
|
+
|
|
361
|
+
The package is marked `"use client"`, so `ModalProvider` and `ModalPort` can be rendered from a Server Component layout, for example a Next.js `app/layout.tsx`. Your backdrop is passed to `ModalPort` as a component, so define the provider, port and backdrop together in a small client component and render that from the layout. The modals themselves are client components, since they use event handlers and hooks.
|
|
293
362
|
|
|
294
363
|
## API reference
|
|
295
364
|
|
|
296
365
|
### `<ModalProvider>`
|
|
297
366
|
|
|
298
|
-
Holds the modal stack. Wrap your app
|
|
367
|
+
Holds the modal stack. Wrap your app, or the part of it that uses modals. `ModalContextProvider` is a deprecated alias.
|
|
299
368
|
|
|
300
369
|
### `<ModalPort>`
|
|
301
370
|
|
|
302
371
|
| Prop | Type | Description |
|
|
303
372
|
| --- | --- | --- |
|
|
304
|
-
| `backdrop` | `ComponentType<ModalPortRenderProps>` |
|
|
305
|
-
| `container` | `Element \| DocumentFragment` | Render into this element through a portal. |
|
|
373
|
+
| `backdrop` | `ComponentType<ModalPortRenderProps>` | Optional. Rendered around the current modal. |
|
|
374
|
+
| `container` | `Element \| DocumentFragment` | Render into this element through a portal instead of in place. |
|
|
306
375
|
| `onModalLaunch` | `() => void` | The stack went from empty to non-empty. |
|
|
307
376
|
| `onModalClose` | `() => void` | The stack became empty. |
|
|
308
|
-
| `onStackChange` | `(size: number) => void` |
|
|
377
|
+
| `onStackChange` | `(size: number) => void` | A modal was launched or closed. |
|
|
309
378
|
| `render` | | Deprecated alias of `backdrop`. |
|
|
310
379
|
|
|
380
|
+
The backdrop receives these props (`ModalPortRenderProps`):
|
|
381
|
+
|
|
382
|
+
| Prop | Type | Description |
|
|
383
|
+
| --- | --- | --- |
|
|
384
|
+
| `children` | `ReactNode` | The current modal. |
|
|
385
|
+
| `onBackdropClick` | `((event) => void) \| undefined` | Dismisses the modal. `undefined` unless it was launched with `onDismiss`. Ignores clicks that bubble up from the modal. |
|
|
386
|
+
| `modalId` | `number` | Id of the modal shown. |
|
|
387
|
+
| `stackSize` | `number` | Number of modals on the stack, including the one shown. |
|
|
388
|
+
|
|
389
|
+
### `launchModal`
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
launchModal(Component, resolvers, props?, options?): ModalHandle
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
| | |
|
|
396
|
+
| --- | --- |
|
|
397
|
+
| `resolvers` | A subset of the modal's function props. Each is wrapped so that the modal closes after it has run or its promise has settled; on a throw or rejection the modal stays open. |
|
|
398
|
+
| `props` | The modal's remaining props. |
|
|
399
|
+
| `options.onDismiss` | Enables dismissing from outside the modal; wrapped like a resolver. |
|
|
400
|
+
| returns `{ id, close }` | `close()` removes the modal without calling a resolver. A no-op once the modal is gone. |
|
|
401
|
+
|
|
311
402
|
### Hooks
|
|
312
403
|
|
|
313
404
|
| Hook | Returns |
|
|
314
405
|
| --- | --- |
|
|
315
|
-
| `useModal()` | `launchModal`. Stable across renders; components
|
|
316
|
-
| `useModalState<S>()` | `[state \| null, setState]` for the modal the hook is called in.
|
|
406
|
+
| `useModal()` | `launchModal`. Stable across renders; components that only call this hook do not re-render when modals open or close. |
|
|
407
|
+
| `useModalState<S>()` | `[state \| null, setState]` for the modal the hook is called in. Outside a modal, it refers to the top modal (`null` if there is none). |
|
|
317
408
|
| `useModalStack()` | A read-only array of `{ id, render, props, state }`, bottom first. |
|
|
318
409
|
| `useModalContext()` | Deprecated. `{ stack, launchModal, updateState }`; re-renders on every stack change. |
|
|
319
410
|
|
|
320
|
-
All hooks throw
|
|
411
|
+
All hooks throw when used outside a `ModalProvider`.
|
|
412
|
+
|
|
413
|
+
### Types
|
|
414
|
+
|
|
415
|
+
| Type | Description |
|
|
416
|
+
| --- | --- |
|
|
417
|
+
| `LaunchModal` | The type of `launchModal`. |
|
|
418
|
+
| `LaunchOptions` | `{ onDismiss? }` |
|
|
419
|
+
| `ModalHandle` | `{ id, close }`, returned by `launchModal`. |
|
|
420
|
+
| `ModalPortProps`, `ModalPortRenderProps` | Props of `ModalPort` and of your backdrop. |
|
|
421
|
+
| `ModalState`, `UpdateModalState<S>` | Modal state and its setter. |
|
|
422
|
+
| `ModalStackEntry` | An item of `useModalStack()`. |
|
|
423
|
+
| `FunctionKeys<P>`, `Resolvers<P, R>`, `RestProps<P, R>` | Helpers behind the `launchModal` signature, useful for wrappers like `useConfirm`. |
|
|
321
424
|
|
|
322
425
|
## License
|
|
323
426
|
|