Dialogs
Render confirm, alert, and form dialogs in the parent portal instead of inside your iframe.
A portal block runs in an <iframe>. A dialog opened inside it renders against the
iframe, not the page: the backdrop only covers the iframe, tall dialogs get clipped,
clicking outside the iframe never closes them, and focus stays trapped inside.
openDialog moves the rendering up. Your block describes the dialog it wants, the
parent portal renders it over the whole page, and the result comes back. Only data
crosses the boundary, never markup.
await openDialog('confirm', { … })spark-block:open-dialog{ id, kind, payload }spark-bridge:dialog-resolution{ result } · or · spark-bridge:dialog-dismissal{ confirmed: true }, or null if dismissed.Try it
Pick a kind, edit the arguments, and send. The block (in a real <iframe>) makes the
openDialog call and this page hosts the dialog. Notice the backdrop covers the whole
page, and the result returns to the block. For the form kind, build your own fields
and watch the fields in the call preview update as you go.
await openDialog('confirm', {
"title": "Remove Volkswagen?",
"body": "This will disconnect the device from your account.",
"confirmLabel": "Remove device",
"cancelLabel": "Cancel",
"destructive": true
})The block · in its <iframe>openDialog(kind, payload, options?)
import { openDialog } from '@epilot/spark-sdk'
const result = await openDialog('confirm', {
title: 'Remove Volkswagen?',
body: 'This will disconnect the device from your account.',
destructive: true,
})
if (result?.confirmed) {
await removeDevice()
}The promise resolves with the dialog's result when the user completes it, or null
when they dismiss it (Esc, click outside, cancel). It never times out: a dialog
waits for the user; to give up early, pass a signal that aborts (the
promise then rejects with the abort reason).
payload and the resolved result are both typed from the kind you pass.
type OpenDialogOptions = {
signal?: AbortSignal // cancel from the block side
}Call
openDialogfrom an event handler, not a render effect. ReactStrictModeruns effects twice in development and would open the dialog twice.
Kinds
A kind names the payload the block sends and the result the host returns. Three ship
today, and both shapes are inferred from the kind you pass.
| Kind | Payload | Result |
|---|---|---|
confirm | { title; body?; confirmLabel?; cancelLabel?; destructive? } | { confirmed: boolean } |
alert | { title; body?; okLabel? } | { acknowledged: true } |
form | { title; description?; submitLabel?; cancelLabel?; fields } | values keyed by field name |
import type {
// one self-contained type per dialog…
ConfirmDialog,
AlertDialog,
FormDialog,
// …unioned as `Dialog`, plus helpers keyed off `kind`
Dialog,
DialogKind,
DialogPayload,
DialogResult,
// form building blocks
Field,
FieldValue,
LocationValue,
} from '@epilot/spark-sdk'confirm
A yes/no decision, resolving { confirmed: boolean }. With destructive: true the
host renders an alert dialog (role="alertdialog", no click-outside, warning-styled
button) for irreversible actions; otherwise a regular dialog.
const result = await openDialog('confirm', {
title: 'Remove Volkswagen?',
body: 'This will disconnect the device.',
confirmLabel: 'Remove device',
destructive: true,
})alert
A single acknowledgement. Resolves { acknowledged: true } on OK, null on dismiss.
await openDialog('alert', {
title: 'Device added',
body: 'Your wallbox is now connected.',
})form
A form built from an ordered list of typed fields. The host renders each field by
its type, validates from the field's own constraints, and resolves with the gathered
values keyed by field name. Submit stays disabled until required fields are filled.
Each field is one entry in a small, shared union (text, number, boolean,
select, date, location), carrying its own label, constraints, and optional
initial value. There's no separate schema or presentation layer: the field is the
contract.
const result = await openDialog('form', {
title: 'Your details',
submitLabel: 'Save',
fields: [
{
type: 'text',
name: 'name',
label: 'Full name',
required: true,
placeholder: 'Jane Doe',
},
{ type: 'boolean', name: 'newsletter', label: 'Send me the newsletter' },
],
})
if (!result) return
const { name, newsletter } = result // typed by field namelocation is a first-class field, not a special widget: the host renders its picker
(map, address search, editable fields) and writes back the whole address:
const result = await openDialog('form', {
title: t('home_location.title'),
submitLabel: t('common.save'),
cancelLabel: t('common.cancel'),
fields: [
{
type: 'location',
name: 'location',
label: t('home_location.title'),
required: true,
},
],
})
if (!result) return
await executeUseCase('home_location', result.location)
// result.location → { street, city, postalCode, latitude, longitude }The host owns no translations, so every visible string comes from the field (
label,placeholder, selectoptions; see Localization). It validates from the field's own constraints (required,min/max,options) to gate submit. Adding a field type is a deliberate change: one variant in the shared field union, one renderer in the host.
Behavior
openDialog handles a few things for you:
- Focus. While any dialog is open the iframe's root is
inertandaria-hidden, so the dialog is the only thing you can interact with. Focus returns to the trigger on close. This is reference-counted across concurrent dialogs. - Concurrency. Each call carries its own id, so replies route back to the right promise.
- Abort. Pass an
AbortSignalto cancel a pending dialog, for example on unmount.
const controller = new AbortController()
openDialog('confirm', { title: 'Save changes?' }, { signal: controller.signal })
controller.abort()Wire protocol
You don't need this to use openDialog; it's here for understanding and debugging.
Three messages flow over the postMessage bridge:
// block → parent, on openDialog()
{ "source": "spark-bridge", "event": "spark-block:open-dialog", "id": "…", "kind": "confirm", "payload": { } }
// parent → block, on complete (result nested under `result`)
{ "source": "portal", "event": "spark-bridge:dialog-resolution", "id": "…", "result": { "confirmed": true } }
// parent → block, on dismiss (openDialog resolves null)
{ "source": "portal", "event": "spark-bridge:dialog-dismissal", "id": "…" }The result is nested under result so it can't collide with the envelope. A reply
with an unknown id is ignored. For an unrecognized kind or invalid payload the host
sends dismissed, so the block's openDialog resolves null instead of hanging
forever (there is no timeout).
The host
You don't build a host. The customer portal ships one (SparkDialogHost), and
it's running whenever you develop against the portal. It listens for
spark-block:open-dialog, checks the origin, validates the payload, renders the dialog over
the page, and posts the result back to the exact origin that asked. The
demo above is a small version of it.
Unknown kinds resolve to dismissed, so an SDK-only change fails safe until the host
catches up. Reuse confirm and alert before reaching for a new kind.