@stratal/inertia-modal 0.0.0-canary-d717e8a

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.
@@ -0,0 +1,46 @@
1
+ import { OnInitialize } from "stratal/module";
2
+ import { RouterContext } from "stratal/router";
3
+ //#region src/modal.module.d.ts
4
+ declare class ModalModule implements OnInitialize {
5
+ onInitialize(): void;
6
+ }
7
+ //#endregion
8
+ //#region src/tokens.d.ts
9
+ declare const MODAL_TOKENS: {
10
+ readonly ModalService: symbol;
11
+ };
12
+ //#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
+ nonce: string;
21
+ }
22
+ interface ModalRenderOptions {
23
+ baseURL: string;
24
+ }
25
+ //#endregion
26
+ //#region src/augment/router-context.d.ts
27
+ declare module 'stratal/router' {
28
+ interface RouterContext {
29
+ /**
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.
36
+ *
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.
40
+ */
41
+ inertiaModal(component: string, props: Record<string, unknown>, options: ModalRenderOptions): Promise<Response>;
42
+ }
43
+ }
44
+ //#endregion
45
+ export { MODAL_TOKENS, type ModalData, ModalModule, type ModalRenderOptions };
46
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +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":";;;cAca,WAAA,YAAuB,YAAA;EAClC,YAAA,CAAA;AAAA;;;cCfW,YAAA;EAAA,SAEH,YAAA;AAAA;;;UCKO,SAAA;EACf,SAAA;EACA,KAAA,EAAO,MAAA;EACP,OAAA;EACA,WAAA;EACA,GAAA;EACA,KAAA;AAAA;AAAA,UAGe,kBAAA;EACf,OAAA;AAAA;;;;YCZU,aAAA;IHSC;;;;;;;;ACdb;;;;IEkBI,YAAA,CACE,SAAA,UACA,KAAA,EAAO,MAAA,mBACP,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,QAAA;EAAA;AAAA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,178 @@
1
+ import { I18nModule } from "stratal/i18n";
2
+ import { Module } from "stratal/module";
3
+ import { ROUTER_TOKENS, RouterContext } from "stratal/router";
4
+ import { INERTIA_TOKENS } from "@stratal/inertia";
5
+ import { Transient, inject } from "stratal/di";
6
+ import { HttpException } from "stratal/errors";
7
+ //#region src/tokens.ts
8
+ const MODAL_TOKENS = { ModalService: Symbol.for("stratal:inertia-modal:service") };
9
+ //#endregion
10
+ //#region src/augment/router-context.ts
11
+ function augmentRouterContextWithModal(resolveService) {
12
+ RouterContext.macro("inertiaModal", function(component, props, options) {
13
+ return resolveService(this).render(this, component, props, options);
14
+ });
15
+ }
16
+ //#endregion
17
+ //#region src/i18n/index.ts
18
+ const i18nMessages = { en: { modal: { en: { errors: { backgroundFetchFailed: "Failed to load background page for modal" } } }.en } };
19
+ //#endregion
20
+ //#region src/errors/modal-background-fetch.error.ts
21
+ /**
22
+ * Thrown when the internal sub-request to fetch the background page fails
23
+ * (e.g., non-2xx response, redirect, or empty body).
24
+ *
25
+ * HTTP Status: 502 Bad Gateway — the modal service acted as a proxy and the
26
+ * upstream (background page) returned an unexpected response.
27
+ */
28
+ var ModalBackgroundFetchError = class extends HttpException {
29
+ constructor() {
30
+ super(502, "modal.errors.backgroundFetchFailed");
31
+ }
32
+ };
33
+ //#endregion
34
+ //#region \0@oxc-project+runtime@0.127.0/helpers/decorateMetadata.js
35
+ function __decorateMetadata(k, v) {
36
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
37
+ }
38
+ //#endregion
39
+ //#region \0@oxc-project+runtime@0.127.0/helpers/decorateParam.js
40
+ function __decorateParam(paramIndex, decorator) {
41
+ return function(target, key) {
42
+ decorator(target, key, paramIndex);
43
+ };
44
+ }
45
+ //#endregion
46
+ //#region \0@oxc-project+runtime@0.127.0/helpers/decorate.js
47
+ function __decorate(decorators, target, key, desc) {
48
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
49
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
50
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
51
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
52
+ }
53
+ //#endregion
54
+ //#region src/services/modal.service.ts
55
+ let ModalService = class ModalService {
56
+ constructor(app, ssr, template) {
57
+ this.app = app;
58
+ this.ssr = ssr;
59
+ this.template = template;
60
+ }
61
+ async render(ctx, component, props, options) {
62
+ const isInertia = ctx.c.req.header("x-inertia") === "true";
63
+ const partialComponent = ctx.c.req.header("x-inertia-partial-component");
64
+ const partialData = ctx.c.req.header("x-inertia-partial-data");
65
+ const redirectURL = this.resolveRedirectURL(ctx, options.baseURL);
66
+ const key = ctx.c.req.header("x-inertia-modal-key") ?? crypto.randomUUID();
67
+ const nonce = crypto.randomUUID();
68
+ const modalURL = new URL(ctx.c.req.url).pathname;
69
+ const modalData = {
70
+ component,
71
+ props,
72
+ baseURL: options.baseURL,
73
+ redirectURL,
74
+ key,
75
+ nonce
76
+ };
77
+ if (isInertia && partialComponent && partialData) {
78
+ if (partialData.split(",").map((s) => s.trim()).includes("modal")) return new Response(JSON.stringify({
79
+ component: partialComponent,
80
+ props: {
81
+ modal: modalData,
82
+ errors: {}
83
+ },
84
+ url: modalURL,
85
+ version: null,
86
+ flash: {},
87
+ rememberedState: {}
88
+ }), {
89
+ status: 200,
90
+ headers: {
91
+ "Content-Type": "application/json",
92
+ "X-Inertia": "true",
93
+ "Vary": "X-Inertia"
94
+ }
95
+ });
96
+ }
97
+ const bgResponse = await this.fetchBackground(ctx, redirectURL);
98
+ const bgText = await bgResponse.text();
99
+ if (!bgText || bgResponse.status >= 300) throw new ModalBackgroundFetchError();
100
+ const bgPage = JSON.parse(bgText);
101
+ const combinedPage = {
102
+ ...bgPage,
103
+ props: {
104
+ ...bgPage.props,
105
+ modal: modalData
106
+ },
107
+ url: modalURL
108
+ };
109
+ if (isInertia) return new Response(JSON.stringify(combinedPage), {
110
+ status: 200,
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ "X-Inertia": "true",
114
+ "Vary": "X-Inertia"
115
+ }
116
+ });
117
+ const ssrResult = await this.ssr.render(combinedPage);
118
+ const html = this.template.render(combinedPage, ssrResult.head, ssrResult.body);
119
+ return new Response(html, {
120
+ status: 200,
121
+ headers: { "Content-Type": "text/html; charset=utf-8" }
122
+ });
123
+ }
124
+ resolveRedirectURL(ctx, baseURL) {
125
+ const referer = ctx.c.req.header("referer");
126
+ if (ctx.c.req.header("x-inertia") === "true" && referer) try {
127
+ const refererURL = new URL(referer);
128
+ const currentURL = new URL(ctx.c.req.url);
129
+ if (refererURL.pathname !== currentURL.pathname) return refererURL.pathname;
130
+ } catch {}
131
+ return baseURL;
132
+ }
133
+ async fetchBackground(ctx, url) {
134
+ const currentURL = new URL(ctx.c.req.url);
135
+ const bgURL = new URL(url, currentURL.origin);
136
+ const bgRequest = new Request(bgURL.toString(), {
137
+ method: "GET",
138
+ headers: {
139
+ "x-inertia": "true",
140
+ "accept": "application/json",
141
+ "cookie": ctx.c.req.header("cookie") ?? "",
142
+ "host": ctx.c.req.header("host") ?? ""
143
+ }
144
+ });
145
+ return this.app.fetch(bgRequest, ctx.c.env, ctx.c.executionCtx);
146
+ }
147
+ };
148
+ ModalService = __decorate([
149
+ Transient(),
150
+ __decorateParam(0, inject(ROUTER_TOKENS.HonoApp)),
151
+ __decorateParam(1, inject(INERTIA_TOKENS.SsrRenderer)),
152
+ __decorateParam(2, inject(INERTIA_TOKENS.TemplateService)),
153
+ __decorateMetadata("design:paramtypes", [
154
+ Object,
155
+ Object,
156
+ Object
157
+ ])
158
+ ], ModalService);
159
+ //#endregion
160
+ //#region src/modal.module.ts
161
+ let ModalModule = class ModalModule {
162
+ onInitialize() {
163
+ augmentRouterContextWithModal((ctx) => {
164
+ return ctx.getContainer().resolve(MODAL_TOKENS.ModalService);
165
+ });
166
+ }
167
+ };
168
+ ModalModule = __decorate([Module({
169
+ imports: [I18nModule.registerMessages(i18nMessages)],
170
+ providers: [{
171
+ provide: MODAL_TOKENS.ModalService,
172
+ useClass: ModalService
173
+ }]
174
+ })], ModalModule);
175
+ //#endregion
176
+ export { MODAL_TOKENS, ModalModule };
177
+
178
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/tokens.ts","../src/augment/router-context.ts","../src/i18n/en.ts","../src/i18n/index.ts","../src/errors/modal-background-fetch.error.ts","../src/services/modal.service.ts","../src/modal.module.ts"],"sourcesContent":["export const MODAL_TOKENS = {\n ModalService: Symbol.for('stratal:inertia-modal:service'),\n} as const\n","import { RouterContext } from 'stratal/router'\nimport type { ModalRenderOptions, ModalService } from '../services/modal.service'\nimport { MODAL_TOKENS } from '../tokens'\n\ndeclare module 'stratal/router' {\n interface RouterContext {\n /**\n * Renders a modal page component over a background page.\n *\n * The background page at `options.baseURL` is always rendered as the main\n * Inertia page. The given `component` and `props` are embedded in the\n * background page's `modal` prop and rendered as an overlay by the\n * client-side `<Modal>` component.\n *\n * Handles direct URL visits by fetching the background page in-process.\n * Handles partial reloads (e.g., cascading selects) when `only: ['modal']`\n * is requested.\n */\n inertiaModal(\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ): Promise<Response>\n }\n}\n\nexport function augmentRouterContextWithModal(\n resolveService: (ctx: RouterContext) => ModalService,\n): void {\n RouterContext.macro('inertiaModal', function (\n this: RouterContext,\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ) {\n const service = resolveService(this)\n return service.render(this, component, props, options)\n })\n}\n\nexport { MODAL_TOKENS }\n","export const modalMessages = {\n en: {\n errors: {\n backgroundFetchFailed: 'Failed to load background page for modal',\n },\n },\n} as const\n\ndeclare module 'stratal/i18n' {\n interface AppMessageNamespaces {\n modal: typeof modalMessages['en']\n }\n}\n","import { modalMessages } from './en'\n\nexport const i18nMessages = { en: { modal: modalMessages.en } }\n","import { HttpException } from 'stratal/errors'\n\n/**\n * Thrown when the internal sub-request to fetch the background page fails\n * (e.g., non-2xx response, redirect, or empty body).\n *\n * HTTP Status: 502 Bad Gateway — the modal service acted as a proxy and the\n * upstream (background page) returned an unexpected response.\n */\nexport class ModalBackgroundFetchError extends HttpException {\n constructor() {\n super(502, 'modal.errors.backgroundFetchFailed')\n }\n}\n","import type { Page } from '@inertiajs/core'\nimport { INERTIA_TOKENS, type SsrRendererService, type TemplateService } from '@stratal/inertia'\nimport { Transient, inject } from 'stratal/di'\nimport type { RouterContext } from 'stratal/router'\nimport { ROUTER_TOKENS } from 'stratal/router'\nimport { ModalBackgroundFetchError } from '../errors/modal-background-fetch.error'\n\nexport interface ModalData {\n component: string\n props: Record<string, unknown>\n baseURL: string\n redirectURL: string\n key: string\n nonce: string\n}\n\nexport interface ModalRenderOptions {\n baseURL: string\n}\n\n// Page from @inertiajs/core doesn't have a 'modal' prop — we extend it here\ntype PageWithModal = Page\n\n// HonoApp extends OpenAPIHono which extends Hono — it has a standard fetch() method\ninterface FetchableApp {\n fetch(request: Request, env: unknown, ctx: unknown): Promise<Response>\n}\n\n@Transient()\nexport class ModalService {\n constructor(\n @inject(ROUTER_TOKENS.HonoApp) private readonly app: FetchableApp,\n @inject(INERTIA_TOKENS.SsrRenderer) private readonly ssr: SsrRendererService,\n @inject(INERTIA_TOKENS.TemplateService) private readonly template: TemplateService,\n ) { }\n\n async render(\n ctx: RouterContext,\n component: string,\n props: Record<string, unknown>,\n options: ModalRenderOptions,\n ): Promise<Response> {\n const isInertia = ctx.c.req.header('x-inertia') === 'true'\n const partialComponent = ctx.c.req.header('x-inertia-partial-component')\n const partialData = ctx.c.req.header('x-inertia-partial-data')\n\n const redirectURL = this.resolveRedirectURL(ctx, options.baseURL)\n const key = ctx.c.req.header('x-inertia-modal-key') ?? crypto.randomUUID()\n const nonce = crypto.randomUUID()\n const modalURL = new URL(ctx.c.req.url).pathname\n\n const modalData: ModalData = {\n component,\n props,\n baseURL: options.baseURL,\n redirectURL,\n key,\n nonce,\n }\n\n // Partial reload requesting 'modal' — skip background sub-request,\n // return just the modal prop with fresh data\n if (isInertia && partialComponent && partialData) {\n const requestedProps = partialData.split(',').map((s) => s.trim())\n if (requestedProps.includes('modal')) {\n const page: PageWithModal = {\n component: partialComponent,\n props: { modal: modalData, errors: {} },\n url: modalURL,\n version: null,\n flash: {},\n rememberedState: {},\n }\n return new Response(JSON.stringify(page), {\n status: 200,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Inertia': 'true',\n 'Vary': 'X-Inertia',\n },\n })\n }\n }\n\n // Fetch background page as an Inertia JSON request to get its component and\n // props without triggering SSR. We will run SSR ourselves below with the\n // combined page object so that page.url equals the modal URL in both the\n // SSR output and on the client — preventing React hydration mismatches.\n const bgResponse = await this.fetchBackground(ctx, redirectURL)\n const bgText = await bgResponse.text()\n if (!bgText || bgResponse.status >= 300) {\n throw new ModalBackgroundFetchError()\n }\n const bgPage = JSON.parse(bgText) as PageWithModal\n\n // Build the combined page: background props + modal data, URL = modal URL.\n // Setting url to the modal URL ensures Inertia's InitialVisit.handleDefault\n // calls history.replaceState with the modal URL (matching window.location),\n // so the address bar stays at the modal URL on direct visits.\n const combinedPage: PageWithModal = {\n ...bgPage,\n props: { ...bgPage.props, modal: modalData },\n url: modalURL,\n }\n\n if (isInertia) {\n // Inertia AJAX navigation: return JSON\n return new Response(JSON.stringify(combinedPage), {\n status: 200,\n headers: {\n 'Content-Type': 'application/json',\n 'X-Inertia': 'true',\n 'Vary': 'X-Inertia',\n },\n })\n }\n\n // Full-page (direct visit): run SSR with the combined page so that\n // page.url = modalURL in both the server-rendered HTML and the client\n // hydration pass. The Modal component renders null during SSR (effects\n // don't run server-side), so there is no hydration mismatch.\n const ssrResult = await this.ssr.render(combinedPage)\n const html = this.template.render(combinedPage, ssrResult.head, ssrResult.body)\n return new Response(html, {\n status: 200,\n headers: { 'Content-Type': 'text/html; charset=utf-8' },\n })\n }\n\n private resolveRedirectURL(ctx: RouterContext, baseURL: string): string {\n const referer = ctx.c.req.header('referer')\n const isInertia = ctx.c.req.header('x-inertia') === 'true'\n\n if (isInertia && referer) {\n try {\n const refererURL = new URL(referer)\n const currentURL = new URL(ctx.c.req.url)\n if (refererURL.pathname !== currentURL.pathname) {\n return refererURL.pathname\n }\n }\n catch {\n // malformed referer — fall through to baseURL\n }\n }\n\n return baseURL\n }\n\n private async fetchBackground(ctx: RouterContext, url: string): Promise<Response> {\n const currentURL = new URL(ctx.c.req.url)\n const bgURL = new URL(url, currentURL.origin)\n\n const bgRequest = new Request(bgURL.toString(), {\n method: 'GET',\n headers: {\n // Always request JSON — we run SSR ourselves with the combined page object\n 'x-inertia': 'true',\n // Deliberately omit x-inertia-version: the InertiaMiddleware version check\n // returns a 409 with no body when versions don't match, which would make\n // JSON.parse fail. Internal sub-requests don't need cache-bust checks.\n 'accept': 'application/json',\n // Forward auth/session cookies so the background request is authenticated\n 'cookie': ctx.c.req.header('cookie') ?? '',\n // Forward the host header so domain-pattern middleware can match the\n // request against the configured domain pattern. Without this, the host\n // resolves to the URL's origin (e.g., localhost:1234) which won't match\n // patterns like '{tenant}.admsn.test', causing a DomainMismatchError.\n 'host': ctx.c.req.header('host') ?? '',\n },\n })\n\n return this.app.fetch(bgRequest, ctx.c.env, ctx.c.executionCtx)\n }\n}\n","import { I18nModule } from 'stratal/i18n'\nimport type { OnInitialize } from 'stratal/module'\nimport { Module } from 'stratal/module'\nimport { augmentRouterContextWithModal } from './augment/router-context'\nimport { i18nMessages } from './i18n/index'\nimport { ModalService } from './services/modal.service'\nimport { MODAL_TOKENS } from './tokens'\n\n@Module({\n imports: [I18nModule.registerMessages(i18nMessages)],\n providers: [\n { provide: MODAL_TOKENS.ModalService, useClass: ModalService },\n ],\n})\nexport class ModalModule implements OnInitialize {\n onInitialize(): void {\n augmentRouterContextWithModal((ctx) => {\n return ctx.getContainer().resolve<ModalService>(MODAL_TOKENS.ModalService)\n })\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ import * as _$react_jsx_runtime0 from "react/jsx-runtime";
2
+
3
+ //#region src/react/modal.d.ts
4
+ /**
5
+ * Headless modal component. Place this anywhere in your layout.
6
+ *
7
+ * When the current Inertia page has `props.modal` (set by `ctx.inertiaModal()`
8
+ * on the server), this component dynamically loads the modal page component and
9
+ * renders it as an overlay. The background page is what Inertia renders normally.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * // dashboard-layout.tsx
14
+ * import { Modal } from '@stratal/inertia-modal/react'
15
+ *
16
+ * export function DashboardLayout({ children }) {
17
+ * return (
18
+ * <>
19
+ * <Sidebar />
20
+ * <main>{children}</main>
21
+ * <Modal />
22
+ * </>
23
+ * )
24
+ * }
25
+ * ```
26
+ */
27
+ declare function Modal(): _$react_jsx_runtime0.JSX.Element | null;
28
+ //#endregion
29
+ //#region src/react/use-modal.d.ts
30
+ interface UseModalReturn {
31
+ /** Whether a modal is currently active on this page. */
32
+ show: boolean;
33
+ /** Navigate back to the page that opened the modal (or the base URL on direct visits). */
34
+ redirect(): void;
35
+ /** The modal component's props, if a modal is active. */
36
+ props: Record<string, unknown> | undefined;
37
+ }
38
+ declare function useModal(): UseModalReturn;
39
+ //#endregion
40
+ //#region src/react/resolver.d.ts
41
+ declare const resolver: {
42
+ set(cb: (name: string) => unknown): void;
43
+ resolve(name: string): unknown;
44
+ };
45
+ //#endregion
46
+ export { Modal, resolver, useModal };
47
+ //# sourceMappingURL=react.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.mts","names":[],"sources":["../src/react/modal.tsx","../src/react/use-modal.ts","../src/react/resolver.ts"],"mappings":";;;;;;AAiCA;;;;;;;;;;;;;;;;;;ACnBA;;iBDmBgB,KAAA,CAAA,GAAK,oBAAA,CAAA,GAAA,CAAA,OAAA;;;UC5BX,cAAA;;EAER,IAAA;ED0Bc;ECxBd,QAAA;;EAEA,KAAA,EAAO,MAAA;AAAA;AAAA,iBAGO,QAAA,CAAA,GAAY,cAAA;;;cCVf,QAAA;WACF,IAAA"}
package/dist/react.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { router, usePage } from "@inertiajs/react";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/react/resolver.ts
5
+ 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
+ };
15
+ //#endregion
16
+ //#region src/react/modal.tsx
17
+ /**
18
+ * Headless modal component. Place this anywhere in your layout.
19
+ *
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.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * // dashboard-layout.tsx
27
+ * import { Modal } from '@stratal/inertia-modal/react'
28
+ *
29
+ * export function DashboardLayout({ children }) {
30
+ * return (
31
+ * <>
32
+ * <Sidebar />
33
+ * <main>{children}</main>
34
+ * <Modal />
35
+ * </>
36
+ * )
37
+ * }
38
+ * ```
39
+ */
40
+ function Modal() {
41
+ const modal = usePage().props.modal;
42
+ const [Component, setComponent] = useState(null);
43
+ 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?.nonce]);
53
+ useEffect(() => {
54
+ if (!modal?.key) return;
55
+ return router.on("before", (event) => {
56
+ event.detail.visit.headers["x-inertia-modal-key"] = modal.key;
57
+ });
58
+ }, [modal?.key]);
59
+ if (!Component || !modal) return null;
60
+ return /* @__PURE__ */ jsx(Component, { ...modal.props }, modal.key);
61
+ }
62
+ //#endregion
63
+ //#region src/react/use-modal.ts
64
+ function useModal() {
65
+ const modal = usePage().props.modal;
66
+ const redirect = useCallback(() => {
67
+ if (!modal) return;
68
+ router.visit(modal.redirectURL ?? modal.baseURL, {
69
+ preserveScroll: true,
70
+ preserveState: true
71
+ });
72
+ }, [modal]);
73
+ return {
74
+ show: !!modal,
75
+ redirect,
76
+ props: modal?.props
77
+ };
78
+ }
79
+ //#endregion
80
+ export { Modal, resolver, useModal };
81
+
82
+ //# sourceMappingURL=react.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.mjs","names":[],"sources":["../src/react/resolver.ts","../src/react/modal.tsx","../src/react/use-modal.ts"],"sourcesContent":["// Module-level resolver store — persists across Inertia navigations.\n// Set once in app.tsx before createInertiaApp.\nlet resolveCallback: ((name: string) => unknown) | undefined\n\nexport const resolver = {\n set(cb: (name: string) => unknown): void {\n resolveCallback = cb\n },\n resolve(name: string): unknown {\n if (!resolveCallback) {\n throw new Error(\n '[@stratal/inertia-modal] Resolver not registered. '\n + 'Call resolver.set() before createInertiaApp().',\n )\n }\n return resolveCallback(name)\n },\n}\n","import type { PageProps } from '@inertiajs/core'\nimport { router, usePage } from '@inertiajs/react'\nimport { type ComponentType, useEffect, useState } from 'react'\nimport type { ModalData } from '../services/modal.service'\nimport { resolver } from './resolver'\n\ninterface ModalPageProps extends PageProps {\n modal?: ModalData\n}\n\n/**\n * Headless modal component. Place this anywhere in your layout.\n *\n * When the current Inertia page has `props.modal` (set by `ctx.inertiaModal()`\n * on the server), this component dynamically loads the modal page component and\n * renders it as an overlay. The background page is what Inertia renders normally.\n *\n * @example\n * ```tsx\n * // dashboard-layout.tsx\n * import { Modal } from '@stratal/inertia-modal/react'\n *\n * export function DashboardLayout({ children }) {\n * return (\n * <>\n * <Sidebar />\n * <main>{children}</main>\n * <Modal />\n * </>\n * )\n * }\n * ```\n */\nexport function Modal() {\n const page = usePage<ModalPageProps>()\n const modal = page.props.modal\n\n const [Component, setComponent] = useState<ComponentType<Record<string, unknown>> | null>(null)\n\n useEffect(() => {\n if (!modal?.component) {\n setComponent(null)\n return\n }\n\n Promise.resolve(resolver.resolve(modal.component))\n .then((mod) => {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const component = (mod as any)?.default ?? mod\n setComponent(() => component as ComponentType<Record<string, unknown>>)\n })\n .catch(() => setComponent(null))\n // Re-load the component whenever nonce changes (new modal or refreshed props)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [modal?.nonce])\n\n // Inject x-inertia-modal-key into every Inertia request while the modal is\n // open. This ensures partial reloads (e.g. country → states cascade) reuse the\n // same key, preventing React from unmounting and remounting the modal component\n // (which would wipe all uncontrolled form state).\n useEffect(() => {\n if (!modal?.key) return\n\n return router.on('before', (event) => {\n event.detail.visit.headers['x-inertia-modal-key'] = modal.key\n })\n }, [modal?.key])\n\n if (!Component || !modal) {\n return null\n }\n\n return <Component key={modal.key} {...modal.props} />\n}\n","import { router, usePage } from '@inertiajs/react'\nimport { useCallback } from 'react'\nimport { type ModalData } from '../services/modal.service'\n\n\ninterface UseModalReturn {\n /** Whether a modal is currently active on this page. */\n show: boolean\n /** Navigate back to the page that opened the modal (or the base URL on direct visits). */\n redirect(): void\n /** The modal component's props, if a modal is active. */\n props: Record<string, unknown> | undefined\n}\n\nexport function useModal(): UseModalReturn {\n const page = usePage()\n const modal = page.props.modal as ModalData\n\n const redirect = useCallback(() => {\n if (!modal) return\n router.visit(modal.redirectURL ?? modal.baseURL, {\n preserveScroll: true,\n preserveState: true,\n })\n }, [modal])\n\n return {\n show: !!modal,\n redirect,\n props: modal?.props,\n }\n}\n"],"mappings":";;;;AAEA,IAAI;AAEJ,MAAa,WAAW;CACtB,IAAI,IAAqC;AACvC,oBAAkB;;CAEpB,QAAQ,MAAuB;AAC7B,MAAI,CAAC,gBACH,OAAM,IAAI,MACR,mGAED;AAEH,SAAO,gBAAgB,KAAK;;CAE/B;;;;;;;;;;;;;;;;;;;;;;;;;;ACgBD,SAAgB,QAAQ;CAEtB,MAAM,QADO,SACK,CAAC,MAAM;CAEzB,MAAM,CAAC,WAAW,gBAAgB,SAAwD,KAAK;AAE/F,iBAAgB;AACd,MAAI,CAAC,OAAO,WAAW;AACrB,gBAAa,KAAK;AAClB;;AAGF,UAAQ,QAAQ,SAAS,QAAQ,MAAM,UAAU,CAAC,CAC/C,MAAM,QAAQ;GAEb,MAAM,YAAa,KAAa,WAAW;AAC3C,sBAAmB,UAAoD;IACvE,CACD,YAAY,aAAa,KAAK,CAAC;IAGjC,CAAC,OAAO,MAAM,CAAC;AAMlB,iBAAgB;AACd,MAAI,CAAC,OAAO,IAAK;AAEjB,SAAO,OAAO,GAAG,WAAW,UAAU;AACpC,SAAM,OAAO,MAAM,QAAQ,yBAAyB,MAAM;IAC1D;IACD,CAAC,OAAO,IAAI,CAAC;AAEhB,KAAI,CAAC,aAAa,CAAC,MACjB,QAAO;AAGT,QAAO,oBAAC,WAAD,EAA2B,GAAI,MAAM,OAAS,EAA9B,MAAM,IAAwB;;;;AC1DvD,SAAgB,WAA2B;CAEzC,MAAM,QADO,SACK,CAAC,MAAM;CAEzB,MAAM,WAAW,kBAAkB;AACjC,MAAI,CAAC,MAAO;AACZ,SAAO,MAAM,MAAM,eAAe,MAAM,SAAS;GAC/C,gBAAgB;GAChB,eAAe;GAChB,CAAC;IACD,CAAC,MAAM,CAAC;AAEX,QAAO;EACL,MAAM,CAAC,CAAC;EACR;EACA,OAAO,OAAO;EACf"}
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@stratal/inertia-modal",
3
+ "version": "0.0.0-canary-d717e8a",
4
+ "description": "Modal page primitive for Stratal Inertia — backend-driven modal dialogs",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Temitayo Fadojutimi",
8
+ "engines": {
9
+ "node": ">=22.0.0"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/strataljs/stratal.git",
14
+ "directory": "packages/inertia-modal"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/strataljs/stratal/issues"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "provenance": true
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs"
31
+ },
32
+ "./react": {
33
+ "types": "./dist/react.d.mts",
34
+ "import": "./dist/react.mjs"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "scripts": {
39
+ "build": "tsdown",
40
+ "typecheck": "tsc --noEmit"
41
+ },
42
+ "peerDependencies": {
43
+ "@inertiajs/core": "^3.0.0-beta.5",
44
+ "@inertiajs/react": "^3.0.0-beta.5",
45
+ "@stratal/inertia": "0.0.0-canary-d717e8a",
46
+ "hono": "^4.12.8",
47
+ "react": "^19.0.0",
48
+ "reflect-metadata": "^0.2.2",
49
+ "stratal": "0.0.0-canary-d717e8a"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@inertiajs/core": {
53
+ "optional": true
54
+ },
55
+ "@inertiajs/react": {
56
+ "optional": true
57
+ },
58
+ "react": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "@cloudflare/workers-types": "4.20260426.1",
64
+ "@inertiajs/core": "^3.0.3",
65
+ "@inertiajs/react": "^3.0.3",
66
+ "@stratal/inertia": "workspace:*",
67
+ "@types/node": "^25.6.0",
68
+ "@types/react": "^19.2.14",
69
+ "hono": "^4.12.15",
70
+ "react": "^19.2.5",
71
+ "reflect-metadata": "^0.2.2",
72
+ "stratal": "workspace:*",
73
+ "tsdown": "^0.21.10",
74
+ "typescript": "^6.0.3"
75
+ }
76
+ }