533 lines
18 KiB
TypeScript
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>`;
|
|
}
|
|
}
|