@lotics/ui 1.11.1 → 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,347 @@
1
+ // =============================================================================
2
+ // Date segment logic
3
+ //
4
+ // Locale-derived segment layout + per-segment editing for the segmented date
5
+ // field. The displayed order (d/m/y vs m/d/y vs y/m/d), separators, and 12h-vs-
6
+ // 24h time are all derived from the locale via Intl.DateTimeFormat — nothing is
7
+ // hardcoded. Canonical values stay ISO and flow through date_picker_value.ts.
8
+ //
9
+ // Pure logic, kept separate from the React component so it can be unit-tested.
10
+ // =============================================================================
11
+
12
+ import { DateParts, pad, parsePart, parseText, partsToIso } from "./date_picker_value";
13
+
14
+ export type SegmentType = "year" | "month" | "day" | "hour" | "minute" | "dayPeriod";
15
+
16
+ /** Accessible names per segment. Keys match {@link SegmentType} so `labels[type]` resolves. */
17
+ export interface SegmentLabels {
18
+ year: string;
19
+ month: string;
20
+ day: string;
21
+ hour: string;
22
+ minute: string;
23
+ dayPeriod: string;
24
+ }
25
+
26
+ export type LayoutSegment =
27
+ | { kind: "field"; type: SegmentType }
28
+ | { kind: "literal"; value: string };
29
+
30
+ export interface DateLayout {
31
+ /** Field + literal segments in locale display order. */
32
+ segments: LayoutSegment[];
33
+ /** Whether time is shown in 12-hour form (with an AM/PM segment). */
34
+ hour12: boolean;
35
+ /** Localized AM/PM strings (only meaningful when hour12). */
36
+ amText: string;
37
+ pmText: string;
38
+ }
39
+
40
+ /** Committed segment values. `hour` is canonical 0–23 regardless of display. */
41
+ export interface SegmentBuffer {
42
+ year: number | null;
43
+ month: number | null;
44
+ day: number | null;
45
+ hour: number | null;
46
+ minute: number | null;
47
+ }
48
+
49
+ export function emptyBuffer(): SegmentBuffer {
50
+ return { year: null, month: null, day: null, hour: null, minute: null };
51
+ }
52
+
53
+ // -----------------------------------------------------------------------------
54
+ // Layout derivation
55
+ // -----------------------------------------------------------------------------
56
+
57
+ // Distinct sample components (2023 / 01 / 02 / 13:45) so part mapping is reliable.
58
+ const SAMPLE = new Date(2023, 0, 2, 13, 45, 0);
59
+
60
+ const FIELD_TYPE: Record<string, SegmentType | undefined> = {
61
+ year: "year",
62
+ month: "month",
63
+ day: "day",
64
+ hour: "hour",
65
+ minute: "minute",
66
+ dayPeriod: "dayPeriod",
67
+ };
68
+
69
+ // Intl formatters are costly to construct; derive each layout once.
70
+ const layoutCache = new Map<string, DateLayout>();
71
+
72
+ function dayPeriodText(locale: string, hour: number): string {
73
+ const part = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" })
74
+ .formatToParts(new Date(2023, 0, 2, hour, 0))
75
+ .find((p) => p.type === "dayPeriod");
76
+ return part?.value ?? (hour < 12 ? "AM" : "PM");
77
+ }
78
+
79
+ function partsToSegments(parts: Intl.DateTimeFormatPart[], hour12: boolean): LayoutSegment[] {
80
+ const segments: LayoutSegment[] = [];
81
+ for (const part of parts) {
82
+ const type = FIELD_TYPE[part.type];
83
+ if (type) {
84
+ if (type === "dayPeriod" && !hour12) continue;
85
+ segments.push({ kind: "field", type });
86
+ } else {
87
+ segments.push({ kind: "literal", value: part.value });
88
+ }
89
+ }
90
+ return segments;
91
+ }
92
+
93
+ export function getDateLayout(locale: string, hasTime: boolean): DateLayout {
94
+ const key = `${locale}|${hasTime}`;
95
+ const cached = layoutCache.get(key);
96
+ if (cached) return cached;
97
+
98
+ // Follow the locale's own field order and separators verbatim — including whether
99
+ // time leads (vi-VN: "HH:mm dd/MM/yyyy") or the date does (en-US: "MM/DD/YYYY,
100
+ // HH:mm AM/PM"), and 12h-vs-24h. Nothing is hardcoded.
101
+ const dtf = new Intl.DateTimeFormat(locale, {
102
+ year: "numeric",
103
+ month: "2-digit",
104
+ day: "2-digit",
105
+ ...(hasTime ? { hour: "2-digit", minute: "2-digit" } : {}),
106
+ });
107
+ const hour12 = hasTime ? (dtf.resolvedOptions().hour12 ?? false) : false;
108
+ const segments = partsToSegments(dtf.formatToParts(SAMPLE), hour12);
109
+
110
+ const layout: DateLayout = {
111
+ segments,
112
+ hour12,
113
+ amText: hour12 ? dayPeriodText(locale, 9) : "AM",
114
+ pmText: hour12 ? dayPeriodText(locale, 21) : "PM",
115
+ };
116
+ layoutCache.set(key, layout);
117
+ return layout;
118
+ }
119
+
120
+ /** Time-only layout (hour / minute / dayPeriod), for a standalone time field. */
121
+ export function getTimeLayout(locale: string): DateLayout {
122
+ const key = `time|${locale}`;
123
+ const cached = layoutCache.get(key);
124
+ if (cached) return cached;
125
+
126
+ const dtf = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" });
127
+ const hour12 = dtf.resolvedOptions().hour12 ?? false;
128
+ const layout: DateLayout = {
129
+ segments: partsToSegments(dtf.formatToParts(SAMPLE), hour12),
130
+ hour12,
131
+ amText: hour12 ? dayPeriodText(locale, 9) : "AM",
132
+ pmText: hour12 ? dayPeriodText(locale, 21) : "PM",
133
+ };
134
+ layoutCache.set(key, layout);
135
+ return layout;
136
+ }
137
+
138
+ /** Editable field segments in display order (literals dropped). */
139
+ export function fieldOrder(layout: DateLayout): SegmentType[] {
140
+ const order: SegmentType[] = [];
141
+ for (const seg of layout.segments) if (seg.kind === "field") order.push(seg.type);
142
+ return order;
143
+ }
144
+
145
+ // -----------------------------------------------------------------------------
146
+ // 12-hour conversions
147
+ // -----------------------------------------------------------------------------
148
+
149
+ export function to12h(hour24: number): { h12: number; pm: boolean } {
150
+ const pm = hour24 >= 12;
151
+ const mod = hour24 % 12;
152
+ return { h12: mod === 0 ? 12 : mod, pm };
153
+ }
154
+
155
+ export function from12h(h12: number, pm: boolean): number {
156
+ const base = h12 % 12;
157
+ return pm ? base + 12 : base;
158
+ }
159
+
160
+ // -----------------------------------------------------------------------------
161
+ // Per-segment editing
162
+ // -----------------------------------------------------------------------------
163
+
164
+ function bounds(type: SegmentType, hour12: boolean): { min: number; max: number } {
165
+ switch (type) {
166
+ case "year":
167
+ return { min: 1, max: 9999 };
168
+ case "month":
169
+ return { min: 1, max: 12 };
170
+ case "day":
171
+ return { min: 1, max: 31 };
172
+ case "hour":
173
+ return hour12 ? { min: 1, max: 12 } : { min: 0, max: 23 };
174
+ case "minute":
175
+ return { min: 0, max: 59 };
176
+ case "dayPeriod":
177
+ return { min: 0, max: 1 };
178
+ }
179
+ }
180
+
181
+ export function placeholderFor(type: SegmentType): string {
182
+ switch (type) {
183
+ case "year":
184
+ return "yyyy";
185
+ case "month":
186
+ return "mm";
187
+ case "day":
188
+ return "dd";
189
+ case "hour":
190
+ return "hh";
191
+ case "minute":
192
+ return "mm";
193
+ case "dayPeriod":
194
+ return "--";
195
+ }
196
+ }
197
+
198
+ export interface DigitResult {
199
+ /** In-progress text for the segment. */
200
+ text: string;
201
+ /** Parsed numeric value (display domain — for `hour` in 12h mode this is 1–12). */
202
+ value: number;
203
+ /** When true the segment is full and focus should advance to the next field. */
204
+ complete: boolean;
205
+ }
206
+
207
+ /** Apply a typed digit to a numeric segment's in-progress text. */
208
+ export function typeDigit(
209
+ type: SegmentType,
210
+ currentText: string,
211
+ digit: string,
212
+ hour12: boolean,
213
+ ): DigitResult {
214
+ const { max } = bounds(type, hour12);
215
+
216
+ if (type === "year") {
217
+ const text = (currentText + digit).slice(-4);
218
+ return { text, value: Number(text), complete: text.length >= 4 };
219
+ }
220
+
221
+ let text = currentText + digit;
222
+ let value = Number(text);
223
+ if (value > max) {
224
+ // The accumulated value overflows — restart from the freshly typed digit.
225
+ text = digit;
226
+ value = Number(digit);
227
+ }
228
+ // Advance once two digits are in, or when no second digit could keep it in range.
229
+ const complete = text.length >= 2 || value * 10 > max;
230
+ return { text, value, complete };
231
+ }
232
+
233
+ /** Step a numeric segment up/down, wrapping within its range. */
234
+ export function incrementSegment(
235
+ type: SegmentType,
236
+ current: number | null,
237
+ delta: number,
238
+ hour12: boolean,
239
+ ): number {
240
+ if (type === "year") {
241
+ if (current == null) return new Date().getFullYear();
242
+ return Math.min(9999, Math.max(1, current + delta));
243
+ }
244
+ const { min, max } = bounds(type, hour12);
245
+ if (current == null) return delta > 0 ? min : max;
246
+ const range = max - min + 1;
247
+ return (((current - min + delta) % range) + range) % range + min;
248
+ }
249
+
250
+ /** Convert a typed `hour` segment value (display domain) to a canonical 0–23 hour. */
251
+ export function setHourField(buffer: SegmentBuffer, typed: number, hour12: boolean): number {
252
+ if (!hour12) return typed;
253
+ const pm = buffer.hour == null ? false : to12h(buffer.hour).pm;
254
+ return from12h(typed, pm);
255
+ }
256
+
257
+ /** Set the AM/PM half-day, preserving the displayed 12-hour value. */
258
+ export function withDayPeriod(buffer: SegmentBuffer, pm: boolean): number {
259
+ const h12 = buffer.hour == null ? 12 : to12h(buffer.hour).h12;
260
+ return from12h(h12, pm);
261
+ }
262
+
263
+ // -----------------------------------------------------------------------------
264
+ // Display + value mapping
265
+ // -----------------------------------------------------------------------------
266
+
267
+ /** Zero-padded display text for a committed segment, or null when unset. */
268
+ export function displayValue(
269
+ type: SegmentType,
270
+ buffer: SegmentBuffer,
271
+ layout: DateLayout,
272
+ ): string | null {
273
+ switch (type) {
274
+ case "year":
275
+ return buffer.year == null ? null : String(buffer.year).padStart(4, "0");
276
+ case "month":
277
+ return buffer.month == null ? null : pad(buffer.month);
278
+ case "day":
279
+ return buffer.day == null ? null : pad(buffer.day);
280
+ case "minute":
281
+ return buffer.minute == null ? null : pad(buffer.minute);
282
+ case "hour":
283
+ if (buffer.hour == null) return null;
284
+ return layout.hour12 ? pad(to12h(buffer.hour).h12) : pad(buffer.hour);
285
+ case "dayPeriod":
286
+ if (buffer.hour == null) return null;
287
+ return to12h(buffer.hour).pm ? layout.pmText : layout.amText;
288
+ }
289
+ }
290
+
291
+ export function valueToSegments(iso: string, hasTime: boolean): SegmentBuffer {
292
+ const p = parsePart(iso);
293
+ if (!p) return emptyBuffer();
294
+ return {
295
+ year: p.y,
296
+ month: p.mo,
297
+ day: p.d,
298
+ hour: hasTime ? p.h : null,
299
+ minute: hasTime ? p.mi : null,
300
+ };
301
+ }
302
+
303
+ export function isBufferEmpty(buffer: SegmentBuffer, hasTime: boolean): boolean {
304
+ const dateEmpty = buffer.year == null && buffer.month == null && buffer.day == null;
305
+ if (!hasTime) return dateEmpty;
306
+ return dateEmpty && buffer.hour == null && buffer.minute == null;
307
+ }
308
+
309
+ /** Compose a canonical ISO value, or null when the buffer is incomplete/invalid. */
310
+ export function segmentsToValue(buffer: SegmentBuffer, hasTime: boolean): string | null {
311
+ if (buffer.year == null || buffer.month == null || buffer.day == null) return null;
312
+ if (hasTime && (buffer.hour == null || buffer.minute == null)) return null;
313
+ const parts: DateParts = {
314
+ y: buffer.year,
315
+ mo: buffer.month,
316
+ d: buffer.day,
317
+ h: hasTime ? (buffer.hour as number) : 0,
318
+ mi: hasTime ? (buffer.minute as number) : 0,
319
+ };
320
+ const iso = partsToIso(parts, hasTime);
321
+ // Reject impossible dates (Feb 30) and sub-1000 years that can't round-trip.
322
+ return parsePart(iso) ? iso : null;
323
+ }
324
+
325
+ // -----------------------------------------------------------------------------
326
+ // Field configuration — the layout + value↔buffer mapping the segment engine
327
+ // uses to drive a date / datetime field.
328
+ // -----------------------------------------------------------------------------
329
+
330
+ export interface SegmentsConfig {
331
+ layout: DateLayout;
332
+ toBuffer: (value: string) => SegmentBuffer;
333
+ toValue: (buffer: SegmentBuffer) => string | null;
334
+ isEmpty: (buffer: SegmentBuffer) => boolean;
335
+ /** Parse loosely-typed/pasted text to a canonical value, or null. */
336
+ parse: (text: string) => string | null;
337
+ }
338
+
339
+ export function dateSegmentsConfig(locale: string, hasTime: boolean): SegmentsConfig {
340
+ return {
341
+ layout: getDateLayout(locale, hasTime),
342
+ toBuffer: (value) => valueToSegments(value, hasTime),
343
+ toValue: (buffer) => segmentsToValue(buffer, hasTime),
344
+ isEmpty: (buffer) => isBufferEmpty(buffer, hasTime),
345
+ parse: (text) => parseText(text, hasTime),
346
+ };
347
+ }