Skip to content
Merged
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
14 changes: 7 additions & 7 deletions v2/sdui/renderers/lit/src/AgDynamicRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ function renderNode(
.errorMessage=${node.errorMessage ?? nothing}
.helpText=${node.helpText ?? nothing}
@click=${() => doDispatch(node.on_click, actions)}
@change=${() => doDispatch(node.on_change, actions)}
@change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })}
></ag-checkbox>`;

case 'AgCopyButton':
Expand Down Expand Up @@ -390,7 +390,7 @@ function renderNode(
.errorMessage=${node.errorMessage ?? nothing}
.helpText=${node.helpText ?? nothing}
@click=${() => doDispatch(node.on_click, actions)}
@change=${() => doDispatch(node.on_change, actions)}
@change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? (e as unknown as { target?: { value?: string } }).target?.value ?? '' })}
></ag-input>`;

case 'AgIntlFormatter':
Expand Down Expand Up @@ -520,7 +520,7 @@ function renderNode(
.errorMessage=${node.errorMessage ?? nothing}
.helpText=${node.helpText ?? nothing}
@click=${() => doDispatch(node.on_click, actions)}
@change=${() => doDispatch(node.on_change, actions)}
@change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? '' })}
></ag-radio>`;

case 'AgRating':
Expand Down Expand Up @@ -559,7 +559,7 @@ function renderNode(
.errorMessage=${node.errorMessage ?? nothing}
.helpText=${node.helpText ?? nothing}
@click=${() => doDispatch(node.on_click, actions)}
@change=${() => doDispatch(node.on_change, actions)}
@change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string | string[] }>).detail?.value ?? '' })}
></ag-select>`;

case 'AgSelectionButton':
Expand All @@ -583,7 +583,7 @@ function renderNode(
.values=${node.values ?? nothing}
.disabled=${node.disabled ?? false}
.required=${node.required ?? false}
@selection-change=${(e: Event) => doDispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)}
@selection-change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })}
>${renderChildren(node.children)}</ag-selection-button-group>`;

case 'AgSelectionCard':
Expand All @@ -605,7 +605,7 @@ function renderNode(
.values=${node.values ?? nothing}
.disabled=${node.disabled ?? false}
.required=${node.required ?? false}
@selection-change=${(e: Event) => doDispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)}
@selection-change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })}
>${renderChildren(node.children)}</ag-selection-card-group>`;

case 'AgSkeletonLoader':
Expand Down Expand Up @@ -679,7 +679,7 @@ function renderNode(
.name=${node.name ?? nothing}
.value=${node.value ?? nothing}
@click=${() => doDispatch(node.on_click, actions)}
@toggle-change=${() => doDispatch(node.on_change, actions)}
@toggle-change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })}
></ag-toggle>`;

case 'AgTooltip':
Expand Down
14 changes: 7 additions & 7 deletions v2/sdui/renderers/react/src/AgDynamicRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ function renderNode(
errorMessage={node.errorMessage}
helpText={node.helpText}
onClick={() => dispatch(node.on_click, actions)}
onChange={() => dispatch(node.on_change, actions)} />
onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })} />
);

case 'AgCopyButton':
Expand Down Expand Up @@ -476,7 +476,7 @@ function renderNode(
errorMessage={node.errorMessage}
helpText={node.helpText}
onClick={() => dispatch(node.on_click, actions)}
onChange={() => dispatch(node.on_change, actions)} />
onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: ((e as unknown) as React.ChangeEvent<HTMLInputElement>).target?.value ?? ((e as unknown) as CustomEvent<{ value: string }>).detail?.value ?? '' })} />
);

case 'AgIntlFormatter':
Expand Down Expand Up @@ -644,7 +644,7 @@ function renderNode(
errorMessage={node.errorMessage}
helpText={node.helpText}
onClick={() => dispatch(node.on_click, actions)}
onChange={() => dispatch(node.on_change, actions)} />
onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? '' })} />
);

case 'AgRating':
Expand Down Expand Up @@ -687,7 +687,7 @@ function renderNode(
errorMessage={node.errorMessage}
helpText={node.helpText}
onClick={() => dispatch(node.on_click, actions)}
onChange={() => dispatch(node.on_change, actions)} />
onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: ((e as unknown) as { value?: string | string[] })?.value ?? '' })} />
);

case 'AgSelectionButton':
Expand Down Expand Up @@ -718,7 +718,7 @@ function renderNode(
values={node.values}
disabled={node.disabled}
required={node.required}
onSelectionChange={(e) => dispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)}
onSelectionChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })}
>
{renderChildren(node.children)}
</ReactSelectionButtonGroup>
Expand Down Expand Up @@ -750,7 +750,7 @@ function renderNode(
values={node.values}
disabled={node.disabled}
required={node.required}
onSelectionChange={(e) => dispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)}
onSelectionChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })}
>
{renderChildren(node.children)}
</ReactSelectionCardGroup>
Expand Down Expand Up @@ -847,7 +847,7 @@ function renderNode(
name={node.name}
value={node.value}
onClick={() => dispatch(node.on_click, actions)}
onToggleChange={() => dispatch(node.on_change, actions)} />
onToggleChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })} />
);

case 'AgTooltip':
Expand Down
14 changes: 7 additions & 7 deletions v2/sdui/renderers/vue/src/AgDynamicRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ function renderNode(
errorMessage: node.errorMessage,
helpText: node.helpText,
onClick: () => doDispatch(node.on_click, actions),
onChange: () => doDispatch(node.on_change, actions),
onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { checked?: boolean })?.checked }),
},
);

Expand Down Expand Up @@ -480,7 +480,7 @@ function renderNode(
errorMessage: node.errorMessage,
helpText: node.helpText,
onClick: () => doDispatch(node.on_click, actions),
onChange: () => doDispatch(node.on_change, actions),
onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string; target?: { value?: string } })?.value ?? (e as unknown as { target?: { value?: string } })?.target?.value ?? '' }),
},
);

Expand Down Expand Up @@ -646,7 +646,7 @@ function renderNode(
errorMessage: node.errorMessage,
helpText: node.helpText,
onClick: () => doDispatch(node.on_click, actions),
onChange: () => doDispatch(node.on_change, actions),
onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string })?.value ?? '' }),
},
);

Expand Down Expand Up @@ -691,7 +691,7 @@ function renderNode(
errorMessage: node.errorMessage,
helpText: node.helpText,
onClick: () => doDispatch(node.on_click, actions),
onChange: () => doDispatch(node.on_change, actions),
onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string | string[] })?.value ?? '' }),
},
);

Expand Down Expand Up @@ -722,7 +722,7 @@ function renderNode(
values: node.values,
disabled: node.disabled,
required: node.required,
onSelectionChange: (e: Event) => doDispatch(node.on_change, actions, (e as unknown as {value: string}).value),
onSelectionChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string })?.value }),
},
{ default: () => renderChildren(node.children) },
);
Expand Down Expand Up @@ -752,7 +752,7 @@ function renderNode(
values: node.values,
disabled: node.disabled,
required: node.required,
onSelectionChange: (e: Event) => doDispatch(node.on_change, actions, (e as unknown as {value: string}).value),
onSelectionChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string })?.value }),
},
{ default: () => renderChildren(node.children) },
);
Expand Down Expand Up @@ -850,7 +850,7 @@ function renderNode(
name: node.name,
value: node.value,
onClick: () => doDispatch(node.on_click, actions),
onToggleChange: () => doDispatch(node.on_change, actions),
onToggleChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { checked?: boolean })?.checked }),
},
);

Expand Down
81 changes: 75 additions & 6 deletions v2/sdui/schema/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,83 @@ function dispatch(alias: string | undefined, actions: Actions): void {
}
```

For events that carry a payload (currently `onSelectionChange`), the renderer emits a one-argument
lambda and passes the extracted value to the action function:
For events that carry a payload (all form input nodes), the renderer emits a one-argument lambda
and passes a structured payload to the action function. The payload shape is standardized across
all input components and all three renderers:

```ts
// React:
onSelectionChange={(e) => dispatch(node.on_change, actions, (e as CustomEvent<{value:string}>).detail.value)}
// Vue (wrappers emit detail directly, not wrapped in CustomEvent):
onSelectionChange={(e) => dispatch(node.on_change, actions, (e as {value:string}).value)}
{ id: string; value: unknown }
```

- `id` is the node's `id` string, allowing the consumer to identify which field changed without
inspecting event targets.
- `value` is the semantic current value of the input: a `string` for text, select, and radio; a
`boolean` (`checked`) for checkbox and toggle; `string | string[]` for multi-select.

```ts
// React — AgInput, AgRadio, AgSelect, AgCheckbox, AgToggle, AgSelectionButtonGroup, AgSelectionCardGroup:
onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: ... })}

// Vue (wrappers emit detail directly as first arg):
onChange: (detail: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: ... })

// Lit (native CustomEvent):
@change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: ... })}
```

This payload shape enables questionnaire-style UIs to accumulate answers without parsing raw DOM
events. See section 5.4 for the recommended integration pattern.

### 5.4 Questionnaire / Adaptive-Flow Integration Pattern

For multi-step UIs where the next screen depends on accumulated answers (health intake forms,
onboarding surveys, insurance applications), the renderer stays stateless. The consumer owns
the transition logic:

```
1. User changes an input
→ on_change fires with { id: node.id, value: currentValue }
→ consumer accumulates: answers[id] = value

2. User clicks "Next" (or any navigation alias)
→ on_click fires (no payload)
→ consumer POSTs accumulated answers to server/LLM endpoint
→ server returns next AgNode[] (only the next screen's nodes)
→ consumer calls setNodes(nextNodes)
→ renderer displays the next screen
```

The renderer never needs to know about steps, conditions, or skip logic. All branching
intelligence lives server-side (or in an LLM prompt). This keeps the renderer predictable
and testable: given `nodes[]` and `actions{}`, the output is deterministic.

Example consumer skeleton (React):

```tsx
const [nodes, setNodes] = useState<AgNode[]>(initialNodes);
const [answers, setAnswers] = useState<Record<string, unknown>>({});

const actions = {
NEXT_STEP: async () => {
const nextNodes = await fetch('/api/next-step', {
method: 'POST',
body: JSON.stringify(answers),
}).then(r => r.json());
setNodes(nextNodes);
},
};

// on_change handlers accumulate answers — no application code needed in the renderer
const changeActions = new Proxy(actions, {
get: (target, alias: string) =>
alias in target
? target[alias as keyof typeof target]
: (payload: { id: string; value: unknown }) => {
setAnswers(prev => ({ ...prev, [payload.id]: payload.value }));
},
});

<AgDynamicRenderer nodes={nodes} actions={changeActions} />
```

### 5.2 XSS Boundary
Expand Down
Loading
Loading