@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
@@ -0,0 +1,56 @@
1
+ import { classifyTemporal } from "../temporal";
2
+ import type { TeamsTemporalField } from "./types";
3
+
4
+ export const TEMPORAL_INPUT_PREFIX = "aui:datetime:";
5
+ const encodeFieldId = (value: string): string =>
6
+ Array.from(value, (char) =>
7
+ /^[\uD800-\uDFFF]$/.test(char)
8
+ ? `%u${char.charCodeAt(0).toString(16).toUpperCase()}`
9
+ : encodeURIComponent(char),
10
+ ).join("");
11
+
12
+ const decodeFieldId = (value: string): string =>
13
+ value
14
+ .split(/(%uD[89A-F][0-9A-F]{2})/)
15
+ .map((part, index) =>
16
+ index % 2 === 0
17
+ ? decodeURIComponent(part)
18
+ : String.fromCharCode(Number.parseInt(part.slice(2), 16)),
19
+ )
20
+ .join("");
21
+
22
+ export const encodeTemporalInputId = (field: TeamsTemporalField): string =>
23
+ `${TEMPORAL_INPUT_PREFIX}${field.role}:${encodeFieldId(field.fieldId)}${field.role === "time" ? `:${encodeURIComponent(field.previousValue ?? "")}` : ""}`;
24
+
25
+ export const decodeTemporalInputId = (
26
+ id: string,
27
+ ): TeamsTemporalField | undefined => {
28
+ try {
29
+ const parts = id.slice(TEMPORAL_INPUT_PREFIX.length).split(":");
30
+ const role = parts[0];
31
+ if (
32
+ !id.startsWith(TEMPORAL_INPUT_PREFIX) ||
33
+ (role !== "date" && role !== "time") ||
34
+ parts.length !== (role === "date" ? 2 : 3)
35
+ )
36
+ return undefined;
37
+ const fieldId = decodeFieldId(parts[1]!);
38
+ const previousValue = role === "time" ? decodeURIComponent(parts[2]!) : "";
39
+ if (
40
+ !fieldId ||
41
+ fieldId === "aui" ||
42
+ fieldId.startsWith(TEMPORAL_INPUT_PREFIX) ||
43
+ (previousValue !== "" &&
44
+ classifyTemporal(previousValue).kind !== "instant")
45
+ )
46
+ return undefined;
47
+ const metadata: TeamsTemporalField = {
48
+ fieldId,
49
+ role,
50
+ ...(previousValue ? { previousValue } : {}),
51
+ };
52
+ return encodeTemporalInputId(metadata) === id ? metadata : undefined;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ };
@@ -11,6 +11,11 @@ import {
11
11
  type NormalizedUIElement,
12
12
  type NormalizedUINode,
13
13
  } from "../ir";
14
+ import {
15
+ classifyTemporal,
16
+ splitTemporalMinutes,
17
+ temporalOffsetLabel,
18
+ } from "../temporal";
14
19
  import {
15
20
  factTrend,
16
21
  formatFactDelta,
@@ -26,6 +31,7 @@ import {
26
31
  buildSubmitAction,
27
32
  utf8ByteLength,
28
33
  } from "./constants";
34
+ import { encodeTemporalInputId } from "./temporalId";
29
35
  import type {
30
36
  AdaptiveCardResult,
31
37
  TeamsActionSet,
@@ -108,7 +114,12 @@ function reservedSafeId(
108
114
  context: ConversionContext,
109
115
  ): string {
110
116
  const reserved = id === RESERVED_INPUT_ID;
111
- const base = reserved ? `${RESERVED_INPUT_ID}_` : id;
117
+ const temporalReserved = /^_*aui:datetime:/.test(id);
118
+ const base = reserved
119
+ ? `${RESERVED_INPUT_ID}_`
120
+ : temporalReserved
121
+ ? `_${id}`
122
+ : id;
112
123
  let candidate = base;
113
124
  let n = 2;
114
125
  while (context.usedInputIds.has(candidate)) {
@@ -122,6 +133,13 @@ function reservedSafeId(
122
133
  component,
123
134
  `the input id "${RESERVED_INPUT_ID}" collides with the submit envelope's reserved key and was renamed to "${candidate}".`,
124
135
  );
136
+ } else if (temporalReserved) {
137
+ warn(
138
+ context,
139
+ "fallback",
140
+ component,
141
+ `the input id "${id}" collides with the reserved datetime namespace and was renamed to "${candidate}".`,
142
+ );
125
143
  } else if (candidate !== base) {
126
144
  warn(
127
145
  context,
@@ -593,6 +611,149 @@ export function convertElement(
593
611
  case "DatePicker": {
594
612
  const name = asString(props["name"]);
595
613
  const label = asString(props["label"]);
614
+ const mode = props["inputType"];
615
+ if (mode === "time") {
616
+ const id = reservedSafeId(name || "datepicker", "DatePicker", context);
617
+ const rawValue = asString(props["value"]);
618
+ const parts = splitTemporalMinutes(rawValue);
619
+ if (
620
+ classifyTemporal(rawValue).kind === "time" &&
621
+ parts?.droppedPrecision
622
+ ) {
623
+ warn(
624
+ context,
625
+ "dropped",
626
+ "DatePicker",
627
+ "Nonzero seconds were dropped from the time value.",
628
+ );
629
+ }
630
+ const value = parts?.time;
631
+ const rawMin = asString(props["min"]);
632
+ const minParts = splitTemporalMinutes(rawMin);
633
+ let min = minParts?.time;
634
+ if (
635
+ classifyTemporal(rawMin).kind === "time" &&
636
+ minParts?.droppedPrecision
637
+ ) {
638
+ const minuteOfDay =
639
+ Number(minParts.time.slice(0, 2)) * 60 +
640
+ Number(minParts.time.slice(3, 5)) +
641
+ 1;
642
+ if (minuteOfDay === 24 * 60) {
643
+ min = undefined;
644
+ warn(
645
+ context,
646
+ "dropped",
647
+ "DatePicker",
648
+ "The time minimum rounds past 23:59 and was dropped.",
649
+ );
650
+ } else {
651
+ min = `${String(Math.floor(minuteOfDay / 60)).padStart(2, "0")}:${String(minuteOfDay % 60).padStart(2, "0")}`;
652
+ }
653
+ }
654
+ const rawMax = asString(props["max"]);
655
+ const maxParts = splitTemporalMinutes(rawMax);
656
+ const max = maxParts?.time;
657
+ if (
658
+ classifyTemporal(rawMax).kind === "time" &&
659
+ maxParts?.droppedPrecision
660
+ ) {
661
+ warn(
662
+ context,
663
+ "dropped",
664
+ "DatePicker",
665
+ rawMax.slice(6, 8) === "00"
666
+ ? "Nonzero fractional seconds were dropped from the time maximum."
667
+ : /\.\d*[1-9]/.test(rawMax)
668
+ ? "Nonzero seconds and fractional seconds were dropped from the time maximum."
669
+ : "Nonzero seconds were dropped from the time maximum.",
670
+ );
671
+ }
672
+ const input: TeamsCardElement = {
673
+ type: "Input.Time",
674
+ id,
675
+ ...(label ? { label } : {}),
676
+ ...(classifyTemporal(rawValue).kind === "time" && value !== undefined
677
+ ? { value }
678
+ : {}),
679
+ ...(classifyTemporal(rawMin).kind === "time" && min !== undefined
680
+ ? { min }
681
+ : {}),
682
+ ...(classifyTemporal(rawMax).kind === "time" && max !== undefined
683
+ ? { max }
684
+ : {}),
685
+ };
686
+ return withCompanionSubmit(element, input, context);
687
+ }
688
+ if (mode === "datetime") {
689
+ const rawValue = asString(props["value"]);
690
+ const temporal = classifyTemporal(rawValue);
691
+ const fieldId = reservedSafeId(
692
+ name || "datepicker",
693
+ "DatePicker",
694
+ context,
695
+ );
696
+ const dateId = encodeTemporalInputId({ fieldId, role: "date" });
697
+ const timeId = encodeTemporalInputId({
698
+ fieldId,
699
+ role: "time",
700
+ ...(temporal.kind === "instant" ? { previousValue: rawValue } : {}),
701
+ });
702
+ const parts = splitTemporalMinutes(rawValue);
703
+ if (temporal.kind === "floating" && parts?.droppedPrecision) {
704
+ warn(
705
+ context,
706
+ "dropped",
707
+ "DatePicker",
708
+ "Nonzero seconds were dropped from the datetime value.",
709
+ );
710
+ }
711
+ const offsetLabel = temporalOffsetLabel(rawValue);
712
+ const boundDate = (raw: unknown) => {
713
+ const value = asString(raw);
714
+ return classifyTemporal(value).kind === "date"
715
+ ? value
716
+ : splitTemporalMinutes(
717
+ value,
718
+ temporal.kind === "instant" ? temporal.offset : undefined,
719
+ )?.date;
720
+ };
721
+ const min = boundDate(props["min"]);
722
+ const max = boundDate(props["max"]);
723
+ const dateInput: TeamsCardElement = {
724
+ type: "Input.Date",
725
+ id: dateId,
726
+ ...(label ? { label } : {}),
727
+ ...(parts?.date !== undefined ? { value: parts.date } : {}),
728
+ ...(min !== undefined ? { min } : {}),
729
+ ...(max !== undefined ? { max } : {}),
730
+ };
731
+ const timeInput: TeamsCardElement = {
732
+ type: "Input.Time",
733
+ id: timeId,
734
+ ...(label || offsetLabel
735
+ ? {
736
+ label: `${label ? `${label} time` : "Time"}${offsetLabel ? ` (${offsetLabel})` : ""}`,
737
+ }
738
+ : {}),
739
+ ...(parts?.time !== undefined ? { value: parts.time } : {}),
740
+ };
741
+ if (element.action === undefined) return [dateInput, timeInput];
742
+ const [input, submit] = withCompanionSubmit(
743
+ element,
744
+ timeInput,
745
+ context,
746
+ );
747
+ return [dateInput, input!, submit!];
748
+ }
749
+ if (mode !== undefined && mode !== "date") {
750
+ warn(
751
+ context,
752
+ "dropped",
753
+ "DatePicker",
754
+ "Unsupported inputType was dropped; the picker was rendered as a date input.",
755
+ );
756
+ }
596
757
  const rawValue = props["value"];
597
758
  const value =
598
759
  typeof rawValue === "string" && DATE_PATTERN.test(rawValue)
@@ -868,7 +1029,10 @@ export function toAdaptiveCard(
868
1029
  node: unknown,
869
1030
  _options?: ToAdaptiveCardOptions,
870
1031
  ): AdaptiveCardResult {
871
- const context: ConversionContext = { warnings: [], usedInputIds: new Set() };
1032
+ const context: ConversionContext = {
1033
+ warnings: [],
1034
+ usedInputIds: new Set(),
1035
+ };
872
1036
  try {
873
1037
  const bounded = boundSpec(node, (reason) =>
874
1038
  warn(context, "clamped", "Root", clampReasonDetail(reason)),
@@ -77,7 +77,10 @@ export function toTeamsAttachments(
77
77
  _options?: ToAdaptiveCardOptions,
78
78
  ): TeamsAttachmentsResult {
79
79
  const warnings: TeamsConversionWarning[] = [];
80
- const context: ConversionContext = { warnings, usedInputIds: new Set() };
80
+ const context: ConversionContext = {
81
+ warnings,
82
+ usedInputIds: new Set(),
83
+ };
81
84
  try {
82
85
  const bounded = boundSpec(node, (reason) =>
83
86
  warnings.push({
@@ -55,6 +55,11 @@ export interface TeamsSubmitData {
55
55
  };
56
56
  }
57
57
 
58
+ export type TeamsTemporalField = { readonly fieldId: string } & (
59
+ | { readonly role: "date" }
60
+ | { readonly role: "time"; readonly previousValue?: string }
61
+ );
62
+
58
63
  /**
59
64
  * A submit action. `data` carries the resume payload under an `aui` key so
60
65
  * {@link decodeSubmitData} can invert it out of the merged `activity.value`
@@ -160,6 +165,17 @@ export interface TeamsInputDate {
160
165
  readonly spacing?: "large";
161
166
  }
162
167
 
168
+ export interface TeamsInputTime {
169
+ readonly type: "Input.Time";
170
+ readonly id: string;
171
+ readonly label?: string;
172
+ readonly value?: string;
173
+ readonly min?: string;
174
+ readonly max?: string;
175
+ readonly separator?: true;
176
+ readonly spacing?: "large";
177
+ }
178
+
163
179
  /** One cell inside a {@link TeamsTableRow}. */
164
180
  export interface TeamsTableCell {
165
181
  readonly type: "TableCell";
@@ -199,6 +215,7 @@ export type TeamsCardElement =
199
215
  | TeamsInputToggle
200
216
  | TeamsInputText
201
217
  | TeamsInputDate
218
+ | TeamsInputTime
202
219
  | TeamsTable;
203
220
 
204
221
  /** A Microsoft Teams Adaptive Card, pinned to schema version 1.5 (the Teams desktop/web cap; mobile clients cap at 1.2). */
package/src/teams.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  TeamsInputChoiceSet,
22
22
  TeamsInputDate,
23
23
  TeamsInputText,
24
+ TeamsInputTime,
24
25
  TeamsInputToggle,
25
26
  TeamsSubmitAction,
26
27
  TeamsSubmitData,
@@ -0,0 +1,337 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ classifyTemporal,
4
+ formatTemporalInstant,
5
+ formatTemporalUnixSeconds,
6
+ fromLocalDateTime,
7
+ fromOffsetDateTime,
8
+ mergeTemporalMinutes,
9
+ normalizeTemporalInputValue,
10
+ splitTemporalMinutes,
11
+ temporalOffsetLabel,
12
+ toLocalDateTime,
13
+ toPickerLocalDateTime,
14
+ } from "./temporal";
15
+
16
+ describe("temporal values", () => {
17
+ it("splits instants in their own offset or another offset", () => {
18
+ expect(splitTemporalMinutes("2025-12-15T17:00:00+02:00")).toEqual({
19
+ date: "2025-12-15",
20
+ time: "17:00",
21
+ droppedPrecision: false,
22
+ });
23
+ expect(splitTemporalMinutes("2025-12-14T23:30:00Z", "+02:00")).toEqual({
24
+ date: "2025-12-15",
25
+ time: "01:30",
26
+ droppedPrecision: false,
27
+ });
28
+ expect(temporalOffsetLabel("2025-12-15T17:00Z")).toBe("UTC");
29
+ expect(temporalOffsetLabel("2025-12-15T17:00+02:00")).toBe("UTC+02:00");
30
+ });
31
+
32
+ it("merges a wall minute using the original offset and precision", () => {
33
+ expect(
34
+ fromOffsetDateTime(
35
+ "2025-12-16T18:20",
36
+ "2025-12-15T17:00:30.123456+02:00",
37
+ ),
38
+ ).toBe("2025-12-16T18:20:30.123456+02:00");
39
+ expect(fromOffsetDateTime("2025-12-16T18:20", "2025-12-15T17:00:00Z")).toBe(
40
+ "2025-12-16T18:20:00Z",
41
+ );
42
+ expect(fromOffsetDateTime("2025-12-16T18:20")).toBe("2025-12-16T18:20");
43
+ expect(fromOffsetDateTime("", "2025-12-15T17:00Z")).toBe("");
44
+ });
45
+ it.each([
46
+ ["17:00", { time: "17:00", droppedPrecision: false }],
47
+ ["17:00:00", { time: "17:00", droppedPrecision: false }],
48
+ ["17:00:30", { time: "17:00", droppedPrecision: true }],
49
+ [
50
+ "2025-12-15T17:00",
51
+ { date: "2025-12-15", time: "17:00", droppedPrecision: false },
52
+ ],
53
+ [
54
+ "2025-12-15T17:00:00.000",
55
+ { date: "2025-12-15", time: "17:00", droppedPrecision: false },
56
+ ],
57
+ [
58
+ "2025-12-15T17:00:00.001",
59
+ { date: "2025-12-15", time: "17:00", droppedPrecision: true },
60
+ ],
61
+ [
62
+ "2025-12-15T17:00Z",
63
+ { date: "2025-12-15", time: "17:00", droppedPrecision: false },
64
+ ],
65
+ [
66
+ "2025-12-15T17:00:30.001Z",
67
+ { date: "2025-12-15", time: "17:00", droppedPrecision: true },
68
+ ],
69
+ [
70
+ "2025-12-15T17:00:00.000Z",
71
+ { date: "2025-12-15", time: "17:00", droppedPrecision: false },
72
+ ],
73
+ ["2025-12-15", undefined],
74
+ ["invalid", undefined],
75
+ ["", undefined],
76
+ ])("splits %s into minute precision wall-clock parts", (value, expected) => {
77
+ expect(splitTemporalMinutes(value as string)).toEqual(expected);
78
+ });
79
+
80
+ it.each([
81
+ ["2025-12-15", "17:00", "2025-12-15T17:00"],
82
+ ["2025-12-15", "17:00:30", "2025-12-15T17:00"],
83
+ ["", "17:00", ""],
84
+ ["2025-12-15", "", ""],
85
+ ["2025-02-29", "17:00", ""],
86
+ ["2025-12-15", "24:00", ""],
87
+ ])("merges %j and %j without assigning a zone", (date, time, expected) => {
88
+ expect(mergeTemporalMinutes(date!, time!)).toBe(expected);
89
+ });
90
+
91
+ it.each([
92
+ [1765818000, "Z", "2025-12-15T17:00:00Z"],
93
+ [1765810800, "+02:00", "2025-12-15T17:00:00+02:00"],
94
+ [0, "-00:00", "1970-01-01T00:00:00-00:00"],
95
+ [-1, "Z", "1969-12-31T23:59:59Z"],
96
+ [0, "+24:00", undefined],
97
+ [0.5, "Z", undefined],
98
+ [Infinity, "Z", undefined],
99
+ [NaN, "Z", undefined],
100
+ [Number.MAX_SAFE_INTEGER, "Z", undefined],
101
+ ])(
102
+ "formats unix seconds %s with offset %s safely",
103
+ (seconds, offset, expected) => {
104
+ expect(
105
+ formatTemporalUnixSeconds(seconds as number, offset as string),
106
+ ).toBe(expected);
107
+ },
108
+ );
109
+
110
+ it.each([
111
+ ["12:00:00", "12:00"],
112
+ ["12:00:00.000", "12:00"],
113
+ ["12:00:05.1200", "12:00:05.12"],
114
+ ["2025-12-15T12:00:00.000", "2025-12-15T12:00"],
115
+ ["2025-12-15T12:00:05.1200", "2025-12-15T12:00:05.12"],
116
+ ["12:00:05.123456", "12:00:05.123"],
117
+ ["12:00:05.000456", "12:00:05"],
118
+ ["2025-12-15T12:00:05.123456789", "2025-12-15T12:00:05.123"],
119
+ ["2025-12-15", "2025-12-15"],
120
+ ])("normalizes the browser input value %s", (value, expected) => {
121
+ expect(normalizeTemporalInputValue(value!)).toBe(expected);
122
+ });
123
+
124
+ it("completes normalization of a 100000 digit zero fraction", () => {
125
+ const zeros = "0".repeat(100_000);
126
+ for (const value of [zeros, `12:00:00.${zeros}`, `12:00:00.${zeros}1`]) {
127
+ expect(normalizeTemporalInputValue(value)).toBe(value);
128
+ }
129
+ expect(normalizeTemporalInputValue("12:00:00.000000000")).toBe("12:00");
130
+ expect(normalizeTemporalInputValue("12:00:00.100000000")).toBe(
131
+ "12:00:00.1",
132
+ );
133
+ });
134
+
135
+ it("uses a fixed viewer time zone with seasonal offsets", () => {
136
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(
137
+ "America/New_York",
138
+ );
139
+ expect(new Date("2026-01-15T12:00Z").getTimezoneOffset()).toBe(300);
140
+ expect(new Date("2026-07-15T12:00Z").getTimezoneOffset()).toBe(240);
141
+ });
142
+
143
+ it.each(["0002", "0020", "0202"])(
144
+ "preserves minute edits in %s when the zone offset has seconds",
145
+ (year) => {
146
+ const local = `${year}-12-15T12:00`;
147
+ const value = fromLocalDateTime(local, "2025-12-15T17:00Z");
148
+ expect(value).toBe(`${year}-12-15T16:56:02Z`);
149
+ expect(toPickerLocalDateTime(value)).toBe(local);
150
+ },
151
+ );
152
+
153
+ it("preserves entered seconds with a minute precision anchor", () => {
154
+ const local = "2025-12-15T12:00:30";
155
+ const value = fromLocalDateTime(local, "2025-12-15T17:00Z");
156
+ expect(value).toBe("2025-12-15T17:00:30Z");
157
+ expect(toPickerLocalDateTime(value)).toBe(local);
158
+ });
159
+
160
+ it.each([
161
+ ["2026-07-15", "date"],
162
+ ["2024-02-29", "date"],
163
+ ["00:00", "time"],
164
+ ["23:59:59", "time"],
165
+ ["12:34:56.1", "time"],
166
+ ["12:34:56.1200", "time"],
167
+ ["2026-07-15T12:34", "floating"],
168
+ ["2026-07-15T12:34:56", "floating"],
169
+ ["2026-07-15T12:34:56.123456", "floating"],
170
+ ])("classifies and passes through %s as %s", (value, kind) => {
171
+ expect(classifyTemporal(value!)).toMatchObject({ kind, value });
172
+ expect(toLocalDateTime(value!)).toBe(value);
173
+ expect(fromLocalDateTime(value!, value!)).toBe(value);
174
+ });
175
+
176
+ describe.each([
177
+ { offset: "Z", local: "2026-07-15T08:34" },
178
+ { offset: "+08:00", local: "2026-07-15T00:34" },
179
+ { offset: "-05:30", local: "2026-07-15T14:04" },
180
+ ])("$offset instants", ({ offset, local }) => {
181
+ it.each([
182
+ { suffix: "", precision: "minutes" },
183
+ { suffix: ":56", precision: "seconds" },
184
+ { suffix: ":56.1", precision: 1 },
185
+ { suffix: ":56.12", precision: 2 },
186
+ { suffix: ":56.123", precision: 3 },
187
+ { suffix: ":56.123456789", precision: 9 },
188
+ { suffix: ":00.000009", precision: 6 },
189
+ ])(
190
+ "round trips local wall time at $precision precision",
191
+ ({ suffix, precision }) => {
192
+ const value = `2026-07-15T12:34${suffix}${offset}`;
193
+ const parsed = classifyTemporal(value);
194
+ expect(parsed).toMatchObject({
195
+ kind: "instant",
196
+ epochMs: Date.parse(value),
197
+ offset,
198
+ precision,
199
+ });
200
+ if (parsed.kind !== "instant") throw new Error("Expected an instant");
201
+ expect(formatTemporalInstant(parsed)).toBe(value);
202
+ const wallTime = toLocalDateTime(value, parsed.precision);
203
+ expect(wallTime).toBe(`${local}${suffix}`);
204
+ expect(fromLocalDateTime(wallTime, value)).toBe(value);
205
+ },
206
+ );
207
+ });
208
+
209
+ it("formats an instant in another offset and precision across calendar boundaries", () => {
210
+ const parsed = classifyTemporal("2026-01-01T00:15:30.120Z");
211
+ if (parsed.kind !== "instant") throw new Error("Expected an instant");
212
+ expect(formatTemporalInstant(parsed, "-05:30", "minutes")).toBe(
213
+ "2025-12-31T18:45-05:30",
214
+ );
215
+ expect(formatTemporalInstant(parsed, "+08:00", "seconds")).toBe(
216
+ "2026-01-01T08:15:30+08:00",
217
+ );
218
+ expect(formatTemporalInstant(parsed, "Z", 6)).toBe(
219
+ "2026-01-01T00:15:30.120000Z",
220
+ );
221
+ });
222
+
223
+ it("preserves offset spelling and uses the edited fractional digits", () => {
224
+ expect(
225
+ fromLocalDateTime(
226
+ "2026-07-16T10:20:30.123456789",
227
+ "2026-07-15T12:34:56.123456789-00:00",
228
+ ),
229
+ ).toBe("2026-07-16T14:20:30.123456789-00:00");
230
+ });
231
+
232
+ it.each([
233
+ ["2025-12-15T12:00:00.750", "2025-12-15T17:00:00.750Z", 3],
234
+ ["2025-12-15T12:00:00.75", "2025-12-15T17:00:00.750Z", 2],
235
+ ["2025-12-15T12:00:00", "2025-12-15T17:00:00.000Z", undefined],
236
+ ["2025-12-15T12:00:00.250", "2025-12-15T17:00:00.250Z", 3],
237
+ ["2025-12-15T12:00:00.750987", "2025-12-15T17:00:00.750987Z", 6],
238
+ ])(
239
+ "round trips a local fraction edit of %s",
240
+ (local, expected, precision) => {
241
+ const result = fromLocalDateTime(local!, "2025-12-15T17:00:00.250Z");
242
+ expect(result).toBe(expected);
243
+ expect(toLocalDateTime(result, precision)).toBe(local);
244
+ },
245
+ );
246
+
247
+ it.each([
248
+ [
249
+ "2026-07-16T08:34:56",
250
+ "2026-07-15T12:34:56.000456Z",
251
+ "2026-07-16T12:34:56.000456Z",
252
+ ],
253
+ [
254
+ "2026-07-16T08:34",
255
+ "2026-07-15T12:34:00.000456Z",
256
+ "2026-07-16T12:34:00.000456Z",
257
+ ],
258
+ ])(
259
+ "keeps a sub-millisecond tail the control cannot show after an edit to %s",
260
+ (local, anchor, expected) => {
261
+ expect(fromLocalDateTime(local, anchor)).toBe(expected);
262
+ },
263
+ );
264
+
265
+ it("uses zero for a native zero or omitted fraction", () => {
266
+ expect(
267
+ fromLocalDateTime("2025-12-15T12:00:00.000", "2025-12-15T17:00:00.250Z"),
268
+ ).toBe("2025-12-15T17:00:00.000Z");
269
+ expect(
270
+ fromLocalDateTime("2025-12-15T12:00:00", "2025-12-15T17:00:00.250123Z"),
271
+ ).toBe("2025-12-15T17:00:00.000000Z");
272
+ });
273
+
274
+ it.each([
275
+ ["2026-01-15T12:34", "2026-01-15T12:34:00-05:00"],
276
+ ["2026-07-15T12:34", "2026-07-15T12:34:00-04:00"],
277
+ ])("uses the viewer offset at %s for an empty value", (value, expected) => {
278
+ expect(fromLocalDateTime(value!, "")).toBe(expected);
279
+ expect(fromLocalDateTime(value!)).toBe(expected);
280
+ });
281
+
282
+ it.each([
283
+ {
284
+ value: "2026-03-08T02:30:45",
285
+ parts: [2026, 2, 8, 2, 30, 45] as const,
286
+ expected: "2026-03-08T07:30:45.000000Z",
287
+ local: "2026-03-08T03:30:45",
288
+ },
289
+ {
290
+ value: "2026-11-01T01:30:45",
291
+ parts: [2026, 10, 1, 1, 30, 45] as const,
292
+ expected: "2026-11-01T05:30:45.000000Z",
293
+ local: "2026-11-01T01:30:45",
294
+ },
295
+ ])(
296
+ "resolves DST wall time $value like the native constructor",
297
+ ({ value, parts, expected, local }) => {
298
+ const result = fromLocalDateTime(value, "2026-01-15T12:00:00.123456Z");
299
+ expect(result).toBe(expected);
300
+ const [year, month, day, hour, minute, second] = parts;
301
+ expect(Date.parse(result)).toBe(
302
+ new Date(year, month, day, hour, minute, second).getTime(),
303
+ );
304
+ expect(toLocalDateTime(result)).toBe(local);
305
+ expect(fromLocalDateTime(value, value)).toBe(value);
306
+ },
307
+ );
308
+
309
+ it("preserves an empty input when an instant is cleared", () => {
310
+ expect(fromLocalDateTime("", "2026-07-15T12:34:56+08:00")).toBe("");
311
+ });
312
+
313
+ it.each([
314
+ "",
315
+ "not a date",
316
+ "2026-02-29",
317
+ "2026-04-31",
318
+ "2026-13-01",
319
+ "2026-01-00",
320
+ "24:00",
321
+ "12:60",
322
+ "12:34:60",
323
+ "2026-07-15T24:00Z",
324
+ "2026-07-15T12:60Z",
325
+ "2026-07-15T12:34:60Z",
326
+ "2026-02-29T12:34Z",
327
+ "2026-07-15T12:34.1Z",
328
+ "2026-07-15T12:34+24:00",
329
+ "2026-07-15T12:34+08:60",
330
+ "2026-07-15T12:34+0800",
331
+ "2026-07-15 12:34Z",
332
+ ])("classifies %j as unparseable and passes it through", (value) => {
333
+ expect(classifyTemporal(value)).toEqual({ kind: "unparseable", value });
334
+ expect(toLocalDateTime(value)).toBe(value);
335
+ expect(fromLocalDateTime(value)).toBe(value);
336
+ });
337
+ });