@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,259 @@
1
+ export type TemporalPrecision = "minutes" | "seconds" | number;
2
+
3
+ export type TemporalInstant = {
4
+ kind: "instant";
5
+ epochMs: number;
6
+ offset: string;
7
+ precision: TemporalPrecision;
8
+ subMillisecondDigits?: string;
9
+ };
10
+
11
+ export type TemporalValue =
12
+ | { kind: "date" | "unparseable"; value: string }
13
+ | {
14
+ kind: "time" | "floating";
15
+ value: string;
16
+ precision: TemporalPrecision;
17
+ }
18
+ | TemporalInstant;
19
+
20
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
21
+ const TIME_PATTERN =
22
+ /^([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.(\d{1,9}))?)?$/;
23
+ const DATETIME_PATTERN =
24
+ /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.(\d+))?)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$/;
25
+ const INPUT_PATTERN =
26
+ /^((?:\d{4}-\d{2}-\d{2}T)?(?:[01]\d|2[0-3]):[0-5]\d)(?::([0-5]\d)(?:\.(\d{1,9}))?)?$/;
27
+
28
+ export const normalizeTemporalInputValue = (value: string): string => {
29
+ const match = INPUT_PATTERN.exec(value);
30
+ if (!match || match[2] === undefined) return value;
31
+ const digits = match[3]?.slice(0, 3) ?? "";
32
+ let end = digits.length;
33
+ while (end > 0 && digits[end - 1] === "0") end--;
34
+ const fraction = digits.slice(0, end);
35
+ if (match[2] === "00" && !fraction) return match[1]!;
36
+ return `${match[1]}:${match[2]}${fraction ? `.${fraction}` : ""}`;
37
+ };
38
+
39
+ const isCalendarDate = (value: string): boolean => {
40
+ const date = new Date(`${value}T00:00:00Z`);
41
+ return (
42
+ Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value
43
+ );
44
+ };
45
+
46
+ export const classifyTemporal = (value: string): TemporalValue => {
47
+ if (DATE_PATTERN.test(value) && isCalendarDate(value)) {
48
+ return { kind: "date", value };
49
+ }
50
+ const time = TIME_PATTERN.exec(value);
51
+ if (time) {
52
+ return {
53
+ kind: "time",
54
+ value,
55
+ precision:
56
+ time[4]?.length ?? (time[3] === undefined ? "minutes" : "seconds"),
57
+ };
58
+ }
59
+ const datetime = DATETIME_PATTERN.exec(value);
60
+ if (!datetime || !isCalendarDate(datetime[1]!)) {
61
+ return { kind: "unparseable", value };
62
+ }
63
+ const precision =
64
+ datetime[5]?.length ?? (datetime[4] === undefined ? "minutes" : "seconds");
65
+ const offset = datetime[6];
66
+ if (offset === undefined) return { kind: "floating", value, precision };
67
+ return {
68
+ kind: "instant",
69
+ epochMs: Date.parse(value),
70
+ offset,
71
+ precision,
72
+ ...(datetime[5] && datetime[5].length > 3
73
+ ? { subMillisecondDigits: datetime[5].slice(3) }
74
+ : {}),
75
+ };
76
+ };
77
+
78
+ export const getTemporalInputStep = (
79
+ ...values: (string | undefined)[]
80
+ ): "any" | 1 | undefined => {
81
+ let step: 1 | undefined;
82
+ for (const value of values) {
83
+ const temporal = classifyTemporal(value ?? "");
84
+ const precision =
85
+ "precision" in temporal
86
+ ? temporal.precision
87
+ : INPUT_PATTERN.exec(value ?? "")?.[3]?.length;
88
+ if (typeof precision === "number") return "any";
89
+ if (precision === "seconds") step = 1;
90
+ }
91
+ return step;
92
+ };
93
+
94
+ export const splitTemporalMinutes = (
95
+ value: string,
96
+ offset?: string,
97
+ ): { date?: string; time: string; droppedPrecision: boolean } | undefined => {
98
+ const temporal = classifyTemporal(value);
99
+ if (temporal.kind === "date" || temporal.kind === "unparseable")
100
+ return undefined;
101
+ const wallTime =
102
+ temporal.kind === "instant"
103
+ ? formatTemporalInstant(temporal, offset).slice(
104
+ 0,
105
+ -(offset ?? temporal.offset).length,
106
+ )
107
+ : temporal.value;
108
+ const end = temporal.kind === "time" ? 5 : 16;
109
+ return {
110
+ ...(temporal.kind === "time" ? {} : { date: wallTime.slice(0, 10) }),
111
+ time: wallTime.slice(end - 5, end),
112
+ droppedPrecision: /[1-9]/.test(wallTime.slice(end)),
113
+ };
114
+ };
115
+
116
+ export const mergeTemporalMinutes = (date: string, time: string): string => {
117
+ if (
118
+ classifyTemporal(date).kind !== "date" ||
119
+ classifyTemporal(time).kind !== "time"
120
+ )
121
+ return "";
122
+ return `${date}T${time.slice(0, 5)}`;
123
+ };
124
+
125
+ const pad = (value: number, digits = 2): string =>
126
+ String(value).padStart(digits, "0");
127
+
128
+ const offsetMinutes = (offset: string): number =>
129
+ offset === "Z"
130
+ ? 0
131
+ : (offset.startsWith("-") ? -1 : 1) *
132
+ (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6)));
133
+
134
+ export const formatTemporalInstant = (
135
+ instant: TemporalInstant,
136
+ offset = instant.offset,
137
+ precision = instant.precision,
138
+ ): string => {
139
+ const date = new Date(instant.epochMs + offsetMinutes(offset) * 60_000);
140
+ const iso = date.toISOString();
141
+ const wallTime =
142
+ precision === "minutes"
143
+ ? iso.slice(0, 16)
144
+ : precision === "seconds"
145
+ ? iso.slice(0, 19)
146
+ : `${iso.slice(0, 19)}.${(pad(date.getUTCMilliseconds(), 3) + (instant.subMillisecondDigits ?? "")).padEnd(precision, "0").slice(0, precision)}`;
147
+ return `${wallTime}${offset}`;
148
+ };
149
+
150
+ export const formatTemporalUnixSeconds = (
151
+ seconds: number,
152
+ offset = "Z",
153
+ ): string | undefined => {
154
+ if (!Number.isInteger(seconds)) return undefined;
155
+ try {
156
+ const value = formatTemporalInstant({
157
+ kind: "instant",
158
+ epochMs: seconds * 1000,
159
+ offset,
160
+ precision: "seconds",
161
+ });
162
+ return classifyTemporal(value).kind === "instant" ? value : undefined;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ };
167
+
168
+ export const temporalOffsetLabel = (value: string): string | undefined => {
169
+ const temporal = classifyTemporal(value);
170
+ if (temporal.kind !== "instant") return undefined;
171
+ return temporal.offset === "Z" ? "UTC" : `UTC${temporal.offset}`;
172
+ };
173
+
174
+ export const fromOffsetDateTime = (
175
+ value: string,
176
+ previousValue?: string,
177
+ ): string => {
178
+ if (value === "") return "";
179
+ if (classifyTemporal(value).kind !== "floating") return value;
180
+ const previous = classifyTemporal(previousValue ?? "");
181
+ if (previous.kind !== "instant") return value;
182
+ const epochMs = Date.parse(`${value}${previous.offset}`);
183
+ const minuteRemainder =
184
+ previous.precision === "minutes"
185
+ ? 0
186
+ : ((previous.epochMs % 60_000) + 60_000) % 60_000;
187
+ return formatTemporalInstant({
188
+ ...previous,
189
+ epochMs: epochMs + minuteRemainder,
190
+ });
191
+ };
192
+
193
+ export const toLocalDateTime = (
194
+ value: string,
195
+ precision?: TemporalPrecision,
196
+ ): string => {
197
+ const temporal = classifyTemporal(value);
198
+ if (temporal.kind !== "instant") return value;
199
+ const date = new Date(temporal.epochMs);
200
+ const wallTime = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
201
+ return (precision ?? temporal.precision) === "minutes"
202
+ ? wallTime
203
+ : `${wallTime}:${pad(date.getSeconds())}${typeof precision === "number" ? formatTemporalInstant(temporal, "Z", precision).slice(19, -1) : ""}`;
204
+ };
205
+
206
+ export const toPickerLocalDateTime = (value: string): string => {
207
+ const temporal = classifyTemporal(value);
208
+ if (temporal.kind !== "instant") return normalizeTemporalInputValue(value);
209
+ const local = toLocalDateTime(
210
+ value,
211
+ typeof temporal.precision === "number" ? 3 : temporal.precision,
212
+ );
213
+ return typeof temporal.precision !== "number" || local.endsWith(".000")
214
+ ? normalizeTemporalInputValue(local)
215
+ : local;
216
+ };
217
+
218
+ export const fromLocalDateTime = (
219
+ value: string,
220
+ previousValue?: string,
221
+ ): string => {
222
+ const temporal = classifyTemporal(value);
223
+ const previous = classifyTemporal(previousValue ?? "");
224
+ if (temporal.kind !== "floating" || previous.kind === "floating") {
225
+ return value;
226
+ }
227
+ const date = new Date(
228
+ value.replace(
229
+ /\.(\d{1,2})$/,
230
+ (_, digits: string) => `.${digits.padEnd(3, "0")}`,
231
+ ),
232
+ );
233
+ const minutes = -date.getTimezoneOffset();
234
+ const offset = `${minutes < 0 ? "-" : "+"}${pad(Math.floor(Math.abs(minutes) / 60))}:${pad(Math.abs(minutes) % 60)}`;
235
+ if (previous.kind !== "instant") {
236
+ return formatTemporalInstant({
237
+ kind: "instant",
238
+ epochMs: date.getTime(),
239
+ offset,
240
+ precision: "seconds",
241
+ });
242
+ }
243
+ return formatTemporalInstant({
244
+ ...previous,
245
+ epochMs: date.getTime(),
246
+ ...(date.getMilliseconds() ===
247
+ new Date(previous.epochMs).getUTCMilliseconds()
248
+ ? {}
249
+ : typeof temporal.precision === "number"
250
+ ? {
251
+ precision: Math.max(
252
+ temporal.precision,
253
+ typeof previous.precision === "number" ? previous.precision : 0,
254
+ ),
255
+ subMillisecondDigits: value.slice(value.indexOf(".") + 4),
256
+ }
257
+ : { subMillisecondDigits: "" }),
258
+ });
259
+ };
@@ -5,7 +5,16 @@ import {
5
5
  collectFormValues,
6
6
  type FormControlElementLike,
7
7
  } from "./collectFormValues";
8
- import { CHECKBOX_GROUP_ATTR, GENERATED_NAME_ATTR } from "../constants";
8
+ import {
9
+ CHECKBOX_GROUP_ATTR,
10
+ FIELD_VALUE_ATTR,
11
+ GENERATED_NAME_ATTR,
12
+ } from "../constants";
13
+ import {
14
+ normalizeTemporalInputValue,
15
+ toLocalDateTime,
16
+ toPickerLocalDateTime,
17
+ } from "../temporal";
9
18
 
10
19
  const el = (
11
20
  partial: Partial<FormControlElementLike>,
@@ -19,6 +28,194 @@ const el = (
19
28
  });
20
29
 
21
30
  describe("collectFormValues", () => {
31
+ it.each([
32
+ ["time", "13:45:30.500", "13:45:30.5"],
33
+ ["time", "13:45:30.000", "13:45:30"],
34
+ ["datetime-local", "2026-07-16T09:45:30.500", "2026-07-16T09:45:30.5"],
35
+ ["datetime-local", "2026-07-16T09:45:30.000", "2026-07-16T09:45:30"],
36
+ ])("collects an edited %s in shortest form", (type, value, expected) => {
37
+ expect(collectFormValues([el({ name: "when", type, value })])).toEqual({
38
+ when: expected,
39
+ });
40
+ });
41
+
42
+ it("retains an untouched instant's offset spelling and precision", () => {
43
+ const anchor = "2025-12-15T17:00:00.5Z";
44
+ const displayed = toPickerLocalDateTime(anchor);
45
+ const getAttribute = (name: string) =>
46
+ name === FIELD_VALUE_ATTR ? anchor : null;
47
+ expect(
48
+ collectFormValues([
49
+ el({
50
+ name: "when",
51
+ type: "datetime-local",
52
+ value: displayed,
53
+ getAttribute,
54
+ }),
55
+ ]),
56
+ ).toEqual({ when: "2025-12-15T17:00:00.5Z" });
57
+ expect(
58
+ collectFormValues([
59
+ el({
60
+ name: "when",
61
+ type: "datetime-local",
62
+ value: displayed.replace(".500", ".5"),
63
+ getAttribute,
64
+ }),
65
+ ]),
66
+ ).toEqual({ when: "2025-12-15T17:00:00.5Z" });
67
+ expect(
68
+ collectFormValues([
69
+ el({
70
+ name: "when",
71
+ type: "datetime-local",
72
+ value: displayed.replace(".500", ".750"),
73
+ getAttribute,
74
+ }),
75
+ ]),
76
+ ).toEqual({ when: "2025-12-15T17:00:00.75Z" });
77
+ });
78
+
79
+ it("uses an instant anchor only for datetime-local controls", () => {
80
+ expect(
81
+ collectFormValues([
82
+ el({
83
+ name: "canonical",
84
+ type: "datetime-local",
85
+ value: normalizeTemporalInputValue(
86
+ toLocalDateTime("2025-12-15T17:00:00Z"),
87
+ ),
88
+ getAttribute: (name) =>
89
+ name === FIELD_VALUE_ATTR ? "2025-12-15T17:00:00Z" : null,
90
+ }),
91
+ el({
92
+ name: "ordinary",
93
+ value: "live",
94
+ getAttribute: (name) => (name === FIELD_VALUE_ATTR ? "stale" : null),
95
+ }),
96
+ ]),
97
+ ).toEqual({ canonical: "2025-12-15T17:00:00Z", ordinary: "live" });
98
+ });
99
+
100
+ it("uses the declared datetime-local type when the browser reports text", () => {
101
+ const anchor = "2025-12-15T17:00:00.250Z";
102
+ const displayed = toPickerLocalDateTime(anchor);
103
+ const getAttribute = (name: string) =>
104
+ name === "type"
105
+ ? "datetime-local"
106
+ : name === FIELD_VALUE_ATTR
107
+ ? anchor
108
+ : null;
109
+ const control = (value: string) =>
110
+ el({ name: "when", type: "text", value, getAttribute });
111
+ expect(collectFormValues([control(displayed)])).toEqual({
112
+ when: anchor,
113
+ });
114
+ expect(
115
+ collectFormValues([control(displayed.replace(".250", ".750"))]),
116
+ ).toEqual({
117
+ when: "2025-12-15T17:00:00.750Z",
118
+ });
119
+ expect(collectFormValues([control(displayed.slice(0, -4))])).toEqual({
120
+ when: "2025-12-15T17:00:00.000Z",
121
+ });
122
+ });
123
+
124
+ it("uses the declared time type when the browser reports text", () => {
125
+ expect(
126
+ collectFormValues([
127
+ el({
128
+ name: "when",
129
+ type: "text",
130
+ value: "12:00",
131
+ getAttribute: (name) =>
132
+ name === "type"
133
+ ? "time"
134
+ : name === FIELD_VALUE_ATTR
135
+ ? "12:00:00"
136
+ : null,
137
+ }),
138
+ ]),
139
+ ).toEqual({ when: "12:00:00" });
140
+ });
141
+
142
+ it.each([
143
+ ["time", "17:00:00", "17:00", "08:30:45"],
144
+ [
145
+ "datetime-local",
146
+ "2025-12-15T17:00:00",
147
+ "2025-12-15T17:00",
148
+ "2025-12-16T08:30:45",
149
+ ],
150
+ ])(
151
+ "uses the canonical %s value only while its projection matches",
152
+ (type, canonical, displayed, edited) => {
153
+ const getAttribute = (name: string) =>
154
+ name === FIELD_VALUE_ATTR ? canonical : null;
155
+ expect(
156
+ collectFormValues([
157
+ el({ name: "value", type, value: displayed, getAttribute }),
158
+ ]),
159
+ ).toEqual({ value: canonical });
160
+ expect(
161
+ collectFormValues([
162
+ el({ name: "value", type, value: edited, getAttribute }),
163
+ ]),
164
+ ).toEqual({ value: edited });
165
+ expect(
166
+ collectFormValues([
167
+ el({ name: "value", type, value: "", getAttribute }),
168
+ ]),
169
+ ).toEqual({ value: "" });
170
+ },
171
+ );
172
+
173
+ it("ignores instant anchors on other control types", () => {
174
+ const getAttribute = (name: string) =>
175
+ name === FIELD_VALUE_ATTR ? "2025-12-15T17:00:00Z" : null;
176
+ expect(
177
+ collectFormValues([
178
+ el({ name: "date", type: "date", value: "", getAttribute }),
179
+ el({ name: "time", type: "time", value: "12:34", getAttribute }),
180
+ el({ name: "text", value: "live", getAttribute }),
181
+ el({
182
+ name: "select",
183
+ type: "select-one",
184
+ value: "chosen",
185
+ getAttribute,
186
+ }),
187
+ el({ name: "textarea", type: "textarea", value: "text", getAttribute }),
188
+ el({
189
+ name: "radio",
190
+ type: "radio",
191
+ value: "chosen",
192
+ checked: true,
193
+ getAttribute,
194
+ }),
195
+ el({ name: "checkbox", type: "checkbox", checked: true, getAttribute }),
196
+ el({
197
+ name: "group",
198
+ type: "checkbox",
199
+ value: "chosen",
200
+ checked: true,
201
+ hasAttribute: (name) => name === CHECKBOX_GROUP_ATTR,
202
+ getAttribute,
203
+ }),
204
+ el({ name: "range", type: "range", value: "7", getAttribute }),
205
+ ]),
206
+ ).toEqual({
207
+ date: "",
208
+ time: "12:34",
209
+ text: "live",
210
+ select: "chosen",
211
+ textarea: "text",
212
+ radio: "chosen",
213
+ checkbox: true,
214
+ group: ["chosen"],
215
+ range: 7,
216
+ });
217
+ });
218
+
22
219
  it("resolves a checkbox to its checked boolean", () => {
23
220
  expect(
24
221
  collectFormValues([
@@ -2,8 +2,14 @@ import type { FormEvent } from "react";
2
2
  import {
3
3
  CHECKBOX_GROUP_ATTR,
4
4
  FIELD_NAME_ATTR,
5
+ FIELD_VALUE_ATTR,
5
6
  GENERATED_NAME_ATTR,
6
7
  } from "../constants";
8
+ import {
9
+ fromLocalDateTime,
10
+ normalizeTemporalInputValue,
11
+ toPickerLocalDateTime,
12
+ } from "../temporal";
7
13
 
8
14
  /**
9
15
  * The subset of `HTMLInputElement`/`HTMLSelectElement`/`HTMLTextAreaElement` that {@link collectFormValues} reads. A structural type rather than the DOM interfaces themselves, so a plain object can stand in for a form control in tests.
@@ -20,7 +26,7 @@ export type FormControlElementLike = {
20
26
  };
21
27
 
22
28
  /**
23
- * Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. A checkbox group resolves to its checked options' values in document order, or an empty array if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, that carry `data-aui-generated-name`, or that are effectively disabled, including through an ancestor disabled fieldset outside its first legend, are skipped entirely.
29
+ * Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. A checkbox group resolves to its checked options' values in document order, or an empty array if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. A time or datetime-local control's `data-aui-field-value` preserves the canonical string when its local projection is unchanged; an edited instant retains its original offset and precision. Controls without a `name`, that carry `data-aui-generated-name`, or that are effectively disabled, including through an ancestor disabled fieldset outside its first legend, are skipped entirely.
24
30
  */
25
31
  export function collectFormValues(
26
32
  elements: ArrayLike<FormControlElementLike>,
@@ -54,12 +60,35 @@ export function collectFormValues(
54
60
  continue;
55
61
  }
56
62
 
63
+ const temporalType =
64
+ element.type === "datetime-local" || element.type === "time"
65
+ ? element.type
66
+ : element.getAttribute?.("type");
67
+ const inputValue =
68
+ temporalType === "datetime-local" || temporalType === "time"
69
+ ? normalizeTemporalInputValue(element.value)
70
+ : element.value;
71
+ const fieldValue =
72
+ temporalType === "datetime-local" || temporalType === "time"
73
+ ? element.getAttribute?.(FIELD_VALUE_ATTR)
74
+ : undefined;
57
75
  const value: string | number | boolean =
58
76
  element.type === "checkbox"
59
77
  ? (element.checked ?? false)
60
78
  : element.type === "range"
61
79
  ? Number(element.value)
62
- : element.value;
80
+ : fieldValue && inputValue !== ""
81
+ ? normalizeTemporalInputValue(inputValue) ===
82
+ normalizeTemporalInputValue(
83
+ temporalType === "time"
84
+ ? fieldValue
85
+ : toPickerLocalDateTime(fieldValue),
86
+ )
87
+ ? fieldValue
88
+ : temporalType === "datetime-local"
89
+ ? fromLocalDateTime(inputValue, fieldValue)
90
+ : inputValue
91
+ : inputValue;
63
92
 
64
93
  if (Object.hasOwn(values, name)) {
65
94
  const existing = values[name];