/* 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 = 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: 8px; 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): 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``; } 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``; } case "Column": { const node = component as NodeOfType<"Column">; return html``; } case "Row": { const node = component as NodeOfType<"Row">; return html``; } case "Image": { const node = component as NodeOfType<"Image">; return html``; } case "Icon": { const node = component as NodeOfType<"Icon">; return html``; } case "AudioPlayer": { const node = component as NodeOfType<"AudioPlayer">; return html``; } case "Button": { const node = component as NodeOfType<"Button">; return html``; } case "Text": { const node = component as NodeOfType<"Text">; return html``; } case "CheckBox": { const node = component as NodeOfType<"CheckBox">; return html``; } case "DateTimeInput": { const node = component as NodeOfType<"DateTimeInput">; return html``; } case "Divider": { // TODO: thickness, axis and color. const node = component as NodeOfType<"Divider">; return html``; } case "MultipleChoice": { // TODO: maxAllowedSelections and selections. const node = component as NodeOfType<"MultipleChoice">; return html``; } case "Slider": { const node = component as NodeOfType<"Slider">; return html``; } case "TextField": { // TODO: type and validationRegexp. const node = component as NodeOfType<"TextField">; return html``; } case "Video": { const node = component as NodeOfType<"Video">; return html``; } 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``; } case "Modal": { const node = component as NodeOfType<"Modal">; const childComponents: AnyComponentNode[] = [ node.properties.entryPointChild, node.properties.contentChild, ]; node.properties.entryPointChild.slotName = "entry"; return html``; } 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``; } }