defineCustomElement function

void defineCustomElement(
  1. String tagName,
  2. BloomNode builder(
    1. CustomElementContext context
    ), {
  3. List<String> observedAttributes = const [],
  4. bool useShadowDom = true,
  5. String shadowMode = 'open',
})

Registers a native browser Custom Element with tagName backed by a Bloom descriptor tree.

Enables authoring reusable Web Components in pure Dart and Bloom JS Native, which can then be consumed by any web application, static HTML page, or non-Bloom framework (React, Vue, Angular, Svelte, vanilla JS).

Lifecycle Integration

  • Connected Callback (connectedCallback): When the custom element connects to the DOM, it mounts the descriptor tree returned by builder via mountToElement into the host element (or a wrapper within the Shadow DOM).
  • Disconnected Callback (disconnectedCallback): When removed from the DOM, it unmounts the Bloom tree and disposes all reactive signals and effects.
  • Attribute Changed Callback (attributeChangedCallback): Attributes listed in observedAttributes are observed; when modified on the DOM element, changes are automatically pushed to the reactive signals created via CustomElementContext.attributeSignal.

Shadow DOM Encapsulation

By default, creates an open Shadow DOM (useShadowDom: true, shadowMode: 'open') and mounts the Bloom subtree into an internal wrapper <div style="display: contents;"> to maintain full style and DOM encapsulation. Set useShadowDom: false to mount directly into the light DOM of the host element.

Browser-Only Execution

defineCustomElement requires the browser's customElements registry, window, and JavaScript evaluation. It does not execute during Server-Side Rendering (renderToHtml).

void main() {
  defineCustomElement(
    'bloom-user-badge',
    (CustomElementContext context) {
      final name = context.attributeSignal('name');
      return Live(() => Div(
        className: 'badge',
        children: [
          Text('User: ${name.value ?? 'Guest'}'),
          Button(
            text: 'Ping',
            onClick: (e) => context.dispatchCustomEvent('badge-click', detail: {'name': name.value}),
          ),
        ],
      ));
    },
    observedAttributes: ['name'],
  );
}

Implementation

void defineCustomElement(
  String tagName,
  BloomNode Function(CustomElementContext context) builder, {
  List<String> observedAttributes = const [],
  bool useShadowDom = true,
  String shadowMode = 'open',
}) {
  final bridgeKey = '__bloom_ce_${tagName.replaceAll('-', '_')}';
  final handlesMap = <web.HTMLElement, BloomMountHandle>{};
  final contextsMap = <web.HTMLElement, CustomElementContext>{};

  void onConnect(web.HTMLElement host) {
    web.ShadowRoot? shadow;
    web.Element mountTarget = host;
    if (useShadowDom) {
      final shadowInit = _newJsObject();
      _reflectSet(shadowInit, 'mode', shadowMode.toJS);
      shadow = host.attachShadow(shadowInit as web.ShadowRootInit);
      // A ShadowRoot is a DocumentFragment, not an Element, so it cannot be
      // passed to mountToElement directly. Mount into a wrapper element inside
      // the shadow root instead; encapsulation is preserved either way.
      final wrapper = web.document.createElement('div') as web.HTMLDivElement;
      wrapper.setAttribute('style', 'display: contents;');
      shadow.appendChild(wrapper);
      mountTarget = wrapper;
    }
    final ctx = CustomElementContext(host: host, shadowRoot: shadow);
    contextsMap[host] = ctx;
    final node = builder(ctx);
    final handle = mountToElement(node, mountTarget);
    handlesMap[host] = handle;
  }

  void onDisconnect(web.HTMLElement host) {
    final handle = handlesMap.remove(host);
    handle?.unmount();
    contextsMap.remove(host);
  }

  void onAttrChange(
      web.HTMLElement host, String name, String? oldValue, String? newValue) {
    final ctx = contextsMap[host];
    ctx?._notifyAttrChange(name, newValue);
  }

  final bridge = _newJsObject();
  _reflectSet(
      bridge, 'onConnect', ((web.HTMLElement host) => onConnect(host)).toJS);
  _reflectSet(bridge, 'onDisconnect',
      ((web.HTMLElement host) => onDisconnect(host)).toJS);
  _reflectSet(
    bridge,
    'onAttrChange',
    ((web.HTMLElement host, JSString name, JSAny? oldVal, JSAny? newVal) {
      onAttrChange(
        host,
        name.toDart,
        oldVal != null && oldVal.isA<JSString>()
            ? (oldVal as JSString).toDart
            : null,
        newVal != null && newVal.isA<JSString>()
            ? (newVal as JSString).toDart
            : null,
      );
    }).toJS,
  );

  final jsWindow = web.window as JSAny;
  _reflectSet(jsWindow, bridgeKey, bridge);

  final attrsJson = observedAttributes.map((a) => '"$a"').join(',');

  final registerJs = '''
(function() {
  if (customElements.get('$tagName')) return;
  const bridge = window['$bridgeKey'];
  class BloomElement extends HTMLElement {
    static get observedAttributes() {
      return [$attrsJson];
    }
    connectedCallback() {
      if (bridge && bridge.onConnect) bridge.onConnect(this);
    }
    disconnectedCallback() {
      if (bridge && bridge.onDisconnect) bridge.onDisconnect(this);
    }
    attributeChangedCallback(name, oldValue, newValue) {
      if (bridge && bridge.onAttrChange) bridge.onAttrChange(this, name, oldValue, newValue);
    }
  }
  customElements.define('$tagName', BloomElement);
})();
''';

  _jsEval(registerJs);
}