@kalyx/core 0.2.2 → 0.3.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,13 @@ 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,
24
28
  formatFullDate: () => formatFullDate,
29
+ formatInTimezone: () => formatInTimezone,
25
30
  formatMonthYear: () => formatMonthYear,
26
31
  formatTimeFromISO: () => formatTimeFromISO,
27
32
  formatTimeString: () => formatTimeString,
@@ -30,8 +35,10 @@ __export(index_exports, {
30
35
  getCalendarDays: () => getCalendarDays,
31
36
  getMonthName: () => getMonthName,
32
37
  getTime: () => getTime,
38
+ getTimezoneOffsetMinutes: () => getTimezoneOffsetMinutes,
33
39
  getWeekdayNames: () => getWeekdayNames,
34
40
  isDateDisabled: () => isDateDisabled,
41
+ isSameDayInTimezone: () => isSameDayInTimezone,
35
42
  isSameTime: () => isSameTime,
36
43
  maxDate: () => maxDate,
37
44
  minDate: () => minDate,
@@ -39,8 +46,10 @@ __export(index_exports, {
39
46
  parseInputValue: () => parseInputValue,
40
47
  parseTimeString: () => parseTimeString,
41
48
  setTime: () => setTime,
49
+ startOfDayInTimezone: () => startOfDayInTimezone,
42
50
  to12Hour: () => to12Hour,
43
- to24Hour: () => to24Hour
51
+ to24Hour: () => to24Hour,
52
+ todayInTimezone: () => todayInTimezone
44
53
  });
45
54
  module.exports = __toCommonJS(index_exports);
46
55
 
@@ -275,14 +284,14 @@ function maxDate(a, b, adapter) {
275
284
 
276
285
  // src/utils/date.ts
277
286
  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}/;
287
+ var ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
279
288
  function normalizeISO(value) {
280
289
  if (!value) return "";
281
290
  if (ISO_DATETIME_REGEX.test(value)) return value;
282
291
  if (ISO_DATE_REGEX.test(value)) return `${value}T00:00:00.000Z`;
283
292
  return value;
284
293
  }
285
- function parseInputValue(input, format, adapter) {
294
+ function parseInputValue(input, adapter) {
286
295
  if (!input.trim()) return null;
287
296
  const cleaned = input.replace(/\//g, "-").trim();
288
297
  if (ISO_DATE_REGEX.test(cleaned)) {
@@ -357,8 +366,8 @@ function generateHours(format = "24h") {
357
366
  return Array.from({ length: 24 }, (_, i) => i);
358
367
  }
359
368
  function generateMinutes(step = 1) {
360
- if (step < 1 || step > 60) {
361
- throw new Error(`[generateMinutes] step must be between 1 and 60, got ${step}`);
369
+ if (step < 1 || step > 30) {
370
+ throw new Error(`[generateMinutes] step must be between 1 and 30, got ${step}`);
362
371
  }
363
372
  const result = [];
364
373
  for (let i = 0; i < 60; i += step) {
@@ -369,7 +378,7 @@ function generateMinutes(step = 1) {
369
378
  function isSameTime(a, b) {
370
379
  return a.hours === b.hours && a.minutes === b.minutes && a.seconds === b.seconds;
371
380
  }
372
- function formatTimeFromISO(iso, format, _adapter) {
381
+ function formatTimeFromISO(iso, format) {
373
382
  const time = getTime(iso);
374
383
  if (format === "h:mm a" || format === "h:mm:ss a") {
375
384
  const { hours12, period } = to12Hour(time.hours);
@@ -384,30 +393,35 @@ function formatTimeFromISO(iso, format, _adapter) {
384
393
  }
385
394
 
386
395
  // src/utils/locale.ts
396
+ var formatterCache = /* @__PURE__ */ new Map();
397
+ function getCachedFormatter(locale, options) {
398
+ const key = `${locale}:${JSON.stringify(options)}`;
399
+ let fmt = formatterCache.get(key);
400
+ if (!fmt) {
401
+ fmt = new Intl.DateTimeFormat(locale, options);
402
+ formatterCache.set(key, fmt);
403
+ }
404
+ return fmt;
405
+ }
406
+ var REFERENCE_YEAR = 2026;
387
407
  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);
408
+ const date = new Date(Date.UTC(REFERENCE_YEAR, month, 1));
409
+ return getCachedFormatter(locale, { month: "long", timeZone: "UTC" }).format(date);
390
410
  }
391
411
  function formatMonthYear(year, month, locale = "en-US") {
392
412
  const date = new Date(Date.UTC(year, month, 1));
393
- return new Intl.DateTimeFormat(locale, {
413
+ return getCachedFormatter(locale, {
394
414
  year: "numeric",
395
415
  month: "long",
396
416
  timeZone: "UTC"
397
417
  }).format(date);
398
418
  }
399
419
  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
- });
420
+ const shortFormatter = getCachedFormatter(locale, { weekday: "short", timeZone: "UTC" });
421
+ const fullFormatter = getCachedFormatter(locale, { weekday: "long", timeZone: "UTC" });
408
422
  const days = [];
409
423
  for (let i = 0; i < 7; i++) {
410
- const date = new Date(Date.UTC(2026, 0, 4 + i));
424
+ const date = new Date(Date.UTC(REFERENCE_YEAR, 0, 4 + i));
411
425
  days.push({
412
426
  short: shortFormatter.format(date),
413
427
  full: fullFormatter.format(date)
@@ -421,7 +435,7 @@ function getWeekdayNames(locale = "en-US", weekStartsOn = 0) {
421
435
  }
422
436
  function formatFullDate(iso, locale = "en-US") {
423
437
  const date = new Date(iso);
424
- return new Intl.DateTimeFormat(locale, {
438
+ return getCachedFormatter(locale, {
425
439
  year: "numeric",
426
440
  month: "long",
427
441
  day: "numeric",
@@ -429,10 +443,123 @@ function formatFullDate(iso, locale = "en-US") {
429
443
  timeZone: "UTC"
430
444
  }).format(date);
431
445
  }
446
+
447
+ // src/utils/timezone.ts
448
+ var import_date_fns2 = require("date-fns");
449
+ var formatterCache2 = /* @__PURE__ */ new Map();
450
+ function getCachedPartsFormatter(timeZone) {
451
+ const key = `parts:${timeZone}`;
452
+ let fmt = formatterCache2.get(key);
453
+ if (!fmt) {
454
+ fmt = new Intl.DateTimeFormat("en-US", {
455
+ timeZone,
456
+ year: "numeric",
457
+ month: "2-digit",
458
+ day: "2-digit",
459
+ hour: "2-digit",
460
+ minute: "2-digit",
461
+ second: "2-digit",
462
+ hourCycle: "h23"
463
+ });
464
+ formatterCache2.set(key, fmt);
465
+ }
466
+ return fmt;
467
+ }
468
+ function partsInTimezone(utc, timeZone) {
469
+ const dtf = getCachedPartsFormatter(timeZone);
470
+ const parts = Object.fromEntries(
471
+ dtf.formatToParts(utc).map((p) => [p.type, p.value])
472
+ );
473
+ return {
474
+ year: Number(parts.year),
475
+ month: Number(parts.month),
476
+ day: Number(parts.day),
477
+ // Some locales/engines return '24' instead of '0' at midnight
478
+ hour: parts.hour === "24" ? 0 : Number(parts.hour),
479
+ minute: Number(parts.minute),
480
+ second: Number(parts.second)
481
+ };
482
+ }
483
+ function formatInTimezone(iso, formatStr, timeZone) {
484
+ const p = partsInTimezone((0, import_date_fns2.parseISO)(iso), timeZone);
485
+ const tokens = {
486
+ yyyy: String(p.year),
487
+ MM: String(p.month).padStart(2, "0"),
488
+ dd: String(p.day).padStart(2, "0"),
489
+ HH: String(p.hour).padStart(2, "0"),
490
+ mm: String(p.minute).padStart(2, "0"),
491
+ ss: String(p.second).padStart(2, "0")
492
+ };
493
+ let result = formatStr;
494
+ for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
495
+ result = result.split(token).join(value);
496
+ }
497
+ return result;
498
+ }
499
+ function getTimezoneOffsetMinutes(iso, timeZone) {
500
+ const utc = (0, import_date_fns2.parseISO)(iso);
501
+ const p = partsInTimezone(utc, timeZone);
502
+ const asUtcEpoch = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
503
+ return Math.round((asUtcEpoch - utc.getTime()) / 6e4);
504
+ }
505
+ function startOfDayInTimezone(iso, timeZone) {
506
+ const utc = (0, import_date_fns2.parseISO)(iso);
507
+ const p = partsInTimezone(utc, timeZone);
508
+ const civilMidnightUtc = Date.UTC(p.year, p.month - 1, p.day, 0, 0, 0);
509
+ const midnightProbe = new Date(civilMidnightUtc).toISOString();
510
+ const offsetMinutes = getTimezoneOffsetMinutes(midnightProbe, timeZone);
511
+ return new Date(civilMidnightUtc - offsetMinutes * 6e4).toISOString();
512
+ }
513
+ function isSameDayInTimezone(a, b, timeZone) {
514
+ const pa = partsInTimezone((0, import_date_fns2.parseISO)(a), timeZone);
515
+ const pb = partsInTimezone((0, import_date_fns2.parseISO)(b), timeZone);
516
+ return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
517
+ }
518
+ function todayInTimezone(timeZone) {
519
+ return startOfDayInTimezone((/* @__PURE__ */ new Date()).toISOString(), timeZone);
520
+ }
521
+
522
+ // src/utils/labels.ts
523
+ var DEFAULT_DATEPICKER_LABELS = {
524
+ triggerOpen: "Open calendar",
525
+ triggerClose: "Close calendar",
526
+ popoverLabel: "Choose date",
527
+ prevMonth: "Previous month",
528
+ nextMonth: "Next month",
529
+ prevYear: "Previous year",
530
+ nextYear: "Next year",
531
+ prevDecade: "Previous decade",
532
+ nextDecade: "Next decade"
533
+ };
534
+ var DEFAULT_RANGEPICKER_LABELS = {
535
+ ...DEFAULT_DATEPICKER_LABELS,
536
+ popoverLabel: "Choose date range",
537
+ startInput: "Start date",
538
+ endInput: "End date",
539
+ presetsGroup: "Date range presets"
540
+ };
541
+ var DEFAULT_TIMEPICKER_LABELS = {
542
+ timeInput: "Time",
543
+ hourList: "Hour",
544
+ minuteList: "Minute",
545
+ amPmToggle: "AM/PM",
546
+ hourOption: (hour) => `${hour} hours`,
547
+ minuteOption: (minute) => `${minute} minutes`
548
+ };
549
+ var DEFAULT_DATETIMEPICKER_LABELS = {
550
+ ...DEFAULT_DATEPICKER_LABELS,
551
+ ...DEFAULT_TIMEPICKER_LABELS,
552
+ dateTimeInput: "Date and time"
553
+ };
432
554
  // Annotate the CommonJS export names for ESM import in node:
433
555
  0 && (module.exports = {
556
+ DEFAULT_DATEPICKER_LABELS,
557
+ DEFAULT_DATETIMEPICKER_LABELS,
558
+ DEFAULT_RANGEPICKER_LABELS,
559
+ DEFAULT_TIMEPICKER_LABELS,
434
560
  DateFnsAdapter,
435
561
  formatFullDate,
562
+ formatInTimezone,
436
563
  formatMonthYear,
437
564
  formatTimeFromISO,
438
565
  formatTimeString,
@@ -441,8 +568,10 @@ function formatFullDate(iso, locale = "en-US") {
441
568
  getCalendarDays,
442
569
  getMonthName,
443
570
  getTime,
571
+ getTimezoneOffsetMinutes,
444
572
  getWeekdayNames,
445
573
  isDateDisabled,
574
+ isSameDayInTimezone,
446
575
  isSameTime,
447
576
  maxDate,
448
577
  minDate,
@@ -450,6 +579,8 @@ function formatFullDate(iso, locale = "en-US") {
450
579
  parseInputValue,
451
580
  parseTimeString,
452
581
  setTime,
582
+ startOfDayInTimezone,
453
583
  to12Hour,
454
- to24Hour
584
+ to24Hour,
585
+ todayInTimezone
455
586
  });
package/dist/index.d.cts CHANGED
@@ -156,13 +156,16 @@ declare function maxDate(a: ISODateString, b: ISODateString, adapter: DateAdapte
156
156
  /**
157
157
  * Normalizes a date string to ISO 8601 UTC form.
158
158
  * "2026-01-15" → "2026-01-15T00:00:00.000Z"
159
+ *
160
+ * Full datetime strings must include a timezone suffix (Z or ±HH:MM).
161
+ * Strings without a timezone suffix are treated as-is (not matched as datetime).
159
162
  */
160
163
  declare function normalizeISO(value: string): string;
161
164
  /**
162
165
  * Parses user input text into an ISO string.
163
166
  * Returns null on failure.
164
167
  */
165
- declare function parseInputValue(input: string, format: string, adapter: DateAdapter): string | null;
168
+ declare function parseInputValue(input: string, adapter: DateAdapter): string | null;
166
169
 
167
170
  /** Time-of-day value (24-hour clock) */
168
171
  interface TimeValue {
@@ -219,7 +222,7 @@ declare function isSameTime(a: TimeValue, b: TimeValue): boolean;
219
222
  * Formats an ISO datetime as a time string (UTC based).
220
223
  * Accepts an adapter for API consistency.
221
224
  */
222
- declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a', _adapter?: DateAdapter): string;
225
+ declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a'): string;
223
226
 
224
227
  interface WeekdayInfo {
225
228
  /** Short name (e.g. "Su", "일") */
@@ -252,4 +255,94 @@ declare function getWeekdayNames(locale?: string, weekStartsOn?: WeekStartsOn):
252
255
  */
253
256
  declare function formatFullDate(iso: string, locale?: string): string;
254
257
 
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 };
258
+ /**
259
+ * Format a UTC ISO string for display in a specific IANA timezone.
260
+ * Handles DST transitions correctly. Supports a small set of tokens: `yyyy MM dd HH mm ss`.
261
+ *
262
+ * @param iso - UTC ISO 8601 string
263
+ * @param formatStr - token string (e.g. `"yyyy-MM-dd HH:mm"`)
264
+ * @param timeZone - IANA zone (e.g. `"America/New_York"`)
265
+ *
266
+ * @example
267
+ * formatInTimezone('2026-03-08T07:30:00.000Z', 'yyyy-MM-dd HH:mm', 'America/New_York');
268
+ * // → '2026-03-08 03:30' (post spring-forward EDT)
269
+ */
270
+ declare function formatInTimezone(iso: ISODateString, formatStr: string, timeZone: string): string;
271
+ /**
272
+ * UTC offset (minutes east of UTC) at a given UTC instant, as applied by the given timezone.
273
+ * The offset may differ on either side of a DST transition.
274
+ *
275
+ * @example
276
+ * getTimezoneOffsetMinutes('2026-03-08T06:00:00.000Z', 'America/New_York'); // -300 (EST, UTC-5)
277
+ * getTimezoneOffsetMinutes('2026-03-08T07:00:00.000Z', 'America/New_York'); // -240 (EDT, UTC-4)
278
+ * getTimezoneOffsetMinutes('2026-01-15T12:00:00.000Z', 'Asia/Seoul'); // 540 (UTC+9)
279
+ */
280
+ declare function getTimezoneOffsetMinutes(iso: ISODateString, timeZone: string): number;
281
+ /**
282
+ * Midnight of the civil date (as observed in `timeZone`) returned as a UTC ISO string.
283
+ *
284
+ * Across DST transitions this is the correct way to compute "start of day" — the offset
285
+ * changes, so the UTC instant of midnight differs before and after the transition.
286
+ *
287
+ * @example
288
+ * startOfDayInTimezone('2026-03-08T12:00:00.000Z', 'America/New_York'); // '2026-03-08T05:00:00.000Z' (EST)
289
+ * startOfDayInTimezone('2026-03-09T12:00:00.000Z', 'America/New_York'); // '2026-03-09T04:00:00.000Z' (EDT)
290
+ */
291
+ declare function startOfDayInTimezone(iso: ISODateString, timeZone: string): ISODateString;
292
+ /**
293
+ * Whether two UTC instants fall on the same civil day in the given timezone.
294
+ * Timezone-safe alternative to comparing `iso.slice(0, 10)`.
295
+ *
296
+ * @example
297
+ * // Seoul is UTC+9, so 03:00 UTC and 14:00 UTC are both on 2026-01-15 KST
298
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T14:00:00.000Z', 'Asia/Seoul'); // true
299
+ * // But 17:00 UTC is 02:00 KST on 2026-01-16
300
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T17:00:00.000Z', 'Asia/Seoul'); // false
301
+ */
302
+ declare function isSameDayInTimezone(a: ISODateString, b: ISODateString, timeZone: string): boolean;
303
+ /**
304
+ * "Today" in the given timezone, returned as the UTC ISO string representing that day's midnight.
305
+ * Prefer this over `new Date()` when the notion of "today" should follow the user's displayed
306
+ * timezone rather than the server's local clock.
307
+ */
308
+ declare function todayInTimezone(timeZone: string): ISODateString;
309
+
310
+ /**
311
+ * Default English labels for ARIA attributes and accessible text.
312
+ * Override via the `labels` prop on Root components.
313
+ */
314
+ interface DatePickerLabels {
315
+ triggerOpen: string;
316
+ triggerClose: string;
317
+ popoverLabel: string;
318
+ prevMonth: string;
319
+ nextMonth: string;
320
+ prevYear: string;
321
+ nextYear: string;
322
+ prevDecade: string;
323
+ nextDecade: string;
324
+ /** Used by DateTimePicker.Input (present only when DatePickerContext is provided by DateTimePickerRoot) */
325
+ dateTimeInput?: string;
326
+ }
327
+ interface RangePickerLabels extends DatePickerLabels {
328
+ startInput: string;
329
+ endInput: string;
330
+ presetsGroup: string;
331
+ }
332
+ interface TimePickerLabels {
333
+ timeInput: string;
334
+ hourList: string;
335
+ minuteList: string;
336
+ amPmToggle: string;
337
+ hourOption: (hour: number) => string;
338
+ minuteOption: (minute: number) => string;
339
+ }
340
+ interface DateTimePickerLabels extends DatePickerLabels, TimePickerLabels {
341
+ dateTimeInput: string;
342
+ }
343
+ declare const DEFAULT_DATEPICKER_LABELS: DatePickerLabels;
344
+ declare const DEFAULT_RANGEPICKER_LABELS: RangePickerLabels;
345
+ declare const DEFAULT_TIMEPICKER_LABELS: TimePickerLabels;
346
+ declare const DEFAULT_DATETIMEPICKER_LABELS: DateTimePickerLabels;
347
+
348
+ 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, formatFullDate, formatInTimezone, formatMonthYear, formatTimeFromISO, formatTimeString, generateHours, generateMinutes, getCalendarDays, getMonthName, getTime, getTimezoneOffsetMinutes, getWeekdayNames, isDateDisabled, isSameDayInTimezone, isSameTime, maxDate, minDate, normalizeISO, parseInputValue, parseTimeString, setTime, startOfDayInTimezone, to12Hour, to24Hour, todayInTimezone };
package/dist/index.d.ts CHANGED
@@ -156,13 +156,16 @@ declare function maxDate(a: ISODateString, b: ISODateString, adapter: DateAdapte
156
156
  /**
157
157
  * Normalizes a date string to ISO 8601 UTC form.
158
158
  * "2026-01-15" → "2026-01-15T00:00:00.000Z"
159
+ *
160
+ * Full datetime strings must include a timezone suffix (Z or ±HH:MM).
161
+ * Strings without a timezone suffix are treated as-is (not matched as datetime).
159
162
  */
160
163
  declare function normalizeISO(value: string): string;
161
164
  /**
162
165
  * Parses user input text into an ISO string.
163
166
  * Returns null on failure.
164
167
  */
165
- declare function parseInputValue(input: string, format: string, adapter: DateAdapter): string | null;
168
+ declare function parseInputValue(input: string, adapter: DateAdapter): string | null;
166
169
 
167
170
  /** Time-of-day value (24-hour clock) */
168
171
  interface TimeValue {
@@ -219,7 +222,7 @@ declare function isSameTime(a: TimeValue, b: TimeValue): boolean;
219
222
  * Formats an ISO datetime as a time string (UTC based).
220
223
  * Accepts an adapter for API consistency.
221
224
  */
222
- declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a', _adapter?: DateAdapter): string;
225
+ declare function formatTimeFromISO(iso: ISODateString, format: 'HH:mm' | 'HH:mm:ss' | 'h:mm a' | 'h:mm:ss a'): string;
223
226
 
224
227
  interface WeekdayInfo {
225
228
  /** Short name (e.g. "Su", "일") */
@@ -252,4 +255,94 @@ declare function getWeekdayNames(locale?: string, weekStartsOn?: WeekStartsOn):
252
255
  */
253
256
  declare function formatFullDate(iso: string, locale?: string): string;
254
257
 
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 };
258
+ /**
259
+ * Format a UTC ISO string for display in a specific IANA timezone.
260
+ * Handles DST transitions correctly. Supports a small set of tokens: `yyyy MM dd HH mm ss`.
261
+ *
262
+ * @param iso - UTC ISO 8601 string
263
+ * @param formatStr - token string (e.g. `"yyyy-MM-dd HH:mm"`)
264
+ * @param timeZone - IANA zone (e.g. `"America/New_York"`)
265
+ *
266
+ * @example
267
+ * formatInTimezone('2026-03-08T07:30:00.000Z', 'yyyy-MM-dd HH:mm', 'America/New_York');
268
+ * // → '2026-03-08 03:30' (post spring-forward EDT)
269
+ */
270
+ declare function formatInTimezone(iso: ISODateString, formatStr: string, timeZone: string): string;
271
+ /**
272
+ * UTC offset (minutes east of UTC) at a given UTC instant, as applied by the given timezone.
273
+ * The offset may differ on either side of a DST transition.
274
+ *
275
+ * @example
276
+ * getTimezoneOffsetMinutes('2026-03-08T06:00:00.000Z', 'America/New_York'); // -300 (EST, UTC-5)
277
+ * getTimezoneOffsetMinutes('2026-03-08T07:00:00.000Z', 'America/New_York'); // -240 (EDT, UTC-4)
278
+ * getTimezoneOffsetMinutes('2026-01-15T12:00:00.000Z', 'Asia/Seoul'); // 540 (UTC+9)
279
+ */
280
+ declare function getTimezoneOffsetMinutes(iso: ISODateString, timeZone: string): number;
281
+ /**
282
+ * Midnight of the civil date (as observed in `timeZone`) returned as a UTC ISO string.
283
+ *
284
+ * Across DST transitions this is the correct way to compute "start of day" — the offset
285
+ * changes, so the UTC instant of midnight differs before and after the transition.
286
+ *
287
+ * @example
288
+ * startOfDayInTimezone('2026-03-08T12:00:00.000Z', 'America/New_York'); // '2026-03-08T05:00:00.000Z' (EST)
289
+ * startOfDayInTimezone('2026-03-09T12:00:00.000Z', 'America/New_York'); // '2026-03-09T04:00:00.000Z' (EDT)
290
+ */
291
+ declare function startOfDayInTimezone(iso: ISODateString, timeZone: string): ISODateString;
292
+ /**
293
+ * Whether two UTC instants fall on the same civil day in the given timezone.
294
+ * Timezone-safe alternative to comparing `iso.slice(0, 10)`.
295
+ *
296
+ * @example
297
+ * // Seoul is UTC+9, so 03:00 UTC and 14:00 UTC are both on 2026-01-15 KST
298
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T14:00:00.000Z', 'Asia/Seoul'); // true
299
+ * // But 17:00 UTC is 02:00 KST on 2026-01-16
300
+ * isSameDayInTimezone('2026-01-15T03:00:00.000Z', '2026-01-15T17:00:00.000Z', 'Asia/Seoul'); // false
301
+ */
302
+ declare function isSameDayInTimezone(a: ISODateString, b: ISODateString, timeZone: string): boolean;
303
+ /**
304
+ * "Today" in the given timezone, returned as the UTC ISO string representing that day's midnight.
305
+ * Prefer this over `new Date()` when the notion of "today" should follow the user's displayed
306
+ * timezone rather than the server's local clock.
307
+ */
308
+ declare function todayInTimezone(timeZone: string): ISODateString;
309
+
310
+ /**
311
+ * Default English labels for ARIA attributes and accessible text.
312
+ * Override via the `labels` prop on Root components.
313
+ */
314
+ interface DatePickerLabels {
315
+ triggerOpen: string;
316
+ triggerClose: string;
317
+ popoverLabel: string;
318
+ prevMonth: string;
319
+ nextMonth: string;
320
+ prevYear: string;
321
+ nextYear: string;
322
+ prevDecade: string;
323
+ nextDecade: string;
324
+ /** Used by DateTimePicker.Input (present only when DatePickerContext is provided by DateTimePickerRoot) */
325
+ dateTimeInput?: string;
326
+ }
327
+ interface RangePickerLabels extends DatePickerLabels {
328
+ startInput: string;
329
+ endInput: string;
330
+ presetsGroup: string;
331
+ }
332
+ interface TimePickerLabels {
333
+ timeInput: string;
334
+ hourList: string;
335
+ minuteList: string;
336
+ amPmToggle: string;
337
+ hourOption: (hour: number) => string;
338
+ minuteOption: (minute: number) => string;
339
+ }
340
+ interface DateTimePickerLabels extends DatePickerLabels, TimePickerLabels {
341
+ dateTimeInput: string;
342
+ }
343
+ declare const DEFAULT_DATEPICKER_LABELS: DatePickerLabels;
344
+ declare const DEFAULT_RANGEPICKER_LABELS: RangePickerLabels;
345
+ declare const DEFAULT_TIMEPICKER_LABELS: TimePickerLabels;
346
+ declare const DEFAULT_DATETIMEPICKER_LABELS: DateTimePickerLabels;
347
+
348
+ 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, formatFullDate, formatInTimezone, formatMonthYear, formatTimeFromISO, formatTimeString, generateHours, generateMinutes, getCalendarDays, getMonthName, getTime, getTimezoneOffsetMinutes, getWeekdayNames, isDateDisabled, isSameDayInTimezone, isSameTime, maxDate, minDate, normalizeISO, parseInputValue, parseTimeString, setTime, startOfDayInTimezone, to12Hour, to24Hour, todayInTimezone };
package/dist/index.js CHANGED
@@ -237,14 +237,14 @@ function maxDate(a, b, adapter) {
237
237
 
238
238
  // src/utils/date.ts
239
239
  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}/;
240
+ var ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
241
241
  function normalizeISO(value) {
242
242
  if (!value) return "";
243
243
  if (ISO_DATETIME_REGEX.test(value)) return value;
244
244
  if (ISO_DATE_REGEX.test(value)) return `${value}T00:00:00.000Z`;
245
245
  return value;
246
246
  }
247
- function parseInputValue(input, format, adapter) {
247
+ function parseInputValue(input, adapter) {
248
248
  if (!input.trim()) return null;
249
249
  const cleaned = input.replace(/\//g, "-").trim();
250
250
  if (ISO_DATE_REGEX.test(cleaned)) {
@@ -319,8 +319,8 @@ function generateHours(format = "24h") {
319
319
  return Array.from({ length: 24 }, (_, i) => i);
320
320
  }
321
321
  function generateMinutes(step = 1) {
322
- if (step < 1 || step > 60) {
323
- throw new Error(`[generateMinutes] step must be between 1 and 60, got ${step}`);
322
+ if (step < 1 || step > 30) {
323
+ throw new Error(`[generateMinutes] step must be between 1 and 30, got ${step}`);
324
324
  }
325
325
  const result = [];
326
326
  for (let i = 0; i < 60; i += step) {
@@ -331,7 +331,7 @@ function generateMinutes(step = 1) {
331
331
  function isSameTime(a, b) {
332
332
  return a.hours === b.hours && a.minutes === b.minutes && a.seconds === b.seconds;
333
333
  }
334
- function formatTimeFromISO(iso, format, _adapter) {
334
+ function formatTimeFromISO(iso, format) {
335
335
  const time = getTime(iso);
336
336
  if (format === "h:mm a" || format === "h:mm:ss a") {
337
337
  const { hours12, period } = to12Hour(time.hours);
@@ -346,30 +346,35 @@ function formatTimeFromISO(iso, format, _adapter) {
346
346
  }
347
347
 
348
348
  // src/utils/locale.ts
349
+ var formatterCache = /* @__PURE__ */ new Map();
350
+ function getCachedFormatter(locale, options) {
351
+ const key = `${locale}:${JSON.stringify(options)}`;
352
+ let fmt = formatterCache.get(key);
353
+ if (!fmt) {
354
+ fmt = new Intl.DateTimeFormat(locale, options);
355
+ formatterCache.set(key, fmt);
356
+ }
357
+ return fmt;
358
+ }
359
+ var REFERENCE_YEAR = 2026;
349
360
  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);
361
+ const date = new Date(Date.UTC(REFERENCE_YEAR, month, 1));
362
+ return getCachedFormatter(locale, { month: "long", timeZone: "UTC" }).format(date);
352
363
  }
353
364
  function formatMonthYear(year, month, locale = "en-US") {
354
365
  const date = new Date(Date.UTC(year, month, 1));
355
- return new Intl.DateTimeFormat(locale, {
366
+ return getCachedFormatter(locale, {
356
367
  year: "numeric",
357
368
  month: "long",
358
369
  timeZone: "UTC"
359
370
  }).format(date);
360
371
  }
361
372
  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
- });
373
+ const shortFormatter = getCachedFormatter(locale, { weekday: "short", timeZone: "UTC" });
374
+ const fullFormatter = getCachedFormatter(locale, { weekday: "long", timeZone: "UTC" });
370
375
  const days = [];
371
376
  for (let i = 0; i < 7; i++) {
372
- const date = new Date(Date.UTC(2026, 0, 4 + i));
377
+ const date = new Date(Date.UTC(REFERENCE_YEAR, 0, 4 + i));
373
378
  days.push({
374
379
  short: shortFormatter.format(date),
375
380
  full: fullFormatter.format(date)
@@ -383,7 +388,7 @@ function getWeekdayNames(locale = "en-US", weekStartsOn = 0) {
383
388
  }
384
389
  function formatFullDate(iso, locale = "en-US") {
385
390
  const date = new Date(iso);
386
- return new Intl.DateTimeFormat(locale, {
391
+ return getCachedFormatter(locale, {
387
392
  year: "numeric",
388
393
  month: "long",
389
394
  day: "numeric",
@@ -391,9 +396,122 @@ function formatFullDate(iso, locale = "en-US") {
391
396
  timeZone: "UTC"
392
397
  }).format(date);
393
398
  }
399
+
400
+ // src/utils/timezone.ts
401
+ import { parseISO as parseISO2 } from "date-fns";
402
+ var formatterCache2 = /* @__PURE__ */ new Map();
403
+ function getCachedPartsFormatter(timeZone) {
404
+ const key = `parts:${timeZone}`;
405
+ let fmt = formatterCache2.get(key);
406
+ if (!fmt) {
407
+ fmt = new Intl.DateTimeFormat("en-US", {
408
+ timeZone,
409
+ year: "numeric",
410
+ month: "2-digit",
411
+ day: "2-digit",
412
+ hour: "2-digit",
413
+ minute: "2-digit",
414
+ second: "2-digit",
415
+ hourCycle: "h23"
416
+ });
417
+ formatterCache2.set(key, fmt);
418
+ }
419
+ return fmt;
420
+ }
421
+ function partsInTimezone(utc, timeZone) {
422
+ const dtf = getCachedPartsFormatter(timeZone);
423
+ const parts = Object.fromEntries(
424
+ dtf.formatToParts(utc).map((p) => [p.type, p.value])
425
+ );
426
+ return {
427
+ year: Number(parts.year),
428
+ month: Number(parts.month),
429
+ day: Number(parts.day),
430
+ // Some locales/engines return '24' instead of '0' at midnight
431
+ hour: parts.hour === "24" ? 0 : Number(parts.hour),
432
+ minute: Number(parts.minute),
433
+ second: Number(parts.second)
434
+ };
435
+ }
436
+ function formatInTimezone(iso, formatStr, timeZone) {
437
+ const p = partsInTimezone(parseISO2(iso), timeZone);
438
+ const tokens = {
439
+ yyyy: String(p.year),
440
+ MM: String(p.month).padStart(2, "0"),
441
+ dd: String(p.day).padStart(2, "0"),
442
+ HH: String(p.hour).padStart(2, "0"),
443
+ mm: String(p.minute).padStart(2, "0"),
444
+ ss: String(p.second).padStart(2, "0")
445
+ };
446
+ let result = formatStr;
447
+ for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
448
+ result = result.split(token).join(value);
449
+ }
450
+ return result;
451
+ }
452
+ function getTimezoneOffsetMinutes(iso, timeZone) {
453
+ const utc = parseISO2(iso);
454
+ const p = partsInTimezone(utc, timeZone);
455
+ const asUtcEpoch = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
456
+ return Math.round((asUtcEpoch - utc.getTime()) / 6e4);
457
+ }
458
+ function startOfDayInTimezone(iso, timeZone) {
459
+ const utc = parseISO2(iso);
460
+ const p = partsInTimezone(utc, timeZone);
461
+ const civilMidnightUtc = Date.UTC(p.year, p.month - 1, p.day, 0, 0, 0);
462
+ const midnightProbe = new Date(civilMidnightUtc).toISOString();
463
+ const offsetMinutes = getTimezoneOffsetMinutes(midnightProbe, timeZone);
464
+ return new Date(civilMidnightUtc - offsetMinutes * 6e4).toISOString();
465
+ }
466
+ function isSameDayInTimezone(a, b, timeZone) {
467
+ const pa = partsInTimezone(parseISO2(a), timeZone);
468
+ const pb = partsInTimezone(parseISO2(b), timeZone);
469
+ return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
470
+ }
471
+ function todayInTimezone(timeZone) {
472
+ return startOfDayInTimezone((/* @__PURE__ */ new Date()).toISOString(), timeZone);
473
+ }
474
+
475
+ // src/utils/labels.ts
476
+ var DEFAULT_DATEPICKER_LABELS = {
477
+ triggerOpen: "Open calendar",
478
+ triggerClose: "Close calendar",
479
+ popoverLabel: "Choose date",
480
+ prevMonth: "Previous month",
481
+ nextMonth: "Next month",
482
+ prevYear: "Previous year",
483
+ nextYear: "Next year",
484
+ prevDecade: "Previous decade",
485
+ nextDecade: "Next decade"
486
+ };
487
+ var DEFAULT_RANGEPICKER_LABELS = {
488
+ ...DEFAULT_DATEPICKER_LABELS,
489
+ popoverLabel: "Choose date range",
490
+ startInput: "Start date",
491
+ endInput: "End date",
492
+ presetsGroup: "Date range presets"
493
+ };
494
+ var DEFAULT_TIMEPICKER_LABELS = {
495
+ timeInput: "Time",
496
+ hourList: "Hour",
497
+ minuteList: "Minute",
498
+ amPmToggle: "AM/PM",
499
+ hourOption: (hour) => `${hour} hours`,
500
+ minuteOption: (minute) => `${minute} minutes`
501
+ };
502
+ var DEFAULT_DATETIMEPICKER_LABELS = {
503
+ ...DEFAULT_DATEPICKER_LABELS,
504
+ ...DEFAULT_TIMEPICKER_LABELS,
505
+ dateTimeInput: "Date and time"
506
+ };
394
507
  export {
508
+ DEFAULT_DATEPICKER_LABELS,
509
+ DEFAULT_DATETIMEPICKER_LABELS,
510
+ DEFAULT_RANGEPICKER_LABELS,
511
+ DEFAULT_TIMEPICKER_LABELS,
395
512
  DateFnsAdapter,
396
513
  formatFullDate,
514
+ formatInTimezone,
397
515
  formatMonthYear,
398
516
  formatTimeFromISO,
399
517
  formatTimeString,
@@ -402,8 +520,10 @@ export {
402
520
  getCalendarDays,
403
521
  getMonthName,
404
522
  getTime,
523
+ getTimezoneOffsetMinutes,
405
524
  getWeekdayNames,
406
525
  isDateDisabled,
526
+ isSameDayInTimezone,
407
527
  isSameTime,
408
528
  maxDate,
409
529
  minDate,
@@ -411,6 +531,8 @@ export {
411
531
  parseInputValue,
412
532
  parseTimeString,
413
533
  setTime,
534
+ startOfDayInTimezone,
414
535
  to12Hour,
415
- to24Hour
536
+ to24Hour,
537
+ todayInTimezone
416
538
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kalyx/core",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Kalyx core — platform-agnostic date logic",
5
5
  "license": "MIT",
6
6
  "author": "jiji-hoon96",