@stratal/inertia-modal 0.0.27 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +298 -0
- package/README.md +223 -0
- package/dist/index.d.mts +85 -22
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +437 -123
- package/dist/index.mjs.map +1 -1
- package/dist/level-path-DCJD-aS3.mjs +33 -0
- package/dist/level-path-DCJD-aS3.mjs.map +1 -0
- package/dist/page-props-BCEWEw3V.d.mts +23 -0
- package/dist/page-props-BCEWEw3V.d.mts.map +1 -0
- package/dist/react.d.mts +144 -21
- package/dist/react.d.mts.map +1 -1
- package/dist/react.mjs +557 -46
- package/dist/react.mjs.map +1 -1
- package/dist/testing.d.mts +33 -0
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +99 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/wire-BNVvmku4.mjs +93 -0
- package/dist/wire-BNVvmku4.mjs.map +1 -0
- package/dist/wire-CbwmWkPr.d.mts +54 -0
- package/dist/wire-CbwmWkPr.d.mts.map +1 -0
- package/package.json +41 -17
package/dist/react.mjs
CHANGED
|
@@ -1,35 +1,332 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { a as MODAL_PROP, c as isModalData, i as MODAL_MARKER_HEADER, r as MODAL_HELD_HEADER, s as encodeHeldLevels, t as MODAL_BENEATH_PROP, u as modalPropPath } from "./wire-BNVvmku4.mjs";
|
|
2
|
+
import { n as samePath, t as levelPath } from "./level-path-DCJD-aS3.mjs";
|
|
3
|
+
import { Deferred as Deferred$1, InfiniteScroll as InfiniteScroll$1, Link, router, usePage } from "@inertiajs/react";
|
|
4
|
+
import { getSsrExcludeMatchers, isSsrExcluded } from "@stratal/inertia/services/ssr-exclusion";
|
|
5
|
+
import { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
6
|
+
import { interceptors } from "@inertiajs/core";
|
|
7
|
+
import { flushSync } from "react-dom";
|
|
3
8
|
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
//#region src/react/component-registry.ts
|
|
10
|
+
/**
|
|
11
|
+
* The modal components resolved so far, by page-component name.
|
|
12
|
+
*
|
|
13
|
+
* Module-level, and safe on a server: it maps build-time names to build-time components, so nothing
|
|
14
|
+
* request-scoped enters it and a render only ever reads the names its own payload carries.
|
|
15
|
+
*/
|
|
16
|
+
let components = {};
|
|
17
|
+
function rememberModalComponents(resolved) {
|
|
18
|
+
Object.assign(components, resolved);
|
|
19
|
+
}
|
|
20
|
+
function readModalComponents() {
|
|
21
|
+
return components;
|
|
22
|
+
}
|
|
23
|
+
function clearModalComponents() {
|
|
24
|
+
components = {};
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/react/scroll-teardown.ts
|
|
28
|
+
const byLevel = /* @__PURE__ */ new Map();
|
|
29
|
+
/**
|
|
30
|
+
* Register `teardown` to run when the level at `url` is dismissed.
|
|
31
|
+
*
|
|
32
|
+
* Returns the unsubscribe, for the ordinary unmount path.
|
|
33
|
+
*/
|
|
34
|
+
function onLevelDismissed(url, teardown) {
|
|
35
|
+
const existing = byLevel.get(url) ?? /* @__PURE__ */ new Set();
|
|
36
|
+
existing.add(teardown);
|
|
37
|
+
byLevel.set(url, existing);
|
|
38
|
+
return () => {
|
|
39
|
+
const current = byLevel.get(url);
|
|
40
|
+
if (current === void 0) return;
|
|
41
|
+
current.delete(teardown);
|
|
42
|
+
if (current.size === 0) byLevel.delete(url);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Run every teardown registered for these levels, and forget them.
|
|
47
|
+
*
|
|
48
|
+
* Scoped to the levels actually being dismissed: closing a level above another must not end the
|
|
49
|
+
* subscriptions of the one it was covering, which stays open and keeps the rows it had loaded.
|
|
50
|
+
*/
|
|
51
|
+
function dismissLevels(urls) {
|
|
52
|
+
for (const url of urls) {
|
|
53
|
+
const teardowns = byLevel.get(url);
|
|
54
|
+
if (teardowns === void 0) continue;
|
|
55
|
+
byLevel.delete(url);
|
|
56
|
+
for (const teardown of teardowns) teardown();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/react/stack.ts
|
|
61
|
+
/**
|
|
62
|
+
* The stack after a response.
|
|
63
|
+
*
|
|
64
|
+
* `beneath` describes the chain a document response rebuilt, where there is nothing open to match
|
|
65
|
+
* against and the whole chain arrives at once. It is only read while that holds: it travels in
|
|
66
|
+
* props, which a partial response merges into rather than replaces, so it is still there long after
|
|
67
|
+
* the response that meant it.
|
|
68
|
+
*/
|
|
69
|
+
function applyStack(current, incoming, beneath) {
|
|
70
|
+
if (incoming === null) return [];
|
|
71
|
+
if (current.length === 0 && beneath !== null) return [...beneath, incoming];
|
|
72
|
+
const held = current.find((level) => samePath(level.url, incoming.url));
|
|
73
|
+
if (held !== void 0) {
|
|
74
|
+
const merged = {
|
|
75
|
+
...incoming,
|
|
76
|
+
close: held.close,
|
|
77
|
+
props: {
|
|
78
|
+
...held.props,
|
|
79
|
+
...incoming.props
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return [...current.slice(0, current.indexOf(held)), merged];
|
|
83
|
+
}
|
|
84
|
+
const parent = current.findIndex((level) => samePath(level.url, incoming.base));
|
|
85
|
+
if (parent >= 0) return [...current.slice(0, parent + 1), incoming];
|
|
86
|
+
return [incoming];
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/react/stack-store.ts
|
|
90
|
+
/**
|
|
91
|
+
* The open stack, held outside the React tree.
|
|
92
|
+
*
|
|
93
|
+
* CLIENT ONLY. A Workers isolate serves many requests, so a module-level value
|
|
94
|
+
* read during SSR would leak one user's open sheets into another user's HTML.
|
|
95
|
+
* `<Modal />` seeds from the payload on the server and consults this only in a
|
|
96
|
+
* browser.
|
|
97
|
+
*/
|
|
98
|
+
let held = [];
|
|
99
|
+
function readHeldStack() {
|
|
100
|
+
return held;
|
|
101
|
+
}
|
|
102
|
+
function holdStack(stack) {
|
|
103
|
+
held = stack;
|
|
104
|
+
}
|
|
105
|
+
function clearHeldStack() {
|
|
106
|
+
held = [];
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/react/graft.ts
|
|
110
|
+
/** The mounted page, as `<Modal />` last saw it. Client-only; the graft never runs on a server. */
|
|
111
|
+
let mounted = null;
|
|
112
|
+
function holdMountedPage(page) {
|
|
113
|
+
mounted = page;
|
|
114
|
+
}
|
|
115
|
+
function clearMountedPage() {
|
|
116
|
+
mounted = null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Rewrites `incoming` in place so it reads as the mounted page carrying a modal.
|
|
120
|
+
*
|
|
121
|
+
* In place because Inertia captures the parsed page object before it runs its response handlers and
|
|
122
|
+
* merges props against that same reference — a replacement object would be dropped.
|
|
123
|
+
*
|
|
124
|
+
* Only the top level of the mounted props is copied. A deep clone would walk the whole background
|
|
125
|
+
* payload on every sheet open, which on a page with many resolved props is real main-thread time.
|
|
126
|
+
*/
|
|
127
|
+
function graftOnto(incoming, page) {
|
|
128
|
+
incoming.component = page.component;
|
|
129
|
+
const { [MODAL_BENEATH_PROP]: _seededChain, ...mounted } = page.props;
|
|
130
|
+
incoming.props = {
|
|
131
|
+
...mounted,
|
|
132
|
+
...incoming.props
|
|
133
|
+
};
|
|
134
|
+
if (page.scrollProps !== void 0 || incoming.scrollProps !== void 0) incoming.scrollProps = {
|
|
135
|
+
...page.scrollProps,
|
|
136
|
+
...incoming.scrollProps
|
|
137
|
+
};
|
|
138
|
+
if (page.onceProps !== void 0 || incoming.onceProps !== void 0) incoming.onceProps = {
|
|
139
|
+
...page.onceProps,
|
|
140
|
+
...incoming.onceProps
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
let installed$1 = false;
|
|
144
|
+
/**
|
|
145
|
+
* Starts grafting modal responses onto the mounted page.
|
|
146
|
+
*
|
|
147
|
+
* Idempotent, and installed from the resolver rather than at module scope so importing this package
|
|
148
|
+
* has no side effect.
|
|
149
|
+
*/
|
|
150
|
+
function installGraft() {
|
|
151
|
+
if (installed$1 || typeof document === "undefined") return;
|
|
152
|
+
installed$1 = true;
|
|
153
|
+
interceptors.onVisitResponse((_visit, response) => {
|
|
154
|
+
const data = response.data;
|
|
155
|
+
const page = typeof data === "object" && data !== null ? data : null;
|
|
156
|
+
const isModal = response.headers[MODAL_MARKER_HEADER] === "true";
|
|
157
|
+
if (isModal && mounted !== null && page !== null) graftOnto(page, mounted);
|
|
158
|
+
if (page !== null) dropDismissedLevels(page, isModal);
|
|
159
|
+
return response;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Take the levels this response drops off the page, before the page changes.
|
|
164
|
+
*
|
|
165
|
+
* A level's props sit at `modal.props.*`, and anything addressing them by path — a scroll
|
|
166
|
+
* subscription, for one — reads that path off the page whenever Inertia announces a response. The
|
|
167
|
+
* path exists only while the level does, so a response that drops the level leaves those reads
|
|
168
|
+
* looking for something the new page does not carry.
|
|
169
|
+
*
|
|
170
|
+
* Here rather than at a dismissal, because a level is dropped by any navigation at all: closing it,
|
|
171
|
+
* the browser's back button, a link to somewhere else entirely. Interceptors run before the page is
|
|
172
|
+
* set and before that announcement, which is the only point early enough to matter — flushed, so
|
|
173
|
+
* React has removed them by then rather than at the next render, which comes after.
|
|
174
|
+
*/
|
|
175
|
+
function dropDismissedLevels(page, isModal) {
|
|
176
|
+
const held = readHeldStack();
|
|
177
|
+
if (held.length === 0) return;
|
|
178
|
+
const carried = page.props[MODAL_PROP];
|
|
179
|
+
const incoming = isModal && isModalData(carried) ? carried : null;
|
|
180
|
+
const beneath = page.props[MODAL_BENEATH_PROP];
|
|
181
|
+
const next = applyStack(held, incoming, Array.isArray(beneath) ? beneath.filter(isModalData) : null);
|
|
182
|
+
if (next.length === 0) {
|
|
183
|
+
page.props[MODAL_PROP] = null;
|
|
184
|
+
page.props[MODAL_BENEATH_PROP] = null;
|
|
185
|
+
}
|
|
186
|
+
const dropped = held.filter((level) => !next.some((kept) => samePath(kept.url, level.url))).map((level) => level.url);
|
|
187
|
+
if (dropped.length > 0) flushSync(() => dismissLevels(dropped));
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/react/modal-context.ts
|
|
191
|
+
/** Every open level, outermost first. */
|
|
192
|
+
const ModalStackContext = createContext([]);
|
|
193
|
+
/** The level the component reading it is rendered in. */
|
|
194
|
+
const ModalLevelContext = createContext(null);
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/react/held-header.ts
|
|
197
|
+
/**
|
|
198
|
+
* `config` carrying the levels the client has open.
|
|
199
|
+
*
|
|
200
|
+
* The visit is not consulted, and is typed as `unknown` to say so: what the client holds is a
|
|
201
|
+
* property of the client, not of whichever visit happens to be leaving.
|
|
202
|
+
*
|
|
203
|
+
* Read here, as the request is built, rather than closed over: the stack changes with every
|
|
204
|
+
* response, and a value captured earlier would describe it as it was one level ago.
|
|
205
|
+
*/
|
|
206
|
+
function nameHeldLevels(_visit, config) {
|
|
207
|
+
return {
|
|
208
|
+
...config,
|
|
209
|
+
headers: {
|
|
210
|
+
...config.headers,
|
|
211
|
+
[MODAL_HELD_HEADER]: encodeHeldLevels(readHeldStack().map((level) => level.url))
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
let installed = false;
|
|
216
|
+
/**
|
|
217
|
+
* Starts naming the open levels on every visit the router makes.
|
|
218
|
+
*
|
|
219
|
+
* Idempotent, and installed from the resolver rather than at module scope so importing this package
|
|
220
|
+
* has no side effect. A server never installs it: the held stack is client-only state, and a
|
|
221
|
+
* request interceptor registered in an isolate would read one visitor's sheets while answering
|
|
222
|
+
* another's.
|
|
223
|
+
*/
|
|
224
|
+
function installHeldHeader() {
|
|
225
|
+
if (installed || typeof document === "undefined") return;
|
|
226
|
+
installed = true;
|
|
227
|
+
interceptors.onVisitRequest(nameHeldLevels);
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
4
230
|
//#region src/react/resolver.ts
|
|
231
|
+
/**
|
|
232
|
+
* Wraps the `resolve` callback `createInertiaApp` is given, so a page's modal levels are resolved
|
|
233
|
+
* before the tree renders.
|
|
234
|
+
*
|
|
235
|
+
* Resolution is a real `import()`, so it cannot happen during render. Doing it here — the one point
|
|
236
|
+
* Inertia already awaits before it swaps the page — is what puts a level in server HTML and keeps a
|
|
237
|
+
* step between levels from flashing an empty sheet.
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```tsx
|
|
241
|
+
* createInertiaApp({
|
|
242
|
+
* resolve: withModals((name) => pages[`./pages/${name}.tsx`]()),
|
|
243
|
+
* setup: ({ el, App, props }) => hydrateRoot(el, <App {...props} />),
|
|
244
|
+
* })
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
function withModals(resolve) {
|
|
248
|
+
resolveCallback = resolve;
|
|
249
|
+
return async (name, page) => {
|
|
250
|
+
installGraft();
|
|
251
|
+
installHeldHeader();
|
|
252
|
+
const [component] = await Promise.all([resolve(name), prepare(resolve, page)]);
|
|
253
|
+
return component;
|
|
254
|
+
};
|
|
255
|
+
}
|
|
5
256
|
let resolveCallback;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Resolves one level's component after the tree has mounted.
|
|
259
|
+
*
|
|
260
|
+
* `<Modal />` needs this for a level the pre-pass could not reach — one excluded from SSR, on a
|
|
261
|
+
* server, which by definition has no entry to resolve it from.
|
|
262
|
+
*/
|
|
263
|
+
function resolveModalComponent(name) {
|
|
264
|
+
if (resolveCallback === void 0) throw new Error("[@stratal/inertia-modal] no resolver registered. Pass createInertiaApp's `resolve` through withModals() before rendering <Modal />.");
|
|
265
|
+
return unwrap(resolveCallback(name));
|
|
266
|
+
}
|
|
267
|
+
/** Resolves every level the page carries that this runtime has an entry for. */
|
|
268
|
+
async function prepare(resolve, page) {
|
|
269
|
+
if (page === void 0) return;
|
|
270
|
+
const top = page.props[MODAL_PROP];
|
|
271
|
+
const beneath = page.props[MODAL_BENEATH_PROP];
|
|
272
|
+
const levels = [...Array.isArray(beneath) ? beneath : [], top].filter(isModalData);
|
|
273
|
+
if (levels.length === 0) return;
|
|
274
|
+
const names = [...new Set(levels.map((level) => level.component))].filter(resolvableHere);
|
|
275
|
+
const resolved = await Promise.all(names.map(async (component) => {
|
|
276
|
+
try {
|
|
277
|
+
return [component, await unwrap(resolve(component))];
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
throw new Error(`[@stratal/inertia-modal] could not resolve modal component "${component}".`, { cause });
|
|
280
|
+
}
|
|
281
|
+
}));
|
|
282
|
+
rememberModalComponents(Object.fromEntries(resolved));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Whether this runtime has an entry to resolve `component` from.
|
|
286
|
+
*
|
|
287
|
+
* `ssrExclude` drops a page from the worker bundle, so on a server there is nothing to import —
|
|
288
|
+
* which is the whole of what the option means. Every page stays in the client glob, so a browser
|
|
289
|
+
* can always resolve one, and must: `<Modal />` draws nothing for a level whose component has not
|
|
290
|
+
* landed, and its own resolve effect runs only once the page has already swapped. A level left to
|
|
291
|
+
* that takes the sheet a visitor is looking at off the screen until the `import()` returns.
|
|
292
|
+
*
|
|
293
|
+
* Rendering it too early is not this decision. `<Modal />` withholds an excluded level for the
|
|
294
|
+
* render that hydrates, which is the only one that has to match HTML the server produced.
|
|
295
|
+
*/
|
|
296
|
+
function resolvableHere(component) {
|
|
297
|
+
return typeof document !== "undefined" || !isSsrExcluded(component, getSsrExcludeMatchers());
|
|
298
|
+
}
|
|
299
|
+
/** A page module's default export, or the module itself when it is the component. */
|
|
300
|
+
async function unwrap(loaded) {
|
|
301
|
+
const mod = await loaded;
|
|
302
|
+
return mod.default ?? mod;
|
|
303
|
+
}
|
|
15
304
|
//#endregion
|
|
16
305
|
//#region src/react/modal.tsx
|
|
306
|
+
/** The store below never changes, so there is nothing to subscribe to. */
|
|
307
|
+
const unchanging = () => () => {};
|
|
308
|
+
/**
|
|
309
|
+
* Whether React is past the render that hydrates.
|
|
310
|
+
*
|
|
311
|
+
* `useSyncExternalStore`'s third argument is the value React reads while server-rendering and
|
|
312
|
+
* while hydrating, and its second is what it switches to once hydration is over — so this asks
|
|
313
|
+
* React which phase it is in rather than asking something to remember. Nothing to set, nothing to
|
|
314
|
+
* reset between documents, and no answer that can outlive the render it describes.
|
|
315
|
+
*/
|
|
316
|
+
function usePastHydration() {
|
|
317
|
+
return useSyncExternalStore(unchanging, () => true, () => false);
|
|
318
|
+
}
|
|
17
319
|
/**
|
|
18
|
-
* Headless modal
|
|
320
|
+
* Headless modal host. Place it once in your layout.
|
|
19
321
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* renders it as an overlay. The background page is what Inertia renders normally.
|
|
322
|
+
* Renders every open level, so a modal opened from inside another appears above it with the one
|
|
323
|
+
* below still mounted.
|
|
23
324
|
*
|
|
24
325
|
* @example
|
|
25
326
|
* ```tsx
|
|
26
|
-
* // dashboard-layout.tsx
|
|
27
|
-
* import { Modal } from '@stratal/inertia-modal/react'
|
|
28
|
-
*
|
|
29
327
|
* export function DashboardLayout({ children }) {
|
|
30
328
|
* return (
|
|
31
329
|
* <>
|
|
32
|
-
* <Sidebar />
|
|
33
330
|
* <main>{children}</main>
|
|
34
331
|
* <Modal />
|
|
35
332
|
* </>
|
|
@@ -38,46 +335,260 @@ const resolver = {
|
|
|
38
335
|
* ```
|
|
39
336
|
*/
|
|
40
337
|
function Modal() {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
338
|
+
const page = usePage();
|
|
339
|
+
const carried = page.props[MODAL_PROP];
|
|
340
|
+
const incoming = isModalData(carried) ? carried : null;
|
|
341
|
+
const beneath = page.props["modalBeneath"] ?? null;
|
|
342
|
+
const pastHydration = usePastHydration();
|
|
343
|
+
const [stack, setStack] = useState(() => {
|
|
344
|
+
return applyStack(typeof document !== "undefined" ? readHeldStack() : [], incoming, beneath);
|
|
345
|
+
});
|
|
346
|
+
const stackRef = useRef(stack);
|
|
347
|
+
const components = readModalComponents();
|
|
348
|
+
const [, redrawWithResolved] = useState(0);
|
|
43
349
|
useEffect(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
Promise.resolve(resolver.resolve(modal.component)).then((mod) => {
|
|
49
|
-
const component = mod?.default ?? mod;
|
|
50
|
-
setComponent(() => component);
|
|
51
|
-
}).catch(() => setComponent(null));
|
|
52
|
-
}, [modal?.component]);
|
|
350
|
+
holdMountedPage(page);
|
|
351
|
+
}, [page]);
|
|
53
352
|
useEffect(() => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
353
|
+
const next = applyStack(stackRef.current, incoming, beneath);
|
|
354
|
+
stackRef.current = next;
|
|
355
|
+
if (typeof document !== "undefined") holdStack(next);
|
|
356
|
+
setStack(next);
|
|
357
|
+
}, [incoming, beneath]);
|
|
358
|
+
const missing = [...new Set(stack.map((level) => level.component))].filter((name) => !components[name]).join("\0");
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const wanted = missing === "" ? [] : missing.split("\0");
|
|
361
|
+
if (wanted.length === 0) return;
|
|
362
|
+
let cancelled = false;
|
|
363
|
+
const held = readModalComponents();
|
|
364
|
+
Promise.allSettled(wanted.map(async (name) => [name, await resolveModalComponent(name)])).then((results) => {
|
|
365
|
+
if (cancelled) return;
|
|
366
|
+
const next = { ...held };
|
|
367
|
+
for (const result of results) if (result.status === "fulfilled") {
|
|
368
|
+
const [name, component] = result.value;
|
|
369
|
+
next[name] = component;
|
|
370
|
+
}
|
|
371
|
+
rememberModalComponents(next);
|
|
372
|
+
redrawWithResolved((n) => n + 1);
|
|
373
|
+
for (const [index, result] of results.entries()) if (result.status === "rejected") console.error(`inertia-modal: failed to resolve component "${wanted[index]}"`, result.reason);
|
|
57
374
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
375
|
+
return () => {
|
|
376
|
+
cancelled = true;
|
|
377
|
+
};
|
|
378
|
+
}, [missing]);
|
|
379
|
+
if (stack.length === 0) return null;
|
|
380
|
+
return /* @__PURE__ */ jsx(ModalStackContext.Provider, {
|
|
381
|
+
value: stack,
|
|
382
|
+
children: stack.map((level, index) => {
|
|
383
|
+
const Component = components[level.component];
|
|
384
|
+
if (!Component) return null;
|
|
385
|
+
if (!pastHydration && isSsrExcluded(level.component, getSsrExcludeMatchers())) return null;
|
|
386
|
+
return /* @__PURE__ */ jsx(ModalLevelContext.Provider, {
|
|
387
|
+
value: {
|
|
388
|
+
modal: level,
|
|
389
|
+
depth: index,
|
|
390
|
+
isTop: index === stack.length - 1
|
|
391
|
+
},
|
|
392
|
+
children: /* @__PURE__ */ jsx(Component, { ...level.props })
|
|
393
|
+
}, levelPath(level.url));
|
|
394
|
+
})
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/react/modal-link.tsx
|
|
399
|
+
/**
|
|
400
|
+
* Opens a modal route as a sheet over the current page.
|
|
401
|
+
*
|
|
402
|
+
* @example
|
|
403
|
+
* ```tsx
|
|
404
|
+
* <ModalLink href="/parent/1/edit">Edit</ModalLink>
|
|
405
|
+
* <ModalLink href="/parent/1/edit" prefetch>Edit</ModalLink>
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
function ModalLink({ children, ...rest }) {
|
|
409
|
+
return /* @__PURE__ */ jsx(Link, {
|
|
410
|
+
only: [MODAL_PROP],
|
|
411
|
+
preserveState: true,
|
|
412
|
+
preserveScroll: true,
|
|
413
|
+
...rest,
|
|
414
|
+
children
|
|
415
|
+
});
|
|
61
416
|
}
|
|
62
417
|
//#endregion
|
|
63
418
|
//#region src/react/use-modal.ts
|
|
419
|
+
/**
|
|
420
|
+
* Closing is always an explicit visit, never `history.back()`.
|
|
421
|
+
*
|
|
422
|
+
* Going back is cheaper — the entry below is already in history, and Inertia answers it from cache
|
|
423
|
+
* without touching the server. But a cached entry is a SNAPSHOT of the whole page as it was when
|
|
424
|
+
* that entry was stored, and restoring it replaces every prop the page holds, not merely the modal
|
|
425
|
+
* ones. A page that has changed since — an autosaving draft, a filtered list — is rewound to the
|
|
426
|
+
* moment it was cached, with no sign that it happened. Landing on a URL asks the server instead:
|
|
427
|
+
* what comes back cannot disagree with what is stored.
|
|
428
|
+
*/
|
|
429
|
+
const CLOSE_OPTIONS = {
|
|
430
|
+
replace: true,
|
|
431
|
+
preserveScroll: true,
|
|
432
|
+
preserveState: true
|
|
433
|
+
};
|
|
64
434
|
function useModal() {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
435
|
+
const level = useContext(ModalLevelContext);
|
|
436
|
+
const stack = useContext(ModalStackContext);
|
|
437
|
+
const modal = level?.modal;
|
|
438
|
+
const close = useCallback((options = {}) => {
|
|
439
|
+
if (modal === void 0) return;
|
|
440
|
+
router.visit(modal.close, {
|
|
441
|
+
...CLOSE_OPTIONS,
|
|
442
|
+
...options
|
|
443
|
+
});
|
|
444
|
+
}, [modal]);
|
|
445
|
+
/**
|
|
446
|
+
* Dismissing the whole stack is the outermost level closing: every level above it goes with it,
|
|
447
|
+
* and it lands where it would have landed alone.
|
|
448
|
+
*/
|
|
449
|
+
const closeAll = useCallback((options = {}) => {
|
|
450
|
+
const outermost = stack[0];
|
|
451
|
+
if (outermost === void 0) return;
|
|
452
|
+
router.visit(outermost.close, {
|
|
453
|
+
...CLOSE_OPTIONS,
|
|
454
|
+
...options
|
|
455
|
+
});
|
|
456
|
+
}, [stack]);
|
|
457
|
+
/**
|
|
458
|
+
* A refined re-read is an ordinary visit to this level's own url. The client recognises the
|
|
459
|
+
* answer as this level by that url, so nothing has to declare the intent.
|
|
460
|
+
*/
|
|
461
|
+
const refresh = useCallback((query, options = {}) => {
|
|
462
|
+
if (modal === void 0) return;
|
|
463
|
+
router.get(modal.url, query, {
|
|
464
|
+
preserveState: true,
|
|
70
465
|
preserveScroll: true,
|
|
71
|
-
|
|
466
|
+
replace: true,
|
|
467
|
+
...options
|
|
72
468
|
});
|
|
73
469
|
}, [modal]);
|
|
470
|
+
const reload = useCallback((options = {}) => {
|
|
471
|
+
router.reload({
|
|
472
|
+
...options,
|
|
473
|
+
...options.only && { only: options.only.map(modalPropPath) },
|
|
474
|
+
...options.except && { except: options.except.map(modalPropPath) },
|
|
475
|
+
...options.reset && { reset: options.reset.map(modalPropPath) }
|
|
476
|
+
});
|
|
477
|
+
}, []);
|
|
478
|
+
/**
|
|
479
|
+
* The options `<ModalLink>` sets, applied to a visit made from code. Spread first so a caller
|
|
480
|
+
* can override any of them, exactly as passing the prop to the component would.
|
|
481
|
+
*/
|
|
482
|
+
const visit = useCallback((href, options = {}) => {
|
|
483
|
+
router.visit(href, {
|
|
484
|
+
only: [MODAL_PROP],
|
|
485
|
+
preserveState: true,
|
|
486
|
+
preserveScroll: true,
|
|
487
|
+
...options
|
|
488
|
+
});
|
|
489
|
+
}, []);
|
|
74
490
|
return {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
491
|
+
modal,
|
|
492
|
+
depth: level?.depth ?? 0,
|
|
493
|
+
isTop: level?.isTop ?? false,
|
|
494
|
+
close,
|
|
495
|
+
closeAll,
|
|
496
|
+
refresh,
|
|
497
|
+
reload,
|
|
498
|
+
visit
|
|
78
499
|
};
|
|
79
500
|
}
|
|
80
501
|
//#endregion
|
|
81
|
-
|
|
502
|
+
//#region src/react/deferred.tsx
|
|
503
|
+
function resolve(data, nested) {
|
|
504
|
+
if (!nested) return data;
|
|
505
|
+
return Array.isArray(data) ? data.map(modalPropPath) : modalPropPath(data);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* `@inertiajs/react`'s `<Deferred>`, with `data` resolved against the modal it is rendered in.
|
|
509
|
+
*
|
|
510
|
+
* The component addresses its prop by name at the page root, and a level's props are nested under
|
|
511
|
+
* one page prop. Import this one instead of the Inertia component and the same JSX works in a sheet
|
|
512
|
+
* and on a page — outside a modal the name is already the path.
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* ```tsx
|
|
516
|
+
* import { Deferred } from '@stratal/inertia-modal/react'
|
|
517
|
+
*
|
|
518
|
+
* <Deferred data="entries" fallback={<Skeleton />}>
|
|
519
|
+
* <Entries entries={entries} />
|
|
520
|
+
* </Deferred>
|
|
521
|
+
* ```
|
|
522
|
+
*/
|
|
523
|
+
const Deferred = Object.assign(function Deferred({ data, ...props }) {
|
|
524
|
+
const level = useContext(ModalLevelContext);
|
|
525
|
+
return /* @__PURE__ */ jsx(Deferred$1, {
|
|
526
|
+
...props,
|
|
527
|
+
data: resolve(data, level !== null)
|
|
528
|
+
});
|
|
529
|
+
}, { displayName: "Deferred" });
|
|
530
|
+
//#endregion
|
|
531
|
+
//#region src/react/infinite-scroll.tsx
|
|
532
|
+
/**
|
|
533
|
+
* `@inertiajs/react`'s `<InfiniteScroll>`, with `data` resolved against the modal it is rendered in.
|
|
534
|
+
*
|
|
535
|
+
* The component addresses its prop by name at the page root, and a level's props are nested under
|
|
536
|
+
* one page prop. Import this one instead of the Inertia component and the same JSX works in a sheet
|
|
537
|
+
* and on a page — outside a modal the name is already the path.
|
|
538
|
+
*
|
|
539
|
+
* @example
|
|
540
|
+
* ```tsx
|
|
541
|
+
* import { InfiniteScroll } from '@stratal/inertia-modal/react'
|
|
542
|
+
*
|
|
543
|
+
* <InfiniteScroll data="items">
|
|
544
|
+
* {items.data.map((item) => <Row key={item.id} item={item} />)}
|
|
545
|
+
* </InfiniteScroll>
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
const InfiniteScroll = forwardRef(function InfiniteScroll({ data, ...props }, ref) {
|
|
549
|
+
const level = useContext(ModalLevelContext);
|
|
550
|
+
const url = level?.modal.url;
|
|
551
|
+
const [dismissed, setDismissed] = useState(false);
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
if (url === void 0) return;
|
|
554
|
+
setDismissed(false);
|
|
555
|
+
return onLevelDismissed(url, () => setDismissed(true));
|
|
556
|
+
}, [url]);
|
|
557
|
+
if (dismissed) return null;
|
|
558
|
+
return /* @__PURE__ */ jsx(InfiniteScroll$1, {
|
|
559
|
+
...props,
|
|
560
|
+
data: level ? modalPropPath(data) : data,
|
|
561
|
+
ref
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/react/reset.ts
|
|
566
|
+
/**
|
|
567
|
+
* Discard everything this package holds outside the React tree.
|
|
568
|
+
*
|
|
569
|
+
* Three stores survive unmounting, because each exists precisely to outlive a render: the open
|
|
570
|
+
* stack, the page a level grafts onto, and the components resolved so far. In a browser that is
|
|
571
|
+
* what they are for — one document, one visitor, state that must not reset when a sheet closes.
|
|
572
|
+
* Under a test runner the same module is reused across files, so one test's open sheet is the next
|
|
573
|
+
* test's starting state: a sheet nothing opened, or a component a test meant to leave unresolved
|
|
574
|
+
* answering instantly from an earlier test's resolution.
|
|
575
|
+
*
|
|
576
|
+
* Call it between tests. It is the only supported way to empty them; the individual stores are
|
|
577
|
+
* internal so that resetting cannot drift out of step with what the package holds.
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* ```ts
|
|
581
|
+
* import { resetModalState } from '@stratal/inertia-modal/react'
|
|
582
|
+
*
|
|
583
|
+
* beforeEach(() => resetModalState())
|
|
584
|
+
* ```
|
|
585
|
+
*/
|
|
586
|
+
function resetModalState() {
|
|
587
|
+
clearHeldStack();
|
|
588
|
+
clearMountedPage();
|
|
589
|
+
clearModalComponents();
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
592
|
+
export { Deferred, InfiniteScroll, MODAL_PROP, Modal, ModalLink, resetModalState, useModal, withModals };
|
|
82
593
|
|
|
83
594
|
//# sourceMappingURL=react.mjs.map
|