Creating an input widget
An input widget binds to a path in the form data, supports validation, and tracks touched state. The framework helpers (useInputWidget in React and Vue, InputWidgetAdapter in Angular and Lit) handle data binding for you — your widget calls a few methods, the engine does the rest. The example we’ll walk through is productRating — a star-based rating control.
Widget definition
Section titled “Widget definition”The maxRating prop controls how many stars are displayed. The path ('product.rating') tells the form engine where to store the selected value (a number from 1 to maxRating). The validator adds built-in number validation — in this case, making the field required.
import { gui } from '@golemui/gui-shared';
gui.inputs.custom('productRating', 'product.rating', { maxRating: 10, validator: { type: 'number', required: true },});{ "kind": "input", "type": "productRating", "path": "product.rating", "props": { "maxRating": 10 }, "validator": { "type": "number", "required": true }}API summary
Section titled “API summary”| Method | Purpose |
|---|---|
onValueChanged(value) | Update the form data at the widget’s path. |
onBlur() | Mark the field as touched and trigger blur-time validation. |
templateData.errors | Array of validation error messages for this field. |
templateData.touched | Whether the user has interacted with the field. |
injectValidationIssues(issues) | Programmatically inject custom validation errors. |
Implementation
Section titled “Implementation”Key concepts (React)
useInputWidgetreturns the typedvalue,errors,isTouched, and a mergedtemplateData(your custom props + engine-managed values).- Call
onValueChanged(newValue)from a click/change handler — the engine writes through to the form data and runs validation. - Call
onBlur()when the user leaves the field. WithvalidateOn: 'blur'(or'eager'), this triggers validation; either way, it setsisTouched. style={{ flex: templateData.size }}lets the input participate inflexparents — see Sizing custom widgets for the rationale.
import type { InputWidget, WithWidget } from '@golemui/core';import { useInputWidget } from '@golemui/react';
interface ProductRatingProps { maxRating: number;}
export function ProductRating(widgetInstance: WithWidget) { const widget = widgetInstance.widget as InputWidget<number>; const { uid, value, errors, isTouched, templateData, onValueChanged, onBlur } = useInputWidget<number, ProductRatingProps>(widget);
const maxRating = templateData.maxRating || 5;
return ( <div className="product-rating" style={{ flex: templateData.size }}> <div className="product-rating__widget" id={uid}> <div className="product-rating__stars" onBlur={onBlur} tabIndex={0}> {Array.from({ length: maxRating }, (_, i) => i + 1).map((star) => ( <span key={star} role="button" className={`product-rating__star ${ star <= (value || 0) ? 'product-rating__star--active' : '' }`} onClick={() => onValueChanged(star)} > {star <= (value || 0) ? '★' : '☆'} </span> ))} </div> {isTouched && errors.length > 0 && ( <div className="product-rating__errors"> {errors.map((error, i) => ( <span key={i} className="product-rating__error">{error}</span> ))} </div> )} </div> </div> );}Key concepts (Angular)
- Provide
InputWidgetAdapterin the component’sproviders, theninit()it inngOnInitanddestroy()it inngOnDestroy. The adapter’stemplateData()signal exposesvalue,errors,touched, plus your custom props. - Call
this.adapter.valueChanged(newValue)to write through to the form data and trigger validation. - Call
this.adapter.onBlur()from a(blur)handler to mark the field touched and run blur-time validation. - The host binding
'[style.flex]': 'this.adapter.templateData().size'lets the input participate inflexparents — see Sizing custom widgets for the rationale.
import { CommonModule } from '@angular/common';import { Component, inject, OnDestroy, OnInit } from '@angular/core';import { InputWidgetAdapter } from '@golemui/angular';import type { InputWidget, WithWidget } from '@golemui/core';
interface ProductRatingProps { maxRating: number;}
@Component({ standalone: true, selector: 'app-product-rating', imports: [CommonModule], providers: [InputWidgetAdapter], host: { class: 'product-rating', '[style.flex]': 'this.adapter.templateData().size', }, template: ` @let templateData = adapter.templateData(); @let maxRating = templateData.maxRating || 5;
<div class="product-rating__widget" [id]="widget.uid"> <div class="product-rating__stars" (blur)="onBlur()" tabindex="0"> @for (star of stars(maxRating); track star) { <span role="button" class="product-rating__star" [class.product-rating__star--active]="star <= (templateData.value || 0)" (click)="selectRating(star)" > {{ star <= (templateData.value || 0) ? '★' : '☆' }} </span> } </div> @if (templateData.touched && templateData.errors?.length) { <div class="product-rating__errors"> @for (error of templateData.errors; track error) { <span class="product-rating__error">{{ error }}</span> } </div> } </div> `,})export class ProductRatingComponent implements OnInit, OnDestroy, WithWidget{ widget!: InputWidget<number>;
protected adapter: InputWidgetAdapter<number, ProductRatingProps> = inject(InputWidgetAdapter);
ngOnInit(): void { this.adapter.init(this.widget); }
ngOnDestroy(): void { this.adapter.destroy(); }
stars(count: number): number[] { return Array.from({ length: count }, (_, i) => i + 1); }
selectRating(star: number): void { this.adapter.valueChanged(star); }
onBlur(): void { this.adapter.onBlur(); }}Key concepts (Lit)
- Consume
formContextand provideinputContextso any nested widget can resolve through the registry. - Subscribe to
adapter.templateDataChanged$to drive Lit’srequestUpdate(). Clean up the subscription indisconnectedCallback. - Call
this.adapter.valueChanged(newValue)to update the form data; callthis.adapter.onBlur()from a@blurhandler. - Adding
class="product-rating"and bindingflextotemplateData.sizelets the input participate inflexparents — see Sizing custom widgets for the rationale.
import { html, LitElement, nothing } from 'lit';import { customElement } from 'lit/decorators.js';import { consume, provide } from '@lit/context';import { Subscription } from 'rxjs';import type { InputWidget, WithWidget } from '@golemui/core';import { formContext, inputContext, InputWidgetAdapter, type LitFormContext,} from '@golemui/lit';
interface ProductRatingProps { maxRating: number;}
@customElement('app-product-rating')export class ProductRatingElement extends LitElement implements WithWidget{ widget!: InputWidget<number>;
@consume({ context: formContext }) formContext!: LitFormContext<any>;
@provide({ context: inputContext }) adapter = new InputWidgetAdapter<number, ProductRatingProps>();
subscriptions: Subscription[] = [];
override createRenderRoot() { return this; }
override connectedCallback() { super.connectedCallback(); this.classList.add('product-rating'); this.adapter.context = this.formContext; this.adapter.init(this.widget);
this.subscriptions.push( this.adapter.templateDataChanged$.subscribe(() => this.requestUpdate(), ), ); }
override render() { const { value, errors, touched, maxRating } = this.adapter.templateData; const stars = maxRating || 5;
return html` <div class="product-rating__widget" id=${this.widget.uid}> <div class="product-rating__stars" tabindex="0" @blur=${() => this.adapter.onBlur()} > ${Array.from({ length: stars }, (_, i) => i + 1).map( (star) => html` <span role="button" class="product-rating__star ${star <= (value || 0) ? 'product-rating__star--active' : ''}" @click=${() => this.adapter.valueChanged(star)} > ${star <= (value || 0) ? '★' : '☆'} </span> `, )} </div> ${touched && errors?.length ? html` <div class="product-rating__errors"> ${errors.map( (error: string) => html`<span class="product-rating__error" >${error}</span >`, )} </div> ` : nothing} </div> `; }
override disconnectedCallback() { super.disconnectedCallback(); this.adapter.destroy(); this.subscriptions.forEach((s) => s.unsubscribe()); }}Key concepts (Vue)
useInputWidgetreturns reactiveRefs forvalue,errors,isTouched, andtemplateData(your custom props + engine-managed values). Vue’s template auto-unwraps refs.- Call
onValueChanged(newValue)from a click handler — the engine writes through to the form data and runs validation. - Call
onBlur()when the user leaves the field. WithvalidateOn: 'blur'(or'eager'), this triggers validation; either way, it setsisTouched. :style="{ flex: templateData.size }"lets the input participate inflexparents — see Sizing custom widgets for the rationale.
<script setup lang="ts">import type { InputWidget, WithWidget } from '@golemui/core';import { useInputWidget } from '@golemui/vue';import { computed } from 'vue';
interface ProductRatingProps { maxRating: number;}
const props = defineProps<WithWidget>();const widget = props.widget as InputWidget<number>;const { uid, value, errors, isTouched, templateData, onValueChanged, onBlur } = useInputWidget<number, ProductRatingProps>(widget);
const maxRating = computed(() => templateData.value.maxRating || 5);const stars = computed(() => Array.from({ length: maxRating.value }, (_, i) => i + 1));</script>
<template> <div class="product-rating" :style="{ flex: templateData.size }"> <div class="product-rating__widget" :id="uid"> <div class="product-rating__stars" tabindex="0" @blur="onBlur"> <span v-for="star in stars" :key="star" role="button" :class="['product-rating__star', star <= (value || 0) && 'product-rating__star--active']" @click="onValueChanged(star)" > {{ star <= (value || 0) ? '★' : '☆' }} </span> </div> <div v-if="isTouched && errors.length > 0" class="product-rating__errors"> <span v-for="(error, i) in errors" :key="i" class="product-rating__error"> {{ error }} </span> </div> </div> </div></template>Key concepts (Vanilla JS)
- Extend
LitElementfromlit(already a transitive dependency via@golemui/lit). ConsumeformContextand provideinputContextso any nested widget can resolve through the registry. - Subscribe to
adapter.templateDataChanged$to driverequestUpdate(). Clean up the subscription indisconnectedCallback. - Call
this.adapter.valueChanged(newValue)to update the form data; callthis.adapter.onBlur()from a@blurhandler. - Register the class with
customElements.define('app-product-rating', ProductRatingElement)at the bottom of the file. - Adding
class="product-rating"and bindingflextotemplateData.sizelets the input participate inflexparents — see Sizing custom widgets for the rationale.
import { html, LitElement, nothing } from 'lit';import { consume, provide } from '@lit/context';import { Subscription } from 'rxjs';import type { InputWidget, WithWidget } from '@golemui/core';import { formContext, inputContext, InputWidgetAdapter, type LitFormContext,} from '@golemui/lit';
interface ProductRatingProps { maxRating: number;}
export class ProductRatingElement extends LitElement implements WithWidget{ widget!: InputWidget<number>;
@consume({ context: formContext }) formContext!: LitFormContext<any>;
@provide({ context: inputContext }) adapter = new InputWidgetAdapter<number, ProductRatingProps>();
subscriptions: Subscription[] = [];
override createRenderRoot() { return this; }
override connectedCallback() { super.connectedCallback(); this.classList.add('product-rating'); this.adapter.context = this.formContext; this.adapter.init(this.widget);
this.subscriptions.push( this.adapter.templateDataChanged$.subscribe(() => this.requestUpdate(), ), ); }
override render() { const { value, errors, touched, maxRating } = this.adapter.templateData; const stars = maxRating || 5;
return html` <div class="product-rating__widget" id=${this.widget.uid}> <div class="product-rating__stars" tabindex="0" @blur=${() => this.adapter.onBlur()} > ${Array.from({ length: stars }, (_, i) => i + 1).map( (star) => html` <span role="button" class="product-rating__star ${star <= (value || 0) ? 'product-rating__star--active' : ''}" @click=${() => this.adapter.valueChanged(star)} > ${star <= (value || 0) ? '★' : '☆'} </span> `, )} </div> ${touched && errors?.length ? html` <div class="product-rating__errors"> ${errors.map( (error: string) => html`<span class="product-rating__error" >${error}</span >`, )} </div> ` : nothing} </div> `; }
override disconnectedCallback() { super.disconnectedCallback(); this.adapter.destroy(); this.subscriptions.forEach((s) => s.unsubscribe()); }}
customElements.define('app-product-rating', ProductRatingElement);Result
Section titled “Result”Try clicking the stars to select a rating, then click away to trigger validation:
See also
Section titled “See also”- Custom Widgets Overview — the full Product Card example with all four kinds.
- Sizing custom widgets — why every widget reads
templateData.size. - Form Definition API / Custom Widgets —
gui.inputs.custom(...)reference. - Features / Validators — validators on custom inputs.