diff --git a/renderers/lit/CHANGELOG.md b/renderers/lit/CHANGELOG.md index 7e7d5b6118..1df4996f88 100644 --- a/renderers/lit/CHANGELOG.md +++ b/renderers/lit/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- (v0_9) Add portable Web Components for Select, Switch, Dialog, and Toast. + + ## 0.10.2 - (v0_9) Normalize Safari placeholder text color for `DateTimeInput` by updating CSS selectors for `.a2ui-date-time-input`. diff --git a/renderers/lit/src/v0_9/catalogs/basic/components/Button.ts b/renderers/lit/src/v0_9/catalogs/basic/components/Button.ts index cb8e145e5b..9aff770adc 100644 --- a/renderers/lit/src/v0_9/catalogs/basic/components/Button.ts +++ b/renderers/lit/src/v0_9/catalogs/basic/components/Button.ts @@ -118,7 +118,12 @@ export class A2uiBasicButtonElement extends BasicCatalogA2uiLitElement { + @query('dialog') accessor dialogElement!: HTMLDialogElement; + static override styles = css` + :host { + display: block; + } + dialog { + border: var(--a2ui-border-width, 1px) solid var(--a2ui-color-border, #ccc); + border-radius: var(--a2ui-border-radius, 12px); + padding: var(--a2ui-spacing-l, 24px); + background-color: var(--a2ui-color-surface, #fff); + color: var(--a2ui-color-on-surface, #333); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + max-width: 500px; + width: 90%; + } + dialog::backdrop { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + } + .dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--a2ui-spacing-m, 16px); + } + .dialog-title { + font-size: var(--a2ui-font-size-l, 1.25rem); + font-weight: 600; + margin: 0; + } + .close-btn { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + color: var(--a2ui-color-on-surface, #666); + } + `; + + protected createController() { + return new A2uiController(this, DialogApi); + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + const isOpen = Boolean(this.controller.props?.open); + if (this.dialogElement) { + if (isOpen && !this.dialogElement.open) { + this.dialogElement.showModal(); + } else if (!isOpen && this.dialogElement.open) { + this.dialogElement.close(); + } + } + } + + private handleNativeClose() { + this.closeDialog(); + } + + private closeDialog() { + this.dispatchEvent( + new CustomEvent('a2uiclose', { + bubbles: true, + composed: true, + }), + ); + } + + override render() { + const props = this.controller.props; + if (!props) return nothing; + + return html` + +
+ ${props.title ? html`

${props.title}

` : nothing} + +
+
+ ${props.child ? html`${this.renderNode(props.child)}` : nothing} +
+
+ `; + } +} + +export const A2uiDialog = { + ...DialogApi, + tagName: 'a2ui-dialog', +}; diff --git a/renderers/lit/src/v0_9/catalogs/basic/components/Select.ts b/renderers/lit/src/v0_9/catalogs/basic/components/Select.ts new file mode 100644 index 0000000000..49c1299c97 --- /dev/null +++ b/renderers/lit/src/v0_9/catalogs/basic/components/Select.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2026 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 {html, nothing, css} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import {SelectApi} from '@a2ui/web_core/v0_9/basic_catalog'; +import {BasicCatalogA2uiLitElement} from '../basic-catalog-a2ui-lit-element.js'; +import {A2uiController} from '../../../a2ui-controller.js'; + +@customElement('a2ui-select') +export class A2uiSelectElement extends BasicCatalogA2uiLitElement { + static override styles = css` + :host { + display: block; + margin: var(--a2ui-select-margin, var(--a2ui-spacing-m, 8px)); + font-family: inherit; + } + .a2ui-select-container { + display: flex; + flex-direction: column; + gap: var(--a2ui-spacing-xs, 4px); + } + label { + font-size: var(--a2ui-font-size-s, 0.875rem); + color: var(--a2ui-color-on-surface, #333); + font-weight: 500; + } + select { + padding: var(--a2ui-select-padding, 8px 12px); + border: var(--a2ui-border-width, 1px) solid var(--a2ui-color-border, #ccc); + border-radius: var(--a2ui-border-radius, 6px); + background-color: var(--a2ui-color-surface, #fff); + color: var(--a2ui-color-on-surface, #333); + font-size: var(--a2ui-font-size-m, 1rem); + cursor: pointer; + } + select:focus { + outline: 2px solid var(--a2ui-color-primary, #17e); + } + `; + + protected createController() { + return new A2uiController(this, SelectApi); + } + + private onChange(e: Event) { + const target = e.target as HTMLSelectElement; + const props = this.controller.props; + if (props) { + props.setValue?.(target.value); + } + // Dispatch custom event for binding update + this.dispatchEvent( + new CustomEvent('a2uichange', { + detail: {value: target.value}, + bubbles: true, + composed: true, + }), + ); + } + + override render() { + const props = this.controller.props; + if (!props) return nothing; + + const options = props.options || []; + + return html` +
+ ${props.label ? html`` : nothing} + +
+ `; + } +} + +export const A2uiSelect = { + ...SelectApi, + tagName: 'a2ui-select', +}; diff --git a/renderers/lit/src/v0_9/catalogs/basic/components/Switch.ts b/renderers/lit/src/v0_9/catalogs/basic/components/Switch.ts new file mode 100644 index 0000000000..1ba5112bf7 --- /dev/null +++ b/renderers/lit/src/v0_9/catalogs/basic/components/Switch.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2026 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 {html, nothing, css} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import {SwitchApi} from '@a2ui/web_core/v0_9/basic_catalog'; +import {BasicCatalogA2uiLitElement} from '../basic-catalog-a2ui-lit-element.js'; +import {A2uiController} from '../../../a2ui-controller.js'; + +@customElement('a2ui-switch') +export class A2uiSwitchElement extends BasicCatalogA2uiLitElement { + static override styles = css` + :host { + display: inline-block; + margin: var(--a2ui-switch-margin, var(--a2ui-spacing-m, 8px)); + font-family: inherit; + } + .a2ui-switch-wrapper { + display: inline-flex; + align-items: center; + gap: var(--a2ui-spacing-s, 8px); + cursor: pointer; + } + .switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + } + .switch input { + opacity: 0; + width: 0; + height: 0; + } + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--a2ui-switch-off-bg, #ccc); + transition: 0.3s; + border-radius: 24px; + } + .slider:before { + position: absolute; + content: ''; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; + } + input:checked + .slider { + background-color: var(--a2ui-color-primary, #17e); + } + input:checked + .slider:before { + transform: translateX(20px); + } + .label-text { + font-size: var(--a2ui-font-size-m, 1rem); + color: var(--a2ui-color-on-surface, #333); + } + `; + + protected createController() { + return new A2uiController(this, SwitchApi); + } + + private onChange(e: Event) { + const target = e.target as HTMLInputElement; + const props = this.controller.props; + if (props) { + props.setValue?.(target.checked); + } + this.dispatchEvent( + new CustomEvent('a2uichange', { + detail: {value: target.checked}, + bubbles: true, + composed: true, + }), + ); + } + + override render() { + const props = this.controller.props; + if (!props) return nothing; + + return html` + + `; + } +} + +export const A2uiSwitch = { + ...SwitchApi, + tagName: 'a2ui-switch', +}; diff --git a/renderers/lit/src/v0_9/catalogs/basic/components/Toast.ts b/renderers/lit/src/v0_9/catalogs/basic/components/Toast.ts new file mode 100644 index 0000000000..bdd5466463 --- /dev/null +++ b/renderers/lit/src/v0_9/catalogs/basic/components/Toast.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2026 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 {html, nothing, css} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import {ToastApi} from '@a2ui/web_core/v0_9/basic_catalog'; +import {BasicCatalogA2uiLitElement} from '../basic-catalog-a2ui-lit-element.js'; +import {A2uiController} from '../../../a2ui-controller.js'; + +@customElement('a2ui-toast') +export class A2uiToastElement extends BasicCatalogA2uiLitElement { + static override styles = css` + :host { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 10000; + font-family: inherit; + } + .toast { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + color: #fff; + font-size: 0.875rem; + min-width: 250px; + } + .toast-info { + background-color: #2196f3; + } + .toast-success { + background-color: #4caf50; + } + .toast-warning { + background-color: #ff9800; + } + .toast-error { + background-color: #f44336; + } + `; + + protected createController() { + return new A2uiController(this, ToastApi); + } + + override render() { + const props = this.controller.props; + if (!props || !props.message) return nothing; + + const variant = props.variant || 'info'; + + return html` +
+ ${props.message} +
+ `; + } +} + +export const A2uiToast = { + ...ToastApi, + tagName: 'a2ui-toast', +}; diff --git a/renderers/lit/src/v0_9/catalogs/basic/index.ts b/renderers/lit/src/v0_9/catalogs/basic/index.ts index 00bbb1e5d9..3984cd7449 100644 --- a/renderers/lit/src/v0_9/catalogs/basic/index.ts +++ b/renderers/lit/src/v0_9/catalogs/basic/index.ts @@ -36,14 +36,18 @@ import {A2uiDateTimeInput} from './components/DateTimeInput.js'; import {A2uiChoicePicker} from './components/ChoicePicker.js'; import {A2uiTabs} from './components/Tabs.js'; import {A2uiModal} from './components/Modal.js'; +import {A2uiSelect} from './components/Select.js'; +import {A2uiSwitch} from './components/Switch.js'; +import {A2uiDialog} from './components/Dialog.js'; +import {A2uiToast} from './components/Toast.js'; /** * The basic catalog for A2UI components in Lit. * * This catalog includes a wide range of components such as list, image, icon, * video, audio player, card, divider, checkbox, slider, date-time input, choice - * picker, tabs, and modal. It also includes the basic functions from package - * @a2ui/web_core. + * picker, tabs, modal, select, switch, dialog, and toast. It also includes the + * basic functions from package @a2ui/web_core. */ export const basicCatalog = new Catalog( 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json', @@ -66,6 +70,13 @@ export const basicCatalog = new Catalog( A2uiChoicePicker, A2uiTabs, A2uiModal, + A2uiSelect, + A2uiSwitch, + A2uiDialog, + A2uiToast, ], BASIC_FUNCTIONS, ); + +export {A2uiSelect, A2uiSwitch, A2uiDialog, A2uiToast}; + diff --git a/renderers/lit/src/v0_9/tests/components/Dialog.test.ts b/renderers/lit/src/v0_9/tests/components/Dialog.test.ts new file mode 100644 index 0000000000..be967f74bd --- /dev/null +++ b/renderers/lit/src/v0_9/tests/components/Dialog.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright 2026 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 {setupTestDom, teardownTestDom, asyncUpdate} from '../dom-setup.js'; +import assert from 'node:assert'; +import {describe, it, beforeEach, afterEach, after, before} from 'node:test'; +import { + ComponentContext, + MessageProcessor, + Catalog, + ComponentApi, + SurfaceModel, +} from '@a2ui/web_core/v0_9'; +import type {A2uiDialogElement} from '../../catalogs/basic/components/Dialog.js'; + +describe('Dialog Component', () => { + let basicCatalog: Catalog; + + before(async () => { + setupTestDom(); + basicCatalog = (await import('../../catalogs/basic/index.js')).basicCatalog; + await import('../../catalogs/basic/components/Dialog.js'); + await import('../../catalogs/basic/components/Text.js'); + }); + + after(teardownTestDom); + + let processor: MessageProcessor; + let surface: SurfaceModel; + let element: A2uiDialogElement | null = null; + + beforeEach(() => { + processor = new MessageProcessor([basicCatalog]); + processor.processMessages([ + { + version: 'v0.9', + createSurface: { + surfaceId: 'test-surface', + catalogId: basicCatalog.id, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'test-surface', + components: [ + { + id: 'dialog_closed', + component: 'Dialog', + title: 'Closed Dialog', + child: 'txt1', + // open defaults to false + }, + { + id: 'dialog_open', + component: 'Dialog', + title: 'Open Dialog', + child: 'txt1', + open: true, + }, + { + id: 'txt1', + component: 'Text', + text: 'Dialog content', + }, + ], + }, + }, + ]); + surface = processor.model.getSurface('test-surface')!; + }); + + afterEach(() => { + if (element) { + element.remove(); + element = null; + } + }); + + it('should remain closed when open prop is false or undefined', async () => { + const el = document.createElement('a2ui-dialog') as A2uiDialogElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'dialog_closed'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const dialog = el.shadowRoot?.querySelector('dialog'); + assert.ok(dialog); + assert.strictEqual(dialog.open, false); + }); + + it('should open using showModal when open prop is true', async () => { + const el = document.createElement('a2ui-dialog') as A2uiDialogElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'dialog_open'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const dialog = el.shadowRoot?.querySelector('dialog'); + assert.ok(dialog); + // In JSDOM setup, dialog.showModal() might set dialog.open to true. + assert.strictEqual(dialog.open, true); + + const title = el.shadowRoot?.querySelector('.dialog-title'); + assert.ok(title); + assert.strictEqual(title.textContent?.trim(), 'Open Dialog'); + }); + + it('should dispatch a2uiclose event when close button is clicked', async () => { + const el = document.createElement('a2ui-dialog') as A2uiDialogElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'dialog_open'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const closeBtn = el.shadowRoot?.querySelector('.close-btn') as HTMLButtonElement; + assert.ok(closeBtn); + + let closeDispatched = false; + el.addEventListener('a2uiclose', () => { + closeDispatched = true; + }); + + closeBtn.click(); + assert.strictEqual(closeDispatched, true); + }); + + it('should dispatch a2uiclose event when native dialog close event occurs', async () => { + const el = document.createElement('a2ui-dialog') as A2uiDialogElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'dialog_open'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const dialog = el.shadowRoot?.querySelector('dialog'); + assert.ok(dialog); + + let closeDispatched = false; + el.addEventListener('a2uiclose', () => { + closeDispatched = true; + }); + + // Dispatch native close event on the dialog element + dialog.dispatchEvent(new Event('close')); + assert.strictEqual(closeDispatched, true); + }); +}); diff --git a/renderers/lit/src/v0_9/tests/components/Select.test.ts b/renderers/lit/src/v0_9/tests/components/Select.test.ts new file mode 100644 index 0000000000..9762edeb25 --- /dev/null +++ b/renderers/lit/src/v0_9/tests/components/Select.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2026 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 {setupTestDom, teardownTestDom, asyncUpdate} from '../dom-setup.js'; +import assert from 'node:assert'; +import {describe, it, beforeEach, afterEach, after, before} from 'node:test'; +import { + ComponentContext, + MessageProcessor, + Catalog, + ComponentApi, + SurfaceModel, +} from '@a2ui/web_core/v0_9'; +import type {A2uiSelectElement} from '../../catalogs/basic/components/Select.js'; + +describe('Select Component', () => { + let basicCatalog: Catalog; + + before(async () => { + setupTestDom(); + basicCatalog = (await import('../../catalogs/basic/index.js')).basicCatalog; + await import('../../catalogs/basic/components/Select.js'); + }); + + after(teardownTestDom); + + let processor: MessageProcessor; + let surface: SurfaceModel; + let element: A2uiSelectElement | null = null; + + beforeEach(() => { + processor = new MessageProcessor([basicCatalog]); + processor.processMessages([ + { + version: 'v0.9', + createSurface: { + surfaceId: 'test-surface', + catalogId: basicCatalog.id, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'test-surface', + components: [ + { + id: 'select_simple', + component: 'Select', + label: 'Choose Option', + options: [ + {label: 'One', value: '1'}, + {label: 'Two', value: '2'}, + ], + value: {path: '/select/value'}, + }, + { + id: 'select_empty', + component: 'Select', + label: 'Choose Initial Empty', + options: [ + {label: 'One', value: '1'}, + {label: 'Two', value: '2'}, + ], + value: {path: '/select/empty_value'}, + }, + ], + }, + }, + ]); + surface = processor.model.getSurface('test-surface')!; + surface.dataModel.set('/select/value', '1'); + // /select/empty_value remains undefined + }); + + afterEach(() => { + if (element) { + element.remove(); + element = null; + } + }); + + it('should render options and label correctly', async () => { + const el = document.createElement('a2ui-select') as A2uiSelectElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'select_simple'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const label = el.shadowRoot?.querySelector('label'); + assert.ok(label); + assert.strictEqual(label.textContent?.trim(), 'Choose Option'); + + const select = el.shadowRoot?.querySelector('select'); + assert.ok(select); + assert.strictEqual(select.value, '1'); + + const options = el.shadowRoot?.querySelectorAll('option'); + assert.ok(options); + assert.strictEqual(options.length, 2); + assert.strictEqual(options[0].textContent?.trim(), 'One'); + assert.strictEqual(options[0].value, '1'); + assert.strictEqual(options[1].textContent?.trim(), 'Two'); + assert.strictEqual(options[1].value, '2'); + }); + + it('should update the data model value on select change', async () => { + const el = document.createElement('a2ui-select') as A2uiSelectElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'select_simple'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const select = el.shadowRoot?.querySelector('select'); + assert.ok(select); + + select.value = '2'; + select.dispatchEvent(new Event('change')); + await asyncUpdate(el, () => {}); + + assert.strictEqual(surface.dataModel.get('/select/value'), '2'); + }); + + it('should update the data model value on select change even if initial value was empty', async () => { + const el = document.createElement('a2ui-select') as A2uiSelectElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'select_empty'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const select = el.shadowRoot?.querySelector('select'); + assert.ok(select); + assert.strictEqual(select.value, '1'); // JSDOM selects first option by default since no empty option exists + + select.value = '2'; + select.dispatchEvent(new Event('change')); + await asyncUpdate(el, () => {}); + + assert.strictEqual(surface.dataModel.get('/select/empty_value'), '2'); + }); +}); diff --git a/renderers/lit/src/v0_9/tests/components/Switch.test.ts b/renderers/lit/src/v0_9/tests/components/Switch.test.ts new file mode 100644 index 0000000000..3f3e5645d5 --- /dev/null +++ b/renderers/lit/src/v0_9/tests/components/Switch.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2026 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 {setupTestDom, teardownTestDom, asyncUpdate} from '../dom-setup.js'; +import assert from 'node:assert'; +import {describe, it, beforeEach, afterEach, after, before} from 'node:test'; +import { + ComponentContext, + MessageProcessor, + Catalog, + ComponentApi, + SurfaceModel, +} from '@a2ui/web_core/v0_9'; +import type {A2uiSwitchElement} from '../../catalogs/basic/components/Switch.js'; + +describe('Switch Component', () => { + let basicCatalog: Catalog; + + before(async () => { + setupTestDom(); + basicCatalog = (await import('../../catalogs/basic/index.js')).basicCatalog; + await import('../../catalogs/basic/components/Switch.js'); + }); + + after(teardownTestDom); + + let processor: MessageProcessor; + let surface: SurfaceModel; + let element: A2uiSwitchElement | null = null; + + beforeEach(() => { + processor = new MessageProcessor([basicCatalog]); + processor.processMessages([ + { + version: 'v0.9', + createSurface: { + surfaceId: 'test-surface', + catalogId: basicCatalog.id, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'test-surface', + components: [ + { + id: 'switch_simple', + component: 'Switch', + label: 'Enable Mode', + value: {path: '/switch/value'}, + }, + ], + }, + }, + ]); + surface = processor.model.getSurface('test-surface')!; + surface.dataModel.set('/switch/value', false); + }); + + afterEach(() => { + if (element) { + element.remove(); + element = null; + } + }); + + it('should render label and checkbox state correctly', async () => { + const el = document.createElement('a2ui-switch') as A2uiSwitchElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'switch_simple'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const labelText = el.shadowRoot?.querySelector('.label-text'); + assert.ok(labelText); + assert.strictEqual(labelText.textContent?.trim(), 'Enable Mode'); + + const input = el.shadowRoot?.querySelector('input[type="checkbox"]') as HTMLInputElement; + assert.ok(input); + assert.strictEqual(input.checked, false); + + // Update value in dataModel and check if it reflects + surface.dataModel.set('/switch/value', true); + await asyncUpdate(el, () => {}); + assert.strictEqual(input.checked, true); + }); + + it('should update the data model value on toggle', async () => { + const el = document.createElement('a2ui-switch') as A2uiSwitchElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'switch_simple'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const input = el.shadowRoot?.querySelector('input[type="checkbox"]') as HTMLInputElement; + assert.ok(input); + + input.checked = true; + input.dispatchEvent(new Event('change')); + await asyncUpdate(el, () => {}); + + assert.strictEqual(surface.dataModel.get('/switch/value'), true); + + input.checked = false; + input.dispatchEvent(new Event('change')); + await asyncUpdate(el, () => {}); + + assert.strictEqual(surface.dataModel.get('/switch/value'), false); + }); +}); diff --git a/renderers/lit/src/v0_9/tests/components/Toast.test.ts b/renderers/lit/src/v0_9/tests/components/Toast.test.ts new file mode 100644 index 0000000000..b29704e6f5 --- /dev/null +++ b/renderers/lit/src/v0_9/tests/components/Toast.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2026 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 {setupTestDom, teardownTestDom, asyncUpdate} from '../dom-setup.js'; +import assert from 'node:assert'; +import {describe, it, beforeEach, afterEach, after, before} from 'node:test'; +import { + ComponentContext, + MessageProcessor, + Catalog, + ComponentApi, + SurfaceModel, +} from '@a2ui/web_core/v0_9'; +import type {A2uiToastElement} from '../../catalogs/basic/components/Toast.js'; + +describe('Toast Component', () => { + let basicCatalog: Catalog; + + before(async () => { + setupTestDom(); + basicCatalog = (await import('../../catalogs/basic/index.js')).basicCatalog; + await import('../../catalogs/basic/components/Toast.js'); + }); + + after(teardownTestDom); + + let processor: MessageProcessor; + let surface: SurfaceModel; + let element: A2uiToastElement | null = null; + + beforeEach(() => { + processor = new MessageProcessor([basicCatalog]); + processor.processMessages([ + { + version: 'v0.9', + createSurface: { + surfaceId: 'test-surface', + catalogId: basicCatalog.id, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'test-surface', + components: [ + { + id: 'toast_info', + component: 'Toast', + message: 'Info message', + // variant defaults to info + }, + { + id: 'toast_error', + component: 'Toast', + message: 'Error message', + variant: 'error', + }, + ], + }, + }, + ]); + surface = processor.model.getSurface('test-surface')!; + }); + + afterEach(() => { + if (element) { + element.remove(); + element = null; + } + }); + + it('should render message and default to info variant style', async () => { + const el = document.createElement('a2ui-toast') as A2uiToastElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'toast_info'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const msg = el.shadowRoot?.querySelector('.toast-message'); + assert.ok(msg); + assert.strictEqual(msg.textContent?.trim(), 'Info message'); + + const toastDiv = el.shadowRoot?.querySelector('.toast'); + assert.ok(toastDiv); + assert.ok(toastDiv.classList.contains('toast-info')); + }); + + it('should render correct variant style when specified', async () => { + const el = document.createElement('a2ui-toast') as A2uiToastElement; + element = el; + document.body.appendChild(el); + + const context = new ComponentContext(surface, 'toast_error'); + await asyncUpdate(el, e => { + e.context = context; + }); + + const msg = el.shadowRoot?.querySelector('.toast-message'); + assert.ok(msg); + assert.strictEqual(msg.textContent?.trim(), 'Error message'); + + const toastDiv = el.shadowRoot?.querySelector('.toast'); + assert.ok(toastDiv); + assert.ok(toastDiv.classList.contains('toast-error')); + }); +}); diff --git a/renderers/lit/src/v0_9/tests/dom-setup.ts b/renderers/lit/src/v0_9/tests/dom-setup.ts index d66b3de0df..6ee877fd55 100644 --- a/renderers/lit/src/v0_9/tests/dom-setup.ts +++ b/renderers/lit/src/v0_9/tests/dom-setup.ts @@ -22,9 +22,21 @@ const originalGlobals: Record = {}; function applyGlobals(obj: Record) { for (const [key, value] of Object.entries(obj)) { if (value === undefined) { - delete (global as any)[key]; + try { + delete (global as any)[key]; + } catch (e) { + Object.defineProperty(global, key, {value: undefined, configurable: true, writable: true}); + } } else { - (global as any)[key] = value; + try { + Object.defineProperty(global, key, { + value: value, + configurable: true, + writable: true, + }); + } catch (e) { + (global as any)[key] = value; + } } } } @@ -49,6 +61,7 @@ export function setupTestDom() { 'Element', 'Node', 'Event', + 'CustomEvent', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame', @@ -80,7 +93,18 @@ export function setupTestDom() { styleEl.textContent = text; }; } - + const HTMLDialogElementClass = (dom.window as any).HTMLDialogElement; + if (HTMLDialogElementClass && !HTMLDialogElementClass.prototype.showModal) { + HTMLDialogElementClass.prototype.showModal = function (this: any) { + this.setAttribute('open', ''); + this.open = true; + }; + HTMLDialogElementClass.prototype.close = function (this: any) { + this.removeAttribute('open'); + this.open = false; + this.dispatchEvent(new dom!.window.Event('close')); + }; + } // Set globals applyGlobals({ window: dom.window, @@ -90,6 +114,7 @@ export function setupTestDom() { Element: dom.window.Element, Node: dom.window.Node, Event: dom.window.Event, + CustomEvent: dom.window.CustomEvent, MutationObserver: dom.window.MutationObserver, CSSStyleSheet: dom.window.CSSStyleSheet, requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(cb, 16), diff --git a/renderers/web_core/CHANGELOG.md b/renderers/web_core/CHANGELOG.md index 564412277e..4a5c600cbc 100644 --- a/renderers/web_core/CHANGELOG.md +++ b/renderers/web_core/CHANGELOG.md @@ -1,7 +1,9 @@ ## Unreleased +- (v0_9) Add Select, Switch, Dialog, and Toast component schemas and catalog adapter helpers. - (v0_8) Export `A2uiMessageSchema` in public API. + ## 0.10.5 - (v0_9) Accept both `v0.9` and `v0.9.1` versions when parsing messages. Allow `A2uiClientCapabilities` to support simultaneous version capability advertising (`'v0.9'` and `'v0.9.1'`). diff --git a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts index 875e6e8215..b81ccdb15c 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts @@ -16,7 +16,7 @@ import {describe, it} from 'node:test'; import * as assert from 'node:assert'; -import {ImageApi} from './basic_components.js'; +import {ImageApi, SelectApi, SwitchApi, DialogApi, ToastApi} from './basic_components.js'; describe('Basic Components Schema', () => { describe('ImageApi', () => { @@ -46,4 +46,61 @@ describe('Basic Components Schema', () => { assert.throws(() => ImageApi.schema.parse(invalidImage)); }); }); + + describe('SelectApi', () => { + it('should parse valid Select schema', () => { + const validSelect = { + label: 'Choose option', + options: [ + {label: 'Opt 1', value: 'v1'}, + {label: 'Opt 2', value: 'v2'}, + ], + value: 'v1', + }; + const parsed = SelectApi.schema.parse(validSelect); + assert.strictEqual(parsed.label, 'Choose option'); + assert.strictEqual(parsed.options.length, 2); + }); + }); + + describe('SwitchApi', () => { + it('should parse valid Switch schema', () => { + const validSwitch = { + label: 'Enable notifications', + value: true, + }; + const parsed = SwitchApi.schema.parse(validSwitch); + assert.strictEqual(parsed.label, 'Enable notifications'); + assert.strictEqual(parsed.value, true); + }); + }); + + describe('DialogApi', () => { + it('should parse valid Dialog schema', () => { + const validDialog = { + title: 'Confirm Action', + child: 'content-comp-1', + open: true, + }; + const parsed = DialogApi.schema.parse(validDialog); + assert.strictEqual(parsed.title, 'Confirm Action'); + assert.strictEqual(parsed.child, 'content-comp-1'); + assert.strictEqual(parsed.open, true); + }); + }); + + describe('ToastApi', () => { + it('should parse valid Toast schema', () => { + const validToast = { + message: 'Settings saved', + variant: 'success', + durationMs: 5000, + }; + const parsed = ToastApi.schema.parse(validToast); + assert.strictEqual(parsed.message, 'Settings saved'); + assert.strictEqual(parsed.variant, 'success'); + assert.strictEqual(parsed.durationMs, 5000); + }); + }); }); + diff --git a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts index b704b00f9a..83a10fab5d 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts @@ -481,6 +481,69 @@ export const DateTimeInputApi = { .strict(), } satisfies ComponentApi; +export const SelectApi = { + name: 'Select', + schema: z + .object({ + ...CommonProps, + label: DynamicStringSchema.describe('The label for the select dropdown.').optional(), + options: z + .array( + z + .object({ + label: DynamicStringSchema.describe('The option display label.'), + value: z.string().describe('The option value.'), + }) + .strict(), + ) + .describe('The list of available options.'), + value: DynamicStringSchema.describe('The currently selected value.').optional(), + checks: CheckableSchema.shape.checks, + }) + .strict(), +} satisfies ComponentApi; + +export const SwitchApi = { + name: 'Switch', + schema: z + .object({ + ...CommonProps, + label: DynamicStringSchema.describe('The text label for the switch toggle.').optional(), + value: DynamicBooleanSchema.describe('The current on/off state of the switch.'), + checks: CheckableSchema.shape.checks, + }) + .strict(), +} satisfies ComponentApi; + +export const DialogApi = { + name: 'Dialog', + schema: z + .object({ + ...CommonProps, + title: DynamicStringSchema.describe('The dialog header title.').optional(), + child: ComponentIdSchema.describe('The ID of the child component rendered inside the dialog body.'), + open: DynamicBooleanSchema.default(false).describe('Whether the dialog is open or closed.').optional(), + action: ActionSchema.optional(), + }) + .strict(), +} satisfies ComponentApi; + +export const ToastApi = { + name: 'Toast', + schema: z + .object({ + ...CommonProps, + message: DynamicStringSchema.describe('The message displayed in the toast banner/snackbar.'), + variant: z + .enum(['info', 'success', 'warning', 'error']) + .default('info') + .describe('Visual variant hint for the toast message.') + .optional(), + durationMs: z.number().default(3000).describe('Auto-dismiss duration in milliseconds.').optional(), + }) + .strict(), +} satisfies ComponentApi; + export const BASIC_COMPONENTS: ComponentApi[] = [ TextApi, ImageApi, @@ -500,4 +563,9 @@ export const BASIC_COMPONENTS: ComponentApi[] = [ ChoicePickerApi, SliderApi, DateTimeInputApi, + SelectApi, + SwitchApi, + DialogApi, + ToastApi, ]; + diff --git a/renderers/web_core/src/v0_9/catalog/adapters.ts b/renderers/web_core/src/v0_9/catalog/adapters.ts new file mode 100644 index 0000000000..ca74c5dbce --- /dev/null +++ b/renderers/web_core/src/v0_9/catalog/adapters.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026 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 {ComponentApi, Catalog} from './types.js'; + +export interface PortableComponentAdapterOptions { + tagName: string; +} + +/** + * Creates a generic catalog adapter mapping a component API to a Web Component custom element tag name. + */ +export function createComponentAdapter( + api: T, + tagName: string, +): T & PortableComponentAdapterOptions { + return { + ...api, + tagName, + }; +} + +function toKebabCase(str: string): string { + return str + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z0-9])/g, '$1-$2') + .toLowerCase(); +} + +/** + * Helper to register Angular catalog adapters for A2UI Portable Web Components. + */ +export function createAngularAdapter( + catalog: Catalog, + tagMap: Record = {}, +): Catalog { + const adaptedComponents: (T & PortableComponentAdapterOptions)[] = []; + for (const [name, comp] of catalog.components.entries()) { + const tagName = tagMap[name] || `a2ui-${toKebabCase(name)}`; + adaptedComponents.push(createComponentAdapter(comp, tagName)); + } + return new Catalog( + `${catalog.id}/angular-adapter`, + adaptedComponents, + Array.from(catalog.functions.values()), + catalog.themeSchema, + ); +} + +/** + * Helper to register React catalog adapters for A2UI Portable Web Components. + */ +export function createReactAdapter( + catalog: Catalog, + tagMap: Record = {}, +): Catalog { + const adaptedComponents: (T & PortableComponentAdapterOptions)[] = []; + for (const [name, comp] of catalog.components.entries()) { + const tagName = tagMap[name] || `a2ui-${toKebabCase(name)}`; + adaptedComponents.push(createComponentAdapter(comp, tagName)); + } + return new Catalog( + `${catalog.id}/react-adapter`, + adaptedComponents, + Array.from(catalog.functions.values()), + catalog.themeSchema, + ); +} diff --git a/renderers/web_core/src/v0_9/index.ts b/renderers/web_core/src/v0_9/index.ts index db2b165fce..d14aaf6194 100644 --- a/renderers/web_core/src/v0_9/index.ts +++ b/renderers/web_core/src/v0_9/index.ts @@ -23,6 +23,8 @@ export * from './catalog/function_invoker.js'; export * from './catalog/types.js'; +export * from './catalog/adapters.js'; + export * from './common/events.js'; export * from './processing/message-processor.js'; export * from './rendering/component-context.js';