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

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 +153 -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 +116 -12
  49. package/dist/vocabulary/interactive.js.map +1 -1
  50. package/package.json +6 -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 +337 -0
  69. package/src/temporal.ts +263 -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 +1457 -0
  73. package/src/vocabulary/interactive.dom.test.tsx +59 -11
  74. package/src/vocabulary/interactive.tsx +212 -15
@@ -12,6 +12,11 @@ import { createActionRegistry, type ActionHandler } from "../actionRegistry";
12
12
  import { AnsweredValuesProvider } from "../answeredValues";
13
13
  import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
14
14
  import { renderGenerativeUI } from "../renderGenerativeUI";
15
+ import {
16
+ fromLocalDateTime,
17
+ normalizeTemporalInputValue,
18
+ toLocalDateTime,
19
+ } from "../temporal";
15
20
  import { defaultGenerativeUILibrary } from "./index";
16
21
 
17
22
  (
@@ -1533,11 +1538,19 @@ describe.each([
1533
1538
  type: "number",
1534
1539
  },
1535
1540
  {
1536
- title: "date and time",
1541
+ title: "floating date and time",
1537
1542
  field: { component: "DateTimeInput", enableDate: true, enableTime: true },
1538
- initial: "2025-12-15T17:00:00Z",
1539
- edited: "2025-12-16T08:30:45+02:00",
1540
- type: "text",
1543
+ initial: "2025-12-15T17:00",
1544
+ edited: "2025-12-16T08:30",
1545
+ type: "datetime-local",
1546
+ },
1547
+ {
1548
+ title: "floating date and time with seconds",
1549
+ field: { component: "DateTimeInput", enableDate: true, enableTime: true },
1550
+ initial: "2025-12-15T17:00:00",
1551
+ displayed: "2025-12-15T17:00",
1552
+ edited: "2025-12-16T08:30",
1553
+ type: "datetime-local",
1541
1554
  },
1542
1555
  {
1543
1556
  title: "timestamp in date mode",
@@ -1550,8 +1563,17 @@ describe.each([
1550
1563
  title: "time only",
1551
1564
  field: { component: "DateTimeInput", enableTime: true },
1552
1565
  initial: "17:00:00",
1566
+ displayed: "17:00",
1553
1567
  edited: "08:30:45",
1554
- type: "text",
1568
+ type: "time",
1569
+ },
1570
+ {
1571
+ title: "fractional time",
1572
+ field: { component: "DateTimeInput", enableTime: true },
1573
+ initial: "17:00:00.000",
1574
+ displayed: "17:00",
1575
+ edited: "08:30",
1576
+ type: "time",
1555
1577
  },
1556
1578
  {
1557
1579
  title: "omitted temporal flags",
@@ -1593,15 +1615,15 @@ describe.each([
1593
1615
  type: "number",
1594
1616
  },
1595
1617
  {
1596
- title: "unresolved date and time",
1597
- field: { component: "DateTimeInput", enableDate: true, enableTime: true },
1618
+ title: "unresolved time",
1619
+ field: { component: "DateTimeInput", enableTime: true },
1598
1620
  initial: undefined,
1599
- edited: "2025-12-15T17:00:00Z",
1600
- type: "text",
1621
+ edited: "08:30",
1622
+ type: "time",
1601
1623
  },
1602
1624
  ])(
1603
1625
  "preserves untouched, edited, and cleared $title values",
1604
- async ({ field, initial, edited, type }) => {
1626
+ async ({ field, initial, displayed, edited, type }) => {
1605
1627
  const { container, render, submit } = await mountSurface(field, initial);
1606
1628
  const input = () =>
1607
1629
  container.querySelector<HTMLInputElement>('input[name="/form/value"]')!;
@@ -1620,7 +1642,7 @@ describe.each([
1620
1642
 
1621
1643
  expect(input().type).toBe(type);
1622
1644
  expect(input().getAttribute("aria-label")).toBe("Value");
1623
- expect(input().value).toBe(initial ?? "");
1645
+ expect(input().value).toBe(displayed ?? initial ?? "");
1624
1646
  if (type === "number") expect(input().step).toBe("any");
1625
1647
  await submit(initial ?? "");
1626
1648
 
@@ -1646,6 +1668,32 @@ describe.each([
1646
1668
  },
1647
1669
  );
1648
1670
 
1671
+ it("preserves an instant offset in the A2UI button context", async () => {
1672
+ const initial = "2025-12-15T17:00:00+02:00";
1673
+ const { container, submit } = await mountSurface(
1674
+ { component: "DateTimeInput", enableDate: true, enableTime: true },
1675
+ initial,
1676
+ );
1677
+ const input = container.querySelector<HTMLInputElement>(
1678
+ 'input[name="/form/value"]',
1679
+ )!;
1680
+ expect(input.type).toBe("datetime-local");
1681
+ expect(input.value).toBe(
1682
+ normalizeTemporalInputValue(toLocalDateTime(initial)),
1683
+ );
1684
+ await submit(initial);
1685
+
1686
+ await act(async () => {
1687
+ Object.getOwnPropertyDescriptor(
1688
+ HTMLInputElement.prototype,
1689
+ "value",
1690
+ )!.set!.call(input, "2025-12-16T09:30");
1691
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1692
+ });
1693
+ expect(input.value).toBe("2025-12-16T09:30");
1694
+ await submit(fromLocalDateTime("2025-12-16T09:30", initial));
1695
+ });
1696
+
1649
1697
  it.each([undefined, "Pick one"])(
1650
1698
  "keeps chips empty until selected with placeholder %s",
1651
1699
  async (placeholder) => {
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import {
4
4
  CHECKBOX_GROUP_ATTR,
5
5
  FIELD_NAME_ATTR,
6
+ FIELD_VALUE_ATTR,
6
7
  GENERATED_NAME_ATTR,
7
8
  } from "../constants";
8
9
  import type { Action } from "../ir";
@@ -17,6 +18,14 @@ import { useAnsweredValue } from "../answeredValues";
17
18
  import { actionAttr, fire } from "./dispatch";
18
19
  import { toTextContent } from "./toTextContent";
19
20
  import { useRadioGroupName } from "../RadioGroupScope";
21
+ import {
22
+ classifyTemporal,
23
+ fromLocalDateTime,
24
+ getTemporalInputStep,
25
+ normalizeTemporalInputValue,
26
+ toLocalDateTime,
27
+ toPickerLocalDateTime,
28
+ } from "../temporal";
20
29
 
21
30
  const optionSchema = z.object({
22
31
  label: z.string(),
@@ -368,6 +377,7 @@ function InputRender({
368
377
  }
369
378
 
370
379
  type DatePickerRenderProps = {
380
+ inputType?: "date" | "datetime" | "time";
371
381
  value?: string;
372
382
  min?: string;
373
383
  max?: string;
@@ -378,7 +388,12 @@ type DatePickerRenderProps = {
378
388
  $dispatch?: GenerativeUIDispatch;
379
389
  };
380
390
 
391
+ const subscribeToHydration = () => () => {};
392
+ const clientHydrationSnapshot = () => true;
393
+ const serverHydrationSnapshot = () => false;
394
+
381
395
  function DatePickerRender({
396
+ inputType = "date",
382
397
  value,
383
398
  min,
384
399
  max,
@@ -392,25 +407,186 @@ function DatePickerRender({
392
407
  const isBound = updateBinding !== undefined;
393
408
  const initialValue =
394
409
  typeof answeredValue === "string" ? answeredValue : value;
410
+ const [selection, setSelection] = React.useState({
411
+ initialValue,
412
+ value: initialValue,
413
+ anchor: initialValue,
414
+ committedValue: initialValue,
415
+ edited: false,
416
+ });
417
+ if (selection.initialValue !== initialValue) {
418
+ const isEcho = isBound && initialValue === selection.value;
419
+ setSelection({
420
+ initialValue,
421
+ value: initialValue,
422
+ anchor: isEcho ? selection.anchor : initialValue,
423
+ committedValue: isEcho ? selection.committedValue : initialValue,
424
+ edited: isEcho && selection.edited,
425
+ });
426
+ }
427
+ const currentValue = isBound ? initialValue : selection.value;
428
+ const temporal = classifyTemporal(currentValue ?? "");
429
+ const anchor = classifyTemporal(selection.anchor ?? "");
430
+ const hasInstantAnchor =
431
+ inputType === "datetime" && anchor.kind === "instant";
432
+ const canonicalFieldValue = (value: string | undefined) => {
433
+ const current = value ?? "";
434
+ const kind = classifyTemporal(current).kind;
435
+ if (inputType === "time")
436
+ return kind === "time" && normalizeTemporalInputValue(current) !== current
437
+ ? value
438
+ : undefined;
439
+ if (inputType !== "datetime") return undefined;
440
+ if (kind === "instant") return value;
441
+ if (hasInstantAnchor) return selection.anchor;
442
+ return kind === "floating" &&
443
+ normalizeTemporalInputValue(current) !== current
444
+ ? value
445
+ : undefined;
446
+ };
447
+ const minimum = classifyTemporal(min ?? "");
448
+ const maximum = classifyTemporal(max ?? "");
449
+ const hydrated = React.useSyncExternalStore(
450
+ subscribeToHydration,
451
+ clientHydrationSnapshot,
452
+ serverHydrationSnapshot,
453
+ );
454
+ const showRawValue =
455
+ inputType === "datetime" && temporal.kind === "instant" && !hydrated;
456
+ const displayValue =
457
+ currentValue === undefined
458
+ ? undefined
459
+ : inputType === "datetime" && hydrated
460
+ ? toPickerLocalDateTime(currentValue)
461
+ : normalizeTemporalInputValue(currentValue);
462
+ const keyboardActive = React.useRef(false);
463
+ const valueFromInput = (input: HTMLInputElement) => {
464
+ const inputValue = normalizeTemporalInputValue(input.value);
465
+ return inputType === "datetime"
466
+ ? fromLocalDateTime(inputValue, selection.anchor)
467
+ : inputValue;
468
+ };
469
+ const commit = (input: HTMLInputElement) => {
470
+ if (!selection.edited) return;
471
+ const nextValue = valueFromInput(input);
472
+ setSelection({ ...selection, committedValue: nextValue, edited: false });
473
+ if (nextValue !== selection.committedValue)
474
+ fire($action, $dispatch, nextValue, input);
475
+ };
395
476
  return (
396
477
  <input
397
478
  key={isBound ? undefined : initialValue}
398
- type="date"
479
+ type={
480
+ showRawValue
481
+ ? "text"
482
+ : inputType === "datetime"
483
+ ? "datetime-local"
484
+ : inputType
485
+ }
486
+ readOnly={showRawValue || undefined}
399
487
  data-aui="datepicker"
400
488
  data-aui-action={actionAttr($action)}
489
+ {...{
490
+ [FIELD_VALUE_ATTR]: canonicalFieldValue(currentValue),
491
+ }}
401
492
  name={name}
402
493
  aria-label={label}
403
- {...(isBound
404
- ? {
405
- value:
406
- typeof answeredValue === "string" ? answeredValue : (value ?? ""),
407
- }
408
- : { defaultValue: initialValue })}
409
- min={min}
410
- max={max}
494
+ {...(isBound || inputType === "datetime"
495
+ ? { value: displayValue ?? "" }
496
+ : { defaultValue: displayValue })}
497
+ min={
498
+ inputType === "datetime" && hydrated && min !== undefined
499
+ ? normalizeTemporalInputValue(
500
+ toLocalDateTime(
501
+ min,
502
+ minimum.kind === "instant" ? minimum.precision : undefined,
503
+ ),
504
+ )
505
+ : min === undefined
506
+ ? undefined
507
+ : normalizeTemporalInputValue(min)
508
+ }
509
+ max={
510
+ inputType === "datetime" && hydrated && max !== undefined
511
+ ? normalizeTemporalInputValue(
512
+ toLocalDateTime(
513
+ max,
514
+ maximum.kind === "instant" ? maximum.precision : undefined,
515
+ ),
516
+ )
517
+ : max === undefined
518
+ ? undefined
519
+ : normalizeTemporalInputValue(max)
520
+ }
521
+ step={
522
+ inputType !== "date"
523
+ ? getTemporalInputStep(
524
+ selection.anchor,
525
+ hasInstantAnchor ? displayValue : currentValue,
526
+ min,
527
+ max,
528
+ )
529
+ : undefined
530
+ }
411
531
  onChange={(e) => {
412
- updateBinding?.(e.currentTarget.value);
413
- fire($action, $dispatch, e.currentTarget.value, e.currentTarget);
532
+ const nextValue = valueFromInput(e.currentTarget);
533
+ const canonical = canonicalFieldValue(nextValue);
534
+ if (canonical !== undefined) {
535
+ e.currentTarget.setAttribute(FIELD_VALUE_ATTR, canonical);
536
+ } else {
537
+ e.currentTarget.removeAttribute(FIELD_VALUE_ATTR);
538
+ }
539
+ // A change without an editing key down is a pick from the native picker, which commits at once; a typed value waits for blur or Enter.
540
+ const typed = keyboardActive.current;
541
+ keyboardActive.current = false;
542
+ setSelection({
543
+ initialValue,
544
+ value: nextValue,
545
+ anchor: selection.anchor,
546
+ committedValue: typed ? selection.committedValue : nextValue,
547
+ edited: typed,
548
+ });
549
+ updateBinding?.(nextValue);
550
+ if (!typed && nextValue !== selection.committedValue)
551
+ fire($action, $dispatch, nextValue, e.currentTarget);
552
+ }}
553
+ onBlur={(e) => {
554
+ keyboardActive.current = false;
555
+ commit(e.currentTarget);
556
+ }}
557
+ onKeyDown={(e) => {
558
+ if (
559
+ e.key === "Enter" &&
560
+ !e.nativeEvent.isComposing &&
561
+ !e.currentTarget.form
562
+ ) {
563
+ commit(e.currentTarget);
564
+ return;
565
+ }
566
+ keyboardActive.current =
567
+ !e.altKey &&
568
+ !e.ctrlKey &&
569
+ !e.metaKey &&
570
+ (e.key.length === 1 ||
571
+ [
572
+ "ArrowDown",
573
+ "ArrowUp",
574
+ "Backspace",
575
+ "Delete",
576
+ "End",
577
+ "Home",
578
+ "PageDown",
579
+ "PageUp",
580
+ ].includes(e.key));
581
+ }}
582
+ onKeyUp={() => {
583
+ keyboardActive.current = false;
584
+ }}
585
+ onPaste={() => {
586
+ keyboardActive.current = true;
587
+ }}
588
+ onCut={() => {
589
+ keyboardActive.current = true;
414
590
  }}
415
591
  />
416
592
  );
@@ -837,11 +1013,32 @@ export const interactiveVocabulary = {
837
1013
  },
838
1014
  DatePicker: {
839
1015
  description:
840
- "A date input. Carries `$action` describing the on-select behavior.",
1016
+ "A date, datetime, or time input. Carries `$action`, which runs once per committed value: a pick from the picker, or a typed value on blur or Enter.",
841
1017
  properties: z.object({
842
- value: z.string().optional().describe("Initial date (YYYY-MM-DD)."),
843
- min: z.string().optional().describe("Minimum date (YYYY-MM-DD)."),
844
- max: z.string().optional().describe("Maximum date (YYYY-MM-DD)."),
1018
+ inputType: z
1019
+ .enum(["date", "datetime", "time"])
1020
+ .optional()
1021
+ .describe(
1022
+ 'Temporal input type. Defaults to "date". Values remain strings.',
1023
+ ),
1024
+ value: z
1025
+ .string()
1026
+ .optional()
1027
+ .describe(
1028
+ "Initial value: YYYY-MM-DD for date, HH:mm or HH:mm:ss with optional fraction of at most 9 digits for time, YYYY-MM-DDTHH:mm with optional :ss and fraction of at most 9 digits for datetime. A datetime with Z or ±HH:mm is an instant, displayed in the viewer's time zone and submitted with the same offset and precision. A datetime without an offset is local and submitted unchanged. An empty datetime submits with the viewer's offset and seconds.",
1029
+ ),
1030
+ min: z
1031
+ .string()
1032
+ .optional()
1033
+ .describe(
1034
+ "Minimum value: YYYY-MM-DD for date, HH:mm or HH:mm:ss with optional fraction of at most 9 digits for time, YYYY-MM-DDTHH:mm with optional :ss and fraction of at most 9 digits for datetime. Datetimes with Z or ±HH:mm are converted to the viewer's time zone.",
1035
+ ),
1036
+ max: z
1037
+ .string()
1038
+ .optional()
1039
+ .describe(
1040
+ "Maximum value: YYYY-MM-DD for date, HH:mm or HH:mm:ss with optional fraction of at most 9 digits for time, YYYY-MM-DDTHH:mm with optional :ss and fraction of at most 9 digits for datetime. Datetimes with Z or ±HH:mm are converted to the viewer's time zone.",
1041
+ ),
845
1042
  label: z
846
1043
  .string()
847
1044
  .optional()