@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,766 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TeamsInputTime } from "../teams";
3
+ import type { TeamsTemporalField } from "./types";
4
+ import { decodeSubmitData } from "./decodeSubmitData";
5
+ import { toAdaptiveCard } from "./toAdaptiveCard";
6
+ import { encodeTemporalInputId } from "./temporalId";
7
+
8
+ const picker = (inputType: "date" | "time" | "datetime", value: string) =>
9
+ toAdaptiveCard({
10
+ $type: "DatePicker",
11
+ name: "when",
12
+ label: "When",
13
+ inputType,
14
+ value,
15
+ $action: { type: "pick", selected: { $field: "when" } },
16
+ });
17
+
18
+ const submit = (
19
+ inputType: "date" | "time" | "datetime",
20
+ value: string,
21
+ overrides: Record<string, string> = {},
22
+ ) => {
23
+ const { card, warnings } = picker(inputType, value);
24
+ const inputs = card.body.filter(
25
+ (element) => element.type === "Input.Date" || element.type === "Input.Time",
26
+ );
27
+ const actionSet = card.body.find((element) => element.type === "ActionSet");
28
+ if (actionSet?.type !== "ActionSet") throw new Error("Missing submit action");
29
+ const values = Object.fromEntries(
30
+ inputs.map((input) => [
31
+ input.id,
32
+ overrides[
33
+ inputType === "datetime"
34
+ ? input.type === "Input.Time"
35
+ ? "when_time"
36
+ : "when"
37
+ : input.id
38
+ ] ??
39
+ input.value ??
40
+ "",
41
+ ]),
42
+ );
43
+ const decoded = decodeSubmitData({
44
+ ...actionSet.actions[0]!.data,
45
+ ...values,
46
+ });
47
+ return { inputs, action: actionSet.actions[0]!, decoded, warnings };
48
+ };
49
+
50
+ describe("Teams temporal DatePicker", () => {
51
+ it.each(["Input", "DatePicker"])(
52
+ "round trips a %s named in the datetime namespace",
53
+ ($type) => {
54
+ const name = "aui:datetime:when";
55
+ const { card, warnings } = toAdaptiveCard({
56
+ $type,
57
+ name,
58
+ inputType: "datetime",
59
+ value: "2025-12-15T17:00:30.123456Z",
60
+ $action: { type: "pick", selected: { $field: name } },
61
+ });
62
+ const inputs = card.body.filter((element) => "id" in element);
63
+ const action = card.body.find((element) => element.type === "ActionSet");
64
+ if (action?.type !== "ActionSet") throw new Error("Missing action");
65
+ const values = Object.fromEntries(
66
+ inputs.map((input) => [
67
+ input.id,
68
+ input.type === "Input.Date"
69
+ ? "2025-12-16"
70
+ : input.type === "Input.Time"
71
+ ? "18:20"
72
+ : "ordinary",
73
+ ]),
74
+ );
75
+ const selected =
76
+ $type === "DatePicker" ? "2025-12-16T18:20:30.123456Z" : "ordinary";
77
+ expect(
78
+ decodeSubmitData({ ...action.actions[0]!.data, ...values }),
79
+ ).toEqual({
80
+ type: "pick",
81
+ selected,
82
+ $input: { [`_${name}`]: selected },
83
+ });
84
+ expect(warnings).toContainEqual(
85
+ expect.objectContaining({ code: "fallback", component: $type }),
86
+ );
87
+ },
88
+ );
89
+
90
+ it("round trips distinct ordinary ids in the datetime namespace", () => {
91
+ const names = ["aui:datetime:x", "_aui:datetime:x", "__aui:datetime:x"];
92
+ const { card, warnings } = toAdaptiveCard([
93
+ {
94
+ $type: "Button",
95
+ label: "Save",
96
+ $action: {
97
+ type: "save",
98
+ selected: names.map((name) => ({ $field: name })),
99
+ },
100
+ },
101
+ ...names.map((name) => ({ $type: "Input", name })),
102
+ ]);
103
+ const action = card.body.find((element) => element.type === "ActionSet");
104
+ if (action?.type !== "ActionSet") throw new Error("Missing action");
105
+ const inputs = card.body.filter((element) => element.type === "Input.Text");
106
+ expect(inputs.map((input) => input.id)).toEqual(
107
+ names.map((name) => `_${name}`),
108
+ );
109
+ const values = Object.fromEntries(
110
+ inputs.map((input, index) => [input.id, String(index)]),
111
+ );
112
+ expect(decodeSubmitData({ ...action.actions[0]!.data, ...values })).toEqual(
113
+ {
114
+ type: "save",
115
+ selected: ["0", "1", "2"],
116
+ $input: values,
117
+ },
118
+ );
119
+ expect(warnings).toEqual([
120
+ expect.objectContaining({ code: "fallback", component: "Input" }),
121
+ expect.objectContaining({ code: "fallback", component: "Input" }),
122
+ expect.objectContaining({ code: "fallback", component: "Input" }),
123
+ ]);
124
+ });
125
+
126
+ it("resolves adjacent escaped namespace ids in submission order", () => {
127
+ const { card } = toAdaptiveCard([
128
+ {
129
+ $type: "Button",
130
+ label: "Save",
131
+ $action: {
132
+ type: "save",
133
+ underscored: { $field: "_aui:datetime:x" },
134
+ plain: { $field: "aui:datetime:x" },
135
+ },
136
+ },
137
+ { $type: "Input", name: "_aui:datetime:x" },
138
+ { $type: "Input", name: "aui:datetime:x" },
139
+ ]);
140
+ const action = card.body[0];
141
+ if (action?.type !== "ActionSet") throw new Error("Missing action");
142
+ expect(
143
+ decodeSubmitData({
144
+ ...action.actions[0]!.data,
145
+ "__aui:datetime:x": "underscored value",
146
+ "_aui:datetime:x": "plain value",
147
+ }),
148
+ ).toEqual({
149
+ type: "save",
150
+ underscored: "underscored value",
151
+ plain: "plain value",
152
+ $input: {
153
+ "__aui:datetime:x": "underscored value",
154
+ "_aui:datetime:x": "plain value",
155
+ },
156
+ });
157
+ });
158
+
159
+ it.each(["aui:datetime:x", "_aui:datetime:x", "__aui:datetime:x"])(
160
+ "round trips datetime field %s through the reserved namespace",
161
+ (name) => {
162
+ const { card } = toAdaptiveCard({
163
+ $type: "DatePicker",
164
+ name,
165
+ inputType: "datetime",
166
+ value: "2025-12-15T17:00:30.123456Z",
167
+ $action: { type: "pick", selected: { $field: name } },
168
+ });
169
+ const inputs = card.body.filter(
170
+ (element) =>
171
+ element.type === "Input.Date" || element.type === "Input.Time",
172
+ );
173
+ const action = card.body.find((element) => element.type === "ActionSet");
174
+ if (action?.type !== "ActionSet") throw new Error("Missing action");
175
+ expect(inputs.map((input) => input.id)).toEqual([
176
+ `aui:datetime:date:${encodeURIComponent(`_${name}`)}`,
177
+ `aui:datetime:time:${encodeURIComponent(`_${name}`)}:2025-12-15T17%3A00%3A30.123456Z`,
178
+ ]);
179
+ expect(
180
+ decodeSubmitData({
181
+ ...action.actions[0]!.data,
182
+ [inputs[0]!.id]: "2025-12-16",
183
+ [inputs[1]!.id]: "18:20",
184
+ }),
185
+ ).toEqual({
186
+ type: "pick",
187
+ selected: "2025-12-16T18:20:30.123456Z",
188
+ $input: { [`_${name}`]: "2025-12-16T18:20:30.123456Z" },
189
+ });
190
+ },
191
+ );
192
+
193
+ describe.each([
194
+ "aui:datetime:",
195
+ "aui:datetime:date:when",
196
+ "aui:datetime:time:when:2025-12-15T17%3A00%3A00Z",
197
+ ])("ordinary input named %j", (name) => {
198
+ it.each([false, true])(
199
+ "stays separate from a datetime picker (input first: %s)",
200
+ (inputFirst) => {
201
+ const input = { $type: "Input", name };
202
+ const datetime = {
203
+ $type: "DatePicker",
204
+ name: "when",
205
+ inputType: "datetime",
206
+ value: "2025-12-15T17:00:00Z",
207
+ };
208
+ const { card } = toAdaptiveCard([
209
+ {
210
+ $type: "Button",
211
+ label: "Save",
212
+ $action: {
213
+ type: "save",
214
+ ordinary: { $field: name },
215
+ datetime: { $field: "when" },
216
+ },
217
+ },
218
+ ...(inputFirst ? [input, datetime] : [datetime, input]),
219
+ ]);
220
+ const action = card.body[0];
221
+ if (action?.type !== "ActionSet") throw new Error("Missing action");
222
+ const values = Object.fromEntries(
223
+ card.body.flatMap((element) => {
224
+ if (element.type === "Input.Text") {
225
+ expect(element.id).toBe(`_${name}`);
226
+ return [[element.id, "ordinary"]];
227
+ }
228
+ if (element.type === "Input.Date" || element.type === "Input.Time")
229
+ return [[element.id, element.value]];
230
+ return [];
231
+ }),
232
+ );
233
+ expect(
234
+ decodeSubmitData({ ...action.actions[0]!.data, ...values }),
235
+ ).toEqual({
236
+ type: "save",
237
+ ordinary: "ordinary",
238
+ datetime: "2025-12-15T17:00:00Z",
239
+ $input: { [`_${name}`]: "ordinary", when: "2025-12-15T17:00:00Z" },
240
+ });
241
+ },
242
+ );
243
+ });
244
+
245
+ it.each([
246
+ ["20:45:30", true],
247
+ ["20:45:00.000001", true],
248
+ ["20:45:30.000001", true],
249
+ ["20:45:00.000", false],
250
+ ["20:45", false],
251
+ ["2025-12-15T20:45:30Z", false],
252
+ ])("reports lost time maximum precision for %s", (max, dropped) => {
253
+ const { warnings } = toAdaptiveCard({
254
+ $type: "DatePicker",
255
+ inputType: "time",
256
+ max,
257
+ });
258
+ expect(warnings).toEqual(
259
+ dropped
260
+ ? [
261
+ {
262
+ code: "dropped",
263
+ component: "DatePicker",
264
+ detail:
265
+ max === "20:45:00.000001"
266
+ ? "Nonzero fractional seconds were dropped from the time maximum."
267
+ : max === "20:45:30.000001"
268
+ ? "Nonzero seconds and fractional seconds were dropped from the time maximum."
269
+ : "Nonzero seconds were dropped from the time maximum.",
270
+ },
271
+ ]
272
+ : [],
273
+ );
274
+ });
275
+
276
+ it.each(["datetime-local", "unknown", "", null, 42])(
277
+ "warns when an unsupported inputType %j falls back to date",
278
+ (inputType) => {
279
+ const { card, warnings } = toAdaptiveCard({
280
+ $type: "DatePicker",
281
+ inputType,
282
+ value: "2025-12-15",
283
+ });
284
+ expect(card.body).toEqual([
285
+ { type: "Input.Date", id: "datepicker", value: "2025-12-15" },
286
+ ]);
287
+ expect(warnings).toEqual([
288
+ {
289
+ code: "dropped",
290
+ component: "DatePicker",
291
+ detail:
292
+ "Unsupported inputType was dropped; the picker was rendered as a date input.",
293
+ },
294
+ ]);
295
+ },
296
+ );
297
+
298
+ it.each([
299
+ "__proto__",
300
+ "constructor",
301
+ "/dates/a:b % 🗓",
302
+ "lone-\uD800",
303
+ "lone-\uDC00",
304
+ "literal-%uD800",
305
+ ])("round trips the field name %j as an own property", (name) => {
306
+ const previousValue = "2025-12-15T17:00:30.123456-00:00";
307
+ const metadata: TeamsTemporalField = {
308
+ fieldId: name,
309
+ role: "time",
310
+ previousValue,
311
+ };
312
+ const { card } = toAdaptiveCard({
313
+ $type: "DatePicker",
314
+ name,
315
+ inputType: "datetime",
316
+ value: previousValue,
317
+ $action: { type: "pick", selected: { $field: name } },
318
+ });
319
+ const input = card.body[1] as TeamsInputTime;
320
+ const action = card.body[2];
321
+ if (action?.type !== "ActionSet") throw new Error("Missing action");
322
+ expect(input.id).toBe(encodeTemporalInputId(metadata));
323
+ const decoded = decodeSubmitData({
324
+ ...action.actions[0]!.data,
325
+ [encodeTemporalInputId({ fieldId: name, role: "date" })]: "2025-12-16",
326
+ [input.id]: "18:20",
327
+ });
328
+ expect(decoded).toEqual({
329
+ type: "pick",
330
+ selected: "2025-12-16T18:20:30.123456-00:00",
331
+ $input: { [name]: "2025-12-16T18:20:30.123456-00:00" },
332
+ });
333
+ expect(Object.hasOwn(decoded?.["$input"] as object, name)).toBe(true);
334
+ expect(Object.getPrototypeOf(decoded?.["$input"])).toBe(Object.prototype);
335
+ });
336
+
337
+ it("keeps each field's precision and offset scoped to its own date", () => {
338
+ const values = [
339
+ "2025-12-15T17:00:30.123456+02:00",
340
+ "2025-12-15T17:00:45-05:30",
341
+ "2025-12-15T17:00",
342
+ ];
343
+ const { card } = toAdaptiveCard(
344
+ values.map((value, index) => ({
345
+ $type: "DatePicker",
346
+ name: `when${index}`,
347
+ value,
348
+ inputType: "datetime",
349
+ $action: { type: "pick" },
350
+ })),
351
+ );
352
+ const inputs = Object.fromEntries(
353
+ card.body.flatMap((element) =>
354
+ element.type === "Input.Date"
355
+ ? [[element.id, "2025-12-16"]]
356
+ : element.type === "Input.Time"
357
+ ? [[element.id, "18:20"]]
358
+ : [],
359
+ ),
360
+ );
361
+ for (const element of card.body) {
362
+ if (element.type !== "ActionSet") continue;
363
+ expect(
364
+ decodeSubmitData({ ...element.actions[0]!.data, ...inputs }),
365
+ ).toEqual({
366
+ type: "pick",
367
+ $input: {
368
+ when0: "2025-12-16T18:20:30.123456+02:00",
369
+ when1: "2025-12-16T18:20:45-05:30",
370
+ when2: "2025-12-16T18:20",
371
+ },
372
+ });
373
+ }
374
+ });
375
+
376
+ it("carries metadata once per field and grows linearly with companion actions", () => {
377
+ const sizes = [20, 40, 80].map((count) => {
378
+ const { card } = toAdaptiveCard(
379
+ Array.from({ length: count }, (_, index) => ({
380
+ $type: "DatePicker",
381
+ name: `when${String(index).padStart(3, "0")}`,
382
+ inputType: "datetime",
383
+ value: "2025-12-15T17:00:30.123456+02:00",
384
+ $action: { type: "pick" },
385
+ })),
386
+ );
387
+ const serialized = JSON.stringify(card);
388
+ expect(
389
+ card.body.filter((item) => item.type === "ActionSet"),
390
+ ).toHaveLength(count);
391
+ expect(serialized.match(/aui:datetime:date:/g)).toHaveLength(count);
392
+ expect(serialized.match(/aui:datetime:time:/g)).toHaveLength(count);
393
+ expect(
394
+ serialized.match(/2025-12-15T17%3A00%3A30.123456%2B02%3A00/g),
395
+ ).toHaveLength(count);
396
+ expect(serialized).not.toContain('"temporal"');
397
+ return new TextEncoder().encode(serialized).length;
398
+ });
399
+ expect(sizes[2]! - sizes[1]!).toBe(2 * (sizes[1]! - sizes[0]!));
400
+ });
401
+
402
+ it.each(["", "2025-12-15T17:00", "2025-12-15T17:00:00+02:00"])(
403
+ "accepts date-only bounds on datetime %j",
404
+ (value) => {
405
+ const { card } = toAdaptiveCard({
406
+ $type: "DatePicker",
407
+ inputType: "datetime",
408
+ value,
409
+ min: "2025-12-14",
410
+ max: "2025-12-20",
411
+ });
412
+ expect(card.body[0]).toMatchObject({
413
+ type: "Input.Date",
414
+ min: "2025-12-14",
415
+ max: "2025-12-20",
416
+ });
417
+ expect(card.body[1]).not.toHaveProperty("min");
418
+ expect(card.body[1]).not.toHaveProperty("max");
419
+ },
420
+ );
421
+
422
+ it.each([
423
+ ["2025-12-15T17:00:30", true],
424
+ ["2025-12-15T17:00:00.000001", true],
425
+ ["2025-12-15T17:00:00.000", false],
426
+ ["2025-12-15T17:00", false],
427
+ ["2025-12-15T17:00:30.123Z", false],
428
+ ])("reports lost wall-clock precision for %s", (value, dropped) => {
429
+ const { warnings } = picker("datetime", value);
430
+ expect(warnings.filter((warning) => warning.code === "dropped")).toEqual(
431
+ dropped
432
+ ? [
433
+ {
434
+ code: "dropped",
435
+ component: "DatePicker",
436
+ detail: "Nonzero seconds were dropped from the datetime value.",
437
+ },
438
+ ]
439
+ : [],
440
+ );
441
+ });
442
+
443
+ it("keeps date mode as one Input.Date with its submitted value", () => {
444
+ const result = submit("date", "2025-12-15");
445
+ expect(result.inputs).toEqual([
446
+ { type: "Input.Date", id: "when", label: "When", value: "2025-12-15" },
447
+ ]);
448
+ expect(result.decoded).toEqual({
449
+ type: "pick",
450
+ selected: "2025-12-15",
451
+ $input: { when: "2025-12-15" },
452
+ });
453
+ });
454
+
455
+ it.each([
456
+ ["17:00", false],
457
+ ["17:00:30", true],
458
+ ])("round trips time %s as an Input.Time minute", (value, dropped) => {
459
+ const result = submit("time", value);
460
+ expect(result.inputs).toEqual([
461
+ { type: "Input.Time", id: "when", label: "When", value: "17:00" },
462
+ ]);
463
+ expect(result.action.data.aui).not.toHaveProperty("temporal");
464
+ expect(result.decoded).toEqual({
465
+ type: "pick",
466
+ selected: "17:00",
467
+ $input: { when: "17:00" },
468
+ });
469
+ expect(
470
+ result.warnings.some(
471
+ (warning) =>
472
+ warning.code === "dropped" && warning.component === "DatePicker",
473
+ ),
474
+ ).toBe(dropped);
475
+ });
476
+
477
+ it("rounds a time minimum up and truncates a time maximum to HH:mm", () => {
478
+ const { card } = toAdaptiveCard({
479
+ $type: "DatePicker",
480
+ inputType: "time",
481
+ value: "17:00:30",
482
+ min: "08:15:30",
483
+ max: "20:45:30",
484
+ });
485
+ expect(card.body).toEqual([
486
+ {
487
+ type: "Input.Time",
488
+ id: "datepicker",
489
+ value: "17:00",
490
+ min: "08:16",
491
+ max: "20:45",
492
+ },
493
+ ]);
494
+ });
495
+
496
+ it("drops a time minimum that rounds past 23:59", () => {
497
+ const { card, warnings } = toAdaptiveCard({
498
+ $type: "DatePicker",
499
+ inputType: "time",
500
+ min: "23:59:30",
501
+ });
502
+ expect(card.body).toEqual([{ type: "Input.Time", id: "datepicker" }]);
503
+ expect(warnings).toEqual([
504
+ {
505
+ code: "dropped",
506
+ component: "DatePicker",
507
+ detail: "The time minimum rounds past 23:59 and was dropped.",
508
+ },
509
+ ]);
510
+ });
511
+
512
+ it.each([
513
+ ["2025-12-15T17:00:00Z", "When time (UTC)"],
514
+ ["2025-12-15T17:00:00+02:00", "When time (UTC+02:00)"],
515
+ ["2025-12-15T17:00", "When time"],
516
+ ["", "When time"],
517
+ ])(
518
+ "round trips datetime %j through the paired inputs",
519
+ (value, timeLabel) => {
520
+ const result = submit("datetime", value);
521
+ expect(result.inputs).toEqual([
522
+ {
523
+ type: "Input.Date",
524
+ id: "aui:datetime:date:when",
525
+ label: "When",
526
+ ...(value ? { value: "2025-12-15" } : {}),
527
+ },
528
+ {
529
+ type: "Input.Time",
530
+ id:
531
+ value.endsWith("Z") || value.endsWith("+02:00")
532
+ ? encodeTemporalInputId({
533
+ fieldId: "when",
534
+ role: "time",
535
+ previousValue: value,
536
+ })
537
+ : "aui:datetime:time:when:",
538
+ label: timeLabel,
539
+ ...(value ? { value: "17:00" } : {}),
540
+ },
541
+ ]);
542
+ expect(result.action.data.aui).not.toHaveProperty("temporal");
543
+ expect(result.decoded).toEqual({
544
+ type: "pick",
545
+ selected: value,
546
+ $input: { when: value },
547
+ });
548
+ },
549
+ );
550
+
551
+ it.each([
552
+ ["2025-12-15T17:00", "When time"],
553
+ ["2025-12-15T17:00:00Z", "When time (UTC)"],
554
+ ["2025-12-15T17:00:00+02:00", "When time (UTC+02:00)"],
555
+ ])("labels both datetime inputs for %s", (value, timeLabel) => {
556
+ const { card } = picker("datetime", value);
557
+ expect(card.body.slice(0, 2)).toMatchObject([
558
+ { type: "Input.Date", label: "When" },
559
+ { type: "Input.Time", label: timeLabel },
560
+ ]);
561
+ });
562
+
563
+ it("names only the offset of an unlabeled instant", () => {
564
+ const { card } = toAdaptiveCard({
565
+ $type: "DatePicker",
566
+ name: "when",
567
+ inputType: "datetime",
568
+ value: "2025-12-15T17:00:00Z",
569
+ });
570
+ expect(card.body).toEqual([
571
+ { type: "Input.Date", id: "aui:datetime:date:when", value: "2025-12-15" },
572
+ {
573
+ type: "Input.Time",
574
+ id: "aui:datetime:time:when:2025-12-15T17%3A00%3A00Z",
575
+ label: "Time (UTC)",
576
+ value: "17:00",
577
+ },
578
+ ]);
579
+ });
580
+
581
+ it("leaves both datetime inputs unlabeled for an unlabeled local value", () => {
582
+ const { card } = toAdaptiveCard({
583
+ $type: "DatePicker",
584
+ name: "when",
585
+ inputType: "datetime",
586
+ value: "2025-12-15T17:00",
587
+ });
588
+ expect(card.body).toEqual([
589
+ { type: "Input.Date", id: "aui:datetime:date:when", value: "2025-12-15" },
590
+ { type: "Input.Time", id: "aui:datetime:time:when:", value: "17:00" },
591
+ ]);
592
+ });
593
+
594
+ it("submits a newly selected empty datetime as a local wall minute", () => {
595
+ const result = submit("datetime", "", {
596
+ when: "2025-12-15",
597
+ when_time: "17:00",
598
+ });
599
+ expect(result.decoded).toEqual({
600
+ type: "pick",
601
+ selected: "2025-12-15T17:00",
602
+ $input: { when: "2025-12-15T17:00" },
603
+ });
604
+ });
605
+
606
+ describe.each(["2025-12-15T17:00:00Z", "2025-12-15T17:00", ""])(
607
+ "clearing datetime %j",
608
+ (value) => {
609
+ it.each([
610
+ { when: "", when_time: "17:00" },
611
+ { when: "2025-12-15", when_time: "" },
612
+ ])(
613
+ "clears the field when either datetime half is empty (%j)",
614
+ (overrides) => {
615
+ expect(submit("datetime", value, overrides).decoded).toEqual({
616
+ type: "pick",
617
+ selected: "",
618
+ $input: { when: "" },
619
+ });
620
+ },
621
+ );
622
+ },
623
+ );
624
+
625
+ it("keeps the instant's offset spelling and fractional precision after an edit", () => {
626
+ const result = submit("datetime", "2025-12-15T17:00:30.123456-00:00", {
627
+ when: "2025-12-16",
628
+ when_time: "18:20",
629
+ });
630
+ expect(result.decoded?.["selected"]).toBe(
631
+ "2025-12-16T18:20:30.123456-00:00",
632
+ );
633
+ });
634
+
635
+ it("uses the instant's date parts for bounds and leaves its time unbounded", () => {
636
+ const { card } = toAdaptiveCard({
637
+ $type: "DatePicker",
638
+ name: "when",
639
+ label: "When",
640
+ inputType: "datetime",
641
+ value: "2025-12-15T17:00:00+02:00",
642
+ min: "2025-12-14T23:30:00Z",
643
+ max: "2025-12-20T23:30:00Z",
644
+ });
645
+ expect(card.body).toEqual([
646
+ {
647
+ type: "Input.Date",
648
+ id: "aui:datetime:date:when",
649
+ label: "When",
650
+ value: "2025-12-15",
651
+ min: "2025-12-15",
652
+ max: "2025-12-21",
653
+ },
654
+ {
655
+ type: "Input.Time",
656
+ id: "aui:datetime:time:when:2025-12-15T17%3A00%3A00%2B02%3A00",
657
+ label: "When time (UTC+02:00)",
658
+ value: "17:00",
659
+ },
660
+ ]);
661
+ });
662
+
663
+ it("resolves $field references in an action before the paired inputs", () => {
664
+ const { card } = toAdaptiveCard([
665
+ {
666
+ $type: "Button",
667
+ label: "Save",
668
+ $action: { type: "save", selected: { $field: "when" } },
669
+ },
670
+ {
671
+ $type: "DatePicker",
672
+ name: "when",
673
+ inputType: "datetime",
674
+ value: "2025-12-15T17:00:00Z",
675
+ },
676
+ ]);
677
+ const button = card.body[0];
678
+ if (button?.type !== "ActionSet") throw new Error("Missing button");
679
+ expect(
680
+ decodeSubmitData({
681
+ ...button.actions[0]!.data,
682
+ "aui:datetime:date:when": "2025-12-16",
683
+ "aui:datetime:time:when:2025-12-15T17%3A00%3A00Z": "18:20",
684
+ }),
685
+ ).toEqual({
686
+ type: "save",
687
+ selected: "2025-12-16T18:20:00Z",
688
+ $input: { when: "2025-12-16T18:20:00Z" },
689
+ });
690
+ });
691
+
692
+ it("reserves both datetime ids when another input already uses the time id", () => {
693
+ const { card } = toAdaptiveCard([
694
+ { $type: "Input", name: "when_time" },
695
+ {
696
+ $type: "DatePicker",
697
+ name: "when",
698
+ inputType: "datetime",
699
+ value: "2025-12-15T17:00",
700
+ $action: { type: "pick", selected: { $field: "when" } },
701
+ },
702
+ ]);
703
+ expect(
704
+ card.body.map((element) => ("id" in element ? element.id : undefined)),
705
+ ).toEqual([
706
+ "when_time",
707
+ "aui:datetime:date:when",
708
+ "aui:datetime:time:when:",
709
+ undefined,
710
+ ]);
711
+ const actionSet = card.body[3];
712
+ if (actionSet?.type !== "ActionSet") throw new Error("Missing action");
713
+ expect(
714
+ decodeSubmitData({
715
+ ...actionSet.actions[0]!.data,
716
+ when_time: "other",
717
+ "aui:datetime:date:when": "2025-12-15",
718
+ "aui:datetime:time:when:": "17:00",
719
+ }),
720
+ ).toEqual({
721
+ type: "pick",
722
+ selected: "2025-12-15T17:00",
723
+ $input: { when_time: "other", when: "2025-12-15T17:00" },
724
+ });
725
+ });
726
+
727
+ it("keeps the reserved aui key for the action envelope", () => {
728
+ const { card } = toAdaptiveCard({
729
+ $type: "DatePicker",
730
+ name: "aui",
731
+ inputType: "datetime",
732
+ value: "2025-12-15T17:00",
733
+ $action: { type: "pick" },
734
+ });
735
+ expect(
736
+ card.body.map((element) => ("id" in element ? element.id : undefined)),
737
+ ).toEqual(["aui:datetime:date:aui_", "aui:datetime:time:aui_:", undefined]);
738
+ const actionSet = card.body[2];
739
+ if (actionSet?.type !== "ActionSet") throw new Error("Missing action");
740
+ expect(
741
+ decodeSubmitData({
742
+ ...actionSet.actions[0]!.data,
743
+ "aui:datetime:date:aui_": "2025-12-15",
744
+ "aui:datetime:time:aui_:": "17:00",
745
+ }),
746
+ ).toEqual({ type: "pick", $input: { aui_: "2025-12-15T17:00" } });
747
+ });
748
+
749
+ it("uses a field fallback when neither datetime input was submitted", () => {
750
+ const { card } = toAdaptiveCard({
751
+ $type: "DatePicker",
752
+ name: "when",
753
+ inputType: "datetime",
754
+ $action: {
755
+ type: "pick",
756
+ selected: { $field: "when", fallback: "missing" },
757
+ },
758
+ });
759
+ const actionSet = card.body[2];
760
+ if (actionSet?.type !== "ActionSet") throw new Error("Missing action");
761
+ expect(decodeSubmitData(actionSet.actions[0]!.data)).toEqual({
762
+ type: "pick",
763
+ selected: "missing",
764
+ });
765
+ });
766
+ });