Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion renderers/angular/src/lib/catalog/datetime-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
3 changes: 2 additions & 1 deletion renderers/angular/src/lib/catalog/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
];
},
Expand Down
84 changes: 72 additions & 12 deletions renderers/angular/src/lib/catalog/multiple-choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -29,12 +29,17 @@ import { Primitives } from '@a2ui/lit/0.8';
<select
(change)="handleChange($event)"
[id]="selectId"
[value]="selectValue()"
[multiple]="isMultiple()"
[class]="theme.components.MultipleChoice.element"
[style]="theme.additionalStyles?.MultipleChoice"
>
@for (option of options(); track option.value) {
<option [value]="option.value">{{ resolvePrimitive(option.label) }}</option>
<option
[value]="option.value"
[selected]="selectedValues().includes(option.value)"
>
{{ resolvePrimitive(option.label) }}
</option>
}
</select>
</section>
Expand All @@ -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<Primitives.StringValue | null>();
readonly selections =
input.required<Types.ResolvedMultipleChoice['selections'] | Primitives.StringValue | null>();
readonly maxAllowedSelections = input<number | null>(null);
readonly description = input.required<string>();

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 [];
}
}
2 changes: 1 addition & 1 deletion renderers/lit/src/0.8/ui/datetime-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
122 changes: 96 additions & 26 deletions renderers/lit/src/0.8/ui/multiple-choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -59,7 +64,6 @@ export class MultipleChoice extends Root {
];

#setBoundValue(value: string[]) {
console.log(value);
if (!this.selections || !this.processor) {
return;
}
Expand All @@ -78,54 +82,115 @@ export class MultipleChoice extends Root {
);
}

protected willUpdate(changedProperties: PropertyValues<this>): 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`<section class=${classMap(
this.theme.components.MultipleChoice.container
)}>
<label class=${classMap(
this.theme.components.MultipleChoice.label
)} for="data">${this.description ?? "Select an item"}</div>
)} for="data">${this.description ?? "Select an item"}</label>
<select
name="data"
id="data"
?multiple=${allowMultiple}
class=${classMap(this.theme.components.MultipleChoice.element)}
style=${
this.theme.additionalStyles?.MultipleChoice
? styleMap(this.theme.additionalStyles?.MultipleChoice)
: nothing
}
@change=${(evt: Event) => {
if (!(evt.target instanceof HTMLSelectElement)) {
return;
}

this.#setBoundValue([evt.target.value]);
}}
@change=${(evt: Event) => this.#handleChange(evt, allowMultiple)}
>
${this.options.map((option) => {
const label = extractStringValue(
Expand All @@ -134,7 +199,12 @@ export class MultipleChoice extends Root {
this.processor,
this.surfaceId
);
return html`<option ${option.value}>${label}</option>`;
return html`<option
value=${option.value}
?selected=${selectedSet.has(option.value)}
>
${label}
</option>`;
})}
</select>
</section>`;
Expand Down
1 change: 0 additions & 1 deletion renderers/lit/src/0.8/ui/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,6 @@ export class Root extends SignalWatcher(LitElement) {
}

case "MultipleChoice": {
// TODO: maxAllowedSelections and selections.
const node = component as NodeOfType<"MultipleChoice">;
return html`<a2ui-multiplechoice
id=${node.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export class LibraryComponent {
{ value: 'opt2', label: { literalString: 'Option 2' } },
{ value: 'opt3', label: { literalString: 'Option 3' } },
],
selections: { literalString: 'opt1' },
selections: { literalArray: ['opt1'] },
}),
},
{
Expand Down Expand Up @@ -526,7 +526,7 @@ export class LibraryComponent {
{ value: 'opt2', label: { literalString: 'Option 2' } },
{ value: 'opt3', label: { literalString: 'Option 3' } },
],
selections: { literalString: 'opt1' },
selections: { literalArray: ['opt1'] },
}),
},
{
Expand Down