/*
Copyright 2025 Google LLC
Licensed under the Apache License , Version 2 . 0 ( the " License " ) ;
you may not use this file except in compliance with the License .
You may obtain a copy of the License at
https : //www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing , software
distributed under the License is distributed on an " AS IS " BASIS ,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND , either express or implied .
See the License for the specific language governing permissions and
limitations under the License .
*/
import { SignalWatcher } from "@lit-labs/signals" ;
import { consume } from "@lit/context" ;
import {
css,
html,
LitElement,
nothing,
PropertyValues,
render,
TemplateResult,
} from "lit" ;
import { customElement, property } from "lit/decorators.js" ;
import { map } from "lit/directives/map.js" ;
import { effect } from "signal-utils/subtle/microtask-effect" ;
import { A2uiMessageProcessor } from "../data/model-processor.js" ;
import { StringValue } from "../types/primitives.js" ;
import { Theme, AnyComponentNode, SurfaceID } from "../types/types.js" ;
import { themeContext } from "./context/theme.js" ;
import { structuralStyles } from "./styles.js" ;
import { componentRegistry } from "./component-registry.js" ;
type NodeOfType<T extends AnyComponentNode["type" ]> = Extract<
AnyComponentNode,
{ type: T }
>;
// This is the base class all the components will inherit
@customElement("a2ui-root" )
export class Root extends (SignalWatcher(LitElement) as typeof LitElement) {
@property()
accessor surfaceId: SurfaceID | null = null ;
@property()
accessor component: AnyComponentNode | null = null ;
@consume({ context: themeContext })
accessor theme!: Theme;
@property({ attribute: false })
accessor childComponents: AnyComponentNode[] | null = null ;
@property({ attribute: false })
accessor processor: A2uiMessageProcessor | null = null ;
@property()
accessor dataContextPath: string = "" ;
@property()
accessor enableCustomElements = false ;
@property()
set weight(weight: string | number) {
this .#weight = weight;
this .style.setProperty("--weight" , `${weight}`);
}
get weight() {
return this .#weight;
}
#weight: string | number = 1 ;
static styles = [
structuralStyles,
css`
:host {
display: flex;
flex-direction: column;
gap: 8 px;
max-height: 80 %;
}
`,
];
/**
* Holds the cleanup function for our effect .
* We need this to stop the effect when the component is disconnected .
*/
#lightDomEffectDisposer: null | (() => void ) = null ;
protected willUpdate(changedProperties: PropertyValues<this >): void {
if (changedProperties.has("childComponents" )) {
if (this .#lightDomEffectDisposer) {
this .#lightDomEffectDisposer();
}
// This effect watches the A2UI Children signal and updates the Light DOM.
this .#lightDomEffectDisposer = effect(() => {
// 1. Read the signal to create the subscription.
const allChildren = this .childComponents ?? null ;
// 2. Generate the template for the children.
const lightDomTemplate = this .renderComponentTree(allChildren);
// 3. Imperatively render that template into the component itself.
render(lightDomTemplate, this , { host: this });
});
}
}
/**
* Clean up the effect when the component is removed from the DOM .
*/
disconnectedCallback(): void {
super .disconnectedCallback();
if (this .#lightDomEffectDisposer) {
this .#lightDomEffectDisposer();
}
}
/**
* Turns the SignalMap into a renderable TemplateResult for Lit .
*/
private renderComponentTree(
components: AnyComponentNode[] | null
): TemplateResult | typeof nothing {
if (!components) {
return nothing;
}
if (!Array.isArray(components)) {
return nothing;
}
return html` ${map(components, (component) => {
// 1. Check if there is a registered custom component or override.
if (this .enableCustomElements) {
const registeredCtor = componentRegistry.get(component.type);
// We also check customElements.get for non-registered but defined elements
const elCtor = registeredCtor || customElements.get(component.type);
if (elCtor) {
const node = component as AnyComponentNode;
const el = new elCtor() as Root;
el.id = node.id;
if (node.slotName) {
el.slot = node.slotName;
}
el.component = node;
el.weight = node.weight ?? "initial" ;
el.processor = this .processor;
el.surfaceId = this .surfaceId;
el.dataContextPath = node.dataContextPath ?? "/" ;
for (const [prop, val] of Object.entries(component.properties)) {
// @ts-expect-error We're off the books.
el[prop] = val;
}
return html`${el}`;
}
}
// 2. Fallback to standard components.
switch (component.type) {
case "List" : {
const node = component as NodeOfType<"List" >;
const childComponents = node.properties.children;
return html`<a2ui-list
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.direction=${node.properties.direction ?? "vertical" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.childComponents=${childComponents}
.enableCustomElements=${this .enableCustomElements}
></a2ui-list>`;
}
case "Card" : {
const node = component as NodeOfType<"Card" >;
let childComponents: AnyComponentNode[] | null =
node.properties.children;
if (!childComponents && node.properties.child) {
childComponents = [node.properties.child];
}
return html`<a2ui-card
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.childComponents=${childComponents}
.dataContextPath=${node.dataContextPath ?? "" }
.enableCustomElements=${this .enableCustomElements}
></a2ui-card>`;
}
case "Column" : {
const node = component as NodeOfType<"Column" >;
return html`<a2ui-column
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.childComponents=${node.properties.children ?? null }
.dataContextPath=${node.dataContextPath ?? "" }
.alignment=${node.properties.alignment ?? "stretch" }
.distribution=${node.properties.distribution ?? "start" }
.enableCustomElements=${this .enableCustomElements}
></a2ui-column>`;
}
case "Row" : {
const node = component as NodeOfType<"Row" >;
return html`<a2ui-row
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.childComponents=${node.properties.children ?? null }
.dataContextPath=${node.dataContextPath ?? "" }
.alignment=${node.properties.alignment ?? "stretch" }
.distribution=${node.properties.distribution ?? "start" }
.enableCustomElements=${this .enableCustomElements}
></a2ui-row>`;
}
case "Image" : {
const node = component as NodeOfType<"Image" >;
return html`<a2ui-image
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.url=${node.properties.url ?? null }
.dataContextPath=${node.dataContextPath ?? "" }
.usageHint=${node.properties.usageHint}
.fit=${node.properties.fit}
.enableCustomElements=${this .enableCustomElements}
></a2ui-image>`;
}
case "Icon" : {
const node = component as NodeOfType<"Icon" >;
return html`<a2ui-icon
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.name=${node.properties.name ?? null }
.dataContextPath=${node.dataContextPath ?? "" }
.enableCustomElements=${this .enableCustomElements}
></a2ui-icon>`;
}
case "AudioPlayer" : {
const node = component as NodeOfType<"AudioPlayer" >;
return html`<a2ui-audioplayer
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.url=${node.properties.url ?? null }
.dataContextPath=${node.dataContextPath ?? "" }
.enableCustomElements=${this .enableCustomElements}
></a2ui-audioplayer>`;
}
case "Button" : {
const node = component as NodeOfType<"Button" >;
return html`<a2ui-button
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath ?? "" }
.action=${node.properties.action}
.childComponents=${[node.properties.child]}
.enableCustomElements=${this .enableCustomElements}
></a2ui-button>`;
}
case "Text" : {
const node = component as NodeOfType<"Text" >;
return html`<a2ui-text
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.model=${this .processor}
.surfaceId=${this .surfaceId}
.processor=${this .processor}
.dataContextPath=${node.dataContextPath}
.text=${node.properties.text}
.usageHint=${node.properties.usageHint}
.enableCustomElements=${this .enableCustomElements}
></a2ui-text>`;
}
case "CheckBox" : {
const node = component as NodeOfType<"CheckBox" >;
return html`<a2ui-checkbox
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath ?? "" }
.label=${node.properties.label}
.value=${node.properties.value}
.enableCustomElements=${this .enableCustomElements}
></a2ui-checkbox>`;
}
case "DateTimeInput" : {
const node = component as NodeOfType<"DateTimeInput" >;
return html`<a2ui-datetimeinput
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath ?? "" }
.enableDate=${node.properties.enableDate ?? true }
.enableTime=${node.properties.enableTime ?? true }
.outputFormat=${node.properties.outputFormat}
.value=${node.properties.value}
.enableCustomElements=${this .enableCustomElements}
></a2ui-datetimeinput>`;
}
case "Divider" : {
// TODO: thickness, axis and color.
const node = component as NodeOfType<"Divider" >;
return html`<a2ui-divider
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath}
.thickness=${node.properties.thickness}
.axis=${node.properties.axis}
.color=${node.properties.color}
.enableCustomElements=${this .enableCustomElements}
></a2ui-divider>`;
}
case "MultipleChoice" : {
// TODO: maxAllowedSelections and selections.
const node = component as NodeOfType<"MultipleChoice" >;
return html`<a2ui-multiplechoice
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath}
.options=${node.properties.options}
.maxAllowedSelections=${node.properties.maxAllowedSelections}
.selections=${node.properties.selections}
.enableCustomElements=${this .enableCustomElements}
></a2ui-multiplechoice>`;
}
case "Slider" : {
const node = component as NodeOfType<"Slider" >;
return html`<a2ui-slider
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath}
.value=${node.properties.value}
.minValue=${node.properties.minValue}
.maxValue=${node.properties.maxValue}
.enableCustomElements=${this .enableCustomElements}
></a2ui-slider>`;
}
case "TextField" : {
// TODO: type and validationRegexp.
const node = component as NodeOfType<"TextField" >;
return html`<a2ui-textfield
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath}
.label=${node.properties.label}
.text=${node.properties.text}
.type=${node.properties.type}
.validationRegexp=${node.properties.validationRegexp}
.enableCustomElements=${this .enableCustomElements}
></a2ui-textfield>`;
}
case "Video" : {
const node = component as NodeOfType<"Video" >;
return html`<a2ui-video
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath}
.url=${node.properties.url}
.enableCustomElements=${this .enableCustomElements}
></a2ui-video>`;
}
case "Tabs" : {
const node = component as NodeOfType<"Tabs" >;
const titles: StringValue[] = [];
const childComponents: AnyComponentNode[] = [];
if (node.properties.tabItems) {
for (const item of node.properties.tabItems) {
titles.push(item.title);
childComponents.push(item.child);
}
}
return html`<a2ui-tabs
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath}
.titles=${titles}
.childComponents=${childComponents}
.enableCustomElements=${this .enableCustomElements}
></a2ui-tabs>`;
}
case "Modal" : {
const node = component as NodeOfType<"Modal" >;
const childComponents: AnyComponentNode[] = [
node.properties.entryPointChild,
node.properties.contentChild,
];
node.properties.entryPointChild.slotName = "entry" ;
return html`<a2ui-modal
id=${node.id}
slot=${node.slotName ? node.slotName : nothing}
.component=${node}
.weight=${node.weight ?? "initial" }
.processor=${this .processor}
.surfaceId=${this .surfaceId}
.dataContextPath=${node.dataContextPath}
.childComponents=${childComponents}
.enableCustomElements=${this .enableCustomElements}
></a2ui-modal>`;
}
default : {
return this .renderCustomComponent(component);
}
}
})}`;
}
private renderCustomComponent(component: AnyComponentNode) {
if (!this .enableCustomElements) {
return ;
}
const node = component as AnyComponentNode;
const registeredCtor = componentRegistry.get(component.type);
const elCtor = registeredCtor || customElements.get(component.type);
if (!elCtor) {
return html`Unknown element ${component.type}`;
}
const el = new elCtor() as Root;
el.id = node.id;
if (node.slotName) {
el.slot = node.slotName;
}
el.component = node;
el.weight = node.weight ?? "initial" ;
el.processor = this .processor;
el.surfaceId = this .surfaceId;
el.dataContextPath = node.dataContextPath ?? "/" ;
for (const [prop, val] of Object.entries(component.properties)) {
// @ts-expect-error We're off the books.
el[prop] = val;
}
return html`${el}`;
}
render(): TemplateResult | typeof nothing {
return html`<slot></slot>`;
}
}
Messung V0.5 in Prozent C=97 H=98 G=97
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-10-11)
¤
*© Formatika GbR, Deutschland