@stratal/inertia-modal 0.0.1 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -31,19 +31,19 @@ var ModalBackgroundFetchError = class extends HttpException {
31
31
  }
32
32
  };
33
33
  //#endregion
34
- //#region \0@oxc-project+runtime@0.122.0/helpers/decorateMetadata.js
34
+ //#region \0@oxc-project+runtime@0.127.0/helpers/decorateMetadata.js
35
35
  function __decorateMetadata(k, v) {
36
36
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
37
37
  }
38
38
  //#endregion
39
- //#region \0@oxc-project+runtime@0.122.0/helpers/decorateParam.js
39
+ //#region \0@oxc-project+runtime@0.127.0/helpers/decorateParam.js
40
40
  function __decorateParam(paramIndex, decorator) {
41
41
  return function(target, key) {
42
42
  decorator(target, key, paramIndex);
43
43
  };
44
44
  }
45
45
  //#endregion
46
- //#region \0@oxc-project+runtime@0.122.0/helpers/decorate.js
46
+ //#region \0@oxc-project+runtime@0.127.0/helpers/decorate.js
47
47
  function __decorate(decorators, target, key, desc) {
48
48
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
49
49
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1 +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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"}
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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"}
@@ -1 +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,SAAyB,CACnB,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,SAAS,CACH,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"}
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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratal/inertia-modal",
3
- "version": "0.0.1",
3
+ "version": "0.0.20",
4
4
  "description": "Modal page primitive for Stratal Inertia — backend-driven modal dialogs",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,9 +8,17 @@
8
8
  "engines": {
9
9
  "node": ">=22.0.0"
10
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
+ },
11
19
  "publishConfig": {
12
20
  "access": "public",
13
- "provenance": false
21
+ "provenance": true
14
22
  },
15
23
  "files": [
16
24
  "dist",
@@ -32,13 +40,13 @@
32
40
  "typecheck": "tsc --noEmit"
33
41
  },
34
42
  "peerDependencies": {
35
- "@inertiajs/core": "^3.0.0-beta.5",
36
- "@inertiajs/react": "^3.0.0-beta.5",
37
- "@stratal/inertia": ">=0.0.18",
38
- "hono": "^4.12.8",
39
- "react": "^19.0.0",
40
- "reflect-metadata": "^0.2.2",
41
- "stratal": "^0.0.18"
43
+ "@inertiajs/core": ">=3",
44
+ "@inertiajs/react": ">=3",
45
+ "@stratal/inertia": ">=0.0.20",
46
+ "hono": ">=4",
47
+ "react": ">=19",
48
+ "reflect-metadata": ">=0.2",
49
+ "stratal": ">=0.0.20"
42
50
  },
43
51
  "peerDependenciesMeta": {
44
52
  "@inertiajs/core": {
@@ -52,13 +60,13 @@
52
60
  }
53
61
  },
54
62
  "devDependencies": {
55
- "@cloudflare/workers-types": "4.20260426.1",
63
+ "@cloudflare/workers-types": "4.20260502.1",
56
64
  "@inertiajs/core": "^3.0.3",
57
65
  "@inertiajs/react": "^3.0.3",
58
66
  "@stratal/inertia": "workspace:*",
59
67
  "@types/node": "^25.6.0",
60
68
  "@types/react": "^19.2.14",
61
- "hono": "^4.12.15",
69
+ "hono": "^4.12.16",
62
70
  "react": "^19.2.5",
63
71
  "reflect-metadata": "^0.2.2",
64
72
  "stratal": "workspace:*",