diff --git a/renderers/lit/CHANGELOG.md b/renderers/lit/CHANGELOG.md index 70b8e28af..36dd1e837 100644 --- a/renderers/lit/CHANGELOG.md +++ b/renderers/lit/CHANGELOG.md @@ -2,6 +2,7 @@ - (v0_9) Re-style the v0_9 catalog components using the default theme from `web_core`. [#1079](https://github.com/google/A2UI/pull/1079) +- (v0_9) Add missing features to ChoicePicker and CheckBox. [#1145](https://github.com/google/A2UI/pull/1145) ## 0.9.0 diff --git a/renderers/lit/package.json b/renderers/lit/package.json index 3d45ac168..7150a826d 100644 --- a/renderers/lit/package.json +++ b/renderers/lit/package.json @@ -64,7 +64,7 @@ "service": true }, "test": { - "command": "node --test --enable-source-maps --test-reporter spec dist/src/0.8/*.test.js dist/src/v0_9/tests/*.test.js", + "command": "node --test --enable-source-maps --test-reporter spec dist/src/0.8/*.test.js dist/src/v0_9/tests/*.test.js dist/src/v0_9/tests/**/*.test.js", "dependencies": [ "build" ] diff --git a/renderers/lit/src/v0_9/catalogs/basic/components/CheckBox.ts b/renderers/lit/src/v0_9/catalogs/basic/components/CheckBox.ts index 7e7fb0107..8109a89fb 100644 --- a/renderers/lit/src/v0_9/catalogs/basic/components/CheckBox.ts +++ b/renderers/lit/src/v0_9/catalogs/basic/components/CheckBox.ts @@ -37,7 +37,11 @@ export class A2uiCheckBoxElement extends BasicCatalogA2uiLitElement - - props.setValue?.((e.target as HTMLInputElement).checked)} - /> - ${props.label} - +
+ + ${isInvalid && props.validationErrors?.length + ? html`
${props.validationErrors[0]}
` + : nothing} +
`; } } diff --git a/renderers/lit/src/v0_9/catalogs/basic/components/ChoicePicker.ts b/renderers/lit/src/v0_9/catalogs/basic/components/ChoicePicker.ts index 60dc5a063..0362936cf 100644 --- a/renderers/lit/src/v0_9/catalogs/basic/components/ChoicePicker.ts +++ b/renderers/lit/src/v0_9/catalogs/basic/components/ChoicePicker.ts @@ -15,7 +15,8 @@ */ import { html, nothing, css } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; import { ChoicePickerApi } from "@a2ui/web_core/v0_9/basic_catalog"; import { BasicCatalogA2uiLitElement } from "../basic-catalog-a2ui-lit-element.js"; import { A2uiController } from "@a2ui/lit/v0_9"; @@ -32,6 +33,9 @@ export class A2uiChoicePickerElement extends BasicCatalogA2uiLitElement< * - `--a2ui-choicepicker-label-font-size`: Font size of all labels. Defaults to `--a2ui-label-font-size` then `--a2ui-font-size-s` for the main label. * - `--a2ui-choicepicker-label-font-weight`: Font weight of the main label. Defaults to `--a2ui-label-font-weight` then `bold`. * - `--a2ui-choicepicker-gap`: Spacing between options. + * - `--a2ui-choicepicker-filter-padding`: Padding for the filter input. Defaults to `--a2ui-spacing-xs` and `--a2ui-spacing-s` (4px 8px). + * - `--a2ui-choicepicker-chip-padding`: Padding for chips. Defaults to `--a2ui-spacing-s` and `--a2ui-spacing-m` (4px 8px). + * - `--a2ui-choicepicker-chip-border-radius`: Border radius for chips. Defaults to `999px`. */ static styles = css` :host { @@ -53,8 +57,43 @@ export class A2uiChoicePickerElement extends BasicCatalogA2uiLitElement< font-size: var(--a2ui-choicepicker-label-font-size, var(--a2ui-label-font-size, var(--a2ui-font-size-s))); font-weight: var(--a2ui-choicepicker-label-font-weight, var(--a2ui-label-font-weight, bold)); } + .filter-input { + background-color: var(--a2ui-color-input, #fff); + color: var(--a2ui-color-on-input, #333); + border: var(--a2ui-textfield-border, var(--a2ui-border)); + border-radius: var(--a2ui-textfield-border-radius, var(--a2ui-spacing-m)); + padding: var(--a2ui-choicepicker-filter-padding, var(--a2ui-spacing-xs, 4px) var(--a2ui-spacing-s, 8px)); + font-family: inherit; + } + .filter-input:focus { + outline: none; + border-color: var(--a2ui-textfield-color-border-focus, var(--a2ui-color-primary, #17e)); + } + .chips { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--a2ui-choicepicker-gap, var(--a2ui-spacing-xs, 0.25rem)); + } + .chip { + padding: var(--a2ui-choicepicker-chip-padding, var(--a2ui-spacing-s, 4px) var(--a2ui-spacing-m, 8px)); + border-radius: var(--a2ui-choicepicker-chip-border-radius, 999px); + border: 1px solid var(--a2ui-color-border, #ccc); + background-color: var(--a2ui-color-surface, #fff); + color: var(--a2ui-color-on-surface, inherit); + cursor: pointer; + font-size: var(--a2ui-font-size-xs, 0.75rem); + font-family: inherit; + } + .chip.selected { + background-color: var(--a2ui-color-primary, #007bff); + color: var(--a2ui-color-on-primary, #fff); + border-color: var(--a2ui-color-primary, #007bff); + } `; + @state() accessor filter = ''; + protected createController() { return new A2uiController(this, ChoicePickerApi); } @@ -65,6 +104,7 @@ export class A2uiChoicePickerElement extends BasicCatalogA2uiLitElement< const selected = Array.isArray(props.value) ? props.value : []; const isMulti = props.variant === "multipleSelection"; + const isChips = props.displayStyle === "chips"; const toggle = (val: string) => { if (!props.setValue) return; @@ -79,20 +119,52 @@ export class A2uiChoicePickerElement extends BasicCatalogA2uiLitElement< } }; + const options = (props.options || []).filter( + (opt: any) => + !props.filterable || + this.filter === "" || + String(opt.label).toLowerCase().includes(this.filter.toLowerCase()) + ); + return html` ${props.label ? html`` : nothing} -
- ${props.options?.map( - (opt: any) => html` - - `, + ${props.filterable + ? html` + (this.filter = (e.target as HTMLInputElement).value)} + /> + ` + : nothing} +
+ ${options.map((opt: any) => + isChips + ? html` + + ` + : html` + + ` )}
`; diff --git a/renderers/lit/src/v0_9/tests/components/CheckBox.test.ts b/renderers/lit/src/v0_9/tests/components/CheckBox.test.ts new file mode 100644 index 000000000..0093c6368 --- /dev/null +++ b/renderers/lit/src/v0_9/tests/components/CheckBox.test.ts @@ -0,0 +1,82 @@ +/* + * 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, after, before } from "node:test"; +import { ComponentContext, MessageProcessor } from "@a2ui/web_core/v0_9"; + +describe("CheckBox Component", () => { + let basicCatalog: any; + + before(async () => { + setupTestDom(); + basicCatalog = (await import("../../catalogs/basic/index.js")).basicCatalog; + // Ensure component is registered + await import("../../catalogs/basic/components/CheckBox.js"); + }); + + after(teardownTestDom); + + let processor: MessageProcessor; + let surface: any; + + 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: "checkbox_invalid", + component: "CheckBox", + label: "Check me", + value: false, + isValid: false, + validationErrors: ["This is required"], + }, + ], + }, + }, + ]); + surface = processor.model.getSurface("test-surface")!; + }); + + it("should render validation error in CheckBox", async () => { + const el = document.createElement("a2ui-checkbox") as any; + document.body.appendChild(el); + + const context = new ComponentContext(surface, "checkbox_invalid"); + await asyncUpdate(el, (e) => { + e.context = context; + }); + + const errorDiv = el.shadowRoot.querySelector(".error"); + assert.ok(errorDiv); + assert.strictEqual(errorDiv.textContent.trim(), "This is required"); + + document.body.removeChild(el); + }); +}); diff --git a/renderers/lit/src/v0_9/tests/components/ChoicePicker.test.ts b/renderers/lit/src/v0_9/tests/components/ChoicePicker.test.ts new file mode 100644 index 000000000..c8abf8eca --- /dev/null +++ b/renderers/lit/src/v0_9/tests/components/ChoicePicker.test.ts @@ -0,0 +1,119 @@ +/* + * 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, after, before } from "node:test"; +import { ComponentContext, MessageProcessor } from "@a2ui/web_core/v0_9"; + +describe("ChoicePicker Component", () => { + let basicCatalog: any; + + before(async () => { + setupTestDom(); + basicCatalog = (await import("../../catalogs/basic/index.js")).basicCatalog; + // Ensure component is registered + await import("../../catalogs/basic/components/ChoicePicker.js"); + }); + + after(teardownTestDom); + + let processor: MessageProcessor; + let surface: any; + + 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: "choice_picker_chips", + component: "ChoicePicker", + label: "Pick chips", + options: [ + { label: "Apple", value: "apple" }, + { label: "Banana", value: "banana" }, + ], + value: [], + displayStyle: "chips", + }, + { + id: "choice_picker_filterable", + component: "ChoicePicker", + label: "Filter me", + options: [ + { label: "Apple", value: "apple" }, + { label: "Banana", value: "banana" }, + ], + value: [], + filterable: true, + }, + ], + }, + }, + ]); + surface = processor.model.getSurface("test-surface")!; + }); + + it("should render chips when displayStyle is chips", async () => { + const el = document.createElement("a2ui-choicepicker") as any; + document.body.appendChild(el); + + const context = new ComponentContext(surface, "choice_picker_chips"); + await asyncUpdate(el, (e) => { + e.context = context; + }); + + const buttons = el.shadowRoot.querySelectorAll("button.chip"); + assert.strictEqual(buttons.length, 2); + assert.strictEqual(buttons[0].textContent.trim(), "Apple"); + + document.body.removeChild(el); + }); + + it("should filter options when filterable is true", async () => { + const el = document.createElement("a2ui-choicepicker") as any; + document.body.appendChild(el); + + const context = new ComponentContext(surface, "choice_picker_filterable"); + await asyncUpdate(el, (e) => { + e.context = context; + }); + + // Initially 2 options + 1 main label = 3 labels + assert.strictEqual(el.shadowRoot.querySelectorAll("label").length, 3); + + // Simulate input by setting state directly + await asyncUpdate(el, (e) => { + e.filter = "app"; + }); + + // Now only Apple should be visible + main label = 2 labels + assert.strictEqual(el.shadowRoot.querySelectorAll("label").length, 2); + + document.body.removeChild(el); + }); +});