@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# @stratal/inertia-modal
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [99e36c5]
|
|
8
|
+
- Updated dependencies [df54f36]
|
|
9
|
+
- @stratal/inertia@0.1.1
|
|
10
|
+
- stratal@0.1.1
|
|
11
|
+
- @stratal/testing@0.1.1
|
|
12
|
+
|
|
13
|
+
## 0.1.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 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()`.
|
|
18
|
+
|
|
19
|
+
### Nested stacks
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- `<ModalLink>` opens a sheet and carries the visit options that keep the page behind it in place.
|
|
24
|
+
- `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.
|
|
25
|
+
- `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.
|
|
26
|
+
- `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.
|
|
27
|
+
|
|
28
|
+
### Closing a level
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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.
|
|
32
|
+
- The first browser Back after closing lands where the close already landed, so it appears to do nothing.
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
35
|
+
### Prop helpers, scroll and SSR
|
|
36
|
+
- `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.
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
return ctx.modal(
|
|
40
|
+
"Parent/Index",
|
|
41
|
+
{
|
|
42
|
+
items: ctx.scroll(
|
|
43
|
+
() =>
|
|
44
|
+
db.$cursor.item.findMany({
|
|
45
|
+
cursor,
|
|
46
|
+
take: 20,
|
|
47
|
+
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
|
48
|
+
}),
|
|
49
|
+
{ matchOn: "id" },
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
{ base: "/parent" },
|
|
53
|
+
);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `<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.
|
|
57
|
+
- 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.
|
|
58
|
+
- **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.
|
|
59
|
+
- 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.
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
createInertiaApp({
|
|
63
|
+
resolve: withModals((name) => pages[`./pages/${name}.tsx`]()),
|
|
64
|
+
setup: ({ el, App, props }) => hydrateRoot(el, <App {...props} />),
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- 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.
|
|
69
|
+
- 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.
|
|
70
|
+
- 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.
|
|
71
|
+
|
|
72
|
+
### Testing
|
|
73
|
+
|
|
74
|
+
Import `@stratal/inertia-modal/testing` alongside `@stratal/inertia/testing`. Eight assertions, chainable like the Inertia family, plus two readers:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
await response.assertModalComponents(["Parent/Index", "Parent/Edit"]);
|
|
78
|
+
await response.assertModalProp("item.id", "42");
|
|
79
|
+
|
|
80
|
+
const level = await response.modalLevel<{ items: Item[] }>();
|
|
81
|
+
expect(level.props.items).toHaveLength(1);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `assertModal(callback?)` / `assertNoModal()`, `assertModalComponent(component, depth?)`, `assertModalComponents(components)`, `assertModalCount(count)`, `assertModalDepth(depth)`, `assertModalProp(path, expected, depth?)`, `assertModalOnly()`, plus `modalLevel<TProps>(depth?)` and `modalLevels<TProps>()`.
|
|
85
|
+
- `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.
|
|
86
|
+
- 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`.
|
|
87
|
+
- `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.
|
|
88
|
+
- `modalPropPath(prop)` is exported for a test naming a level's prop path.
|
|
89
|
+
|
|
90
|
+
### Fixes
|
|
91
|
+
- `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.
|
|
92
|
+
- 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.
|
|
93
|
+
- 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.
|
|
94
|
+
- 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.
|
|
95
|
+
- 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.
|
|
96
|
+
- 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`.
|
|
97
|
+
- 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`.
|
|
98
|
+
- 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.
|
|
99
|
+
- 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.
|
|
100
|
+
|
|
101
|
+
### Breaking Changes
|
|
102
|
+
- **`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.
|
|
103
|
+
- **`MODAL_VISIT` and `MODAL_REFRESH` are removed.** Replace `<Link {...MODAL_VISIT}>` with `<ModalLink>`, and a `MODAL_REFRESH` visit with `refresh()` from `useModal()`.
|
|
104
|
+
- **`useModal().redirect()` is renamed to `close()`.** Update `const { redirect } = useModal()` to `const { close } = useModal()`, and any `onClick={redirect}` to `onClick={close}`.
|
|
105
|
+
- **`useModal()` no longer returns `show` or `props`.** Read `modal` instead: it is `undefined` outside a modal, and carries the level's `props`.
|
|
106
|
+
- **`useModalPropPath` is removed.** A level's props sit at a fixed path, so `reload({ only: ['items'] })` from `useModal()` names them by bare name.
|
|
107
|
+
- **`prepareModalComponents`, `rememberModalComponents`, `clearModalComponents` and `ModalComponentsContext` are removed.** Pass `resolve` through `withModals()` instead. A test suite that called `clearModalComponents` between tests wants `resetModalState()`.
|
|
108
|
+
- **`modalPropPath` moves to `@stratal/inertia-modal/testing`** and takes only a prop name.
|
|
109
|
+
- **`ModalNestingLimitError`, `ModalPayloadMismatchError` and `ModalRequestHeaderError` are removed**, along with every modal request header. Nothing about the stack travels to the server any more.
|
|
110
|
+
- **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:
|
|
111
|
+
|
|
112
|
+
```diff
|
|
113
|
+
-expect(body.props.modal.stack[0].component).toBe('Parent/Edit')
|
|
114
|
+
-expect(body.props.modal.stack[0].props.item.id).toBe(target.id)
|
|
115
|
+
+await response.assertModalComponents(['Parent/Edit'])
|
|
116
|
+
+await response.assertModalProp('item.id', target.id)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **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.
|
|
120
|
+
|
|
121
|
+
### Patch Changes
|
|
122
|
+
|
|
123
|
+
- Updated dependencies [a753e55]
|
|
124
|
+
- Updated dependencies [a753e55]
|
|
125
|
+
- Updated dependencies [a753e55]
|
|
126
|
+
- stratal@0.1.0
|
|
127
|
+
- @stratal/inertia@0.1.0
|
|
128
|
+
- @stratal/testing@0.1.0
|
|
129
|
+
|
|
130
|
+
## 0.0.27
|
|
131
|
+
|
|
132
|
+
### Patch Changes
|
|
133
|
+
|
|
134
|
+
- Updated dependencies [41a9140]
|
|
135
|
+
- stratal@0.0.27
|
|
136
|
+
- @stratal/inertia@0.0.27
|
|
137
|
+
|
|
138
|
+
## 0.0.26
|
|
139
|
+
|
|
140
|
+
### Patch Changes
|
|
141
|
+
|
|
142
|
+
- Updated dependencies [ab95f52]
|
|
143
|
+
- Updated dependencies [ab95f52]
|
|
144
|
+
- Updated dependencies [bb6d3b9]
|
|
145
|
+
- stratal@0.0.26
|
|
146
|
+
- @stratal/inertia@0.0.26
|
|
147
|
+
|
|
148
|
+
## 0.0.25
|
|
149
|
+
|
|
150
|
+
### Patch Changes
|
|
151
|
+
|
|
152
|
+
- Updated dependencies [e93db60]
|
|
153
|
+
- Updated dependencies [e93db60]
|
|
154
|
+
- stratal@0.0.25
|
|
155
|
+
- @stratal/inertia@0.0.25
|
|
156
|
+
|
|
157
|
+
## 0.0.24
|
|
158
|
+
|
|
159
|
+
### Patch Changes
|
|
160
|
+
|
|
161
|
+
- Updated dependencies [10cf223]
|
|
162
|
+
- @stratal/inertia@0.0.24
|
|
163
|
+
- stratal@0.0.24
|
|
164
|
+
|
|
165
|
+
## 0.0.23
|
|
166
|
+
|
|
167
|
+
### Patch Changes
|
|
168
|
+
|
|
169
|
+
- Updated dependencies [13b0e8d]
|
|
170
|
+
- Updated dependencies [13b0e8d]
|
|
171
|
+
- Updated dependencies [13b0e8d]
|
|
172
|
+
- Updated dependencies [13b0e8d]
|
|
173
|
+
- Updated dependencies [13b0e8d]
|
|
174
|
+
- Updated dependencies [13b0e8d]
|
|
175
|
+
- Updated dependencies [13b0e8d]
|
|
176
|
+
- Updated dependencies [13b0e8d]
|
|
177
|
+
- Updated dependencies [13b0e8d]
|
|
178
|
+
- Updated dependencies [13b0e8d]
|
|
179
|
+
- Updated dependencies [13b0e8d]
|
|
180
|
+
- Updated dependencies [be813bc]
|
|
181
|
+
- stratal@0.0.23
|
|
182
|
+
- @stratal/inertia@0.0.23
|
|
183
|
+
|
|
184
|
+
## 0.0.22
|
|
185
|
+
|
|
186
|
+
### Patch Changes
|
|
187
|
+
|
|
188
|
+
- 4b273ea: Add `nativeBack` support to modal navigation and eagerly resolve deferred props in background page fetches
|
|
189
|
+
- `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.
|
|
190
|
+
- Background page fetches send `x-inertia-resolve-deferred: true` to ensure deferred props are included in the response.
|
|
191
|
+
|
|
192
|
+
- 1658945: Fix modal component re-rendering by tracking component path instead of nonce
|
|
193
|
+
- Updated dependencies [1658945]
|
|
194
|
+
- Updated dependencies [1658945]
|
|
195
|
+
- Updated dependencies [4b273ea]
|
|
196
|
+
- Updated dependencies [4b273ea]
|
|
197
|
+
- @stratal/inertia@0.0.22
|
|
198
|
+
- stratal@0.0.22
|
|
199
|
+
|
|
200
|
+
## 0.0.21
|
|
201
|
+
|
|
202
|
+
### Patch Changes
|
|
203
|
+
|
|
204
|
+
- 3489cfd: Preserve query string and forwarded headers on modal background requests
|
|
205
|
+
- 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.
|
|
206
|
+
- `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.
|
|
207
|
+
|
|
208
|
+
- Updated dependencies [3489cfd]
|
|
209
|
+
- Updated dependencies [3489cfd]
|
|
210
|
+
- Updated dependencies [3489cfd]
|
|
211
|
+
- Updated dependencies [3489cfd]
|
|
212
|
+
- stratal@0.0.21
|
|
213
|
+
- @stratal/inertia@0.0.21
|
|
214
|
+
|
|
215
|
+
## 0.0.20
|
|
216
|
+
|
|
217
|
+
### Patch Changes
|
|
218
|
+
|
|
219
|
+
- f8c61e1: Loosen peer dependency ranges for broader compatibility
|
|
220
|
+
|
|
221
|
+
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.
|
|
222
|
+
|
|
223
|
+
- Updated dependencies [f8c61e1]
|
|
224
|
+
- Updated dependencies [f8c61e1]
|
|
225
|
+
- Updated dependencies [f8c61e1]
|
|
226
|
+
- Updated dependencies [f8c61e1]
|
|
227
|
+
- Updated dependencies [f8c61e1]
|
|
228
|
+
- Updated dependencies [f8c61e1]
|
|
229
|
+
- Updated dependencies [f8c61e1]
|
|
230
|
+
- Updated dependencies [f8c61e1]
|
|
231
|
+
- Updated dependencies [f8c61e1]
|
|
232
|
+
- stratal@0.0.20
|
|
233
|
+
- @stratal/inertia@0.0.20
|
|
234
|
+
|
|
235
|
+
## 0.0.19
|
|
236
|
+
|
|
237
|
+
### Patch Changes
|
|
238
|
+
|
|
239
|
+
- 5d26c24: Rearchitect i18n module augmentation to a per-module keyed registry (breaking change)
|
|
240
|
+
|
|
241
|
+
**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.
|
|
242
|
+
|
|
243
|
+
**What changed:**
|
|
244
|
+
- 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.
|
|
245
|
+
- `AppMessages` is now derived: `{ [K in keyof AppMessageNamespaces]: AppMessageNamespaces[K] }`.
|
|
246
|
+
- Access keys are unchanged dot-notation — `i18n.t('auth.errors.invalidCredentials')` — so no custom resolver is needed.
|
|
247
|
+
|
|
248
|
+
**Migration:**
|
|
249
|
+
|
|
250
|
+
Before:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
declare module "stratal/i18n" {
|
|
254
|
+
interface AppMessages {
|
|
255
|
+
errors: { uploads: { notFound: string } };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
After:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
declare module "stratal/i18n" {
|
|
264
|
+
interface AppMessageNamespaces {
|
|
265
|
+
uploads: { errors: { notFound: string } };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Framework package moves:**
|
|
271
|
+
- 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.
|
|
272
|
+
- `@stratal/framework`'s `DatabaseModule` now registers its `database.*` validation messages via `I18nModule.registerMessages` (previously the messages file existed but was never wired up).
|
|
273
|
+
- `@stratal/inertia-modal`'s `errors.modal.*` key moved to `modal.errors.*`.
|
|
274
|
+
|
|
275
|
+
**Callsite updates required in downstream apps:**
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
// Before
|
|
279
|
+
new ApplicationError('errors.auth.invalidCredentials', ...)
|
|
280
|
+
i18n.t('errors.auth.org.organizationNotFound')
|
|
281
|
+
|
|
282
|
+
// After
|
|
283
|
+
new ApplicationError('auth.errors.invalidCredentials', ...)
|
|
284
|
+
i18n.t('auth.org.organizationNotFound')
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
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.
|
|
288
|
+
|
|
289
|
+
- Updated dependencies [3b16f5b]
|
|
290
|
+
- Updated dependencies [5d26c24]
|
|
291
|
+
- Updated dependencies [5d26c24]
|
|
292
|
+
- Updated dependencies [3b16f5b]
|
|
293
|
+
- Updated dependencies [3b16f5b]
|
|
294
|
+
- Updated dependencies [5d26c24]
|
|
295
|
+
- Updated dependencies [5d26c24]
|
|
296
|
+
- Updated dependencies [3b16f5b]
|
|
297
|
+
- stratal@0.0.19
|
|
298
|
+
- @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
|
+
[](https://www.npmjs.com/package/@stratal/inertia-modal)
|
|
6
|
+
[](https://github.com/strataljs/stratal/actions/workflows/ci.yml)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/strataljs/stratal)
|
|
9
|
+
[](https://snyk.io/test/github/strataljs/stratal)
|
|
10
|
+
[](https://www.npmjs.com/package/@stratal/inertia-modal)
|
|
11
|
+
[](https://www.typescriptlang.org/)
|
|
12
|
+
[](https://github.com/strataljs/stratal/pulls)
|
|
13
|
+
[](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/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* is
|
|
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
|
-
|
|
104
|
+
modal(component: string, props: Record<string, unknown>, options: ModalRenderOptions): Promise<Response>;
|
|
42
105
|
}
|
|
43
106
|
}
|
|
44
107
|
//#endregion
|
|
45
|
-
export {
|
|
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
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/modal.module.ts","../src/tokens.ts","../src/
|
|
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"}
|