Joy DOM

Custom components

Render the document's kebab-case node types with your own SwiftUI, UIKit, or WebKit views.

Register a handler keyed by the kebab-case node type. The factory receives a single ComponentContext and returns a ComponentBody.custom { … } for SwiftUI:

import JoyDOM

let registry = ComponentRegistry().withDefaultPrimitives()

registry.register("contact-button") { context in
    .custom {
        Button(context.props.string("label") ?? "") {
            context.events.emit("tap")
        }
    }
}

register is last-wins and chainable, so a custom "img" overrides the built-in one.

The component context

The context carries everything the component can see:

  • context.props — the node's effective props: props.string("key") reads a scalar, props.value("key") the raw JSON value.
  • context.events — the outbound surface for reporting interactions (emit).
  • context.node — the component's own raw spec Node (structure, children, authored extras).
  • context.children — the rendered-children slot. Place it in a .custom body to render the node's JSON children at that position; ignore it and they don't render (shadow-DOM-without-<slot> semantics). Don't retain it past the factory invocation: each snapshot allocates a fresh slot box, so holding one keeps that snapshot's rendered subtree pinned in memory.
  • context.parent — this node's parent as a raw spec Node (nil at the root; read its id via parent?.props?.id). The whole-document tree is intentionally not exposed — only this one parent hop.
  • context.style — the node's fully resolved/cascaded style, i.e. what the renderer itself lays it out with: style?.visual, style?.item, style?.container, style?.display.

Rendering children

Place context.children wherever the node's JSON children should appear:

registry.register("fancy-card") { context in
    .custom {
        VStack(alignment: .leading) {
            Text(context.props.string("title") ?? "")
            context.children   // the node's JSON children render here
        }
    }
}

The children slot only activates for kebab-case custom types with .custom bodies. A registered override for a built-in type ("div", "img", …) keeps the renderer-managed children layout, and .uiKit / .webView bodies can't host SwiftUI children, so nodes rendered by those fall back to the auto-wrapped container (with a diagnostic).

Overriding a built-in

Overriding a built-in also takes over its event dispatch: the renderer stops auto-wiring the document's onclick/onfocus/onblur for that type (wiring both would fire one tap twice), so an override that should stay tappable re-wires explicitly — one line:

registry.register("div") { context in
    .custom {
        MyDivChrome()
            .onTapGesture { context.events.emit("onclick") }
    }
}

A document that binds events on an overridden type without this re-wire gets a diagnostic pointing here.

register vs. registerDefault

register(_:factory:) makes the type an app component — the renderer hands event dispatch to you. registerDefault(_:factory:) is for drop-in replacements of built-in rendering where the renderer should keep ownership of the node's event wiring, as it does for withDefaultPrimitives() — e.g. your own registerDefault("img") { context in .custom { … } } that loads images from your asset pipeline. (The bundled withTestAssetImageFactory() is one such replacement, but it lives in the dev-only JoyDomSampleSpecs target — import JoyDomSampleSpecs to reach it — not the shipped JoyDOM library, so app code registers its own instead.) There's also default { … }, a single catch-all rendered for any node whose type has no registered factory; it's treated like a registry hit, so a kebab-case unknown type's .custom body gets a working context.children slot too.

UIKit and WebKit bodies

When SwiftUI can't express the component, return a different ComponentBody. A UIKit body bridges a UIView:

registry.register("signature-pad") { context in
    .uiKit(
        make: { SignatureView() },
        update: { view in view.strokeColor = .label }
    )
}

A WebKit body loads HTML into a WKWebView. Both baseURL (nil) and onMessage (a no-op) default, so .webView(html: …) alone compiles; pass onMessage only when the page talks back:

registry.register("rich-text") { context in
    .webView(
        html: context.props.string("html") ?? "",
        baseURL: nil,
        onMessage: { payload in print(payload) }
    )
}

JS contract: the page posts to the cssLayout handler — window.webkit.messageHandlers.cssLayout.postMessage({ … }) — and only message bodies that decode as [String: String] reach onMessage; other shapes are dropped silently.

Neither bridge hosts SwiftUI children, so don't rely on context.children inside them.

Where next

  • Events — what emit dispatches and how .onEvent receives it.
  • API reference (DocC) ↗ComponentRegistry, ComponentContext, and ComponentBody in full.

On this page