@assistant-ui/react-generative-ui 0.0.21 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +344 -83
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +2 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +3 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +58 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +20 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +187 -19
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +4 -0
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/teams/constants.d.ts +1 -1
  77. package/dist/teams/constants.d.ts.map +1 -1
  78. package/dist/teams/constants.js +1 -1
  79. package/dist/teams/constants.js.map +1 -1
  80. package/dist/teams/decodeSubmitData.d.ts +4 -0
  81. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  82. package/dist/teams/decodeSubmitData.js +6 -1
  83. package/dist/teams/decodeSubmitData.js.map +1 -1
  84. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  85. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  86. package/dist/teams/toAdaptiveCard.js +56 -17
  87. package/dist/teams/toAdaptiveCard.js.map +1 -1
  88. package/dist/teams/types.d.ts +4 -1
  89. package/dist/teams/types.d.ts.map +1 -1
  90. package/dist/vocabulary/alert.d.ts +9 -2
  91. package/dist/vocabulary/alert.d.ts.map +1 -1
  92. package/dist/vocabulary/alert.js +116 -19
  93. package/dist/vocabulary/alert.js.map +1 -1
  94. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  95. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  96. package/dist/vocabulary/collectFormValues.js +10 -3
  97. package/dist/vocabulary/collectFormValues.js.map +1 -1
  98. package/dist/vocabulary/data.d.ts +22 -5
  99. package/dist/vocabulary/data.d.ts.map +1 -1
  100. package/dist/vocabulary/data.js +278 -83
  101. package/dist/vocabulary/data.js.map +1 -1
  102. package/dist/vocabulary/dispatch.d.ts +3 -2
  103. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  104. package/dist/vocabulary/dispatch.js +33 -4
  105. package/dist/vocabulary/dispatch.js.map +1 -1
  106. package/dist/vocabulary/fact.d.ts +8 -1
  107. package/dist/vocabulary/fact.d.ts.map +1 -1
  108. package/dist/vocabulary/fact.js +37 -11
  109. package/dist/vocabulary/fact.js.map +1 -1
  110. package/dist/vocabulary/form.d.ts.map +1 -1
  111. package/dist/vocabulary/form.js +3 -3
  112. package/dist/vocabulary/form.js.map +1 -1
  113. package/dist/vocabulary/formatValue.d.ts +7 -0
  114. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  115. package/dist/vocabulary/formatValue.js +76 -0
  116. package/dist/vocabulary/formatValue.js.map +1 -0
  117. package/dist/vocabulary/icon.d.ts +1 -1
  118. package/dist/vocabulary/interactive.d.ts +137 -8
  119. package/dist/vocabulary/interactive.d.ts.map +1 -1
  120. package/dist/vocabulary/interactive.js +404 -103
  121. package/dist/vocabulary/interactive.js.map +1 -1
  122. package/dist/vocabulary/layout.d.ts.map +1 -1
  123. package/dist/vocabulary/layout.js +4 -4
  124. package/dist/vocabulary/layout.js.map +1 -1
  125. package/dist/vocabulary/list.js +2 -2
  126. package/dist/vocabulary/list.js.map +1 -1
  127. package/dist/vocabulary/text.d.ts +1 -1
  128. package/package.json +6 -3
  129. package/src/JSONGenerativeUI.client.tsx +84 -40
  130. package/src/JSONGenerativeUI.test.tsx +964 -68
  131. package/src/RadioGroupScope.server.test.tsx +24 -0
  132. package/src/RadioGroupScope.tsx +34 -0
  133. package/src/a2ui/BindingContext.ts +26 -0
  134. package/src/a2ui/PresentRenderer.tsx +83 -0
  135. package/src/a2ui/convert.test.ts +1795 -179
  136. package/src/a2ui/convert.ts +629 -132
  137. package/src/a2ui/dataModel.ts +69 -0
  138. package/src/a2ui/reducer.test.ts +35 -0
  139. package/src/a2ui/reducer.ts +5 -4
  140. package/src/a2ui/types.ts +9 -2
  141. package/src/a2ui/valueFunctions.test.ts +271 -0
  142. package/src/a2ui/valueFunctions.ts +523 -0
  143. package/src/actionRegistry.ts +1 -1
  144. package/src/answeredValues.test.ts +33 -0
  145. package/src/answeredValues.tsx +70 -0
  146. package/src/constants.ts +4 -0
  147. package/src/fieldReferences.test.ts +66 -0
  148. package/src/fieldReferences.ts +83 -0
  149. package/src/generativeUIToJSX.test.ts +18 -0
  150. package/src/generativeUIToJSX.ts +4 -6
  151. package/src/index.server.test.ts +106 -0
  152. package/src/ir.ts +1 -1
  153. package/src/renderGenerativeUI.test.tsx +14 -5
  154. package/src/renderGenerativeUI.tsx +30 -1
  155. package/src/slack/a2uiSlack.test.ts +198 -0
  156. package/src/slack/constants.ts +3 -0
  157. package/src/slack/decodeBlockAction.test.ts +98 -0
  158. package/src/slack/decodeBlockAction.ts +110 -17
  159. package/src/slack/fromSlackBlocks.test.ts +222 -3
  160. package/src/slack/fromSlackBlocks.ts +53 -2
  161. package/src/slack/toSlackBlocks.test.ts +682 -3
  162. package/src/slack/toSlackBlocks.ts +374 -23
  163. package/src/slack/types.ts +4 -0
  164. package/src/teams/constants.ts +1 -1
  165. package/src/teams/decodeSubmitData.test.ts +33 -0
  166. package/src/teams/decodeSubmitData.ts +9 -1
  167. package/src/teams/toAdaptiveCard.test.ts +219 -0
  168. package/src/teams/toAdaptiveCard.ts +93 -19
  169. package/src/teams/types.ts +4 -1
  170. package/src/vocabulary/alert.test.tsx +288 -7
  171. package/src/vocabulary/alert.tsx +168 -26
  172. package/src/vocabulary/collectFormValues.test.ts +28 -1
  173. package/src/vocabulary/collectFormValues.ts +25 -5
  174. package/src/vocabulary/data.test.tsx +248 -6
  175. package/src/vocabulary/data.tsx +398 -100
  176. package/src/vocabulary/dispatch.ts +63 -2
  177. package/src/vocabulary/fact.test.tsx +67 -0
  178. package/src/vocabulary/fact.tsx +40 -9
  179. package/src/vocabulary/form.tsx +7 -2
  180. package/src/vocabulary/formatValue.test.ts +72 -0
  181. package/src/vocabulary/formatValue.ts +127 -0
  182. package/src/vocabulary/index.test.ts +2 -0
  183. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  184. package/src/vocabulary/interactive.dom.test.tsx +1699 -0
  185. package/src/vocabulary/interactive.test.tsx +60 -4
  186. package/src/vocabulary/interactive.tsx +770 -151
  187. package/src/vocabulary/layout.test.tsx +6 -4
  188. package/src/vocabulary/layout.tsx +13 -2
  189. package/src/vocabulary/list.tsx +2 -2
@@ -1,6 +1,10 @@
1
- import { useId, type ReactNode } from "react";
1
+ import * as React from "react";
2
2
  import { z } from "zod";
3
- import { GENERATED_NAME_ATTR } from "../constants";
3
+ import {
4
+ CHECKBOX_GROUP_ATTR,
5
+ FIELD_NAME_ATTR,
6
+ GENERATED_NAME_ATTR,
7
+ } from "../constants";
4
8
  import type { Action } from "../ir";
5
9
  import { BUTTON_STYLES } from "../ir";
6
10
  import type {
@@ -8,36 +12,59 @@ import type {
8
12
  GenerativeUILibrary,
9
13
  GenerativeUIStatus,
10
14
  } from "../types";
15
+ import { A2uiBindingContext, useA2uiBinding } from "../a2ui/BindingContext";
16
+ import { useAnsweredValue } from "../answeredValues";
11
17
  import { actionAttr, fire } from "./dispatch";
12
18
  import { toTextContent } from "./toTextContent";
19
+ import { useRadioGroupName } from "../RadioGroupScope";
13
20
 
14
21
  const optionSchema = z.object({
15
22
  label: z.string(),
16
23
  value: z.string(),
17
24
  });
18
25
 
19
- type Option = { label: string; value: string };
26
+ const describedOptionSchema = optionSchema.extend({
27
+ description: z.string().optional(),
28
+ });
29
+
30
+ type Option = { label: string; description?: string; value: string };
20
31
 
21
32
  const isOption = (option: unknown): option is Option =>
22
33
  option !== null &&
23
34
  typeof option === "object" &&
24
35
  "label" in option &&
25
36
  typeof option.label === "string" &&
37
+ (!("description" in option) || typeof option.description === "string") &&
26
38
  "value" in option &&
27
39
  typeof option.value === "string";
28
40
 
41
+ const mapOptions = (
42
+ options: unknown,
43
+ render: (option: Option, key: string) => React.ReactNode,
44
+ ) => {
45
+ const occurrences = new Map<string, number>();
46
+ return (Array.isArray(options) ? options : []).map((option) => {
47
+ if (!isOption(option)) return null;
48
+ const occurrence = occurrences.get(option.value) ?? 0;
49
+ occurrences.set(option.value, occurrence + 1);
50
+ return render(option, JSON.stringify([option.value, occurrence]));
51
+ });
52
+ };
53
+
29
54
  type RadioGroupRenderProps = {
55
+ value?: string;
30
56
  options: Option[];
31
57
  name?: string;
32
58
  label?: string;
33
59
  defaultValue?: string;
34
- children?: ReactNode;
60
+ children?: React.ReactNode;
35
61
  $status: GenerativeUIStatus;
36
62
  $action?: Action;
37
63
  $dispatch?: GenerativeUIDispatch;
38
64
  };
39
65
 
40
66
  function RadioGroupRender({
67
+ value,
41
68
  options,
42
69
  name,
43
70
  label,
@@ -46,35 +73,700 @@ function RadioGroupRender({
46
73
  $action,
47
74
  $dispatch,
48
75
  }: RadioGroupRenderProps) {
49
- const generatedName = useId();
50
- const fieldName = name ?? generatedName;
51
- const safeOptions = Array.isArray(options) ? options : [];
76
+ const groupName = useRadioGroupName(name);
77
+ const answeredValue = useAnsweredValue(name);
78
+ const updateBinding = useA2uiBinding(name);
79
+ const isBound = updateBinding !== undefined;
80
+ const initialValue =
81
+ typeof answeredValue === "string" ? answeredValue : defaultValue;
52
82
  return (
53
83
  <fieldset
84
+ key={initialValue}
54
85
  data-aui="radiogroup"
55
86
  data-aui-action={actionAttr($action)}
56
87
  aria-label={label}
57
88
  >
58
- {safeOptions.map((option, i) =>
59
- isOption(option) ? (
60
- <label key={i} data-aui="radiogroup-option">
61
- <input
62
- type="radio"
63
- name={fieldName}
64
- {...(name == null ? { [GENERATED_NAME_ATTR]: "" } : {})}
65
- value={option.value}
66
- defaultChecked={defaultValue === option.value}
67
- onChange={() => fire($action, $dispatch, option.value)}
68
- />
69
- {option.label}
70
- </label>
71
- ) : null,
72
- )}
89
+ {mapOptions(options, (option, key) => (
90
+ <label key={key} data-aui="radiogroup-option">
91
+ <input
92
+ type="radio"
93
+ name={groupName}
94
+ {...{ [FIELD_NAME_ATTR]: name }}
95
+ {...(name == null ? { [GENERATED_NAME_ATTR]: "" } : {})}
96
+ value={option.value}
97
+ {...(isBound
98
+ ? { checked: (answeredValue ?? value) === option.value }
99
+ : { defaultChecked: initialValue === option.value })}
100
+ onChange={(e) => {
101
+ updateBinding?.(option.value);
102
+ fire($action, $dispatch, option.value, e.currentTarget);
103
+ }}
104
+ />
105
+ {option.description ? (
106
+ <span data-aui="option-content">
107
+ <span data-aui="option-label">{option.label}</span>
108
+ <span data-aui="option-description">{option.description}</span>
109
+ </span>
110
+ ) : (
111
+ option.label
112
+ )}
113
+ </label>
114
+ ))}
115
+ {children}
116
+ </fieldset>
117
+ );
118
+ }
119
+
120
+ type CheckboxGroupRenderProps = {
121
+ value?: string[];
122
+ options: Option[];
123
+ name?: string;
124
+ label?: string;
125
+ defaultValue?: string[];
126
+ children?: React.ReactNode;
127
+ $status: GenerativeUIStatus;
128
+ $action?: Action;
129
+ $dispatch?: GenerativeUIDispatch;
130
+ };
131
+
132
+ const checkedGroupValues = (input: HTMLInputElement): string[] =>
133
+ Array.from(
134
+ input
135
+ .closest("fieldset")
136
+ ?.querySelectorAll<HTMLInputElement>(
137
+ `:scope > label > input[${CHECKBOX_GROUP_ATTR}]:checked`,
138
+ ) ?? [],
139
+ (checkbox) => checkbox.value,
140
+ );
141
+
142
+ function CheckboxGroupRender({
143
+ value,
144
+ options,
145
+ name,
146
+ label,
147
+ defaultValue,
148
+ children,
149
+ $action,
150
+ $dispatch,
151
+ }: CheckboxGroupRenderProps) {
152
+ const generatedName = React.useId();
153
+ const fieldName = name ?? generatedName;
154
+ const answeredValue = useAnsweredValue(name);
155
+ const updateBinding = useA2uiBinding(name);
156
+ const isBound = updateBinding !== undefined;
157
+ const checkedValues =
158
+ Array.isArray(answeredValue) &&
159
+ answeredValue.every((value): value is string => typeof value === "string")
160
+ ? answeredValue
161
+ : Array.isArray(defaultValue)
162
+ ? defaultValue
163
+ : [];
164
+ return (
165
+ <fieldset
166
+ key={JSON.stringify(checkedValues)}
167
+ data-aui="checkboxgroup"
168
+ data-aui-action={actionAttr($action)}
169
+ aria-label={label}
170
+ >
171
+ {mapOptions(options, (option, key) => (
172
+ <label key={key} data-aui="checkboxgroup-option">
173
+ <input
174
+ type="checkbox"
175
+ name={fieldName}
176
+ {...{ [CHECKBOX_GROUP_ATTR]: "" }}
177
+ {...(name == null ? { [GENERATED_NAME_ATTR]: "" } : {})}
178
+ value={option.value}
179
+ {...(isBound
180
+ ? {
181
+ checked: (Array.isArray(answeredValue)
182
+ ? checkedValues
183
+ : (value ?? [])
184
+ ).includes(option.value),
185
+ }
186
+ : { defaultChecked: checkedValues.includes(option.value) })}
187
+ onChange={(e) => {
188
+ const selected = checkedGroupValues(e.currentTarget);
189
+ updateBinding?.(selected);
190
+ fire($action, $dispatch, selected, e.currentTarget);
191
+ }}
192
+ />
193
+ {option.description ? (
194
+ <span data-aui="option-content">
195
+ <span data-aui="option-label">{option.label}</span>
196
+ <span data-aui="option-description">{option.description}</span>
197
+ </span>
198
+ ) : (
199
+ option.label
200
+ )}
201
+ </label>
202
+ ))}
73
203
  {children}
74
204
  </fieldset>
75
205
  );
76
206
  }
77
207
 
208
+ type SelectRenderProps = {
209
+ value?: string;
210
+ options: Option[];
211
+ placeholder?: string;
212
+ label?: string;
213
+ name?: string;
214
+ defaultValue?: string;
215
+ children?: React.ReactNode;
216
+ $status: GenerativeUIStatus;
217
+ $action?: Action;
218
+ $dispatch?: GenerativeUIDispatch;
219
+ };
220
+
221
+ function SelectRender({
222
+ value,
223
+ options,
224
+ placeholder,
225
+ label,
226
+ name,
227
+ defaultValue,
228
+ $action,
229
+ $dispatch,
230
+ children,
231
+ }: SelectRenderProps) {
232
+ const answeredValue = useAnsweredValue(name);
233
+ const updateBinding = useA2uiBinding(name);
234
+ const isBound = updateBinding !== undefined;
235
+ const placeholderText = toTextContent(placeholder);
236
+ const initialValue =
237
+ typeof answeredValue === "string"
238
+ ? answeredValue
239
+ : typeof defaultValue === "string"
240
+ ? defaultValue
241
+ : "";
242
+ return (
243
+ <select
244
+ key={initialValue}
245
+ data-aui="select"
246
+ data-aui-action={actionAttr($action)}
247
+ name={name}
248
+ aria-label={label}
249
+ {...(isBound
250
+ ? {
251
+ value:
252
+ typeof answeredValue === "string" ? answeredValue : (value ?? ""),
253
+ }
254
+ : { defaultValue: initialValue })}
255
+ onChange={(e) => {
256
+ updateBinding?.(e.currentTarget.value);
257
+ fire($action, $dispatch, e.currentTarget.value, e.currentTarget);
258
+ }}
259
+ >
260
+ {placeholderText ? (
261
+ <option value="" disabled>
262
+ {placeholderText}
263
+ </option>
264
+ ) : null}
265
+ {mapOptions(options, (option, key) => (
266
+ <option key={key} value={option.value}>
267
+ {option.label}
268
+ </option>
269
+ ))}
270
+ {children}
271
+ </select>
272
+ );
273
+ }
274
+
275
+ type InputRenderProps = {
276
+ value?: string;
277
+ inputType?: "text" | "password" | "number";
278
+ placeholder?: string;
279
+ multiline?: boolean;
280
+ label?: string;
281
+ name?: string;
282
+ defaultValue?: string;
283
+ $status: GenerativeUIStatus;
284
+ $action?: Action;
285
+ $dispatch?: GenerativeUIDispatch;
286
+ };
287
+
288
+ function InputRender({
289
+ value,
290
+ inputType,
291
+ placeholder,
292
+ multiline,
293
+ label,
294
+ name,
295
+ defaultValue,
296
+ $action,
297
+ $dispatch,
298
+ }: InputRenderProps) {
299
+ const answeredValue = useAnsweredValue(name);
300
+ const updateBinding = useA2uiBinding(name);
301
+ const isBound = updateBinding !== undefined;
302
+ const initialValue =
303
+ typeof answeredValue === "string" ? answeredValue : defaultValue;
304
+ const submit = (control: HTMLInputElement | HTMLTextAreaElement) =>
305
+ fire($action, $dispatch, control.value, control);
306
+ return multiline && (inputType === undefined || inputType === "text") ? (
307
+ <textarea
308
+ key={initialValue}
309
+ data-aui="input"
310
+ data-aui-multiline
311
+ data-aui-action={actionAttr($action)}
312
+ name={name}
313
+ aria-label={label}
314
+ placeholder={placeholder}
315
+ {...(isBound
316
+ ? {
317
+ value:
318
+ typeof answeredValue === "string" ? answeredValue : (value ?? ""),
319
+ }
320
+ : { defaultValue: initialValue })}
321
+ onChange={
322
+ updateBinding ? (e) => updateBinding(e.currentTarget.value) : undefined
323
+ }
324
+ onKeyDown={(e) => {
325
+ if (
326
+ e.key !== "Enter" ||
327
+ !(e.ctrlKey || e.metaKey) ||
328
+ e.nativeEvent.isComposing
329
+ )
330
+ return;
331
+ if (e.currentTarget.form) {
332
+ e.preventDefault();
333
+ HTMLFormElement.prototype.requestSubmit.call(e.currentTarget.form);
334
+ } else {
335
+ submit(e.currentTarget);
336
+ }
337
+ }}
338
+ />
339
+ ) : (
340
+ <input
341
+ key={initialValue}
342
+ type={inputType}
343
+ step={inputType === "number" ? "any" : undefined}
344
+ data-aui="input"
345
+ data-aui-action={actionAttr($action)}
346
+ name={name}
347
+ aria-label={label}
348
+ placeholder={placeholder}
349
+ {...(isBound
350
+ ? {
351
+ value:
352
+ typeof answeredValue === "string" ? answeredValue : (value ?? ""),
353
+ }
354
+ : { defaultValue: initialValue })}
355
+ onChange={
356
+ updateBinding ? (e) => updateBinding(e.currentTarget.value) : undefined
357
+ }
358
+ onKeyDown={(e) => {
359
+ if (
360
+ e.key === "Enter" &&
361
+ !e.nativeEvent.isComposing &&
362
+ !e.currentTarget.form
363
+ )
364
+ submit(e.currentTarget);
365
+ }}
366
+ />
367
+ );
368
+ }
369
+
370
+ type DatePickerRenderProps = {
371
+ value?: string;
372
+ min?: string;
373
+ max?: string;
374
+ label?: string;
375
+ name?: string;
376
+ $status: GenerativeUIStatus;
377
+ $action?: Action;
378
+ $dispatch?: GenerativeUIDispatch;
379
+ };
380
+
381
+ function DatePickerRender({
382
+ value,
383
+ min,
384
+ max,
385
+ label,
386
+ name,
387
+ $action,
388
+ $dispatch,
389
+ }: DatePickerRenderProps) {
390
+ const answeredValue = useAnsweredValue(name);
391
+ const updateBinding = useA2uiBinding(name);
392
+ const isBound = updateBinding !== undefined;
393
+ const initialValue =
394
+ typeof answeredValue === "string" ? answeredValue : value;
395
+ return (
396
+ <input
397
+ key={isBound ? undefined : initialValue}
398
+ type="date"
399
+ data-aui="datepicker"
400
+ data-aui-action={actionAttr($action)}
401
+ name={name}
402
+ aria-label={label}
403
+ {...(isBound
404
+ ? {
405
+ value:
406
+ typeof answeredValue === "string" ? answeredValue : (value ?? ""),
407
+ }
408
+ : { defaultValue: initialValue })}
409
+ min={min}
410
+ max={max}
411
+ onChange={(e) => {
412
+ updateBinding?.(e.currentTarget.value);
413
+ fire($action, $dispatch, e.currentTarget.value, e.currentTarget);
414
+ }}
415
+ />
416
+ );
417
+ }
418
+
419
+ type CheckboxRenderProps = {
420
+ checked?: boolean;
421
+ label: string;
422
+ name?: string;
423
+ defaultChecked?: boolean;
424
+ variant?: "checkbox" | "switch";
425
+ $status: GenerativeUIStatus;
426
+ $action?: Action;
427
+ $dispatch?: GenerativeUIDispatch;
428
+ };
429
+
430
+ function CheckboxRender({
431
+ checked,
432
+ label,
433
+ name,
434
+ defaultChecked,
435
+ variant = "checkbox",
436
+ $action,
437
+ $dispatch,
438
+ }: CheckboxRenderProps) {
439
+ const answeredValue = useAnsweredValue(name);
440
+ const updateBinding = useA2uiBinding(name);
441
+ const isBound = updateBinding !== undefined;
442
+ const initialChecked =
443
+ typeof answeredValue === "boolean" ? answeredValue : defaultChecked;
444
+ return (
445
+ <label
446
+ data-aui="checkbox"
447
+ data-aui-variant={variant === "switch" ? variant : undefined}
448
+ >
449
+ <input
450
+ key={String(initialChecked)}
451
+ type="checkbox"
452
+ role={variant === "switch" ? "switch" : undefined}
453
+ data-aui-action={actionAttr($action)}
454
+ name={name}
455
+ {...(isBound
456
+ ? {
457
+ checked:
458
+ typeof answeredValue === "boolean"
459
+ ? answeredValue
460
+ : checked === true,
461
+ }
462
+ : { defaultChecked: initialChecked })}
463
+ onChange={(e) => {
464
+ updateBinding?.(e.currentTarget.checked);
465
+ fire($action, $dispatch, e.currentTarget.checked, e.currentTarget);
466
+ }}
467
+ />
468
+ <span data-aui="checkbox-label">{toTextContent(label)}</span>
469
+ </label>
470
+ );
471
+ }
472
+
473
+ const finiteNumber = (value: unknown): value is number =>
474
+ typeof value === "number" && Number.isFinite(value);
475
+
476
+ const clamp = (value: number, min: number, max: number): number =>
477
+ Math.min(Math.max(value, min), max);
478
+
479
+ type SliderRenderProps = {
480
+ value?: number;
481
+ name?: string;
482
+ label?: string;
483
+ min: number;
484
+ max: number;
485
+ step?: number;
486
+ defaultValue?: number;
487
+ unit?: string;
488
+ $status: GenerativeUIStatus;
489
+ $action?: Action;
490
+ $dispatch?: GenerativeUIDispatch;
491
+ };
492
+
493
+ type SliderControlProps = {
494
+ controlledValue?: number;
495
+ name?: string;
496
+ label?: string;
497
+ min: number;
498
+ max: number;
499
+ step: number;
500
+ initialValue: number;
501
+ unit?: string;
502
+ $action?: Action;
503
+ $dispatch?: GenerativeUIDispatch;
504
+ };
505
+
506
+ function SliderControl({
507
+ controlledValue,
508
+ name,
509
+ label,
510
+ min,
511
+ max,
512
+ step,
513
+ initialValue,
514
+ unit,
515
+ $action,
516
+ $dispatch,
517
+ }: SliderControlProps) {
518
+ const updateBinding = useA2uiBinding(name);
519
+ const [localValue, setValue] = React.useState(initialValue);
520
+ const value = controlledValue ?? localValue;
521
+ const pointerActive = React.useRef(false);
522
+ const keyboardActive = React.useRef(false);
523
+ const lastCommitted = React.useRef(initialValue);
524
+ const currentValue = (input: HTMLInputElement) =>
525
+ clamp(Number(input.value), min, max);
526
+ const commit = (input: HTMLInputElement) => {
527
+ if (controlledValue !== undefined) return;
528
+ const nextValue = currentValue(input);
529
+ setValue(nextValue);
530
+ if (lastCommitted.current === nextValue) return;
531
+ lastCommitted.current = nextValue;
532
+ fire($action, $dispatch, nextValue, input);
533
+ };
534
+
535
+ return (
536
+ <label data-aui="slider-field">
537
+ {label ? <span data-aui="slider-label">{label}</span> : null}
538
+ <input
539
+ type="range"
540
+ data-aui="slider"
541
+ data-aui-action={actionAttr($action)}
542
+ name={name}
543
+ aria-label={label}
544
+ aria-valuetext={unit ? `${value} ${unit}` : String(value)}
545
+ min={min}
546
+ max={max}
547
+ step={step}
548
+ {...(controlledValue !== undefined
549
+ ? { value }
550
+ : { defaultValue: initialValue })}
551
+ onInput={(e) => {
552
+ const nextValue = currentValue(e.currentTarget);
553
+ if (controlledValue !== undefined) updateBinding?.(nextValue);
554
+ else setValue(nextValue);
555
+ }}
556
+ onPointerDown={() => {
557
+ pointerActive.current = true;
558
+ }}
559
+ onPointerUp={(e) => {
560
+ pointerActive.current = false;
561
+ commit(e.currentTarget);
562
+ }}
563
+ onKeyDown={(e) => {
564
+ keyboardActive.current = [
565
+ "ArrowDown",
566
+ "ArrowLeft",
567
+ "ArrowRight",
568
+ "ArrowUp",
569
+ "End",
570
+ "Home",
571
+ "PageDown",
572
+ "PageUp",
573
+ ].includes(e.key);
574
+ }}
575
+ onKeyUp={(e) => {
576
+ if (!keyboardActive.current) return;
577
+ keyboardActive.current = false;
578
+ commit(e.currentTarget);
579
+ }}
580
+ onChange={(e) => {
581
+ if (!pointerActive.current && !keyboardActive.current)
582
+ commit(e.currentTarget);
583
+ }}
584
+ />
585
+ <output data-aui="slider-value">
586
+ {value}
587
+ {unit ? ` ${unit}` : ""}
588
+ </output>
589
+ </label>
590
+ );
591
+ }
592
+
593
+ function SliderRender({
594
+ value,
595
+ name,
596
+ label,
597
+ min,
598
+ max,
599
+ step,
600
+ defaultValue,
601
+ unit,
602
+ $action,
603
+ $dispatch,
604
+ }: SliderRenderProps) {
605
+ const answeredValue = useAnsweredValue(name);
606
+ const updateBinding = useA2uiBinding(name);
607
+ const isBound = updateBinding !== undefined;
608
+ const safeMin = finiteNumber(min) ? min : 0;
609
+ const safeMax = finiteNumber(max) ? Math.max(max, safeMin) : safeMin + 100;
610
+ const safeStep = finiteNumber(step) && step > 0 ? step : 1;
611
+ const defaultNumber = finiteNumber(isBound ? value : defaultValue)
612
+ ? isBound
613
+ ? value!
614
+ : defaultValue!
615
+ : safeMin;
616
+ const initialValue = clamp(
617
+ finiteNumber(answeredValue) ? answeredValue : defaultNumber,
618
+ safeMin,
619
+ safeMax,
620
+ );
621
+ return (
622
+ <SliderControl
623
+ key={`${safeMin}:${safeMax}:${safeStep}:${isBound ? "bound" : initialValue}`}
624
+ {...(isBound ? { controlledValue: initialValue } : {})}
625
+ min={safeMin}
626
+ max={safeMax}
627
+ step={safeStep}
628
+ initialValue={initialValue}
629
+ {...(name !== undefined ? { name } : {})}
630
+ {...(label !== undefined ? { label } : {})}
631
+ {...(unit !== undefined ? { unit } : {})}
632
+ {...($action !== undefined ? { $action } : {})}
633
+ {...($dispatch !== undefined ? { $dispatch } : {})}
634
+ />
635
+ );
636
+ }
637
+
638
+ const UNDO_WINDOW_SECONDS = 5;
639
+
640
+ type ButtonRenderProps = {
641
+ label: string;
642
+ buttonStyle?: (typeof BUTTON_STYLES)[number];
643
+ block?: boolean;
644
+ submit?: boolean;
645
+ undoable?: boolean;
646
+ children?: React.ReactNode;
647
+ $status: GenerativeUIStatus;
648
+ $action?: Action;
649
+ $dispatch?: GenerativeUIDispatch;
650
+ };
651
+
652
+ function ButtonRender({
653
+ label,
654
+ buttonStyle,
655
+ block,
656
+ submit,
657
+ undoable,
658
+ children,
659
+ $action,
660
+ $dispatch,
661
+ }: ButtonRenderProps) {
662
+ const bindings = React.useContext(A2uiBindingContext!)?.fields;
663
+ const bindingsRef = React.useRef(bindings);
664
+ bindingsRef.current = bindings;
665
+ const [remaining, setRemaining] = React.useState<number | undefined>(
666
+ undefined,
667
+ );
668
+ const labelText = String(toTextContent(label) ?? "");
669
+ const undoLabel = labelText.trim();
670
+ const pendingAction = React.useRef<
671
+ | {
672
+ $action: Action;
673
+ $dispatch: GenerativeUIDispatch;
674
+ source: HTMLButtonElement;
675
+ }
676
+ | undefined
677
+ >(undefined);
678
+ const canUndo =
679
+ undoable && !submit && $action !== undefined && $dispatch !== undefined;
680
+
681
+ React.useEffect(() => {
682
+ if (remaining === undefined) return;
683
+ const timer = setTimeout(() => {
684
+ if (remaining > 1) {
685
+ setRemaining(remaining - 1);
686
+ return;
687
+ }
688
+ const action = pendingAction.current;
689
+ pendingAction.current = undefined;
690
+ setRemaining(undefined);
691
+ if (action)
692
+ fire(
693
+ action.$action,
694
+ action.$dispatch,
695
+ undefined,
696
+ action.source,
697
+ bindingsRef.current,
698
+ );
699
+ }, 1_000);
700
+ return () => clearTimeout(timer);
701
+ }, [remaining]);
702
+
703
+ const cancelUndo = () => {
704
+ pendingAction.current = undefined;
705
+ setRemaining(undefined);
706
+ };
707
+
708
+ return (
709
+ <button
710
+ type={submit ? "submit" : "button"}
711
+ data-aui="button"
712
+ data-aui-style={buttonStyle}
713
+ data-aui-block={block || undefined}
714
+ data-aui-submit={submit || undefined}
715
+ data-aui-state={remaining === undefined ? undefined : "pending"}
716
+ data-aui-action={actionAttr($action)}
717
+ aria-label={
718
+ remaining === undefined
719
+ ? undefined
720
+ : undoLabel
721
+ ? `Undo ${undoLabel}`
722
+ : "Undo"
723
+ }
724
+ onClick={
725
+ submit
726
+ ? undefined
727
+ : canUndo
728
+ ? (e) => {
729
+ if (remaining !== undefined) {
730
+ cancelUndo();
731
+ return;
732
+ }
733
+ pendingAction.current = {
734
+ $action,
735
+ $dispatch,
736
+ source: e.currentTarget,
737
+ };
738
+ setRemaining(UNDO_WINDOW_SECONDS);
739
+ }
740
+ : (e) =>
741
+ fire($action, $dispatch, undefined, e.currentTarget, bindings)
742
+ }
743
+ onKeyDown={
744
+ canUndo
745
+ ? (e) => {
746
+ if (e.key !== "Escape" || remaining === undefined) return;
747
+ e.preventDefault();
748
+ cancelUndo();
749
+ }
750
+ : undefined
751
+ }
752
+ >
753
+ {remaining === undefined ? (
754
+ <>
755
+ {labelText}
756
+ {children}
757
+ </>
758
+ ) : (
759
+ <>
760
+ Undo <span data-aui="button-countdown">{remaining}</span>
761
+ <span data-aui="button-undo-status" role="status">
762
+ {labelText} in {UNDO_WINDOW_SECONDS} seconds
763
+ </span>
764
+ </>
765
+ )}
766
+ </button>
767
+ );
768
+ }
769
+
78
770
  export const interactiveVocabulary = {
79
771
  Button: {
80
772
  description:
@@ -92,29 +784,14 @@ export const interactiveVocabulary = {
92
784
  .describe(
93
785
  "Render as a submit button for an ancestor Form/Card instead of a click button.",
94
786
  ),
787
+ undoable: z
788
+ .boolean()
789
+ .optional()
790
+ .describe(
791
+ "Delay the action with an undo control, for sends and other hard-to-reverse actions.",
792
+ ),
95
793
  }),
96
- render: ({
97
- label,
98
- buttonStyle,
99
- block,
100
- submit,
101
- $action,
102
- $dispatch,
103
- children,
104
- }) => (
105
- <button
106
- type={submit ? "submit" : "button"}
107
- data-aui="button"
108
- data-aui-style={buttonStyle}
109
- data-aui-block={block || undefined}
110
- data-aui-submit={submit || undefined}
111
- data-aui-action={actionAttr($action)}
112
- onClick={submit ? undefined : () => fire($action, $dispatch)}
113
- >
114
- {toTextContent(label)}
115
- {children}
116
- </button>
117
- ),
794
+ render: ButtonRender,
118
795
  },
119
796
  Select: {
120
797
  description:
@@ -130,103 +807,33 @@ export const interactiveVocabulary = {
130
807
  .optional()
131
808
  .describe("Accessible label for the control."),
132
809
  name: z.string().optional().describe("Field name used inside a Form."),
810
+ defaultValue: z.string().optional().describe("Initially selected value."),
133
811
  }),
134
- render: ({
135
- options,
136
- placeholder,
137
- label,
138
- name,
139
- $action,
140
- $dispatch,
141
- children,
142
- }) => {
143
- const placeholderText = toTextContent(placeholder);
144
- return (
145
- <select
146
- data-aui="select"
147
- data-aui-action={actionAttr($action)}
148
- name={name}
149
- aria-label={label}
150
- defaultValue=""
151
- onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
152
- >
153
- {placeholderText ? (
154
- <option value="" disabled>
155
- {placeholderText}
156
- </option>
157
- ) : null}
158
- {(Array.isArray(options) ? options : []).map((option, i) =>
159
- isOption(option) ? (
160
- <option key={i} value={option.value}>
161
- {option.label}
162
- </option>
163
- ) : null,
164
- )}
165
- {children}
166
- </select>
167
- );
168
- },
812
+ render: SelectRender,
169
813
  },
170
814
  Input: {
171
815
  description:
172
816
  "A text input. Carries `$action` describing the on-submit behavior.",
173
817
  properties: z.object({
818
+ inputType: z
819
+ .enum(["text", "password", "number"])
820
+ .optional()
821
+ .describe("Single-line input type. Values remain strings."),
174
822
  placeholder: z.string().optional().describe("Placeholder text."),
175
823
  multiline: z
176
824
  .boolean()
177
825
  .optional()
178
- .describe("Render a textarea instead of a single-line input."),
826
+ .describe(
827
+ 'Render a textarea instead of a single-line input; ignored when `inputType` is `"password"` or `"number"`.',
828
+ ),
179
829
  label: z
180
830
  .string()
181
831
  .optional()
182
832
  .describe("Accessible label for the control."),
183
833
  name: z.string().optional().describe("Field name used inside a Form."),
834
+ defaultValue: z.string().optional().describe("Initial text."),
184
835
  }),
185
- render: ({ placeholder, multiline, label, name, $action, $dispatch }) => {
186
- const submit = (v: string) => fire($action, $dispatch, v);
187
- return multiline ? (
188
- <textarea
189
- data-aui="input"
190
- data-aui-multiline
191
- data-aui-action={actionAttr($action)}
192
- name={name}
193
- aria-label={label}
194
- placeholder={placeholder}
195
- onKeyDown={(e) => {
196
- if (
197
- e.key !== "Enter" ||
198
- !(e.ctrlKey || e.metaKey) ||
199
- e.nativeEvent.isComposing
200
- )
201
- return;
202
- if (e.currentTarget.form) {
203
- e.preventDefault();
204
- HTMLFormElement.prototype.requestSubmit.call(
205
- e.currentTarget.form,
206
- );
207
- } else {
208
- submit(e.currentTarget.value);
209
- }
210
- }}
211
- />
212
- ) : (
213
- <input
214
- data-aui="input"
215
- data-aui-action={actionAttr($action)}
216
- name={name}
217
- aria-label={label}
218
- placeholder={placeholder}
219
- onKeyDown={(e) => {
220
- if (
221
- e.key === "Enter" &&
222
- !e.nativeEvent.isComposing &&
223
- !e.currentTarget.form
224
- )
225
- submit(e.currentTarget.value);
226
- }}
227
- />
228
- );
229
- },
836
+ render: InputRender,
230
837
  },
231
838
  DatePicker: {
232
839
  description:
@@ -241,19 +848,7 @@ export const interactiveVocabulary = {
241
848
  .describe("Accessible label for the control."),
242
849
  name: z.string().optional().describe("Field name used inside a Form."),
243
850
  }),
244
- render: ({ value, min, max, label, name, $action, $dispatch }) => (
245
- <input
246
- type="date"
247
- data-aui="datepicker"
248
- data-aui-action={actionAttr($action)}
249
- name={name}
250
- aria-label={label}
251
- defaultValue={value}
252
- min={min}
253
- max={max}
254
- onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
255
- />
256
- ),
851
+ render: DatePickerRender,
257
852
  },
258
853
  Checkbox: {
259
854
  description:
@@ -265,29 +860,53 @@ export const interactiveVocabulary = {
265
860
  .boolean()
266
861
  .optional()
267
862
  .describe("Whether the checkbox starts checked."),
863
+ variant: z
864
+ .enum(["checkbox", "switch"])
865
+ .optional()
866
+ .describe("Whether to render a checkbox or switch."),
268
867
  }),
269
- render: ({ label, name, defaultChecked, $action, $dispatch }) => (
270
- <label data-aui="checkbox">
271
- <input
272
- type="checkbox"
273
- data-aui-action={actionAttr($action)}
274
- name={name}
275
- defaultChecked={defaultChecked}
276
- onChange={(e) => fire($action, $dispatch, e.currentTarget.checked)}
277
- />
278
- <span data-aui="checkbox-label">{toTextContent(label)}</span>
279
- </label>
280
- ),
868
+ render: CheckboxRender,
869
+ },
870
+ Slider: {
871
+ description:
872
+ "A numeric range control. Carries `$action` describing the committed value.",
873
+ properties: z.object({
874
+ name: z.string().optional().describe("Field name used inside a Form."),
875
+ label: z
876
+ .string()
877
+ .optional()
878
+ .describe("Accessible label for the control."),
879
+ min: z.number().describe("Minimum value."),
880
+ max: z.number().describe("Maximum value."),
881
+ step: z.number().optional().describe("Value increment. Defaults to 1."),
882
+ defaultValue: z.number().optional().describe("Initial value."),
883
+ unit: z.string().optional().describe("Unit shown with the value."),
884
+ }),
885
+ render: SliderRender,
281
886
  },
282
887
  RadioGroup: {
283
888
  description:
284
889
  "A group of mutually exclusive radio options. Carries `$action` describing the on-change behavior.",
285
890
  properties: z.object({
286
- options: z.array(optionSchema).describe("Selectable options."),
891
+ options: z.array(describedOptionSchema).describe("Selectable options."),
287
892
  name: z.string().optional().describe("Field name used inside a Form."),
288
893
  label: z.string().optional().describe("Accessible name for the group."),
289
894
  defaultValue: z.string().optional().describe("Initially selected value."),
290
895
  }),
291
896
  render: RadioGroupRender,
292
897
  },
898
+ CheckboxGroup: {
899
+ description:
900
+ "A group of checkbox options where any number can be checked. Carries `$action` describing the on-change behavior.",
901
+ properties: z.object({
902
+ options: z.array(describedOptionSchema).describe("Selectable options."),
903
+ name: z.string().optional().describe("Field name used inside a Form."),
904
+ label: z.string().optional().describe("Accessible name for the group."),
905
+ defaultValue: z
906
+ .array(z.string())
907
+ .optional()
908
+ .describe("Initially checked values."),
909
+ }),
910
+ render: CheckboxGroupRender,
911
+ },
293
912
  } satisfies GenerativeUILibrary;