@lotics/ui 20.0.1 → 20.1.0

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.
@@ -1,62 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { getPresetValue, PRESET_IDS, type PresetId } from "./date_filter_presets";
3
-
4
- // Wednesday, 10 June 2026, 14:30 — a fixed clock so every range is deterministic.
5
- const NOW = new Date(2026, 5, 10, 14, 30, 0, 0);
6
-
7
- function ymd(d: Date | null): string {
8
- if (!d) return "null";
9
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
10
- }
11
-
12
- describe("getPresetValue", () => {
13
- it("today spans the start to end of the current day", () => {
14
- const v = getPresetValue("today", NOW)!;
15
- expect(ymd(v.start.date)).toBe("2026-06-10");
16
- expect(ymd(v.end.date)).toBe("2026-06-10");
17
- expect(v.start.date!.getHours()).toBe(0);
18
- expect(v.start.date!.getMilliseconds()).toBe(0);
19
- expect(v.end.date!.getHours()).toBe(23);
20
- expect(v.end.date!.getMinutes()).toBe(59);
21
- expect(v.end.date!.getMilliseconds()).toBe(999);
22
- });
23
-
24
- it("yesterday and tomorrow shift by one day", () => {
25
- expect(ymd(getPresetValue("yesterday", NOW)!.start.date)).toBe("2026-06-09");
26
- expect(ymd(getPresetValue("yesterday", NOW)!.end.date)).toBe("2026-06-09");
27
- expect(ymd(getPresetValue("tomorrow", NOW)!.start.date)).toBe("2026-06-11");
28
- expect(ymd(getPresetValue("tomorrow", NOW)!.end.date)).toBe("2026-06-11");
29
- });
30
-
31
- it("this_week runs Monday → Sunday around now", () => {
32
- const v = getPresetValue("this_week", NOW)!;
33
- expect(ymd(v.start.date)).toBe("2026-06-08"); // Monday
34
- expect(v.start.date!.getDay()).toBe(1);
35
- expect(ymd(v.end.date)).toBe("2026-06-14"); // Sunday
36
- expect(v.end.date!.getDay()).toBe(0);
37
- });
38
-
39
- it("this_month covers the whole calendar month", () => {
40
- const v = getPresetValue("this_month", NOW)!;
41
- expect(ymd(v.start.date)).toBe("2026-06-01");
42
- expect(ymd(v.end.date)).toBe("2026-06-30");
43
- });
44
-
45
- it("last_month covers the previous calendar month", () => {
46
- const v = getPresetValue("last_month", NOW)!;
47
- expect(ymd(v.start.date)).toBe("2026-05-01");
48
- expect(ymd(v.end.date)).toBe("2026-05-31");
49
- });
50
-
51
- it("custom carries no range", () => {
52
- expect(getPresetValue("custom", NOW)).toBeNull();
53
- });
54
-
55
- it("every preset id resolves (only custom is null)", () => {
56
- for (const id of PRESET_IDS) {
57
- const v = getPresetValue(id as PresetId, NOW);
58
- if (id === "custom") expect(v).toBeNull();
59
- else expect(v).not.toBeNull();
60
- }
61
- });
62
- });
@@ -1,204 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- parsePart,
4
- partsToIso,
5
- parseText,
6
- resolveDateCommit,
7
- timeText,
8
- splitTime,
9
- isoToDate,
10
- parseTimeString,
11
- withCalendarDate,
12
- withTimeOfDay,
13
- nowIso,
14
- } from "./date_picker_value";
15
-
16
- describe("parsePart", () => {
17
- it("parses a plain date", () => {
18
- expect(parsePart("2026-05-17")).toEqual({ y: 2026, mo: 5, d: 17, h: 0, mi: 0 });
19
- });
20
-
21
- it("parses a datetime with T or space separator", () => {
22
- expect(parsePart("2026-05-17T14:30")).toEqual({ y: 2026, mo: 5, d: 17, h: 14, mi: 30 });
23
- expect(parsePart("2026-05-17 14:30")).toEqual({ y: 2026, mo: 5, d: 17, h: 14, mi: 30 });
24
- });
25
-
26
- it("is lenient on missing leading zeros", () => {
27
- expect(parsePart("2026-3-7")).toEqual({ y: 2026, mo: 3, d: 7, h: 0, mi: 0 });
28
- });
29
-
30
- it("trims surrounding whitespace", () => {
31
- expect(parsePart(" 2026-05-17 ")).toEqual({ y: 2026, mo: 5, d: 17, h: 0, mi: 0 });
32
- });
33
-
34
- it("rejects impossible calendar dates", () => {
35
- expect(parsePart("2026-02-30")).toBeNull();
36
- expect(parsePart("2026-13-01")).toBeNull();
37
- expect(parsePart("2026-00-10")).toBeNull();
38
- });
39
-
40
- it("rejects out-of-range times", () => {
41
- expect(parsePart("2026-05-17 24:00")).toBeNull();
42
- expect(parsePart("2026-05-17 12:60")).toBeNull();
43
- });
44
-
45
- it("rejects malformed input", () => {
46
- expect(parsePart("not-a-date")).toBeNull();
47
- expect(parsePart("2026/05/17")).toBeNull();
48
- expect(parsePart("")).toBeNull();
49
- });
50
- });
51
-
52
- describe("partsToIso", () => {
53
- it("formats a date without time", () => {
54
- expect(partsToIso({ y: 2026, mo: 5, d: 7, h: 0, mi: 0 }, false)).toBe("2026-05-07");
55
- });
56
-
57
- it("formats a datetime with zero-padded time", () => {
58
- expect(partsToIso({ y: 2026, mo: 5, d: 7, h: 9, mi: 5 }, true)).toBe("2026-05-07T09:05");
59
- });
60
- });
61
-
62
- describe("parseText", () => {
63
- it("returns empty string for blank input (clear)", () => {
64
- expect(parseText("", false)).toBe("");
65
- expect(parseText(" ", false)).toBe("");
66
- });
67
-
68
- it("parses a single date", () => {
69
- expect(parseText("2026-3-7", false)).toBe("2026-03-07");
70
- });
71
-
72
- it("drops the time component for date-only formats", () => {
73
- expect(parseText("2026-05-17 14:30", false)).toBe("2026-05-17");
74
- });
75
-
76
- it("keeps the time component for datetime formats", () => {
77
- expect(parseText("2026-05-17 14:30", true)).toBe("2026-05-17T14:30");
78
- });
79
-
80
- it("returns null for an unparseable value", () => {
81
- expect(parseText("2026-02-30", false)).toBeNull();
82
- expect(parseText("garbage", false)).toBeNull();
83
- });
84
- });
85
-
86
- describe("timeText", () => {
87
- it("extracts HH:mm from a datetime value", () => {
88
- expect(timeText("2026-05-17T09:05")).toBe("09:05");
89
- });
90
-
91
- it("treats a date-only value as midnight", () => {
92
- expect(timeText("2026-05-17")).toBe("00:00");
93
- });
94
-
95
- it("returns empty for an unparseable value", () => {
96
- expect(timeText("")).toBe("");
97
- });
98
- });
99
-
100
- describe("splitTime", () => {
101
- it("splits a canonical time string", () => {
102
- expect(splitTime("14:30")).toEqual({ h: 14, mi: 30 });
103
- });
104
-
105
- it("returns midnight for empty input", () => {
106
- expect(splitTime("")).toEqual({ h: 0, mi: 0 });
107
- });
108
- });
109
-
110
- describe("isoToDate", () => {
111
- it("builds a local Date from an ISO string", () => {
112
- const date = isoToDate("2026-05-17T14:30");
113
- expect(date?.getFullYear()).toBe(2026);
114
- expect(date?.getMonth()).toBe(4);
115
- expect(date?.getDate()).toBe(17);
116
- expect(date?.getHours()).toBe(14);
117
- expect(date?.getMinutes()).toBe(30);
118
- });
119
-
120
- it("returns null for an unparseable string", () => {
121
- expect(isoToDate("")).toBeNull();
122
- expect(isoToDate("2026-02-30")).toBeNull();
123
- });
124
- });
125
-
126
- describe("withCalendarDate", () => {
127
- const may20 = new Date(2026, 4, 20);
128
-
129
- it("keeps the time carried by the previous value for datetime formats", () => {
130
- expect(withCalendarDate(may20, "2026-01-01T14:30", true)).toBe("2026-05-20T14:30");
131
- });
132
-
133
- it("defaults to midnight when the previous value has no time", () => {
134
- expect(withCalendarDate(may20, "", true)).toBe("2026-05-20T00:00");
135
- });
136
-
137
- it("drops the time component for date-only formats", () => {
138
- expect(withCalendarDate(may20, "2026-01-01T14:30", false)).toBe("2026-05-20");
139
- });
140
- });
141
-
142
- describe("withTimeOfDay", () => {
143
- it("keeps the date and applies the new time", () => {
144
- expect(withTimeOfDay("2026-05-20", "09:05")).toBe("2026-05-20T09:05");
145
- expect(withTimeOfDay("2026-05-20T14:30", "08:00")).toBe("2026-05-20T08:00");
146
- });
147
- });
148
-
149
- describe("nowIso", () => {
150
- it("produces a canonical date or datetime string", () => {
151
- expect(nowIso(false)).toMatch(/^\d{4}-\d{2}-\d{2}$/);
152
- expect(nowIso(true)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
153
- });
154
- });
155
-
156
- describe("parseTimeString", () => {
157
- it("parses and zero-pads a valid time", () => {
158
- expect(parseTimeString("9:05")).toBe("09:05");
159
- expect(parseTimeString("14:30")).toBe("14:30");
160
- });
161
-
162
- it("rejects out-of-range or malformed times", () => {
163
- expect(parseTimeString("24:00")).toBeNull();
164
- expect(parseTimeString("12:60")).toBeNull();
165
- expect(parseTimeString("1430")).toBeNull();
166
- expect(parseTimeString("")).toBeNull();
167
- });
168
- });
169
-
170
- describe("resolveDateCommit — the inline editor's commit contract", () => {
171
- it("a typed complete date commits the value", () => {
172
- expect(
173
- resolveDateCommit({ draft: "2026-03-15", value: "2026-01-01", incomplete: false, clearable: false }),
174
- ).toEqual({ kind: "save", value: "2026-03-15" });
175
- // …including onto an empty field.
176
- expect(
177
- resolveDateCommit({ draft: "2026-03-15", value: null, incomplete: false, clearable: false }),
178
- ).toEqual({ kind: "save", value: "2026-03-15" });
179
- });
180
-
181
- it("a partial entry never commits and never clears the stored value", () => {
182
- expect(
183
- resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: true, clearable: true }),
184
- ).toEqual({ kind: "invalid" });
185
- });
186
-
187
- it("an unchanged draft exits without a write (a touched-but-equal date is not a diff)", () => {
188
- expect(
189
- resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: false, clearable: true }),
190
- ).toEqual({ kind: "none" });
191
- expect(resolveDateCommit({ draft: "", value: null, incomplete: false, clearable: true })).toEqual({
192
- kind: "none",
193
- });
194
- });
195
-
196
- it("an emptied entry clears only when the field is clearable", () => {
197
- expect(
198
- resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: true }),
199
- ).toEqual({ kind: "clear" });
200
- expect(
201
- resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: false }),
202
- ).toEqual({ kind: "none" });
203
- });
204
- });
@@ -1,60 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { nextRangeSelection, type DateRangeSelection } from "./date_range_selection";
3
-
4
- const d = (day: number): Date => new Date(2026, 6, day);
5
- const ymd = (x: Date | null): string =>
6
- x ? `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}` : "null";
7
- const EMPTY: DateRangeSelection = { start: null, end: null };
8
-
9
- describe("nextRangeSelection", () => {
10
- it("opens a range on the first click", () => {
11
- const next = nextRangeSelection(EMPTY, d(14));
12
- expect(ymd(next.start)).toBe("2026-07-14");
13
- expect(next.end).toBeNull();
14
- });
15
-
16
- it("closes the open range on the second click", () => {
17
- const next = nextRangeSelection({ start: d(14), end: null }, d(20));
18
- expect(ymd(next.start)).toBe("2026-07-14");
19
- expect(ymd(next.end)).toBe("2026-07-20");
20
- });
21
-
22
- it("orders the bounds when the later day is clicked first", () => {
23
- const next = nextRangeSelection({ start: d(20), end: null }, d(14));
24
- expect(ymd(next.start)).toBe("2026-07-14");
25
- expect(ymd(next.end)).toBe("2026-07-20");
26
- });
27
-
28
- // The whole point of dropping the single/range mode: a range must be
29
- // reachable from EVERY starting value, including one that is already a
30
- // single day. Under the mode, a single-day value silently disabled range
31
- // selection entirely — two clicks just moved the one day around.
32
- it("reaches a range from a single-day value", () => {
33
- const singleDay: DateRangeSelection = { start: d(31), end: d(31) };
34
- const opened = nextRangeSelection(singleDay, d(1));
35
- const closed = nextRangeSelection(opened, d(20));
36
- expect(ymd(closed.start)).toBe("2026-07-01");
37
- expect(ymd(closed.end)).toBe("2026-07-20");
38
- });
39
-
40
- it("reaches a range from a closed multi-day range", () => {
41
- const opened = nextRangeSelection({ start: d(1), end: d(5) }, d(10));
42
- expect(ymd(opened.start)).toBe("2026-07-10");
43
- expect(opened.end).toBeNull();
44
- expect(ymd(nextRangeSelection(opened, d(12)).end)).toBe("2026-07-12");
45
- });
46
-
47
- // Selecting one day is clicking it twice — the replacement for the mode.
48
- it("selects a single day when the same day is clicked twice", () => {
49
- const next = nextRangeSelection(nextRangeSelection(EMPTY, d(14)), d(14));
50
- expect(ymd(next.start)).toBe("2026-07-14");
51
- expect(ymd(next.end)).toBe("2026-07-14");
52
- });
53
-
54
- it("never mutates the selection it is given", () => {
55
- const current: DateRangeSelection = { start: d(14), end: null };
56
- nextRangeSelection(current, d(20));
57
- expect(ymd(current.start)).toBe("2026-07-14");
58
- expect(current.end).toBeNull();
59
- });
60
- });
@@ -1,335 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { resolveLocaleTag, en as enLocale, vi as viLocale } from "./locale";
3
- import {
4
- getDateLayout,
5
- getTimeLayout,
6
- timeSegmentsConfig,
7
- segmentsToTime,
8
- fieldOrder,
9
- to12h,
10
- from12h,
11
- typeDigit,
12
- incrementSegment,
13
- separatorAdvances,
14
- setHourField,
15
- withDayPeriod,
16
- displayValue,
17
- valueToSegments,
18
- segmentsToValue,
19
- isBufferEmpty,
20
- placeholderFor,
21
- } from "./date_segments";
22
-
23
- const empty = { year: null, month: null, day: null, hour: null, minute: null };
24
-
25
- describe("getDateLayout / fieldOrder", () => {
26
- it("derives m/d/y and 12h for en-US", () => {
27
- expect(fieldOrder(getDateLayout("en-US", false))).toEqual(["month", "day", "year"]);
28
- const dt = getDateLayout("en-US", true);
29
- expect(fieldOrder(dt)).toEqual(["month", "day", "year", "hour", "minute", "dayPeriod"]);
30
- expect(dt.hour12).toBe(true);
31
- });
32
-
33
- it("derives d/m/y, 24h, and time-first datetime for vi-VN", () => {
34
- expect(fieldOrder(getDateLayout("vi-VN", false))).toEqual(["day", "month", "year"]);
35
- const dt = getDateLayout("vi-VN", true);
36
- // Vietnamese leads with the time ("HH:mm dd/MM/yyyy") — we follow the locale.
37
- expect(fieldOrder(dt)).toEqual(["hour", "minute", "day", "month", "year"]);
38
- expect(dt.hour12).toBe(false);
39
- });
40
-
41
- it("derives d/m/y for en-GB and y/m/d for ja-JP", () => {
42
- expect(fieldOrder(getDateLayout("en-GB", false))).toEqual(["day", "month", "year"]);
43
- expect(fieldOrder(getDateLayout("ja-JP", false))).toEqual(["year", "month", "day"]);
44
- });
45
-
46
- it("emits literal separators between fields", () => {
47
- const literals = getDateLayout("en-US", false).segments.filter((s) => s.kind === "literal");
48
- expect(literals.length).toBeGreaterThan(0);
49
- });
50
- });
51
-
52
- describe("to12h / from12h", () => {
53
- it("maps 24h to 12h", () => {
54
- expect(to12h(0)).toEqual({ h12: 12, pm: false });
55
- expect(to12h(12)).toEqual({ h12: 12, pm: true });
56
- expect(to12h(13)).toEqual({ h12: 1, pm: true });
57
- expect(to12h(23)).toEqual({ h12: 11, pm: true });
58
- });
59
-
60
- it("maps 12h back to 24h", () => {
61
- expect(from12h(12, false)).toBe(0);
62
- expect(from12h(12, true)).toBe(12);
63
- expect(from12h(1, true)).toBe(13);
64
- expect(from12h(11, false)).toBe(11);
65
- });
66
- });
67
-
68
- describe("typeDigit", () => {
69
- it("waits for a second digit when one could still fit", () => {
70
- expect(typeDigit("month", "", "1", false)).toEqual({ text: "1", value: 1, complete: false });
71
- expect(typeDigit("day", "", "3", false)).toEqual({ text: "3", value: 3, complete: false });
72
- expect(typeDigit("minute", "", "5", false)).toEqual({ text: "5", value: 5, complete: false });
73
- });
74
-
75
- it("auto-advances when no second digit could keep it in range", () => {
76
- expect(typeDigit("month", "", "2", false)).toEqual({ text: "2", value: 2, complete: true });
77
- expect(typeDigit("day", "", "4", false)).toEqual({ text: "4", value: 4, complete: true });
78
- expect(typeDigit("minute", "", "6", false)).toEqual({ text: "6", value: 6, complete: true });
79
- expect(typeDigit("hour", "", "3", false)).toEqual({ text: "3", value: 3, complete: true });
80
- expect(typeDigit("hour", "", "2", true)).toEqual({ text: "2", value: 2, complete: true });
81
- });
82
-
83
- it("completes on the second digit", () => {
84
- expect(typeDigit("month", "1", "2", false)).toEqual({ text: "12", value: 12, complete: true });
85
- expect(typeDigit("minute", "5", "9", false)).toEqual({ text: "59", value: 59, complete: true });
86
- });
87
-
88
- it("restarts when the accumulated value overflows", () => {
89
- expect(typeDigit("month", "1", "5", false)).toEqual({ text: "5", value: 5, complete: true });
90
- expect(typeDigit("hour", "2", "5", false)).toEqual({ text: "5", value: 5, complete: true });
91
- });
92
-
93
- it("accumulates up to four digits for the year", () => {
94
- expect(typeDigit("year", "", "2", false)).toEqual({ text: "2", value: 2, complete: false });
95
- expect(typeDigit("year", "202", "6", false)).toEqual({
96
- text: "2026",
97
- value: 2026,
98
- complete: true,
99
- });
100
- expect(typeDigit("year", "2026", "7", false)).toEqual({
101
- text: "0267",
102
- value: 267,
103
- complete: true,
104
- });
105
- });
106
- });
107
-
108
- describe("typed date entry — the inline keyboard path", () => {
109
- // Simulate the segment engine's typing pipeline: each keystroke flows through
110
- // typeDigit in the locale's own field order; the buffer then resolves through
111
- // segmentsToValue. This is the outcome contract behind "type 15/03/2026 into
112
- // an inline date editor and it commits".
113
- it("a typed dd/MM/yyyy date composes the committed ISO value (vi-VN order)", () => {
114
- expect(fieldOrder(getDateLayout("vi-VN", false))).toEqual(["day", "month", "year"]);
115
- const day = typeDigit("day", typeDigit("day", "", "1", false).text, "5", false);
116
- expect(day).toEqual({ text: "15", value: 15, complete: true });
117
- const month = typeDigit("month", typeDigit("month", "", "0", false).text, "3", false);
118
- expect(month).toEqual({ text: "03", value: 3, complete: true });
119
- let yearText = "";
120
- for (const digit of ["2", "0", "2", "6"]) yearText = typeDigit("year", yearText, digit, false).text;
121
- const buffer = { year: Number(yearText), month: month.value, day: day.value, hour: null, minute: null };
122
- expect(segmentsToValue(buffer, false)).toBe("2026-03-15");
123
- });
124
-
125
- it("a single-digit d/M/yyyy entry advances on the separator and still commits", () => {
126
- // "1/1/2026": neither "1" can auto-advance (a second digit could still
127
- // fit — "12", "11") — the typed "/" is the explicit advance.
128
- const day = typeDigit("day", "", "1", false);
129
- expect(day.complete).toBe(false);
130
- expect(separatorAdvances("/", day.text !== "")).toBe(true);
131
- const month = typeDigit("month", "", "1", false);
132
- expect(month.complete).toBe(false);
133
- expect(separatorAdvances("/", month.text !== "")).toBe(true);
134
- const buffer = { year: 2026, month: month.value, day: day.value, hour: null, minute: null };
135
- expect(segmentsToValue(buffer, false)).toBe("2026-01-01");
136
- });
137
-
138
- it("separators advance only off a segment that has content", () => {
139
- for (const key of ["/", ".", "-", ",", ":", " "]) {
140
- expect(separatorAdvances(key, true)).toBe(true);
141
- expect(separatorAdvances(key, false)).toBe(false);
142
- }
143
- expect(separatorAdvances("a", true)).toBe(false);
144
- expect(separatorAdvances("ArrowDown", true)).toBe(false);
145
- });
146
-
147
- it("an impossible typed date never composes a value", () => {
148
- expect(segmentsToValue({ year: 2026, month: 2, day: 30, hour: null, minute: null }, false)).toBeNull();
149
- });
150
- });
151
-
152
- describe("incrementSegment", () => {
153
- it("wraps within range", () => {
154
- expect(incrementSegment("month", 12, 1, false)).toBe(1);
155
- expect(incrementSegment("month", 1, -1, false)).toBe(12);
156
- expect(incrementSegment("minute", 59, 1, false)).toBe(0);
157
- expect(incrementSegment("hour", 23, 1, false)).toBe(0);
158
- expect(incrementSegment("hour", 12, 1, true)).toBe(1);
159
- });
160
-
161
- it("starts from an end when empty", () => {
162
- expect(incrementSegment("day", null, 1, false)).toBe(1);
163
- expect(incrementSegment("day", null, -1, false)).toBe(31);
164
- });
165
-
166
- it("clamps the year instead of wrapping", () => {
167
- expect(incrementSegment("year", 2026, 1, false)).toBe(2027);
168
- expect(incrementSegment("year", 9999, 1, false)).toBe(9999);
169
- expect(incrementSegment("year", 1, -1, false)).toBe(1);
170
- });
171
- });
172
-
173
- describe("setHourField / withDayPeriod", () => {
174
- it("keeps AM/PM when typing the hour in 12h mode", () => {
175
- expect(setHourField({ ...empty, hour: 14 }, 9, true)).toBe(21); // 9 PM
176
- expect(setHourField({ ...empty, hour: 9 }, 11, true)).toBe(11); // 11 AM
177
- });
178
-
179
- it("returns the typed hour as-is in 24h mode", () => {
180
- expect(setHourField({ ...empty, hour: null }, 18, false)).toBe(18);
181
- });
182
-
183
- it("toggles half-day while preserving the 12h value", () => {
184
- expect(withDayPeriod({ ...empty, hour: 9 }, true)).toBe(21);
185
- expect(withDayPeriod({ ...empty, hour: 21 }, false)).toBe(9);
186
- });
187
- });
188
-
189
- describe("displayValue", () => {
190
- const en = getDateLayout("en-US", true);
191
- const vn = getDateLayout("vi-VN", true);
192
-
193
- it("renders the 12h hour and AM/PM for en-US", () => {
194
- const buffer = { year: 2026, month: 5, day: 17, hour: 14, minute: 30 };
195
- expect(displayValue("hour", buffer, en)).toBe("02");
196
- expect(displayValue("dayPeriod", buffer, en)).toBe(en.pmText);
197
- expect(displayValue("minute", buffer, en)).toBe("30");
198
- });
199
-
200
- it("renders the 24h hour for vi-VN", () => {
201
- const buffer = { year: 2026, month: 5, day: 17, hour: 14, minute: 30 };
202
- expect(displayValue("hour", buffer, vn)).toBe("14");
203
- });
204
-
205
- it("returns null for unset fields", () => {
206
- expect(displayValue("year", empty, en)).toBeNull();
207
- expect(displayValue("hour", empty, en)).toBeNull();
208
- });
209
- });
210
-
211
- describe("valueToSegments / segmentsToValue round-trip", () => {
212
- it("round-trips a date", () => {
213
- const buffer = valueToSegments("2026-05-17", false);
214
- expect(buffer).toEqual({ year: 2026, month: 5, day: 17, hour: null, minute: null });
215
- expect(segmentsToValue(buffer, false)).toBe("2026-05-17");
216
- });
217
-
218
- it("round-trips a datetime", () => {
219
- const buffer = valueToSegments("2026-05-17T14:30", true);
220
- expect(buffer).toEqual({ year: 2026, month: 5, day: 17, hour: 14, minute: 30 });
221
- expect(segmentsToValue(buffer, true)).toBe("2026-05-17T14:30");
222
- });
223
-
224
- it("returns null for incomplete buffers", () => {
225
- expect(segmentsToValue({ ...empty, year: 2026, month: 5 }, false)).toBeNull();
226
- expect(segmentsToValue({ year: 2026, month: 5, day: 17, hour: 14, minute: null }, true)).toBeNull();
227
- });
228
-
229
- it("rejects impossible calendar dates", () => {
230
- expect(segmentsToValue({ ...empty, year: 2026, month: 2, day: 30 }, false)).toBeNull();
231
- });
232
-
233
- it("yields an empty buffer for an unparseable value", () => {
234
- expect(valueToSegments("", false)).toEqual(empty);
235
- expect(isBufferEmpty(valueToSegments("garbage", false), false)).toBe(true);
236
- });
237
- });
238
-
239
- describe("placeholderFor", () => {
240
- it("gives per-type placeholders", () => {
241
- expect(placeholderFor("year")).toBe("yyyy");
242
- expect(placeholderFor("month")).toBe("mm");
243
- expect(placeholderFor("dayPeriod")).toBe("--");
244
- });
245
- });
246
-
247
- describe("locale-driven default segment order (LoticsLocaleProvider)", () => {
248
- // The provider pack carries a BCP-47 tag; a wired date field defaults its
249
- // segment order to that tag when no explicit `locale` prop is given. These
250
- // assert the end-to-end contract — provider pack → rendered part ORDER —
251
- // through the same resolveLocaleTag + getDateLayout the components compose.
252
- // Before this, the field hardcoded "en-US", so a vi-wrapped app showed
253
- // MM/DD/YYYY unless every call site threaded a `locale` prop.
254
- it("a vi provider yields dd/MM/yyyy by default (no explicit locale)", () => {
255
- const tag = resolveLocaleTag(viLocale);
256
- expect(tag).toBe("vi-VN");
257
- expect(fieldOrder(getDateLayout(tag, false))).toEqual(["day", "month", "year"]);
258
- });
259
-
260
- it("an en provider yields MM/dd/yyyy by default", () => {
261
- const tag = resolveLocaleTag(enLocale);
262
- expect(tag).toBe("en-US");
263
- expect(fieldOrder(getDateLayout(tag, false))).toEqual(["month", "day", "year"]);
264
- });
265
-
266
- it("an explicit locale prop overrides the provider pack, both directions", () => {
267
- // Under a vi provider, an author passing en-US still gets MM/dd/yyyy…
268
- expect(fieldOrder(getDateLayout(resolveLocaleTag(viLocale, "en-US"), false))).toEqual([
269
- "month",
270
- "day",
271
- "year",
272
- ]);
273
- // …and an en provider with an explicit vi-VN gets dd/MM/yyyy.
274
- expect(fieldOrder(getDateLayout(resolveLocaleTag(enLocale, "vi-VN"), false))).toEqual([
275
- "day",
276
- "month",
277
- "year",
278
- ]);
279
- });
280
- });
281
-
282
- describe("getTimeLayout", () => {
283
- it("is hour/minute(+AM/PM) only, locale-driven", () => {
284
- expect(fieldOrder(getTimeLayout("en-US"))).toEqual(["hour", "minute", "dayPeriod"]);
285
- const vn = getTimeLayout("vi-VN");
286
- expect(fieldOrder(vn)).toEqual(["hour", "minute"]);
287
- expect(vn.hour12).toBe(false);
288
- });
289
- });
290
-
291
- // -----------------------------------------------------------------------------
292
- // Time-only field. The point of the segmented time field is that 12/24-hour is
293
- // OUR locale's decision — a native `<input type="time">` takes it from the
294
- // browser's UI locale and ignores `lang`, so a Vietnamese screen on an en-US
295
- // browser rendered "01:45 PM" beside its own 24-hour text.
296
- // -----------------------------------------------------------------------------
297
-
298
- describe("timeSegmentsConfig", () => {
299
- it("derives 24-hour display from the locale, not the runtime", () => {
300
- expect(timeSegmentsConfig("vi-VN").layout.hour12).toBe(false);
301
- expect(timeSegmentsConfig("de-DE").layout.hour12).toBe(false);
302
- });
303
-
304
- it("derives 12-hour display where the locale uses one", () => {
305
- const layout = timeSegmentsConfig("en-US").layout;
306
- expect(layout.hour12).toBe(true);
307
- expect(layout.segments.some((s) => s.kind === "field" && s.type === "dayPeriod")).toBe(true);
308
- });
309
-
310
- it("keeps the value canonical 24-hour under a 12-hour locale", () => {
311
- const cfg = timeSegmentsConfig("en-US");
312
- expect(cfg.toValue(cfg.toBuffer("13:45"))).toBe("13:45");
313
- expect(cfg.toValue(cfg.toBuffer("00:30"))).toBe("00:30");
314
- });
315
-
316
- it("round-trips every hour of the day", () => {
317
- const cfg = timeSegmentsConfig("en-US");
318
- for (let h = 0; h < 24; h++) {
319
- const iso = `${String(h).padStart(2, "0")}:15`;
320
- expect(cfg.toValue(cfg.toBuffer(iso))).toBe(iso);
321
- }
322
- });
323
-
324
- it("reads an empty or unparseable time as empty", () => {
325
- const cfg = timeSegmentsConfig("vi-VN");
326
- for (const bad of ["", "banana", "25:00", "12:99"]) {
327
- expect(cfg.isEmpty(cfg.toBuffer(bad))).toBe(true);
328
- }
329
- });
330
-
331
- it("emits nothing while only half the time is typed", () => {
332
- expect(segmentsToTime({ year: null, month: null, day: null, hour: 13, minute: null })).toBeNull();
333
- expect(segmentsToTime({ year: null, month: null, day: null, hour: null, minute: 45 })).toBeNull();
334
- });
335
- });