Files
clawdbot/vendor/a2ui/renderers/lit/src/0.8/ui/root.ts
2025-12-26 12:17:36 +00:00

533 lines
18 KiB
TypeScript

/*
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: 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<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>`;
}
}