defineCustomElement function
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 bybuildervia 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 inobservedAttributesare 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);
}