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

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 (212) 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 +363 -85
  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 +3 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +4 -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 +96 -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 +43 -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 +244 -20
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +24 -1
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/slack.d.ts +1 -1
  77. package/dist/slack.d.ts.map +1 -1
  78. package/dist/teams/constants.d.ts +1 -1
  79. package/dist/teams/constants.d.ts.map +1 -1
  80. package/dist/teams/constants.js +1 -1
  81. package/dist/teams/constants.js.map +1 -1
  82. package/dist/teams/decodeSubmitData.d.ts +4 -0
  83. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  84. package/dist/teams/decodeSubmitData.js +29 -3
  85. package/dist/teams/decodeSubmitData.js.map +1 -1
  86. package/dist/teams/temporalId.d.ts +5 -0
  87. package/dist/teams/temporalId.d.ts.map +1 -0
  88. package/dist/teams/temporalId.js +28 -0
  89. package/dist/teams/temporalId.js.map +1 -0
  90. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  91. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  92. package/dist/teams/toAdaptiveCard.js +136 -18
  93. package/dist/teams/toAdaptiveCard.js.map +1 -1
  94. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  95. package/dist/teams/toTeamsAttachments.js.map +1 -1
  96. package/dist/teams/types.d.ts +23 -2
  97. package/dist/teams/types.d.ts.map +1 -1
  98. package/dist/teams.d.ts +1 -1
  99. package/dist/teams.d.ts.map +1 -1
  100. package/dist/temporal.d.ts +33 -0
  101. package/dist/temporal.d.ts.map +1 -0
  102. package/dist/temporal.js +152 -0
  103. package/dist/temporal.js.map +1 -0
  104. package/dist/vocabulary/alert.d.ts +9 -2
  105. package/dist/vocabulary/alert.d.ts.map +1 -1
  106. package/dist/vocabulary/alert.js +116 -19
  107. package/dist/vocabulary/alert.js.map +1 -1
  108. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  109. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  110. package/dist/vocabulary/collectFormValues.js +15 -4
  111. package/dist/vocabulary/collectFormValues.js.map +1 -1
  112. package/dist/vocabulary/data.d.ts +22 -5
  113. package/dist/vocabulary/data.d.ts.map +1 -1
  114. package/dist/vocabulary/data.js +278 -83
  115. package/dist/vocabulary/data.js.map +1 -1
  116. package/dist/vocabulary/dispatch.d.ts +3 -2
  117. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  118. package/dist/vocabulary/dispatch.js +33 -4
  119. package/dist/vocabulary/dispatch.js.map +1 -1
  120. package/dist/vocabulary/fact.d.ts +8 -1
  121. package/dist/vocabulary/fact.d.ts.map +1 -1
  122. package/dist/vocabulary/fact.js +37 -11
  123. package/dist/vocabulary/fact.js.map +1 -1
  124. package/dist/vocabulary/form.d.ts.map +1 -1
  125. package/dist/vocabulary/form.js +3 -3
  126. package/dist/vocabulary/form.js.map +1 -1
  127. package/dist/vocabulary/formatValue.d.ts +7 -0
  128. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  129. package/dist/vocabulary/formatValue.js +76 -0
  130. package/dist/vocabulary/formatValue.js.map +1 -0
  131. package/dist/vocabulary/icon.d.ts +1 -1
  132. package/dist/vocabulary/interactive.d.ts +143 -8
  133. package/dist/vocabulary/interactive.d.ts.map +1 -1
  134. package/dist/vocabulary/interactive.js +458 -107
  135. package/dist/vocabulary/interactive.js.map +1 -1
  136. package/dist/vocabulary/layout.d.ts.map +1 -1
  137. package/dist/vocabulary/layout.js +4 -4
  138. package/dist/vocabulary/layout.js.map +1 -1
  139. package/dist/vocabulary/list.js +2 -2
  140. package/dist/vocabulary/list.js.map +1 -1
  141. package/dist/vocabulary/text.d.ts +1 -1
  142. package/package.json +8 -5
  143. package/src/JSONGenerativeUI.client.tsx +84 -40
  144. package/src/JSONGenerativeUI.test.tsx +964 -68
  145. package/src/RadioGroupScope.server.test.tsx +24 -0
  146. package/src/RadioGroupScope.tsx +34 -0
  147. package/src/a2ui/BindingContext.ts +26 -0
  148. package/src/a2ui/PresentRenderer.tsx +83 -0
  149. package/src/a2ui/convert.test.ts +2054 -229
  150. package/src/a2ui/convert.ts +665 -134
  151. package/src/a2ui/dataModel.ts +69 -0
  152. package/src/a2ui/reducer.test.ts +35 -0
  153. package/src/a2ui/reducer.ts +5 -4
  154. package/src/a2ui/types.ts +9 -2
  155. package/src/a2ui/valueFunctions.test.ts +271 -0
  156. package/src/a2ui/valueFunctions.ts +523 -0
  157. package/src/actionRegistry.ts +1 -1
  158. package/src/answeredValues.test.ts +33 -0
  159. package/src/answeredValues.tsx +70 -0
  160. package/src/constants.ts +5 -0
  161. package/src/fieldReferences.test.ts +66 -0
  162. package/src/fieldReferences.ts +83 -0
  163. package/src/generativeUIToJSX.test.ts +18 -0
  164. package/src/generativeUIToJSX.ts +4 -6
  165. package/src/index.server.test.ts +106 -0
  166. package/src/ir.ts +1 -1
  167. package/src/renderGenerativeUI.test.tsx +14 -5
  168. package/src/renderGenerativeUI.tsx +30 -1
  169. package/src/slack/a2uiSlack.test.ts +198 -0
  170. package/src/slack/constants.ts +3 -0
  171. package/src/slack/datePicker.test.ts +612 -0
  172. package/src/slack/decodeBlockAction.test.ts +98 -0
  173. package/src/slack/decodeBlockAction.ts +182 -16
  174. package/src/slack/fromSlackBlocks.test.ts +222 -3
  175. package/src/slack/fromSlackBlocks.ts +82 -2
  176. package/src/slack/toSlackBlocks.test.ts +682 -3
  177. package/src/slack/toSlackBlocks.ts +463 -25
  178. package/src/slack/types.ts +28 -0
  179. package/src/slack.ts +2 -0
  180. package/src/teams/constants.ts +1 -1
  181. package/src/teams/decodeSubmitData.test.ts +249 -0
  182. package/src/teams/decodeSubmitData.ts +57 -4
  183. package/src/teams/temporal.test.ts +766 -0
  184. package/src/teams/temporalId.ts +56 -0
  185. package/src/teams/toAdaptiveCard.test.ts +219 -0
  186. package/src/teams/toAdaptiveCard.ts +259 -21
  187. package/src/teams/toTeamsAttachments.ts +4 -1
  188. package/src/teams/types.ts +21 -1
  189. package/src/teams.ts +1 -0
  190. package/src/temporal.test.ts +319 -0
  191. package/src/temporal.ts +259 -0
  192. package/src/vocabulary/alert.test.tsx +288 -7
  193. package/src/vocabulary/alert.tsx +168 -26
  194. package/src/vocabulary/collectFormValues.test.ts +225 -1
  195. package/src/vocabulary/collectFormValues.ts +54 -5
  196. package/src/vocabulary/data.test.tsx +248 -6
  197. package/src/vocabulary/data.tsx +398 -100
  198. package/src/vocabulary/datepicker.dom.test.tsx +1148 -0
  199. package/src/vocabulary/dispatch.ts +63 -2
  200. package/src/vocabulary/fact.test.tsx +67 -0
  201. package/src/vocabulary/fact.tsx +40 -9
  202. package/src/vocabulary/form.tsx +7 -2
  203. package/src/vocabulary/formatValue.test.ts +72 -0
  204. package/src/vocabulary/formatValue.ts +127 -0
  205. package/src/vocabulary/index.test.ts +2 -0
  206. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  207. package/src/vocabulary/interactive.dom.test.tsx +1747 -0
  208. package/src/vocabulary/interactive.test.tsx +60 -4
  209. package/src/vocabulary/interactive.tsx +910 -155
  210. package/src/vocabulary/layout.test.tsx +6 -4
  211. package/src/vocabulary/layout.tsx +13 -2
  212. package/src/vocabulary/list.tsx +2 -2
@@ -0,0 +1,1148 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import * as React 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 { A2uiBindingContext } from "../a2ui/BindingContext";
8
+ import { AnsweredValuesProvider } from "../answeredValues";
9
+ import { FIELD_VALUE_ATTR } from "../constants";
10
+ import { renderGenerativeUI } from "../renderGenerativeUI";
11
+ import { normalizeTemporalInputValue } from "../temporal";
12
+ import type { GenerativeUIDispatch } from "../types";
13
+ import { defaultGenerativeUILibrary } from "./index";
14
+ import { interactiveVocabulary } from "./interactive";
15
+ import { collectFormValues } from "./collectFormValues";
16
+
17
+ (
18
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
19
+ ).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ let root: Root | undefined;
22
+
23
+ afterEach(async () => {
24
+ await React.act(async () => root?.unmount());
25
+ root = undefined;
26
+ document.body.innerHTML = "";
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ const view = (
31
+ props: Record<string, unknown>,
32
+ dispatch?: GenerativeUIDispatch,
33
+ ) =>
34
+ renderGenerativeUI(
35
+ { $type: "DatePicker", label: "When", name: "when", ...props },
36
+ interactiveVocabulary,
37
+ { status: "done", ...(dispatch ? { dispatch } : {}) },
38
+ );
39
+
40
+ const mount = async (element: React.ReactNode) => {
41
+ const container = document.createElement("div");
42
+ document.body.append(container);
43
+ root = createRoot(container);
44
+ await React.act(async () => root!.render(element));
45
+ return container;
46
+ };
47
+
48
+ const change = async (input: HTMLInputElement, value: string) => {
49
+ await React.act(async () => {
50
+ Object.getOwnPropertyDescriptor(
51
+ HTMLInputElement.prototype,
52
+ "value",
53
+ )!.set!.call(input, value);
54
+ input.dispatchEvent(new Event("input", { bubbles: true }));
55
+ });
56
+ };
57
+
58
+ const nativeValue = (type: string, value: string) => {
59
+ const input = document.createElement("input");
60
+ input.type = type;
61
+ input.value = value;
62
+ return input.value;
63
+ };
64
+
65
+ const normalizedNativeValue = (type: string, value: string) =>
66
+ normalizeTemporalInputValue(nativeValue(type, value));
67
+
68
+ describe("DatePicker temporal contract", () => {
69
+ it.each([
70
+ ["2026-07-15T12:34Z", "2026-07-16T13:45Z", ""],
71
+ ["2026-07-15T12:34:56+08:00", "2026-07-16T21:45:00+08:00", "1"],
72
+ [
73
+ "2026-07-15T12:34:56.123456-00:00",
74
+ "2026-07-16T13:45:00.000000-00:00",
75
+ "any",
76
+ ],
77
+ ])(
78
+ "preserves the anchor through an empty segment edit of %s",
79
+ async (value, emitted, step) => {
80
+ const dispatch = vi.fn();
81
+ const container = await mount(
82
+ view(
83
+ { inputType: "datetime", value, $action: { type: "save" } },
84
+ dispatch,
85
+ ),
86
+ );
87
+ const input = container.querySelector("input")!;
88
+ await change(input, "2026-07-16T08:30");
89
+ expect(input.step).toBe(step);
90
+ await change(input, "");
91
+ expect(dispatch).toHaveBeenLastCalledWith({ type: "save", $input: "" });
92
+ expect(collectFormValues([input])).toEqual({ when: "" });
93
+ expect(input.step).toBe(step);
94
+ await change(input, "2026-07-16T09:45");
95
+ expect(dispatch).toHaveBeenLastCalledWith({
96
+ type: "save",
97
+ $input: emitted,
98
+ });
99
+ expect(collectFormValues([input])).toEqual({ when: emitted });
100
+ expect(input.step).toBe(step);
101
+ },
102
+ );
103
+
104
+ it("submits an edited instant fraction", async () => {
105
+ const dispatch = vi.fn();
106
+ const container = await mount(
107
+ view(
108
+ {
109
+ inputType: "datetime",
110
+ value: "2025-12-15T17:00:00.250Z",
111
+ $action: { type: "save" },
112
+ },
113
+ dispatch,
114
+ ),
115
+ );
116
+ const input = container.querySelector("input")!;
117
+ await change(input, "2025-12-15T12:00:00.750");
118
+ expect(dispatch).toHaveBeenLastCalledWith({
119
+ type: "save",
120
+ $input: "2025-12-15T17:00:00.750Z",
121
+ });
122
+ expect(collectFormValues([input])).toEqual({
123
+ when: "2025-12-15T17:00:00.750Z",
124
+ });
125
+ });
126
+
127
+ it.each(["2025-11-02T01:30:00.25", "2025-11-02T01:30:00.250"])(
128
+ "submits an untouched repeated hour instant when the browser reports %s",
129
+ async (reported) => {
130
+ const anchor = "2025-11-02T06:30:00.250Z";
131
+ const container = await mount(
132
+ view({ inputType: "datetime", value: anchor }),
133
+ );
134
+ const input = container.querySelector("input")!;
135
+ vi.spyOn(input, "value", "get").mockReturnValue(reported);
136
+ expect(collectFormValues([input])).toEqual({ when: anchor });
137
+ },
138
+ );
139
+
140
+ it.each([
141
+ [
142
+ "2026-07-15T12:34:56.123456Z",
143
+ "2026-07-15T08:34:56.123",
144
+ "2026-07-16T08:34:56.123",
145
+ "2026-07-16T12:34:56.123456Z",
146
+ ],
147
+ [
148
+ "2026-07-15T12:34:56.5Z",
149
+ "2026-07-15T08:34:56.500",
150
+ "2026-07-16T08:34:56.500",
151
+ "2026-07-16T12:34:56.5Z",
152
+ ],
153
+ ])(
154
+ "keeps the fraction's precision when only the day changes from %s",
155
+ async (value, displayed, edited, emitted) => {
156
+ const dispatch = vi.fn();
157
+ const container = await mount(
158
+ view(
159
+ { inputType: "datetime", value, $action: { type: "save" } },
160
+ dispatch,
161
+ ),
162
+ );
163
+ const input = container.querySelector("input")!;
164
+ expect(input.value).toBe(nativeValue(input.type, displayed));
165
+ if (value === "2026-07-15T12:34:56.5Z") {
166
+ expect(input.getAttribute("value")).toBe("2026-07-15T08:34:56.500");
167
+ expect(input.value).toBe("2026-07-15T08:34:56.5");
168
+ expect(collectFormValues([input])).toEqual({ when: value });
169
+ }
170
+ await change(input, edited);
171
+ expect(dispatch).toHaveBeenLastCalledWith({
172
+ type: "save",
173
+ $input: emitted,
174
+ });
175
+ expect(collectFormValues([input])).toEqual({ when: emitted });
176
+ },
177
+ );
178
+
179
+ it("pads edited milliseconds to the anchor precision", async () => {
180
+ const dispatch = vi.fn();
181
+ const container = await mount(
182
+ view(
183
+ {
184
+ inputType: "datetime",
185
+ value: "2026-07-15T12:34:56.123456Z",
186
+ $action: { type: "save" },
187
+ },
188
+ dispatch,
189
+ ),
190
+ );
191
+ const input = container.querySelector("input")!;
192
+ await change(input, "2026-07-16T08:34:56.789");
193
+ expect(dispatch).toHaveBeenLastCalledWith({
194
+ type: "save",
195
+ $input: "2026-07-16T12:34:56.789000Z",
196
+ });
197
+ });
198
+
199
+ it("submits a cleared instant fraction in its original offset", async () => {
200
+ const dispatch = vi.fn();
201
+ const container = await mount(
202
+ renderGenerativeUI(
203
+ {
204
+ $type: "Form",
205
+ $action: { type: "submit" },
206
+ children: [
207
+ {
208
+ $type: "DatePicker",
209
+ name: "when",
210
+ inputType: "datetime",
211
+ value: "2025-12-15T17:00:00.250-00:00",
212
+ },
213
+ { $type: "Button", label: "Submit", submit: true },
214
+ ],
215
+ },
216
+ defaultGenerativeUILibrary,
217
+ { status: "done", dispatch },
218
+ ),
219
+ );
220
+ const input = container.querySelector("input")!;
221
+ expect(input.step).toBe("any");
222
+ expect(input.value).toBe(
223
+ nativeValue(input.type, "2025-12-15T12:00:00.250"),
224
+ );
225
+ await change(input, "2025-12-15T12:00:00");
226
+ expect(input.value).toBe(nativeValue(input.type, "2025-12-15T12:00:00"));
227
+ await React.act(async () => container.querySelector("button")!.click());
228
+ expect(dispatch).toHaveBeenLastCalledWith({
229
+ type: "submit",
230
+ $input: { when: "2025-12-15T17:00:00.000-00:00" },
231
+ });
232
+ });
233
+
234
+ it.each([
235
+ [
236
+ "datetime",
237
+ "2026-07-15T12:34:56",
238
+ "2026-07-15T12:34:56",
239
+ "2026-07-16T09:45:30.500",
240
+ "2026-07-16T09:45:30.5",
241
+ ],
242
+ [
243
+ "datetime",
244
+ "2026-07-15T12:34:56.500",
245
+ "2026-07-15T12:34:56.5",
246
+ "2026-07-16T09:45:30.000",
247
+ "2026-07-16T09:45:30",
248
+ ],
249
+ ["time", "12:34:56", "12:34:56", "13:45:30.500", "13:45:30.5"],
250
+ ["time", "12:34:56.500", "12:34:56.5", "13:45:30.000", "13:45:30"],
251
+ ])(
252
+ "dispatches and binds the shortest edited %s value",
253
+ async (inputType, value, shown, edited, emitted) => {
254
+ const update = vi.fn();
255
+ const dispatch = vi.fn();
256
+ const BindingContext = A2uiBindingContext!;
257
+ const Surface = () => {
258
+ const [current, setCurrent] = React.useState(value);
259
+ return (
260
+ <BindingContext.Provider
261
+ value={{
262
+ fields: new Map([
263
+ ["when", { value: current, arrayValue: false }],
264
+ ]),
265
+ update: (path, next) => {
266
+ update(path, next);
267
+ setCurrent(String(next));
268
+ },
269
+ }}
270
+ >
271
+ {view(
272
+ { inputType, value: current, $action: { type: "save" } },
273
+ dispatch,
274
+ )}
275
+ <output>{current}</output>
276
+ </BindingContext.Provider>
277
+ );
278
+ };
279
+ const container = await mount(<Surface />);
280
+ const input = container.querySelector("input")!;
281
+ expect(input.getAttribute("value")).toBe(shown);
282
+ await change(input, edited);
283
+ expect(update).toHaveBeenLastCalledWith("when", emitted);
284
+ expect(dispatch).toHaveBeenLastCalledWith({
285
+ type: "save",
286
+ $input: emitted,
287
+ });
288
+ expect(container.querySelector("output")!.textContent).toBe(emitted);
289
+ expect(collectFormValues([input])).toEqual({ when: emitted });
290
+ expect(input.getAttribute("value")).toBe(emitted);
291
+ },
292
+ );
293
+
294
+ it("uses shortest edited floating values in $field reads and Form submits", async () => {
295
+ const dispatch = vi.fn();
296
+ const container = await mount(
297
+ <div data-aui="root">
298
+ {renderGenerativeUI(
299
+ {
300
+ $type: "Form",
301
+ $action: { type: "submit" },
302
+ children: [
303
+ {
304
+ $type: "DatePicker",
305
+ name: "when",
306
+ inputType: "datetime",
307
+ value: "2025-12-15T17:00:05.500",
308
+ },
309
+ { $type: "Button", label: "Submit", submit: true },
310
+ {
311
+ $type: "Button",
312
+ label: "Reference",
313
+ $action: { type: "reference", when: { $field: "when" } },
314
+ },
315
+ ],
316
+ },
317
+ defaultGenerativeUILibrary,
318
+ { status: "done", dispatch },
319
+ )}
320
+ </div>,
321
+ );
322
+ const input = container.querySelector("input")!;
323
+ const [submit, reference] = container.querySelectorAll("button");
324
+ await change(input, "2025-12-16T08:30:45.500");
325
+ expect(input.getAttribute("value")).toBe("2025-12-16T08:30:45.5");
326
+ expect(input.value).toBe("2025-12-16T08:30:45.5");
327
+ await React.act(async () => reference!.click());
328
+ expect(dispatch).toHaveBeenLastCalledWith({
329
+ type: "reference",
330
+ when: "2025-12-16T08:30:45.5",
331
+ });
332
+ await React.act(async () => submit!.click());
333
+ expect(dispatch).toHaveBeenLastCalledWith({
334
+ type: "submit",
335
+ $input: { when: "2025-12-16T08:30:45.5" },
336
+ });
337
+ await change(input, "2025-12-16T08:30:45.000");
338
+ await React.act(async () => reference!.click());
339
+ expect(dispatch).toHaveBeenLastCalledWith({
340
+ type: "reference",
341
+ when: "2025-12-16T08:30:45",
342
+ });
343
+ await React.act(async () => submit!.click());
344
+ expect(dispatch).toHaveBeenLastCalledWith({
345
+ type: "submit",
346
+ $input: { when: "2025-12-16T08:30:45" },
347
+ });
348
+ });
349
+
350
+ it("preserves the anchor through binding echoes and replaces it on an external update", async () => {
351
+ const dispatch = vi.fn();
352
+ const BindingContext = A2uiBindingContext!;
353
+ let replace: React.Dispatch<React.SetStateAction<string>> | undefined;
354
+ const Surface = () => {
355
+ const [value, setValue] = React.useState("2026-07-15T12:34-00:00");
356
+ replace = setValue;
357
+ return (
358
+ <BindingContext.Provider
359
+ value={{
360
+ fields: new Map([["when", { value, arrayValue: false }]]),
361
+ update: (_, next) => setValue(String(next)),
362
+ }}
363
+ >
364
+ {view(
365
+ { inputType: "datetime", value, $action: { type: "save" } },
366
+ dispatch,
367
+ )}
368
+ </BindingContext.Provider>
369
+ );
370
+ };
371
+ const container = await mount(<Surface />);
372
+ const input = container.querySelector("input")!;
373
+ await change(input, "2026-07-16T08:30");
374
+ await change(input, "");
375
+ expect(input.step).toBe("");
376
+ await change(input, "2026-07-16T09:45");
377
+ expect(dispatch).toHaveBeenLastCalledWith({
378
+ type: "save",
379
+ $input: "2026-07-16T13:45-00:00",
380
+ });
381
+ expect(input.step).toBe("");
382
+ await React.act(async () => replace!("2026-07-17T12:34:56+08:00"));
383
+ await change(input, "");
384
+ expect(input.step).toBe("1");
385
+ await change(input, "2026-07-18T09:45:30");
386
+ expect(dispatch).toHaveBeenLastCalledWith({
387
+ type: "save",
388
+ $input: "2026-07-18T21:45:30+08:00",
389
+ });
390
+ });
391
+
392
+ it("submits the sanitized live value of an invalid date", async () => {
393
+ const container = await mount(view({ value: "12/15/2025" }));
394
+ const input = container.querySelector("input")!;
395
+ expect(input.value).toBe("");
396
+ expect(input.hasAttribute(FIELD_VALUE_ATTR)).toBe(false);
397
+ expect(collectFormValues([input])).toEqual({ when: "" });
398
+ });
399
+
400
+ it.each([
401
+ ["2025-12-15T17:00Z", "2025-12-16T14:30Z"],
402
+ ["2025-12-15T17:00:00.120000-00:00", "2025-12-16T14:30:00.000000-00:00"],
403
+ ])(
404
+ "collects the live instant without an input event using %s",
405
+ async (value, emitted) => {
406
+ const container = await mount(view({ inputType: "datetime", value }));
407
+ const input = container.querySelector("input")!;
408
+ expect(collectFormValues([input])).toEqual({ when: value });
409
+ input.value = "2025-12-16T09:30";
410
+ expect(collectFormValues([input])).toEqual({ when: emitted });
411
+ input.value = "";
412
+ expect(collectFormValues([input])).toEqual({ when: "" });
413
+ },
414
+ );
415
+
416
+ it.each([
417
+ {
418
+ value: "2026-07-15T12:34:56.123",
419
+ min: "2026-07-15T12:00:00",
420
+ max: "2026-07-15T13:00:00",
421
+ },
422
+ {
423
+ value: "2026-07-15T12:34:56",
424
+ min: "2026-07-15T12:00:00.123",
425
+ max: "2026-07-15T13:00:00",
426
+ },
427
+ {
428
+ value: "2026-07-15T12:34:56",
429
+ min: "2026-07-15T12:00:00",
430
+ max: "2026-07-15T13:00:00.123",
431
+ },
432
+ ])(
433
+ "allows fractional local values and bounds without step mismatch: $value, $min, $max",
434
+ async (props) => {
435
+ const container = await mount(view({ inputType: "datetime", ...props }));
436
+ const input = container.querySelector("input")!;
437
+ expect(input.step).toBe("any");
438
+ expect(input.validity.stepMismatch).toBe(false);
439
+ expect(input.checkValidity()).toBe(true);
440
+ },
441
+ );
442
+
443
+ it("preserves fractional instant bounds in the viewer time zone", async () => {
444
+ const container = await mount(
445
+ view({
446
+ inputType: "datetime",
447
+ value: "2026-07-15T12:34Z",
448
+ min: "2026-07-15T12:00:00.123+08:00",
449
+ max: "2026-07-15T13:00:00.456789-05:30",
450
+ }),
451
+ );
452
+ const input = container.querySelector("input")!;
453
+ expect(input.min).toBe("2026-07-15T00:00:00.123");
454
+ expect(input.max).toBe("2026-07-15T14:30:00.456");
455
+ expect(input.step).toBe("any");
456
+ expect(input.validity.stepMismatch).toBe(false);
457
+ });
458
+
459
+ it.each([
460
+ [
461
+ "2025-12-15T17:00:00Z",
462
+ "2025-12-15T12:00",
463
+ "2025-12-16T14:30:00Z",
464
+ "2025-12-17T14:30:00Z",
465
+ ],
466
+ [
467
+ "2025-12-15T17:00:00+02:00",
468
+ "2025-12-15T10:00",
469
+ "2025-12-16T16:30:00+02:00",
470
+ "2025-12-17T16:30:00+02:00",
471
+ ],
472
+ ])(
473
+ "submits and resolves canonical instant %s before and after editing",
474
+ async (initial, displayed, edited, autofilled) => {
475
+ const dispatch = vi.fn();
476
+ const tree = (
477
+ <div data-aui="root">
478
+ {renderGenerativeUI(
479
+ {
480
+ $type: "Form",
481
+ $action: { type: "submit" },
482
+ children: [
483
+ {
484
+ $type: "DatePicker",
485
+ name: "when",
486
+ inputType: "datetime",
487
+ value: initial,
488
+ $action: { type: "picker", when: { $field: "when" } },
489
+ },
490
+ { $type: "Button", label: "Submit", submit: true },
491
+ {
492
+ $type: "Button",
493
+ label: "Reference",
494
+ $action: { type: "reference", when: { $field: "when" } },
495
+ },
496
+ ],
497
+ },
498
+ defaultGenerativeUILibrary,
499
+ { status: "done", dispatch },
500
+ )}
501
+ </div>
502
+ );
503
+ const container = await mount(tree);
504
+ const input = container.querySelector("input")!;
505
+ const [submit, reference] = container.querySelectorAll("button");
506
+ expect(input.value).toBe(displayed);
507
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(initial);
508
+ await React.act(async () => reference!.click());
509
+ expect(dispatch).toHaveBeenLastCalledWith({
510
+ type: "reference",
511
+ when: initial,
512
+ });
513
+ await React.act(async () => submit!.click());
514
+ expect(dispatch).toHaveBeenLastCalledWith({
515
+ type: "submit",
516
+ $input: { when: initial },
517
+ });
518
+ await change(input, "2025-12-16T09:30");
519
+ expect(dispatch).toHaveBeenLastCalledWith({
520
+ type: "picker",
521
+ when: edited,
522
+ $input: edited,
523
+ });
524
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(edited);
525
+ await React.act(async () => reference!.click());
526
+ expect(dispatch).toHaveBeenLastCalledWith({
527
+ type: "reference",
528
+ when: edited,
529
+ });
530
+ await React.act(async () => submit!.click());
531
+ expect(dispatch).toHaveBeenLastCalledWith({
532
+ type: "submit",
533
+ $input: { when: edited },
534
+ });
535
+ input.value = "2025-12-17T09:30";
536
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(edited);
537
+ await React.act(async () => reference!.click());
538
+ expect(dispatch).toHaveBeenLastCalledWith({
539
+ type: "reference",
540
+ when: autofilled,
541
+ });
542
+ await React.act(async () => submit!.click());
543
+ expect(dispatch).toHaveBeenLastCalledWith({
544
+ type: "submit",
545
+ $input: { when: autofilled },
546
+ });
547
+ input.value = "";
548
+ await React.act(async () => reference!.click());
549
+ expect(dispatch).toHaveBeenLastCalledWith({
550
+ type: "reference",
551
+ when: "",
552
+ });
553
+ await React.act(async () => submit!.click());
554
+ expect(dispatch).toHaveBeenLastCalledWith({
555
+ type: "submit",
556
+ $input: { when: "" },
557
+ });
558
+ },
559
+ );
560
+
561
+ it.each([
562
+ ["time", "17:00:00", "17:00", "08:30:45"],
563
+ ["time", "12:00:05.1200", "12:00:05.12", "08:30"],
564
+ ["datetime", "2025-12-15T17:00:00", "2025-12-15T17:00", "2025-12-16T08:30"],
565
+ ])(
566
+ "preserves a canonical %s value through Form and $field",
567
+ async (inputType, initial, displayed, edited) => {
568
+ const dispatch = vi.fn();
569
+ const container = await mount(
570
+ <div data-aui="root">
571
+ {renderGenerativeUI(
572
+ {
573
+ $type: "Form",
574
+ $action: { type: "submit" },
575
+ children: [
576
+ {
577
+ $type: "DatePicker",
578
+ name: "when",
579
+ inputType,
580
+ value: initial,
581
+ },
582
+ { $type: "Button", label: "Submit", submit: true },
583
+ {
584
+ $type: "Button",
585
+ label: "Reference",
586
+ $action: { type: "reference", when: { $field: "when" } },
587
+ },
588
+ ],
589
+ },
590
+ defaultGenerativeUILibrary,
591
+ { status: "done", dispatch },
592
+ )}
593
+ </div>,
594
+ );
595
+ const input = container.querySelector("input")!;
596
+ const [submit, reference] = container.querySelectorAll("button");
597
+ expect(input.value).toBe(displayed);
598
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(initial);
599
+ await React.act(async () => reference!.click());
600
+ expect(dispatch).toHaveBeenLastCalledWith({
601
+ type: "reference",
602
+ when: initial,
603
+ });
604
+ await React.act(async () => submit!.click());
605
+ expect(dispatch).toHaveBeenLastCalledWith({
606
+ type: "submit",
607
+ $input: { when: initial },
608
+ });
609
+
610
+ await change(input, edited);
611
+ expect(input.value).toBe(edited);
612
+ expect(input.hasAttribute(FIELD_VALUE_ATTR)).toBe(false);
613
+ await React.act(async () => reference!.click());
614
+ expect(dispatch).toHaveBeenLastCalledWith({
615
+ type: "reference",
616
+ when: edited,
617
+ });
618
+ await React.act(async () => submit!.click());
619
+ expect(dispatch).toHaveBeenLastCalledWith({
620
+ type: "submit",
621
+ $input: { when: edited },
622
+ });
623
+
624
+ input.value = "";
625
+ await React.act(async () => reference!.click());
626
+ expect(dispatch).toHaveBeenLastCalledWith({
627
+ type: "reference",
628
+ when: "",
629
+ });
630
+ await React.act(async () => submit!.click());
631
+ expect(dispatch).toHaveBeenLastCalledWith({
632
+ type: "submit",
633
+ $input: { when: "" },
634
+ });
635
+ },
636
+ );
637
+
638
+ it("submits a filled initially empty datetime as an instant in the viewer offset", async () => {
639
+ const dispatch = vi.fn();
640
+ const container = await mount(
641
+ <div data-aui="root">
642
+ {renderGenerativeUI(
643
+ {
644
+ $type: "Form",
645
+ $action: { type: "submit" },
646
+ children: [
647
+ { $type: "DatePicker", name: "when", inputType: "datetime" },
648
+ { $type: "Button", label: "Submit", submit: true },
649
+ {
650
+ $type: "Button",
651
+ label: "Reference",
652
+ $action: { type: "reference", when: { $field: "when" } },
653
+ },
654
+ ],
655
+ },
656
+ defaultGenerativeUILibrary,
657
+ { status: "done", dispatch },
658
+ )}
659
+ </div>,
660
+ );
661
+ const input = container.querySelector("input")!;
662
+ const [submit, reference] = container.querySelectorAll("button");
663
+ expect(input.hasAttribute(FIELD_VALUE_ATTR)).toBe(false);
664
+
665
+ await change(input, "2025-12-15T09:00");
666
+ await React.act(async () => submit!.click());
667
+ expect(dispatch).toHaveBeenLastCalledWith({
668
+ type: "submit",
669
+ $input: { when: "2025-12-15T09:00:00-05:00" },
670
+ });
671
+ await React.act(async () => reference!.click());
672
+ expect(dispatch).toHaveBeenLastCalledWith({
673
+ type: "reference",
674
+ when: "2025-12-15T09:00:00-05:00",
675
+ });
676
+ });
677
+
678
+ it.each([
679
+ ["time", "12:00:00", "12:00", "12:00:00", "1"],
680
+ ["time", "12:00:05.1200", "12:00:05.12", "12:00:05.1200", "any"],
681
+ ["time", "12:00:05.123456", "12:00:05.123", "12:00:05.123456", "any"],
682
+ [
683
+ "datetime",
684
+ "2025-12-15T12:00:05.123456",
685
+ "2025-12-15T12:00:05.123",
686
+ "2025-12-15T12:00:05.123456",
687
+ "any",
688
+ ],
689
+ [
690
+ "datetime",
691
+ "2025-12-15T17:00:00Z",
692
+ "2025-12-15T12:00",
693
+ "2025-12-15T17:00:00Z",
694
+ "1",
695
+ ],
696
+ ])(
697
+ "normalizes %s without rewriting the input on rerender",
698
+ async (inputType, canonical, normalized, fieldValue, step) => {
699
+ const setter = vi.spyOn(HTMLInputElement.prototype, "value", "set");
700
+ const container = await mount(view({ inputType, value: canonical }));
701
+ const input = container.querySelector("input")!;
702
+ expect(input.value).toBe(normalized);
703
+ expect(input.getAttribute("value")).toBe(normalized);
704
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(fieldValue);
705
+ expect(collectFormValues([input])).toEqual({
706
+ when: fieldValue ?? normalized,
707
+ });
708
+ expect(input.step).toBe(step);
709
+ const calls = setter.mock.calls.length;
710
+ await React.act(async () =>
711
+ root!.render(view({ inputType, value: canonical })),
712
+ );
713
+ expect(setter.mock.calls.length).toBe(calls);
714
+ },
715
+ );
716
+
717
+ it("uses a fixed viewer time zone", () => {
718
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(
719
+ "America/New_York",
720
+ );
721
+ expect(new Date("2026-07-15T12:00Z").getTimezoneOffset()).toBe(240);
722
+ });
723
+
724
+ it("declares the three temporal input types and defaults to date", async () => {
725
+ const schema = interactiveVocabulary.DatePicker.properties;
726
+ for (const inputType of ["date", "datetime", "time"]) {
727
+ expect(schema.safeParse({ inputType }).success).toBe(true);
728
+ }
729
+ expect(schema.safeParse({}).success).toBe(true);
730
+ expect(schema.safeParse({ inputType: "datetime-local" }).success).toBe(
731
+ false,
732
+ );
733
+ const container = await mount(view({ value: "2026-07-15" }));
734
+ expect(container.querySelector("input")!.type).toBe("date");
735
+ });
736
+
737
+ it.each([
738
+ {
739
+ value: "2026-07-15T12:34Z",
740
+ displayed: "2026-07-15T08:34",
741
+ edited: "2026-07-16T09:45",
742
+ emitted: "2026-07-16T13:45Z",
743
+ step: "",
744
+ },
745
+ {
746
+ value: "2026-07-15T12:34:56+08:00",
747
+ displayed: "2026-07-15T00:34:56",
748
+ edited: "2026-07-16T09:45:30",
749
+ emitted: "2026-07-16T21:45:30+08:00",
750
+ step: "1",
751
+ },
752
+ {
753
+ value: "2026-07-15T12:34:56.123456-05:30",
754
+ displayed: "2026-07-15T14:04:56.123",
755
+ edited: "2026-07-16T09:45:30",
756
+ emitted: "2026-07-16T08:15:30.000000-05:30",
757
+ step: "any",
758
+ },
759
+ ])(
760
+ "shows $value locally on the first client render and submits its offset and precision",
761
+ async ({ value, displayed, edited, emitted, step }) => {
762
+ const dispatch = vi.fn();
763
+ let firstType: string | undefined;
764
+ const Capture = () => (
765
+ <div
766
+ ref={(element) => {
767
+ if (element && firstType === undefined) {
768
+ firstType = element.querySelector("input")!.type;
769
+ }
770
+ }}
771
+ >
772
+ {view(
773
+ { inputType: "datetime", value, $action: { type: "save" } },
774
+ dispatch,
775
+ )}
776
+ </div>
777
+ );
778
+ const container = await mount(<Capture />);
779
+ const input = container.querySelector("input")!;
780
+ expect(firstType).toBe("datetime-local");
781
+ expect(input.getAttribute("value")).toBe(displayed);
782
+ expect(input.value).toBe(nativeValue("datetime-local", displayed));
783
+ expect(input.step).toBe(step);
784
+ expect(input.getAttribute("aria-label")).toBe("When");
785
+ expect(input.name).toBe("when");
786
+ expect(input.readOnly).toBe(false);
787
+ await change(input, edited);
788
+ expect(dispatch).toHaveBeenLastCalledWith({
789
+ type: "save",
790
+ $input: emitted,
791
+ });
792
+ expect(input.value).toBe(nativeValue("datetime-local", edited));
793
+ },
794
+ );
795
+
796
+ it("converts instant bounds and preserves floating bounds", async () => {
797
+ const container = await mount(
798
+ view({
799
+ inputType: "datetime",
800
+ value: "2026-07-15T12:34Z",
801
+ min: "2026-07-15T00:00:30+08:00",
802
+ max: "2026-07-16T23:59:45-05:30",
803
+ }),
804
+ );
805
+ const input = container.querySelector("input")!;
806
+ expect(input.min).toBe("2026-07-14T12:00:30");
807
+ expect(input.max).toBe("2026-07-17T01:29:45");
808
+ await React.act(async () =>
809
+ root!.render(
810
+ view({
811
+ inputType: "datetime",
812
+ value: "2026-07-15T12:34Z",
813
+ min: "2026-07-15T01:00",
814
+ max: "2026-07-15T22:00",
815
+ }),
816
+ ),
817
+ );
818
+ expect(input.min).toBe("2026-07-15T01:00");
819
+ expect(input.max).toBe("2026-07-15T22:00");
820
+ });
821
+
822
+ it("normalizes local and converted instant bounds", async () => {
823
+ const container = await mount(
824
+ view({
825
+ inputType: "datetime",
826
+ value: "2025-12-15T17:00:00Z",
827
+ min: "2025-12-15T17:00:00Z",
828
+ max: "2025-12-15T20:00:05.1200",
829
+ }),
830
+ );
831
+ const input = container.querySelector("input")!;
832
+ expect(input.min).toBe("2025-12-15T12:00");
833
+ expect(input.max).toBe("2025-12-15T20:00:05.12");
834
+ });
835
+
836
+ it.each([
837
+ ["2026-01-15T12:34", "2026-01-15T12:34:00-05:00"],
838
+ ["2026-07-15T12:34", "2026-07-15T12:34:00-04:00"],
839
+ ])(
840
+ "submits an empty datetime with the viewer offset at %s",
841
+ async (edited, emitted) => {
842
+ const dispatch = vi.fn();
843
+ const container = await mount(
844
+ view({ inputType: "datetime", $action: { type: "save" } }, dispatch),
845
+ );
846
+ const input = container.querySelector("input")!;
847
+ expect(input.type).toBe("datetime-local");
848
+ expect(input.value).toBe("");
849
+ await change(input, edited!);
850
+ expect(dispatch).toHaveBeenLastCalledWith({
851
+ type: "save",
852
+ $input: emitted,
853
+ });
854
+ expect(input.step).toBe("1");
855
+ },
856
+ );
857
+
858
+ it("uses the viewer offset for each unbound edit of an initially empty picker", async () => {
859
+ const dispatch = vi.fn();
860
+ const container = await mount(
861
+ view({ inputType: "datetime", $action: { type: "save" } }, dispatch),
862
+ );
863
+ const input = container.querySelector("input")!;
864
+ await change(input, "2026-01-15T12:34");
865
+ await change(input, "2026-07-15T12:34");
866
+ expect(dispatch).toHaveBeenLastCalledWith({
867
+ type: "save",
868
+ $input: "2026-07-15T12:34:00-04:00",
869
+ });
870
+ await change(input, "");
871
+ expect(dispatch).toHaveBeenLastCalledWith({ type: "save", $input: "" });
872
+ await change(input, "2026-07-16T12:34");
873
+ expect(dispatch).toHaveBeenLastCalledWith({
874
+ type: "save",
875
+ $input: "2026-07-16T12:34:00-04:00",
876
+ });
877
+ });
878
+
879
+ it.each([
880
+ {
881
+ inputType: "date",
882
+ value: "2026-07-15",
883
+ min: "2026-01-01",
884
+ max: "2026-12-31",
885
+ edited: "2026-07-16",
886
+ step: "",
887
+ },
888
+ {
889
+ inputType: "time",
890
+ value: "12:34",
891
+ min: "09:00",
892
+ max: "17:00",
893
+ edited: "13:45",
894
+ step: "",
895
+ },
896
+ {
897
+ inputType: "time",
898
+ value: "12:34:56",
899
+ min: "09:00:01",
900
+ max: "17:00:59",
901
+ edited: "13:45:30",
902
+ step: "1",
903
+ },
904
+ {
905
+ inputType: "time",
906
+ value: "12:34:56",
907
+ min: "09:00:01",
908
+ max: "17:00:59",
909
+ edited: "13:45:30.500",
910
+ step: "1",
911
+ },
912
+ {
913
+ inputType: "datetime",
914
+ value: "2026-07-15T12:34",
915
+ min: "2026-07-15T09:00",
916
+ max: "2026-07-15T17:00",
917
+ edited: "2026-07-15T13:45",
918
+ step: "",
919
+ },
920
+ {
921
+ inputType: "datetime",
922
+ value: "2026-07-15T12:34:56",
923
+ min: "2026-07-15T09:00:01",
924
+ max: "2026-07-15T17:00:59",
925
+ edited: "2026-07-15T13:45:30",
926
+ step: "1",
927
+ },
928
+ ])(
929
+ "passes $inputType value $value and bounds through, then normalizes edits",
930
+ async ({ inputType, value, min, max, edited, step }) => {
931
+ const dispatch = vi.fn();
932
+ const container = await mount(
933
+ view(
934
+ { inputType, value, min, max, $action: { type: "save" } },
935
+ dispatch,
936
+ ),
937
+ );
938
+ const input = container.querySelector("input")!;
939
+ expect(input.type).toBe(
940
+ inputType === "datetime" ? "datetime-local" : inputType,
941
+ );
942
+ expect(input.getAttribute("value")).toBe(value);
943
+ expect(input.hasAttribute(FIELD_VALUE_ATTR)).toBe(false);
944
+ expect(collectFormValues([input])).toEqual({
945
+ when: normalizedNativeValue(input.type, value),
946
+ });
947
+ expect(input.value).toBe(nativeValue(input.type, value));
948
+ expect(input.min).toBe(min);
949
+ expect(input.max).toBe(max);
950
+ expect(input.step).toBe(step);
951
+ await change(input, edited);
952
+ expect(dispatch).toHaveBeenLastCalledWith({
953
+ type: "save",
954
+ $input: normalizedNativeValue(input.type, edited),
955
+ });
956
+ expect(input.value).toBe(nativeValue(input.type, edited));
957
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(null);
958
+ expect(collectFormValues([input])).toEqual({
959
+ when: normalizedNativeValue(input.type, edited),
960
+ });
961
+ },
962
+ );
963
+
964
+ it.each([
965
+ {
966
+ inputType: "datetime",
967
+ value: "2026-07-15T12:34:56+08:00",
968
+ edited: "2026-07-16T09:45:30",
969
+ emitted: "2026-07-16T21:45:30+08:00",
970
+ hasInstantAnchor: true,
971
+ },
972
+ {
973
+ inputType: "datetime",
974
+ value: "",
975
+ edited: "2026-07-16T09:45",
976
+ emitted: "2026-07-16T09:45:00-04:00",
977
+ hasInstantAnchor: false,
978
+ },
979
+ {
980
+ inputType: "datetime",
981
+ value: "2026-07-15T12:34",
982
+ edited: "2026-07-16T09:45",
983
+ emitted: "2026-07-16T09:45",
984
+ hasInstantAnchor: false,
985
+ },
986
+ {
987
+ inputType: "time",
988
+ value: "12:34",
989
+ edited: "09:45",
990
+ emitted: "09:45",
991
+ hasInstantAnchor: false,
992
+ },
993
+ ])(
994
+ "sends the same $inputType value to the binding and action for $value",
995
+ async ({ inputType, value, edited, emitted, hasInstantAnchor }) => {
996
+ const update = vi.fn();
997
+ const dispatch = vi.fn();
998
+ const BindingContext = A2uiBindingContext!;
999
+ const Surface = () => {
1000
+ const [current, setCurrent] = React.useState(value);
1001
+ return (
1002
+ <BindingContext.Provider
1003
+ value={{
1004
+ fields: new Map([
1005
+ ["when", { value: current, arrayValue: false }],
1006
+ ]),
1007
+ update: (path, next) => {
1008
+ update(path, next);
1009
+ setCurrent(String(next));
1010
+ },
1011
+ }}
1012
+ >
1013
+ {view(
1014
+ { inputType, value: current, $action: { type: "save" } },
1015
+ dispatch,
1016
+ )}
1017
+ <output>{current}</output>
1018
+ </BindingContext.Provider>
1019
+ );
1020
+ };
1021
+ const container = await mount(<Surface />);
1022
+ const input = container.querySelector("input")!;
1023
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(
1024
+ hasInstantAnchor ? value : null,
1025
+ );
1026
+ await change(input, edited);
1027
+ expect(update).toHaveBeenLastCalledWith("when", emitted);
1028
+ expect(dispatch).toHaveBeenLastCalledWith({
1029
+ type: "save",
1030
+ $input: emitted,
1031
+ });
1032
+ expect(container.querySelector("output")!.textContent).toBe(emitted);
1033
+ expect(input.getAttribute(FIELD_VALUE_ATTR)).toBe(
1034
+ hasInstantAnchor || /(?:Z|[+-]\d{2}:\d{2})$/.test(emitted)
1035
+ ? emitted
1036
+ : null,
1037
+ );
1038
+ expect(input.value).toBe(nativeValue(input.type, edited));
1039
+ await change(input, "");
1040
+ expect(update).toHaveBeenLastCalledWith("when", "");
1041
+ expect(dispatch).toHaveBeenLastCalledWith({ type: "save", $input: "" });
1042
+ },
1043
+ );
1044
+
1045
+ it("uses answered values and accepts later value replacements", async () => {
1046
+ const dispatch = vi.fn();
1047
+ const container = await mount(
1048
+ <AnsweredValuesProvider values={{ when: "2026-07-15T12:34:56-05:30" }}>
1049
+ {view(
1050
+ {
1051
+ inputType: "datetime",
1052
+ value: "2026-07-15T12:34Z",
1053
+ $action: { type: "save" },
1054
+ },
1055
+ dispatch,
1056
+ )}
1057
+ </AnsweredValuesProvider>,
1058
+ );
1059
+ let input = container.querySelector("input")!;
1060
+ expect(input.value).toBe(
1061
+ nativeValue("datetime-local", "2026-07-15T14:04:56"),
1062
+ );
1063
+ await change(input, "2026-07-16T09:45:30");
1064
+ expect(dispatch).toHaveBeenLastCalledWith({
1065
+ type: "save",
1066
+ $input: "2026-07-16T08:15:30-05:30",
1067
+ });
1068
+ await React.act(async () =>
1069
+ root!.render(
1070
+ <AnsweredValuesProvider values={undefined}>
1071
+ {view(
1072
+ {
1073
+ inputType: "datetime",
1074
+ value: "2026-07-17T12:34:56+08:00",
1075
+ $action: { type: "save" },
1076
+ },
1077
+ dispatch,
1078
+ )}
1079
+ </AnsweredValuesProvider>,
1080
+ ),
1081
+ );
1082
+ input = container.querySelector("input")!;
1083
+ expect(input.value).toBe(
1084
+ nativeValue("datetime-local", "2026-07-17T00:34:56"),
1085
+ );
1086
+ await change(input, "2026-07-18T09:45:30");
1087
+ expect(dispatch).toHaveBeenLastCalledWith({
1088
+ type: "save",
1089
+ $input: "2026-07-18T21:45:30+08:00",
1090
+ });
1091
+ });
1092
+
1093
+ it("renders the raw instant on the server and hydrates without a mismatch", async () => {
1094
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
1095
+ const onRecoverableError = vi.fn();
1096
+ const dispatch = vi.fn();
1097
+ const tree = view(
1098
+ {
1099
+ inputType: "datetime",
1100
+ value: "2026-07-15T12:34:56.123+08:00",
1101
+ $action: { type: "save" },
1102
+ },
1103
+ dispatch,
1104
+ );
1105
+ const html = renderToString(tree);
1106
+ expect(html).toContain('type="text"');
1107
+ expect(html).toMatch(/readonly=""/i);
1108
+ expect(html).toContain('value="2026-07-15T12:34:56.123+08:00"');
1109
+ const container = document.createElement("div");
1110
+ container.innerHTML = html;
1111
+ document.body.append(container);
1112
+ await React.act(async () => {
1113
+ root = hydrateRoot(container, tree, { onRecoverableError });
1114
+ });
1115
+ const input = container.querySelector("input")!;
1116
+ expect(input.type).toBe("datetime-local");
1117
+ expect(input.readOnly).toBe(false);
1118
+ expect(input.value).toBe(
1119
+ nativeValue("datetime-local", "2026-07-15T00:34:56.123"),
1120
+ );
1121
+ await change(input, "2026-07-16T09:45:30");
1122
+ expect(dispatch).toHaveBeenLastCalledWith({
1123
+ type: "save",
1124
+ $input: "2026-07-16T21:45:30.000+08:00",
1125
+ });
1126
+ expect(onRecoverableError).not.toHaveBeenCalled();
1127
+ expect(error).not.toHaveBeenCalled();
1128
+ });
1129
+
1130
+ it.each([
1131
+ { inputType: "date", value: "2026-07-15", type: "date" },
1132
+ { inputType: "time", value: "12:34:56", type: "time" },
1133
+ {
1134
+ inputType: "datetime",
1135
+ value: "2026-07-15T12:34:56.123",
1136
+ type: "datetime-local",
1137
+ },
1138
+ { inputType: "datetime", value: "", type: "datetime-local" },
1139
+ ])(
1140
+ "server renders $inputType $value as an editable picker immediately",
1141
+ ({ inputType, value, type }) => {
1142
+ const html = renderToString(view({ inputType, value }));
1143
+ expect(html).toContain(`type="${type}"`);
1144
+ expect(html).toContain(`value="${value}"`);
1145
+ expect(html.toLowerCase()).not.toContain("readonly");
1146
+ },
1147
+ );
1148
+ });