@trackunit/react-form-components 1.23.2 → 1.23.3

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/index.cjs.js CHANGED
@@ -30,7 +30,7 @@ var defaultTranslations = {
30
30
  "dateField.actions.apply": "Apply",
31
31
  "dateField.actions.cancel": "Cancel",
32
32
  "dateField.actions.clear": "Clear",
33
- "dateField.placeholder": "mm dd, yyyy",
33
+ "dateField.placeholder": "yyyy-mm-dd",
34
34
  "dropzone.input.title": "Drag-and-drop file input",
35
35
  "dropzone.label.default": "<clickable>Browse</clickable> or drag files here...",
36
36
  "emailField.error.INVALID_EMAIL": "Please enter a valid email address",
@@ -107,6 +107,76 @@ const setupLibraryTranslations = () => {
107
107
  i18nLibraryTranslation.registerTranslations(translations);
108
108
  };
109
109
 
110
+ const ISO_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/;
111
+ function parseAndValidateYYYYMMDD(value) {
112
+ if (value === "")
113
+ return null;
114
+ const isoMatch = ISO_DATE_REGEX.exec(value);
115
+ if (!isoMatch)
116
+ return null;
117
+ const [, y, m, d] = isoMatch;
118
+ if (y === undefined || m === undefined || d === undefined)
119
+ return null;
120
+ const year = Number.parseInt(y, 10);
121
+ const month = Number.parseInt(m, 10) - 1;
122
+ const day = Number.parseInt(d, 10);
123
+ if (month < 0 || month > 11 || day < 1 || day > 31)
124
+ return null;
125
+ const local = new Date(year, month, day);
126
+ if (Number.isNaN(local.getTime()))
127
+ return null;
128
+ if (local.getFullYear() !== year || local.getMonth() !== month || local.getDate() !== day) {
129
+ return null;
130
+ }
131
+ return { year, month: month + 1, day };
132
+ }
133
+ /**
134
+ * Parses the value from a DateField change event (YYYY-MM-DD string) to a Date at local midnight.
135
+ * Use this when you need a Date from event.target.value to avoid timezone shifts that occur
136
+ * with `new Date(value)` (which interprets the string as UTC).
137
+ *
138
+ * @param value - The string from event.target.value (e.g. "2025-03-07" or "")
139
+ * @returns {Date | null} - Date at local midnight for the given day, or null if value is empty or invalid
140
+ */
141
+ function parseDateFieldValue(value) {
142
+ const parsed = parseAndValidateYYYYMMDD(value);
143
+ if (!parsed)
144
+ return null;
145
+ return new Date(parsed.year, parsed.month - 1, parsed.day);
146
+ }
147
+ /**
148
+ * Converts a YYYY-MM-DD string (e.g. from DateField) to an ISO string at UTC midnight for that calendar day.
149
+ * Use this when storing or sending date-only values so that the calendar day is preserved regardless of
150
+ * user timezone (unlike calling .toISOString() on a local-midnight Date, which shifts the day for UTC+).
151
+ *
152
+ * @param value - The string from event.target.value (e.g. "2025-03-07" or "")
153
+ * @returns {string | null} - "YYYY-MM-DDT00:00:00.000Z" or null if value is empty or invalid
154
+ */
155
+ function toISODateStringUTC(value) {
156
+ const parsed = parseAndValidateYYYYMMDD(value);
157
+ if (!parsed)
158
+ return null;
159
+ const { year, month, day } = parsed;
160
+ const mm = String(month).padStart(2, "0");
161
+ const dd = String(day).padStart(2, "0");
162
+ return `${year}-${mm}-${dd}T00:00:00.000Z`;
163
+ }
164
+ /**
165
+ * Converts a Date (e.g. at local midnight) to an ISO string at UTC midnight for the same calendar day.
166
+ * Use when sending a date-only value to an API that expects UTC midnight (e.g. booking date).
167
+ *
168
+ * @param date - A Date instance (typically local midnight from a date picker)
169
+ * @returns {string} "YYYY-MM-DDT00:00:00.000Z" for that calendar day
170
+ */
171
+ function dateToISODateUTC(date) {
172
+ const year = date.getFullYear();
173
+ const month = date.getMonth() + 1;
174
+ const day = date.getDate();
175
+ const mm = String(month).padStart(2, "0");
176
+ const dd = String(day).padStart(2, "0");
177
+ return `${year}-${mm}-${dd}T00:00:00.000Z`;
178
+ }
179
+
110
180
  const createSyntheticInputChangeEvent = (value, sourceInput) => {
111
181
  const target = document.createElement("input");
112
182
  target.value = value;
@@ -531,147 +601,6 @@ const BaseInput = ({ className, isInvalid = false, "data-testid": dataTestId, pr
531
601
  };
532
602
  BaseInput.displayName = "BaseInput";
533
603
 
534
- const normalizeDisplayCompare = (value) => value
535
- .normalize("NFKC")
536
- .replace(/[\u202f\u00a0]/g, " ")
537
- .replace(/\s+/g, " ")
538
- .trim();
539
- const COMPACT_DATE_REGEX = /^\d{8}$/;
540
- const localePartOrderCache = new Map();
541
- /**
542
- * Returns the date-part display order (month/day/year) for the given locale by inspecting
543
- * `Intl.DateTimeFormat.formatToParts`. Results are cached after the first call per locale.
544
- * Falls back to MDY (English/US convention) on any error.
545
- */
546
- function getLocaleDatePartOrder(locale) {
547
- const cached = localePartOrderCache.get(locale);
548
- if (cached !== undefined)
549
- return cached;
550
- try {
551
- const parts = new Intl.DateTimeFormat(locale, { year: "numeric", month: "2-digit", day: "2-digit" })
552
- .formatToParts(new Date(2000, 0, 15))
553
- .filter(p => p.type === "month" || p.type === "day" || p.type === "year")
554
- .map(p => p.type.charAt(0).toUpperCase())
555
- .join("");
556
- const order = parts === "DMY" ? "DMY" : parts === "YMD" ? "YMD" : "MDY";
557
- localePartOrderCache.set(locale, order);
558
- return order;
559
- }
560
- catch {
561
- return "MDY";
562
- }
563
- }
564
- /**
565
- * Converts a locale-ordered 8-digit compact string to `YYYY-MM-DD`.
566
- *
567
- * The split follows the locale's field order:
568
- * - MDY (e.g. `en`): `MMDDYYYY` → `YYYY-MM-DD`
569
- * - DMY (e.g. `de`): `DDMMYYYY` → `YYYY-MM-DD`
570
- * - YMD (e.g. `ja`): `YYYYMMDD` → `YYYY-MM-DD`
571
- *
572
- * Returns the original value unchanged for non-8-digit inputs.
573
- */
574
- const normalizeCompactDateInput = (value, locale) => {
575
- if (!COMPACT_DATE_REGEX.test(value))
576
- return value;
577
- const order = getLocaleDatePartOrder(locale ?? "en");
578
- if (order === "MDY")
579
- return `${value.slice(4, 8)}-${value.slice(0, 2)}-${value.slice(2, 4)}`;
580
- if (order === "DMY")
581
- return `${value.slice(4, 8)}-${value.slice(2, 4)}-${value.slice(0, 2)}`;
582
- return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
583
- };
584
- /**
585
- * Parses date-field keyboard input to a local-midnight {@link Date}.
586
- *
587
- * Strict `YYYY-MM-DD` is accepted first; a locale-ordered compact 8-digit string (e.g. `04132026`
588
- * in English for April 13 2026) is normalised to `YYYY-MM-DD` before that check. Otherwise the
589
- * string must round-trip with {@link formatShortDateUtil} for the given `locale`. Arbitrary
590
- * `new Date(string)`-parseable inputs that don't match the locale's format return `null` to avoid
591
- * accepting confusable formats like `03/07/2025`.
592
- *
593
- * @param value - Raw text from the input.
594
- * @param locale - Locale to compare against when the input is not `YYYY-MM-DD`.
595
- */
596
- const parseDateFieldInputForChange = (value, locale) => {
597
- const fromIso = dateAndTimeUtils.parseYYYYMMDDUtil(normalizeCompactDateInput(value, locale));
598
- if (fromIso !== null)
599
- return fromIso;
600
- const trimmed = value.trim();
601
- if (trimmed === "")
602
- return null;
603
- const parsed = dateAndTimeUtils.parseValidDate(trimmed);
604
- if (parsed === null)
605
- return null;
606
- const localMidnight = dateAndTimeUtils.toDateUtil(dateAndTimeUtils.startOfDayUtil(parsed));
607
- const formatted = dateAndTimeUtils.formatShortDateUtil(localMidnight, locale);
608
- if (normalizeDisplayCompare(formatted) === normalizeDisplayCompare(trimmed)) {
609
- return localMidnight;
610
- }
611
- return null;
612
- };
613
- /**
614
- * Renders the stored date-field value for display:
615
- * - empty string → empty
616
- * - full canonical `YYYY-MM-DD` → locale-aware short date
617
- * - partial input (e.g. `"2025-03"`) → returned as-is so the user can keep typing
618
- *
619
- * @param stored - The current canonical value (typically from `event.target.value`).
620
- * @param locale - Locale for the display format.
621
- */
622
- const dateFieldStoredValueToDisplayString = (stored, locale) => {
623
- if (stored === "")
624
- return "";
625
- const parsed = dateAndTimeUtils.parseYYYYMMDDUtil(stored);
626
- if (parsed !== null)
627
- return dateAndTimeUtils.formatShortDateUtil(parsed, locale);
628
- return stored;
629
- };
630
-
631
- /**
632
- * Converts a Date instant to the canonical YYYY-MM-DD for a specific IANA time zone.
633
- * This preserves the calendar day the user selected in their local zone.
634
- */
635
- const dateToCalendarDayString = ({ date, timeZoneId }) => {
636
- if (!date || Number.isNaN(date.getTime()))
637
- return "";
638
- const resolvedTimeZoneId = timeZoneId ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
639
- return dateAndTimeUtils.toZonedDateTimeUtil(date, resolvedTimeZoneId).toPlainDate().toString();
640
- };
641
- /**
642
- * Clamps a date to [minDate, maxDate] by comparing calendar days in a specific timezone.
643
- * Returns the original `date` when it's in range, or the relevant boundary date when out of range.
644
- */
645
- const clampDateToCalendarDayRange = ({ date, minDate, maxDate, timeZoneId, }) => {
646
- if (date === null || date === undefined)
647
- return null;
648
- const day = dateToCalendarDayString({ date, timeZoneId });
649
- if (day === "")
650
- return null;
651
- if (minDate !== undefined) {
652
- const minDay = dateToCalendarDayString({ date: minDate, timeZoneId });
653
- if (minDay !== "" && day < minDay)
654
- return minDate;
655
- }
656
- if (maxDate !== undefined) {
657
- const maxDay = dateToCalendarDayString({ date: maxDate, timeZoneId });
658
- if (maxDay !== "" && day > maxDay)
659
- return maxDate;
660
- }
661
- return date;
662
- };
663
- /**
664
- * Returns true when a date falls outside [minDate, maxDate] by calendar day in a specific timezone.
665
- */
666
- const isCalendarDayOutOfRange = ({ date, minDate, maxDate, timeZoneId, }) => {
667
- if (!date)
668
- return false;
669
- const clamped = clampDateToCalendarDayRange({ date, minDate, maxDate, timeZoneId });
670
- if (!clamped)
671
- return false;
672
- return clamped.getTime() !== date.getTime();
673
- };
674
-
675
604
  function parseToDate(v) {
676
605
  if (v === undefined || v === "")
677
606
  return undefined;
@@ -682,38 +611,63 @@ function parseToDate(v) {
682
611
  return Number.isNaN(d.getTime()) ? undefined : d;
683
612
  }
684
613
  const str = String(v);
685
- const fromField = dateAndTimeUtils.parseYYYYMMDDUtil(str);
614
+ const fromField = parseDateFieldValue(str);
686
615
  if (fromField !== null)
687
616
  return fromField;
688
617
  const fallback = new Date(str);
689
618
  return Number.isNaN(fallback.getTime()) ? undefined : fallback;
690
619
  }
620
+ function formatToInputString(d) {
621
+ if (!d)
622
+ return "";
623
+ return dateAndTimeUtils.Temporal.PlainDateTime.from({
624
+ year: d.getFullYear(),
625
+ month: d.getMonth() + 1,
626
+ day: d.getDate(),
627
+ })
628
+ .toPlainDate()
629
+ .toString();
630
+ }
631
+ function startOfDayMs(d) {
632
+ return dateAndTimeUtils.toDateUtil(dateAndTimeUtils.startOfDayUtil(d)).getTime();
633
+ }
634
+ /** Clamp a date to [minDate, maxDate]; returns the same date if in range or no bounds. */
635
+ function clampToRange(date, minDate, maxDate) {
636
+ if (date === null || date === undefined)
637
+ return null;
638
+ const dayStart = startOfDayMs(date);
639
+ if (minDate !== undefined && dayStart < startOfDayMs(minDate))
640
+ return minDate;
641
+ if (maxDate !== undefined && dayStart > startOfDayMs(maxDate))
642
+ return maxDate;
643
+ return date;
644
+ }
691
645
  /**
692
646
  * A wrapper around BaseInput with a pop-up day picker using the same calendar UI as DayPicker.
693
647
  *
694
- * The input shows a locale-aware date (short month, numeric day and year, e.g. "Mar 23, 2026" in English).
695
- * `onChange` still emits the canonical YYYY-MM-DD string for form parsing.
648
+ * The value is formatted to an ISO date string (YYYY-MM-DD)
696
649
  *
697
650
  * NOTE: If shown with a label, please use the `DateField` component instead.
698
651
  */
699
- const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: suffixProp, "data-testid": dataTestId, onBlur, ...rest }) => {
700
- const timeZoneId = Intl.DateTimeFormat().resolvedOptions().timeZone;
652
+ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: suffixProp, "data-testid": dataTestId, ...rest }) => {
701
653
  const isControlled = value !== undefined;
702
- const [internalValue, setInternalValue] = react.useState(() => dateToCalendarDayString({ date: parseToDate(defaultValue), timeZoneId }));
654
+ const [internalValue, setInternalValue] = react.useState(() => formatToInputString(parseToDate(defaultValue)));
703
655
  const [pendingDate, setPendingDate] = react.useState(null);
704
- const [t, i18n] = useTranslation();
705
- const locale = i18n.resolvedLanguage ?? i18n.language;
706
656
  // For controlled string value, only normalize when it's strict YYYY-MM-DD so partial input (e.g. "2025-03") is preserved.
707
657
  const resolvedValue = isControlled
708
658
  ? typeof value === "string"
709
- ? dateFieldStoredValueToDisplayString(value, locale)
710
- : dateFieldStoredValueToDisplayString(dateToCalendarDayString({ date: parseToDate(value), timeZoneId }), locale)
711
- : dateFieldStoredValueToDisplayString(internalValue, locale);
659
+ ? (() => {
660
+ const strict = parseDateFieldValue(value);
661
+ return strict !== null ? formatToInputString(strict) : value;
662
+ })()
663
+ : formatToInputString(parseToDate(value))
664
+ : internalValue;
712
665
  const selectedDate = isControlled && typeof value === "string"
713
- ? (dateAndTimeUtils.parseYYYYMMDDUtil(value) ?? undefined)
666
+ ? (parseDateFieldValue(value) ?? undefined)
714
667
  : parseToDate(isControlled ? value : internalValue);
715
668
  const inputRef = react.useRef(null);
716
669
  const createInputChangeEvent = useCreateInputChangeEvent();
670
+ const [t] = useTranslation();
717
671
  react.useImperativeHandle(ref, () => inputRef.current ?? document.createElement("input"), []);
718
672
  const syncPendingFromValue = react.useCallback(() => {
719
673
  setPendingDate(selectedDate ?? null);
@@ -723,8 +677,13 @@ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: s
723
677
  return false;
724
678
  const minDate = parseToDate(min);
725
679
  const maxDate = parseToDate(max);
726
- return isCalendarDayOutOfRange({ date, minDate, maxDate, timeZoneId });
727
- }, [min, max, timeZoneId]);
680
+ const dayStart = startOfDayMs(date);
681
+ if (minDate !== undefined && dayStart < startOfDayMs(minDate))
682
+ return true;
683
+ if (maxDate !== undefined && dayStart > startOfDayMs(maxDate))
684
+ return true;
685
+ return false;
686
+ }, [min, max]);
728
687
  const handleCalendarChange = react.useCallback((next) => {
729
688
  setPendingDate(next);
730
689
  }, []);
@@ -741,21 +700,21 @@ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: s
741
700
  const handleApply = react.useCallback((closePopover) => {
742
701
  const minDate = parseToDate(min);
743
702
  const maxDate = parseToDate(max);
744
- const clamped = clampDateToCalendarDayRange({ date: pendingDate, minDate, maxDate, timeZoneId });
745
- const str = clamped ? dateToCalendarDayString({ date: clamped, timeZoneId }) : "";
703
+ const clamped = clampToRange(pendingDate, minDate, maxDate);
704
+ const str = clamped ? formatToInputString(clamped) : "";
746
705
  if (!isControlled)
747
706
  setInternalValue(str);
748
707
  onChange?.(createInputChangeEvent(str, inputRef.current));
749
708
  closePopover();
750
- }, [createInputChangeEvent, isControlled, min, max, onChange, pendingDate, timeZoneId]);
709
+ }, [createInputChangeEvent, isControlled, min, max, onChange, pendingDate]);
751
710
  const handleInputChange = react.useCallback((e) => {
752
711
  const raw = e.target.value;
753
- const parsed = parseDateFieldInputForChange(raw, locale);
712
+ const parsed = parseDateFieldValue(raw);
754
713
  const minDate = parseToDate(min);
755
714
  const maxDate = parseToDate(max);
756
715
  if (parsed !== null) {
757
- const clamped = clampDateToCalendarDayRange({ date: parsed, minDate, maxDate, timeZoneId });
758
- const str = clamped ? dateToCalendarDayString({ date: clamped, timeZoneId }) : "";
716
+ const clamped = clampToRange(parsed, minDate, maxDate);
717
+ const str = clamped ? formatToInputString(clamped) : "";
759
718
  if (!isControlled)
760
719
  setInternalValue(str);
761
720
  onChange?.(createInputChangeEvent(str, inputRef.current));
@@ -765,30 +724,10 @@ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: s
765
724
  setInternalValue(raw);
766
725
  onChange?.(e);
767
726
  }
768
- }, [createInputChangeEvent, isControlled, locale, min, max, onChange, timeZoneId]);
769
- const handleBlur = react.useCallback((e) => {
770
- if (!onBlur)
771
- return;
772
- const storedValue = isControlled
773
- ? typeof value === "string"
774
- ? value
775
- : dateToCalendarDayString({ date: parseToDate(value), timeZoneId })
776
- : internalValue;
777
- // When the stored value is a committed calendar day (YYYY-MM-DD) we expose it on blur, even though the
778
- // displayed input value is localized.
779
- const shouldExposeStoredValue = storedValue === "" || dateAndTimeUtils.parseYYYYMMDDUtil(storedValue) !== null;
780
- if (!shouldExposeStoredValue) {
781
- onBlur(e);
782
- return;
783
- }
784
- const prev = e.currentTarget.value;
785
- e.currentTarget.value = storedValue;
786
- onBlur(e);
787
- e.currentTarget.value = prev;
788
- }, [internalValue, isControlled, onBlur, timeZoneId, value]);
789
- return (jsxRuntime.jsxs(reactComponents.Popover, { onOpenStateChange: open => open && syncPendingFromValue(), placement: "bottom-start", children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx("div", { className: tailwindMerge.twMerge("flex w-full min-w-0 cursor-pointer items-center", (Boolean(rest.disabled) || Boolean(rest.readOnly)) && "pointer-events-none"), children: jsxRuntime.jsx(BaseInput, { ...rest, "aria-readonly": true, className: tailwindMerge.twMerge("w-full min-w-0", rest.className), "data-testid": dataTestId ? `${dataTestId}-input` : undefined, onBlur: handleBlur, onChange: handleInputChange, placeholder: rest.placeholder ?? t("dateField.placeholder"), ref: inputRef, suffix: suffixProp ?? (jsxRuntime.jsx(reactComponents.Icon, { "aria-label": undefined, className: Boolean(rest.disabled) || Boolean(rest.readOnly) ? "text-neutral-500" : undefined, "data-testid": dataTestId ? `${dataTestId}-calendar` : "calendar", name: "Calendar", size: "medium", type: "solid" })), type: "text", value: resolvedValue }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { children: closePopover => {
727
+ }, [createInputChangeEvent, isControlled, min, max, onChange]);
728
+ return (jsxRuntime.jsxs(reactComponents.Popover, { onOpenStateChange: open => open && syncPendingFromValue(), placement: "bottom-start", children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx("div", { className: tailwindMerge.twMerge("flex w-full min-w-0 cursor-pointer items-center", (Boolean(rest.disabled) || Boolean(rest.readOnly)) && "pointer-events-none"), children: jsxRuntime.jsx(BaseInput, { ...rest, "aria-readonly": true, className: tailwindMerge.twMerge("w-full min-w-0", rest.className), "data-testid": dataTestId ? `${dataTestId}-input` : undefined, onChange: handleInputChange, placeholder: rest.placeholder ?? t("dateField.placeholder"), ref: inputRef, suffix: suffixProp ?? (jsxRuntime.jsx(reactComponents.Icon, { "aria-label": undefined, className: Boolean(rest.disabled) || Boolean(rest.readOnly) ? "text-neutral-500" : undefined, "data-testid": dataTestId ? `${dataTestId}-calendar` : "calendar", name: "Calendar", size: "medium", type: "solid" })), type: "text", value: resolvedValue }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { children: closePopover => {
790
729
  const displayDate = pendingDate ?? selectedDate ?? null;
791
- return (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("flex w-min flex-col overflow-hidden rounded-md border border-neutral-300 bg-white p-0"), children: [jsxRuntime.jsx(ReactCalendar, { allowPartialRange: true, className: tailwindMerge.twMerge("custom-day-picker", "range-picker", "p-0"), defaultActiveStartDate: displayDate ?? undefined, defaultView: "month", locale: locale, onChange: val => {
730
+ return (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("flex w-min flex-col overflow-hidden rounded-md border border-neutral-300 bg-white p-0"), children: [jsxRuntime.jsx(ReactCalendar, { allowPartialRange: true, className: tailwindMerge.twMerge("custom-day-picker", "range-picker", "p-0"), defaultActiveStartDate: displayDate ?? undefined, defaultView: "month", onChange: val => {
792
731
  const next = val instanceof Date ? val : Array.isArray(val) ? (val[0] instanceof Date ? val[0] : null) : null;
793
732
  handleCalendarChange(next);
794
733
  }, selectRange: false, tileDisabled: tileDisabled, value: displayDate }), jsxRuntime.jsx("hr", {}), jsxRuntime.jsxs("div", { className: "flex w-full justify-between gap-2 px-4 py-3", children: [jsxRuntime.jsx(reactComponents.Button, { className: "mr-auto", "data-testid": dataTestId ? `${dataTestId}-clear-button` : undefined, onClick: () => handleClear(closePopover), size: "small", variant: "secondary", children: t("dateField.actions.clear") }), jsxRuntime.jsxs("div", { className: "flex gap-2", children: [jsxRuntime.jsx(reactComponents.Button, { "data-testid": dataTestId ? `${dataTestId}-cancel-button` : undefined, onClick: () => handleCancel(closePopover), size: "small", variant: "ghost-neutral", children: t("dateField.actions.cancel") }), jsxRuntime.jsx(reactComponents.Button, { "data-testid": dataTestId ? `${dataTestId}-apply-button` : undefined, onClick: () => handleApply(closePopover), size: "small", children: t("dateField.actions.apply") })] })] })] }));
@@ -2571,9 +2510,6 @@ ColorField.displayName = "ColorField";
2571
2510
 
2572
2511
  /**
2573
2512
  * The date field component is used for entering date values with a calendar picker (same UI as DayPicker).
2574
- * The input shows a localized short date; when the user commits a valid day, both `onChange` and `onBlur`
2575
- * expose `YYYY-MM-DD` on `event.target.value` (use `parseYYYYMMDDUtil` from `@trackunit/date-and-time-utils`
2576
- * for a local-midnight `Date`).
2577
2513
  *
2578
2514
  * ### When to use
2579
2515
  * Use DateField for selecting calendar dates such as birthdates, deadlines, or scheduling.
@@ -2601,13 +2537,11 @@ ColorField.displayName = "ColorField";
2601
2537
  * @example Date field with constraints
2602
2538
  * ```tsx
2603
2539
  * import { DateField } from "@trackunit/react-form-components";
2604
- * import { dateToYYYYMMDDUtil } from "@trackunit/date-and-time-utils";
2605
2540
  * import { useState } from "react";
2606
2541
  *
2607
2542
  * const BookingForm = () => {
2608
2543
  * const [checkIn, setCheckIn] = useState("");
2609
- * // Use the local calendar day, not UTC (avoids day-shift in non-UTC zones).
2610
- * const today = dateToYYYYMMDDUtil(new Date());
2544
+ * const today = new Date().toISOString().split("T")[0];
2611
2545
  *
2612
2546
  * return (
2613
2547
  * <DateField
@@ -5082,14 +5016,17 @@ exports.cvaSelectPlaceholder = cvaSelectPlaceholder;
5082
5016
  exports.cvaSelectPrefixSuffix = cvaSelectPrefixSuffix;
5083
5017
  exports.cvaSelectSingleValue = cvaSelectSingleValue;
5084
5018
  exports.cvaSelectValueContainer = cvaSelectValueContainer;
5019
+ exports.dateToISODateUTC = dateToISODateUTC;
5085
5020
  exports.getCountryAbbreviation = getCountryAbbreviation;
5086
5021
  exports.getPhoneNumberWithPlus = getPhoneNumberWithPlus;
5087
5022
  exports.isInvalidCountryCode = isInvalidCountryCode;
5088
5023
  exports.isInvalidPhoneNumber = isInvalidPhoneNumber;
5089
5024
  exports.isValidHEXColor = isValidHEXColor;
5025
+ exports.parseDateFieldValue = parseDateFieldValue;
5090
5026
  exports.parseSchedule = parseSchedule;
5091
5027
  exports.phoneErrorMessage = phoneErrorMessage;
5092
5028
  exports.serializeSchedule = serializeSchedule;
5029
+ exports.toISODateStringUTC = toISODateStringUTC;
5093
5030
  exports.useCreatableSelect = useCreatableSelect;
5094
5031
  exports.useCreateInputChangeEvent = useCreateInputChangeEvent;
5095
5032
  exports.useCustomComponents = useCustomComponents;
package/index.esm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { registerTranslations, useNamespaceTranslation, NamespaceTrans } from '@trackunit/i18n-library-translation';
3
- import { parseYYYYMMDDUtil, formatShortDateUtil, parseValidDate, toDateUtil, startOfDayUtil, toZonedDateTimeUtil } from '@trackunit/date-and-time-utils';
3
+ import { Temporal, toDateUtil, startOfDayUtil } from '@trackunit/date-and-time-utils';
4
4
  import { IconButton, Icon, Tooltip, Popover, PopoverTrigger, PopoverContent, Button, cvaMenu, cvaMenuList, Tag, useIsTextTruncated, ZStack, MenuItem, useMeasure, useDebounce, useMergeRefs, Spinner, useScrollBlock, Text, Heading, useIsFirstRender } from '@trackunit/react-components';
5
5
  import { useCallback, useRef, useEffect, useImperativeHandle, useState, cloneElement, isValidElement, useLayoutEffect, useMemo, useReducer, createContext, useContext, useId } from 'react';
6
6
  import ReactCalendar from 'react-calendar';
@@ -29,7 +29,7 @@ var defaultTranslations = {
29
29
  "dateField.actions.apply": "Apply",
30
30
  "dateField.actions.cancel": "Cancel",
31
31
  "dateField.actions.clear": "Clear",
32
- "dateField.placeholder": "mm dd, yyyy",
32
+ "dateField.placeholder": "yyyy-mm-dd",
33
33
  "dropzone.input.title": "Drag-and-drop file input",
34
34
  "dropzone.label.default": "<clickable>Browse</clickable> or drag files here...",
35
35
  "emailField.error.INVALID_EMAIL": "Please enter a valid email address",
@@ -106,6 +106,76 @@ const setupLibraryTranslations = () => {
106
106
  registerTranslations(translations);
107
107
  };
108
108
 
109
+ const ISO_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/;
110
+ function parseAndValidateYYYYMMDD(value) {
111
+ if (value === "")
112
+ return null;
113
+ const isoMatch = ISO_DATE_REGEX.exec(value);
114
+ if (!isoMatch)
115
+ return null;
116
+ const [, y, m, d] = isoMatch;
117
+ if (y === undefined || m === undefined || d === undefined)
118
+ return null;
119
+ const year = Number.parseInt(y, 10);
120
+ const month = Number.parseInt(m, 10) - 1;
121
+ const day = Number.parseInt(d, 10);
122
+ if (month < 0 || month > 11 || day < 1 || day > 31)
123
+ return null;
124
+ const local = new Date(year, month, day);
125
+ if (Number.isNaN(local.getTime()))
126
+ return null;
127
+ if (local.getFullYear() !== year || local.getMonth() !== month || local.getDate() !== day) {
128
+ return null;
129
+ }
130
+ return { year, month: month + 1, day };
131
+ }
132
+ /**
133
+ * Parses the value from a DateField change event (YYYY-MM-DD string) to a Date at local midnight.
134
+ * Use this when you need a Date from event.target.value to avoid timezone shifts that occur
135
+ * with `new Date(value)` (which interprets the string as UTC).
136
+ *
137
+ * @param value - The string from event.target.value (e.g. "2025-03-07" or "")
138
+ * @returns {Date | null} - Date at local midnight for the given day, or null if value is empty or invalid
139
+ */
140
+ function parseDateFieldValue(value) {
141
+ const parsed = parseAndValidateYYYYMMDD(value);
142
+ if (!parsed)
143
+ return null;
144
+ return new Date(parsed.year, parsed.month - 1, parsed.day);
145
+ }
146
+ /**
147
+ * Converts a YYYY-MM-DD string (e.g. from DateField) to an ISO string at UTC midnight for that calendar day.
148
+ * Use this when storing or sending date-only values so that the calendar day is preserved regardless of
149
+ * user timezone (unlike calling .toISOString() on a local-midnight Date, which shifts the day for UTC+).
150
+ *
151
+ * @param value - The string from event.target.value (e.g. "2025-03-07" or "")
152
+ * @returns {string | null} - "YYYY-MM-DDT00:00:00.000Z" or null if value is empty or invalid
153
+ */
154
+ function toISODateStringUTC(value) {
155
+ const parsed = parseAndValidateYYYYMMDD(value);
156
+ if (!parsed)
157
+ return null;
158
+ const { year, month, day } = parsed;
159
+ const mm = String(month).padStart(2, "0");
160
+ const dd = String(day).padStart(2, "0");
161
+ return `${year}-${mm}-${dd}T00:00:00.000Z`;
162
+ }
163
+ /**
164
+ * Converts a Date (e.g. at local midnight) to an ISO string at UTC midnight for the same calendar day.
165
+ * Use when sending a date-only value to an API that expects UTC midnight (e.g. booking date).
166
+ *
167
+ * @param date - A Date instance (typically local midnight from a date picker)
168
+ * @returns {string} "YYYY-MM-DDT00:00:00.000Z" for that calendar day
169
+ */
170
+ function dateToISODateUTC(date) {
171
+ const year = date.getFullYear();
172
+ const month = date.getMonth() + 1;
173
+ const day = date.getDate();
174
+ const mm = String(month).padStart(2, "0");
175
+ const dd = String(day).padStart(2, "0");
176
+ return `${year}-${mm}-${dd}T00:00:00.000Z`;
177
+ }
178
+
109
179
  const createSyntheticInputChangeEvent = (value, sourceInput) => {
110
180
  const target = document.createElement("input");
111
181
  target.value = value;
@@ -530,147 +600,6 @@ const BaseInput = ({ className, isInvalid = false, "data-testid": dataTestId, pr
530
600
  };
531
601
  BaseInput.displayName = "BaseInput";
532
602
 
533
- const normalizeDisplayCompare = (value) => value
534
- .normalize("NFKC")
535
- .replace(/[\u202f\u00a0]/g, " ")
536
- .replace(/\s+/g, " ")
537
- .trim();
538
- const COMPACT_DATE_REGEX = /^\d{8}$/;
539
- const localePartOrderCache = new Map();
540
- /**
541
- * Returns the date-part display order (month/day/year) for the given locale by inspecting
542
- * `Intl.DateTimeFormat.formatToParts`. Results are cached after the first call per locale.
543
- * Falls back to MDY (English/US convention) on any error.
544
- */
545
- function getLocaleDatePartOrder(locale) {
546
- const cached = localePartOrderCache.get(locale);
547
- if (cached !== undefined)
548
- return cached;
549
- try {
550
- const parts = new Intl.DateTimeFormat(locale, { year: "numeric", month: "2-digit", day: "2-digit" })
551
- .formatToParts(new Date(2000, 0, 15))
552
- .filter(p => p.type === "month" || p.type === "day" || p.type === "year")
553
- .map(p => p.type.charAt(0).toUpperCase())
554
- .join("");
555
- const order = parts === "DMY" ? "DMY" : parts === "YMD" ? "YMD" : "MDY";
556
- localePartOrderCache.set(locale, order);
557
- return order;
558
- }
559
- catch {
560
- return "MDY";
561
- }
562
- }
563
- /**
564
- * Converts a locale-ordered 8-digit compact string to `YYYY-MM-DD`.
565
- *
566
- * The split follows the locale's field order:
567
- * - MDY (e.g. `en`): `MMDDYYYY` → `YYYY-MM-DD`
568
- * - DMY (e.g. `de`): `DDMMYYYY` → `YYYY-MM-DD`
569
- * - YMD (e.g. `ja`): `YYYYMMDD` → `YYYY-MM-DD`
570
- *
571
- * Returns the original value unchanged for non-8-digit inputs.
572
- */
573
- const normalizeCompactDateInput = (value, locale) => {
574
- if (!COMPACT_DATE_REGEX.test(value))
575
- return value;
576
- const order = getLocaleDatePartOrder(locale ?? "en");
577
- if (order === "MDY")
578
- return `${value.slice(4, 8)}-${value.slice(0, 2)}-${value.slice(2, 4)}`;
579
- if (order === "DMY")
580
- return `${value.slice(4, 8)}-${value.slice(2, 4)}-${value.slice(0, 2)}`;
581
- return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
582
- };
583
- /**
584
- * Parses date-field keyboard input to a local-midnight {@link Date}.
585
- *
586
- * Strict `YYYY-MM-DD` is accepted first; a locale-ordered compact 8-digit string (e.g. `04132026`
587
- * in English for April 13 2026) is normalised to `YYYY-MM-DD` before that check. Otherwise the
588
- * string must round-trip with {@link formatShortDateUtil} for the given `locale`. Arbitrary
589
- * `new Date(string)`-parseable inputs that don't match the locale's format return `null` to avoid
590
- * accepting confusable formats like `03/07/2025`.
591
- *
592
- * @param value - Raw text from the input.
593
- * @param locale - Locale to compare against when the input is not `YYYY-MM-DD`.
594
- */
595
- const parseDateFieldInputForChange = (value, locale) => {
596
- const fromIso = parseYYYYMMDDUtil(normalizeCompactDateInput(value, locale));
597
- if (fromIso !== null)
598
- return fromIso;
599
- const trimmed = value.trim();
600
- if (trimmed === "")
601
- return null;
602
- const parsed = parseValidDate(trimmed);
603
- if (parsed === null)
604
- return null;
605
- const localMidnight = toDateUtil(startOfDayUtil(parsed));
606
- const formatted = formatShortDateUtil(localMidnight, locale);
607
- if (normalizeDisplayCompare(formatted) === normalizeDisplayCompare(trimmed)) {
608
- return localMidnight;
609
- }
610
- return null;
611
- };
612
- /**
613
- * Renders the stored date-field value for display:
614
- * - empty string → empty
615
- * - full canonical `YYYY-MM-DD` → locale-aware short date
616
- * - partial input (e.g. `"2025-03"`) → returned as-is so the user can keep typing
617
- *
618
- * @param stored - The current canonical value (typically from `event.target.value`).
619
- * @param locale - Locale for the display format.
620
- */
621
- const dateFieldStoredValueToDisplayString = (stored, locale) => {
622
- if (stored === "")
623
- return "";
624
- const parsed = parseYYYYMMDDUtil(stored);
625
- if (parsed !== null)
626
- return formatShortDateUtil(parsed, locale);
627
- return stored;
628
- };
629
-
630
- /**
631
- * Converts a Date instant to the canonical YYYY-MM-DD for a specific IANA time zone.
632
- * This preserves the calendar day the user selected in their local zone.
633
- */
634
- const dateToCalendarDayString = ({ date, timeZoneId }) => {
635
- if (!date || Number.isNaN(date.getTime()))
636
- return "";
637
- const resolvedTimeZoneId = timeZoneId ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
638
- return toZonedDateTimeUtil(date, resolvedTimeZoneId).toPlainDate().toString();
639
- };
640
- /**
641
- * Clamps a date to [minDate, maxDate] by comparing calendar days in a specific timezone.
642
- * Returns the original `date` when it's in range, or the relevant boundary date when out of range.
643
- */
644
- const clampDateToCalendarDayRange = ({ date, minDate, maxDate, timeZoneId, }) => {
645
- if (date === null || date === undefined)
646
- return null;
647
- const day = dateToCalendarDayString({ date, timeZoneId });
648
- if (day === "")
649
- return null;
650
- if (minDate !== undefined) {
651
- const minDay = dateToCalendarDayString({ date: minDate, timeZoneId });
652
- if (minDay !== "" && day < minDay)
653
- return minDate;
654
- }
655
- if (maxDate !== undefined) {
656
- const maxDay = dateToCalendarDayString({ date: maxDate, timeZoneId });
657
- if (maxDay !== "" && day > maxDay)
658
- return maxDate;
659
- }
660
- return date;
661
- };
662
- /**
663
- * Returns true when a date falls outside [minDate, maxDate] by calendar day in a specific timezone.
664
- */
665
- const isCalendarDayOutOfRange = ({ date, minDate, maxDate, timeZoneId, }) => {
666
- if (!date)
667
- return false;
668
- const clamped = clampDateToCalendarDayRange({ date, minDate, maxDate, timeZoneId });
669
- if (!clamped)
670
- return false;
671
- return clamped.getTime() !== date.getTime();
672
- };
673
-
674
603
  function parseToDate(v) {
675
604
  if (v === undefined || v === "")
676
605
  return undefined;
@@ -681,38 +610,63 @@ function parseToDate(v) {
681
610
  return Number.isNaN(d.getTime()) ? undefined : d;
682
611
  }
683
612
  const str = String(v);
684
- const fromField = parseYYYYMMDDUtil(str);
613
+ const fromField = parseDateFieldValue(str);
685
614
  if (fromField !== null)
686
615
  return fromField;
687
616
  const fallback = new Date(str);
688
617
  return Number.isNaN(fallback.getTime()) ? undefined : fallback;
689
618
  }
619
+ function formatToInputString(d) {
620
+ if (!d)
621
+ return "";
622
+ return Temporal.PlainDateTime.from({
623
+ year: d.getFullYear(),
624
+ month: d.getMonth() + 1,
625
+ day: d.getDate(),
626
+ })
627
+ .toPlainDate()
628
+ .toString();
629
+ }
630
+ function startOfDayMs(d) {
631
+ return toDateUtil(startOfDayUtil(d)).getTime();
632
+ }
633
+ /** Clamp a date to [minDate, maxDate]; returns the same date if in range or no bounds. */
634
+ function clampToRange(date, minDate, maxDate) {
635
+ if (date === null || date === undefined)
636
+ return null;
637
+ const dayStart = startOfDayMs(date);
638
+ if (minDate !== undefined && dayStart < startOfDayMs(minDate))
639
+ return minDate;
640
+ if (maxDate !== undefined && dayStart > startOfDayMs(maxDate))
641
+ return maxDate;
642
+ return date;
643
+ }
690
644
  /**
691
645
  * A wrapper around BaseInput with a pop-up day picker using the same calendar UI as DayPicker.
692
646
  *
693
- * The input shows a locale-aware date (short month, numeric day and year, e.g. "Mar 23, 2026" in English).
694
- * `onChange` still emits the canonical YYYY-MM-DD string for form parsing.
647
+ * The value is formatted to an ISO date string (YYYY-MM-DD)
695
648
  *
696
649
  * NOTE: If shown with a label, please use the `DateField` component instead.
697
650
  */
698
- const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: suffixProp, "data-testid": dataTestId, onBlur, ...rest }) => {
699
- const timeZoneId = Intl.DateTimeFormat().resolvedOptions().timeZone;
651
+ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: suffixProp, "data-testid": dataTestId, ...rest }) => {
700
652
  const isControlled = value !== undefined;
701
- const [internalValue, setInternalValue] = useState(() => dateToCalendarDayString({ date: parseToDate(defaultValue), timeZoneId }));
653
+ const [internalValue, setInternalValue] = useState(() => formatToInputString(parseToDate(defaultValue)));
702
654
  const [pendingDate, setPendingDate] = useState(null);
703
- const [t, i18n] = useTranslation();
704
- const locale = i18n.resolvedLanguage ?? i18n.language;
705
655
  // For controlled string value, only normalize when it's strict YYYY-MM-DD so partial input (e.g. "2025-03") is preserved.
706
656
  const resolvedValue = isControlled
707
657
  ? typeof value === "string"
708
- ? dateFieldStoredValueToDisplayString(value, locale)
709
- : dateFieldStoredValueToDisplayString(dateToCalendarDayString({ date: parseToDate(value), timeZoneId }), locale)
710
- : dateFieldStoredValueToDisplayString(internalValue, locale);
658
+ ? (() => {
659
+ const strict = parseDateFieldValue(value);
660
+ return strict !== null ? formatToInputString(strict) : value;
661
+ })()
662
+ : formatToInputString(parseToDate(value))
663
+ : internalValue;
711
664
  const selectedDate = isControlled && typeof value === "string"
712
- ? (parseYYYYMMDDUtil(value) ?? undefined)
665
+ ? (parseDateFieldValue(value) ?? undefined)
713
666
  : parseToDate(isControlled ? value : internalValue);
714
667
  const inputRef = useRef(null);
715
668
  const createInputChangeEvent = useCreateInputChangeEvent();
669
+ const [t] = useTranslation();
716
670
  useImperativeHandle(ref, () => inputRef.current ?? document.createElement("input"), []);
717
671
  const syncPendingFromValue = useCallback(() => {
718
672
  setPendingDate(selectedDate ?? null);
@@ -722,8 +676,13 @@ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: s
722
676
  return false;
723
677
  const minDate = parseToDate(min);
724
678
  const maxDate = parseToDate(max);
725
- return isCalendarDayOutOfRange({ date, minDate, maxDate, timeZoneId });
726
- }, [min, max, timeZoneId]);
679
+ const dayStart = startOfDayMs(date);
680
+ if (minDate !== undefined && dayStart < startOfDayMs(minDate))
681
+ return true;
682
+ if (maxDate !== undefined && dayStart > startOfDayMs(maxDate))
683
+ return true;
684
+ return false;
685
+ }, [min, max]);
727
686
  const handleCalendarChange = useCallback((next) => {
728
687
  setPendingDate(next);
729
688
  }, []);
@@ -740,21 +699,21 @@ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: s
740
699
  const handleApply = useCallback((closePopover) => {
741
700
  const minDate = parseToDate(min);
742
701
  const maxDate = parseToDate(max);
743
- const clamped = clampDateToCalendarDayRange({ date: pendingDate, minDate, maxDate, timeZoneId });
744
- const str = clamped ? dateToCalendarDayString({ date: clamped, timeZoneId }) : "";
702
+ const clamped = clampToRange(pendingDate, minDate, maxDate);
703
+ const str = clamped ? formatToInputString(clamped) : "";
745
704
  if (!isControlled)
746
705
  setInternalValue(str);
747
706
  onChange?.(createInputChangeEvent(str, inputRef.current));
748
707
  closePopover();
749
- }, [createInputChangeEvent, isControlled, min, max, onChange, pendingDate, timeZoneId]);
708
+ }, [createInputChangeEvent, isControlled, min, max, onChange, pendingDate]);
750
709
  const handleInputChange = useCallback((e) => {
751
710
  const raw = e.target.value;
752
- const parsed = parseDateFieldInputForChange(raw, locale);
711
+ const parsed = parseDateFieldValue(raw);
753
712
  const minDate = parseToDate(min);
754
713
  const maxDate = parseToDate(max);
755
714
  if (parsed !== null) {
756
- const clamped = clampDateToCalendarDayRange({ date: parsed, minDate, maxDate, timeZoneId });
757
- const str = clamped ? dateToCalendarDayString({ date: clamped, timeZoneId }) : "";
715
+ const clamped = clampToRange(parsed, minDate, maxDate);
716
+ const str = clamped ? formatToInputString(clamped) : "";
758
717
  if (!isControlled)
759
718
  setInternalValue(str);
760
719
  onChange?.(createInputChangeEvent(str, inputRef.current));
@@ -764,30 +723,10 @@ const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, suffix: s
764
723
  setInternalValue(raw);
765
724
  onChange?.(e);
766
725
  }
767
- }, [createInputChangeEvent, isControlled, locale, min, max, onChange, timeZoneId]);
768
- const handleBlur = useCallback((e) => {
769
- if (!onBlur)
770
- return;
771
- const storedValue = isControlled
772
- ? typeof value === "string"
773
- ? value
774
- : dateToCalendarDayString({ date: parseToDate(value), timeZoneId })
775
- : internalValue;
776
- // When the stored value is a committed calendar day (YYYY-MM-DD) we expose it on blur, even though the
777
- // displayed input value is localized.
778
- const shouldExposeStoredValue = storedValue === "" || parseYYYYMMDDUtil(storedValue) !== null;
779
- if (!shouldExposeStoredValue) {
780
- onBlur(e);
781
- return;
782
- }
783
- const prev = e.currentTarget.value;
784
- e.currentTarget.value = storedValue;
785
- onBlur(e);
786
- e.currentTarget.value = prev;
787
- }, [internalValue, isControlled, onBlur, timeZoneId, value]);
788
- return (jsxs(Popover, { onOpenStateChange: open => open && syncPendingFromValue(), placement: "bottom-start", children: [jsx(PopoverTrigger, { children: jsx("div", { className: twMerge("flex w-full min-w-0 cursor-pointer items-center", (Boolean(rest.disabled) || Boolean(rest.readOnly)) && "pointer-events-none"), children: jsx(BaseInput, { ...rest, "aria-readonly": true, className: twMerge("w-full min-w-0", rest.className), "data-testid": dataTestId ? `${dataTestId}-input` : undefined, onBlur: handleBlur, onChange: handleInputChange, placeholder: rest.placeholder ?? t("dateField.placeholder"), ref: inputRef, suffix: suffixProp ?? (jsx(Icon, { "aria-label": undefined, className: Boolean(rest.disabled) || Boolean(rest.readOnly) ? "text-neutral-500" : undefined, "data-testid": dataTestId ? `${dataTestId}-calendar` : "calendar", name: "Calendar", size: "medium", type: "solid" })), type: "text", value: resolvedValue }) }) }), jsx(PopoverContent, { children: closePopover => {
726
+ }, [createInputChangeEvent, isControlled, min, max, onChange]);
727
+ return (jsxs(Popover, { onOpenStateChange: open => open && syncPendingFromValue(), placement: "bottom-start", children: [jsx(PopoverTrigger, { children: jsx("div", { className: twMerge("flex w-full min-w-0 cursor-pointer items-center", (Boolean(rest.disabled) || Boolean(rest.readOnly)) && "pointer-events-none"), children: jsx(BaseInput, { ...rest, "aria-readonly": true, className: twMerge("w-full min-w-0", rest.className), "data-testid": dataTestId ? `${dataTestId}-input` : undefined, onChange: handleInputChange, placeholder: rest.placeholder ?? t("dateField.placeholder"), ref: inputRef, suffix: suffixProp ?? (jsx(Icon, { "aria-label": undefined, className: Boolean(rest.disabled) || Boolean(rest.readOnly) ? "text-neutral-500" : undefined, "data-testid": dataTestId ? `${dataTestId}-calendar` : "calendar", name: "Calendar", size: "medium", type: "solid" })), type: "text", value: resolvedValue }) }) }), jsx(PopoverContent, { children: closePopover => {
789
728
  const displayDate = pendingDate ?? selectedDate ?? null;
790
- return (jsxs("div", { className: twMerge("flex w-min flex-col overflow-hidden rounded-md border border-neutral-300 bg-white p-0"), children: [jsx(ReactCalendar, { allowPartialRange: true, className: twMerge("custom-day-picker", "range-picker", "p-0"), defaultActiveStartDate: displayDate ?? undefined, defaultView: "month", locale: locale, onChange: val => {
729
+ return (jsxs("div", { className: twMerge("flex w-min flex-col overflow-hidden rounded-md border border-neutral-300 bg-white p-0"), children: [jsx(ReactCalendar, { allowPartialRange: true, className: twMerge("custom-day-picker", "range-picker", "p-0"), defaultActiveStartDate: displayDate ?? undefined, defaultView: "month", onChange: val => {
791
730
  const next = val instanceof Date ? val : Array.isArray(val) ? (val[0] instanceof Date ? val[0] : null) : null;
792
731
  handleCalendarChange(next);
793
732
  }, selectRange: false, tileDisabled: tileDisabled, value: displayDate }), jsx("hr", {}), jsxs("div", { className: "flex w-full justify-between gap-2 px-4 py-3", children: [jsx(Button, { className: "mr-auto", "data-testid": dataTestId ? `${dataTestId}-clear-button` : undefined, onClick: () => handleClear(closePopover), size: "small", variant: "secondary", children: t("dateField.actions.clear") }), jsxs("div", { className: "flex gap-2", children: [jsx(Button, { "data-testid": dataTestId ? `${dataTestId}-cancel-button` : undefined, onClick: () => handleCancel(closePopover), size: "small", variant: "ghost-neutral", children: t("dateField.actions.cancel") }), jsx(Button, { "data-testid": dataTestId ? `${dataTestId}-apply-button` : undefined, onClick: () => handleApply(closePopover), size: "small", children: t("dateField.actions.apply") })] })] })] }));
@@ -2570,9 +2509,6 @@ ColorField.displayName = "ColorField";
2570
2509
 
2571
2510
  /**
2572
2511
  * The date field component is used for entering date values with a calendar picker (same UI as DayPicker).
2573
- * The input shows a localized short date; when the user commits a valid day, both `onChange` and `onBlur`
2574
- * expose `YYYY-MM-DD` on `event.target.value` (use `parseYYYYMMDDUtil` from `@trackunit/date-and-time-utils`
2575
- * for a local-midnight `Date`).
2576
2512
  *
2577
2513
  * ### When to use
2578
2514
  * Use DateField for selecting calendar dates such as birthdates, deadlines, or scheduling.
@@ -2600,13 +2536,11 @@ ColorField.displayName = "ColorField";
2600
2536
  * @example Date field with constraints
2601
2537
  * ```tsx
2602
2538
  * import { DateField } from "@trackunit/react-form-components";
2603
- * import { dateToYYYYMMDDUtil } from "@trackunit/date-and-time-utils";
2604
2539
  * import { useState } from "react";
2605
2540
  *
2606
2541
  * const BookingForm = () => {
2607
2542
  * const [checkIn, setCheckIn] = useState("");
2608
- * // Use the local calendar day, not UTC (avoids day-shift in non-UTC zones).
2609
- * const today = dateToYYYYMMDDUtil(new Date());
2543
+ * const today = new Date().toISOString().split("T")[0];
2610
2544
  *
2611
2545
  * return (
2612
2546
  * <DateField
@@ -5002,4 +4936,4 @@ const useZodValidators = () => {
5002
4936
  */
5003
4937
  setupLibraryTranslations();
5004
4938
 
5005
- export { ActionButton, BaseInput, BaseSelect, Checkbox, CheckboxField, ColorField, CreatableSelect, CreatableSelectField, DEFAULT_TIME, DateBaseInput, DateField, DropZone, DropZoneDefaultLabel, EMAIL_REGEX, EmailField, FormFieldSelectAdapter, FormGroup, Label, MultiSelectField, NumberBaseInput, NumberField, OptionCard, PasswordBaseInput, PasswordField, PhoneBaseInput, PhoneField, PhoneFieldWithController, RadioGroup, RadioGroupContext, RadioItem, Schedule, ScheduleVariant, Search, SelectField, TextAreaBaseInput, TextAreaField, TextBaseInput, TextField, TimeRange, TimeRangeField, ToggleSwitch, ToggleSwitchOption, UploadField, UploadInput, UrlField, checkIfPhoneNumberHasPlus, countryCodeToFlagEmoji, cvaAccessoriesContainer, cvaActionButton, cvaActionContainer, cvaInput$1 as cvaInput, cvaInputAddon, cvaInputBase, cvaInputBaseDisabled, cvaInputBaseInvalid, cvaInputBaseReadOnly, cvaInputBaseSize, cvaInputElement, cvaInputGroup, cvaInputItemPlacementManager, cvaInputPrefix, cvaInputSuffix, cvaLabel, cvaRadioItem, cvaSelectClearIndicator, cvaSelectContainer, cvaSelectControl, cvaSelectDropdownIconContainer, cvaSelectDropdownIndicator, cvaSelectIndicatorsContainer, cvaSelectLoadingMessage, cvaSelectMenu, cvaSelectMenuList, cvaSelectMultiValue, cvaSelectNoOptionsMessage, cvaSelectPlaceholder, cvaSelectPrefixSuffix, cvaSelectSingleValue, cvaSelectValueContainer, getCountryAbbreviation, getPhoneNumberWithPlus, isInvalidCountryCode, isInvalidPhoneNumber, isValidHEXColor, parseSchedule, phoneErrorMessage, serializeSchedule, useCreatableSelect, useCreateInputChangeEvent, useCustomComponents, useGetPhoneValidationRules, usePhoneInput, useRadioItemChecked, useSelect, useZodValidators, validateEmailAddress, validatePhoneNumber, weekDay };
4939
+ export { ActionButton, BaseInput, BaseSelect, Checkbox, CheckboxField, ColorField, CreatableSelect, CreatableSelectField, DEFAULT_TIME, DateBaseInput, DateField, DropZone, DropZoneDefaultLabel, EMAIL_REGEX, EmailField, FormFieldSelectAdapter, FormGroup, Label, MultiSelectField, NumberBaseInput, NumberField, OptionCard, PasswordBaseInput, PasswordField, PhoneBaseInput, PhoneField, PhoneFieldWithController, RadioGroup, RadioGroupContext, RadioItem, Schedule, ScheduleVariant, Search, SelectField, TextAreaBaseInput, TextAreaField, TextBaseInput, TextField, TimeRange, TimeRangeField, ToggleSwitch, ToggleSwitchOption, UploadField, UploadInput, UrlField, checkIfPhoneNumberHasPlus, countryCodeToFlagEmoji, cvaAccessoriesContainer, cvaActionButton, cvaActionContainer, cvaInput$1 as cvaInput, cvaInputAddon, cvaInputBase, cvaInputBaseDisabled, cvaInputBaseInvalid, cvaInputBaseReadOnly, cvaInputBaseSize, cvaInputElement, cvaInputGroup, cvaInputItemPlacementManager, cvaInputPrefix, cvaInputSuffix, cvaLabel, cvaRadioItem, cvaSelectClearIndicator, cvaSelectContainer, cvaSelectControl, cvaSelectDropdownIconContainer, cvaSelectDropdownIndicator, cvaSelectIndicatorsContainer, cvaSelectLoadingMessage, cvaSelectMenu, cvaSelectMenuList, cvaSelectMultiValue, cvaSelectNoOptionsMessage, cvaSelectPlaceholder, cvaSelectPrefixSuffix, cvaSelectSingleValue, cvaSelectValueContainer, dateToISODateUTC, getCountryAbbreviation, getPhoneNumberWithPlus, isInvalidCountryCode, isInvalidPhoneNumber, isValidHEXColor, parseDateFieldValue, parseSchedule, phoneErrorMessage, serializeSchedule, toISODateStringUTC, useCreatableSelect, useCreateInputChangeEvent, useCustomComponents, useGetPhoneValidationRules, usePhoneInput, useRadioItemChecked, useSelect, useZodValidators, validateEmailAddress, validatePhoneNumber, weekDay };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-form-components",
3
- "version": "1.23.2",
3
+ "version": "1.23.3",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -15,11 +15,11 @@
15
15
  "zod": "^3.25.76",
16
16
  "tailwind-merge": "^2.0.0",
17
17
  "@trackunit/css-class-variance-utilities": "1.11.117",
18
- "@trackunit/react-components": "1.22.25",
18
+ "@trackunit/react-components": "1.22.26",
19
19
  "@trackunit/ui-icons": "1.11.113",
20
20
  "@trackunit/shared-utils": "1.13.117",
21
21
  "@trackunit/ui-design-tokens": "1.11.114",
22
- "@trackunit/i18n-library-translation": "1.19.2",
22
+ "@trackunit/i18n-library-translation": "1.19.3",
23
23
  "string-ts": "^2.0.0",
24
24
  "es-toolkit": "^1.39.10"
25
25
  },
@@ -22,10 +22,9 @@ export interface DateBaseInputProps extends BaseInputExposedProps {
22
22
  /**
23
23
  * A wrapper around BaseInput with a pop-up day picker using the same calendar UI as DayPicker.
24
24
  *
25
- * The input shows a locale-aware date (short month, numeric day and year, e.g. "Mar 23, 2026" in English).
26
- * `onChange` still emits the canonical YYYY-MM-DD string for form parsing.
25
+ * The value is formatted to an ISO date string (YYYY-MM-DD)
27
26
  *
28
27
  * NOTE: If shown with a label, please use the `DateField` component instead.
29
28
  */
30
- export declare const DateBaseInput: ({ min, max, defaultValue, value, ref, onChange, suffix: suffixProp, "data-testid": dataTestId, onBlur, ...rest }: DateBaseInputProps) => ReactElement;
29
+ export declare const DateBaseInput: ({ min, max, defaultValue, value, ref, onChange, suffix: suffixProp, "data-testid": dataTestId, ...rest }: DateBaseInputProps) => ReactElement;
31
30
  export {};
@@ -10,9 +10,6 @@ export interface DateFieldProps extends DateBaseInputProps, FormGroupExposedProp
10
10
  }
11
11
  /**
12
12
  * The date field component is used for entering date values with a calendar picker (same UI as DayPicker).
13
- * The input shows a localized short date; when the user commits a valid day, both `onChange` and `onBlur`
14
- * expose `YYYY-MM-DD` on `event.target.value` (use `parseYYYYMMDDUtil` from `@trackunit/date-and-time-utils`
15
- * for a local-midnight `Date`).
16
13
  *
17
14
  * ### When to use
18
15
  * Use DateField for selecting calendar dates such as birthdates, deadlines, or scheduling.
@@ -40,13 +37,11 @@ export interface DateFieldProps extends DateBaseInputProps, FormGroupExposedProp
40
37
  * @example Date field with constraints
41
38
  * ```tsx
42
39
  * import { DateField } from "@trackunit/react-form-components";
43
- * import { dateToYYYYMMDDUtil } from "@trackunit/date-and-time-utils";
44
40
  * import { useState } from "react";
45
41
  *
46
42
  * const BookingForm = () => {
47
43
  * const [checkIn, setCheckIn] = useState("");
48
- * // Use the local calendar day, not UTC (avoids day-shift in non-UTC zones).
49
- * const today = dateToYYYYMMDDUtil(new Date());
44
+ * const today = new Date().toISOString().split("T")[0];
50
45
  *
51
46
  * return (
52
47
  * <DateField
package/src/index.d.ts CHANGED
@@ -54,6 +54,7 @@ export * from "./components/UploadField/UploadField";
54
54
  export * from "./components/UploadInput/UploadInput";
55
55
  export * from "./components/UrlField/UrlField";
56
56
  export * from "./utilities/emailUtils";
57
+ export * from "./utilities/parseDateFieldValue";
57
58
  export * from "./utilities/useCreateInputChangeEvent";
58
59
  export * from "./utilities/useGetPhoneValidationRules";
59
60
  export * from "./utilities/usePhoneInput";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Parses the value from a DateField change event (YYYY-MM-DD string) to a Date at local midnight.
3
+ * Use this when you need a Date from event.target.value to avoid timezone shifts that occur
4
+ * with `new Date(value)` (which interprets the string as UTC).
5
+ *
6
+ * @param value - The string from event.target.value (e.g. "2025-03-07" or "")
7
+ * @returns {Date | null} - Date at local midnight for the given day, or null if value is empty or invalid
8
+ */
9
+ export declare function parseDateFieldValue(value: string): Date | null;
10
+ /**
11
+ * Converts a YYYY-MM-DD string (e.g. from DateField) to an ISO string at UTC midnight for that calendar day.
12
+ * Use this when storing or sending date-only values so that the calendar day is preserved regardless of
13
+ * user timezone (unlike calling .toISOString() on a local-midnight Date, which shifts the day for UTC+).
14
+ *
15
+ * @param value - The string from event.target.value (e.g. "2025-03-07" or "")
16
+ * @returns {string | null} - "YYYY-MM-DDT00:00:00.000Z" or null if value is empty or invalid
17
+ */
18
+ export declare function toISODateStringUTC(value: string): string | null;
19
+ /**
20
+ * Converts a Date (e.g. at local midnight) to an ISO string at UTC midnight for the same calendar day.
21
+ * Use when sending a date-only value to an API that expects UTC midnight (e.g. booking date).
22
+ *
23
+ * @param date - A Date instance (typically local midnight from a date picker)
24
+ * @returns {string} "YYYY-MM-DDT00:00:00.000Z" for that calendar day
25
+ */
26
+ export declare function dateToISODateUTC(date: Date): string;
@@ -1,25 +0,0 @@
1
- interface DateToCalendarDayStringParams {
2
- readonly date: Date | undefined;
3
- readonly timeZoneId: string | undefined;
4
- }
5
- /**
6
- * Converts a Date instant to the canonical YYYY-MM-DD for a specific IANA time zone.
7
- * This preserves the calendar day the user selected in their local zone.
8
- */
9
- export declare const dateToCalendarDayString: ({ date, timeZoneId }: DateToCalendarDayStringParams) => string;
10
- interface DateRangeByCalendarDayParams {
11
- readonly date: Date | null | undefined;
12
- readonly minDate: Date | undefined;
13
- readonly maxDate: Date | undefined;
14
- readonly timeZoneId: string | undefined;
15
- }
16
- /**
17
- * Clamps a date to [minDate, maxDate] by comparing calendar days in a specific timezone.
18
- * Returns the original `date` when it's in range, or the relevant boundary date when out of range.
19
- */
20
- export declare const clampDateToCalendarDayRange: ({ date, minDate, maxDate, timeZoneId, }: DateRangeByCalendarDayParams) => Date | null;
21
- /**
22
- * Returns true when a date falls outside [minDate, maxDate] by calendar day in a specific timezone.
23
- */
24
- export declare const isCalendarDayOutOfRange: ({ date, minDate, maxDate, timeZoneId, }: DateRangeByCalendarDayParams) => boolean;
25
- export {};
@@ -1,23 +0,0 @@
1
- /**
2
- * Parses date-field keyboard input to a local-midnight {@link Date}.
3
- *
4
- * Strict `YYYY-MM-DD` is accepted first; a locale-ordered compact 8-digit string (e.g. `04132026`
5
- * in English for April 13 2026) is normalised to `YYYY-MM-DD` before that check. Otherwise the
6
- * string must round-trip with {@link formatShortDateUtil} for the given `locale`. Arbitrary
7
- * `new Date(string)`-parseable inputs that don't match the locale's format return `null` to avoid
8
- * accepting confusable formats like `03/07/2025`.
9
- *
10
- * @param value - Raw text from the input.
11
- * @param locale - Locale to compare against when the input is not `YYYY-MM-DD`.
12
- */
13
- export declare const parseDateFieldInputForChange: (value: string, locale: string | undefined) => Date | null;
14
- /**
15
- * Renders the stored date-field value for display:
16
- * - empty string → empty
17
- * - full canonical `YYYY-MM-DD` → locale-aware short date
18
- * - partial input (e.g. `"2025-03"`) → returned as-is so the user can keep typing
19
- *
20
- * @param stored - The current canonical value (typically from `event.target.value`).
21
- * @param locale - Locale for the display format.
22
- */
23
- export declare const dateFieldStoredValueToDisplayString: (stored: string, locale: string | undefined) => string;