@lotics/ui 1.12.0 → 1.13.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.
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ parsePart,
4
+ partsToIso,
5
+ parseText,
6
+ timeText,
7
+ splitTime,
8
+ isoToDate,
9
+ parseTimeString,
10
+ withCalendarDate,
11
+ withTimeOfDay,
12
+ nowIso,
13
+ } from "./date_picker_value";
14
+
15
+ describe("parsePart", () => {
16
+ it("parses a plain date", () => {
17
+ expect(parsePart("2026-05-17")).toEqual({ y: 2026, mo: 5, d: 17, h: 0, mi: 0 });
18
+ });
19
+
20
+ it("parses a datetime with T or space separator", () => {
21
+ expect(parsePart("2026-05-17T14:30")).toEqual({ y: 2026, mo: 5, d: 17, h: 14, mi: 30 });
22
+ expect(parsePart("2026-05-17 14:30")).toEqual({ y: 2026, mo: 5, d: 17, h: 14, mi: 30 });
23
+ });
24
+
25
+ it("is lenient on missing leading zeros", () => {
26
+ expect(parsePart("2026-3-7")).toEqual({ y: 2026, mo: 3, d: 7, h: 0, mi: 0 });
27
+ });
28
+
29
+ it("trims surrounding whitespace", () => {
30
+ expect(parsePart(" 2026-05-17 ")).toEqual({ y: 2026, mo: 5, d: 17, h: 0, mi: 0 });
31
+ });
32
+
33
+ it("rejects impossible calendar dates", () => {
34
+ expect(parsePart("2026-02-30")).toBeNull();
35
+ expect(parsePart("2026-13-01")).toBeNull();
36
+ expect(parsePart("2026-00-10")).toBeNull();
37
+ });
38
+
39
+ it("rejects out-of-range times", () => {
40
+ expect(parsePart("2026-05-17 24:00")).toBeNull();
41
+ expect(parsePart("2026-05-17 12:60")).toBeNull();
42
+ });
43
+
44
+ it("rejects malformed input", () => {
45
+ expect(parsePart("not-a-date")).toBeNull();
46
+ expect(parsePart("2026/05/17")).toBeNull();
47
+ expect(parsePart("")).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe("partsToIso", () => {
52
+ it("formats a date without time", () => {
53
+ expect(partsToIso({ y: 2026, mo: 5, d: 7, h: 0, mi: 0 }, false)).toBe("2026-05-07");
54
+ });
55
+
56
+ it("formats a datetime with zero-padded time", () => {
57
+ expect(partsToIso({ y: 2026, mo: 5, d: 7, h: 9, mi: 5 }, true)).toBe("2026-05-07T09:05");
58
+ });
59
+ });
60
+
61
+ describe("parseText", () => {
62
+ it("returns empty string for blank input (clear)", () => {
63
+ expect(parseText("", false)).toBe("");
64
+ expect(parseText(" ", false)).toBe("");
65
+ });
66
+
67
+ it("parses a single date", () => {
68
+ expect(parseText("2026-3-7", false)).toBe("2026-03-07");
69
+ });
70
+
71
+ it("drops the time component for date-only formats", () => {
72
+ expect(parseText("2026-05-17 14:30", false)).toBe("2026-05-17");
73
+ });
74
+
75
+ it("keeps the time component for datetime formats", () => {
76
+ expect(parseText("2026-05-17 14:30", true)).toBe("2026-05-17T14:30");
77
+ });
78
+
79
+ it("returns null for an unparseable value", () => {
80
+ expect(parseText("2026-02-30", false)).toBeNull();
81
+ expect(parseText("garbage", false)).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe("timeText", () => {
86
+ it("extracts HH:mm from a datetime value", () => {
87
+ expect(timeText("2026-05-17T09:05")).toBe("09:05");
88
+ });
89
+
90
+ it("treats a date-only value as midnight", () => {
91
+ expect(timeText("2026-05-17")).toBe("00:00");
92
+ });
93
+
94
+ it("returns empty for an unparseable value", () => {
95
+ expect(timeText("")).toBe("");
96
+ });
97
+ });
98
+
99
+ describe("splitTime", () => {
100
+ it("splits a canonical time string", () => {
101
+ expect(splitTime("14:30")).toEqual({ h: 14, mi: 30 });
102
+ });
103
+
104
+ it("returns midnight for empty input", () => {
105
+ expect(splitTime("")).toEqual({ h: 0, mi: 0 });
106
+ });
107
+ });
108
+
109
+ describe("isoToDate", () => {
110
+ it("builds a local Date from an ISO string", () => {
111
+ const date = isoToDate("2026-05-17T14:30");
112
+ expect(date?.getFullYear()).toBe(2026);
113
+ expect(date?.getMonth()).toBe(4);
114
+ expect(date?.getDate()).toBe(17);
115
+ expect(date?.getHours()).toBe(14);
116
+ expect(date?.getMinutes()).toBe(30);
117
+ });
118
+
119
+ it("returns null for an unparseable string", () => {
120
+ expect(isoToDate("")).toBeNull();
121
+ expect(isoToDate("2026-02-30")).toBeNull();
122
+ });
123
+ });
124
+
125
+ describe("withCalendarDate", () => {
126
+ const may20 = new Date(2026, 4, 20);
127
+
128
+ it("keeps the time carried by the previous value for datetime formats", () => {
129
+ expect(withCalendarDate(may20, "2026-01-01T14:30", true)).toBe("2026-05-20T14:30");
130
+ });
131
+
132
+ it("defaults to midnight when the previous value has no time", () => {
133
+ expect(withCalendarDate(may20, "", true)).toBe("2026-05-20T00:00");
134
+ });
135
+
136
+ it("drops the time component for date-only formats", () => {
137
+ expect(withCalendarDate(may20, "2026-01-01T14:30", false)).toBe("2026-05-20");
138
+ });
139
+ });
140
+
141
+ describe("withTimeOfDay", () => {
142
+ it("keeps the date and applies the new time", () => {
143
+ expect(withTimeOfDay("2026-05-20", "09:05")).toBe("2026-05-20T09:05");
144
+ expect(withTimeOfDay("2026-05-20T14:30", "08:00")).toBe("2026-05-20T08:00");
145
+ });
146
+ });
147
+
148
+ describe("nowIso", () => {
149
+ it("produces a canonical date or datetime string", () => {
150
+ expect(nowIso(false)).toMatch(/^\d{4}-\d{2}-\d{2}$/);
151
+ expect(nowIso(true)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
152
+ });
153
+ });
154
+
155
+ describe("parseTimeString", () => {
156
+ it("parses and zero-pads a valid time", () => {
157
+ expect(parseTimeString("9:05")).toBe("09:05");
158
+ expect(parseTimeString("14:30")).toBe("14:30");
159
+ });
160
+
161
+ it("rejects out-of-range or malformed times", () => {
162
+ expect(parseTimeString("24:00")).toBeNull();
163
+ expect(parseTimeString("12:60")).toBeNull();
164
+ expect(parseTimeString("1430")).toBeNull();
165
+ expect(parseTimeString("")).toBeNull();
166
+ });
167
+ });
@@ -0,0 +1,128 @@
1
+ // =============================================================================
2
+ // Date picker value logic
3
+ //
4
+ // Values are canonical ISO strings: "YYYY-MM-DD" (date) or "YYYY-MM-DDTHH:mm"
5
+ // (datetime). Range formats store two parts joined by "/".
6
+ //
7
+ // Pure string/Date logic, kept separate from the React component so it can be
8
+ // unit-tested directly.
9
+ // =============================================================================
10
+
11
+ export type DatePickerFormat = "date" | "datetime" | "date_range" | "datetime_range";
12
+
13
+ export interface DateParts {
14
+ y: number;
15
+ mo: number;
16
+ d: number;
17
+ h: number;
18
+ mi: number;
19
+ }
20
+
21
+ export function isRangeFormat(format: DatePickerFormat): boolean {
22
+ return format === "date_range" || format === "datetime_range";
23
+ }
24
+
25
+ export function hasTimeFormat(format: DatePickerFormat): boolean {
26
+ return format === "datetime" || format === "datetime_range";
27
+ }
28
+
29
+ export const pad = (n: number) => String(n).padStart(2, "0");
30
+
31
+ // Accepts "2026-05-17" and "2026-05-17 14:30" / "2026-05-17T14:30", lenient on
32
+ // leading zeros so partially-typed input still parses on blur.
33
+ const PART_RE = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2}))?$/;
34
+
35
+ export function parsePart(input: string): DateParts | null {
36
+ const match = input.trim().match(PART_RE);
37
+ if (!match) return null;
38
+ const y = Number(match[1]);
39
+ const mo = Number(match[2]);
40
+ const d = Number(match[3]);
41
+ const h = match[4] !== undefined ? Number(match[4]) : 0;
42
+ const mi = match[5] !== undefined ? Number(match[5]) : 0;
43
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
44
+ if (h > 23 || mi > 59) return null;
45
+ // Reject impossible dates like Feb 30.
46
+ const probe = new Date(y, mo - 1, d);
47
+ if (probe.getMonth() !== mo - 1 || probe.getDate() !== d) return null;
48
+ return { y, mo, d, h, mi };
49
+ }
50
+
51
+ export function partsToIso(p: DateParts, hasTime: boolean): string {
52
+ const date = `${p.y}-${pad(p.mo)}-${pad(p.d)}`;
53
+ return hasTime ? `${date}T${pad(p.h)}:${pad(p.mi)}` : date;
54
+ }
55
+
56
+ export function isoToDate(iso: string): Date | null {
57
+ const p = parsePart(iso);
58
+ return p ? new Date(p.y, p.mo - 1, p.d, p.h, p.mi) : null;
59
+ }
60
+
61
+ export function dateToParts(d: Date): DateParts {
62
+ return {
63
+ y: d.getFullYear(),
64
+ mo: d.getMonth() + 1,
65
+ d: d.getDate(),
66
+ h: d.getHours(),
67
+ mi: d.getMinutes(),
68
+ };
69
+ }
70
+
71
+ /** Parse a typed value back to a canonical ISO string. Returns "" when blank, null when unparseable. */
72
+ export function parseText(text: string, hasTime: boolean): string | null {
73
+ const trimmed = text.trim();
74
+ if (trimmed === "") return "";
75
+ const single = parsePart(trimmed);
76
+ return single ? partsToIso(single, hasTime) : null;
77
+ }
78
+
79
+ export function timeText(iso: string): string {
80
+ const p = parsePart(iso);
81
+ return p ? `${pad(p.h)}:${pad(p.mi)}` : "";
82
+ }
83
+
84
+ export function splitTime(time: string): { h: number; mi: number } {
85
+ if (!time) return { h: 0, mi: 0 };
86
+ const [h, mi] = time.split(":").map(Number);
87
+ return { h, mi };
88
+ }
89
+
90
+ /** Combine a picked calendar date with the time carried by a previous value. */
91
+ export function withCalendarDate(date: Date, prevIso: string, hasTime: boolean): string {
92
+ const prev = parsePart(prevIso);
93
+ return partsToIso(
94
+ {
95
+ ...dateToParts(date),
96
+ h: hasTime && prev ? prev.h : 0,
97
+ mi: hasTime && prev ? prev.mi : 0,
98
+ },
99
+ hasTime,
100
+ );
101
+ }
102
+
103
+ /** Combine a picked "HH:mm" time with the date carried by a previous value (today if none). */
104
+ export function withTimeOfDay(prevIso: string, time: string): string {
105
+ const base = parsePart(prevIso) ?? dateToParts(new Date());
106
+ const { h, mi } = splitTime(time);
107
+ return partsToIso({ y: base.y, mo: base.mo, d: base.d, h, mi }, true);
108
+ }
109
+
110
+ /** The current date as a canonical value string. */
111
+ export function nowIso(hasTime: boolean): string {
112
+ return partsToIso(dateToParts(new Date()), hasTime);
113
+ }
114
+
115
+ const TIME_RE = /^(\d{1,2}):(\d{2})$/;
116
+
117
+ /**
118
+ * Parse a loosely-typed time string into a canonical "HH:mm" string.
119
+ * Returns null when the input is not a valid time.
120
+ */
121
+ export function parseTimeString(input: string): string | null {
122
+ const match = input.trim().match(TIME_RE);
123
+ if (!match) return null;
124
+ const hour = Number(match[1]);
125
+ const minute = Number(match[2]);
126
+ if (hour > 23 || minute > 59) return null;
127
+ return `${pad(hour)}:${match[2]}`;
128
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ getDateLayout,
4
+ getTimeLayout,
5
+ fieldOrder,
6
+ to12h,
7
+ from12h,
8
+ typeDigit,
9
+ incrementSegment,
10
+ setHourField,
11
+ withDayPeriod,
12
+ displayValue,
13
+ valueToSegments,
14
+ segmentsToValue,
15
+ isBufferEmpty,
16
+ placeholderFor,
17
+ } from "./date_segments";
18
+
19
+ const empty = { year: null, month: null, day: null, hour: null, minute: null };
20
+
21
+ describe("getDateLayout / fieldOrder", () => {
22
+ it("derives m/d/y and 12h for en-US", () => {
23
+ expect(fieldOrder(getDateLayout("en-US", false))).toEqual(["month", "day", "year"]);
24
+ const dt = getDateLayout("en-US", true);
25
+ expect(fieldOrder(dt)).toEqual(["month", "day", "year", "hour", "minute", "dayPeriod"]);
26
+ expect(dt.hour12).toBe(true);
27
+ });
28
+
29
+ it("derives d/m/y, 24h, and time-first datetime for vi-VN", () => {
30
+ expect(fieldOrder(getDateLayout("vi-VN", false))).toEqual(["day", "month", "year"]);
31
+ const dt = getDateLayout("vi-VN", true);
32
+ // Vietnamese leads with the time ("HH:mm dd/MM/yyyy") — we follow the locale.
33
+ expect(fieldOrder(dt)).toEqual(["hour", "minute", "day", "month", "year"]);
34
+ expect(dt.hour12).toBe(false);
35
+ });
36
+
37
+ it("derives d/m/y for en-GB and y/m/d for ja-JP", () => {
38
+ expect(fieldOrder(getDateLayout("en-GB", false))).toEqual(["day", "month", "year"]);
39
+ expect(fieldOrder(getDateLayout("ja-JP", false))).toEqual(["year", "month", "day"]);
40
+ });
41
+
42
+ it("emits literal separators between fields", () => {
43
+ const literals = getDateLayout("en-US", false).segments.filter((s) => s.kind === "literal");
44
+ expect(literals.length).toBeGreaterThan(0);
45
+ });
46
+ });
47
+
48
+ describe("to12h / from12h", () => {
49
+ it("maps 24h to 12h", () => {
50
+ expect(to12h(0)).toEqual({ h12: 12, pm: false });
51
+ expect(to12h(12)).toEqual({ h12: 12, pm: true });
52
+ expect(to12h(13)).toEqual({ h12: 1, pm: true });
53
+ expect(to12h(23)).toEqual({ h12: 11, pm: true });
54
+ });
55
+
56
+ it("maps 12h back to 24h", () => {
57
+ expect(from12h(12, false)).toBe(0);
58
+ expect(from12h(12, true)).toBe(12);
59
+ expect(from12h(1, true)).toBe(13);
60
+ expect(from12h(11, false)).toBe(11);
61
+ });
62
+ });
63
+
64
+ describe("typeDigit", () => {
65
+ it("waits for a second digit when one could still fit", () => {
66
+ expect(typeDigit("month", "", "1", false)).toEqual({ text: "1", value: 1, complete: false });
67
+ expect(typeDigit("day", "", "3", false)).toEqual({ text: "3", value: 3, complete: false });
68
+ expect(typeDigit("minute", "", "5", false)).toEqual({ text: "5", value: 5, complete: false });
69
+ });
70
+
71
+ it("auto-advances when no second digit could keep it in range", () => {
72
+ expect(typeDigit("month", "", "2", false)).toEqual({ text: "2", value: 2, complete: true });
73
+ expect(typeDigit("day", "", "4", false)).toEqual({ text: "4", value: 4, complete: true });
74
+ expect(typeDigit("minute", "", "6", false)).toEqual({ text: "6", value: 6, complete: true });
75
+ expect(typeDigit("hour", "", "3", false)).toEqual({ text: "3", value: 3, complete: true });
76
+ expect(typeDigit("hour", "", "2", true)).toEqual({ text: "2", value: 2, complete: true });
77
+ });
78
+
79
+ it("completes on the second digit", () => {
80
+ expect(typeDigit("month", "1", "2", false)).toEqual({ text: "12", value: 12, complete: true });
81
+ expect(typeDigit("minute", "5", "9", false)).toEqual({ text: "59", value: 59, complete: true });
82
+ });
83
+
84
+ it("restarts when the accumulated value overflows", () => {
85
+ expect(typeDigit("month", "1", "5", false)).toEqual({ text: "5", value: 5, complete: true });
86
+ expect(typeDigit("hour", "2", "5", false)).toEqual({ text: "5", value: 5, complete: true });
87
+ });
88
+
89
+ it("accumulates up to four digits for the year", () => {
90
+ expect(typeDigit("year", "", "2", false)).toEqual({ text: "2", value: 2, complete: false });
91
+ expect(typeDigit("year", "202", "6", false)).toEqual({
92
+ text: "2026",
93
+ value: 2026,
94
+ complete: true,
95
+ });
96
+ expect(typeDigit("year", "2026", "7", false)).toEqual({
97
+ text: "0267",
98
+ value: 267,
99
+ complete: true,
100
+ });
101
+ });
102
+ });
103
+
104
+ describe("incrementSegment", () => {
105
+ it("wraps within range", () => {
106
+ expect(incrementSegment("month", 12, 1, false)).toBe(1);
107
+ expect(incrementSegment("month", 1, -1, false)).toBe(12);
108
+ expect(incrementSegment("minute", 59, 1, false)).toBe(0);
109
+ expect(incrementSegment("hour", 23, 1, false)).toBe(0);
110
+ expect(incrementSegment("hour", 12, 1, true)).toBe(1);
111
+ });
112
+
113
+ it("starts from an end when empty", () => {
114
+ expect(incrementSegment("day", null, 1, false)).toBe(1);
115
+ expect(incrementSegment("day", null, -1, false)).toBe(31);
116
+ });
117
+
118
+ it("clamps the year instead of wrapping", () => {
119
+ expect(incrementSegment("year", 2026, 1, false)).toBe(2027);
120
+ expect(incrementSegment("year", 9999, 1, false)).toBe(9999);
121
+ expect(incrementSegment("year", 1, -1, false)).toBe(1);
122
+ });
123
+ });
124
+
125
+ describe("setHourField / withDayPeriod", () => {
126
+ it("keeps AM/PM when typing the hour in 12h mode", () => {
127
+ expect(setHourField({ ...empty, hour: 14 }, 9, true)).toBe(21); // 9 PM
128
+ expect(setHourField({ ...empty, hour: 9 }, 11, true)).toBe(11); // 11 AM
129
+ });
130
+
131
+ it("returns the typed hour as-is in 24h mode", () => {
132
+ expect(setHourField({ ...empty, hour: null }, 18, false)).toBe(18);
133
+ });
134
+
135
+ it("toggles half-day while preserving the 12h value", () => {
136
+ expect(withDayPeriod({ ...empty, hour: 9 }, true)).toBe(21);
137
+ expect(withDayPeriod({ ...empty, hour: 21 }, false)).toBe(9);
138
+ });
139
+ });
140
+
141
+ describe("displayValue", () => {
142
+ const en = getDateLayout("en-US", true);
143
+ const vn = getDateLayout("vi-VN", true);
144
+
145
+ it("renders the 12h hour and AM/PM for en-US", () => {
146
+ const buffer = { year: 2026, month: 5, day: 17, hour: 14, minute: 30 };
147
+ expect(displayValue("hour", buffer, en)).toBe("02");
148
+ expect(displayValue("dayPeriod", buffer, en)).toBe(en.pmText);
149
+ expect(displayValue("minute", buffer, en)).toBe("30");
150
+ });
151
+
152
+ it("renders the 24h hour for vi-VN", () => {
153
+ const buffer = { year: 2026, month: 5, day: 17, hour: 14, minute: 30 };
154
+ expect(displayValue("hour", buffer, vn)).toBe("14");
155
+ });
156
+
157
+ it("returns null for unset fields", () => {
158
+ expect(displayValue("year", empty, en)).toBeNull();
159
+ expect(displayValue("hour", empty, en)).toBeNull();
160
+ });
161
+ });
162
+
163
+ describe("valueToSegments / segmentsToValue round-trip", () => {
164
+ it("round-trips a date", () => {
165
+ const buffer = valueToSegments("2026-05-17", false);
166
+ expect(buffer).toEqual({ year: 2026, month: 5, day: 17, hour: null, minute: null });
167
+ expect(segmentsToValue(buffer, false)).toBe("2026-05-17");
168
+ });
169
+
170
+ it("round-trips a datetime", () => {
171
+ const buffer = valueToSegments("2026-05-17T14:30", true);
172
+ expect(buffer).toEqual({ year: 2026, month: 5, day: 17, hour: 14, minute: 30 });
173
+ expect(segmentsToValue(buffer, true)).toBe("2026-05-17T14:30");
174
+ });
175
+
176
+ it("returns null for incomplete buffers", () => {
177
+ expect(segmentsToValue({ ...empty, year: 2026, month: 5 }, false)).toBeNull();
178
+ expect(segmentsToValue({ year: 2026, month: 5, day: 17, hour: 14, minute: null }, true)).toBeNull();
179
+ });
180
+
181
+ it("rejects impossible calendar dates", () => {
182
+ expect(segmentsToValue({ ...empty, year: 2026, month: 2, day: 30 }, false)).toBeNull();
183
+ });
184
+
185
+ it("yields an empty buffer for an unparseable value", () => {
186
+ expect(valueToSegments("", false)).toEqual(empty);
187
+ expect(isBufferEmpty(valueToSegments("garbage", false), false)).toBe(true);
188
+ });
189
+ });
190
+
191
+ describe("placeholderFor", () => {
192
+ it("gives per-type placeholders", () => {
193
+ expect(placeholderFor("year")).toBe("yyyy");
194
+ expect(placeholderFor("month")).toBe("mm");
195
+ expect(placeholderFor("dayPeriod")).toBe("--");
196
+ });
197
+ });
198
+
199
+ describe("getTimeLayout", () => {
200
+ it("is hour/minute(+AM/PM) only, locale-driven", () => {
201
+ expect(fieldOrder(getTimeLayout("en-US"))).toEqual(["hour", "minute", "dayPeriod"]);
202
+ const vn = getTimeLayout("vi-VN");
203
+ expect(fieldOrder(vn)).toEqual(["hour", "minute"]);
204
+ expect(vn.hour12).toBe(false);
205
+ });
206
+ });