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.

🧩 Portal blockruns inside the <iframe>
🪟 Portal hostparent window · end-customer-portal
await openDialog('confirm', { … })
spark-block:open-dialog{ id, kind, payload }
Renders the dialog in the parent document. The backdrop covers the whole page, and focus moves out of the iframe.
User confirms, cancels, or dismisses.
spark-bridge:dialog-resolution{ result } · or · spark-bridge:dialog-dismissal
Promise resolves: { 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.

Compose an openDialog call: the arguments your block would pass
Dialog kind
Live call preview
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 openDialog from an event handler, not a render effect. React StrictMode runs 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.

KindPayloadResult
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 name

location 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, select options; 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 inert and aria-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 AbortSignal to 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.

On this page