@assistant-ui/react-generative-ui 0.0.24 → 0.1.0

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 (338) hide show
  1. package/README.md +2 -0
  2. package/dist/a2ui.d.ts +1 -4
  3. package/dist/a2ui.d.ts.map +1 -1
  4. package/dist/a2ui.js +1 -4
  5. package/dist/index.d.ts +1 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -10
  8. package/dist/index.server.d.ts +1 -2
  9. package/dist/index.server.d.ts.map +1 -1
  10. package/dist/index.server.js +1 -10
  11. package/dist/ir.d.ts +1 -111
  12. package/dist/ir.d.ts.map +1 -1
  13. package/dist/ir.js +1 -175
  14. package/dist/slack.d.ts +1 -4
  15. package/dist/slack.d.ts.map +1 -1
  16. package/dist/slack.js +1 -4
  17. package/dist/teams.d.ts +1 -4
  18. package/dist/teams.d.ts.map +1 -1
  19. package/dist/teams.js +1 -4
  20. package/package.json +4 -5
  21. package/src/a2ui.ts +1 -22
  22. package/src/index.server.ts +1 -2
  23. package/src/index.test.ts +75 -0
  24. package/src/index.ts +1 -2
  25. package/src/ir.ts +1 -279
  26. package/src/slack.ts +1 -39
  27. package/src/teams.ts +1 -35
  28. package/dist/JSONGenerativeUI.client.d.ts +0 -28
  29. package/dist/JSONGenerativeUI.client.d.ts.map +0 -1
  30. package/dist/JSONGenerativeUI.client.js +0 -101
  31. package/dist/JSONGenerativeUI.client.js.map +0 -1
  32. package/dist/JSONGenerativeUI.server.d.ts +0 -21
  33. package/dist/JSONGenerativeUI.server.d.ts.map +0 -1
  34. package/dist/JSONGenerativeUI.server.js +0 -32
  35. package/dist/JSONGenerativeUI.server.js.map +0 -1
  36. package/dist/JSONGenerativeUI.shared.d.ts +0 -63
  37. package/dist/JSONGenerativeUI.shared.d.ts.map +0 -1
  38. package/dist/JSONGenerativeUI.shared.js +0 -31
  39. package/dist/JSONGenerativeUI.shared.js.map +0 -1
  40. package/dist/RadioGroupScope.d.ts +0 -6
  41. package/dist/RadioGroupScope.d.ts.map +0 -1
  42. package/dist/RadioGroupScope.js +0 -25
  43. package/dist/RadioGroupScope.js.map +0 -1
  44. package/dist/a2ui/BindingContext.d.ts +0 -11
  45. package/dist/a2ui/BindingContext.d.ts.map +0 -1
  46. package/dist/a2ui/BindingContext.js +0 -12
  47. package/dist/a2ui/BindingContext.js.map +0 -1
  48. package/dist/a2ui/PresentRenderer.d.ts +0 -11
  49. package/dist/a2ui/PresentRenderer.d.ts.map +0 -1
  50. package/dist/a2ui/PresentRenderer.js +0 -39
  51. package/dist/a2ui/PresentRenderer.js.map +0 -1
  52. package/dist/a2ui/convert.d.ts +0 -15
  53. package/dist/a2ui/convert.d.ts.map +0 -1
  54. package/dist/a2ui/convert.js +0 -728
  55. package/dist/a2ui/convert.js.map +0 -1
  56. package/dist/a2ui/dataModel.d.ts +0 -9
  57. package/dist/a2ui/dataModel.d.ts.map +0 -1
  58. package/dist/a2ui/dataModel.js +0 -44
  59. package/dist/a2ui/dataModel.js.map +0 -1
  60. package/dist/a2ui/reducer.d.ts +0 -8
  61. package/dist/a2ui/reducer.d.ts.map +0 -1
  62. package/dist/a2ui/reducer.js +0 -242
  63. package/dist/a2ui/reducer.js.map +0 -1
  64. package/dist/a2ui/snapshot.d.ts +0 -3
  65. package/dist/a2ui/snapshot.d.ts.map +0 -1
  66. package/dist/a2ui/snapshot.js +0 -33
  67. package/dist/a2ui/snapshot.js.map +0 -1
  68. package/dist/a2ui/types.d.ts +0 -88
  69. package/dist/a2ui/types.d.ts.map +0 -1
  70. package/dist/a2ui/types.js +0 -6
  71. package/dist/a2ui/types.js.map +0 -1
  72. package/dist/a2ui/valueFunctions.d.ts +0 -16
  73. package/dist/a2ui/valueFunctions.d.ts.map +0 -1
  74. package/dist/a2ui/valueFunctions.js +0 -332
  75. package/dist/a2ui/valueFunctions.js.map +0 -1
  76. package/dist/actionRegistry.d.ts +0 -29
  77. package/dist/actionRegistry.d.ts.map +0 -1
  78. package/dist/actionRegistry.js +0 -41
  79. package/dist/actionRegistry.js.map +0 -1
  80. package/dist/answeredValues.d.ts +0 -16
  81. package/dist/answeredValues.d.ts.map +0 -1
  82. package/dist/answeredValues.js +0 -37
  83. package/dist/answeredValues.js.map +0 -1
  84. package/dist/buildPresentParameters.d.ts +0 -21
  85. package/dist/buildPresentParameters.d.ts.map +0 -1
  86. package/dist/buildPresentParameters.js +0 -95
  87. package/dist/buildPresentParameters.js.map +0 -1
  88. package/dist/constants.d.ts +0 -15
  89. package/dist/constants.d.ts.map +0 -1
  90. package/dist/constants.js +0 -19
  91. package/dist/constants.js.map +0 -1
  92. package/dist/convert/boundSpec.d.ts +0 -48
  93. package/dist/convert/boundSpec.d.ts.map +0 -1
  94. package/dist/convert/boundSpec.js +0 -108
  95. package/dist/convert/boundSpec.js.map +0 -1
  96. package/dist/convert/copyBounded.d.ts +0 -25
  97. package/dist/convert/copyBounded.d.ts.map +0 -1
  98. package/dist/convert/copyBounded.js +0 -38
  99. package/dist/convert/copyBounded.js.map +0 -1
  100. package/dist/convert/isElement.d.ts +0 -3
  101. package/dist/convert/isElement.d.ts.map +0 -1
  102. package/dist/convert/isElement.js +0 -6
  103. package/dist/convert/isElement.js.map +0 -1
  104. package/dist/convert/takeRun.d.ts +0 -6
  105. package/dist/convert/takeRun.d.ts.map +0 -1
  106. package/dist/convert/takeRun.js +0 -20
  107. package/dist/convert/takeRun.js.map +0 -1
  108. package/dist/defineGenerativeComponents.d.ts +0 -29
  109. package/dist/defineGenerativeComponents.d.ts.map +0 -1
  110. package/dist/defineGenerativeComponents.js +0 -34
  111. package/dist/defineGenerativeComponents.js.map +0 -1
  112. package/dist/fieldReferences.d.ts +0 -9
  113. package/dist/fieldReferences.d.ts.map +0 -1
  114. package/dist/fieldReferences.js +0 -53
  115. package/dist/fieldReferences.js.map +0 -1
  116. package/dist/generativeUIToJSX.d.ts +0 -18
  117. package/dist/generativeUIToJSX.d.ts.map +0 -1
  118. package/dist/generativeUIToJSX.js +0 -76
  119. package/dist/generativeUIToJSX.js.map +0 -1
  120. package/dist/index.shared.d.ts +0 -15
  121. package/dist/index.shared.d.ts.map +0 -1
  122. package/dist/index.shared.js +0 -9
  123. package/dist/ir.js.map +0 -1
  124. package/dist/renderGenerativeUI.d.ts +0 -14
  125. package/dist/renderGenerativeUI.d.ts.map +0 -1
  126. package/dist/renderGenerativeUI.js +0 -83
  127. package/dist/renderGenerativeUI.js.map +0 -1
  128. package/dist/scopeSchema.d.ts +0 -6
  129. package/dist/scopeSchema.d.ts.map +0 -1
  130. package/dist/scopeSchema.js +0 -56
  131. package/dist/scopeSchema.js.map +0 -1
  132. package/dist/slack/constants.d.ts +0 -93
  133. package/dist/slack/constants.d.ts.map +0 -1
  134. package/dist/slack/constants.js +0 -146
  135. package/dist/slack/constants.js.map +0 -1
  136. package/dist/slack/decodeBlockAction.d.ts +0 -10
  137. package/dist/slack/decodeBlockAction.d.ts.map +0 -1
  138. package/dist/slack/decodeBlockAction.js +0 -129
  139. package/dist/slack/decodeBlockAction.js.map +0 -1
  140. package/dist/slack/fromSlackBlocks.d.ts +0 -27
  141. package/dist/slack/fromSlackBlocks.d.ts.map +0 -1
  142. package/dist/slack/fromSlackBlocks.js +0 -483
  143. package/dist/slack/fromSlackBlocks.js.map +0 -1
  144. package/dist/slack/toSlackBlocks.d.ts +0 -4
  145. package/dist/slack/toSlackBlocks.d.ts.map +0 -1
  146. package/dist/slack/toSlackBlocks.js +0 -901
  147. package/dist/slack/toSlackBlocks.js.map +0 -1
  148. package/dist/slack/types.d.ts +0 -204
  149. package/dist/slack/types.d.ts.map +0 -1
  150. package/dist/slack/types.js +0 -0
  151. package/dist/teams/constants.d.ts +0 -34
  152. package/dist/teams/constants.d.ts.map +0 -1
  153. package/dist/teams/constants.js +0 -68
  154. package/dist/teams/constants.js.map +0 -1
  155. package/dist/teams/decodeSubmitData.d.ts +0 -26
  156. package/dist/teams/decodeSubmitData.d.ts.map +0 -1
  157. package/dist/teams/decodeSubmitData.js +0 -70
  158. package/dist/teams/decodeSubmitData.js.map +0 -1
  159. package/dist/teams/temporalId.d.ts +0 -5
  160. package/dist/teams/temporalId.d.ts.map +0 -1
  161. package/dist/teams/temporalId.js +0 -28
  162. package/dist/teams/temporalId.js.map +0 -1
  163. package/dist/teams/toAdaptiveCard.d.ts +0 -45
  164. package/dist/teams/toAdaptiveCard.d.ts.map +0 -1
  165. package/dist/teams/toAdaptiveCard.js +0 -642
  166. package/dist/teams/toAdaptiveCard.js.map +0 -1
  167. package/dist/teams/toTeamsAttachments.d.ts +0 -10
  168. package/dist/teams/toTeamsAttachments.d.ts.map +0 -1
  169. package/dist/teams/toTeamsAttachments.js +0 -99
  170. package/dist/teams/toTeamsAttachments.js.map +0 -1
  171. package/dist/teams/types.d.ts +0 -224
  172. package/dist/teams/types.d.ts.map +0 -1
  173. package/dist/teams/types.js +0 -0
  174. package/dist/temporal.d.ts +0 -33
  175. package/dist/temporal.d.ts.map +0 -1
  176. package/dist/temporal.js +0 -153
  177. package/dist/temporal.js.map +0 -1
  178. package/dist/types.d.ts +0 -99
  179. package/dist/types.d.ts.map +0 -1
  180. package/dist/types.js +0 -0
  181. package/dist/vocabulary/alert.d.ts +0 -32
  182. package/dist/vocabulary/alert.d.ts.map +0 -1
  183. package/dist/vocabulary/alert.js +0 -160
  184. package/dist/vocabulary/alert.js.map +0 -1
  185. package/dist/vocabulary/collectFormValues.d.ts +0 -23
  186. package/dist/vocabulary/collectFormValues.d.ts.map +0 -1
  187. package/dist/vocabulary/collectFormValues.js +0 -44
  188. package/dist/vocabulary/collectFormValues.js.map +0 -1
  189. package/dist/vocabulary/data.d.ts +0 -66
  190. package/dist/vocabulary/data.d.ts.map +0 -1
  191. package/dist/vocabulary/data.js +0 -503
  192. package/dist/vocabulary/data.js.map +0 -1
  193. package/dist/vocabulary/dispatch.d.ts +0 -9
  194. package/dist/vocabulary/dispatch.d.ts.map +0 -1
  195. package/dist/vocabulary/dispatch.js +0 -50
  196. package/dist/vocabulary/dispatch.js.map +0 -1
  197. package/dist/vocabulary/fact.d.ts +0 -19
  198. package/dist/vocabulary/fact.d.ts.map +0 -1
  199. package/dist/vocabulary/fact.js +0 -51
  200. package/dist/vocabulary/fact.js.map +0 -1
  201. package/dist/vocabulary/form.d.ts +0 -11
  202. package/dist/vocabulary/form.d.ts.map +0 -1
  203. package/dist/vocabulary/form.js +0 -23
  204. package/dist/vocabulary/form.js.map +0 -1
  205. package/dist/vocabulary/formatValue.d.ts +0 -7
  206. package/dist/vocabulary/formatValue.d.ts.map +0 -1
  207. package/dist/vocabulary/formatValue.js +0 -76
  208. package/dist/vocabulary/formatValue.js.map +0 -1
  209. package/dist/vocabulary/icon.d.ts +0 -41
  210. package/dist/vocabulary/icon.d.ts.map +0 -1
  211. package/dist/vocabulary/icon.js +0 -272
  212. package/dist/vocabulary/icon.js.map +0 -1
  213. package/dist/vocabulary/index.d.ts +0 -12
  214. package/dist/vocabulary/index.d.ts.map +0 -1
  215. package/dist/vocabulary/index.js +0 -36
  216. package/dist/vocabulary/index.js.map +0 -1
  217. package/dist/vocabulary/interactive.d.ts +0 -230
  218. package/dist/vocabulary/interactive.d.ts.map +0 -1
  219. package/dist/vocabulary/interactive.js +0 -584
  220. package/dist/vocabulary/interactive.js.map +0 -1
  221. package/dist/vocabulary/layout.d.ts +0 -79
  222. package/dist/vocabulary/layout.d.ts.map +0 -1
  223. package/dist/vocabulary/layout.js +0 -139
  224. package/dist/vocabulary/layout.js.map +0 -1
  225. package/dist/vocabulary/list.d.ts +0 -14
  226. package/dist/vocabulary/list.d.ts.map +0 -1
  227. package/dist/vocabulary/list.js +0 -50
  228. package/dist/vocabulary/list.js.map +0 -1
  229. package/dist/vocabulary/media.d.ts +0 -25
  230. package/dist/vocabulary/media.d.ts.map +0 -1
  231. package/dist/vocabulary/media.js +0 -42
  232. package/dist/vocabulary/media.js.map +0 -1
  233. package/dist/vocabulary/text.d.ts +0 -57
  234. package/dist/vocabulary/text.d.ts.map +0 -1
  235. package/dist/vocabulary/text.js +0 -49
  236. package/dist/vocabulary/text.js.map +0 -1
  237. package/dist/vocabulary/toTextContent.d.ts +0 -2
  238. package/dist/vocabulary/toTextContent.d.ts.map +0 -1
  239. package/dist/vocabulary/toTextContent.js +0 -6
  240. package/dist/vocabulary/toTextContent.js.map +0 -1
  241. package/src/JSONGenerativeUI.client.tsx +0 -168
  242. package/src/JSONGenerativeUI.server.tsx +0 -39
  243. package/src/JSONGenerativeUI.shared.ts +0 -90
  244. package/src/JSONGenerativeUI.test.tsx +0 -1298
  245. package/src/RadioGroupScope.server.test.tsx +0 -24
  246. package/src/RadioGroupScope.tsx +0 -34
  247. package/src/a2ui/BindingContext.ts +0 -26
  248. package/src/a2ui/PresentRenderer.tsx +0 -83
  249. package/src/a2ui/convert.test.ts +0 -2923
  250. package/src/a2ui/convert.ts +0 -1207
  251. package/src/a2ui/dataModel.ts +0 -69
  252. package/src/a2ui/reducer.test.ts +0 -422
  253. package/src/a2ui/reducer.ts +0 -363
  254. package/src/a2ui/snapshot.test.ts +0 -52
  255. package/src/a2ui/snapshot.ts +0 -52
  256. package/src/a2ui/types.ts +0 -114
  257. package/src/a2ui/valueFunctions.test.ts +0 -271
  258. package/src/a2ui/valueFunctions.ts +0 -523
  259. package/src/actionRegistry.test.ts +0 -90
  260. package/src/actionRegistry.ts +0 -88
  261. package/src/answeredValues.test.ts +0 -33
  262. package/src/answeredValues.tsx +0 -70
  263. package/src/buildPresentParameters.test.ts +0 -246
  264. package/src/buildPresentParameters.ts +0 -130
  265. package/src/constants.ts +0 -17
  266. package/src/convert/boundSpec.test.ts +0 -192
  267. package/src/convert/boundSpec.ts +0 -134
  268. package/src/convert/copyBounded.test.ts +0 -131
  269. package/src/convert/copyBounded.ts +0 -38
  270. package/src/convert/isElement.ts +0 -6
  271. package/src/convert/takeRun.test.ts +0 -64
  272. package/src/convert/takeRun.ts +0 -19
  273. package/src/defineGenerativeComponents.ts +0 -38
  274. package/src/fieldReferences.test.ts +0 -66
  275. package/src/fieldReferences.ts +0 -83
  276. package/src/generativeUIToJSX.test.ts +0 -268
  277. package/src/generativeUIToJSX.ts +0 -152
  278. package/src/index.server.test.ts +0 -106
  279. package/src/index.shared.ts +0 -61
  280. package/src/ir.test.ts +0 -203
  281. package/src/renderGenerativeUI.test.tsx +0 -373
  282. package/src/renderGenerativeUI.tsx +0 -165
  283. package/src/scopeSchema.test.ts +0 -72
  284. package/src/scopeSchema.ts +0 -71
  285. package/src/slack/a2uiSlack.test.ts +0 -198
  286. package/src/slack/constants.ts +0 -194
  287. package/src/slack/datePicker.test.ts +0 -612
  288. package/src/slack/decodeBlockAction.test.ts +0 -233
  289. package/src/slack/decodeBlockAction.ts +0 -232
  290. package/src/slack/fromSlackBlocks.test.ts +0 -1465
  291. package/src/slack/fromSlackBlocks.ts +0 -789
  292. package/src/slack/toSlackBlocks.test.ts +0 -3065
  293. package/src/slack/toSlackBlocks.ts +0 -1818
  294. package/src/slack/types.ts +0 -264
  295. package/src/teams/constants.ts +0 -93
  296. package/src/teams/decodeSubmitData.test.ts +0 -397
  297. package/src/teams/decodeSubmitData.ts +0 -110
  298. package/src/teams/temporal.test.ts +0 -766
  299. package/src/teams/temporalId.ts +0 -56
  300. package/src/teams/toAdaptiveCard.test.ts +0 -1897
  301. package/src/teams/toAdaptiveCard.ts +0 -1057
  302. package/src/teams/toTeamsAttachments.test.ts +0 -74
  303. package/src/teams/toTeamsAttachments.ts +0 -133
  304. package/src/teams/types.ts +0 -266
  305. package/src/temporal.test.ts +0 -337
  306. package/src/temporal.ts +0 -263
  307. package/src/types.ts +0 -119
  308. package/src/vocabulary/alert.test.tsx +0 -427
  309. package/src/vocabulary/alert.tsx +0 -217
  310. package/src/vocabulary/collectFormValues.test.ts +0 -389
  311. package/src/vocabulary/collectFormValues.ts +0 -116
  312. package/src/vocabulary/data.test.tsx +0 -858
  313. package/src/vocabulary/data.tsx +0 -819
  314. package/src/vocabulary/datepicker.dom.test.tsx +0 -1457
  315. package/src/vocabulary/dispatch.ts +0 -83
  316. package/src/vocabulary/fact.test.tsx +0 -94
  317. package/src/vocabulary/fact.tsx +0 -54
  318. package/src/vocabulary/form.test.tsx +0 -113
  319. package/src/vocabulary/form.tsx +0 -39
  320. package/src/vocabulary/formatValue.test.ts +0 -72
  321. package/src/vocabulary/formatValue.ts +0 -127
  322. package/src/vocabulary/icon.test.tsx +0 -64
  323. package/src/vocabulary/icon.tsx +0 -174
  324. package/src/vocabulary/index.test.ts +0 -65
  325. package/src/vocabulary/index.ts +0 -33
  326. package/src/vocabulary/interactive.dispatch.test.tsx +0 -454
  327. package/src/vocabulary/interactive.dom.test.tsx +0 -1747
  328. package/src/vocabulary/interactive.test.tsx +0 -377
  329. package/src/vocabulary/interactive.tsx +0 -1109
  330. package/src/vocabulary/layout.test.tsx +0 -323
  331. package/src/vocabulary/layout.tsx +0 -247
  332. package/src/vocabulary/list.test.tsx +0 -222
  333. package/src/vocabulary/list.tsx +0 -50
  334. package/src/vocabulary/media.test.tsx +0 -85
  335. package/src/vocabulary/media.tsx +0 -56
  336. package/src/vocabulary/text.test.tsx +0 -102
  337. package/src/vocabulary/text.tsx +0 -60
  338. package/src/vocabulary/toTextContent.ts +0 -2
@@ -1,1747 +0,0 @@
1
- // @vitest-environment jsdom
2
-
3
- import { act, useState } from "react";
4
- import { createRoot, hydrateRoot, type Root } from "react-dom/client";
5
- import { renderToString } from "react-dom/server";
6
- import { afterEach, describe, expect, it, vi } from "vitest";
7
- import { convertSurfaceToUISpec } from "../a2ui/convert";
8
- import { A2uiBindingContext } from "../a2ui/BindingContext";
9
- import { applyA2uiOperations } from "../a2ui/reducer";
10
- import { A2uiPresentRenderer } from "../a2ui/PresentRenderer";
11
- import { createActionRegistry, type ActionHandler } from "../actionRegistry";
12
- import { AnsweredValuesProvider } from "../answeredValues";
13
- import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
14
- import { renderGenerativeUI } from "../renderGenerativeUI";
15
- import {
16
- fromLocalDateTime,
17
- normalizeTemporalInputValue,
18
- toLocalDateTime,
19
- } from "../temporal";
20
- import { defaultGenerativeUILibrary } from "./index";
21
-
22
- (
23
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
24
- ).IS_REACT_ACT_ENVIRONMENT = true;
25
-
26
- const toppings = [
27
- { label: "Basil", value: "basil" },
28
- { label: "Olives", value: "olives" },
29
- { label: "Onion", value: "onion" },
30
- ];
31
-
32
- let root: Root | undefined;
33
-
34
- afterEach(async () => {
35
- await act(async () => root?.unmount());
36
- root = undefined;
37
- document.body.innerHTML = "";
38
- vi.useRealTimers();
39
- });
40
-
41
- const view = (node: unknown, dispatch?: GenerativeUIDispatch) => (
42
- <div data-aui="root">
43
- {renderGenerativeUI(node, defaultGenerativeUILibrary, {
44
- status: "done",
45
- ...(dispatch ? { dispatch } : {}),
46
- })}
47
- </div>
48
- );
49
-
50
- const mount = async (
51
- node: unknown,
52
- handlers: Readonly<Record<string, ActionHandler>>,
53
- parent: HTMLElement = document.body,
54
- ) => {
55
- const container = document.createElement("div");
56
- parent.append(container);
57
- const registry = createActionRegistry(handlers);
58
- root = createRoot(container);
59
- await act(async () => {
60
- root!.render(view(node, registry.dispatch));
61
- });
62
- return container;
63
- };
64
-
65
- describe("Input", () => {
66
- it("keeps password and number single-line when multiline is set", async () => {
67
- const container = await mount(
68
- [
69
- {
70
- $type: "Input",
71
- name: "password",
72
- inputType: "password",
73
- multiline: true,
74
- },
75
- {
76
- $type: "Input",
77
- name: "number",
78
- inputType: "number",
79
- multiline: true,
80
- },
81
- { $type: "Input", name: "text", inputType: "text", multiline: true },
82
- { $type: "Input", name: "default", multiline: true },
83
- ],
84
- {},
85
- );
86
-
87
- expect(
88
- Array.from(container.querySelectorAll("input, textarea")).map(
89
- (control) =>
90
- control instanceof HTMLInputElement
91
- ? control.type
92
- : control.tagName.toLowerCase(),
93
- ),
94
- ).toEqual(["password", "number", "textarea", "textarea"]);
95
- });
96
- });
97
-
98
- describe("RadioGroup", () => {
99
- it("keeps repeated logical fields exclusive within one root without a form", async () => {
100
- const save = vi.fn();
101
- const container = await mount(
102
- {
103
- $type: "Col",
104
- children: [
105
- { $type: "RadioGroup", name: "choice", options: toppings },
106
- { $type: "RadioGroup", name: "choice", options: toppings },
107
- {
108
- $type: "Button",
109
- label: "Save",
110
- $action: { type: "save", choice: { $field: "choice" } },
111
- },
112
- ],
113
- },
114
- { save },
115
- );
116
- const inputs = container.querySelectorAll("input");
117
- await act(async () => inputs[4]!.click());
118
- await act(async () => inputs[0]!.click());
119
- await act(async () => container.querySelector("button")!.click());
120
-
121
- expect(inputs[4]!.checked).toBe(false);
122
- expect(save).toHaveBeenCalledWith({
123
- payload: { type: "save", choice: "basil" },
124
- });
125
- });
126
-
127
- it.each(["Form", "Card"])(
128
- "keeps repeated logical names mutually exclusive inside a %s form",
129
- async ($type) => {
130
- const save = vi.fn();
131
- const container = await mount(
132
- {
133
- $type,
134
- ...($type === "Form"
135
- ? { $action: { type: "save" } }
136
- : {
137
- asForm: true,
138
- confirm: { label: "Save", $action: { type: "save" } },
139
- }),
140
- children: [
141
- { $type: "RadioGroup", name: "choice", options: toppings },
142
- {
143
- $type: "Card",
144
- children: {
145
- $type: "RadioGroup",
146
- name: "choice",
147
- options: toppings,
148
- },
149
- },
150
- ...($type === "Form"
151
- ? [{ $type: "Button", label: "Save", submit: true }]
152
- : []),
153
- ],
154
- },
155
- { save },
156
- );
157
- const inputs = container.querySelectorAll("input");
158
- await act(async () => inputs[4]!.click());
159
- await act(async () => inputs[0]!.click());
160
- await act(async () => container.querySelector("button")!.click());
161
-
162
- expect(inputs[4]!.checked).toBe(false);
163
- expect(save).toHaveBeenCalledWith({
164
- payload: { type: "save", $input: { choice: "basil" } },
165
- });
166
- },
167
- );
168
-
169
- it("preserves independent selections across separately rendered and hydrated roots", async () => {
170
- const save = vi.fn();
171
- const registry = createActionRegistry({ save });
172
- const tree = view(
173
- {
174
- $type: "Col",
175
- children: [
176
- { $type: "RadioGroup", name: "choice", options: toppings },
177
- {
178
- $type: "Button",
179
- label: "Save",
180
- $action: { type: "save", choice: { $field: "choice" } },
181
- },
182
- ],
183
- },
184
- registry.dispatch,
185
- );
186
- const containers = ["first-", "second-"].map((identifierPrefix) => {
187
- const container = document.createElement("div");
188
- container.innerHTML = renderToString(tree, { identifierPrefix });
189
- document.body.append(container);
190
- return { container, identifierPrefix };
191
- });
192
- const hydratedRoots: Root[] = [];
193
- const onRecoverableError = vi.fn();
194
- try {
195
- await act(async () => {
196
- for (const { container, identifierPrefix } of containers) {
197
- hydratedRoots.push(
198
- hydrateRoot(container, tree, {
199
- identifierPrefix,
200
- onRecoverableError,
201
- }),
202
- );
203
- }
204
- });
205
- for (const [index, { container }] of containers.entries()) {
206
- await act(async () =>
207
- container.querySelectorAll("input")[index]!.click(),
208
- );
209
- }
210
- for (const { container } of containers) {
211
- await act(async () => container.querySelector("button")!.click());
212
- }
213
-
214
- expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
215
- { type: "save", choice: "basil" },
216
- { type: "save", choice: "olives" },
217
- ]);
218
- expect(onRecoverableError).not.toHaveBeenCalled();
219
- } finally {
220
- await act(async () => hydratedRoots.forEach((item) => item.unmount()));
221
- }
222
- });
223
-
224
- it("keeps same-named groups in separate roots independent and resolves their field values", async () => {
225
- const save = vi.fn();
226
- const registry = createActionRegistry({ save });
227
- const container = document.createElement("div");
228
- document.body.append(container);
229
- root = createRoot(container);
230
- const card = (id: string) => ({
231
- $type: "Col",
232
- children: [
233
- { $type: "RadioGroup", name: "choice", options: toppings },
234
- {
235
- $type: "Button",
236
- label: "Save",
237
- $action: { type: "save", id, choice: { $field: "choice" } },
238
- },
239
- ],
240
- });
241
- await act(async () => {
242
- root!.render(
243
- <>
244
- {view(card("first"), registry.dispatch)}
245
- {view(card("second"), registry.dispatch)}
246
- </>,
247
- );
248
- });
249
- const inputs = container.querySelectorAll("input");
250
- const [firstSave, secondSave] = container.querySelectorAll("button");
251
-
252
- await act(async () => inputs[0]!.click());
253
- await act(async () => inputs[4]!.click());
254
- await act(async () => firstSave!.click());
255
- await act(async () => secondSave!.click());
256
-
257
- expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
258
- { type: "save", id: "first", choice: "basil" },
259
- { type: "save", id: "second", choice: "olives" },
260
- ]);
261
- expect(inputs[0]!.checked).toBe(true);
262
- expect(inputs[4]!.checked).toBe(true);
263
-
264
- await act(async () => inputs[2]!.click());
265
- expect(inputs[0]!.checked).toBe(false);
266
- expect(inputs[2]!.checked).toBe(true);
267
- expect(inputs[4]!.checked).toBe(true);
268
- });
269
-
270
- it("submits logical field names and omits unnamed groups", async () => {
271
- const save = vi.fn();
272
- const container = await mount(
273
- {
274
- $type: "Form",
275
- $action: { type: "save" },
276
- children: [
277
- { $type: "RadioGroup", name: "choice", options: toppings },
278
- { $type: "RadioGroup", options: toppings },
279
- { $type: "Button", label: "Save", submit: true },
280
- ],
281
- },
282
- { save },
283
- );
284
- const inputs = container.querySelectorAll("input");
285
- await act(async () => inputs[0]!.click());
286
- await act(async () => inputs[4]!.click());
287
- await act(async () => container.querySelector("button")!.click());
288
-
289
- expect(save).toHaveBeenCalledWith({
290
- payload: { type: "save", $input: { choice: "basil" } },
291
- });
292
- });
293
- });
294
-
295
- describe("option identity", () => {
296
- it.each(["CheckboxGroup", "RadioGroup", "Select"])(
297
- "%s preserves the submitted choice when options move or their labels change",
298
- async (type) => {
299
- const save = vi.fn();
300
- const registry = createActionRegistry({ save });
301
- const form = (options: typeof toppings) => ({
302
- $type: "Form",
303
- $action: { type: "save" },
304
- children: [
305
- { $type: type, name: "choice", options },
306
- { $type: "Button", label: "Save", submit: true },
307
- ],
308
- });
309
- const container = await mount(form(toppings), { save });
310
- if (type === "Select") {
311
- container.querySelector("select")!.value = "olives";
312
- } else {
313
- await act(async () => container.querySelectorAll("input")[1]!.click());
314
- }
315
- await act(async () => container.querySelector("button")!.click());
316
-
317
- await act(async () => {
318
- root!.render(
319
- view(
320
- form([
321
- { label: "Pepper", value: "pepper" },
322
- ...[...toppings].reverse().map((option) => ({
323
- ...option,
324
- label: option.label.toUpperCase(),
325
- })),
326
- ]),
327
- registry.dispatch,
328
- ),
329
- );
330
- });
331
- await act(async () => container.querySelector("button")!.click());
332
-
333
- const payload = {
334
- type: "save",
335
- $input: { choice: type === "CheckboxGroup" ? ["olives"] : "olives" },
336
- };
337
- expect(save.mock.calls.map(([call]) => call.payload)).toEqual([
338
- payload,
339
- payload,
340
- ]);
341
- },
342
- );
343
-
344
- it.each(["CheckboxGroup", "RadioGroup", "Select"])(
345
- "%s retains duplicate-valued options without duplicate React keys",
346
- async (type) => {
347
- const errors = vi.spyOn(console, "error").mockImplementation(() => {});
348
- try {
349
- const container = await mount(
350
- {
351
- $type: type,
352
- options: [
353
- { label: "First", value: "same" },
354
- { label: "Second", value: "same" },
355
- { label: "Third", value: "same:1" },
356
- ],
357
- },
358
- {},
359
- );
360
- expect(container.querySelectorAll("input, option")).toHaveLength(3);
361
- expect(errors).not.toHaveBeenCalled();
362
- } finally {
363
- errors.mockRestore();
364
- }
365
- },
366
- );
367
- });
368
-
369
- describe("CheckboxGroup", () => {
370
- it("dispatches its checked option values in option order on every change", async () => {
371
- const pick = vi.fn();
372
- const container = await mount(
373
- {
374
- $type: "CheckboxGroup",
375
- options: toppings,
376
- defaultValue: ["onion"],
377
- $action: { type: "pick" },
378
- children: { $type: "Checkbox", label: "Extra cheese" },
379
- },
380
- { pick },
381
- );
382
- const [basil, , onion, cheese] = container.querySelectorAll("input");
383
-
384
- await act(async () => basil!.click());
385
- await act(async () => cheese!.click());
386
- await act(async () => onion!.click());
387
-
388
- expect(pick.mock.calls.map(([{ payload }]) => payload)).toEqual([
389
- { type: "pick", $input: ["basil", "onion"] },
390
- { type: "pick", $input: ["basil"] },
391
- ]);
392
- });
393
-
394
- it("renders a converted A2UI multipleSelection ChoicePicker with its bound values checked", async () => {
395
- const { state } = applyA2uiOperations(new Map(), [
396
- { version: "v0.9", createSurface: { surfaceId: "s" } },
397
- {
398
- version: "v0.9",
399
- updateComponents: {
400
- surfaceId: "s",
401
- components: [
402
- {
403
- id: "root",
404
- component: "ChoicePicker",
405
- variant: "multipleSelection",
406
- options: toppings,
407
- value: { path: "/picked" },
408
- },
409
- ],
410
- },
411
- },
412
- {
413
- version: "v0.9",
414
- updateDataModel: {
415
- surfaceId: "s",
416
- path: "/",
417
- value: { picked: ["basil", "onion"] },
418
- },
419
- },
420
- ]);
421
- const container = await mount(
422
- convertSurfaceToUISpec(state.get("s")!).spec,
423
- {},
424
- );
425
-
426
- expect(
427
- [...container.querySelectorAll("input")].map((input) => input.checked),
428
- ).toEqual([true, false, true]);
429
- });
430
-
431
- it("submits each group in a Form as its checked values keyed by name", async () => {
432
- const submit = vi.fn();
433
- const container = await mount(
434
- {
435
- $type: "Form",
436
- $action: { type: "submit" },
437
- children: [
438
- {
439
- $type: "CheckboxGroup",
440
- name: "toppings",
441
- options: toppings,
442
- defaultValue: ["olives"],
443
- },
444
- {
445
- $type: "CheckboxGroup",
446
- name: "sides",
447
- options: [{ label: "Fries", value: "fries" }],
448
- },
449
- { $type: "Button", label: "Order", submit: true },
450
- ],
451
- },
452
- { submit },
453
- );
454
-
455
- await act(async () => container.querySelector("input")!.click());
456
- await act(async () => container.querySelector("button")!.click());
457
-
458
- expect(submit).toHaveBeenCalledWith({
459
- payload: {
460
- type: "submit",
461
- $input: { toppings: ["basil", "olives"], sides: [] },
462
- },
463
- });
464
- });
465
- });
466
-
467
- describe("Button undo window", () => {
468
- it("keeps counting down while a bound input changes and dispatches its latest value", async () => {
469
- vi.useFakeTimers();
470
- const save = vi.fn();
471
- const container = document.createElement("div");
472
- document.body.append(container);
473
- const registry = createActionRegistry({ save });
474
- const BindingContext = A2uiBindingContext!;
475
- const Surface = () => {
476
- const [name, setName] = useState("Initial");
477
- return (
478
- <BindingContext.Provider
479
- value={{
480
- fields: new Map([["/name", { value: name, arrayValue: false }]]),
481
- update: (_path, value) => setName(String(value)),
482
- }}
483
- >
484
- <div data-aui="root">
485
- {renderGenerativeUI(
486
- [
487
- { $type: "Input", name: "/name", value: name },
488
- {
489
- $type: "Button",
490
- label: "Save",
491
- undoable: true,
492
- $action: { type: "save", name: { $field: "/name" } },
493
- },
494
- ],
495
- defaultGenerativeUILibrary,
496
- { status: "done", dispatch: registry.dispatch },
497
- )}
498
- </div>
499
- </BindingContext.Provider>
500
- );
501
- };
502
- root = createRoot(container);
503
- await act(async () => root!.render(<Surface />));
504
- const button = container.querySelector<HTMLButtonElement>("button")!;
505
- const input = container.querySelector<HTMLInputElement>("input")!;
506
- await act(async () => button.click());
507
- await act(async () => {
508
- await vi.advanceTimersByTimeAsync(500);
509
- });
510
- await act(async () => {
511
- Object.getOwnPropertyDescriptor(
512
- HTMLInputElement.prototype,
513
- "value",
514
- )!.set!.call(input, "Edited");
515
- input.dispatchEvent(new Event("input", { bubbles: true }));
516
- });
517
- await act(async () => {
518
- await vi.advanceTimersByTimeAsync(500);
519
- });
520
- expect(button.textContent).toContain("Undo 4");
521
- for (let seconds = 0; seconds < 4; seconds++) {
522
- await act(async () => {
523
- await vi.advanceTimersByTimeAsync(1_000);
524
- });
525
- }
526
- expect(save).toHaveBeenCalledWith({
527
- payload: { type: "save", name: "Edited" },
528
- });
529
- });
530
-
531
- it("counts down for five seconds before firing once", async () => {
532
- vi.useFakeTimers();
533
- const purchase = vi.fn();
534
- const container = await mount(
535
- {
536
- $type: "Button",
537
- label: "Purchase",
538
- undoable: true,
539
- $action: { type: "purchase" },
540
- },
541
- { purchase },
542
- );
543
- const button = container.querySelector<HTMLButtonElement>("button")!;
544
-
545
- await act(async () => button.click());
546
-
547
- expect(button.dataset.auiState).toBe("pending");
548
- expect(button.textContent).toContain("Undo 5");
549
- expect(button.getAttribute("aria-label")).toBe("Undo Purchase");
550
- expect(container.querySelector('[role="status"]')?.textContent).toBe(
551
- "Purchase in 5 seconds",
552
- );
553
- expect(purchase).not.toHaveBeenCalled();
554
-
555
- for (const seconds of [4, 3, 2, 1]) {
556
- await act(async () => {
557
- await vi.advanceTimersByTimeAsync(1_000);
558
- });
559
- expect(button.textContent).toContain(`Undo ${seconds}`);
560
- expect(purchase).not.toHaveBeenCalled();
561
- }
562
-
563
- await act(async () => {
564
- await vi.advanceTimersByTimeAsync(1_000);
565
- });
566
-
567
- expect(purchase).toHaveBeenCalledTimes(1);
568
- expect(purchase).toHaveBeenCalledWith({ payload: { type: "purchase" } });
569
- expect(button.dataset.auiState).toBeUndefined();
570
- expect(button.textContent).toBe("Purchase");
571
- });
572
-
573
- it("keeps the generic undo label when its text label is empty", async () => {
574
- vi.useFakeTimers();
575
- const remove = vi.fn();
576
- const container = await mount(
577
- {
578
- $type: "Button",
579
- label: "",
580
- undoable: true,
581
- $action: { type: "remove" },
582
- },
583
- { remove },
584
- );
585
- const button = container.querySelector<HTMLButtonElement>("button")!;
586
-
587
- await act(async () => button.click());
588
-
589
- expect(button.getAttribute("aria-label")).toBe("Undo");
590
- });
591
-
592
- it("cancels when clicked again", async () => {
593
- vi.useFakeTimers();
594
- const send = vi.fn();
595
- const container = await mount(
596
- {
597
- $type: "Button",
598
- label: "Send",
599
- undoable: true,
600
- $action: { type: "send" },
601
- },
602
- { send },
603
- );
604
- const button = container.querySelector<HTMLButtonElement>("button")!;
605
-
606
- await act(async () => button.click());
607
- await act(async () => button.click());
608
- await act(async () => {
609
- await vi.advanceTimersByTimeAsync(5_000);
610
- });
611
-
612
- expect(send).not.toHaveBeenCalled();
613
- expect(button.dataset.auiState).toBeUndefined();
614
- expect(button.textContent).toBe("Send");
615
- });
616
-
617
- it("cancels when Escape is pressed while focused", async () => {
618
- vi.useFakeTimers();
619
- const remove = vi.fn();
620
- const container = await mount(
621
- {
622
- $type: "Button",
623
- label: "Delete",
624
- undoable: true,
625
- $action: { type: "remove" },
626
- },
627
- { remove },
628
- );
629
- const button = container.querySelector<HTMLButtonElement>("button")!;
630
-
631
- await act(async () => button.click());
632
- button.focus();
633
- await act(async () => {
634
- button.dispatchEvent(
635
- new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
636
- );
637
- });
638
- await act(async () => {
639
- await vi.advanceTimersByTimeAsync(5_000);
640
- });
641
-
642
- expect(remove).not.toHaveBeenCalled();
643
- expect(button.dataset.auiState).toBeUndefined();
644
- expect(button.textContent).toBe("Delete");
645
- });
646
-
647
- it("clears the timer on unmount", async () => {
648
- vi.useFakeTimers();
649
- const purchase = vi.fn();
650
- const container = await mount(
651
- {
652
- $type: "Button",
653
- label: "Purchase",
654
- undoable: true,
655
- $action: { type: "purchase" },
656
- },
657
- { purchase },
658
- );
659
-
660
- await act(async () => container.querySelector("button")!.click());
661
- await act(async () => root!.unmount());
662
- root = undefined;
663
- await act(async () => {
664
- await vi.advanceTimersByTimeAsync(5_000);
665
- });
666
-
667
- expect(purchase).not.toHaveBeenCalled();
668
- });
669
-
670
- it("leaves an undoable button without a dispatcher unchanged", async () => {
671
- vi.useFakeTimers();
672
- const container = document.createElement("div");
673
- document.body.append(container);
674
- root = createRoot(container);
675
- await act(async () => {
676
- root!.render(
677
- view({
678
- $type: "Button",
679
- label: "Purchase",
680
- undoable: true,
681
- $action: { type: "purchase" },
682
- }),
683
- );
684
- });
685
- const button = container.querySelector<HTMLButtonElement>("button")!;
686
-
687
- await act(async () => button.click());
688
-
689
- expect(button.dataset.auiState).toBeUndefined();
690
- expect(button.textContent).toBe("Purchase");
691
- });
692
- });
693
-
694
- describe("Slider", () => {
695
- it("renders a labelled range with its current value and unit", async () => {
696
- const container = await mount(
697
- {
698
- $type: "Slider",
699
- name: "volume",
700
- label: "Volume",
701
- min: 0,
702
- max: 10,
703
- defaultValue: 4,
704
- unit: "%",
705
- },
706
- {},
707
- );
708
- const slider = container.querySelector<HTMLInputElement>(
709
- '[data-aui="slider"]',
710
- )!;
711
-
712
- expect(slider.type).toBe("range");
713
- expect(slider.getAttribute("aria-valuetext")).toBe("4 %");
714
- expect(
715
- container.querySelector('[data-aui="slider-value"]')?.textContent,
716
- ).toBe("4 %");
717
- });
718
-
719
- it("normalizes malformed range props before rendering", () => {
720
- function Slider() {
721
- return defaultGenerativeUILibrary.Slider!.render({
722
- $status: "done",
723
- min: Number.NaN,
724
- max: Number.POSITIVE_INFINITY,
725
- step: Number.NEGATIVE_INFINITY,
726
- defaultValue: Number.NaN,
727
- });
728
- }
729
-
730
- const markup = renderToString(<Slider />);
731
-
732
- expect(markup).toContain('min="0"');
733
- expect(markup).toContain('max="100"');
734
- expect(markup).toContain('step="1"');
735
- expect(markup).toContain('value="0"');
736
- });
737
-
738
- it("fires once with a number for a committed adjustment", async () => {
739
- const setVolume = vi.fn();
740
- const container = await mount(
741
- {
742
- $type: "Slider",
743
- name: "volume",
744
- min: 0,
745
- max: 10,
746
- $action: { type: "set_volume" },
747
- },
748
- { set_volume: setVolume },
749
- );
750
- const slider = container.querySelector<HTMLInputElement>(
751
- '[data-aui="slider"]',
752
- )!;
753
-
754
- await act(async () => {
755
- slider.dispatchEvent(new Event("pointerdown", { bubbles: true }));
756
- slider.value = "7";
757
- slider.dispatchEvent(new Event("input", { bubbles: true }));
758
- slider.dispatchEvent(new Event("change", { bubbles: true }));
759
- slider.dispatchEvent(new Event("pointerup", { bubbles: true }));
760
- slider.dispatchEvent(new Event("change", { bubbles: true }));
761
- });
762
-
763
- expect(setVolume).toHaveBeenCalledTimes(1);
764
- expect(setVolume).toHaveBeenCalledWith({
765
- payload: { type: "set_volume", $input: 7 },
766
- });
767
- });
768
-
769
- it("does not dispatch when a pointer interaction keeps the initial value", async () => {
770
- const setVolume = vi.fn();
771
- const container = await mount(
772
- {
773
- $type: "Slider",
774
- min: 0,
775
- max: 10,
776
- defaultValue: 4,
777
- $action: { type: "set_volume" },
778
- },
779
- { set_volume: setVolume },
780
- );
781
- const slider = container.querySelector<HTMLInputElement>(
782
- '[data-aui="slider"]',
783
- )!;
784
-
785
- await act(async () => {
786
- slider.dispatchEvent(new Event("pointerdown", { bubbles: true }));
787
- slider.dispatchEvent(new Event("pointerup", { bubbles: true }));
788
- });
789
-
790
- expect(setVolume).not.toHaveBeenCalled();
791
- });
792
-
793
- it("collects as a number in a Form", async () => {
794
- const submit = vi.fn();
795
- const container = await mount(
796
- {
797
- $type: "Form",
798
- $action: { type: "submit" },
799
- children: [
800
- {
801
- $type: "Slider",
802
- name: "volume",
803
- min: 0,
804
- max: 10,
805
- defaultValue: 3,
806
- },
807
- { $type: "Button", label: "Save", submit: true },
808
- ],
809
- },
810
- { submit },
811
- );
812
-
813
- await act(async () => container.querySelector("button")!.click());
814
-
815
- expect(submit).toHaveBeenCalledWith({
816
- payload: { type: "submit", $input: { volume: 3 } },
817
- });
818
- });
819
-
820
- it("restores an answered numeric value", () => {
821
- function Slider() {
822
- return defaultGenerativeUILibrary.Slider!.render({
823
- $status: "done",
824
- name: "volume",
825
- min: 0,
826
- max: 10,
827
- defaultValue: 3,
828
- });
829
- }
830
-
831
- const markup = renderToString(
832
- <AnsweredValuesProvider values={{ volume: 7 }}>
833
- <Slider />
834
- </AnsweredValuesProvider>,
835
- );
836
-
837
- expect(markup).toContain('value="7"');
838
- });
839
- });
840
-
841
- describe("Checkbox and option descriptions", () => {
842
- it("renders a switch role and preserves its boolean form value", async () => {
843
- const submit = vi.fn();
844
- const container = await mount(
845
- {
846
- $type: "Form",
847
- $action: { type: "submit" },
848
- children: [
849
- {
850
- $type: "Checkbox",
851
- name: "enabled",
852
- label: "Enabled",
853
- variant: "switch",
854
- defaultChecked: true,
855
- },
856
- { $type: "Button", label: "Save", submit: true },
857
- ],
858
- },
859
- { submit },
860
- );
861
-
862
- expect(container.querySelector('input[role="switch"]')).not.toBeNull();
863
- await act(async () => container.querySelector("button")!.click());
864
- expect(submit).toHaveBeenCalledWith({
865
- payload: { type: "submit", $input: { enabled: true } },
866
- });
867
- });
868
-
869
- it.each(["RadioGroup", "CheckboxGroup"])(
870
- "renders an option description for %s",
871
- async ($type) => {
872
- const container = await mount(
873
- {
874
- $type,
875
- options: [
876
- {
877
- label: "Free",
878
- description: "For personal projects",
879
- value: "free",
880
- },
881
- ],
882
- },
883
- {},
884
- );
885
-
886
- expect(
887
- container.querySelector('[data-aui="option-description"]')?.textContent,
888
- ).toBe("For personal projects");
889
- },
890
- );
891
- });
892
-
893
- describe("$field references", () => {
894
- it("resolve from the enclosing form, or else the generative UI root, at the moment the action fires", async () => {
895
- const save = vi.fn();
896
- const container = await mount(
897
- {
898
- $type: "Col",
899
- children: [
900
- { $type: "Input", name: "title", defaultValue: "Draft" },
901
- {
902
- $type: "Select",
903
- name: "size",
904
- options: [
905
- { label: "Medium", value: "m" },
906
- { label: "Large", value: "l" },
907
- ],
908
- defaultValue: "l",
909
- },
910
- {
911
- $type: "Form",
912
- $action: { type: "submit" },
913
- children: [
914
- { $type: "Input", name: "note" },
915
- {
916
- $type: "Button",
917
- label: "Save note",
918
- $action: {
919
- type: "save",
920
- note: { $field: "note" },
921
- title: { $field: "title" },
922
- },
923
- },
924
- ],
925
- },
926
- {
927
- $type: "Button",
928
- label: "Save",
929
- $action: {
930
- type: "save",
931
- fields: [{ $field: "title" }, { $field: "missing" }],
932
- size: { $field: "size" },
933
- note: { $field: "note" },
934
- missing: { $field: "missing" },
935
- },
936
- },
937
- ],
938
- },
939
- { save },
940
- );
941
- const [title, note] = container.querySelectorAll("input");
942
- const [saveNote, saveAll] = container.querySelectorAll("button");
943
-
944
- expect(title!.value).toBe("Draft");
945
- expect(container.querySelector("select")!.value).toBe("l");
946
- title!.value = "Final";
947
- note!.value = "Ship it";
948
- await act(async () => saveNote!.click());
949
- await act(async () => saveAll!.click());
950
-
951
- expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
952
- { type: "save", note: "Ship it" },
953
- { type: "save", fields: ["Final"], size: "l", note: "Ship it" },
954
- ]);
955
- });
956
- it("never read a control inside a nested generative UI root", async () => {
957
- const save = vi.fn();
958
- const container = await mount(
959
- {
960
- $type: "Col",
961
- children: [
962
- { $type: "Input", name: "note", defaultValue: "outer" },
963
- {
964
- $type: "Button",
965
- label: "Save",
966
- $action: { type: "save", note: { $field: "note" } },
967
- },
968
- ],
969
- },
970
- { save },
971
- );
972
- const nested = document.createElement("div");
973
- nested.setAttribute("data-aui", "root");
974
- nested.innerHTML = '<input name="note" value="inner">';
975
- container.querySelector('[data-aui="root"]')!.append(nested);
976
-
977
- await act(async () => container.querySelector("button")!.click());
978
-
979
- expect(save).toHaveBeenCalledWith({
980
- payload: { type: "save", note: "outer" },
981
- });
982
- });
983
-
984
- it("never read a host form around the generative UI root", async () => {
985
- const save = vi.fn();
986
- const hostForm = document.createElement("form");
987
- hostForm.innerHTML = '<input name="secret" value="host">';
988
- document.body.append(hostForm);
989
- const container = await mount(
990
- {
991
- $type: "Button",
992
- label: "Save",
993
- $action: { type: "save", secret: { $field: "secret" } },
994
- },
995
- { save },
996
- hostForm,
997
- );
998
-
999
- await act(async () => container.querySelector("button")!.click());
1000
-
1001
- expect(save).toHaveBeenCalledWith({ payload: { type: "save" } });
1002
- });
1003
- it("read nothing outside a vocabulary form when the tree has no generative UI root", async () => {
1004
- const save = vi.fn();
1005
- const registry = createActionRegistry({ save });
1006
- const hostForm = document.createElement("form");
1007
- hostForm.innerHTML = '<input name="secret" value="host">';
1008
- const inHost = document.createElement("div");
1009
- const standalone = document.createElement("div");
1010
- hostForm.append(inHost);
1011
- document.body.append(hostForm, standalone);
1012
- const roots = [createRoot(inHost), createRoot(standalone)];
1013
- await act(async () => {
1014
- roots[0]!.render(
1015
- renderGenerativeUI(
1016
- {
1017
- $type: "Button",
1018
- label: "Save",
1019
- $action: { type: "save", secret: { $field: "secret" } },
1020
- },
1021
- defaultGenerativeUILibrary,
1022
- { status: "done", dispatch: registry.dispatch },
1023
- ),
1024
- );
1025
- roots[1]!.render(
1026
- renderGenerativeUI(
1027
- {
1028
- $type: "Form",
1029
- children: [
1030
- { $type: "Input", name: "note", defaultValue: "kept" },
1031
- {
1032
- $type: "Button",
1033
- label: "Save note",
1034
- $action: { type: "save", note: { $field: "note" } },
1035
- },
1036
- ],
1037
- },
1038
- defaultGenerativeUILibrary,
1039
- { status: "done", dispatch: registry.dispatch },
1040
- ),
1041
- );
1042
- });
1043
-
1044
- await act(async () => inHost.querySelector("button")!.click());
1045
- await act(async () => standalone.querySelector("button")!.click());
1046
- await act(async () => roots.forEach((r) => r.unmount()));
1047
-
1048
- expect(save.mock.calls.map(([{ payload }]) => payload)).toEqual([
1049
- { type: "save" },
1050
- { type: "save", note: "kept" },
1051
- ]);
1052
- });
1053
- });
1054
-
1055
- describe("A2UI two-way binding", () => {
1056
- it("renders bound input values and sends the values the user entered as the action context", async () => {
1057
- const { state } = applyA2uiOperations(new Map(), [
1058
- { version: "v0.9", createSurface: { surfaceId: "s" } },
1059
- {
1060
- version: "v0.9",
1061
- updateComponents: {
1062
- surfaceId: "s",
1063
- components: [
1064
- {
1065
- id: "root",
1066
- component: "Column",
1067
- children: ["name", "plan", "send"],
1068
- },
1069
- {
1070
- id: "name",
1071
- component: "TextField",
1072
- label: "Name",
1073
- value: { path: "/form/name" },
1074
- },
1075
- {
1076
- id: "plan",
1077
- component: "ChoicePicker",
1078
- options: [
1079
- { label: "Free", value: "free" },
1080
- { label: "Pro", value: "pro" },
1081
- ],
1082
- value: { path: "/form/plan" },
1083
- },
1084
- {
1085
- id: "send",
1086
- component: "Button",
1087
- label: "Send",
1088
- action: {
1089
- event: {
1090
- name: "send",
1091
- context: {
1092
- name: { path: "/form/name" },
1093
- form: { path: "/form" },
1094
- },
1095
- },
1096
- },
1097
- },
1098
- ],
1099
- },
1100
- },
1101
- {
1102
- version: "v0.9",
1103
- updateDataModel: {
1104
- surfaceId: "s",
1105
- path: "/",
1106
- value: { form: { name: "Ada", plan: ["free"], id: 7 } },
1107
- },
1108
- },
1109
- ]);
1110
- const send = vi.fn();
1111
- const container = await mount(
1112
- convertSurfaceToUISpec(state.get("s")!).spec,
1113
- { "a2ui:action": send },
1114
- );
1115
- const name = container.querySelector<HTMLInputElement>(
1116
- 'input[data-aui="input"]',
1117
- )!;
1118
- const [free, pro] = container.querySelectorAll<HTMLInputElement>(
1119
- 'input[type="radio"]',
1120
- );
1121
-
1122
- expect(name.value).toBe("Ada");
1123
- expect(free!.checked).toBe(true);
1124
- name.value = "Grace";
1125
- await act(async () => pro!.click());
1126
- await act(async () => container.querySelector("button")!.click());
1127
-
1128
- expect(send).toHaveBeenCalledWith({
1129
- payload: {
1130
- type: "a2ui:action",
1131
- name: "send",
1132
- surfaceId: "s",
1133
- sourceComponentId: "send",
1134
- context: {
1135
- name: "Grace",
1136
- form: { name: "Grace", plan: ["pro"], id: 7 },
1137
- },
1138
- },
1139
- });
1140
- });
1141
-
1142
- it("sends a userMessage bound to an input as the text the user entered", async () => {
1143
- const { state } = applyA2uiOperations(new Map(), [
1144
- {
1145
- version: "v1.0",
1146
- createSurface: {
1147
- surfaceId: "s",
1148
- dataModel: { note: "Draft" },
1149
- components: [
1150
- { id: "root", component: "Column", children: ["note", "send"] },
1151
- { id: "note", component: "TextField", value: { path: "/note" } },
1152
- {
1153
- id: "send",
1154
- component: "Button",
1155
- label: "Send",
1156
- action: {
1157
- event: { name: "send", userMessage: { path: "/note" } },
1158
- },
1159
- },
1160
- ],
1161
- },
1162
- },
1163
- ]);
1164
- const send = vi.fn();
1165
- const container = await mount(
1166
- convertSurfaceToUISpec(state.get("s")!).spec,
1167
- { "a2ui:action": send },
1168
- );
1169
-
1170
- container.querySelector<HTMLInputElement>(
1171
- 'input[data-aui="input"]',
1172
- )!.value = "Ship it";
1173
- await act(async () => container.querySelector("button")!.click());
1174
-
1175
- expect(send).toHaveBeenCalledWith({
1176
- payload: {
1177
- type: "a2ui:action",
1178
- name: "send",
1179
- surfaceId: "s",
1180
- sourceComponentId: "send",
1181
- userMessage: "Ship it",
1182
- },
1183
- });
1184
- });
1185
-
1186
- it("sends the agent's values when an overridden Button dispatches $action itself", async () => {
1187
- const { state } = applyA2uiOperations(new Map(), [
1188
- { version: "v0.9", createSurface: { surfaceId: "s" } },
1189
- {
1190
- version: "v0.9",
1191
- updateComponents: {
1192
- surfaceId: "s",
1193
- components: [
1194
- { id: "root", component: "Column", children: ["email", "send"] },
1195
- {
1196
- id: "email",
1197
- component: "TextField",
1198
- value: { path: "/form/email" },
1199
- },
1200
- {
1201
- id: "send",
1202
- component: "Button",
1203
- label: "Send",
1204
- action: {
1205
- event: {
1206
- name: "send",
1207
- context: {
1208
- email: { path: "/form/email" },
1209
- form: { path: "/form" },
1210
- },
1211
- },
1212
- },
1213
- },
1214
- ],
1215
- },
1216
- },
1217
- {
1218
- version: "v0.9",
1219
- updateDataModel: {
1220
- surfaceId: "s",
1221
- path: "/",
1222
- value: { form: { email: "old@example.com" } },
1223
- },
1224
- },
1225
- ]);
1226
- const button = defaultGenerativeUILibrary.Button!;
1227
- const library: GenerativeUILibrary = {
1228
- ...defaultGenerativeUILibrary,
1229
- Button: {
1230
- properties: button.properties,
1231
- description: button.description,
1232
- render: ({ label, $action, $dispatch }) => (
1233
- <button
1234
- type="button"
1235
- onClick={() => ($action ? $dispatch?.($action) : undefined)}
1236
- >
1237
- {label}
1238
- </button>
1239
- ),
1240
- },
1241
- };
1242
- const send = vi.fn();
1243
- const registry = createActionRegistry({ "a2ui:action": send });
1244
- const container = document.createElement("div");
1245
- document.body.append(container);
1246
- root = createRoot(container);
1247
- await act(async () => {
1248
- root!.render(
1249
- <div data-aui="root">
1250
- {renderGenerativeUI(
1251
- convertSurfaceToUISpec(state.get("s")!).spec,
1252
- library,
1253
- { status: "done", dispatch: registry.dispatch },
1254
- )}
1255
- </div>,
1256
- );
1257
- });
1258
-
1259
- container.querySelector<HTMLInputElement>(
1260
- 'input[data-aui="input"]',
1261
- )!.value = "new@example.com";
1262
- await act(async () => container.querySelector("button")!.click());
1263
-
1264
- expect(send).toHaveBeenCalledWith({
1265
- payload: {
1266
- type: "a2ui:action",
1267
- name: "send",
1268
- surfaceId: "s",
1269
- sourceComponentId: "send",
1270
- context: {
1271
- email: "old@example.com",
1272
- form: { email: "old@example.com" },
1273
- },
1274
- },
1275
- });
1276
- });
1277
-
1278
- it("keeps what the user entered until the agent updates the bound value", async () => {
1279
- const options = [
1280
- { label: "Free", value: "free" },
1281
- { label: "Pro", value: "pro" },
1282
- { label: "Team", value: "team" },
1283
- ];
1284
- const surface = (data: Record<string, unknown>) =>
1285
- convertSurfaceToUISpec(
1286
- applyA2uiOperations(new Map(), [
1287
- { version: "v0.9", createSurface: { surfaceId: "s" } },
1288
- {
1289
- version: "v0.9",
1290
- updateComponents: {
1291
- surfaceId: "s",
1292
- components: [
1293
- {
1294
- id: "root",
1295
- component: "Column",
1296
- children: ["name", "agree", "plan", "size", "extras", "day"],
1297
- },
1298
- {
1299
- id: "name",
1300
- component: "TextField",
1301
- value: { path: "/name" },
1302
- },
1303
- {
1304
- id: "agree",
1305
- component: "CheckBox",
1306
- value: { path: "/agree" },
1307
- },
1308
- {
1309
- id: "plan",
1310
- component: "ChoicePicker",
1311
- options,
1312
- value: { path: "/plan" },
1313
- },
1314
- {
1315
- id: "size",
1316
- component: "ChoicePicker",
1317
- displayStyle: "chips",
1318
- options,
1319
- value: { path: "/size" },
1320
- },
1321
- {
1322
- id: "extras",
1323
- component: "ChoicePicker",
1324
- variant: "multipleSelection",
1325
- options,
1326
- value: { path: "/extras" },
1327
- },
1328
- {
1329
- id: "day",
1330
- component: "DateTimeInput",
1331
- value: { path: "/day" },
1332
- },
1333
- ],
1334
- },
1335
- },
1336
- {
1337
- version: "v0.9",
1338
- updateDataModel: { surfaceId: "s", path: "/", value: data },
1339
- },
1340
- ]).state.get("s")!,
1341
- ).spec;
1342
- const initial = {
1343
- name: "Ada",
1344
- agree: false,
1345
- plan: ["free"],
1346
- size: ["free"],
1347
- extras: [],
1348
- day: "2026-01-02",
1349
- };
1350
- const container = await mount(surface(initial), {});
1351
- const read = () => ({
1352
- name: container.querySelector<HTMLInputElement>(
1353
- 'input[data-aui="input"]',
1354
- )!.value,
1355
- agree: container.querySelector<HTMLInputElement>(
1356
- 'label[data-aui="checkbox"] input',
1357
- )!.checked,
1358
- plan: container.querySelector<HTMLInputElement>(
1359
- 'input[type="radio"]:checked',
1360
- )?.value,
1361
- size: container.querySelector<HTMLInputElement>(
1362
- 'input[data-aui-field-name="/size"]:checked',
1363
- )?.value,
1364
- extras: [
1365
- ...container.querySelectorAll<HTMLInputElement>(
1366
- 'fieldset[data-aui="checkboxgroup"] input:checked',
1367
- ),
1368
- ].map((input) => input.value),
1369
- day: container.querySelector<HTMLInputElement>('input[type="date"]')!
1370
- .value,
1371
- });
1372
- const edited = {
1373
- name: "Grace",
1374
- agree: false,
1375
- plan: "pro",
1376
- size: "pro",
1377
- extras: ["pro"],
1378
- day: "2026-03-04",
1379
- };
1380
- container.querySelector<HTMLInputElement>(
1381
- 'input[data-aui="input"]',
1382
- )!.value = edited.name;
1383
- container.querySelectorAll<HTMLInputElement>(
1384
- 'input[type="radio"]',
1385
- )[1]!.checked = true;
1386
- container.querySelector<HTMLInputElement>(
1387
- 'input[data-aui-field-name="/size"][value="pro"]',
1388
- )!.checked = true;
1389
- container.querySelectorAll<HTMLInputElement>(
1390
- 'fieldset[data-aui="checkboxgroup"] input',
1391
- )[1]!.checked = true;
1392
- container.querySelector<HTMLInputElement>('input[type="date"]')!.value =
1393
- edited.day;
1394
-
1395
- await act(async () => root!.render(view(surface(initial))));
1396
- expect(read()).toEqual(edited);
1397
-
1398
- await act(async () =>
1399
- root!.render(
1400
- view(
1401
- surface({
1402
- name: "Hopper",
1403
- agree: true,
1404
- plan: ["team"],
1405
- size: ["team"],
1406
- extras: ["team"],
1407
- day: "2026-05-06",
1408
- }),
1409
- ),
1410
- ),
1411
- );
1412
- expect(read()).toEqual({
1413
- name: "Hopper",
1414
- agree: true,
1415
- plan: "team",
1416
- size: "team",
1417
- extras: ["team"],
1418
- day: "2026-05-06",
1419
- });
1420
- });
1421
- });
1422
-
1423
- describe.each([
1424
- { version: "v0.9", live: false },
1425
- { version: "v0.9", live: true },
1426
- { version: "v0.9.1", live: false },
1427
- { version: "v0.9.1", live: true },
1428
- ])("A2UI input contracts ($version, live: $live)", ({ version, live }) => {
1429
- const mountSurface = async (
1430
- field: Record<string, unknown>,
1431
- initial: unknown,
1432
- previewPath = "/form/value",
1433
- ) => {
1434
- const send = vi.fn();
1435
- const registry = createActionRegistry({ "a2ui:action": send });
1436
- const container = document.createElement("div");
1437
- document.body.append(container);
1438
- root = createRoot(container);
1439
- const render = async (value: unknown) => {
1440
- const operations = [
1441
- { version, createSurface: { surfaceId: "s" } },
1442
- {
1443
- version,
1444
- updateComponents: {
1445
- surfaceId: "s",
1446
- components: [
1447
- {
1448
- id: "root",
1449
- component: "Column",
1450
- children: ["field", "preview", "send"],
1451
- },
1452
- {
1453
- id: "field",
1454
- label: "Value",
1455
- ...field,
1456
- value: { path: "/form/value" },
1457
- },
1458
- { id: "preview", component: "Text", text: { path: previewPath } },
1459
- {
1460
- id: "send",
1461
- component: "Button",
1462
- label: "Send",
1463
- action: {
1464
- event: {
1465
- name: "send",
1466
- context: {
1467
- value: { path: "/form/value" },
1468
- form: { path: "/form" },
1469
- },
1470
- },
1471
- },
1472
- },
1473
- ],
1474
- },
1475
- },
1476
- {
1477
- version,
1478
- updateDataModel: {
1479
- surfaceId: "s",
1480
- path: "/",
1481
- value: { form: value === undefined ? {} : { value } },
1482
- },
1483
- },
1484
- ];
1485
- const surface = applyA2uiOperations(new Map(), operations).state.get(
1486
- "s",
1487
- )!;
1488
- const converted = convertSurfaceToUISpec(surface);
1489
- expect(converted.warnings).toEqual([]);
1490
- await act(async () =>
1491
- root!.render(
1492
- live ? (
1493
- <div data-aui="root">
1494
- <A2uiPresentRenderer
1495
- surfaceId="s"
1496
- operations={operations}
1497
- fallback={converted.spec}
1498
- library={defaultGenerativeUILibrary}
1499
- status="done"
1500
- dispatch={registry.dispatch}
1501
- />
1502
- </div>
1503
- ) : (
1504
- view(converted.spec, registry.dispatch)
1505
- ),
1506
- ),
1507
- );
1508
- };
1509
- await render(initial);
1510
- const submit = async (value: unknown) => {
1511
- await act(async () => container.querySelector("button")!.click());
1512
- expect(send).toHaveBeenLastCalledWith({
1513
- payload: {
1514
- type: "a2ui:action",
1515
- name: "send",
1516
- surfaceId: "s",
1517
- sourceComponentId: "send",
1518
- context: { value, form: { value } },
1519
- },
1520
- });
1521
- };
1522
- return { container, render, submit };
1523
- };
1524
-
1525
- it.each([
1526
- {
1527
- title: "obscured text",
1528
- field: { component: "TextField", variant: "obscured" },
1529
- initial: "s3cret",
1530
- edited: " new secret ",
1531
- type: "password",
1532
- },
1533
- {
1534
- title: "number text",
1535
- field: { component: "TextField", variant: "number" },
1536
- initial: "3",
1537
- edited: "12.5",
1538
- type: "number",
1539
- },
1540
- {
1541
- title: "floating date and time",
1542
- field: { component: "DateTimeInput", enableDate: true, enableTime: true },
1543
- initial: "2025-12-15T17:00",
1544
- edited: "2025-12-16T08:30",
1545
- type: "datetime-local",
1546
- },
1547
- {
1548
- title: "floating date and time with seconds",
1549
- field: { component: "DateTimeInput", enableDate: true, enableTime: true },
1550
- initial: "2025-12-15T17:00:00",
1551
- displayed: "2025-12-15T17:00",
1552
- edited: "2025-12-16T08:30",
1553
- type: "datetime-local",
1554
- },
1555
- {
1556
- title: "timestamp in date mode",
1557
- field: { component: "DateTimeInput", enableDate: true },
1558
- initial: "2025-12-15T17:00:00Z",
1559
- edited: "2025-12-16T08:30:00Z",
1560
- type: "text",
1561
- },
1562
- {
1563
- title: "time only",
1564
- field: { component: "DateTimeInput", enableTime: true },
1565
- initial: "17:00:00",
1566
- displayed: "17:00",
1567
- edited: "08:30:45",
1568
- type: "time",
1569
- },
1570
- {
1571
- title: "fractional time",
1572
- field: { component: "DateTimeInput", enableTime: true },
1573
- initial: "17:00:00.000",
1574
- displayed: "17:00",
1575
- edited: "08:30",
1576
- type: "time",
1577
- },
1578
- {
1579
- title: "omitted temporal flags",
1580
- field: { component: "DateTimeInput" },
1581
- initial: "2025-12-15",
1582
- edited: "2025-12-16",
1583
- type: "date",
1584
- },
1585
- {
1586
- title: "disabled temporal flags",
1587
- field: {
1588
- component: "DateTimeInput",
1589
- enableDate: false,
1590
- enableTime: false,
1591
- },
1592
- initial: "",
1593
- edited: "2025-12-16",
1594
- type: "date",
1595
- },
1596
- {
1597
- title: "date only",
1598
- field: { component: "DateTimeInput", enableDate: true },
1599
- initial: "2025-12-15",
1600
- edited: "2025-12-16",
1601
- type: "date",
1602
- },
1603
- {
1604
- title: "unresolved obscured text",
1605
- field: { component: "TextField", variant: "obscured" },
1606
- initial: undefined,
1607
- edited: "s3cret",
1608
- type: "password",
1609
- },
1610
- {
1611
- title: "unresolved number text",
1612
- field: { component: "TextField", variant: "number" },
1613
- initial: undefined,
1614
- edited: "12.5",
1615
- type: "number",
1616
- },
1617
- {
1618
- title: "unresolved time",
1619
- field: { component: "DateTimeInput", enableTime: true },
1620
- initial: undefined,
1621
- edited: "08:30",
1622
- type: "time",
1623
- },
1624
- ])(
1625
- "preserves untouched, edited, and cleared $title values",
1626
- async ({ field, initial, displayed, edited, type }) => {
1627
- const { container, render, submit } = await mountSurface(field, initial);
1628
- const input = () =>
1629
- container.querySelector<HTMLInputElement>('input[name="/form/value"]')!;
1630
- const preview = () =>
1631
- container.querySelector('[data-aui="markdown"]')?.textContent ?? "";
1632
- const edit = async (value: string) => {
1633
- await act(async () => {
1634
- const control = input();
1635
- Object.getOwnPropertyDescriptor(
1636
- HTMLInputElement.prototype,
1637
- "value",
1638
- )!.set!.call(control, value);
1639
- control.dispatchEvent(new Event("input", { bubbles: true }));
1640
- });
1641
- };
1642
-
1643
- expect(input().type).toBe(type);
1644
- expect(input().getAttribute("aria-label")).toBe("Value");
1645
- expect(input().value).toBe(displayed ?? initial ?? "");
1646
- if (type === "number") expect(input().step).toBe("any");
1647
- await submit(initial ?? "");
1648
-
1649
- await edit(edited);
1650
- expect(input().value).toBe(edited);
1651
- if (type === "number") expect(input().validity.stepMismatch).toBe(false);
1652
- if (live) expect(preview()).toBe(edited);
1653
- await submit(edited);
1654
-
1655
- await edit("");
1656
- expect(input().type).toBe(type);
1657
- expect(input().value).toBe("");
1658
- if (live) expect(preview()).toBe("");
1659
- await submit("");
1660
-
1661
- await render(initial);
1662
- expect(input().value).toBe("");
1663
- await submit("");
1664
- await render(edited);
1665
- expect(input().value).toBe(edited);
1666
- expect(preview()).toBe(edited);
1667
- await submit(edited);
1668
- },
1669
- );
1670
-
1671
- it("preserves an instant offset in the A2UI button context", async () => {
1672
- const initial = "2025-12-15T17:00:00+02:00";
1673
- const { container, submit } = await mountSurface(
1674
- { component: "DateTimeInput", enableDate: true, enableTime: true },
1675
- initial,
1676
- );
1677
- const input = container.querySelector<HTMLInputElement>(
1678
- 'input[name="/form/value"]',
1679
- )!;
1680
- expect(input.type).toBe("datetime-local");
1681
- expect(input.value).toBe(
1682
- normalizeTemporalInputValue(toLocalDateTime(initial)),
1683
- );
1684
- await submit(initial);
1685
-
1686
- await act(async () => {
1687
- Object.getOwnPropertyDescriptor(
1688
- HTMLInputElement.prototype,
1689
- "value",
1690
- )!.set!.call(input, "2025-12-16T09:30");
1691
- input.dispatchEvent(new Event("input", { bubbles: true }));
1692
- });
1693
- expect(input.value).toBe("2025-12-16T09:30");
1694
- await submit(fromLocalDateTime("2025-12-16T09:30", initial));
1695
- });
1696
-
1697
- it.each([undefined, "Pick one"])(
1698
- "keeps chips empty until selected with placeholder %s",
1699
- async (placeholder) => {
1700
- const { container, render, submit } = await mountSurface(
1701
- {
1702
- component: "ChoicePicker",
1703
- displayStyle: "chips",
1704
- placeholder,
1705
- options: [
1706
- { label: "A", value: "a" },
1707
- { label: "B", value: "b" },
1708
- ],
1709
- },
1710
- [],
1711
- "/form/value/0",
1712
- );
1713
- const radios = () => [
1714
- ...container.querySelectorAll<HTMLInputElement>('input[type="radio"]'),
1715
- ];
1716
- const selected = () =>
1717
- radios()
1718
- .filter((input) => input.checked)
1719
- .map((input) => input.value);
1720
-
1721
- await submit([]);
1722
- expect(container.querySelector("select")).toBeNull();
1723
- expect(radios()).toHaveLength(2);
1724
- expect(
1725
- container.querySelector("fieldset")?.getAttribute("aria-label"),
1726
- ).toBe("Value");
1727
- expect(selected()).toEqual([]);
1728
-
1729
- await act(async () => radios()[1]!.click());
1730
- expect(selected()).toEqual(["b"]);
1731
- if (live)
1732
- expect(
1733
- container.querySelector('[data-aui="markdown"]')?.textContent,
1734
- ).toBe("b");
1735
- await submit(["b"]);
1736
- await render([]);
1737
- expect(selected()).toEqual(["b"]);
1738
- await submit(["b"]);
1739
- await render(["a"]);
1740
- expect(selected()).toEqual(["a"]);
1741
- await submit(["a"]);
1742
- await render([]);
1743
- expect(selected()).toEqual([]);
1744
- await submit([]);
1745
- },
1746
- );
1747
- });