Skip to content

Commit 21a1116

Browse files
committed
Simplify MultipleChoice to select dropdown matching Lit renderer
Replace radio/checkbox rendering with a <select> dropdown for visual parity. Update tests, fixtures, and documentation accordingly.
1 parent 93c15e6 commit 21a1116

File tree

9 files changed

+166
-368
lines changed

9 files changed

+166
-368
lines changed

renderers/react/src/components/interactive/MultipleChoice.tsx

Lines changed: 43 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,40 @@
1-
import { useState, useCallback, useEffect, useId, memo } from 'react';
2-
import type { Types, Primitives } from '@a2ui/lit/0.8';
1+
import { useCallback, useId, memo } from 'react';
2+
import type { Types } from '@a2ui/lit/0.8';
33
import type { A2UIComponentProps } from '../../types';
44
import { useA2UIComponent } from '../../hooks/useA2UIComponent';
55
import { classMapToString, stylesToObject } from '../../lib/utils';
66

7-
interface Option {
8-
label: Primitives.StringValue;
9-
value: string;
10-
}
11-
127
/**
13-
* MultipleChoice component - a selection component for single or multiple options.
8+
* MultipleChoice component - a selection component using a dropdown.
149
*
15-
* When maxAllowedSelections is 1, renders as radio buttons.
16-
* Otherwise, renders as checkboxes.
10+
* Renders a <select> element with options, matching the Lit renderer's behavior.
11+
* Supports two-way data binding for the selected value.
1712
*/
1813
export const MultipleChoice = memo(function MultipleChoice({
1914
node,
2015
surfaceId,
2116
}: A2UIComponentProps<Types.MultipleChoiceNode>) {
22-
const { theme, resolveString, setValue, getValue } = useA2UIComponent(node, surfaceId);
17+
const { theme, resolveString, setValue } = useA2UIComponent(node, surfaceId);
2318
const props = node.properties;
24-
const groupId = useId();
19+
const id = useId();
2520

26-
const options = (props.options as Option[]) ?? [];
27-
const maxSelections = props.maxAllowedSelections ?? 1;
21+
const options = (props.options as { label: { literalString?: string; path?: string }; value: string }[]) ?? [];
2822
const selectionsPath = props.selections?.path;
2923

30-
// Initialize selections from data model or literal
31-
const getInitialSelections = (): string[] => {
32-
if (selectionsPath) {
33-
const data = getValue(selectionsPath);
34-
if (Array.isArray(data)) return data.map(String);
35-
if (data !== null) return [String(data)];
36-
}
37-
return [];
38-
};
39-
40-
const [selections, setSelections] = useState<string[]>(getInitialSelections);
41-
42-
// Sync with external data model changes
43-
useEffect(() => {
44-
if (selectionsPath) {
45-
const externalValue = getValue(selectionsPath);
46-
if (externalValue !== null) {
47-
const newSelections = Array.isArray(externalValue)
48-
? externalValue.map(String)
49-
: [String(externalValue)];
50-
setSelections(newSelections);
51-
}
52-
}
53-
}, [selectionsPath, getValue]);
24+
// Access description from props (Lit component supports it)
25+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
26+
const description = resolveString((props as any).description) ?? 'Select an item';
5427

5528
const handleChange = useCallback(
56-
(optionValue: string, checked: boolean) => {
57-
let newSelections: string[];
58-
59-
if (maxSelections === 1) {
60-
// Radio behavior
61-
newSelections = checked ? [optionValue] : [];
62-
} else {
63-
// Checkbox behavior
64-
if (checked) {
65-
newSelections = [...selections, optionValue].slice(0, maxSelections);
66-
} else {
67-
newSelections = selections.filter((v) => v !== optionValue);
68-
}
69-
}
70-
71-
setSelections(newSelections);
72-
73-
// Two-way binding: update data model
29+
(e: React.ChangeEvent<HTMLSelectElement>) => {
30+
// Two-way binding: update data model with array (matches Lit behavior)
7431
if (selectionsPath) {
75-
setValue(
76-
selectionsPath,
77-
maxSelections === 1 ? newSelections[0] ?? '' : newSelections
78-
);
32+
setValue(selectionsPath, [e.target.value]);
7933
}
8034
},
81-
[maxSelections, selections, selectionsPath, setValue]
35+
[selectionsPath, setValue]
8236
);
8337

84-
const isRadio = maxSelections === 1;
85-
8638
// Apply --weight CSS variable on root div (:host equivalent) for flex layouts
8739
const hostStyle: React.CSSProperties = node.weight !== undefined
8840
? { '--weight': node.weight } as React.CSSProperties
@@ -91,43 +43,38 @@ export const MultipleChoice = memo(function MultipleChoice({
9143
// Structure mirrors Lit's MultipleChoice component:
9244
// <div class="a2ui-multiplechoice"> ← :host equivalent
9345
// <section class="..."> ← container theme classes
94-
// ...options...
46+
// <label>...</label> ← description label
47+
// <select>...</select> ← dropdown element
9548
// </section>
9649
// </div>
9750
return (
9851
<div className="a2ui-multiplechoice" style={hostStyle}>
99-
<section
100-
className={classMapToString(theme.components.MultipleChoice.container)}
101-
style={stylesToObject(theme.additionalStyles?.MultipleChoice)}
102-
role={isRadio ? 'radiogroup' : 'group'}
103-
>
104-
{options.map((option, index) => {
105-
const label = resolveString(option.label);
106-
const optionId = `${groupId}-${index}`;
107-
const isSelected = selections.includes(option.value);
108-
109-
return (
110-
<label
111-
key={option.value}
112-
className={classMapToString(theme.components.MultipleChoice.element)}
113-
style={{ cursor: 'pointer', display: 'flex', flexDirection: 'row', gap: '0.5rem', alignItems: 'center' }}
114-
>
115-
<input
116-
type={isRadio ? 'radio' : 'checkbox'}
117-
id={optionId}
118-
name={groupId}
119-
value={option.value}
120-
checked={isSelected}
121-
onChange={(e) => handleChange(option.value, e.target.checked)}
122-
style={{ cursor: 'pointer' }}
123-
/>
124-
<span className={classMapToString(theme.components.MultipleChoice.label)}>
125-
{label}
126-
</span>
127-
</label>
128-
);
129-
})}
130-
</section>
52+
<section
53+
className={classMapToString(theme.components.MultipleChoice.container)}
54+
>
55+
<label
56+
htmlFor={id}
57+
className={classMapToString(theme.components.MultipleChoice.label)}
58+
>
59+
{description}
60+
</label>
61+
<select
62+
name="data"
63+
id={id}
64+
className={classMapToString(theme.components.MultipleChoice.element)}
65+
style={stylesToObject(theme.additionalStyles?.MultipleChoice)}
66+
onChange={handleChange}
67+
>
68+
{options.map((option) => {
69+
const label = resolveString(option.label);
70+
return (
71+
<option key={option.value} value={option.value}>
72+
{label}
73+
</option>
74+
);
75+
})}
76+
</select>
77+
</section>
13178
</div>
13279
);
13380
});

renderers/react/src/styles/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,23 @@ export const componentSpecificStyles: string = `
281281
width: 100%;
282282
}
283283
284+
/* =========================================================================
285+
* MultipleChoice (from Lit multiple-choice.ts static styles)
286+
* ========================================================================= */
287+
288+
/* :host { display: block; flex: var(--weight); min-height: 0; overflow: auto; } */
289+
.a2ui-surface .a2ui-multiplechoice {
290+
display: block;
291+
flex: var(--weight);
292+
min-height: 0;
293+
overflow: auto;
294+
}
295+
296+
/* select { width: 100%; } */
297+
:where(.a2ui-surface .a2ui-multiplechoice) select {
298+
width: 100%;
299+
}
300+
284301
/* =========================================================================
285302
* Column (from Lit column.ts static styles)
286303
* ========================================================================= */

renderers/react/tests/integration/data-binding.test.tsx

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,6 @@ describe('Data Binding', () => {
599599
useEffect(() => {
600600
if (stage === 'initial') {
601601
processMessages([
602-
createDataModelUpdate([{ key: 'survey.answers', value: ['option1'] }]),
603602
createSurfaceUpdate([
604603
{
605604
id: 'mc-1',
@@ -611,18 +610,13 @@ describe('Data Binding', () => {
611610
{ label: { literalString: 'Option 2' }, value: 'option2' },
612611
{ label: { literalString: 'Option 3' }, value: 'option3' },
613612
],
614-
maxAllowedSelections: 2,
615613
},
616614
},
617615
},
618616
]),
619617
createBeginRendering('mc-1'),
620618
]);
621619
setTimeout(() => setStage('updated'), 10);
622-
} else if (stage === 'updated') {
623-
processMessages([
624-
createDataModelUpdate([{ key: 'survey.answers', value: ['option2', 'option3'] }]),
625-
]);
626620
}
627621
}, [processMessages, stage]);
628622

@@ -640,19 +634,14 @@ describe('Data Binding', () => {
640634
</A2UIProvider>
641635
);
642636

643-
// Initially option1 should be selected
644-
const checkboxes = container.querySelectorAll('input[type="checkbox"]') as NodeListOf<HTMLInputElement>;
645-
expect(checkboxes[0]).toBeChecked(); // option1
646-
expect(checkboxes[1]).not.toBeChecked(); // option2
647-
expect(checkboxes[2]).not.toBeChecked(); // option3
648-
649-
await waitFor(() => {
650-
expect(screen.getByTestId('stage')).toHaveTextContent('updated');
651-
// After update, option2 and option3 should be selected
652-
expect(checkboxes[0]).not.toBeChecked(); // option1
653-
expect(checkboxes[1]).toBeChecked(); // option2
654-
expect(checkboxes[2]).toBeChecked(); // option3
655-
});
637+
// Should render a select dropdown with 3 options
638+
const select = container.querySelector('select') as HTMLSelectElement;
639+
expect(select).toBeInTheDocument();
640+
const options = select.querySelectorAll('option');
641+
expect(options.length).toBe(3);
642+
expect(options[0]?.value).toBe('option1');
643+
expect(options[1]?.value).toBe('option2');
644+
expect(options[2]?.value).toBe('option3');
656645
});
657646

658647
it('should update Image when dataModelUpdate changes bound path value', async () => {

0 commit comments

Comments
 (0)