diff --git a/renderers/angular/src/lib/catalog/datetime-input.ts b/renderers/angular/src/lib/catalog/datetime-input.ts index e17ca806..88bfc7f2 100644 --- a/renderers/angular/src/lib/catalog/datetime-input.ts +++ b/renderers/angular/src/lib/catalog/datetime-input.ts @@ -94,7 +94,7 @@ export class DatetimeInput extends DynamicComponent { } const year = this.padNumber(date.getFullYear()); - const month = this.padNumber(date.getMonth()); + const month = this.padNumber(date.getMonth() + 1); const day = this.padNumber(date.getDate()); const hours = this.padNumber(date.getHours()); const minutes = this.padNumber(date.getMinutes()); diff --git a/renderers/angular/src/lib/catalog/default.ts b/renderers/angular/src/lib/catalog/default.ts index 10c1146f..fd6d0d51 100644 --- a/renderers/angular/src/lib/catalog/default.ts +++ b/renderers/angular/src/lib/catalog/default.ts @@ -116,7 +116,8 @@ export const DEFAULT_CATALOG: Catalog = { const properties = (node as Types.MultipleChoiceNode).properties; return [ inputBinding('options', () => properties.options || []), - inputBinding('value', () => properties.selections), + inputBinding('selections', () => properties.selections), + inputBinding('maxAllowedSelections', () => properties.maxAllowedSelections ?? null), inputBinding('description', () => 'Select an item'), // TODO: this should be defined in the properties ]; }, diff --git a/renderers/angular/src/lib/catalog/multiple-choice.ts b/renderers/angular/src/lib/catalog/multiple-choice.ts index 538eb5bb..cc5932df 100644 --- a/renderers/angular/src/lib/catalog/multiple-choice.ts +++ b/renderers/angular/src/lib/catalog/multiple-choice.ts @@ -16,7 +16,7 @@ import { Component, computed, input } from '@angular/core'; import { DynamicComponent } from '../rendering/dynamic-component'; -import { Primitives } from '@a2ui/lit/0.8'; +import { Primitives, Types } from '@a2ui/lit/0.8'; @Component({ selector: 'a2ui-multiple-choice', @@ -29,12 +29,17 @@ import { Primitives } from '@a2ui/lit/0.8'; @@ -55,23 +60,78 @@ import { Primitives } from '@a2ui/lit/0.8'; }) export class MultipleChoice extends DynamicComponent { readonly options = input.required<{ label: Primitives.StringValue; value: string }[]>(); - readonly value = input.required(); + readonly selections = + input.required(); + readonly maxAllowedSelections = input(null); readonly description = input.required(); protected readonly selectId = super.getUniqueId('a2ui-multiple-choice'); - protected selectValue = computed(() => super.resolvePrimitive(this.value())); + protected selectedValues = computed(() => this.resolveSelections()); + protected isMultiple = computed(() => { + const maxSelections = this.maxAllowedSelections(); + const selections = this.selectedValues(); + return typeof maxSelections === 'number' ? maxSelections > 1 : selections.length > 1; + }); protected handleChange(event: Event) { - const path = this.value()?.path; + const selections = this.selections(); + const path = selections?.path; - if (!(event.target instanceof HTMLSelectElement) || !event.target.value || !path) { + if (!(event.target instanceof HTMLSelectElement) || !path) { return; } - this.processor.setData( - this.component(), - this.processor.resolvePath(path, this.component().dataContextPath), - event.target.value, - ); + const isMultiple = this.isMultiple(); + const selectedValues = isMultiple + ? Array.from(event.target.selectedOptions).map((option) => option.value) + : [event.target.value]; + const maxSelections = this.maxAllowedSelections(); + const nextSelections = + typeof maxSelections === 'number' && selectedValues.length > maxSelections + ? selectedValues.slice(0, maxSelections) + : selectedValues; + + if (isMultiple && nextSelections.length !== selectedValues.length) { + const allowed = new Set(nextSelections); + for (const option of Array.from(event.target.options)) { + option.selected = allowed.has(option.value); + } + } + + this.processor.setData(this.component(), path, nextSelections, this.surfaceId()); + } + + private resolveSelections() { + const selections = this.selections(); + + if (!selections || typeof selections !== 'object') { + return []; + } + + if ('literalArray' in selections) { + return Array.isArray(selections.literalArray) ? selections.literalArray : []; + } + + if ('literalString' in selections) { + return selections.literalString ? [selections.literalString] : []; + } + + if ('literal' in selections) { + return selections.literal != null ? [selections.literal] : []; + } + + if (selections.path) { + const resolved = this.processor.getData( + this.component(), + selections.path, + this.surfaceId() ?? undefined, + ); + if (Array.isArray(resolved)) { + return resolved.filter((value): value is string => typeof value === 'string'); + } + return typeof resolved === 'string' ? [resolved] : []; + } + + return []; } } diff --git a/renderers/lit/src/0.8/ui/datetime-input.ts b/renderers/lit/src/0.8/ui/datetime-input.ts index c77acd8d..3ed2f211 100644 --- a/renderers/lit/src/0.8/ui/datetime-input.ts +++ b/renderers/lit/src/0.8/ui/datetime-input.ts @@ -134,7 +134,7 @@ export class DateTimeInput extends Root { } const year = this.#padNumber(date.getFullYear()); - const month = this.#padNumber(date.getMonth()); + const month = this.#padNumber(date.getMonth() + 1); const day = this.#padNumber(date.getDate()); const hours = this.#padNumber(date.getHours()); const minutes = this.#padNumber(date.getMinutes()); diff --git a/renderers/lit/src/0.8/ui/multiple-choice.ts b/renderers/lit/src/0.8/ui/multiple-choice.ts index a4fba38e..d9ad7404 100644 --- a/renderers/lit/src/0.8/ui/multiple-choice.ts +++ b/renderers/lit/src/0.8/ui/multiple-choice.ts @@ -14,7 +14,7 @@ limitations under the License. */ -import { html, css, PropertyValues, nothing } from "lit"; +import { html, css, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; import { Root } from "./root.js"; import { StringValue } from "../types/primitives.js"; @@ -23,6 +23,7 @@ import { classMap } from "lit/directives/class-map.js"; import { styleMap } from "lit/directives/style-map.js"; import { structuralStyles } from "./styles.js"; import { extractStringValue } from "./utils/utils.js"; +import { ResolvedMultipleChoice } from "../types/types"; @customElement("a2ui-multiplechoice") export class MultipleChoice extends Root { @@ -33,7 +34,11 @@ export class MultipleChoice extends Root { accessor options: { label: StringValue; value: string }[] = []; @property() - accessor selections: StringValue | string[] = []; + accessor selections: ResolvedMultipleChoice["selections"] | StringValue | null = + null; + + @property({ type: Number }) + accessor maxAllowedSelections: number | null = null; static styles = [ structuralStyles, @@ -59,7 +64,6 @@ export class MultipleChoice extends Root { ]; #setBoundValue(value: string[]) { - console.log(value); if (!this.selections || !this.processor) { return; } @@ -78,54 +82,115 @@ export class MultipleChoice extends Root { ); } - protected willUpdate(changedProperties: PropertyValues): void { - const shouldUpdate = changedProperties.has("options"); - if (!shouldUpdate) { - return; + #resolveSelections(): string[] { + if (!this.selections || typeof this.selections !== "object") { + return []; } - if (!this.processor || !this.component || Array.isArray(this.selections)) { - return; + if ("literalArray" in this.selections) { + return Array.isArray(this.selections.literalArray) + ? this.selections.literalArray + : []; } - this.selections; + if ("literalString" in this.selections) { + return this.selections.literalString + ? [this.selections.literalString] + : []; + } - const selectionValue = this.processor.getData( - this.component, - this.selections.path!, - this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID - ); + if ("literal" in this.selections) { + return this.selections.literal !== undefined + ? [this.selections.literal] + : []; + } + + if ("path" in this.selections && this.selections.path) { + if (!this.processor || !this.component) { + return []; + } + + const selectionValue = this.processor.getData( + this.component, + this.selections.path, + this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID + ); + + if (Array.isArray(selectionValue)) { + return selectionValue.filter( + (value): value is string => typeof value === "string" + ); + } + + return typeof selectionValue === "string" ? [selectionValue] : []; + } + + return []; + } + + #getMaxSelections(): number | null { + if (typeof this.maxAllowedSelections !== "number") { + return null; + } + + return this.maxAllowedSelections > 0 ? this.maxAllowedSelections : 0; + } - if (!Array.isArray(selectionValue)) { + #isMultiple(selectedValues: string[]): boolean { + const maxSelections = this.#getMaxSelections(); + return maxSelections ? maxSelections > 1 : selectedValues.length > 1; + } + + #handleChange(event: Event, allowMultiple: boolean) { + if (!(event.target instanceof HTMLSelectElement)) { return; } - this.#setBoundValue(selectionValue as string[]); + const selectedValues = allowMultiple + ? Array.from(event.target.selectedOptions).map((option) => option.value) + : [event.target.value]; + const maxSelections = this.#getMaxSelections(); + const nextSelections = + maxSelections !== null && selectedValues.length > maxSelections + ? selectedValues.slice(0, maxSelections) + : selectedValues; + + if ( + allowMultiple && + nextSelections.length !== selectedValues.length && + event.target.options + ) { + const allowed = new Set(nextSelections); + for (const option of Array.from(event.target.options)) { + option.selected = allowed.has(option.value); + } + } + + this.#setBoundValue(nextSelections); } render() { + const selectedValues = this.#resolveSelections(); + const allowMultiple = this.#isMultiple(selectedValues); + const selectedSet = new Set(selectedValues); + return html`
`; diff --git a/renderers/lit/src/0.8/ui/root.ts b/renderers/lit/src/0.8/ui/root.ts index 93ba9cb6..29843f4b 100644 --- a/renderers/lit/src/0.8/ui/root.ts +++ b/renderers/lit/src/0.8/ui/root.ts @@ -372,7 +372,6 @@ export class Root extends SignalWatcher(LitElement) { } case "MultipleChoice": { - // TODO: maxAllowedSelections and selections. const node = component as NodeOfType<"MultipleChoice">; return html`