@assistant-ui/react-generative-ui 0.0.22 → 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 (74) hide show
  1. package/dist/a2ui/convert.d.ts.map +1 -1
  2. package/dist/a2ui/convert.js +21 -4
  3. package/dist/a2ui/convert.js.map +1 -1
  4. package/dist/constants.d.ts +1 -0
  5. package/dist/constants.d.ts.map +1 -1
  6. package/dist/constants.js +2 -1
  7. package/dist/constants.js.map +1 -1
  8. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  9. package/dist/slack/decodeBlockAction.js +64 -26
  10. package/dist/slack/decodeBlockAction.js.map +1 -1
  11. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  12. package/dist/slack/fromSlackBlocks.js +23 -0
  13. package/dist/slack/fromSlackBlocks.js.map +1 -1
  14. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  15. package/dist/slack/toSlackBlocks.js +74 -18
  16. package/dist/slack/toSlackBlocks.js.map +1 -1
  17. package/dist/slack/types.d.ts +20 -1
  18. package/dist/slack/types.d.ts.map +1 -1
  19. package/dist/slack.d.ts +1 -1
  20. package/dist/slack.d.ts.map +1 -1
  21. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  22. package/dist/teams/decodeSubmitData.js +24 -3
  23. package/dist/teams/decodeSubmitData.js.map +1 -1
  24. package/dist/teams/temporalId.d.ts +5 -0
  25. package/dist/teams/temporalId.d.ts.map +1 -0
  26. package/dist/teams/temporalId.js +28 -0
  27. package/dist/teams/temporalId.js.map +1 -0
  28. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  29. package/dist/teams/toAdaptiveCard.js +80 -1
  30. package/dist/teams/toAdaptiveCard.js.map +1 -1
  31. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  32. package/dist/teams/toTeamsAttachments.js.map +1 -1
  33. package/dist/teams/types.d.ts +19 -1
  34. package/dist/teams/types.d.ts.map +1 -1
  35. package/dist/teams.d.ts +1 -1
  36. package/dist/teams.d.ts.map +1 -1
  37. package/dist/temporal.d.ts +33 -0
  38. package/dist/temporal.d.ts.map +1 -0
  39. package/dist/temporal.js +152 -0
  40. package/dist/temporal.js.map +1 -0
  41. package/dist/vocabulary/collectFormValues.d.ts +1 -1
  42. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  43. package/dist/vocabulary/collectFormValues.js +7 -3
  44. package/dist/vocabulary/collectFormValues.js.map +1 -1
  45. package/dist/vocabulary/data.d.ts +1 -1
  46. package/dist/vocabulary/interactive.d.ts +7 -1
  47. package/dist/vocabulary/interactive.d.ts.map +1 -1
  48. package/dist/vocabulary/interactive.js +62 -12
  49. package/dist/vocabulary/interactive.js.map +1 -1
  50. package/package.json +5 -5
  51. package/src/a2ui/convert.test.ts +223 -14
  52. package/src/a2ui/convert.ts +41 -7
  53. package/src/constants.ts +1 -0
  54. package/src/slack/datePicker.test.ts +612 -0
  55. package/src/slack/decodeBlockAction.ts +114 -41
  56. package/src/slack/fromSlackBlocks.ts +29 -0
  57. package/src/slack/toSlackBlocks.ts +123 -36
  58. package/src/slack/types.ts +24 -0
  59. package/src/slack.ts +2 -0
  60. package/src/teams/decodeSubmitData.test.ts +216 -0
  61. package/src/teams/decodeSubmitData.ts +49 -4
  62. package/src/teams/temporal.test.ts +766 -0
  63. package/src/teams/temporalId.ts +56 -0
  64. package/src/teams/toAdaptiveCard.ts +166 -2
  65. package/src/teams/toTeamsAttachments.ts +4 -1
  66. package/src/teams/types.ts +17 -0
  67. package/src/teams.ts +1 -0
  68. package/src/temporal.test.ts +319 -0
  69. package/src/temporal.ts +259 -0
  70. package/src/vocabulary/collectFormValues.test.ts +198 -1
  71. package/src/vocabulary/collectFormValues.ts +31 -2
  72. package/src/vocabulary/datepicker.dom.test.tsx +1148 -0
  73. package/src/vocabulary/interactive.dom.test.tsx +59 -11
  74. package/src/vocabulary/interactive.tsx +151 -15
@@ -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
+ });