@proteos/component-sdk 0.41.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hooks.ts CHANGED
@@ -1,8 +1,11 @@
1
- import { useContext, useEffect, useState } from 'react'
1
+ import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
2
2
 
3
3
  import {
4
4
  type ComponentSdk,
5
5
  type NavigateOptions,
6
+ type OpenModalOptions,
7
+ type OverlayController,
8
+ postModalResult,
6
9
  RuntimeContext,
7
10
  type RuntimeContextValue,
8
11
  } from './context'
@@ -63,6 +66,80 @@ export function useApiBaseUrl(): string {
63
66
  return useRuntime().apiBaseUrl
64
67
  }
65
68
 
69
+ const NOOP_OVERLAY: OverlayController = {
70
+ expand: () => {
71
+ console.warn(
72
+ '@proteos/component-sdk: useOverlayExpansion() has no effect — this runtime ' +
73
+ 'does not support overlay promotion (older shell or non-promoting host).',
74
+ )
75
+ },
76
+ collapse: () => {},
77
+ isExpanded: () => false,
78
+ }
79
+
80
+ /**
81
+ * Manual overlay promotion — lifts the whole component iframe over the rest of
82
+ * the page so an overlay can escape the frame's box.
83
+ *
84
+ * You rarely need this. Every `@proteos/ui` overlay (Modal, Popover, Menu,
85
+ * Select, Tooltip, …) portals to the iframe document's `<body>`, which the
86
+ * runtime watches — those promote automatically, with no code. Reach for this
87
+ * hook only when you portal somewhere else yourself, or want the frame lifted
88
+ * *before* the overlay mounts. Reference-counted: pair every `expand()` with a
89
+ * `collapse()`.
90
+ */
91
+ export function useOverlayExpansion(): OverlayController {
92
+ return useRuntime().overlay ?? NOOP_OVERLAY
93
+ }
94
+
95
+ /**
96
+ * Open ANOTHER component in a modal the host renders above everything — the
97
+ * cross-component flow (a picker, a wizard step, a confirm dialog shared by
98
+ * several components). Resolves with whatever that component closed with, or
99
+ * `undefined` if the user dismissed it.
100
+ *
101
+ * ```ts
102
+ * const openModal = useComponentModal()
103
+ * const picked = await openModal('contact-picker', { entity: 'contact' }, { title: 'Pick a contact' })
104
+ * ```
105
+ *
106
+ * For an overlay that is part of THIS component, just render a `<Modal>` from
107
+ * `@proteos/ui` — it promotes automatically.
108
+ */
109
+ export function useComponentModal(): (
110
+ componentSlug: string,
111
+ props?: Record<string, unknown>,
112
+ options?: OpenModalOptions,
113
+ ) => Promise<unknown> {
114
+ return useRuntime().openModal
115
+ }
116
+
117
+ export interface ModalHost {
118
+ /** True when this component is rendered as the content of a host modal. */
119
+ isInModal: boolean
120
+ /** Close the host modal, resolving the opener's promise with `result`.
121
+ * No-op outside a modal. */
122
+ close: (result?: unknown) => void
123
+ }
124
+
125
+ /**
126
+ * For a component that is being shown INSIDE a host modal (opened by another
127
+ * component's `useComponentModal()`): close it and hand a value back.
128
+ * `isInModal` is false on ordinary page-hosted mounts, so one component can
129
+ * serve both roles.
130
+ */
131
+ export function useModalHost(): ModalHost {
132
+ const requestId = useRuntime().modalRequestId
133
+ const close = useCallback(
134
+ (result?: unknown) => {
135
+ if (requestId === undefined) return
136
+ postModalResult(requestId, result)
137
+ },
138
+ [requestId],
139
+ )
140
+ return useMemo(() => ({ isInModal: requestId !== undefined, close }), [requestId, close])
141
+ }
142
+
66
143
  /**
67
144
  * A getter for the current bearer token. Returns a function (not the token)
68
145
  * because the token is refreshed in place — read it at call time for a raw
package/src/index.ts CHANGED
@@ -7,15 +7,25 @@
7
7
  // Register the ambient `Window.__PROTEOS_RUNTIME__` augmentation.
8
8
  import './global'
9
9
 
10
- export type { ComponentSdk, NavigateOptions, RuntimeContextValue, RuntimeValue } from './context'
10
+ export type {
11
+ ComponentSdk,
12
+ NavigateOptions,
13
+ OpenModalOptions,
14
+ OverlayController,
15
+ RuntimeContextValue,
16
+ RuntimeValue,
17
+ } from './context'
11
18
  export { RuntimeProvider } from './context'
12
- export type { FillViewportOptions, HostLocation } from './hooks'
19
+ export type { FillViewportOptions, HostLocation, ModalHost } from './hooks'
13
20
  export {
14
21
  useApiBaseUrl,
15
22
  useAuthToken,
23
+ useComponentModal,
16
24
  useFillViewportHeight,
17
25
  useHostLocation,
26
+ useModalHost,
18
27
  useNavigate,
28
+ useOverlayExpansion,
19
29
  usePageContext,
20
30
  useProps,
21
31
  useRuntime,
@@ -23,6 +33,7 @@ export {
23
33
  } from './hooks'
24
34
  export {
25
35
  type AuthRefreshMessage,
36
+ COMPONENT_OVERLAY_Z_INDEX,
26
37
  type DataInvalidateMessage,
27
38
  type Envelope,
28
39
  type ErrorMessage,
@@ -31,8 +42,14 @@ export {
31
42
  isHostMessage,
32
43
  isRuntimeMessage,
33
44
  MESSAGE_SOURCE,
45
+ type ModalClosedMessage,
46
+ type ModalResultMessage,
34
47
  type NavigateMessage,
48
+ type OpenModalMessage,
35
49
  type OpenPeekMessage,
50
+ type OverlayRect,
51
+ type OverlayRectMessage,
52
+ type OverlayStateMessage,
36
53
  type PageContext,
37
54
  type ReadyMessage,
38
55
  type ResizeMessage,
package/src/protocol.ts CHANGED
@@ -10,6 +10,37 @@
10
10
 
11
11
  export const MESSAGE_SOURCE = 'proteos-component-runtime' as const
12
12
 
13
+ /**
14
+ * z-index the component iframe is promoted to while one of its own overlays
15
+ * (modal, popover, menu, select, tooltip) is open. The iframe goes
16
+ * `position: fixed; inset: 0` at this z so the overlay — which portals to the
17
+ * IFRAME's body and would otherwise clip at the frame's box — can paint over
18
+ * the rest of the page.
19
+ *
20
+ * Host z ladder this slots into:
21
+ * 50 @proteos/ui Modal / Tooltip / ImageLightbox (default tier)
22
+ * 1000+ full-page panel stack (scrim 1000, panels 1001+n)
23
+ * 1090 peek scrim 1100 peek panel, ask-proteos window
24
+ * 1150 ← THIS
25
+ * 1200 menu popover, command palette, action dialog, component modal
26
+ * 1300 select popover
27
+ *
28
+ * Above the peek/full-page surfaces a component can live on, below the global
29
+ * host chrome. Note the cap: inside a peek (a `fixed` + z-index ancestor) the
30
+ * promoted iframe paints within that 1100 stacking context — it covers the
31
+ * peek's own content and everything under it, but host chrome at 1200+ still
32
+ * wins. That is the intended layering, not a bug.
33
+ */
34
+ export const COMPONENT_OVERLAY_Z_INDEX = 1150
35
+
36
+ /** Viewport-relative box of the iframe's in-flow placeholder, in CSS px. */
37
+ export interface OverlayRect {
38
+ top: number
39
+ left: number
40
+ width: number
41
+ height: number
42
+ }
43
+
13
44
  export interface PageContext {
14
45
  record?: Record<string, unknown>
15
46
  entity?: { slug: string; [key: string]: unknown }
@@ -39,6 +70,11 @@ export interface InitMessage {
39
70
  /** Org id for public mode — public endpoints carry the org in the path
40
71
  * because there is no token to scope from. */
41
72
  orgId?: string
73
+ /** Set when this frame is the CONTENT of a host component modal (opened by
74
+ * another component via OPEN_MODAL). The component reads it through
75
+ * `useModalHost()` and closes with a result by posting MODAL_RESULT with
76
+ * this id. Absent on ordinary page-hosted frames. */
77
+ modalRequestId?: string
42
78
  }
43
79
 
44
80
  export interface AuthRefreshMessage {
@@ -51,7 +87,30 @@ export interface DataInvalidateMessage {
51
87
  queryKey?: unknown[]
52
88
  }
53
89
 
54
- export type HostMessage = InitMessage | AuthRefreshMessage | DataInvalidateMessage
90
+ /** Where the iframe's in-flow box sits in the host viewport. Sent while the
91
+ * frame is promoted (OVERLAY_STATE open) so the runtime can keep its content
92
+ * parked at the original rect while the iframe itself covers the viewport —
93
+ * and re-sent when the host window resizes under an open overlay. */
94
+ export interface OverlayRectMessage {
95
+ type: 'OVERLAY_RECT'
96
+ rect: OverlayRect
97
+ }
98
+
99
+ /** Resolution of an OPEN_MODAL request: the host modal closed. `result` is the
100
+ * value the modal's component passed to MODAL_RESULT, and is absent when the
101
+ * modal was dismissed (backdrop / Esc / ×). */
102
+ export interface ModalClosedMessage {
103
+ type: 'MODAL_CLOSED'
104
+ requestId: string
105
+ result?: unknown
106
+ }
107
+
108
+ export type HostMessage =
109
+ | InitMessage
110
+ | AuthRefreshMessage
111
+ | DataInvalidateMessage
112
+ | OverlayRectMessage
113
+ | ModalClosedMessage
55
114
 
56
115
  // ── Runtime → host ──────────────────────────────────────────────────────────
57
116
 
@@ -77,6 +136,44 @@ export interface OpenPeekMessage {
77
136
  to: string
78
137
  }
79
138
 
139
+ /** The runtime has (or no longer has) an overlay open and needs the host to
140
+ * promote (or restore) the iframe. See {@link COMPONENT_OVERLAY_Z_INDEX}.
141
+ *
142
+ * Same-origin today, so the runtime applies the promotion styles to its own
143
+ * `window.frameElement` synchronously (pre-paint, no flicker) and this message
144
+ * is the host's cue to MIRROR that state into React — gate RESIZE, hold the
145
+ * placeholder height, start posting OVERLAY_RECT. When components move to a
146
+ * dedicated origin the runtime can no longer touch the frame and this message
147
+ * becomes the actuation instead; the runtime hides its overlay until the
148
+ * answering OVERLAY_RECT arrives. */
149
+ export interface OverlayStateMessage {
150
+ type: 'OVERLAY_STATE'
151
+ open: boolean
152
+ }
153
+
154
+ /** Ask the host to open ANOTHER component in a central modal, above everything
155
+ * this frame could reach on its own. The host answers with MODAL_CLOSED
156
+ * carrying the same `requestId`. */
157
+ export interface OpenModalMessage {
158
+ type: 'OPEN_MODAL'
159
+ /** Correlates the eventual MODAL_CLOSED. Generated by the opener. */
160
+ requestId: string
161
+ /** Slug of the component to mount inside the modal. */
162
+ componentSlug: string
163
+ /** Props for that component (already literal values — no Liquid). */
164
+ props?: Record<string, unknown>
165
+ /** Optional modal heading. */
166
+ title?: string
167
+ }
168
+
169
+ /** Posted by the component INSIDE a host modal to close it with a value. The
170
+ * `requestId` is the one INIT carried as `modalRequestId`. */
171
+ export interface ModalResultMessage {
172
+ type: 'MODAL_RESULT'
173
+ requestId: string
174
+ result?: unknown
175
+ }
176
+
80
177
  export interface ErrorMessage {
81
178
  type: 'ERROR'
82
179
  message: string
@@ -88,6 +185,9 @@ export type RuntimeMessage =
88
185
  | ResizeMessage
89
186
  | NavigateMessage
90
187
  | OpenPeekMessage
188
+ | OverlayStateMessage
189
+ | OpenModalMessage
190
+ | ModalResultMessage
91
191
  | ErrorMessage
92
192
 
93
193
  // Wire envelope: the message plus the source discriminator.
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/protocol.ts"],"names":[],"mappings":";AAUO,IAAM,cAAA,GAAiB;AAqFvB,SAAS,cAAc,IAAA,EAA8C;AAC1E,EAAA,OACE,OAAO,IAAA,KAAS,QAAA,IAChB,IAAA,KAAS,IAAA,IACR,KAA8B,MAAA,KAAW,cAAA,IAC1C,OAAQ,IAAA,CAA4B,IAAA,KAAS,QAAA;AAEjD;AAEO,SAAS,iBAAiB,IAAA,EAAiD;AAChF,EAAA,OACE,OAAO,IAAA,KAAS,QAAA,IAChB,IAAA,KAAS,IAAA,IACR,KAA8B,MAAA,KAAW,cAAA,IAC1C,OAAQ,IAAA,CAA4B,IAAA,KAAS,QAAA;AAEjD","file":"chunk-DD7AKVFC.js","sourcesContent":["// postMessage protocol between the host page and the component-runtime iframe.\n// This is the single source of truth for the wire contract: the host renderer\n// (clients/web .../component-frame.tsx), the runtime shell\n// (clients/web/component-runtime/shell.tsx), and the author-facing hooks\n// (this package's RuntimeProvider / useNavigate) all import these same types so\n// every end stays in lockstep.\n//\n// Every message carries `source: MESSAGE_SOURCE` so we can ignore the unrelated\n// postMessage traffic that shares the window (OIDC silent-renew, Vite HMR, …).\n\nexport const MESSAGE_SOURCE = 'proteos-component-runtime' as const\n\nexport interface PageContext {\n record?: Record<string, unknown>\n entity?: { slug: string; [key: string]: unknown }\n params?: Record<string, unknown>\n [key: string]: unknown\n}\n\n// ── Host → runtime ──────────────────────────────────────────────────────────\n\nexport interface InitMessage {\n type: 'INIT'\n /** Raw bearer token (the OIDC `id_token`). The runtime's ProteosClient\n * prepends `Bearer ` when building the Authorization header, so the host\n * sends the token verbatim — NOT a full `Bearer …` header value.\n * Empty string in public mode (`isPublic: true`). */\n authToken: string\n /** API origin the bundle endpoint + SDK calls resolve against. */\n apiBaseUrl: string\n /** Resolved props for the component (Liquid templating happens host-side). */\n props: Record<string, unknown>\n pageCtx: PageContext\n /** PUBLIC mode: the component renders on an unauthenticated public page.\n * The shell fetches the bundle from the public endpoint (no bearer) and\n * injects a restricted sdk — only `functions.actions.invokePublic` reaches\n * the platform. Requires `orgId`. */\n isPublic?: boolean\n /** Org id for public mode — public endpoints carry the org in the path\n * because there is no token to scope from. */\n orgId?: string\n}\n\nexport interface AuthRefreshMessage {\n type: 'AUTH_REFRESH'\n token: string\n}\n\nexport interface DataInvalidateMessage {\n type: 'DATA_INVALIDATE'\n queryKey?: unknown[]\n}\n\nexport type HostMessage = InitMessage | AuthRefreshMessage | DataInvalidateMessage\n\n// ── Runtime → host ──────────────────────────────────────────────────────────\n\nexport interface ReadyMessage {\n type: 'READY'\n}\n\nexport interface ResizeMessage {\n type: 'RESIZE'\n height: number\n}\n\nexport interface NavigateMessage {\n type: 'NAVIGATE'\n to: string\n}\n\n/** Open an in-app path in a peek (slide-over) panel instead of full-page\n * navigation. Same `/…` in-app path grammar as NAVIGATE; the host decides\n * what can peek (today: platform pages) and falls back to NAVIGATE otherwise. */\nexport interface OpenPeekMessage {\n type: 'OPEN_PEEK'\n to: string\n}\n\nexport interface ErrorMessage {\n type: 'ERROR'\n message: string\n stack?: string\n}\n\nexport type RuntimeMessage =\n | ReadyMessage\n | ResizeMessage\n | NavigateMessage\n | OpenPeekMessage\n | ErrorMessage\n\n// Wire envelope: the message plus the source discriminator.\nexport type Envelope<T> = T & { source: typeof MESSAGE_SOURCE }\n\nexport function isHostMessage(data: unknown): data is Envelope<HostMessage> {\n return (\n typeof data === 'object' &&\n data !== null &&\n (data as { source?: unknown }).source === MESSAGE_SOURCE &&\n typeof (data as { type?: unknown }).type === 'string'\n )\n}\n\nexport function isRuntimeMessage(data: unknown): data is Envelope<RuntimeMessage> {\n return (\n typeof data === 'object' &&\n data !== null &&\n (data as { source?: unknown }).source === MESSAGE_SOURCE &&\n typeof (data as { type?: unknown }).type === 'string'\n )\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/protocol.ts"],"names":[],"mappings":";;;AAUO,IAAM,cAAA,GAAiB;AAqFvB,SAAS,cAAc,IAAA,EAA8C;AAC1E,EAAA,OACE,OAAO,IAAA,KAAS,QAAA,IAChB,IAAA,KAAS,IAAA,IACR,KAA8B,MAAA,KAAW,cAAA,IAC1C,OAAQ,IAAA,CAA4B,IAAA,KAAS,QAAA;AAEjD;AAEO,SAAS,iBAAiB,IAAA,EAAiD;AAChF,EAAA,OACE,OAAO,IAAA,KAAS,QAAA,IAChB,IAAA,KAAS,IAAA,IACR,KAA8B,MAAA,KAAW,cAAA,IAC1C,OAAQ,IAAA,CAA4B,IAAA,KAAS,QAAA;AAEjD","file":"chunk-GL7ZEEP4.cjs","sourcesContent":["// postMessage protocol between the host page and the component-runtime iframe.\n// This is the single source of truth for the wire contract: the host renderer\n// (clients/web .../component-frame.tsx), the runtime shell\n// (clients/web/component-runtime/shell.tsx), and the author-facing hooks\n// (this package's RuntimeProvider / useNavigate) all import these same types so\n// every end stays in lockstep.\n//\n// Every message carries `source: MESSAGE_SOURCE` so we can ignore the unrelated\n// postMessage traffic that shares the window (OIDC silent-renew, Vite HMR, …).\n\nexport const MESSAGE_SOURCE = 'proteos-component-runtime' as const\n\nexport interface PageContext {\n record?: Record<string, unknown>\n entity?: { slug: string; [key: string]: unknown }\n params?: Record<string, unknown>\n [key: string]: unknown\n}\n\n// ── Host → runtime ──────────────────────────────────────────────────────────\n\nexport interface InitMessage {\n type: 'INIT'\n /** Raw bearer token (the OIDC `id_token`). The runtime's ProteosClient\n * prepends `Bearer ` when building the Authorization header, so the host\n * sends the token verbatim — NOT a full `Bearer …` header value.\n * Empty string in public mode (`isPublic: true`). */\n authToken: string\n /** API origin the bundle endpoint + SDK calls resolve against. */\n apiBaseUrl: string\n /** Resolved props for the component (Liquid templating happens host-side). */\n props: Record<string, unknown>\n pageCtx: PageContext\n /** PUBLIC mode: the component renders on an unauthenticated public page.\n * The shell fetches the bundle from the public endpoint (no bearer) and\n * injects a restricted sdk — only `functions.actions.invokePublic` reaches\n * the platform. Requires `orgId`. */\n isPublic?: boolean\n /** Org id for public mode — public endpoints carry the org in the path\n * because there is no token to scope from. */\n orgId?: string\n}\n\nexport interface AuthRefreshMessage {\n type: 'AUTH_REFRESH'\n token: string\n}\n\nexport interface DataInvalidateMessage {\n type: 'DATA_INVALIDATE'\n queryKey?: unknown[]\n}\n\nexport type HostMessage = InitMessage | AuthRefreshMessage | DataInvalidateMessage\n\n// ── Runtime → host ──────────────────────────────────────────────────────────\n\nexport interface ReadyMessage {\n type: 'READY'\n}\n\nexport interface ResizeMessage {\n type: 'RESIZE'\n height: number\n}\n\nexport interface NavigateMessage {\n type: 'NAVIGATE'\n to: string\n}\n\n/** Open an in-app path in a peek (slide-over) panel instead of full-page\n * navigation. Same `/…` in-app path grammar as NAVIGATE; the host decides\n * what can peek (today: platform pages) and falls back to NAVIGATE otherwise. */\nexport interface OpenPeekMessage {\n type: 'OPEN_PEEK'\n to: string\n}\n\nexport interface ErrorMessage {\n type: 'ERROR'\n message: string\n stack?: string\n}\n\nexport type RuntimeMessage =\n | ReadyMessage\n | ResizeMessage\n | NavigateMessage\n | OpenPeekMessage\n | ErrorMessage\n\n// Wire envelope: the message plus the source discriminator.\nexport type Envelope<T> = T & { source: typeof MESSAGE_SOURCE }\n\nexport function isHostMessage(data: unknown): data is Envelope<HostMessage> {\n return (\n typeof data === 'object' &&\n data !== null &&\n (data as { source?: unknown }).source === MESSAGE_SOURCE &&\n typeof (data as { type?: unknown }).type === 'string'\n )\n}\n\nexport function isRuntimeMessage(data: unknown): data is Envelope<RuntimeMessage> {\n return (\n typeof data === 'object' &&\n data !== null &&\n (data as { source?: unknown }).source === MESSAGE_SOURCE &&\n typeof (data as { type?: unknown }).type === 'string'\n )\n}\n"]}