@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/dist/react.mjs CHANGED
@@ -1,35 +1,332 @@
1
- import { router, usePage } from "@inertiajs/react";
2
- import { useCallback, useEffect, useState } from "react";
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
- const resolver = {
7
- set(cb) {
8
- resolveCallback = cb;
9
- },
10
- resolve(name) {
11
- if (!resolveCallback) throw new Error("[@stratal/inertia-modal] Resolver not registered. Call resolver.set() before createInertiaApp().");
12
- return resolveCallback(name);
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 component. Place this anywhere in your layout.
320
+ * Headless modal host. Place it once in your layout.
19
321
  *
20
- * When the current Inertia page has `props.modal` (set by `ctx.inertiaModal()`
21
- * on the server), this component dynamically loads the modal page component and
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 modal = usePage().props.modal;
42
- const [Component, setComponent] = useState(null);
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
- if (!modal?.component) {
45
- setComponent(null);
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
- if (!modal?.key) return;
55
- return router.on("before", (event) => {
56
- event.detail.visit.headers["x-inertia-modal-key"] = modal.key;
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
- }, [modal?.key]);
59
- if (!Component || !modal) return null;
60
- return /* @__PURE__ */ jsx(Component, { ...modal.props }, modal.component);
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 modal = usePage().props.modal;
66
- const redirect = useCallback(() => {
67
- if (!modal) return;
68
- if (modal.nativeBack) window.history.back();
69
- else router.visit(modal.redirectURL ?? modal.baseURL, {
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
- preserveState: true
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
- show: !!modal,
76
- redirect,
77
- props: modal?.props
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
- export { Modal, resolver, useModal };
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