Creating an action widget
An action widget fires events. It has no data binding — instead, it triggers named events via the on / onClick property that your application handles. The example we’ll walk through is productShare — a widget that renders social-network buttons (with brand icons) and fires a shareEvent when clicked, passing the chosen network as the event detail.
Widget definition
Section titled “Widget definition”The widget accepts a networks prop so callers decide which buttons to render and which icons to use. Each entry has a stable id (sent as the click detail), a human label, and an optional icon — either an inline SVG string or a URL to an SVG/PNG.
import { gui } from '@golemui/gui-shared';
gui.actions.custom('productShare', { onClick: 'shareEvent', networks: [ { id: 'twitter', label: 'X', icon: 'https://cdn.simpleicons.org/x/333' }, { id: 'facebook', label: 'Facebook', icon: 'https://cdn.simpleicons.org/facebook/1877F2', }, { id: 'reddit', label: 'Reddit', icon: 'https://cdn.simpleicons.org/reddit/FF4500', }, ],});{ "kind": "action", "type": "productShare", "props": { "networks": [ { "id": "twitter", "label": "X", "icon": "https://cdn.simpleicons.org/x/333" }, { "id": "facebook", "label": "Facebook", "icon": "https://cdn.simpleicons.org/facebook/1877F2" }, { "id": "reddit", "label": "Reddit", "icon": "https://cdn.simpleicons.org/reddit/FF4500" } ] }, "on": { "click": "shareEvent" }}When the widget calls click('twitter'), the form engine looks up onClick, finds 'shareEvent', and emits it through the formEvent callback with detail: 'twitter'. Your application code catches it there.
Implementation
Section titled “Implementation”Key concepts (React)
useActionWidgetreturnsuid, the mergedtemplateData, and anonClick(detail?)callback that emits the wired event.- The event name (
'shareEvent') lives in the widget definition, not in the component — so the same widget reuses across forms. - Pass the network id as
onClick(network.id)so the consumer’sformEventhandler readsevent.detailto know which button was clicked. style={{ flex: templateData.size }}lets the action participate inflexparents — see Sizing custom widgets for the rationale.
import type { ActionWidget, WithWidget } from '@golemui/core';import { useActionWidget } from '@golemui/react';
interface ShareNetwork { id: string; label: string; icon?: string;}interface ProductShareProps { networks?: ShareNetwork[];}
const DEFAULT_NETWORKS: ShareNetwork[] = [ { id: 'twitter', label: 'X' }, { id: 'facebook', label: 'Facebook' }, { id: 'reddit', label: 'Reddit' },];
function isInlineSvg(icon: string) { return icon.trim().startsWith('<svg');}
export function ProductShare(widgetInstance: WithWidget) { const widget = widgetInstance.widget as ActionWidget; const { uid, templateData, onClick } = useActionWidget<ProductShareProps>(widget);
const networks = templateData.networks ?? DEFAULT_NETWORKS;
return ( <div className="product-share" style={{ flex: templateData.size }}> <div className="product-share__widget" id={uid}> <span className="product-share__label">Share this product:</span> <div className="product-share__buttons"> {networks.map((n) => ( <button key={n.id} type="button" className="product-share__button" onClick={() => onClick(n.id)} title={n.label} aria-label={n.label} > {n.icon ? ( isInlineSvg(n.icon) ? <span className="product-share__icon" dangerouslySetInnerHTML={{ __html: n.icon }} /> : <img className="product-share__icon" src={n.icon} alt="" aria-hidden /> ) : ( <span className="product-share__network">{n.label}</span> )} </button> ))} </div> </div> </div> );}Key concepts (Angular)
- Provide
ActionWidgetAdapter, theninit()/destroy()it. The adapter exposes a reactivetemplateData()signal. - Call
this.adapter.click(networkId)to emit the wired event with the chosen network asdetail. - For URL icons, use
<img [src]="...">. For inline SVG strings, bind via[innerHTML]on a wrapping span (Angular’s sanitizer will strip unsafe content unless you explicitly trust it viaDomSanitizer). - The host binding
'[style.flex]': 'this.adapter.templateData().size'lets the action participate inflexparents — see Sizing custom widgets for the rationale.
import { CommonModule } from '@angular/common';import { Component, inject, OnDestroy, OnInit } from '@angular/core';import { DomSanitizer, SafeHtml } from '@angular/platform-browser';import { ActionWidgetAdapter } from '@golemui/angular';import type { ActionWidget, WithWidget } from '@golemui/core';
interface ShareNetwork { id: string; label: string; icon?: string;}interface ProductShareProps { networks?: ShareNetwork[];}
const DEFAULT_NETWORKS: ShareNetwork[] = [ { id: 'twitter', label: 'X' }, { id: 'facebook', label: 'Facebook' }, { id: 'reddit', label: 'Reddit' },];
@Component({ standalone: true, selector: 'app-product-share', imports: [CommonModule], providers: [ActionWidgetAdapter], host: { class: 'product-share', '[style.flex]': 'this.adapter.templateData().size', }, template: ` @let networks = adapter.templateData().networks ?? defaults;
<div class="product-share__widget" [id]="widget.uid"> <span class="product-share__label">Share this product:</span> <div class="product-share__buttons"> @for (n of networks; track n.id) { <button type="button" class="product-share__button" (click)="share(n.id)" [title]="n.label" [attr.aria-label]="n.label" > @if (n.icon) { @if (isInlineSvg(n.icon)) { <span class="product-share__icon" [innerHTML]="trustHtml(n.icon)"></span> } @else { <img class="product-share__icon" [src]="n.icon" alt="" aria-hidden="true" /> } } @else { <span class="product-share__network">{{ n.label }}</span> } </button> } </div> </div> `,})export class ProductShareComponent implements OnInit, OnDestroy, WithWidget{ widget!: ActionWidget;
protected adapter: ActionWidgetAdapter<ProductShareProps> = inject(ActionWidgetAdapter);
private sanitizer = inject(DomSanitizer); protected defaults = DEFAULT_NETWORKS;
ngOnInit(): void { this.adapter.init(this.widget); }
ngOnDestroy(): void { this.adapter.destroy(); }
share(id: string): void { this.adapter.click(id); }
isInlineSvg(icon: string): boolean { return icon.trim().startsWith('<svg'); }
trustHtml(icon: string): SafeHtml { return this.sanitizer.bypassSecurityTrustHtml(icon); }}Key concepts (Lit)
- Consume
formContextand provideactionContext. - Subscribe to
adapter.templateDataChanged$to driverequestUpdate(); clean up indisconnectedCallback. - For URL icons, render an
<img>. For inline SVG strings, use Lit’sunsafeHTMLdirective to inject the SVG as innerHTML on a wrapping span. - Adding
class="product-share"and bindingflextotemplateData.sizelets the action participate inflexparents — see Sizing custom widgets for the rationale.
import { html, LitElement } from 'lit';import { customElement } from 'lit/decorators.js';import { unsafeHTML } from 'lit/directives/unsafe-html.js';import { consume, provide } from '@lit/context';import { Subscription } from 'rxjs';import type { ActionWidget, WithWidget } from '@golemui/core';import { actionContext, ActionWidgetAdapter, formContext, type LitFormContext,} from '@golemui/lit';
interface ShareNetwork { id: string; label: string; icon?: string;}interface ProductShareProps { networks?: ShareNetwork[];}
const DEFAULT_NETWORKS: ShareNetwork[] = [ { id: 'twitter', label: 'X' }, { id: 'facebook', label: 'Facebook' }, { id: 'reddit', label: 'Reddit' },];
function isInlineSvg(icon: string) { return icon.trim().startsWith('<svg');}
@customElement('app-product-share')export class ProductShareElement extends LitElement implements WithWidget{ widget!: ActionWidget;
@consume({ context: formContext }) formContext!: LitFormContext<any>;
@provide({ context: actionContext }) adapter = new ActionWidgetAdapter<ProductShareProps>();
subscriptions: Subscription[] = [];
override createRenderRoot() { return this; }
override connectedCallback() { super.connectedCallback(); this.classList.add('product-share'); this.adapter.context = this.formContext; this.adapter.init(this.widget);
this.subscriptions.push( this.adapter.templateDataChanged$.subscribe(() => this.requestUpdate(), ), ); }
override render() { const networks = this.adapter.templateData.networks ?? DEFAULT_NETWORKS; return html` <div class="product-share__widget" id=${this.widget.uid}> <span class="product-share__label">Share this product:</span> <div class="product-share__buttons"> ${networks.map( (n) => html` <button type="button" class="product-share__button" @click=${() => this.adapter.click(n.id)} title=${n.label} aria-label=${n.label} > ${n.icon ? isInlineSvg(n.icon) ? html`<span class="product-share__icon" >${unsafeHTML(n.icon)}</span >` : html`<img class="product-share__icon" src=${n.icon} alt="" aria-hidden="true" />` : html`<span class="product-share__network">${n.label}</span>`} </button> `, )} </div> </div> `; }
override disconnectedCallback() { super.disconnectedCallback(); this.adapter.destroy(); this.subscriptions.forEach((s) => s.unsubscribe()); }}Key concepts (Vue)
useActionWidgetreturnsuid,templateData(as aRef), and anonClick()callback that emits the wired event.- Pass the network id as
onClick(network.id)so the consumer’sformEventhandler readsevent.detailto know which button was clicked. - For URL icons, render an
<img>. For inline SVG strings, use Vue’sv-htmldirective — Vue’s template compiler won’t escape the markup. :style="{ flex: templateData.size }"lets the action participate inflexparents — see Sizing custom widgets for the rationale.
<script setup lang="ts">import type { ActionWidget, WithWidget } from '@golemui/core';import { useActionWidget } from '@golemui/vue';import { computed } from 'vue';
interface ShareNetwork { id: string; label: string; icon?: string;}interface ProductShareProps { networks?: ShareNetwork[];}
const DEFAULT_NETWORKS: ShareNetwork[] = [ { id: 'twitter', label: 'X' }, { id: 'facebook', label: 'Facebook' }, { id: 'reddit', label: 'Reddit' },];
const props = defineProps<WithWidget>();const widget = props.widget as ActionWidget;const { uid, templateData, onClick } = useActionWidget<ProductShareProps>(widget);
const networks = computed(() => templateData.value.networks ?? DEFAULT_NETWORKS);const isInlineSvg = (icon: string) => icon.trim().startsWith('<svg');</script>
<template> <div class="product-share" :style="{ flex: templateData.size }"> <div class="product-share__widget" :id="uid"> <span class="product-share__label">Share this product:</span> <div class="product-share__buttons"> <button v-for="n in networks" :key="n.id" type="button" class="product-share__button" :title="n.label" :aria-label="n.label" @click="onClick(n.id)" > <template v-if="n.icon"> <span v-if="isInlineSvg(n.icon)" class="product-share__icon" v-html="n.icon"></span> <img v-else class="product-share__icon" :src="n.icon" alt="" aria-hidden="true" /> </template> <span v-else class="product-share__network">{{ n.label }}</span> </button> </div> </div> </div></template>Key concepts (Vanilla JS)
- Extend
LitElementfromlit(already a transitive dependency via@golemui/lit) and consumeformContext/ provideactionContextso the form engine can wire your widget into the registry. - Subscribe to
adapter.templateDataChanged$to driverequestUpdate(); clean up indisconnectedCallback. - For URL icons, render an
<img>. For inline SVG strings, use Lit’sunsafeHTMLdirective to inject the SVG as innerHTML on a wrapping span. - Call
this.adapter.click(id)to emit the wired event with the network id asdetail. - Register the class with
customElements.define('app-product-share', ProductShareElement)at the bottom of the file. - Adding
class="product-share"and bindingflextotemplateData.sizelets the action participate inflexparents — see Sizing custom widgets for the rationale.
import { html, LitElement } from 'lit';import { unsafeHTML } from 'lit/directives/unsafe-html.js';import { consume, provide } from '@lit/context';import { Subscription } from 'rxjs';import type { ActionWidget, WithWidget } from '@golemui/core';import { actionContext, ActionWidgetAdapter, formContext, type LitFormContext,} from '@golemui/lit';
interface ShareNetwork { id: string; label: string; icon?: string;}interface ProductShareProps { networks?: ShareNetwork[];}
const DEFAULT_NETWORKS: ShareNetwork[] = [ { id: 'twitter', label: 'X' }, { id: 'facebook', label: 'Facebook' }, { id: 'reddit', label: 'Reddit' },];
function isInlineSvg(icon: string) { return icon.trim().startsWith('<svg');}
export class ProductShareElement extends LitElement implements WithWidget{ widget!: ActionWidget;
@consume({ context: formContext }) formContext!: LitFormContext<any>;
@provide({ context: actionContext }) adapter = new ActionWidgetAdapter<ProductShareProps>();
subscriptions: Subscription[] = [];
override createRenderRoot() { return this; }
override connectedCallback() { super.connectedCallback(); this.classList.add('product-share'); this.adapter.context = this.formContext; this.adapter.init(this.widget);
this.subscriptions.push( this.adapter.templateDataChanged$.subscribe(() => this.requestUpdate(), ), ); }
override render() { const networks = this.adapter.templateData.networks ?? DEFAULT_NETWORKS; return html` <div class="product-share__widget" id=${this.widget.uid}> <span class="product-share__label">Share this product:</span> <div class="product-share__buttons"> ${networks.map( (n) => html` <button type="button" class="product-share__button" @click=${() => this.adapter.click(n.id)} title=${n.label} aria-label=${n.label} > ${n.icon ? isInlineSvg(n.icon) ? html`<span class="product-share__icon" >${unsafeHTML(n.icon)}</span >` : html`<img class="product-share__icon" src=${n.icon} alt="" aria-hidden="true" />` : html`<span class="product-share__network">${n.label}</span>`} </button> `, )} </div> </div> `; }
override disconnectedCallback() { super.disconnectedCallback(); this.adapter.destroy(); this.subscriptions.forEach((s) => s.unsubscribe()); }}
customElements.define('app-product-share', ProductShareElement);Result
Section titled “Result”Click any of the share buttons to fire the shareEvent. The icons come from https://cdn.simpleicons.org, but you can pass any SVG string or URL via the networks prop.
See also
Section titled “See also”- Sending events — emitting events with detail payloads, and catching them application-side.
- Custom Widgets Overview — the full Product Card example with all four kinds.
- Sizing custom widgets — why every widget reads
templateData.size. - Features / Form Events — handling events application-side.