@stratal/inertia-modal 0.0.26 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,288 @@
1
+ # @stratal/inertia-modal
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a753e55: Stack modal routes above one another with the stack held by the browser, server-render modal levels, and make the prop helpers work inside `ctx.modal()`.
8
+
9
+ ### Nested stacks
10
+ - A level nests when its `base` names the modal route below it; otherwise it starts a fresh stack. Each level owns its URL — path and query string — so a direct visit or refresh renders the whole chain, and closing a level lands where it was opened from, with the query it was opened under.
11
+ - A level keeps a stable identity while it stays open at the same URL, so a refresh, or a level opening above it, does not remount it and lose in-progress form state.
12
+ - Opening a sheet is one request that renders one component. The page behind it is rendered only for a direct visit or a refresh — the one case with nothing already on screen — so a sheet reached by a redirect no longer costs a second render of the page beneath it.
13
+ - `<ModalLink>` opens a sheet and carries the visit options that keep the page behind it in place.
14
+ - `useModal()` gives you `modal`, `depth`, `isTop`, `close()`, `closeAll()`, `refresh()`, `reload()` and `visit()`, and browser Back closes exactly one level. `refresh()` takes `router.get`'s options, `reload()` takes `router.reload`'s, and `close()`/`closeAll()` take `router.visit`'s, so a caller can time the visit it started rather than the next one to finish. All are properties holding closures, so destructuring one is safe.
15
+ - `refresh(query)` re-reads the open level under a refined query — applying a filter, a sort, or a code the server prices. The level is recognised by its own URL, so a refinement never reads as a second sheet of the same route opening.
16
+ - `isModalBackground(ctx)` tells a route it is being rendered as the page beneath a modal, so a route that answers clients with a redirect can render instead. Without it that redirect is followed back to the modal and surfaces as `ModalBaseCycleError`, which is added here and thrown when a `base` chain leads back to a route already in it. A client cannot make `isModalBackground` answer true.
17
+
18
+ ### Closing a level
19
+ - Closing lands on the page or the level the sheet was opened from, which stays mounted — so regions that were showing content keep showing it instead of falling back to a placeholder, and the page keeps the props it holds. That landing costs one request.
20
+ - Where a level closes to is decided once, when it opens, and travels with the level, so a refresh of the sheet no longer loses the query the list beneath was filtered by. A level lands on the page, never on another level that is still open: a visit made from a sheet sends that sheet as its `Referer`, so the two could previously aim at each other and no amount of closing ever reached the page.
21
+ - Closing repeatedly unwinds the whole stack rather than reopening the level that just closed, and dismissing a whole stack lands the same way as closing the outermost level.
22
+ - The first browser Back after closing lands where the close already landed, so it appears to do nothing.
23
+ - Rows a level had already loaded survive a close instead of restarting from the first page, and the level below is handed back under the identity it already had — so its scroll metadata and merge target stay at the path its mounted components read.
24
+
25
+ ### Prop helpers, scroll and SSR
26
+ - `defer`, `merge`, `once` and `scroll` now work inside `ctx.modal()`. A level was previously built from the raw argument and never run through prop resolution, so none of them had any effect; all four now behave in a sheet exactly as they do on a page.
27
+
28
+ ```typescript
29
+ return ctx.modal(
30
+ "Parent/Index",
31
+ {
32
+ items: ctx.scroll(
33
+ () =>
34
+ db.$cursor.item.findMany({
35
+ cursor,
36
+ take: 20,
37
+ orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
38
+ }),
39
+ { matchOn: "id" },
40
+ ),
41
+ },
42
+ { base: "/parent" },
43
+ );
44
+ ```
45
+
46
+ - `<Deferred>` and `<InfiniteScroll>` re-exported from `@stratal/inertia-modal/react` resolve `data` against the modal they render in, so the same JSX works in a sheet and on a page and no caller composes the wire path itself.
47
+ - A partial reload that names nothing about the level leaves it alone, so the client keeps what it holds and the level's outstanding `defer()` props are not advertised a second time — re-announcing them made the client fetch each one again for every reload the surrounding page made. A partial reload naming a level's props resolves just those.
48
+ - **Modal levels now server-render.** `<Modal />` previously initialised its stack as empty state and filled it in two effects, so no level ever appeared in server-rendered HTML; levels now resolve before the tree renders and reach first paint. The open stack is also held outside the page component, so a background-page remount no longer destroys every open sheet.
49
+ - Pass `createInertiaApp`'s `resolve` through `withModals()` in both the client and SSR entries. There is no bootstrap step to run before hydrating and no provider to wrap the tree in.
50
+
51
+ ```tsx
52
+ createInertiaApp({
53
+ resolve: withModals((name) => pages[`./pages/${name}.tsx`]()),
54
+ setup: ({ el, App, props }) => hydrateRoot(el, <App {...props} />),
55
+ });
56
+ ```
57
+
58
+ - This makes first-paint modal content _possible_; it does not make every modal's markup appear. A level whose content renders inside a Radix `Portal` still will not appear in server HTML, since `createPortal` is an inherently client-side DOM operation. If you want a level's content in first paint, that level has to render without a Portal.
59
+ - Render a modal route's background page client-only when that page is excluded from SSR through `ssrExclude`. A direct visit or refresh of such a modal route previously failed with `Page not found` and a 500.
60
+ - Keep a sheet on screen while the level replacing it loads, rather than leaving the screen with no sheet for as long as the component takes to arrive.
61
+
62
+ ### Testing
63
+
64
+ Import `@stratal/inertia-modal/testing` alongside `@stratal/inertia/testing`. Eight assertions, chainable like the Inertia family, plus two readers:
65
+
66
+ ```typescript
67
+ await response.assertModalComponents(["Parent/Index", "Parent/Edit"]);
68
+ await response.assertModalProp("item.id", "42");
69
+
70
+ const level = await response.modalLevel<{ items: Item[] }>();
71
+ expect(level.props.items).toHaveLength(1);
72
+ ```
73
+
74
+ - `assertModal(callback?)` / `assertNoModal()`, `assertModalComponent(component, depth?)`, `assertModalComponents(components)`, `assertModalCount(count)`, `assertModalDepth(depth)`, `assertModalProp(path, expected, depth?)`, `assertModalOnly()`, plus `modalLevel<TProps>(depth?)` and `modalLevels<TProps>()`.
75
+ - `assertModalBase()` and `assertModalClose()` assert what a level sits over and where closing it lands — a wrong close target is otherwise invisible until someone taps Close and does not arrive.
76
+ - Not one of them names a payload field, so a later payload change costs you nothing. Only a direct visit or a refresh reports what sits beneath, so the whole-stack assertions fail on any other response rather than reporting `1`.
77
+ - `resetModalState()` empties everything the package holds outside the React tree, so a test runner sharing the module across tests does not carry one test's open sheet into the next.
78
+ - `modalPropPath(prop)` is exported for a test naming a level's prop path.
79
+
80
+ ### Fixes
81
+ - `ctx.seo()` on a modal route now reaches the page. A level's metadata is written to the prop the client head-sync reads and injected into the document head on a direct visit, so the sheet's own title and description apply while its URL is the address. It was previously discarded outright, and a direct visit rendered no SEO tags at all. A level that never calls `ctx.seo()` leaves the background page's metadata untouched.
82
+ - A modal route answers with the flash the request carries, instead of an empty one. A submission that flashed its result and redirected into a sheet — a purchase outcome, a confirmation — previously lost it outright.
83
+ - Fix `<InfiniteScroll>` inside a level requesting the same page over and over without adding rows, and silently stopping after a sheet opened over it is closed. A scroll fetch is now recognised as the level it came from asking for the next page of its own prop, and a scrolled level keeps the URL it was opened under so neither the close target nor a later refresh drifts a page at a time.
84
+ - Leaving a level that holds an `<InfiniteScroll>` no longer throws about a missing scroll prop. The subscription now ends with the level, however the level was left — closing it, the back button, a link elsewhere — and a level still open keeps the rows it had loaded.
85
+ - A level is recognised under either spelling of its path, so an app appending a trailing slash no longer draws a second copy of a sheet it already has open.
86
+ - Leave an open level alone when a response is not addressed to it. A partial reload for a prop of the page _beneath_ a modal — a poller, a `defer()` prop of that page — was answered with the level attached and carrying no props, and the client seated that empty level back on the chain, so the sheet lost every prop it held and a level reading one as it renders went blank with an uncaught `TypeError`.
87
+ - Errors this package raises — a failed background fetch, a `base` chain that cycles — now read as English sentences rather than raw message keys. The `modal.*` keys are exported, so an app running i18n can translate or override any of them. A `base` answering `2xx` with a body that is not a page reports a 502 carrying the parse failure as its `cause`.
88
+ - Keep server-only code out of the `@stratal/inertia-modal/react` entry, so pages hydrate in development. The entry reached request handling that runs on `node:async_hooks`; a production build dropped it as unused, but a development build shipped it to the browser where it cannot resolve, so no page rendering `Modal` hydrated.
89
+ - Keep the render of the page beneath a modal inside the isolate that issued it, rather than forwarding it to a response-cache gateway where the background marker means nothing — which refused a base page that answers with a redirect, and could store that render under the visitor's own cache key.
90
+
91
+ ### Breaking Changes
92
+ - **`ctx.inertiaModal(component, props, { baseURL })` is replaced by `ctx.modal(component, props, { base })`.** Rename the call and the option at every call site, including modal routes whose background is another modal route — there is no separate call for those.
93
+ - **`MODAL_VISIT` and `MODAL_REFRESH` are removed.** Replace `<Link {...MODAL_VISIT}>` with `<ModalLink>`, and a `MODAL_REFRESH` visit with `refresh()` from `useModal()`.
94
+ - **`useModal().redirect()` is renamed to `close()`.** Update `const { redirect } = useModal()` to `const { close } = useModal()`, and any `onClick={redirect}` to `onClick={close}`.
95
+ - **`useModal()` no longer returns `show` or `props`.** Read `modal` instead: it is `undefined` outside a modal, and carries the level's `props`.
96
+ - **`useModalPropPath` is removed.** A level's props sit at a fixed path, so `reload({ only: ['items'] })` from `useModal()` names them by bare name.
97
+ - **`prepareModalComponents`, `rememberModalComponents`, `clearModalComponents` and `ModalComponentsContext` are removed.** Pass `resolve` through `withModals()` instead. A test suite that called `clearModalComponents` between tests wants `resetModalState()`.
98
+ - **`modalPropPath` moves to `@stratal/inertia-modal/testing`** and takes only a prop name.
99
+ - **`ModalNestingLimitError`, `ModalPayloadMismatchError` and `ModalRequestHeaderError` are removed**, along with every modal request header. Nothing about the stack travels to the server any more.
100
+ - **A response carries one modal, at `page.props.modal`**, addressed at `modal.props.<name>` with no key in the path; the keyed and positional containers are both gone, and `ModalData.nativeBack` with them. No runtime code outside the package reads the payload, so this affects only tests asserting on it directly — move those onto the assertions above rather than onto the new field names:
101
+
102
+ ```diff
103
+ -expect(body.props.modal.stack[0].component).toBe('Parent/Edit')
104
+ -expect(body.props.modal.stack[0].props.item.id).toBe(target.id)
105
+ +await response.assertModalComponents(['Parent/Edit'])
106
+ +await response.assertModalProp('item.id', target.id)
107
+ ```
108
+
109
+ - **Modal levels now server-render**, where they were previously always drawn in after hydration. A consumer relying on client-only mounting inside a level — a `useLayoutEffect` that assumed it would never run on the server, say — should read this as a behaviour change, not a fix.
110
+
111
+ ### Patch Changes
112
+
113
+ - Updated dependencies [a753e55]
114
+ - Updated dependencies [a753e55]
115
+ - Updated dependencies [a753e55]
116
+ - stratal@0.1.0
117
+ - @stratal/inertia@0.1.0
118
+ - @stratal/testing@0.1.0
119
+
120
+ ## 0.0.27
121
+
122
+ ### Patch Changes
123
+
124
+ - Updated dependencies [41a9140]
125
+ - stratal@0.0.27
126
+ - @stratal/inertia@0.0.27
127
+
128
+ ## 0.0.26
129
+
130
+ ### Patch Changes
131
+
132
+ - Updated dependencies [ab95f52]
133
+ - Updated dependencies [ab95f52]
134
+ - Updated dependencies [bb6d3b9]
135
+ - stratal@0.0.26
136
+ - @stratal/inertia@0.0.26
137
+
138
+ ## 0.0.25
139
+
140
+ ### Patch Changes
141
+
142
+ - Updated dependencies [e93db60]
143
+ - Updated dependencies [e93db60]
144
+ - stratal@0.0.25
145
+ - @stratal/inertia@0.0.25
146
+
147
+ ## 0.0.24
148
+
149
+ ### Patch Changes
150
+
151
+ - Updated dependencies [10cf223]
152
+ - @stratal/inertia@0.0.24
153
+ - stratal@0.0.24
154
+
155
+ ## 0.0.23
156
+
157
+ ### Patch Changes
158
+
159
+ - Updated dependencies [13b0e8d]
160
+ - Updated dependencies [13b0e8d]
161
+ - Updated dependencies [13b0e8d]
162
+ - Updated dependencies [13b0e8d]
163
+ - Updated dependencies [13b0e8d]
164
+ - Updated dependencies [13b0e8d]
165
+ - Updated dependencies [13b0e8d]
166
+ - Updated dependencies [13b0e8d]
167
+ - Updated dependencies [13b0e8d]
168
+ - Updated dependencies [13b0e8d]
169
+ - Updated dependencies [13b0e8d]
170
+ - Updated dependencies [be813bc]
171
+ - stratal@0.0.23
172
+ - @stratal/inertia@0.0.23
173
+
174
+ ## 0.0.22
175
+
176
+ ### Patch Changes
177
+
178
+ - 4b273ea: Add `nativeBack` support to modal navigation and eagerly resolve deferred props in background page fetches
179
+ - `useModal().redirect()` now uses `history.back()` instead of a server round-trip when the modal was loaded via a partial reload, providing instant close behavior.
180
+ - Background page fetches send `x-inertia-resolve-deferred: true` to ensure deferred props are included in the response.
181
+
182
+ - 1658945: Fix modal component re-rendering by tracking component path instead of nonce
183
+ - Updated dependencies [1658945]
184
+ - Updated dependencies [1658945]
185
+ - Updated dependencies [4b273ea]
186
+ - Updated dependencies [4b273ea]
187
+ - @stratal/inertia@0.0.22
188
+ - stratal@0.0.22
189
+
190
+ ## 0.0.21
191
+
192
+ ### Patch Changes
193
+
194
+ - 3489cfd: Preserve query string and forwarded headers on modal background requests
195
+ - The background page request now keeps the referer URL's query string, so opening a modal no longer resets the parent list view's filter/pagination state to defaults.
196
+ - `x-forwarded-proto`, `x-forwarded-host`, `x-forwarded-for`, `x-forwarded-port`, `x-real-ip`, `accept-language`, and `user-agent` are forwarded from the original request when present. Middleware that reconstructs the canonical request URL (e.g. apps whose `appUrl` is derived from forwarded headers) now sees the same protocol/host as the original request, fixing background fetches that previously appeared unauthenticated because Better Auth's secure-cookie prefix was resolved against the wrong base URL.
197
+
198
+ - Updated dependencies [3489cfd]
199
+ - Updated dependencies [3489cfd]
200
+ - Updated dependencies [3489cfd]
201
+ - Updated dependencies [3489cfd]
202
+ - stratal@0.0.21
203
+ - @stratal/inertia@0.0.21
204
+
205
+ ## 0.0.20
206
+
207
+ ### Patch Changes
208
+
209
+ - f8c61e1: Loosen peer dependency ranges for broader compatibility
210
+
211
+ Peer dependencies (`@inertiajs/core`, `@inertiajs/react`, `hono`, `react`, `reflect-metadata`, `stratal`) now use `>=` ranges instead of pinned `^` ranges, so apps can adopt newer majors of these packages without waiting for a coordinated bump.
212
+
213
+ - Updated dependencies [f8c61e1]
214
+ - Updated dependencies [f8c61e1]
215
+ - Updated dependencies [f8c61e1]
216
+ - Updated dependencies [f8c61e1]
217
+ - Updated dependencies [f8c61e1]
218
+ - Updated dependencies [f8c61e1]
219
+ - Updated dependencies [f8c61e1]
220
+ - Updated dependencies [f8c61e1]
221
+ - Updated dependencies [f8c61e1]
222
+ - stratal@0.0.20
223
+ - @stratal/inertia@0.0.20
224
+
225
+ ## 0.0.19
226
+
227
+ ### Patch Changes
228
+
229
+ - 5d26c24: Rearchitect i18n module augmentation to a per-module keyed registry (breaking change)
230
+
231
+ **Why:** Multiple modules augmenting `AppMessages` with a shared top-level parent (e.g., `errors.auth`, `errors.uploads`, `errors.branding`) collided with TypeScript error **TS2717** ("Subsequent property declarations must have the same type"). Interface merging adds new properties across declarations but requires same-named properties to have structurally identical types — it does not deep-merge nested shapes.
232
+
233
+ **What changed:**
234
+ - Replaced the single augmentable `AppMessages` interface with an `AppMessageNamespaces` keyed registry. Each module declares its own distinct top-level key (Laravel-style package namespacing). Because each declaration adds a different property, interface merging accepts them all.
235
+ - `AppMessages` is now derived: `{ [K in keyof AppMessageNamespaces]: AppMessageNamespaces[K] }`.
236
+ - Access keys are unchanged dot-notation — `i18n.t('auth.errors.invalidCredentials')` — so no custom resolver is needed.
237
+
238
+ **Migration:**
239
+
240
+ Before:
241
+
242
+ ```ts
243
+ declare module "stratal/i18n" {
244
+ interface AppMessages {
245
+ errors: { uploads: { notFound: string } };
246
+ }
247
+ }
248
+ ```
249
+
250
+ After:
251
+
252
+ ```ts
253
+ declare module "stratal/i18n" {
254
+ interface AppMessageNamespaces {
255
+ uploads: { errors: { notFound: string } };
256
+ }
257
+ }
258
+ ```
259
+
260
+ **Framework package moves:**
261
+ - All `errors.auth.*` keys (previously split between `stratal` core and `@stratal/framework`) now live in the auth module as `auth.errors.*`. `errors.auth.org.*` → `auth.org.*`. The `errors.auth.*` namespace has been removed from `stratal`'s core messages.
262
+ - `@stratal/framework`'s `DatabaseModule` now registers its `database.*` validation messages via `I18nModule.registerMessages` (previously the messages file existed but was never wired up).
263
+ - `@stratal/inertia-modal`'s `errors.modal.*` key moved to `modal.errors.*`.
264
+
265
+ **Callsite updates required in downstream apps:**
266
+
267
+ ```ts
268
+ // Before
269
+ new ApplicationError('errors.auth.invalidCredentials', ...)
270
+ i18n.t('errors.auth.org.organizationNotFound')
271
+
272
+ // After
273
+ new ApplicationError('auth.errors.invalidCredentials', ...)
274
+ i18n.t('auth.org.organizationNotFound')
275
+ ```
276
+
277
+ No runtime API change: `I18nModule.registerMessages(messages)` keeps its existing signature, and deep-merge behavior is unchanged. Locale-only contributions that override core's built-in `errors.*` / `common.*` / etc. continue to work.
278
+
279
+ - Updated dependencies [3b16f5b]
280
+ - Updated dependencies [5d26c24]
281
+ - Updated dependencies [5d26c24]
282
+ - Updated dependencies [3b16f5b]
283
+ - Updated dependencies [3b16f5b]
284
+ - Updated dependencies [5d26c24]
285
+ - Updated dependencies [5d26c24]
286
+ - Updated dependencies [3b16f5b]
287
+ - stratal@0.0.19
288
+ - @stratal/inertia@0.0.19
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # @stratal/inertia-modal
2
+
3
+ Backend-driven modal pages for [Stratal](https://stratal.dev) Inertia apps. A modal route is a real route, so direct visits, refreshes and back/forward navigation all work.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@stratal/inertia-modal)](https://www.npmjs.com/package/@stratal/inertia-modal)
6
+ [![CI](https://github.com/strataljs/stratal/actions/workflows/ci.yml/badge.svg)](https://github.com/strataljs/stratal/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
8
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/strataljs/stratal/badge)](https://securityscorecards.dev/viewer/?uri=github.com/strataljs/stratal)
9
+ [![Known Vulnerabilities](https://snyk.io/test/github/strataljs/stratal/badge.svg)](https://snyk.io/test/github/strataljs/stratal)
10
+ [![npm downloads](https://img.shields.io/npm/dm/@stratal/inertia-modal)](https://www.npmjs.com/package/@stratal/inertia-modal)
11
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
12
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/strataljs/stratal/pulls)
13
+ [![GitHub stars](https://img.shields.io/github/stars/strataljs/stratal?style=social)](https://github.com/strataljs/stratal)
14
+
15
+ ## How it works
16
+
17
+ The server renders **one modal per response** and the browser holds the stack. Opening a sheet is one request that renders one component; the page beneath is rendered only for a direct visit or a refresh — the one case with nothing already on screen.
18
+
19
+ - **Permalinkable** — a modal URL can be shared, bookmarked and refreshed
20
+ - **Stackable** — a modal opened from inside another appears above it, with the one below still mounted
21
+ - **Headless** — `<Modal />` renders your components; bring your own dialog, sheet or drawer
22
+ - **Typed page props** — `usePage().props.modal` is typed, no cast
23
+ - **Test assertions** — `assertModalComponent()`, `assertModalProp()` and more
24
+
25
+ ## Installation
26
+
27
+ Requires `@stratal/inertia` already configured.
28
+
29
+ ```bash
30
+ npm install @stratal/inertia-modal
31
+ # or
32
+ yarn add @stratal/inertia-modal
33
+ ```
34
+
35
+ ## Setup
36
+
37
+ Add `ModalModule` to your root module. It needs no configuration and registers its own i18n messages.
38
+
39
+ ```typescript
40
+ import { Module } from 'stratal/module'
41
+ import { InertiaModule } from '@stratal/inertia'
42
+ import { ModalModule } from '@stratal/inertia-modal'
43
+
44
+ @Module({
45
+ imports: [
46
+ InertiaModule.forRoot({ rootView: 'app' }),
47
+ ModalModule,
48
+ ],
49
+ })
50
+ export class AppModule {}
51
+ ```
52
+
53
+ ## Render a modal route
54
+
55
+ Use `ctx.modal(component, props, { base })` in any controller. `base` is what sits *beneath* this level — a page route, or another modal route.
56
+
57
+ ```typescript
58
+ import { Controller, Get, type RouterContext } from 'stratal/router'
59
+ import { object, string } from 'zod/mini'
60
+
61
+ @Controller('/parent')
62
+ export class ParentController {
63
+ @Get('/:id/edit', { params: object({ id: string() }) })
64
+ async edit(ctx: RouterContext) {
65
+ const item = await this.service.find(ctx.param('id'))
66
+ return ctx.modal('Parent/Edit', { item }, { base: '/parent' })
67
+ }
68
+ }
69
+ ```
70
+
71
+ On an in-app visit to `/parent/42/edit` the current page stays mounted and `Parent/Edit` is drawn over it. On a direct visit, the framework follows `base` in-process until it reaches a route that is not a modal, and renders that as the page.
72
+
73
+ Write `base` as a route that always renders for anyone who can reach the modal route — it is the guaranteed background. A `base` that cannot be rendered raises `ModalBackgroundFetchError`.
74
+
75
+ ## Frontend setup
76
+
77
+ Pass `createInertiaApp`'s `resolve` through `withModals()` in **both** entries, and place `<Modal />` once in your layout. `withModals()` resolves every open level's component ahead of the render, so `setup` only hydrates.
78
+
79
+ ```tsx
80
+ // src/inertia/app.tsx — client entry
81
+ import { createInertiaApp } from '@inertiajs/react'
82
+ import { hydrateRoot } from 'react-dom/client'
83
+ import { withModals } from '@stratal/inertia-modal/react'
84
+
85
+ const pages = import.meta.glob('./pages/**/*.tsx')
86
+
87
+ const resolve = async (name: string) => {
88
+ const page = await pages[`./pages/${name}.tsx`]?.()
89
+ if (!page) throw new Error(`Page not found: ${name}`)
90
+ return page
91
+ }
92
+
93
+ createInertiaApp({
94
+ resolve: withModals(resolve),
95
+ setup: ({ el, App, props }) => hydrateRoot(el, <App {...props} />),
96
+ })
97
+ ```
98
+
99
+ ```tsx
100
+ // src/inertia/ssr.tsx — SSR entry
101
+ import { createInertiaSsrApp } from '@stratal/inertia/ssr'
102
+ import { withModals } from '@stratal/inertia-modal/react'
103
+
104
+ export const { render } = createInertiaSsrApp({
105
+ resolve: withModals(resolve),
106
+ })
107
+ ```
108
+
109
+ ```tsx
110
+ // src/inertia/layouts/dashboard-layout.tsx
111
+ import { Modal } from '@stratal/inertia-modal/react'
112
+
113
+ export function DashboardLayout({ children }) {
114
+ return (
115
+ <>
116
+ <Sidebar />
117
+ <main>{children}</main>
118
+ <Modal />
119
+ </>
120
+ )
121
+ }
122
+ ```
123
+
124
+ > A level rendered inside a portal cannot server-render. `createPortal` is client-only, so a level whose content sits in a Radix `DialogPortal` / `SheetPortal` is absent from the server HTML however the entries are wired. Render it outside a portal if its content must be in first paint.
125
+
126
+ ## Open a sheet
127
+
128
+ `<ModalLink>` carries the visit options that keep the page beneath mounted, in place, and un-refetched.
129
+
130
+ ```tsx
131
+ import { ModalLink } from '@stratal/inertia-modal/react'
132
+
133
+ <ModalLink href={`/parent/${item.id}/edit`}>Edit</ModalLink>
134
+ <ModalLink href={`/parent/${item.id}/edit`} prefetch>Edit</ModalLink>
135
+ ```
136
+
137
+ ## Inside a modal
138
+
139
+ `useModal()` gives the current level and the ways to act on it:
140
+
141
+ ```tsx
142
+ import { useModal } from '@stratal/inertia-modal/react'
143
+
144
+ function EditSheet() {
145
+ const { modal, depth, isTop, close, closeAll, refresh, reload, visit } = useModal()
146
+
147
+ return (
148
+ <Dialog open onOpenChange={(open) => { if (!open) close() }}>
149
+ <button onClick={() => refresh({ sort: 'name' })}>Sort by name</button>
150
+ <button onClick={() => reload({ only: ['items'] })}>Refresh items</button>
151
+ <button onClick={() => visit(`/parent/${modal.props.item.id}/delete`)}>Delete</button>
152
+ </Dialog>
153
+ )
154
+ }
155
+ ```
156
+
157
+ | Member | Description |
158
+ |---|---|
159
+ | `modal` | This level's data, or `undefined` outside a modal |
160
+ | `depth` | How deep this level sits; the outermost is `0` |
161
+ | `isTop` | Whether this is the level the reader is looking at |
162
+ | `close()` | Close this level and land where it was opened from |
163
+ | `closeAll()` | Close every open level |
164
+ | `refresh(query?)` | Re-read this level under a refined query |
165
+ | `reload(options?)` | Fetch some of this level's props again, named in the level's own terms |
166
+ | `visit(href, options?)` | Open a modal route from code |
167
+
168
+ Closing is always an explicit visit, never `history.back()` — a cached history entry would rewind the whole page, not just the modal.
169
+
170
+ ## Deferred and infinite-scroll props
171
+
172
+ A level's props are nested under one page prop, so Inertia's own `<Deferred>` and `<InfiniteScroll>` cannot address them by name. Import these instead and the same JSX works inside a sheet and on a page:
173
+
174
+ ```tsx
175
+ import { Deferred, InfiniteScroll } from '@stratal/inertia-modal/react'
176
+
177
+ <Deferred data="stats" fallback={<Spinner />}>
178
+ <Stats />
179
+ </Deferred>
180
+
181
+ <InfiniteScroll data="items">
182
+ {items.data.map((item) => <Row key={item.id} item={item} />)}
183
+ </InfiniteScroll>
184
+ ```
185
+
186
+ ## Testing
187
+
188
+ ```typescript
189
+ // vitest.setup.ts
190
+ import '@stratal/inertia-modal/testing' // augments TestResponse with modal assertions
191
+ import { resetModalState } from '@stratal/inertia-modal/react'
192
+
193
+ afterEach(() => resetModalState())
194
+ ```
195
+
196
+ ```typescript
197
+ const response = await module.http.get('/parent/42/edit').send()
198
+
199
+ await response.assertModalComponent('Parent/Edit')
200
+ await response.assertModalBase('/parent')
201
+ await response.assertModalProp('item.id', '42')
202
+ await response.assertModalCount(1)
203
+ ```
204
+
205
+ Available assertions: `assertModal`, `assertNoModal`, `assertModalComponent`, `assertModalComponents`, `assertModalBase`, `assertModalClose`, `assertModalCount`, `assertModalDepth`, `assertModalProp`, `assertModalOnly`.
206
+
207
+ `resetModalState()` clears the three stores that deliberately outlive a render — the open stack, the page a level grafts onto, and the resolved components. Call it between tests.
208
+
209
+ ## Documentation
210
+
211
+ Full guides and examples are available at **[stratal.dev](https://stratal.dev)**.
212
+
213
+ ## Support the project
214
+
215
+ If Stratal is useful to you, **[star the repository](https://github.com/strataljs/stratal)** — it is the simplest way to help others find it.
216
+
217
+ ## Maintainer
218
+
219
+ Built and maintained by **Temitayo Fadojutimi** — [@adesege_](https://x.com/adesege_).
220
+
221
+ ## License
222
+
223
+ MIT
package/dist/index.d.mts CHANGED
@@ -1,46 +1,109 @@
1
+ import { a as ModalData, i as MODAL_PROP, n as MODAL_DOCUMENT_HEADER, r as MODAL_MARKER_HEADER, t as MODAL_BENEATH_PROP } from "./wire-CbwmWkPr.mjs";
2
+ import "./page-props-BCEWEw3V.mjs";
1
3
  import { OnInitialize } from "stratal/module";
2
4
  import { RouterContext } from "stratal/router";
5
+ import { HttpException } from "stratal/errors";
6
+ import "@stratal/inertia";
7
+ import { Page } from "@inertiajs/core";
3
8
  //#region src/modal.module.d.ts
4
- declare class ModalModule implements OnInitialize {
9
+ export declare class ModalModule implements OnInitialize {
5
10
  onInitialize(): void;
6
11
  }
7
12
  //#endregion
8
13
  //#region src/tokens.d.ts
9
- declare const MODAL_TOKENS: {
14
+ export declare const MODAL_TOKENS: {
10
15
  readonly ModalService: symbol;
16
+ /**
17
+ * How the page beneath a modal is fetched on a document request. Override it to dispatch through
18
+ * something other than the app in process.
19
+ */
20
+ readonly BackgroundDispatcher: symbol;
11
21
  };
12
22
  //#endregion
13
- //#region src/services/modal.service.d.ts
14
- interface ModalData {
15
- component: string;
16
- props: Record<string, unknown>;
17
- baseURL: string;
18
- redirectURL: string;
19
- key: string;
20
- nativeBack: boolean;
23
+ //#region src/i18n/en.d.ts
24
+ export declare const modalMessages: {
25
+ readonly en: {
26
+ readonly errors: {
27
+ readonly backgroundFetchFailed: 'Failed to load background page for modal';
28
+ readonly baseCycle: 'The modal base chain leads back to {url}';
29
+ };
30
+ };
31
+ };
32
+ declare module 'stratal/i18n' {
33
+ interface AppMessageNamespaces {
34
+ modal: typeof modalMessages['en'];
35
+ }
21
36
  }
37
+ //#endregion
38
+ //#region src/server/background.d.ts
39
+ interface ModalBackgroundDispatcher {
40
+ fetch(request: Request, ctx: RouterContext): Promise<Response>;
41
+ }
42
+ /**
43
+ * Whether this request is the background render issued for the page beneath a modal.
44
+ *
45
+ * A route that answers a client with a redirect still has to render when it is the `base` of a
46
+ * modal that client may open — otherwise the redirect is followed back to the modal and the chain
47
+ * reports a cycle.
48
+ *
49
+ * A dispatcher that leaves the isolate answers `false` on the far side, which is the safe
50
+ * direction: the route gates as it would for a client rather than opening for one.
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * if (isModalBackground(ctx)) return next()
55
+ * ```
56
+ */
57
+ export declare function isModalBackground(ctx: RouterContext): boolean;
58
+ //#endregion
59
+ //#region src/server/modal.service.d.ts
22
60
  interface ModalRenderOptions {
23
- baseURL: string;
61
+ /** What sits beneath this level: a page route, or another modal route. */
62
+ base: string;
63
+ }
64
+ //#endregion
65
+ //#region src/errors/modal-background-fetch.error.d.ts
66
+ /**
67
+ * Thrown when the sub-request for the page beneath a modal answers with something the chain cannot
68
+ * be built from — a non-2xx, a redirect, an empty body, a body that is not a page, or a level of a
69
+ * shape this build cannot read.
70
+ *
71
+ * `cause` carries the parse failure where there was one. The status this reports is a property of
72
+ * the exchange, not of the reason, so every one of those answers the caller identically; a `base`
73
+ * pointing at a route that does not render a page is still a mistake someone has to find, and the
74
+ * reason is the only thing that says which mistake it was.
75
+ *
76
+ * HTTP Status: 502 Bad Gateway — this service acted as a proxy and the upstream answered
77
+ * unexpectedly.
78
+ */
79
+ export declare class ModalBackgroundFetchError extends HttpException {
80
+ constructor(cause?: unknown);
81
+ }
82
+ //#endregion
83
+ //#region src/errors/modal-base-cycle.error.d.ts
84
+ /**
85
+ * Raised when a route's `base` chain leads back to a route already in it.
86
+ *
87
+ * Assembling the chain costs one sub-request per level, so a cycle would otherwise run until the
88
+ * runtime's sub-request budget is exhausted and surface as an opaque failure.
89
+ */
90
+ export declare class ModalBaseCycleError extends HttpException {
91
+ constructor(url: string);
24
92
  }
25
93
  //#endregion
26
94
  //#region src/augment/router-context.d.ts
27
95
  declare module 'stratal/router' {
28
96
  interface RouterContext {
29
97
  /**
30
- * Renders a modal page component over a background page.
31
- *
32
- * The background page at `options.baseURL` is always rendered as the main
33
- * Inertia page. The given `component` and `props` are embedded in the
34
- * background page's `modal` prop and rendered as an overlay by the
35
- * client-side `<Modal>` component.
98
+ * Renders `component` as a modal over whatever the client already has mounted.
36
99
  *
37
- * Handles direct URL visits by fetching the background page in-process.
38
- * Handles partial reloads (e.g., cascading selects) when `only: ['modal']`
39
- * is requested.
100
+ * `options.base` declares what sits beneath this level a page route, or another modal route.
101
+ * On a document request that chain is followed and rendered, so a modal URL stays a permalink;
102
+ * on an Inertia visit only this level is sent, and the client grafts it onto the page it holds.
40
103
  */
41
- inertiaModal(component: string, props: Record<string, unknown>, options: ModalRenderOptions): Promise<Response>;
104
+ modal(component: string, props: Record<string, unknown>, options: ModalRenderOptions): Promise<Response>;
42
105
  }
43
106
  }
44
107
  //#endregion
45
- export { MODAL_TOKENS, type ModalData, ModalModule, type ModalRenderOptions };
108
+ export { MODAL_BENEATH_PROP, MODAL_DOCUMENT_HEADER, MODAL_MARKER_HEADER, MODAL_PROP, type ModalBackgroundDispatcher, type ModalData, type ModalRenderOptions };
46
109
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/modal.module.ts","../src/tokens.ts","../src/services/modal.service.ts","../src/augment/router-context.ts"],"mappings":";;;cAWa,WAAA,YAAuB,YAAY;EAC9C,YAAY;AAAA;;;cCZD,YAAA;EAAA,SAEH,YAAA;AAAA;;;UCKO,SAAA;EACf,SAAA;EACA,KAAA,EAAO,MAAM;EACb,OAAA;EACA,WAAA;EACA,GAAA;EACA,UAAA;AAAA;AAAA,UAGe,kBAAA;EACf,OAAO;AAAA;;;;YCZG,aAAA;IHMC;;;;AACC;;;;ACZd;;;;IEkBI,YAAA,CACE,SAAA,UACA,KAAA,EAAO,MAAA,mBACP,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,QAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/modal.module.ts","../src/tokens.ts","../src/i18n/en.ts","../src/server/background.ts","../src/server/modal.service.ts","../src/errors/modal-background-fetch.error.ts","../src/errors/modal-base-cycle.error.ts","../src/augment/router-context.ts"],"mappings":";;;;;;;;qBAwBa,uBAAuB;EAClC;;;;qBCzBW;WACX;;;;;WAKA;;;;qBCNW;WACX;aACE;eACE;eACA;;;;;YAMM;IACR,cAAc;;;;;UCOD;EACf,MAAM,SAAS,SAAS,KAAK,gBAAgB,QAAQ;;;;;;;;;;;;;;;;;wBAmCvC,kBAAkB,KAAK;;;UC7BtB;;EAEf;;;;;;;;;;;;;;;;;qBCXW,kCAAkC;EAC7C,YAAY;;;;;;;;;;qBCRD,4BAA4B;EACvC,YAAY;;;;;YCLF;;;;;;;;IAQR,MACE,mBACA,OAAO,yBACP,SAAS,qBACR,QAAQ"}