feat(macos): add Canvas A2UI renderer
This commit is contained in:
532
vendor/a2ui/renderers/lit/src/0.8/ui/root.ts
vendored
Normal file
532
vendor/a2ui/renderers/lit/src/0.8/ui/root.ts
vendored
Normal file
@@ -0,0 +1,532 @@
|
||||
/*
|
||||
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) {
|
||||
@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>`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user