@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.
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +21 -4
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.js +64 -26
- package/dist/slack/decodeBlockAction.js.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.js +23 -0
- package/dist/slack/fromSlackBlocks.js.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +74 -18
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/slack/types.d.ts +20 -1
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/slack.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.js +24 -3
- package/dist/teams/decodeSubmitData.js.map +1 -1
- package/dist/teams/temporalId.d.ts +5 -0
- package/dist/teams/temporalId.d.ts.map +1 -0
- package/dist/teams/temporalId.js +28 -0
- package/dist/teams/temporalId.js.map +1 -0
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +80 -1
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/dist/teams/types.d.ts +19 -1
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/teams.d.ts +1 -1
- package/dist/teams.d.ts.map +1 -1
- package/dist/temporal.d.ts +33 -0
- package/dist/temporal.d.ts.map +1 -0
- package/dist/temporal.js +153 -0
- package/dist/temporal.js.map +1 -0
- package/dist/vocabulary/collectFormValues.d.ts +1 -1
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.js +7 -3
- package/dist/vocabulary/collectFormValues.js.map +1 -1
- package/dist/vocabulary/data.d.ts +1 -1
- package/dist/vocabulary/interactive.d.ts +7 -1
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +116 -12
- package/dist/vocabulary/interactive.js.map +1 -1
- package/package.json +6 -5
- package/src/a2ui/convert.test.ts +223 -14
- package/src/a2ui/convert.ts +41 -7
- package/src/constants.ts +1 -0
- package/src/slack/datePicker.test.ts +612 -0
- package/src/slack/decodeBlockAction.ts +114 -41
- package/src/slack/fromSlackBlocks.ts +29 -0
- package/src/slack/toSlackBlocks.ts +123 -36
- package/src/slack/types.ts +24 -0
- package/src/slack.ts +2 -0
- package/src/teams/decodeSubmitData.test.ts +216 -0
- package/src/teams/decodeSubmitData.ts +49 -4
- package/src/teams/temporal.test.ts +766 -0
- package/src/teams/temporalId.ts +56 -0
- package/src/teams/toAdaptiveCard.ts +166 -2
- package/src/teams/toTeamsAttachments.ts +4 -1
- package/src/teams/types.ts +17 -0
- package/src/teams.ts +1 -0
- package/src/temporal.test.ts +337 -0
- package/src/temporal.ts +263 -0
- package/src/vocabulary/collectFormValues.test.ts +198 -1
- package/src/vocabulary/collectFormValues.ts +31 -2
- package/src/vocabulary/datepicker.dom.test.tsx +1457 -0
- package/src/vocabulary/interactive.dom.test.tsx +59 -11
- 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
|
|
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 = {
|
|
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 = {
|
|
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({
|
package/src/teams/types.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
});
|