feat(macos): add Canvas A2UI renderer

This commit is contained in:
Peter Steinberger
2025-12-17 11:35:06 +01:00
parent 1cdebb68a0
commit cdb5ddb2da
408 changed files with 73598 additions and 32 deletions

View File

@@ -0,0 +1,50 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-audio',
template: `
@let resolvedUrl = this.resolvedUrl();
@if (resolvedUrl) {
<section [class]="theme.components.AudioPlayer" [style]="theme.additionalStyles?.AudioPlayer">
<audio controls [src]="resolvedUrl"></audio>
</section>
}
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
audio {
display: block;
width: 100%;
box-sizing: border-box;
}
`
})
export class Audio extends DynamicComponent {
readonly url = input.required<Primitives.StringValue | null>();
protected readonly resolvedUrl = computed(() => this.resolvePrimitive(this.url()));
}

View File

@@ -0,0 +1,56 @@
/*
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 { Component, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-button',
imports: [Renderer],
template: `
<button
[class]="theme.components.Button"
[style]="theme.additionalStyles?.Button"
(click)="handleClick()"
>
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="component().properties.child"
/>
</button>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
}
`,
})
export class Button extends DynamicComponent<Types.ButtonNode> {
readonly action = input.required<Types.Action | null>();
protected handleClick() {
const action = this.action();
if (action) {
super.sendAction(action);
}
}
}

View File

@@ -0,0 +1,57 @@
/*
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 { Component, ViewEncapsulation } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
import { Types } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-card',
imports: [Renderer],
encapsulation: ViewEncapsulation.None,
styles: `
a2ui-card {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
a2ui-card > section {
height: 100%;
width: 100%;
min-height: 0;
overflow: auto;
}
a2ui-card > section > * {
height: 100%;
width: 100%;
}
`,
template: `
@let properties = component().properties;
@let children = properties.children || [properties.child];
<section [class]="theme.components.Card" [style]="theme.additionalStyles?.Card">
@for (child of children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class Card extends DynamicComponent<Types.CardNode> { }

View File

@@ -0,0 +1,73 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-checkbox',
template: `
<section
[class]="theme.components.CheckBox.container"
[style]="theme.additionalStyles?.CheckBox"
>
<input
autocomplete="off"
type="checkbox"
[id]="inputId"
[checked]="inputChecked()"
[class]="theme.components.CheckBox.element"
(change)="handleChange($event)"
/>
<label [htmlFor]="inputId" [class]="theme.components.CheckBox.label">{{
resolvedLabel()
}}</label>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
input {
display: block;
width: 100%;
}
`,
})
export class Checkbox extends DynamicComponent {
readonly value = input.required<Primitives.BooleanValue | null>();
readonly label = input.required<Primitives.StringValue | null>();
protected inputChecked = computed(() => super.resolvePrimitive(this.value()) ?? false);
protected resolvedLabel = computed(() => super.resolvePrimitive(this.label()));
protected inputId = super.getUniqueId('a2ui-checkbox');
protected handleChange(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.checked, this.surfaceId());
}
}

View File

@@ -0,0 +1,96 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-column',
imports: [Renderer],
styles: `
:host {
display: flex;
flex: var(--weight);
}
section {
display: flex;
flex-direction: column;
min-width: 100%;
height: 100%;
box-sizing: border-box;
}
.align-start {
align-items: start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: end;
}
.align-stretch {
align-items: stretch;
}
.distribute-start {
justify-content: start;
}
.distribute-center {
justify-content: center;
}
.distribute-end {
justify-content: end;
}
.distribute-spaceBetween {
justify-content: space-between;
}
.distribute-spaceAround {
justify-content: space-around;
}
.distribute-spaceEvenly {
justify-content: space-evenly;
}
`,
template: `
<section [class]="classes()" [style]="theme.additionalStyles?.Column">
@for (child of component().properties.children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class Column extends DynamicComponent<Types.ColumnNode> {
readonly alignment = input<Types.ResolvedColumn['alignment']>('stretch');
readonly distribution = input<Types.ResolvedColumn['distribution']>('start');
protected readonly classes = computed(() => ({
...this.theme.components.Column,
[`align-${this.alignment()}`]: true,
[`distribute-${this.distribution()}`]: true,
}));
}

View File

@@ -0,0 +1,127 @@
/*
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 { computed, Component, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-datetime-input',
template: `
<section [class]="theme.components.DateTimeInput.container">
<label [for]="inputId" [class]="theme.components.DateTimeInput.label">{{ label() }}</label>
<input
autocomplete="off"
[attr.type]="inputType()"
[id]="inputId"
[class]="theme.components.DateTimeInput.element"
[style]="theme.additionalStyles?.DateTimeInput"
[value]="inputValue()"
(input)="handleInput($event)"
/>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
input {
display: block;
width: 100%;
box-sizing: border-box;
}
`,
})
export class DatetimeInput extends DynamicComponent {
readonly value = input.required<Primitives.StringValue | null>();
readonly enableDate = input.required<boolean>();
readonly enableTime = input.required<boolean>();
protected readonly inputId = super.getUniqueId('a2ui-datetime-input');
protected inputType = computed(() => {
const enableDate = this.enableDate();
const enableTime = this.enableTime();
if (enableDate && enableTime) {
return 'datetime-local';
} else if (enableDate) {
return 'date';
} else if (enableTime) {
return 'time';
}
return 'datetime-local';
});
protected label = computed(() => {
// TODO: this should likely be passed from the model.
const inputType = this.inputType();
if (inputType === 'date') {
return 'Date';
} else if (inputType === 'time') {
return 'Time';
}
return 'Date & Time';
});
protected inputValue = computed(() => {
const inputType = this.inputType();
const parsed = super.resolvePrimitive(this.value()) || '';
const date = parsed ? new Date(parsed) : null;
if (!date || isNaN(date.getTime())) {
return '';
}
const year = this.padNumber(date.getFullYear());
const month = this.padNumber(date.getMonth());
const day = this.padNumber(date.getDate());
const hours = this.padNumber(date.getHours());
const minutes = this.padNumber(date.getMinutes());
// Browsers are picky with what format they allow for the `value` attribute of date/time inputs.
// We need to parse it out of the provided value. Note that we don't use `toISOString`,
// because the resulting value is relative to UTC.
if (inputType === 'date') {
return `${year}-${month}-${day}`;
} else if (inputType === 'time') {
return `${hours}:${minutes}`;
}
return `${year}-${month}-${day}T${hours}:${minutes}`;
});
protected handleInput(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.value, this.surfaceId());
}
private padNumber(value: number) {
return value.toString().padStart(2, '0');
}
}

View File

@@ -0,0 +1,185 @@
/*
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 { inputBinding } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { Catalog } from '../rendering/catalog';
import { Row } from './row';
import { Column } from './column';
import { Text } from './text';
export const DEFAULT_CATALOG: Catalog = {
Row: {
type: () => Row,
bindings: (node) => {
const properties = (node as Types.RowNode).properties;
return [
inputBinding('alignment', () => properties.alignment ?? 'stretch'),
inputBinding('distribution', () => properties.distribution ?? 'start'),
];
},
},
Column: {
type: () => Column,
bindings: (node) => {
const properties = (node as Types.ColumnNode).properties;
return [
inputBinding('alignment', () => properties.alignment ?? 'stretch'),
inputBinding('distribution', () => properties.distribution ?? 'start'),
];
},
},
List: {
type: () => import('./list').then((r) => r.List),
bindings: (node) => {
const properties = (node as Types.ListNode).properties;
return [inputBinding('direction', () => properties.direction ?? 'vertical')];
},
},
Card: () => import('./card').then((r) => r.Card),
Image: {
type: () => import('./image').then((r) => r.Image),
bindings: (node) => {
const properties = (node as Types.ImageNode).properties;
return [
inputBinding('url', () => properties.url),
inputBinding('usageHint', () => properties.usageHint),
];
},
},
Icon: {
type: () => import('./icon').then((r) => r.Icon),
bindings: (node) => {
const properties = (node as Types.IconNode).properties;
return [inputBinding('name', () => properties.name)];
},
},
Video: {
type: () => import('./video').then((r) => r.Video),
bindings: (node) => {
const properties = (node as Types.VideoNode).properties;
return [inputBinding('url', () => properties.url)];
},
},
AudioPlayer: {
type: () => import('./audio').then((r) => r.Audio),
bindings: (node) => {
const properties = (node as Types.AudioPlayerNode).properties;
return [inputBinding('url', () => properties.url)];
},
},
Text: {
type: () => Text,
bindings: (node) => {
const properties = (node as Types.TextNode).properties;
return [
inputBinding('text', () => properties.text),
inputBinding('usageHint', () => properties.usageHint || null),
];
},
},
Button: {
type: () => import('./button').then((r) => r.Button),
bindings: (node) => {
const properties = (node as Types.ButtonNode).properties;
return [inputBinding('action', () => properties.action)];
},
},
Divider: () => import('./divider').then((r) => r.Divider),
MultipleChoice: {
type: () => import('./multiple-choice').then((r) => r.MultipleChoice),
bindings: (node) => {
const properties = (node as Types.MultipleChoiceNode).properties;
return [
inputBinding('options', () => properties.options || []),
inputBinding('value', () => properties.selections),
inputBinding('description', () => 'Select an item'), // TODO: this should be defined in the properties
];
},
},
TextField: {
type: () => import('./text-field').then((r) => r.TextField),
bindings: (node) => {
const properties = (node as Types.TextFieldNode).properties;
return [
inputBinding('text', () => properties.text ?? null),
inputBinding('label', () => properties.label),
inputBinding('inputType', () => properties.type),
];
},
},
DateTimeInput: {
type: () => import('./datetime-input').then((r) => r.DatetimeInput),
bindings: (node) => {
const properties = (node as Types.DateTimeInputNode).properties;
return [
inputBinding('enableDate', () => properties.enableDate),
inputBinding('enableTime', () => properties.enableTime),
inputBinding('value', () => properties.value),
];
},
},
CheckBox: {
type: () => import('./checkbox').then((r) => r.Checkbox),
bindings: (node) => {
const properties = (node as Types.CheckboxNode).properties;
return [
inputBinding('label', () => properties.label),
inputBinding('value', () => properties.value),
];
},
},
Slider: {
type: () => import('./slider').then((r) => r.Slider),
bindings: (node) => {
const properties = (node as Types.SliderNode).properties;
return [
inputBinding('value', () => properties.value),
inputBinding('minValue', () => properties.minValue),
inputBinding('maxValue', () => properties.maxValue),
inputBinding('label', () => ''), // TODO: this should be defined in the properties
];
},
},
Tabs: {
type: () => import('./tabs').then((r) => r.Tabs),
bindings: (node) => {
const properties = (node as Types.TabsNode).properties;
return [inputBinding('tabs', () => properties.tabItems)];
},
},
Modal: {
type: () => import('./modal').then((r) => r.Modal),
bindings: () => [],
},
};

View File

@@ -0,0 +1,37 @@
/*
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 { Component } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: 'a2ui-divider',
template: '<hr [class]="theme.components.Divider" [style]="theme.additionalStyles?.Divider"/>',
styles: `
:host {
display: block;
min-height: 0;
overflow: auto;
}
hr {
height: 1px;
background: #ccc;
border: none;
}
`,
})
export class Divider extends DynamicComponent {}

View File

@@ -0,0 +1,44 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-icon',
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
`,
template: `
@let resolvedName = this.resolvedName();
@if (resolvedName) {
<section [class]="theme.components.Icon" [style]="theme.additionalStyles?.Icon">
<span class="g-icon">{{ resolvedName }}</span>
</section>
}
`,
})
export class Icon extends DynamicComponent {
readonly name = input.required<Primitives.StringValue | null>();
protected readonly resolvedName = computed(() => this.resolvePrimitive(this.name()));
}

View File

@@ -0,0 +1,62 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Primitives, Styles, Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: 'a2ui-image',
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
img {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
}
`,
template: `
@let resolvedUrl = this.resolvedUrl();
@if (resolvedUrl) {
<section [class]="classes()" [style]="theme.additionalStyles?.Image">
<img [src]="resolvedUrl" />
</section>
}
`,
})
export class Image extends DynamicComponent {
readonly url = input.required<Primitives.StringValue | null>();
readonly usageHint = input.required<Types.ResolvedImage['usageHint'] | null>();
protected readonly resolvedUrl = computed(() => this.resolvePrimitive(this.url()));
protected classes = computed(() => {
const usageHint = this.usageHint();
return Styles.merge(
this.theme.components.Image.all,
usageHint ? this.theme.components.Image[usageHint] : {},
);
});
}

View File

@@ -0,0 +1,63 @@
/*
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 { Component, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-list',
imports: [Renderer],
host: {
'[attr.direction]': 'direction()',
},
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
:host([direction='vertical']) section {
display: grid;
}
:host([direction='horizontal']) section {
display: flex;
max-width: 100%;
overflow-x: scroll;
overflow-y: hidden;
scrollbar-width: none;
> ::slotted(*) {
flex: 1 0 fit-content;
max-width: min(80%, 400px);
}
}
`,
template: `
<section [class]="theme.components.List" [style]="theme.additionalStyles?.List">
@for (child of component().properties.children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class List extends DynamicComponent<Types.ListNode> {
readonly direction = input<'vertical' | 'horizontal'>('vertical');
}

View File

@@ -0,0 +1,113 @@
/*
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 { Component, signal, viewChild, ElementRef, effect } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Types } from '@a2ui/lit/0.8';
import { Renderer } from '../rendering';
@Component({
selector: 'a2ui-modal',
imports: [Renderer],
template: `
@if (showDialog()) {
<dialog #dialog [class]="theme.components.Modal.backdrop" (click)="handleDialogClick($event)">
<section [class]="theme.components.Modal.element" [style]="theme.additionalStyles?.Modal">
<div class="controls">
<button (click)="closeDialog()">
<span class="g-icon">close</span>
</button>
</div>
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="component().properties.contentChild"
/>
</section>
</dialog>
} @else {
<section (click)="showDialog.set(true)">
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="component().properties.entryPointChild"
/>
</section>
}
`,
styles: `
dialog {
padding: 0;
border: none;
background: none;
& section {
& .controls {
display: flex;
justify-content: end;
margin-bottom: 4px;
& button {
padding: 0;
background: none;
width: 20px;
height: 20px;
pointer: cursor;
border: none;
cursor: pointer;
}
}
}
}
`,
})
export class Modal extends DynamicComponent<Types.ModalNode> {
protected readonly showDialog = signal(false);
protected readonly dialog = viewChild<ElementRef<HTMLDialogElement>>('dialog');
constructor() {
super();
effect(() => {
const dialog = this.dialog();
if (dialog && !dialog.nativeElement.open) {
dialog.nativeElement.showModal();
}
});
}
protected handleDialogClick(event: MouseEvent) {
if (event.target instanceof HTMLDialogElement) {
this.closeDialog();
}
}
protected closeDialog() {
const dialog = this.dialog();
if (!dialog) {
return;
}
if (!dialog.nativeElement.open) {
dialog.nativeElement.close();
}
this.showDialog.set(false);
}
}

View File

@@ -0,0 +1,77 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-multiple-choice',
template: `
<section [class]="theme.components.MultipleChoice.container">
<label [class]="theme.components.MultipleChoice.label" [for]="selectId">{{
description()
}}</label>
<select
(change)="handleChange($event)"
[id]="selectId"
[value]="selectValue()"
[class]="theme.components.MultipleChoice.element"
[style]="theme.additionalStyles?.MultipleChoice"
>
@for (option of options(); track option.value) {
<option [value]="option.value">{{ resolvePrimitive(option.label) }}</option>
}
</select>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
select {
width: 100%;
box-sizing: border-box;
}
`,
})
export class MultipleChoice extends DynamicComponent {
readonly options = input.required<{ label: Primitives.StringValue; value: string }[]>();
readonly value = input.required<Primitives.StringValue | null>();
readonly description = input.required<string>();
protected readonly selectId = super.getUniqueId('a2ui-multiple-choice');
protected selectValue = computed(() => super.resolvePrimitive(this.value()));
protected handleChange(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLSelectElement) || !event.target.value || !path) {
return;
}
this.processor.setData(
this.component(),
this.processor.resolvePath(path, this.component().dataContextPath),
event.target.value,
);
}
}

View File

@@ -0,0 +1,100 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
import { Types } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-row',
imports: [Renderer],
host: {
'[attr.alignment]': 'alignment()',
'[attr.distribution]': 'distribution()',
},
styles: `
:host {
display: flex;
flex: var(--weight);
}
section {
display: flex;
flex-direction: row;
width: 100%;
min-height: 100%;
box-sizing: border-box;
}
.align-start {
align-items: start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: end;
}
.align-stretch {
align-items: stretch;
}
.distribute-start {
justify-content: start;
}
.distribute-center {
justify-content: center;
}
.distribute-end {
justify-content: end;
}
.distribute-spaceBetween {
justify-content: space-between;
}
.distribute-spaceAround {
justify-content: space-around;
}
.distribute-spaceEvenly {
justify-content: space-evenly;
}
`,
template: `
<section [class]="classes()" [style]="theme.additionalStyles?.Row">
@for (child of component().properties.children; track child) {
<ng-container a2ui-renderer [surfaceId]="surfaceId()!" [component]="child" />
}
</section>
`,
})
export class Row extends DynamicComponent<Types.RowNode> {
readonly alignment = input<Types.ResolvedRow['alignment']>('stretch');
readonly distribution = input<Types.ResolvedRow['distribution']>('start');
protected readonly classes = computed(() => ({
...this.theme.components.Row,
[`align-${this.alignment()}`]: true,
[`distribute-${this.distribution()}`]: true,
}));
}

View File

@@ -0,0 +1,73 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Primitives } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: '[a2ui-slider]',
template: `
<section [class]="theme.components.Slider.container">
<label [class]="theme.components.Slider.label" [for]="inputId">
{{ label() }}
</label>
<input
autocomplete="off"
type="range"
[value]="resolvedValue()"
[min]="minValue()"
[max]="maxValue()"
[id]="inputId"
(input)="handleInput($event)"
[class]="theme.components.Slider.element"
[style]="theme.additionalStyles?.Slider"
/>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
}
input {
display: block;
width: 100%;
box-sizing: border-box;
}
`,
})
export class Slider extends DynamicComponent {
readonly value = input.required<Primitives.NumberValue | null>();
readonly label = input('');
readonly minValue = input.required<number | undefined>();
readonly maxValue = input.required<number | undefined>();
protected readonly inputId = super.getUniqueId('a2ui-slider');
protected resolvedValue = computed(() => super.resolvePrimitive(this.value()) ?? 0);
protected handleInput(event: Event) {
const path = this.value()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.valueAsNumber, this.surfaceId());
}
}

View File

@@ -0,0 +1,99 @@
/*
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 { Component, computed, input } from '@angular/core';
import { Types } from '@a2ui/lit/0.8';
import { Renderer } from '../rendering/renderer';
@Component({
selector: 'a2ui-surface',
imports: [Renderer],
template: `
@let surfaceId = this.surfaceId();
@let surface = this.surface();
@if (surfaceId && surface) {
<ng-container a2ui-renderer [surfaceId]="surfaceId" [component]="surface.componentTree!" />
}
`,
styles: `
:host {
display: flex;
min-height: 0;
max-height: 100%;
flex-direction: column;
gap: 16px;
}
`,
host: {
'[style]': 'styles()',
},
})
export class Surface {
readonly surfaceId = input.required<Types.SurfaceID | null>();
readonly surface = input.required<Types.Surface | null>();
protected readonly styles = computed(() => {
const surface = this.surface();
const styles: Record<string, string> = {};
if (surface?.styles) {
for (const [key, value] of Object.entries(surface.styles)) {
switch (key) {
// Here we generate a palette from the singular primary color received
// from the surface data. We will want the values to range from
// 0 <= x <= 100, where 0 = back, 100 = white, and 50 = the primary
// color itself. As such we use a color-mix to create the intermediate
// values.
//
// Note: since we use half the range for black to the primary color,
// and half the range for primary color to white the mixed values have
// to go up double the amount, i.e., a range from black to primary
// color needs to fit in 0 -> 50 rather than 0 -> 100.
case 'primaryColor': {
styles['--p-100'] = '#ffffff';
styles['--p-99'] = `color-mix(in srgb, ${value} 2%, white 98%)`;
styles['--p-98'] = `color-mix(in srgb, ${value} 4%, white 96%)`;
styles['--p-95'] = `color-mix(in srgb, ${value} 10%, white 90%)`;
styles['--p-90'] = `color-mix(in srgb, ${value} 20%, white 80%)`;
styles['--p-80'] = `color-mix(in srgb, ${value} 40%, white 60%)`;
styles['--p-70'] = `color-mix(in srgb, ${value} 60%, white 40%)`;
styles['--p-60'] = `color-mix(in srgb, ${value} 80%, white 20%)`;
styles['--p-50'] = value;
styles['--p-40'] = `color-mix(in srgb, ${value} 80%, black 20%)`;
styles['--p-35'] = `color-mix(in srgb, ${value} 70%, black 30%)`;
styles['--p-30'] = `color-mix(in srgb, ${value} 60%, black 40%)`;
styles['--p-25'] = `color-mix(in srgb, ${value} 50%, black 50%)`;
styles['--p-20'] = `color-mix(in srgb, ${value} 40%, black 60%)`;
styles['--p-15'] = `color-mix(in srgb, ${value} 30%, black 70%)`;
styles['--p-10'] = `color-mix(in srgb, ${value} 20%, black 80%)`;
styles['--p-5'] = `color-mix(in srgb, ${value} 10%, black 90%)`;
styles['--0'] = '#00000';
break;
}
case 'font': {
styles['--font-family'] = value;
styles['--font-family-flex'] = value;
break;
}
}
}
}
return styles;
});
}

View File

@@ -0,0 +1,72 @@
/*
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 { Component, computed, input, signal } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Renderer } from '../rendering/renderer';
import { Styles, Types } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-tabs',
imports: [Renderer],
template: `
@let tabs = this.tabs();
@let selectedIndex = this.selectedIndex();
<section [class]="theme.components.Tabs.container" [style]="theme.additionalStyles?.Tabs">
<div [class]="theme.components.Tabs.element">
@for (tab of tabs; track tab) {
<button
(click)="this.selectedIndex.set($index)"
[disabled]="selectedIndex === $index"
[class]="buttonClasses()[selectedIndex]"
>
{{ resolvePrimitive(tab.title) }}
</button>
}
</div>
<ng-container
a2ui-renderer
[surfaceId]="surfaceId()!"
[component]="tabs[selectedIndex].child"
/>
</section>
`,
styles: `
:host {
display: block;
flex: var(--weight);
}
`,
})
export class Tabs extends DynamicComponent {
protected selectedIndex = signal(0);
readonly tabs = input.required<Types.ResolvedTabItem[]>();
protected readonly buttonClasses = computed(() => {
const selectedIndex = this.selectedIndex();
return this.tabs().map((_, index) => {
return index === selectedIndex
? Styles.merge(
this.theme.components.Tabs.controls.all,
this.theme.components.Tabs.controls.selected,
)
: this.theme.components.Tabs.controls.all;
});
});
}

View File

@@ -0,0 +1,86 @@
/*
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 { computed, Component, input } from '@angular/core';
import { Primitives, Types } from '@a2ui/lit/0.8';
import { DynamicComponent } from '../rendering/dynamic-component';
@Component({
selector: 'a2ui-text-field',
styles: `
:host {
display: flex;
flex: var(--weight);
}
section,
input,
label {
box-sizing: border-box;
}
input {
display: block;
width: 100%;
}
label {
display: block;
margin-bottom: 4px;
}
`,
template: `
@let resolvedLabel = this.resolvedLabel();
<section [class]="theme.components.TextField.container">
@if (resolvedLabel) {
<label [for]="inputId" [class]="theme.components.TextField.label">{{
resolvedLabel
}}</label>
}
<input
autocomplete="off"
[class]="theme.components.TextField.element"
[style]="theme.additionalStyles?.TextField"
(input)="handleInput($event)"
[id]="inputId"
[value]="inputValue()"
placeholder="Please enter a value"
[type]="inputType() === 'number' ? 'number' : 'text'"
/>
</section>
`,
})
export class TextField extends DynamicComponent {
readonly text = input.required<Primitives.StringValue | null>();
readonly label = input.required<Primitives.StringValue | null>();
readonly inputType = input.required<Types.ResolvedTextField['type'] | null>();
protected inputValue = computed(() => super.resolvePrimitive(this.text()) || '');
protected resolvedLabel = computed(() => super.resolvePrimitive(this.label()));
protected inputId = super.getUniqueId('a2ui-input');
protected handleInput(event: Event) {
const path = this.text()?.path;
if (!(event.target instanceof HTMLInputElement) || !path) {
return;
}
this.processor.setData(this.component(), path, event.target.value, this.surfaceId());
}
}

View File

@@ -0,0 +1,137 @@
/*
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 { Component, computed, inject, input, ViewEncapsulation } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives, Styles, Types } from '@a2ui/lit/0.8';
import { MarkdownRenderer } from '../data/markdown';
interface HintedStyles {
h1: Record<string, string>;
h2: Record<string, string>;
h3: Record<string, string>;
h4: Record<string, string>;
h5: Record<string, string>;
body: Record<string, string>;
caption: Record<string, string>;
}
@Component({
selector: 'a2ui-text',
template: `
<section
[class]="classes()"
[style]="additionalStyles()"
[innerHTML]="resolvedText()"
></section>
`,
encapsulation: ViewEncapsulation.None,
styles: `
a2ui-text {
display: block;
flex: var(--weight);
}
a2ui-text h1,
a2ui-text h2,
a2ui-text h3,
a2ui-text h4,
a2ui-text h5 {
line-height: inherit;
font: inherit;
}
`,
})
export class Text extends DynamicComponent {
private markdownRenderer = inject(MarkdownRenderer);
readonly text = input.required<Primitives.StringValue | null>();
readonly usageHint = input.required<Types.ResolvedText['usageHint'] | null>();
protected resolvedText = computed(() => {
const usageHint = this.usageHint();
let value = super.resolvePrimitive(this.text());
if (value == null) {
return '(empty)';
}
switch (usageHint) {
case 'h1':
value = `# ${value}`;
break;
case 'h2':
value = `## ${value}`;
break;
case 'h3':
value = `### ${value}`;
break;
case 'h4':
value = `#### ${value}`;
break;
case 'h5':
value = `##### ${value}`;
break;
case 'caption':
value = `*${value}*`;
break;
default:
value = String(value);
break;
}
return this.markdownRenderer.render(
value,
Styles.appendToAll(this.theme.markdown, ['ol', 'ul', 'li'], {}),
);
});
protected classes = computed(() => {
const usageHint = this.usageHint();
return Styles.merge(
this.theme.components.Text.all,
usageHint ? this.theme.components.Text[usageHint] : {},
);
});
protected additionalStyles = computed(() => {
const usageHint = this.usageHint();
const styles = this.theme.additionalStyles?.Text;
if (!styles) {
return null;
}
let additionalStyles: Record<string, string> = {};
if (this.areHintedStyles(styles)) {
additionalStyles = styles[usageHint ?? 'body'];
} else {
additionalStyles = styles;
}
return additionalStyles;
});
private areHintedStyles(styles: unknown): styles is HintedStyles {
if (typeof styles !== 'object' || !styles || Array.isArray(styles)) {
return false;
}
const expected = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'caption', 'body'];
return expected.every((v) => v in styles);
}
}

View File

@@ -0,0 +1,50 @@
/*
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 { Component, computed, input } from '@angular/core';
import { DynamicComponent } from '../rendering/dynamic-component';
import { Primitives } from '@a2ui/lit/0.8';
@Component({
selector: 'a2ui-video',
template: `
@let resolvedUrl = this.resolvedUrl();
@if (resolvedUrl) {
<section [class]="theme.components.Video" [style]="theme.additionalStyles?.Video">
<video controls [src]="resolvedUrl"></video>
</section>
}
`,
styles: `
:host {
display: block;
flex: var(--weight);
min-height: 0;
overflow: auto;
}
video {
display: block;
width: 100%;
box-sizing: border-box;
}
`,
})
export class Video extends DynamicComponent {
readonly url = input.required<Primitives.StringValue | null>();
protected readonly resolvedUrl = computed(() => this.resolvePrimitive(this.url()));
}

View File

@@ -0,0 +1,25 @@
/*
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 { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { Catalog, Theme } from './rendering';
export function provideA2UI(config: { catalog: Catalog; theme: Theme }): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: Catalog, useValue: config.catalog },
{ provide: Theme, useValue: config.theme },
]);
}

View File

@@ -0,0 +1,18 @@
/*
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.
*/
export * from './processor';
export * from './types';

View File

@@ -0,0 +1,114 @@
/*
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 { inject, Injectable, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import MarkdownIt from 'markdown-it';
@Injectable({ providedIn: 'root' })
export class MarkdownRenderer {
private originalClassMap = new Map<string, any>();
private sanitizer = inject(DomSanitizer);
private markdownIt = MarkdownIt({
highlight: (str, lang) => {
if (lang === 'html') {
const iframe = document.createElement('iframe');
iframe.classList.add('html-view');
iframe.srcdoc = str;
iframe.sandbox = '';
return iframe.innerHTML;
}
return str;
},
});
render(value: string, tagClassMap?: Record<string, string[]>) {
if (tagClassMap) {
this.applyTagClassMap(tagClassMap);
}
const htmlString = this.markdownIt.render(value);
this.unapplyTagClassMap();
return this.sanitizer.sanitize(SecurityContext.HTML, htmlString);
}
private applyTagClassMap(tagClassMap: Record<string, string[]>) {
Object.entries(tagClassMap).forEach(([tag, classes]) => {
let tokenName;
switch (tag) {
case 'p':
tokenName = 'paragraph';
break;
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
tokenName = 'heading';
break;
case 'ul':
tokenName = 'bullet_list';
break;
case 'ol':
tokenName = 'ordered_list';
break;
case 'li':
tokenName = 'list_item';
break;
case 'a':
tokenName = 'link';
break;
case 'strong':
tokenName = 'strong';
break;
case 'em':
tokenName = 'em';
break;
}
if (!tokenName) {
return;
}
const key = `${tokenName}_open`;
const original = this.markdownIt.renderer.rules[key];
this.originalClassMap.set(key, original);
this.markdownIt.renderer.rules[key] = (tokens, idx, options, env, self) => {
const token = tokens[idx];
for (const clazz of classes) {
token.attrJoin('class', clazz);
}
if (original) {
return original.call(this, tokens, idx, options, env, self);
} else {
return self.renderToken(tokens, idx, options);
}
};
});
}
private unapplyTagClassMap() {
for (const [key, original] of this.originalClassMap) {
this.markdownIt.renderer.rules[key] = original;
}
this.originalClassMap.clear();
}
}

View File

@@ -0,0 +1,47 @@
/*
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 { Data, Types } from '@a2ui/lit/0.8';
import { Injectable } from '@angular/core';
import { firstValueFrom, Subject } from 'rxjs';
export interface DispatchedEvent {
message: Types.A2UIClientEventMessage;
completion: Subject<Types.ServerToClientMessage[]>;
}
@Injectable({ providedIn: 'root' })
export class MessageProcessor extends Data.A2uiMessageProcessor {
readonly events = new Subject<DispatchedEvent>();
override setData(
node: Types.AnyComponentNode,
relativePath: string,
value: Types.DataValue,
surfaceId?: Types.SurfaceID | null,
) {
// Override setData to convert from optional inputs (which can be null)
// to undefined so that this correctly falls back to the default value for
// surfaceId.
return super.setData(node, relativePath, value, surfaceId ?? undefined);
}
dispatch(message: Types.A2UIClientEventMessage): Promise<Types.ServerToClientMessage[]> {
const completion = new Subject<Types.ServerToClientMessage[]>();
this.events.next({ message, completion });
return firstValueFrom(completion);
}
}

View File

@@ -0,0 +1,29 @@
/*
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 { Types } from '@a2ui/lit/0.8';
export interface A2TextPayload {
kind: 'text';
text: string;
}
export interface A2DataPayload {
kind: 'data';
data: Types.ServerToClientMessage;
}
export type A2AServerPayload = Array<A2DataPayload | A2TextPayload> | { error: string };

View File

@@ -0,0 +1,36 @@
/*
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 { Binding, InjectionToken, Type } from '@angular/core';
import { DynamicComponent } from './dynamic-component';
import { Types } from '@a2ui/lit/0.8';
export type CatalogLoader = () =>
| Promise<Type<DynamicComponent<any>>>
| Type<DynamicComponent<any>>;
export type CatalogEntry<T extends Types.AnyComponentNode> =
| CatalogLoader
| {
type: CatalogLoader;
bindings: (data: T) => Binding[];
};
export interface Catalog {
[key: string]: CatalogEntry<Types.AnyComponentNode>;
}
export const Catalog = new InjectionToken<Catalog>('Catalog');

View File

@@ -0,0 +1,100 @@
/*
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 { Types, Primitives } from '@a2ui/lit/0.8';
import { Directive, inject, input } from '@angular/core';
import { MessageProcessor } from '../data';
import { Theme } from './theming';
let idCounter = 0;
@Directive({
host: {
'[style.--weight]': 'weight()',
},
})
export abstract class DynamicComponent<T extends Types.AnyComponentNode = Types.AnyComponentNode> {
protected readonly processor = inject(MessageProcessor);
protected readonly theme = inject(Theme);
readonly surfaceId = input.required<Types.SurfaceID | null>();
readonly component = input.required<T>();
readonly weight = input.required<string | number>();
protected sendAction(action: Types.Action): Promise<Types.ServerToClientMessage[]> {
const component = this.component();
const surfaceId = this.surfaceId() ?? undefined;
const context: Record<string, unknown> = {};
if (action.context) {
for (const item of action.context) {
if (item.value.literalBoolean) {
context[item.key] = item.value.literalBoolean;
} else if (item.value.literalNumber) {
context[item.key] = item.value.literalNumber;
} else if (item.value.literalString) {
context[item.key] = item.value.literalString;
} else if (item.value.path) {
const path = this.processor.resolvePath(item.value.path, component.dataContextPath);
const value = this.processor.getData(component, path, surfaceId);
context[item.key] = value;
}
}
}
const message: Types.A2UIClientEventMessage = {
userAction: {
name: action.name,
sourceComponentId: component.id,
surfaceId: surfaceId!,
timestamp: new Date().toISOString(),
context,
},
};
return this.processor.dispatch(message);
}
protected resolvePrimitive(value: Primitives.StringValue | null): string | null;
protected resolvePrimitive(value: Primitives.BooleanValue | null): boolean | null;
protected resolvePrimitive(value: Primitives.NumberValue | null): number | null;
protected resolvePrimitive(
value: Primitives.StringValue | Primitives.BooleanValue | Primitives.NumberValue | null,
) {
const component = this.component();
const surfaceId = this.surfaceId();
if (!value || typeof value !== 'object') {
return null;
} else if (value.literal != null) {
return value.literal;
} else if (value.path) {
return this.processor.getData(component, value.path, surfaceId ?? undefined);
} else if ('literalString' in value) {
return value.literalString;
} else if ('literalNumber' in value) {
return value.literalNumber;
} else if ('literalBoolean' in value) {
return value.literalBoolean;
}
return null;
}
protected getUniqueId(prefix: string) {
return `${prefix}-${idCounter++}`;
}
}

View File

@@ -0,0 +1,20 @@
/*
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.
*/
export * from './catalog';
export * from './dynamic-component';
export * from './renderer';
export * from './theming';

View File

@@ -0,0 +1,109 @@
/*
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 {
Binding,
ComponentRef,
Directive,
DOCUMENT,
effect,
inject,
input,
inputBinding,
OnDestroy,
PLATFORM_ID,
Type,
untracked,
ViewContainerRef,
} from '@angular/core';
import { Types, Styles } from '@a2ui/lit/0.8';
import { Catalog } from './catalog';
import { isPlatformBrowser } from '@angular/common';
@Directive({
selector: 'ng-container[a2ui-renderer]',
})
export class Renderer implements OnDestroy {
private viewContainerRef = inject(ViewContainerRef);
private catalog = inject(Catalog);
private static hasInsertedStyles = false;
private currentRef: ComponentRef<unknown> | null = null;
private isDestroyed = false;
readonly surfaceId = input.required<Types.SurfaceID>();
readonly component = input.required<Types.AnyComponentNode>();
constructor() {
effect(() => {
const surfaceId = this.surfaceId();
const component = this.component();
untracked(() => this.render(surfaceId, component));
});
const platformId = inject(PLATFORM_ID);
const document = inject(DOCUMENT);
if (!Renderer.hasInsertedStyles && isPlatformBrowser(platformId)) {
const styles = document.createElement('style');
styles.textContent = Styles.structuralStyles;
document.head.appendChild(styles);
Renderer.hasInsertedStyles = true;
}
}
ngOnDestroy(): void {
this.isDestroyed = true;
this.clear();
}
private async render(surfaceId: Types.SurfaceID, component: Types.AnyComponentNode) {
const config = this.catalog[component.type];
let newComponent: Type<unknown> | null = null;
let componentBindings: Binding[] | null = null;
if (typeof config === 'function') {
newComponent = await config();
} else if (typeof config === 'object') {
newComponent = await config.type();
componentBindings = config.bindings(component as any);
}
this.clear();
if (newComponent && !this.isDestroyed) {
const bindings = [
inputBinding('surfaceId', () => surfaceId),
inputBinding('component', () => component),
inputBinding('weight', () => component.weight ?? 'initial'),
];
if (componentBindings) {
bindings.push(...componentBindings);
}
this.currentRef = this.viewContainerRef.createComponent(newComponent, {
bindings,
injector: this.viewContainerRef.injector,
});
}
}
private clear() {
this.currentRef?.destroy();
this.currentRef = null;
}
}

View File

@@ -0,0 +1,22 @@
/*
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 { Types } from '@a2ui/lit/0.8';
import { InjectionToken } from '@angular/core';
export const Theme = new InjectionToken<Theme>('Theme');
export type Theme = Types.Theme;

View File

@@ -0,0 +1,21 @@
/*
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.
*/
export * from './lib/rendering/index';
export * from './lib/data/index';
export * from './lib/config';
export * from './lib/catalog/default';
export { Surface } from './lib/catalog/surface';