@kalyx/core 0.2.2 → 0.4.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.
package/dist/index.cjs CHANGED
@@ -20,8 +20,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ DEFAULT_DATEPICKER_LABELS: () => DEFAULT_DATEPICKER_LABELS,
24
+ DEFAULT_DATETIMEPICKER_LABELS: () => DEFAULT_DATETIMEPICKER_LABELS,
25
+ DEFAULT_RANGEPICKER_LABELS: () => DEFAULT_RANGEPICKER_LABELS,
26
+ DEFAULT_TIMEPICKER_LABELS: () => DEFAULT_TIMEPICKER_LABELS,
23
27
  DateFnsAdapter: () => DateFnsAdapter,
28
+ civilMidnightFromUtcDay: () => civilMidnightFromUtcDay,
24
29
  formatFullDate: () => formatFullDate,
30
+ formatInTimezone: () => formatInTimezone,
25
31
  formatMonthYear: () => formatMonthYear,
26
32
  formatTimeFromISO: () => formatTimeFromISO,
27
33
  formatTimeString: () => formatTimeString,
@@ -30,8 +36,11 @@ __export(index_exports, {
30
36
  getCalendarDays: () => getCalendarDays,
31
37
  getMonthName: () => getMonthName,
32
38
  getTime: () => getTime,
39
+ getTimeInTimezone: () => getTimeInTimezone,
40
+ getTimezoneOffsetMinutes: () => getTimezoneOffsetMinutes,
33
41
  getWeekdayNames: () => getWeekdayNames,
34
42
  isDateDisabled: () => isDateDisabled,
43
+ isSameDayInTimezone: () => isSameDayInTimezone,
35
44
  isSameTime: () => isSameTime,
36
45
  maxDate: () => maxDate,
37
46
  minDate: () => minDate,
@@ -39,15 +48,125 @@ __export(index_exports, {
39
48
  parseInputValue: () => parseInputValue,
40
49
  parseTimeString: () => parseTimeString,
41
50
  setTime: () => setTime,
51
+ setTimeInTimezone: () => setTimeInTimezone,
52
+ startOfDayInTimezone: () => startOfDayInTimezone,
42
53
  to12Hour: () => to12Hour,
43
- to24Hour: () => to24Hour
54
+ to24Hour: () => to24Hour,
55
+ todayInTimezone: () => todayInTimezone
44
56
  });
45
57
  module.exports = __toCommonJS(index_exports);
46
58
 
47
59
  // src/adapters/date-fns.ts
60
+ var import_date_fns2 = require("date-fns");
61
+
62
+ // src/utils/timezone.ts
48
63
  var import_date_fns = require("date-fns");
64
+ var formatterCache = /* @__PURE__ */ new Map();
65
+ function getCachedPartsFormatter(timeZone) {
66
+ const key = `parts:${timeZone}`;
67
+ let fmt = formatterCache.get(key);
68
+ if (!fmt) {
69
+ fmt = new Intl.DateTimeFormat("en-US", {
70
+ timeZone,
71
+ year: "numeric",
72
+ month: "2-digit",
73
+ day: "2-digit",
74
+ hour: "2-digit",
75
+ minute: "2-digit",
76
+ second: "2-digit",
77
+ hourCycle: "h23"
78
+ });
79
+ formatterCache.set(key, fmt);
80
+ }
81
+ return fmt;
82
+ }
83
+ function partsInTimezone(utc, timeZone) {
84
+ const dtf = getCachedPartsFormatter(timeZone);
85
+ const parts = Object.fromEntries(
86
+ dtf.formatToParts(utc).map((p) => [p.type, p.value])
87
+ );
88
+ return {
89
+ year: Number(parts.year),
90
+ month: Number(parts.month),
91
+ day: Number(parts.day),
92
+ // Some locales/engines return '24' instead of '0' at midnight
93
+ hour: parts.hour === "24" ? 0 : Number(parts.hour),
94
+ minute: Number(parts.minute),
95
+ second: Number(parts.second)
96
+ };
97
+ }
98
+ function formatInTimezone(iso, formatStr, timeZone) {
99
+ const p = partsInTimezone((0, import_date_fns.parseISO)(iso), timeZone);
100
+ const tokens = {
101
+ yyyy: String(p.year),
102
+ MM: String(p.month).padStart(2, "0"),
103
+ dd: String(p.day).padStart(2, "0"),
104
+ HH: String(p.hour).padStart(2, "0"),
105
+ mm: String(p.minute).padStart(2, "0"),
106
+ ss: String(p.second).padStart(2, "0")
107
+ };
108
+ let result = formatStr;
109
+ for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
110
+ result = result.split(token).join(value);
111
+ }
112
+ return result;
113
+ }
114
+ function getTimezoneOffsetMinutes(iso, timeZone) {
115
+ const utc = (0, import_date_fns.parseISO)(iso);
116
+ const p = partsInTimezone(utc, timeZone);
117
+ const asUtcEpoch = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
118
+ return Math.round((asUtcEpoch - utc.getTime()) / 6e4);
119
+ }
120
+ function startOfDayInTimezone(iso, timeZone) {
121
+ const utc = (0, import_date_fns.parseISO)(iso);
122
+ const p = partsInTimezone(utc, timeZone);
123
+ const civilMidnightUtc = Date.UTC(p.year, p.month - 1, p.day, 0, 0, 0);
124
+ const midnightProbe = new Date(civilMidnightUtc).toISOString();
125
+ const offsetMinutes = getTimezoneOffsetMinutes(midnightProbe, timeZone);
126
+ return new Date(civilMidnightUtc - offsetMinutes * 6e4).toISOString();
127
+ }
128
+ function isSameDayInTimezone(a, b, timeZone) {
129
+ const pa = partsInTimezone((0, import_date_fns.parseISO)(a), timeZone);
130
+ const pb = partsInTimezone((0, import_date_fns.parseISO)(b), timeZone);
131
+ return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
132
+ }
133
+ function todayInTimezone(timeZone) {
134
+ return startOfDayInTimezone((/* @__PURE__ */ new Date()).toISOString(), timeZone);
135
+ }
136
+ function civilMidnightFromUtcDay(gridUtcIso, timeZone) {
137
+ const utc = (0, import_date_fns.parseISO)(gridUtcIso);
138
+ const probe = new Date(Date.UTC(
139
+ utc.getUTCFullYear(),
140
+ utc.getUTCMonth(),
141
+ utc.getUTCDate(),
142
+ 12,
143
+ 0,
144
+ 0
145
+ )).toISOString();
146
+ return startOfDayInTimezone(probe, timeZone);
147
+ }
148
+ function getTimeInTimezone(iso, timeZone) {
149
+ const p = partsInTimezone((0, import_date_fns.parseISO)(iso), timeZone);
150
+ return { hours: p.hour, minutes: p.minute, seconds: p.second };
151
+ }
152
+ function setTimeInTimezone(iso, partial, timeZone) {
153
+ const p = partsInTimezone((0, import_date_fns.parseISO)(iso), timeZone);
154
+ const targetHours = partial.hours ?? p.hour;
155
+ const targetMinutes = partial.minutes ?? p.minute;
156
+ const targetSeconds = partial.seconds ?? p.second;
157
+ const civilEpoch = Date.UTC(p.year, p.month - 1, p.day, targetHours, targetMinutes, targetSeconds);
158
+ const probe1 = new Date(civilEpoch).toISOString();
159
+ const offset1 = getTimezoneOffsetMinutes(probe1, timeZone);
160
+ const realEpoch1 = civilEpoch - offset1 * 6e4;
161
+ const probe2 = new Date(realEpoch1).toISOString();
162
+ const offset2 = getTimezoneOffsetMinutes(probe2, timeZone);
163
+ const realEpoch2 = civilEpoch - offset2 * 6e4;
164
+ return new Date(realEpoch2).toISOString();
165
+ }
166
+
167
+ // src/adapters/date-fns.ts
49
168
  function toDate(iso) {
50
- return (0, import_date_fns.parseISO)(iso);
169
+ return (0, import_date_fns2.parseISO)(iso);
51
170
  }
52
171
  function toISO(date) {
53
172
  return date.toISOString();
@@ -89,7 +208,10 @@ var DateFnsAdapter = {
89
208
  if (!value) return "";
90
209
  return normalize(value);
91
210
  },
92
- format(iso, formatStr) {
211
+ format(iso, formatStr, timezone) {
212
+ if (timezone) {
213
+ return formatInTimezone(iso, formatStr, timezone);
214
+ }
93
215
  const d = toDate(iso);
94
216
  const tokens = {
95
217
  yyyy: String(d.getUTCFullYear()),
@@ -108,22 +230,25 @@ var DateFnsAdapter = {
108
230
  return result;
109
231
  },
110
232
  addDays(iso, n) {
111
- return toISO((0, import_date_fns.addDays)(toDate(iso), n));
233
+ return toISO((0, import_date_fns2.addDays)(toDate(iso), n));
112
234
  },
113
235
  addMonths(iso, n) {
114
- return toISO((0, import_date_fns.addMonths)(toDate(iso), n));
236
+ return toISO((0, import_date_fns2.addMonths)(toDate(iso), n));
115
237
  },
116
238
  addYears(iso, n) {
117
- return toISO((0, import_date_fns.addYears)(toDate(iso), n));
239
+ return toISO((0, import_date_fns2.addYears)(toDate(iso), n));
118
240
  },
119
241
  isBefore(a, b) {
120
- return (0, import_date_fns.isBefore)(toDate(a), toDate(b));
242
+ return (0, import_date_fns2.isBefore)(toDate(a), toDate(b));
121
243
  },
122
244
  isAfter(a, b) {
123
- return (0, import_date_fns.isAfter)(toDate(a), toDate(b));
245
+ return (0, import_date_fns2.isAfter)(toDate(a), toDate(b));
124
246
  },
125
- isSameDay(a, b) {
247
+ isSameDay(a, b, timezone) {
126
248
  if (!a || !b) return false;
249
+ if (timezone) {
250
+ return isSameDayInTimezone(a, b, timezone);
251
+ }
127
252
  const da = toDate(a);
128
253
  const db = toDate(b);
129
254
  return da.getUTCFullYear() === db.getUTCFullYear() && da.getUTCMonth() === db.getUTCMonth() && da.getUTCDate() === db.getUTCDate();
@@ -133,7 +258,10 @@ var DateFnsAdapter = {
133
258
  const db = toDate(b);
134
259
  return da.getUTCFullYear() === db.getUTCFullYear() && da.getUTCMonth() === db.getUTCMonth();
135
260
  },
136
- startOfDay(iso) {
261
+ startOfDay(iso, timezone) {
262
+ if (timezone) {
263
+ return startOfDayInTimezone(iso, timezone);
264
+ }
137
265
  return toISO(utcStartOfDay(toDate(iso)));
138
266
  },
139
267
  startOfMonth(iso) {
@@ -151,14 +279,17 @@ var DateFnsAdapter = {
151
279
  now() {
152
280
  return (/* @__PURE__ */ new Date()).toISOString();
153
281
  },
154
- today() {
282
+ today(timezone) {
283
+ if (timezone) {
284
+ return todayInTimezone(timezone);
285
+ }
155
286
  return toISO(utcStartOfDay(/* @__PURE__ */ new Date()));
156
287
  },
157
288
  isValid(value) {
158
289
  if (!value) return false;
159
290
  const normalized = normalize(value);
160
- const date = (0, import_date_fns.parseISO)(normalized);
161
- return (0, import_date_fns.isValid)(date);
291
+ const date = (0, import_date_fns2.parseISO)(normalized);
292
+ return (0, import_date_fns2.isValid)(date);
162
293
  },
163
294
  getYear(iso) {
164
295
  return toDate(iso).getUTCFullYear();
@@ -183,23 +314,24 @@ function getCalendarDays(monthISO, adapter, options = {}) {
183
314
  focusedDate,
184
315
  disabled = [],
185
316
  range,
186
- rangeHover
317
+ rangeHover,
318
+ timezone
187
319
  } = options;
188
- const todayISO = today ?? adapter.today();
320
+ const todayISO = today ?? adapter.today(timezone);
189
321
  const monthStart = adapter.startOfMonth(monthISO);
190
322
  const gridStart = adapter.startOfWeek(monthStart, weekStartsOn);
191
- const normalizedRange = normalizeRangeForDisplay(range, rangeHover, adapter);
323
+ const normalizedRange = normalizeRangeForDisplay(range, rangeHover, adapter, timezone);
192
324
  const weeks = [];
193
325
  let current = gridStart;
194
326
  for (let week = 0; week < 6; week++) {
195
327
  const days = [];
196
328
  for (let day = 0; day < 7; day++) {
197
329
  const isCurrentMonth = adapter.isSameMonth(current, monthISO);
198
- const isTodayDate = adapter.isSameDay(current, todayISO);
199
- const isSelected_ = selected ? adapter.isSameDay(current, selected) : false;
200
- const isFocused_ = focusedDate ? adapter.isSameDay(current, focusedDate) : false;
330
+ const isTodayDate = adapter.isSameDay(current, todayISO, timezone);
331
+ const isSelected_ = selected ? adapter.isSameDay(current, selected, timezone) : false;
332
+ const isFocused_ = focusedDate ? adapter.isSameDay(current, focusedDate, timezone) : false;
201
333
  const isDisabled_ = isDateDisabled(current, disabled, adapter);
202
- const rangeFlags = computeRangeFlags(current, normalizedRange, adapter);
334
+ const rangeFlags = computeRangeFlags(current, normalizedRange, adapter, timezone);
203
335
  days.push({
204
336
  isoString: current,
205
337
  dayNumber: adapter.getDate(current),
@@ -219,7 +351,7 @@ function getCalendarDays(monthISO, adapter, options = {}) {
219
351
  }
220
352
  return weeks;
221
353
  }
222
- function normalizeRangeForDisplay(range, hover, adapter) {
354
+ function normalizeRangeForDisplay(range, hover, adapter, _timezone) {
223
355
  if (!range) return { start: null, end: null };
224
356
  const { start, end } = range;
225
357
  if (start && end) {
@@ -239,16 +371,16 @@ function normalizeRangeForDisplay(range, hover, adapter) {
239
371
  }
240
372
  return { start: null, end: null };
241
373
  }
242
- function computeRangeFlags(iso, range, adapter) {
374
+ function computeRangeFlags(iso, range, adapter, timezone) {
243
375
  const { start, end } = range;
244
376
  if (!start) {
245
377
  return { isRangeStart: false, isRangeEnd: false, isInRange: false };
246
378
  }
247
- const isRangeStart = adapter.isSameDay(iso, start);
379
+ const isRangeStart = adapter.isSameDay(iso, start, timezone);
248
380
  if (!end) {
249
381
  return { isRangeStart, isRangeEnd: false, isInRange: false };
250
382
  }
251
- const isRangeEnd = adapter.isSameDay(iso, end);
383
+ const isRangeEnd = adapter.isSameDay(iso, end, timezone);
252
384
  const isInRange = !isRangeStart && !isRangeEnd && adapter.isAfter(iso, start) && adapter.isBefore(iso, end);
253
385
  return { isRangeStart, isRangeEnd, isInRange };
254
386
  }
@@ -275,14 +407,14 @@ function maxDate(a, b, adapter) {
275
407
 
276
408
  // src/utils/date.ts
277
409
  var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
278
- var ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
410
+ var ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
279
411
  function normalizeISO(value) {
280
412
  if (!value) return "";
281
413
  if (ISO_DATETIME_REGEX.test(value)) return value;
282
414
  if (ISO_DATE_REGEX.test(value)) return `${value}T00:00:00.000Z`;
283
415
  return value;
284
416
  }
285
- function parseInputValue(input, format, adapter) {
417
+ function parseInputValue(input, adapter) {
286
418
  if (!input.trim()) return null;
287
419
  const cleaned = input.replace(/\//g, "-").trim();
288
420
  if (ISO_DATE_REGEX.test(cleaned)) {
@@ -357,8 +489,8 @@ function generateHours(format = "24h") {
357
489
  return Array.from({ length: 24 }, (_, i) => i);
358
490
  }
359
491
  function generateMinutes(step = 1) {
360
- if (step < 1 || step > 60) {
361
- throw new Error(`[generateMinutes] step must be between 1 and 60, got ${step}`);
492
+ if (step < 1 || step > 30) {
493
+ throw new Error(`[generateMinutes] step must be between 1 and 30, got ${step}`);
362
494
  }
363
495
  const result = [];
364
496
  for (let i = 0; i < 60; i += step) {
@@ -369,7 +501,7 @@ function generateMinutes(step = 1) {
369
501
  function isSameTime(a, b) {
370
502
  return a.hours === b.hours && a.minutes === b.minutes && a.seconds === b.seconds;
371
503
  }
372
- function formatTimeFromISO(iso, format, _adapter) {
504
+ function formatTimeFromISO(iso, format) {
373
505
  const time = getTime(iso);
374
506
  if (format === "h:mm a" || format === "h:mm:ss a") {
375
507
  const { hours12, period } = to12Hour(time.hours);
@@ -384,30 +516,35 @@ function formatTimeFromISO(iso, format, _adapter) {
384
516
  }
385
517
 
386
518
  // src/utils/locale.ts
519
+ var formatterCache2 = /* @__PURE__ */ new Map();
520
+ function getCachedFormatter(locale, options) {
521
+ const key = `${locale}:${JSON.stringify(options)}`;
522
+ let fmt = formatterCache2.get(key);
523
+ if (!fmt) {
524
+ fmt = new Intl.DateTimeFormat(locale, options);
525
+ formatterCache2.set(key, fmt);
526
+ }
527
+ return fmt;
528
+ }
529
+ var REFERENCE_YEAR = 2026;
387
530
  function getMonthName(month, locale = "en-US") {
388
- const date = new Date(Date.UTC(2026, month, 1));
389
- return new Intl.DateTimeFormat(locale, { month: "long", timeZone: "UTC" }).format(date);
531
+ const date = new Date(Date.UTC(REFERENCE_YEAR, month, 1));
532
+ return getCachedFormatter(locale, { month: "long", timeZone: "UTC" }).format(date);
390
533
  }
391
534
  function formatMonthYear(year, month, locale = "en-US") {
392
535
  const date = new Date(Date.UTC(year, month, 1));
393
- return new Intl.DateTimeFormat(locale, {
536
+ return getCachedFormatter(locale, {
394
537
  year: "numeric",
395
538
  month: "long",
396
539
  timeZone: "UTC"
397
540
  }).format(date);
398
541
  }
399
542
  function getWeekdayNames(locale = "en-US", weekStartsOn = 0) {
400
- const shortFormatter = new Intl.DateTimeFormat(locale, {
401
- weekday: "short",
402
- timeZone: "UTC"
403
- });
404
- const fullFormatter = new Intl.DateTimeFormat(locale, {
405
- weekday: "long",
406
- timeZone: "UTC"
407
- });
543
+ const shortFormatter = getCachedFormatter(locale, { weekday: "short", timeZone: "UTC" });
544
+ const fullFormatter = getCachedFormatter(locale, { weekday: "long", timeZone: "UTC" });
408
545
  const days = [];
409
546
  for (let i = 0; i < 7; i++) {
410
- const date = new Date(Date.UTC(2026, 0, 4 + i));
547
+ const date = new Date(Date.UTC(REFERENCE_YEAR, 0, 4 + i));
411
548
  days.push({
412
549
  short: shortFormatter.format(date),
413
550
  full: fullFormatter.format(date)
@@ -421,7 +558,7 @@ function getWeekdayNames(locale = "en-US", weekStartsOn = 0) {
421
558
  }
422
559
  function formatFullDate(iso, locale = "en-US") {
423
560
  const date = new Date(iso);
424
- return new Intl.DateTimeFormat(locale, {
561
+ return getCachedFormatter(locale, {
425
562
  year: "numeric",
426
563
  month: "long",
427
564
  day: "numeric",
@@ -429,10 +566,49 @@ function formatFullDate(iso, locale = "en-US") {
429
566
  timeZone: "UTC"
430
567
  }).format(date);
431
568
  }
569
+
570
+ // src/utils/labels.ts
571
+ var DEFAULT_DATEPICKER_LABELS = {
572
+ triggerOpen: "Open calendar",
573
+ triggerClose: "Close calendar",
574
+ popoverLabel: "Choose date",
575
+ prevMonth: "Previous month",
576
+ nextMonth: "Next month",
577
+ prevYear: "Previous year",
578
+ nextYear: "Next year",
579
+ prevDecade: "Previous decade",
580
+ nextDecade: "Next decade"
581
+ };
582
+ var DEFAULT_RANGEPICKER_LABELS = {
583
+ ...DEFAULT_DATEPICKER_LABELS,
584
+ popoverLabel: "Choose date range",
585
+ startInput: "Start date",
586
+ endInput: "End date",
587
+ presetsGroup: "Date range presets"
588
+ };
589
+ var DEFAULT_TIMEPICKER_LABELS = {
590
+ timeInput: "Time",
591
+ hourList: "Hour",
592
+ minuteList: "Minute",
593
+ amPmToggle: "AM/PM",
594
+ hourOption: (hour) => `${hour} hours`,
595
+ minuteOption: (minute) => `${minute} minutes`
596
+ };
597
+ var DEFAULT_DATETIMEPICKER_LABELS = {
598
+ ...DEFAULT_DATEPICKER_LABELS,
599
+ ...DEFAULT_TIMEPICKER_LABELS,
600
+ dateTimeInput: "Date and time"
601
+ };
432
602
  // Annotate the CommonJS export names for ESM import in node:
433
603
  0 && (module.exports = {
604
+ DEFAULT_DATEPICKER_LABELS,
605
+ DEFAULT_DATETIMEPICKER_LABELS,
606
+ DEFAULT_RANGEPICKER_LABELS,
607
+ DEFAULT_TIMEPICKER_LABELS,
434
608
  DateFnsAdapter,
609
+ civilMidnightFromUtcDay,
435
610
  formatFullDate,
611
+ formatInTimezone,
436
612
  formatMonthYear,
437
613
  formatTimeFromISO,
438
614
  formatTimeString,
@@ -441,8 +617,11 @@ function formatFullDate(iso, locale = "en-US") {
441
617
  getCalendarDays,
442
618
  getMonthName,
443
619
  getTime,
620
+ getTimeInTimezone,
621
+ getTimezoneOffsetMinutes,
444
622
  getWeekdayNames,
445
623
  isDateDisabled,
624
+ isSameDayInTimezone,
446
625
  isSameTime,
447
626
  maxDate,
448
627
  minDate,
@@ -450,6 +629,9 @@ function formatFullDate(iso, locale = "en-US") {
450
629
  parseInputValue,
451
630
  parseTimeString,
452
631
  setTime,
632
+ setTimeInTimezone,
633
+ startOfDayInTimezone,
453
634
  to12Hour,
454
- to24Hour
635
+ to24Hour,
636
+ todayInTimezone
455
637
  });
package/dist/index.d.cts CHANGED
@@ -102,6 +102,12 @@ interface CalendarOptions {
102
102
  range?: DateRange | null;
103
103
  /** Currently hovered date (for RangePicker preview) */
104
104
  rangeHover?: ISODateString | null;
105
+ /**
106
+ * IANA timezone. When set, `isSameDay` comparisons for today/selected/range flags are
107
+ * evaluated in this zone — required when the picker stores values in display-tz civil
108
+ * midnight form while the grid iterates in UTC.
109
+ */
110
+ timezone?: string;
105
111
  }
106
112
 
107
113
  /**
@@ -156,13 +162,16 @@ declare function maxDate(a: ISODateString, b: ISODateString, adapter: DateAdapte
156
162
  /**
157
163
  * Normalizes a date string to ISO 8601 UTC form.
158
164
  * "2026-01-15" → "2026-01-15T00:00:00.000Z"
165
+ *
166
+ * Full datetime strings must include a timezone suffix (Z or ±HH:MM).
167
+ * Strings without a timezone suffix are treated as-is (not matched as datetime).
159
168
  */
160
169
  declare function normalizeISO(value: string): string;
161
170
  /**
162
171
  * Parses user input text into an ISO string.
163
172
  * Returns null on failure.
164
173
  */
165
- declare function parseInputValue(input: string, format: string, adapter: DateAdapter): string | null;
174
+ declare function parseInputValue(input: string, adapter: DateAdapter): string | null;
166
175
 
167
176
  /** Time-of-day value (24-hour clock) */
168
177
  interface TimeValue {
@@ -219,7 +228,7 @@ declare function isSameTime(a: TimeValue, b: TimeValue): boolean;
219
228
  * Formats an ISO datetime as a time string (UTC based).
220
229
  * Accepts an adapter for API consistency.
221
230
  */
222
- declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a', _adapter?: DateAdapter): string;
231
+ declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a'): string;
223
232
 
224
233
  interface WeekdayInfo {
225
234
  /** Short name (e.g. "Su", "일") */
@@ -252,4 +261,140 @@ declare function getWeekdayNames(locale?: string, weekStartsOn?: WeekStartsOn):
252
261
  */
253
262
  declare function formatFullDate(iso: string, locale?: string): string;
254
263
 
255
- export { type CalendarDay, type CalendarGrid, type CalendarOptions, type CalendarWeek, type DateAdapter, DateFnsAdapter, type DateRange, type DisabledRule, type ISODateString, type TimeValue, type WeekStartsOn, type WeekdayInfo, formatFullDate, formatMonthYear, formatTimeFromISO, formatTimeString, generateHours, generateMinutes, getCalendarDays, getMonthName, getTime, getWeekdayNames, isDateDisabled, isSameTime, maxDate, minDate, normalizeISO, parseInputValue, parseTimeString, setTime, to12Hour, to24Hour };
264
+ /**
265
+ * Format a UTC ISO string for display in a specific IANA timezone.
266
+ * Handles DST transitions correctly. Supports a small set of tokens: `yyyy MM dd HH mm ss`.
267
+ *
268
+ * @param iso - UTC ISO 8601 string
269
+ * @param formatStr - token string (e.g. `"yyyy-MM-dd HH:mm"`)
270
+ * @param timeZone - IANA zone (e.g. `"America/New_York"`)
271
+ *
272
+ * @example
273
+ * formatInTimezone('2026-03-08T07:30:00.000Z', 'yyyy-MM-dd HH:mm', 'America/New_York');
274
+ * // → '2026-03-08 03:30' (post spring-forward EDT)
275
+ */
276
+ declare function formatInTimezone(iso: ISODateString, formatStr: string, timeZone: string): string;
277
+ /**
278
+ * UTC offset (minutes east of UTC) at a given UTC instant, as applied by the given timezone.
279
+ * The offset may differ on either side of a DST transition.
280
+ *
281
+ * @example
282
+ * getTimezoneOffsetMinutes('2026-03-08T06:00:00.000Z', 'America/New_York'); // -300 (EST, UTC-5)
283
+ * getTimezoneOffsetMinutes('2026-03-08T07:00:00.000Z', 'America/New_York'); // -240 (EDT, UTC-4)
284
+ * getTimezoneOffsetMinutes('2026-01-15T12:00:00.000Z', 'Asia/Seoul'); // 540 (UTC+9)
285
+ */
286
+ declare function getTimezoneOffsetMinutes(iso: ISODateString, timeZone: string): number;
287
+ /**
288
+ * Midnight of the civil date (as observed in `timeZone`) returned as a UTC ISO string.
289
+ *
290
+ * Across DST transitions this is the correct way to compute "start of day" — the offset
291
+ * changes, so the UTC instant of midnight differs before and after the transition.
292
+ *
293
+ * @example
294
+ * startOfDayInTimezone('2026-03-08T12:00:00.000Z', 'America/New_York'); // '2026-03-08T05:00:00.000Z' (EST)
295
+ * startOfDayInTimezone('2026-03-09T12:00:00.000Z', 'America/New_York'); // '2026-03-09T04:00:00.000Z' (EDT)
296
+ */
297
+ declare function startOfDayInTimezone(iso: ISODateString, timeZone: string): ISODateString;
298
+ /**
299
+ * Whether two UTC instants fall on the same civil day in the given timezone.
300
+ * Timezone-safe alternative to comparing `iso.slice(0, 10)`.
301
+ *
302
+ * @example
303
+ * // Seoul is UTC+9, so 03:00 UTC and 14:00 UTC are both on 2026-01-15 KST
304
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T14:00:00.000Z', 'Asia/Seoul'); // true
305
+ * // But 17:00 UTC is 02:00 KST on 2026-01-16
306
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T17:00:00.000Z', 'Asia/Seoul'); // false
307
+ */
308
+ declare function isSameDayInTimezone(a: ISODateString, b: ISODateString, timeZone: string): boolean;
309
+ /**
310
+ * "Today" in the given timezone, returned as the UTC ISO string representing that day's midnight.
311
+ * Prefer this over `new Date()` when the notion of "today" should follow the user's displayed
312
+ * timezone rather than the server's local clock.
313
+ */
314
+ declare function todayInTimezone(timeZone: string): ISODateString;
315
+ /**
316
+ * Converts a UTC-midnight ISO (as produced by the default calendar grid iteration) into the
317
+ * civil midnight of the same calendar day in `timeZone`.
318
+ *
319
+ * This is the bridge used by DatePicker/RangePicker when `displayTimezone` is set: the grid
320
+ * iterates in UTC, so a cell's `isoString` is `YYYY-MM-DDT00:00:00.000Z`. When the user clicks
321
+ * that cell we want to emit an ISO representing the same civil day's midnight in the display
322
+ * timezone. A probe at noon UTC is used so the target civil day is unambiguous across any zone.
323
+ *
324
+ * @example
325
+ * civilMidnightFromUtcDay('2026-01-15T00:00:00.000Z', 'Asia/Seoul');
326
+ * // → '2026-01-14T15:00:00.000Z' (Seoul Jan 15 00:00 = UTC Jan 14 15:00)
327
+ */
328
+ declare function civilMidnightFromUtcDay(gridUtcIso: ISODateString, timeZone: string): ISODateString;
329
+ /**
330
+ * Extracts the time-of-day (hours / minutes / seconds) of a UTC instant as observed in
331
+ * `timeZone`. The result differs from reading UTC hours when the zone has a non-zero offset.
332
+ *
333
+ * @example
334
+ * getTimeInTimezone('2026-01-15T00:00:00.000Z', 'Asia/Seoul');
335
+ * // → { hours: 9, minutes: 0, seconds: 0 } (Seoul is UTC+9)
336
+ */
337
+ declare function getTimeInTimezone(iso: ISODateString, timeZone: string): {
338
+ hours: number;
339
+ minutes: number;
340
+ seconds: number;
341
+ };
342
+ /**
343
+ * Returns a new ISO UTC string where the civil date in `timeZone` is preserved and the time
344
+ * portion is replaced according to `partial` (as observed in that same timezone). Undefined
345
+ * fields keep their current value.
346
+ *
347
+ * Implementation note: the civil target (Y,M,D,H,m,s) is first mapped to a UTC epoch as if
348
+ * the wall-clock reading lived in UTC; then we subtract the timezone offset at that instant
349
+ * to recover the real UTC instant. The offset is refined once to absorb DST transitions.
350
+ *
351
+ * @example
352
+ * // In Asia/Seoul (UTC+9): set the hour to 10
353
+ * setTimeInTimezone('2026-01-15T00:00:00.000Z', { hours: 10 }, 'Asia/Seoul');
354
+ * // → '2026-01-15T01:00:00.000Z' (Seoul Jan 15 10:00 = UTC 01:00)
355
+ */
356
+ declare function setTimeInTimezone(iso: ISODateString, partial: {
357
+ hours?: number;
358
+ minutes?: number;
359
+ seconds?: number;
360
+ }, timeZone: string): ISODateString;
361
+
362
+ /**
363
+ * Default English labels for ARIA attributes and accessible text.
364
+ * Override via the `labels` prop on Root components.
365
+ */
366
+ interface DatePickerLabels {
367
+ triggerOpen: string;
368
+ triggerClose: string;
369
+ popoverLabel: string;
370
+ prevMonth: string;
371
+ nextMonth: string;
372
+ prevYear: string;
373
+ nextYear: string;
374
+ prevDecade: string;
375
+ nextDecade: string;
376
+ /** Used by DateTimePicker.Input (present only when DatePickerContext is provided by DateTimePickerRoot) */
377
+ dateTimeInput?: string;
378
+ }
379
+ interface RangePickerLabels extends DatePickerLabels {
380
+ startInput: string;
381
+ endInput: string;
382
+ presetsGroup: string;
383
+ }
384
+ interface TimePickerLabels {
385
+ timeInput: string;
386
+ hourList: string;
387
+ minuteList: string;
388
+ amPmToggle: string;
389
+ hourOption: (hour: number) => string;
390
+ minuteOption: (minute: number) => string;
391
+ }
392
+ interface DateTimePickerLabels extends DatePickerLabels, TimePickerLabels {
393
+ dateTimeInput: string;
394
+ }
395
+ declare const DEFAULT_DATEPICKER_LABELS: DatePickerLabels;
396
+ declare const DEFAULT_RANGEPICKER_LABELS: RangePickerLabels;
397
+ declare const DEFAULT_TIMEPICKER_LABELS: TimePickerLabels;
398
+ declare const DEFAULT_DATETIMEPICKER_LABELS: DateTimePickerLabels;
399
+
400
+ export { type CalendarDay, type CalendarGrid, type CalendarOptions, type CalendarWeek, DEFAULT_DATEPICKER_LABELS, DEFAULT_DATETIMEPICKER_LABELS, DEFAULT_RANGEPICKER_LABELS, DEFAULT_TIMEPICKER_LABELS, type DateAdapter, DateFnsAdapter, type DatePickerLabels, type DateRange, type DateTimePickerLabels, type DisabledRule, type ISODateString, type RangePickerLabels, type TimePickerLabels, type TimeValue, type WeekStartsOn, type WeekdayInfo, civilMidnightFromUtcDay, formatFullDate, formatInTimezone, formatMonthYear, formatTimeFromISO, formatTimeString, generateHours, generateMinutes, getCalendarDays, getMonthName, getTime, getTimeInTimezone, getTimezoneOffsetMinutes, getWeekdayNames, isDateDisabled, isSameDayInTimezone, isSameTime, maxDate, minDate, normalizeISO, parseInputValue, parseTimeString, setTime, setTimeInTimezone, startOfDayInTimezone, to12Hour, to24Hour, todayInTimezone };
package/dist/index.d.ts CHANGED
@@ -102,6 +102,12 @@ interface CalendarOptions {
102
102
  range?: DateRange | null;
103
103
  /** Currently hovered date (for RangePicker preview) */
104
104
  rangeHover?: ISODateString | null;
105
+ /**
106
+ * IANA timezone. When set, `isSameDay` comparisons for today/selected/range flags are
107
+ * evaluated in this zone — required when the picker stores values in display-tz civil
108
+ * midnight form while the grid iterates in UTC.
109
+ */
110
+ timezone?: string;
105
111
  }
106
112
 
107
113
  /**
@@ -156,13 +162,16 @@ declare function maxDate(a: ISODateString, b: ISODateString, adapter: DateAdapte
156
162
  /**
157
163
  * Normalizes a date string to ISO 8601 UTC form.
158
164
  * "2026-01-15" → "2026-01-15T00:00:00.000Z"
165
+ *
166
+ * Full datetime strings must include a timezone suffix (Z or ±HH:MM).
167
+ * Strings without a timezone suffix are treated as-is (not matched as datetime).
159
168
  */
160
169
  declare function normalizeISO(value: string): string;
161
170
  /**
162
171
  * Parses user input text into an ISO string.
163
172
  * Returns null on failure.
164
173
  */
165
- declare function parseInputValue(input: string, format: string, adapter: DateAdapter): string | null;
174
+ declare function parseInputValue(input: string, adapter: DateAdapter): string | null;
166
175
 
167
176
  /** Time-of-day value (24-hour clock) */
168
177
  interface TimeValue {
@@ -219,7 +228,7 @@ declare function isSameTime(a: TimeValue, b: TimeValue): boolean;
219
228
  * Formats an ISO datetime as a time string (UTC based).
220
229
  * Accepts an adapter for API consistency.
221
230
  */
222
- declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a', _adapter?: DateAdapter): string;
231
+ declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a'): string;
223
232
 
224
233
  interface WeekdayInfo {
225
234
  /** Short name (e.g. "Su", "일") */
@@ -252,4 +261,140 @@ declare function getWeekdayNames(locale?: string, weekStartsOn?: WeekStartsOn):
252
261
  */
253
262
  declare function formatFullDate(iso: string, locale?: string): string;
254
263
 
255
- export { type CalendarDay, type CalendarGrid, type CalendarOptions, type CalendarWeek, type DateAdapter, DateFnsAdapter, type DateRange, type DisabledRule, type ISODateString, type TimeValue, type WeekStartsOn, type WeekdayInfo, formatFullDate, formatMonthYear, formatTimeFromISO, formatTimeString, generateHours, generateMinutes, getCalendarDays, getMonthName, getTime, getWeekdayNames, isDateDisabled, isSameTime, maxDate, minDate, normalizeISO, parseInputValue, parseTimeString, setTime, to12Hour, to24Hour };
264
+ /**
265
+ * Format a UTC ISO string for display in a specific IANA timezone.
266
+ * Handles DST transitions correctly. Supports a small set of tokens: `yyyy MM dd HH mm ss`.
267
+ *
268
+ * @param iso - UTC ISO 8601 string
269
+ * @param formatStr - token string (e.g. `"yyyy-MM-dd HH:mm"`)
270
+ * @param timeZone - IANA zone (e.g. `"America/New_York"`)
271
+ *
272
+ * @example
273
+ * formatInTimezone('2026-03-08T07:30:00.000Z', 'yyyy-MM-dd HH:mm', 'America/New_York');
274
+ * // → '2026-03-08 03:30' (post spring-forward EDT)
275
+ */
276
+ declare function formatInTimezone(iso: ISODateString, formatStr: string, timeZone: string): string;
277
+ /**
278
+ * UTC offset (minutes east of UTC) at a given UTC instant, as applied by the given timezone.
279
+ * The offset may differ on either side of a DST transition.
280
+ *
281
+ * @example
282
+ * getTimezoneOffsetMinutes('2026-03-08T06:00:00.000Z', 'America/New_York'); // -300 (EST, UTC-5)
283
+ * getTimezoneOffsetMinutes('2026-03-08T07:00:00.000Z', 'America/New_York'); // -240 (EDT, UTC-4)
284
+ * getTimezoneOffsetMinutes('2026-01-15T12:00:00.000Z', 'Asia/Seoul'); // 540 (UTC+9)
285
+ */
286
+ declare function getTimezoneOffsetMinutes(iso: ISODateString, timeZone: string): number;
287
+ /**
288
+ * Midnight of the civil date (as observed in `timeZone`) returned as a UTC ISO string.
289
+ *
290
+ * Across DST transitions this is the correct way to compute "start of day" — the offset
291
+ * changes, so the UTC instant of midnight differs before and after the transition.
292
+ *
293
+ * @example
294
+ * startOfDayInTimezone('2026-03-08T12:00:00.000Z', 'America/New_York'); // '2026-03-08T05:00:00.000Z' (EST)
295
+ * startOfDayInTimezone('2026-03-09T12:00:00.000Z', 'America/New_York'); // '2026-03-09T04:00:00.000Z' (EDT)
296
+ */
297
+ declare function startOfDayInTimezone(iso: ISODateString, timeZone: string): ISODateString;
298
+ /**
299
+ * Whether two UTC instants fall on the same civil day in the given timezone.
300
+ * Timezone-safe alternative to comparing `iso.slice(0, 10)`.
301
+ *
302
+ * @example
303
+ * // Seoul is UTC+9, so 03:00 UTC and 14:00 UTC are both on 2026-01-15 KST
304
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T14:00:00.000Z', 'Asia/Seoul'); // true
305
+ * // But 17:00 UTC is 02:00 KST on 2026-01-16
306
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T17:00:00.000Z', 'Asia/Seoul'); // false
307
+ */
308
+ declare function isSameDayInTimezone(a: ISODateString, b: ISODateString, timeZone: string): boolean;
309
+ /**
310
+ * "Today" in the given timezone, returned as the UTC ISO string representing that day's midnight.
311
+ * Prefer this over `new Date()` when the notion of "today" should follow the user's displayed
312
+ * timezone rather than the server's local clock.
313
+ */
314
+ declare function todayInTimezone(timeZone: string): ISODateString;
315
+ /**
316
+ * Converts a UTC-midnight ISO (as produced by the default calendar grid iteration) into the
317
+ * civil midnight of the same calendar day in `timeZone`.
318
+ *
319
+ * This is the bridge used by DatePicker/RangePicker when `displayTimezone` is set: the grid
320
+ * iterates in UTC, so a cell's `isoString` is `YYYY-MM-DDT00:00:00.000Z`. When the user clicks
321
+ * that cell we want to emit an ISO representing the same civil day's midnight in the display
322
+ * timezone. A probe at noon UTC is used so the target civil day is unambiguous across any zone.
323
+ *
324
+ * @example
325
+ * civilMidnightFromUtcDay('2026-01-15T00:00:00.000Z', 'Asia/Seoul');
326
+ * // → '2026-01-14T15:00:00.000Z' (Seoul Jan 15 00:00 = UTC Jan 14 15:00)
327
+ */
328
+ declare function civilMidnightFromUtcDay(gridUtcIso: ISODateString, timeZone: string): ISODateString;
329
+ /**
330
+ * Extracts the time-of-day (hours / minutes / seconds) of a UTC instant as observed in
331
+ * `timeZone`. The result differs from reading UTC hours when the zone has a non-zero offset.
332
+ *
333
+ * @example
334
+ * getTimeInTimezone('2026-01-15T00:00:00.000Z', 'Asia/Seoul');
335
+ * // → { hours: 9, minutes: 0, seconds: 0 } (Seoul is UTC+9)
336
+ */
337
+ declare function getTimeInTimezone(iso: ISODateString, timeZone: string): {
338
+ hours: number;
339
+ minutes: number;
340
+ seconds: number;
341
+ };
342
+ /**
343
+ * Returns a new ISO UTC string where the civil date in `timeZone` is preserved and the time
344
+ * portion is replaced according to `partial` (as observed in that same timezone). Undefined
345
+ * fields keep their current value.
346
+ *
347
+ * Implementation note: the civil target (Y,M,D,H,m,s) is first mapped to a UTC epoch as if
348
+ * the wall-clock reading lived in UTC; then we subtract the timezone offset at that instant
349
+ * to recover the real UTC instant. The offset is refined once to absorb DST transitions.
350
+ *
351
+ * @example
352
+ * // In Asia/Seoul (UTC+9): set the hour to 10
353
+ * setTimeInTimezone('2026-01-15T00:00:00.000Z', { hours: 10 }, 'Asia/Seoul');
354
+ * // → '2026-01-15T01:00:00.000Z' (Seoul Jan 15 10:00 = UTC 01:00)
355
+ */
356
+ declare function setTimeInTimezone(iso: ISODateString, partial: {
357
+ hours?: number;
358
+ minutes?: number;
359
+ seconds?: number;
360
+ }, timeZone: string): ISODateString;
361
+
362
+ /**
363
+ * Default English labels for ARIA attributes and accessible text.
364
+ * Override via the `labels` prop on Root components.
365
+ */
366
+ interface DatePickerLabels {
367
+ triggerOpen: string;
368
+ triggerClose: string;
369
+ popoverLabel: string;
370
+ prevMonth: string;
371
+ nextMonth: string;
372
+ prevYear: string;
373
+ nextYear: string;
374
+ prevDecade: string;
375
+ nextDecade: string;
376
+ /** Used by DateTimePicker.Input (present only when DatePickerContext is provided by DateTimePickerRoot) */
377
+ dateTimeInput?: string;
378
+ }
379
+ interface RangePickerLabels extends DatePickerLabels {
380
+ startInput: string;
381
+ endInput: string;
382
+ presetsGroup: string;
383
+ }
384
+ interface TimePickerLabels {
385
+ timeInput: string;
386
+ hourList: string;
387
+ minuteList: string;
388
+ amPmToggle: string;
389
+ hourOption: (hour: number) => string;
390
+ minuteOption: (minute: number) => string;
391
+ }
392
+ interface DateTimePickerLabels extends DatePickerLabels, TimePickerLabels {
393
+ dateTimeInput: string;
394
+ }
395
+ declare const DEFAULT_DATEPICKER_LABELS: DatePickerLabels;
396
+ declare const DEFAULT_RANGEPICKER_LABELS: RangePickerLabels;
397
+ declare const DEFAULT_TIMEPICKER_LABELS: TimePickerLabels;
398
+ declare const DEFAULT_DATETIMEPICKER_LABELS: DateTimePickerLabels;
399
+
400
+ export { type CalendarDay, type CalendarGrid, type CalendarOptions, type CalendarWeek, DEFAULT_DATEPICKER_LABELS, DEFAULT_DATETIMEPICKER_LABELS, DEFAULT_RANGEPICKER_LABELS, DEFAULT_TIMEPICKER_LABELS, type DateAdapter, DateFnsAdapter, type DatePickerLabels, type DateRange, type DateTimePickerLabels, type DisabledRule, type ISODateString, type RangePickerLabels, type TimePickerLabels, type TimeValue, type WeekStartsOn, type WeekdayInfo, civilMidnightFromUtcDay, formatFullDate, formatInTimezone, formatMonthYear, formatTimeFromISO, formatTimeString, generateHours, generateMinutes, getCalendarDays, getMonthName, getTime, getTimeInTimezone, getTimezoneOffsetMinutes, getWeekdayNames, isDateDisabled, isSameDayInTimezone, isSameTime, maxDate, minDate, normalizeISO, parseInputValue, parseTimeString, setTime, setTimeInTimezone, startOfDayInTimezone, to12Hour, to24Hour, todayInTimezone };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/adapters/date-fns.ts
2
2
  import {
3
- parseISO,
3
+ parseISO as parseISO2,
4
4
  addDays as dfAddDays,
5
5
  addMonths as dfAddMonths,
6
6
  addYears as dfAddYears,
@@ -8,8 +8,115 @@ import {
8
8
  isAfter as dfIsAfter,
9
9
  isValid as dfIsValid
10
10
  } from "date-fns";
11
+
12
+ // src/utils/timezone.ts
13
+ import { parseISO } from "date-fns";
14
+ var formatterCache = /* @__PURE__ */ new Map();
15
+ function getCachedPartsFormatter(timeZone) {
16
+ const key = `parts:${timeZone}`;
17
+ let fmt = formatterCache.get(key);
18
+ if (!fmt) {
19
+ fmt = new Intl.DateTimeFormat("en-US", {
20
+ timeZone,
21
+ year: "numeric",
22
+ month: "2-digit",
23
+ day: "2-digit",
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ second: "2-digit",
27
+ hourCycle: "h23"
28
+ });
29
+ formatterCache.set(key, fmt);
30
+ }
31
+ return fmt;
32
+ }
33
+ function partsInTimezone(utc, timeZone) {
34
+ const dtf = getCachedPartsFormatter(timeZone);
35
+ const parts = Object.fromEntries(
36
+ dtf.formatToParts(utc).map((p) => [p.type, p.value])
37
+ );
38
+ return {
39
+ year: Number(parts.year),
40
+ month: Number(parts.month),
41
+ day: Number(parts.day),
42
+ // Some locales/engines return '24' instead of '0' at midnight
43
+ hour: parts.hour === "24" ? 0 : Number(parts.hour),
44
+ minute: Number(parts.minute),
45
+ second: Number(parts.second)
46
+ };
47
+ }
48
+ function formatInTimezone(iso, formatStr, timeZone) {
49
+ const p = partsInTimezone(parseISO(iso), timeZone);
50
+ const tokens = {
51
+ yyyy: String(p.year),
52
+ MM: String(p.month).padStart(2, "0"),
53
+ dd: String(p.day).padStart(2, "0"),
54
+ HH: String(p.hour).padStart(2, "0"),
55
+ mm: String(p.minute).padStart(2, "0"),
56
+ ss: String(p.second).padStart(2, "0")
57
+ };
58
+ let result = formatStr;
59
+ for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
60
+ result = result.split(token).join(value);
61
+ }
62
+ return result;
63
+ }
64
+ function getTimezoneOffsetMinutes(iso, timeZone) {
65
+ const utc = parseISO(iso);
66
+ const p = partsInTimezone(utc, timeZone);
67
+ const asUtcEpoch = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
68
+ return Math.round((asUtcEpoch - utc.getTime()) / 6e4);
69
+ }
70
+ function startOfDayInTimezone(iso, timeZone) {
71
+ const utc = parseISO(iso);
72
+ const p = partsInTimezone(utc, timeZone);
73
+ const civilMidnightUtc = Date.UTC(p.year, p.month - 1, p.day, 0, 0, 0);
74
+ const midnightProbe = new Date(civilMidnightUtc).toISOString();
75
+ const offsetMinutes = getTimezoneOffsetMinutes(midnightProbe, timeZone);
76
+ return new Date(civilMidnightUtc - offsetMinutes * 6e4).toISOString();
77
+ }
78
+ function isSameDayInTimezone(a, b, timeZone) {
79
+ const pa = partsInTimezone(parseISO(a), timeZone);
80
+ const pb = partsInTimezone(parseISO(b), timeZone);
81
+ return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
82
+ }
83
+ function todayInTimezone(timeZone) {
84
+ return startOfDayInTimezone((/* @__PURE__ */ new Date()).toISOString(), timeZone);
85
+ }
86
+ function civilMidnightFromUtcDay(gridUtcIso, timeZone) {
87
+ const utc = parseISO(gridUtcIso);
88
+ const probe = new Date(Date.UTC(
89
+ utc.getUTCFullYear(),
90
+ utc.getUTCMonth(),
91
+ utc.getUTCDate(),
92
+ 12,
93
+ 0,
94
+ 0
95
+ )).toISOString();
96
+ return startOfDayInTimezone(probe, timeZone);
97
+ }
98
+ function getTimeInTimezone(iso, timeZone) {
99
+ const p = partsInTimezone(parseISO(iso), timeZone);
100
+ return { hours: p.hour, minutes: p.minute, seconds: p.second };
101
+ }
102
+ function setTimeInTimezone(iso, partial, timeZone) {
103
+ const p = partsInTimezone(parseISO(iso), timeZone);
104
+ const targetHours = partial.hours ?? p.hour;
105
+ const targetMinutes = partial.minutes ?? p.minute;
106
+ const targetSeconds = partial.seconds ?? p.second;
107
+ const civilEpoch = Date.UTC(p.year, p.month - 1, p.day, targetHours, targetMinutes, targetSeconds);
108
+ const probe1 = new Date(civilEpoch).toISOString();
109
+ const offset1 = getTimezoneOffsetMinutes(probe1, timeZone);
110
+ const realEpoch1 = civilEpoch - offset1 * 6e4;
111
+ const probe2 = new Date(realEpoch1).toISOString();
112
+ const offset2 = getTimezoneOffsetMinutes(probe2, timeZone);
113
+ const realEpoch2 = civilEpoch - offset2 * 6e4;
114
+ return new Date(realEpoch2).toISOString();
115
+ }
116
+
117
+ // src/adapters/date-fns.ts
11
118
  function toDate(iso) {
12
- return parseISO(iso);
119
+ return parseISO2(iso);
13
120
  }
14
121
  function toISO(date) {
15
122
  return date.toISOString();
@@ -51,7 +158,10 @@ var DateFnsAdapter = {
51
158
  if (!value) return "";
52
159
  return normalize(value);
53
160
  },
54
- format(iso, formatStr) {
161
+ format(iso, formatStr, timezone) {
162
+ if (timezone) {
163
+ return formatInTimezone(iso, formatStr, timezone);
164
+ }
55
165
  const d = toDate(iso);
56
166
  const tokens = {
57
167
  yyyy: String(d.getUTCFullYear()),
@@ -84,8 +194,11 @@ var DateFnsAdapter = {
84
194
  isAfter(a, b) {
85
195
  return dfIsAfter(toDate(a), toDate(b));
86
196
  },
87
- isSameDay(a, b) {
197
+ isSameDay(a, b, timezone) {
88
198
  if (!a || !b) return false;
199
+ if (timezone) {
200
+ return isSameDayInTimezone(a, b, timezone);
201
+ }
89
202
  const da = toDate(a);
90
203
  const db = toDate(b);
91
204
  return da.getUTCFullYear() === db.getUTCFullYear() && da.getUTCMonth() === db.getUTCMonth() && da.getUTCDate() === db.getUTCDate();
@@ -95,7 +208,10 @@ var DateFnsAdapter = {
95
208
  const db = toDate(b);
96
209
  return da.getUTCFullYear() === db.getUTCFullYear() && da.getUTCMonth() === db.getUTCMonth();
97
210
  },
98
- startOfDay(iso) {
211
+ startOfDay(iso, timezone) {
212
+ if (timezone) {
213
+ return startOfDayInTimezone(iso, timezone);
214
+ }
99
215
  return toISO(utcStartOfDay(toDate(iso)));
100
216
  },
101
217
  startOfMonth(iso) {
@@ -113,13 +229,16 @@ var DateFnsAdapter = {
113
229
  now() {
114
230
  return (/* @__PURE__ */ new Date()).toISOString();
115
231
  },
116
- today() {
232
+ today(timezone) {
233
+ if (timezone) {
234
+ return todayInTimezone(timezone);
235
+ }
117
236
  return toISO(utcStartOfDay(/* @__PURE__ */ new Date()));
118
237
  },
119
238
  isValid(value) {
120
239
  if (!value) return false;
121
240
  const normalized = normalize(value);
122
- const date = parseISO(normalized);
241
+ const date = parseISO2(normalized);
123
242
  return dfIsValid(date);
124
243
  },
125
244
  getYear(iso) {
@@ -145,23 +264,24 @@ function getCalendarDays(monthISO, adapter, options = {}) {
145
264
  focusedDate,
146
265
  disabled = [],
147
266
  range,
148
- rangeHover
267
+ rangeHover,
268
+ timezone
149
269
  } = options;
150
- const todayISO = today ?? adapter.today();
270
+ const todayISO = today ?? adapter.today(timezone);
151
271
  const monthStart = adapter.startOfMonth(monthISO);
152
272
  const gridStart = adapter.startOfWeek(monthStart, weekStartsOn);
153
- const normalizedRange = normalizeRangeForDisplay(range, rangeHover, adapter);
273
+ const normalizedRange = normalizeRangeForDisplay(range, rangeHover, adapter, timezone);
154
274
  const weeks = [];
155
275
  let current = gridStart;
156
276
  for (let week = 0; week < 6; week++) {
157
277
  const days = [];
158
278
  for (let day = 0; day < 7; day++) {
159
279
  const isCurrentMonth = adapter.isSameMonth(current, monthISO);
160
- const isTodayDate = adapter.isSameDay(current, todayISO);
161
- const isSelected_ = selected ? adapter.isSameDay(current, selected) : false;
162
- const isFocused_ = focusedDate ? adapter.isSameDay(current, focusedDate) : false;
280
+ const isTodayDate = adapter.isSameDay(current, todayISO, timezone);
281
+ const isSelected_ = selected ? adapter.isSameDay(current, selected, timezone) : false;
282
+ const isFocused_ = focusedDate ? adapter.isSameDay(current, focusedDate, timezone) : false;
163
283
  const isDisabled_ = isDateDisabled(current, disabled, adapter);
164
- const rangeFlags = computeRangeFlags(current, normalizedRange, adapter);
284
+ const rangeFlags = computeRangeFlags(current, normalizedRange, adapter, timezone);
165
285
  days.push({
166
286
  isoString: current,
167
287
  dayNumber: adapter.getDate(current),
@@ -181,7 +301,7 @@ function getCalendarDays(monthISO, adapter, options = {}) {
181
301
  }
182
302
  return weeks;
183
303
  }
184
- function normalizeRangeForDisplay(range, hover, adapter) {
304
+ function normalizeRangeForDisplay(range, hover, adapter, _timezone) {
185
305
  if (!range) return { start: null, end: null };
186
306
  const { start, end } = range;
187
307
  if (start && end) {
@@ -201,16 +321,16 @@ function normalizeRangeForDisplay(range, hover, adapter) {
201
321
  }
202
322
  return { start: null, end: null };
203
323
  }
204
- function computeRangeFlags(iso, range, adapter) {
324
+ function computeRangeFlags(iso, range, adapter, timezone) {
205
325
  const { start, end } = range;
206
326
  if (!start) {
207
327
  return { isRangeStart: false, isRangeEnd: false, isInRange: false };
208
328
  }
209
- const isRangeStart = adapter.isSameDay(iso, start);
329
+ const isRangeStart = adapter.isSameDay(iso, start, timezone);
210
330
  if (!end) {
211
331
  return { isRangeStart, isRangeEnd: false, isInRange: false };
212
332
  }
213
- const isRangeEnd = adapter.isSameDay(iso, end);
333
+ const isRangeEnd = adapter.isSameDay(iso, end, timezone);
214
334
  const isInRange = !isRangeStart && !isRangeEnd && adapter.isAfter(iso, start) && adapter.isBefore(iso, end);
215
335
  return { isRangeStart, isRangeEnd, isInRange };
216
336
  }
@@ -237,14 +357,14 @@ function maxDate(a, b, adapter) {
237
357
 
238
358
  // src/utils/date.ts
239
359
  var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
240
- var ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
360
+ var ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
241
361
  function normalizeISO(value) {
242
362
  if (!value) return "";
243
363
  if (ISO_DATETIME_REGEX.test(value)) return value;
244
364
  if (ISO_DATE_REGEX.test(value)) return `${value}T00:00:00.000Z`;
245
365
  return value;
246
366
  }
247
- function parseInputValue(input, format, adapter) {
367
+ function parseInputValue(input, adapter) {
248
368
  if (!input.trim()) return null;
249
369
  const cleaned = input.replace(/\//g, "-").trim();
250
370
  if (ISO_DATE_REGEX.test(cleaned)) {
@@ -319,8 +439,8 @@ function generateHours(format = "24h") {
319
439
  return Array.from({ length: 24 }, (_, i) => i);
320
440
  }
321
441
  function generateMinutes(step = 1) {
322
- if (step < 1 || step > 60) {
323
- throw new Error(`[generateMinutes] step must be between 1 and 60, got ${step}`);
442
+ if (step < 1 || step > 30) {
443
+ throw new Error(`[generateMinutes] step must be between 1 and 30, got ${step}`);
324
444
  }
325
445
  const result = [];
326
446
  for (let i = 0; i < 60; i += step) {
@@ -331,7 +451,7 @@ function generateMinutes(step = 1) {
331
451
  function isSameTime(a, b) {
332
452
  return a.hours === b.hours && a.minutes === b.minutes && a.seconds === b.seconds;
333
453
  }
334
- function formatTimeFromISO(iso, format, _adapter) {
454
+ function formatTimeFromISO(iso, format) {
335
455
  const time = getTime(iso);
336
456
  if (format === "h:mm a" || format === "h:mm:ss a") {
337
457
  const { hours12, period } = to12Hour(time.hours);
@@ -346,30 +466,35 @@ function formatTimeFromISO(iso, format, _adapter) {
346
466
  }
347
467
 
348
468
  // src/utils/locale.ts
469
+ var formatterCache2 = /* @__PURE__ */ new Map();
470
+ function getCachedFormatter(locale, options) {
471
+ const key = `${locale}:${JSON.stringify(options)}`;
472
+ let fmt = formatterCache2.get(key);
473
+ if (!fmt) {
474
+ fmt = new Intl.DateTimeFormat(locale, options);
475
+ formatterCache2.set(key, fmt);
476
+ }
477
+ return fmt;
478
+ }
479
+ var REFERENCE_YEAR = 2026;
349
480
  function getMonthName(month, locale = "en-US") {
350
- const date = new Date(Date.UTC(2026, month, 1));
351
- return new Intl.DateTimeFormat(locale, { month: "long", timeZone: "UTC" }).format(date);
481
+ const date = new Date(Date.UTC(REFERENCE_YEAR, month, 1));
482
+ return getCachedFormatter(locale, { month: "long", timeZone: "UTC" }).format(date);
352
483
  }
353
484
  function formatMonthYear(year, month, locale = "en-US") {
354
485
  const date = new Date(Date.UTC(year, month, 1));
355
- return new Intl.DateTimeFormat(locale, {
486
+ return getCachedFormatter(locale, {
356
487
  year: "numeric",
357
488
  month: "long",
358
489
  timeZone: "UTC"
359
490
  }).format(date);
360
491
  }
361
492
  function getWeekdayNames(locale = "en-US", weekStartsOn = 0) {
362
- const shortFormatter = new Intl.DateTimeFormat(locale, {
363
- weekday: "short",
364
- timeZone: "UTC"
365
- });
366
- const fullFormatter = new Intl.DateTimeFormat(locale, {
367
- weekday: "long",
368
- timeZone: "UTC"
369
- });
493
+ const shortFormatter = getCachedFormatter(locale, { weekday: "short", timeZone: "UTC" });
494
+ const fullFormatter = getCachedFormatter(locale, { weekday: "long", timeZone: "UTC" });
370
495
  const days = [];
371
496
  for (let i = 0; i < 7; i++) {
372
- const date = new Date(Date.UTC(2026, 0, 4 + i));
497
+ const date = new Date(Date.UTC(REFERENCE_YEAR, 0, 4 + i));
373
498
  days.push({
374
499
  short: shortFormatter.format(date),
375
500
  full: fullFormatter.format(date)
@@ -383,7 +508,7 @@ function getWeekdayNames(locale = "en-US", weekStartsOn = 0) {
383
508
  }
384
509
  function formatFullDate(iso, locale = "en-US") {
385
510
  const date = new Date(iso);
386
- return new Intl.DateTimeFormat(locale, {
511
+ return getCachedFormatter(locale, {
387
512
  year: "numeric",
388
513
  month: "long",
389
514
  day: "numeric",
@@ -391,9 +516,48 @@ function formatFullDate(iso, locale = "en-US") {
391
516
  timeZone: "UTC"
392
517
  }).format(date);
393
518
  }
519
+
520
+ // src/utils/labels.ts
521
+ var DEFAULT_DATEPICKER_LABELS = {
522
+ triggerOpen: "Open calendar",
523
+ triggerClose: "Close calendar",
524
+ popoverLabel: "Choose date",
525
+ prevMonth: "Previous month",
526
+ nextMonth: "Next month",
527
+ prevYear: "Previous year",
528
+ nextYear: "Next year",
529
+ prevDecade: "Previous decade",
530
+ nextDecade: "Next decade"
531
+ };
532
+ var DEFAULT_RANGEPICKER_LABELS = {
533
+ ...DEFAULT_DATEPICKER_LABELS,
534
+ popoverLabel: "Choose date range",
535
+ startInput: "Start date",
536
+ endInput: "End date",
537
+ presetsGroup: "Date range presets"
538
+ };
539
+ var DEFAULT_TIMEPICKER_LABELS = {
540
+ timeInput: "Time",
541
+ hourList: "Hour",
542
+ minuteList: "Minute",
543
+ amPmToggle: "AM/PM",
544
+ hourOption: (hour) => `${hour} hours`,
545
+ minuteOption: (minute) => `${minute} minutes`
546
+ };
547
+ var DEFAULT_DATETIMEPICKER_LABELS = {
548
+ ...DEFAULT_DATEPICKER_LABELS,
549
+ ...DEFAULT_TIMEPICKER_LABELS,
550
+ dateTimeInput: "Date and time"
551
+ };
394
552
  export {
553
+ DEFAULT_DATEPICKER_LABELS,
554
+ DEFAULT_DATETIMEPICKER_LABELS,
555
+ DEFAULT_RANGEPICKER_LABELS,
556
+ DEFAULT_TIMEPICKER_LABELS,
395
557
  DateFnsAdapter,
558
+ civilMidnightFromUtcDay,
396
559
  formatFullDate,
560
+ formatInTimezone,
397
561
  formatMonthYear,
398
562
  formatTimeFromISO,
399
563
  formatTimeString,
@@ -402,8 +566,11 @@ export {
402
566
  getCalendarDays,
403
567
  getMonthName,
404
568
  getTime,
569
+ getTimeInTimezone,
570
+ getTimezoneOffsetMinutes,
405
571
  getWeekdayNames,
406
572
  isDateDisabled,
573
+ isSameDayInTimezone,
407
574
  isSameTime,
408
575
  maxDate,
409
576
  minDate,
@@ -411,6 +578,9 @@ export {
411
578
  parseInputValue,
412
579
  parseTimeString,
413
580
  setTime,
581
+ setTimeInTimezone,
582
+ startOfDayInTimezone,
414
583
  to12Hour,
415
- to24Hour
584
+ to24Hour,
585
+ todayInTimezone
416
586
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kalyx/core",
3
- "version": "0.2.2",
4
- "description": "Kalyx core — platform-agnostic date logic",
3
+ "version": "0.4.0",
4
+ "description": "Kalyx core — platform-agnostic date logic, IANA timezone helpers, and the DateAdapter contract used by @kalyx/react",
5
5
  "license": "MIT",
6
6
  "author": "jiji-hoon96",
7
7
  "homepage": "https://github.com/jiji-hoon96/kalyx#readme",
@@ -18,11 +18,14 @@
18
18
  "datepicker",
19
19
  "calendar",
20
20
  "timezone",
21
+ "iana",
22
+ "dst",
21
23
  "iso8601",
22
24
  "utc",
23
25
  "date-fns"
24
26
  ],
25
27
  "type": "module",
28
+ "sideEffects": false,
26
29
  "main": "./dist/index.cjs",
27
30
  "module": "./dist/index.js",
28
31
  "types": "./dist/index.d.ts",