@trackunit/react-date-and-time-components 2.17.7 → 2.17.8

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
@@ -580,6 +580,21 @@ const useParseTemporalPeriodFromText = () => {
580
580
  }, [t, findTemporalDirection, findTemporalUnit]);
581
581
  };
582
582
 
583
+ /**
584
+ * Returns a function that checks if two dates fall on the same calendar day
585
+ * in the user's current timezone.
586
+ */
587
+ const useSameCalendarDay = () => {
588
+ const { startOf } = reactDateAndTimeHooks.useDateAndTime();
589
+ return react.useCallback((left, right) => {
590
+ if (left === undefined || right === undefined) {
591
+ return false;
592
+ }
593
+ // Compare the start-of-day timestamps in the user's timezone
594
+ return startOf(left, "day").getTime() === startOf(right, "day").getTime();
595
+ }, [startOf]);
596
+ };
597
+
583
598
  const DEFAULT_DIRECTIONS = ["last", "next"];
584
599
  const DEFAULT_COUNT = 7;
585
600
  /**
@@ -657,6 +672,11 @@ const isTemporalDirection = (direction) => {
657
672
  return typeof direction === "string" && TEMPORAL_DIRECTIONS.includes(direction);
658
673
  };
659
674
 
675
+ /**
676
+ * Checks if two date ranges are identical by comparing their start and end timestamps.
677
+ */
678
+ const sameDateRange = (left, right) => left?.start?.getTime() === right?.start?.getTime() && left?.end?.getTime() === right?.end?.getTime();
679
+
660
680
  /**
661
681
  * DayRangeSelect is a popover-based date range picker with preset temporal options (e.g., "Last 7 days", "Next 30 days")
662
682
  * and an optional custom calendar picker. Users can search for presets by typing natural language queries like "last 3 months".
@@ -688,6 +708,7 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
688
708
  const filterTemporalPeriodsInRange = useFilterTemporalPeriodsInRange();
689
709
  const formatTemporalPeriodLabel = useFormatTemporalPeriodLabel();
690
710
  const calculateDateRange = useCalculateDateRange();
711
+ const sameCalendarDay = useSameCalendarDay();
691
712
  const calculateCustomDateRangeDisabledDays = useCalculateCustomDateRangeDisabledDays();
692
713
  const customDateRangeDisabledDays = react.useMemo(() => calculateCustomDateRangeDisabledDays({ maxDaysInRange, allowedDirection, disabledDays }), [calculateCustomDateRangeDisabledDays, maxDaysInRange, allowedDirection, disabledDays]);
693
714
  const defaultTemporalPeriods = react.useMemo(() => {
@@ -707,6 +728,34 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
707
728
  // input from uncontrolled to controlled, which React warns about.
708
729
  const [dateRangeSearchValue, setDateRangeSearchValue] = react.useState("");
709
730
  const [selectedRange, setSelectedRange] = react.useState();
731
+ const [emittedDateRange, setEmittedDateRange] = react.useState();
732
+ const controlledDateRange = react.useMemo(() => {
733
+ if (!selectedDateRange) {
734
+ return undefined;
735
+ }
736
+ if (isTemporalPeriod(selectedDateRange)) {
737
+ return calculateDateRange(selectedDateRange);
738
+ }
739
+ return selectedDateRange;
740
+ }, [calculateDateRange, selectedDateRange]);
741
+ const [trackedDateRange, setTrackedDateRange] = react.useState(controlledDateRange);
742
+ // Parents often normalize the range we just emitted (start of day, or "now" when the range ends today)
743
+ // before passing it back. That write-back is still the user's selection.
744
+ const isEchoOfEmission = emittedDateRange !== undefined &&
745
+ controlledDateRange !== undefined &&
746
+ sameCalendarDay(emittedDateRange.start, controlledDateRange.start) &&
747
+ sameCalendarDay(emittedDateRange.end, controlledDateRange.end);
748
+ if (!sameDateRange(trackedDateRange, controlledDateRange)) {
749
+ setTrackedDateRange(controlledDateRange);
750
+ if (emittedDateRange) {
751
+ setEmittedDateRange(undefined);
752
+ }
753
+ if (!isEchoOfEmission && !sameDateRange(selectedRange?.dateRange, controlledDateRange)) {
754
+ setDateRangeSearchValue("");
755
+ setTemporalPeriods(defaultTemporalPeriods);
756
+ setSelectedRange(undefined);
757
+ }
758
+ }
710
759
  const currentSelectedRange = react.useMemo(() => {
711
760
  // Internal selected range takes precedence
712
761
  if (selectedRange) {
@@ -758,12 +807,14 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
758
807
  const handleReset = react.useCallback(() => {
759
808
  setTemporalPeriods(defaultTemporalPeriods);
760
809
  setSelectedRange(undefined);
810
+ setEmittedDateRange(undefined);
761
811
  onRangeSelect(undefined);
762
812
  handleDateRangeSearch("");
763
813
  }, [handleDateRangeSearch, onRangeSelect, defaultTemporalPeriods]);
764
814
  const handleOptionsSelect = react.useCallback((value, closeMenu) => {
765
815
  closeMenu();
766
816
  setSelectedRange(value);
817
+ setEmittedDateRange(value.dateRange);
767
818
  onRangeSelect(value);
768
819
  }, [onRangeSelect]);
769
820
  const handleCustomDateRangeSelect = react.useCallback((dateRange, closeMenu) => {
@@ -777,9 +828,20 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
777
828
  temporalPeriod: undefined,
778
829
  };
779
830
  setSelectedRange(_selectedDateRange);
831
+ setEmittedDateRange(_selectedDateRange.dateRange);
780
832
  onRangeSelect(_selectedDateRange);
781
833
  }, [onRangeSelect, handleReset]);
782
- return (jsxRuntime.jsxs(reactComponents.Popover, { onOpenStateChange: state => !state && setIsCustomDateRangeOpen(false), placement: popoverPlacement, children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { ref: ref, children: !actionButton ? (jsxRuntime.jsx(reactComponents.Button, { className: cvaTriggerButton({ active: !!selectedDateRange, className }), "data-testid": dataTestId, disabled: disabled, fullWidth: fullWidth, prefix: jsxRuntime.jsx(reactComponents.Icon, { ariaLabel: t("input.icon.tooltip.calendar"), color: disabled ? "neutral" : selectedDateRange ? "primary" : undefined, name: "Calendar", size: size === "large" ? "medium" : "small" }), size: size, style: style, variant: "secondary", children: popoverTriggerLabel ?? t("trigger.selectDateRange") })) : (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, style: style, children: actionButton })) }), jsxRuntime.jsx(reactComponents.PopoverContent, { "data-testid": dataTestId ? `${dataTestId}-popover-content` : undefined, children: closeMenu => {
834
+ return (jsxRuntime.jsxs(reactComponents.Popover, { onOpenStateChange: isOpen => {
835
+ if (isOpen) {
836
+ return;
837
+ }
838
+ setIsCustomDateRangeOpen(false);
839
+ // Clear emitted range so a later external change landing on the same
840
+ // calendar days is not mistakenly treated as an echo of the user's selection.
841
+ // Note: search and periods are NOT cleared on close (original behavior).
842
+ // They are only cleared when the controlled range changes externally.
843
+ setEmittedDateRange(undefined);
844
+ }, placement: popoverPlacement, children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { ref: ref, children: !actionButton ? (jsxRuntime.jsx(reactComponents.Button, { className: cvaTriggerButton({ active: !!selectedDateRange, className }), "data-testid": dataTestId, disabled: disabled, fullWidth: fullWidth, prefix: jsxRuntime.jsx(reactComponents.Icon, { ariaLabel: t("input.icon.tooltip.calendar"), color: disabled ? "neutral" : selectedDateRange ? "primary" : undefined, name: "Calendar", size: size === "large" ? "medium" : "small" }), size: size, style: style, variant: "secondary", children: popoverTriggerLabel ?? t("trigger.selectDateRange") })) : (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, style: style, children: actionButton })) }), jsxRuntime.jsx(reactComponents.PopoverContent, { "data-testid": dataTestId ? `${dataTestId}-popover-content` : undefined, children: closeMenu => {
783
845
  return !isCustomDateRangeOpen ? (jsxRuntime.jsxs(reactComponents.MenuContent, { className: "min-w-[280px]", children: [showPopoverHeader ? (jsxRuntime.jsxs("div", { children: [jsxRuntime.jsxs("div", { className: "flex flex-col gap-1", children: [showDateRangeSearch ? (jsxRuntime.jsx(reactFormComponents.Search, { className: "w-full", "data-testid": dataTestId ? `${dataTestId}-search-input` : undefined, onChange: event => handleDateRangeSearch(event.target.value), onClear: () => handleDateRangeSearch(""), placeholder: allowedDirection === "last"
784
846
  ? t("input.placeholder.customRange.last")
785
847
  : t("input.placeholder.customRange.next"), value: dateRangeSearchValue })) : null, showResetButton ? (jsxRuntime.jsx(reactComponents.Button, { className: "ml-auto", "data-testid": dataTestId ? `${dataTestId}-reset-button` : undefined, disabled: !selectedDateRange, onClick: handleReset, size: "small", variant: "ghost", children: t("layout.actions.reset") })) : null] }), jsxRuntime.jsx("hr", { className: "my-1 border-neutral-200", role: "separator" })] })) : null, jsxRuntime.jsx(DayRangeSelectOptions, { "data-testid": dataTestId ? `${dataTestId}-options` : undefined, onSelect: value => handleOptionsSelect(value, closeMenu), selectedDateRange: currentSelectedRange, temporalPeriods: temporalPeriods }), showCustomDateRangeOption ? (jsxRuntime.jsxs("section", { children: [jsxRuntime.jsx(reactComponents.MenuDivider, {}), jsxRuntime.jsx(reactComponents.MenuItem, { "data-testid": dataTestId ? `${dataTestId}-custom-range` : undefined, label: t("trigger.customRange"), onClick: () => setIsCustomDateRangeOpen(true), selected: isCustomDateRangeSelected, suffix: jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [isCustomDateRangeSelected ? jsxRuntime.jsx(reactComponents.Icon, { color: "primary", name: "Check", size: "small" }) : null, jsxRuntime.jsx(reactComponents.Icon, { name: "ChevronRight", size: "small" })] }) })] })) : null] })) : (jsxRuntime.jsx(reactComponents.Card, { children: jsxRuntime.jsx(DayRangePicker, { cancelButtonLabel: t("layout.actions.back"), disabledDays: customDateRangeDisabledDays, language: "en", onClose: () => setIsCustomDateRangeOpen(false), onRangeSelect: dateRange => handleCustomDateRangeSelect(dateRange
package/index.esm.js CHANGED
@@ -578,6 +578,21 @@ const useParseTemporalPeriodFromText = () => {
578
578
  }, [t, findTemporalDirection, findTemporalUnit]);
579
579
  };
580
580
 
581
+ /**
582
+ * Returns a function that checks if two dates fall on the same calendar day
583
+ * in the user's current timezone.
584
+ */
585
+ const useSameCalendarDay = () => {
586
+ const { startOf } = useDateAndTime();
587
+ return useCallback((left, right) => {
588
+ if (left === undefined || right === undefined) {
589
+ return false;
590
+ }
591
+ // Compare the start-of-day timestamps in the user's timezone
592
+ return startOf(left, "day").getTime() === startOf(right, "day").getTime();
593
+ }, [startOf]);
594
+ };
595
+
581
596
  const DEFAULT_DIRECTIONS = ["last", "next"];
582
597
  const DEFAULT_COUNT = 7;
583
598
  /**
@@ -655,6 +670,11 @@ const isTemporalDirection = (direction) => {
655
670
  return typeof direction === "string" && TEMPORAL_DIRECTIONS.includes(direction);
656
671
  };
657
672
 
673
+ /**
674
+ * Checks if two date ranges are identical by comparing their start and end timestamps.
675
+ */
676
+ const sameDateRange = (left, right) => left?.start?.getTime() === right?.start?.getTime() && left?.end?.getTime() === right?.end?.getTime();
677
+
658
678
  /**
659
679
  * DayRangeSelect is a popover-based date range picker with preset temporal options (e.g., "Last 7 days", "Next 30 days")
660
680
  * and an optional custom calendar picker. Users can search for presets by typing natural language queries like "last 3 months".
@@ -686,6 +706,7 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
686
706
  const filterTemporalPeriodsInRange = useFilterTemporalPeriodsInRange();
687
707
  const formatTemporalPeriodLabel = useFormatTemporalPeriodLabel();
688
708
  const calculateDateRange = useCalculateDateRange();
709
+ const sameCalendarDay = useSameCalendarDay();
689
710
  const calculateCustomDateRangeDisabledDays = useCalculateCustomDateRangeDisabledDays();
690
711
  const customDateRangeDisabledDays = useMemo(() => calculateCustomDateRangeDisabledDays({ maxDaysInRange, allowedDirection, disabledDays }), [calculateCustomDateRangeDisabledDays, maxDaysInRange, allowedDirection, disabledDays]);
691
712
  const defaultTemporalPeriods = useMemo(() => {
@@ -705,6 +726,34 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
705
726
  // input from uncontrolled to controlled, which React warns about.
706
727
  const [dateRangeSearchValue, setDateRangeSearchValue] = useState("");
707
728
  const [selectedRange, setSelectedRange] = useState();
729
+ const [emittedDateRange, setEmittedDateRange] = useState();
730
+ const controlledDateRange = useMemo(() => {
731
+ if (!selectedDateRange) {
732
+ return undefined;
733
+ }
734
+ if (isTemporalPeriod(selectedDateRange)) {
735
+ return calculateDateRange(selectedDateRange);
736
+ }
737
+ return selectedDateRange;
738
+ }, [calculateDateRange, selectedDateRange]);
739
+ const [trackedDateRange, setTrackedDateRange] = useState(controlledDateRange);
740
+ // Parents often normalize the range we just emitted (start of day, or "now" when the range ends today)
741
+ // before passing it back. That write-back is still the user's selection.
742
+ const isEchoOfEmission = emittedDateRange !== undefined &&
743
+ controlledDateRange !== undefined &&
744
+ sameCalendarDay(emittedDateRange.start, controlledDateRange.start) &&
745
+ sameCalendarDay(emittedDateRange.end, controlledDateRange.end);
746
+ if (!sameDateRange(trackedDateRange, controlledDateRange)) {
747
+ setTrackedDateRange(controlledDateRange);
748
+ if (emittedDateRange) {
749
+ setEmittedDateRange(undefined);
750
+ }
751
+ if (!isEchoOfEmission && !sameDateRange(selectedRange?.dateRange, controlledDateRange)) {
752
+ setDateRangeSearchValue("");
753
+ setTemporalPeriods(defaultTemporalPeriods);
754
+ setSelectedRange(undefined);
755
+ }
756
+ }
708
757
  const currentSelectedRange = useMemo(() => {
709
758
  // Internal selected range takes precedence
710
759
  if (selectedRange) {
@@ -756,12 +805,14 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
756
805
  const handleReset = useCallback(() => {
757
806
  setTemporalPeriods(defaultTemporalPeriods);
758
807
  setSelectedRange(undefined);
808
+ setEmittedDateRange(undefined);
759
809
  onRangeSelect(undefined);
760
810
  handleDateRangeSearch("");
761
811
  }, [handleDateRangeSearch, onRangeSelect, defaultTemporalPeriods]);
762
812
  const handleOptionsSelect = useCallback((value, closeMenu) => {
763
813
  closeMenu();
764
814
  setSelectedRange(value);
815
+ setEmittedDateRange(value.dateRange);
765
816
  onRangeSelect(value);
766
817
  }, [onRangeSelect]);
767
818
  const handleCustomDateRangeSelect = useCallback((dateRange, closeMenu) => {
@@ -775,9 +826,20 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
775
826
  temporalPeriod: undefined,
776
827
  };
777
828
  setSelectedRange(_selectedDateRange);
829
+ setEmittedDateRange(_selectedDateRange.dateRange);
778
830
  onRangeSelect(_selectedDateRange);
779
831
  }, [onRangeSelect, handleReset]);
780
- return (jsxs(Popover, { onOpenStateChange: state => !state && setIsCustomDateRangeOpen(false), placement: popoverPlacement, children: [jsx(PopoverTrigger, { ref: ref, children: !actionButton ? (jsx(Button, { className: cvaTriggerButton({ active: !!selectedDateRange, className }), "data-testid": dataTestId, disabled: disabled, fullWidth: fullWidth, prefix: jsx(Icon, { ariaLabel: t("input.icon.tooltip.calendar"), color: disabled ? "neutral" : selectedDateRange ? "primary" : undefined, name: "Calendar", size: size === "large" ? "medium" : "small" }), size: size, style: style, variant: "secondary", children: popoverTriggerLabel ?? t("trigger.selectDateRange") })) : (jsx("div", { className: className, "data-testid": dataTestId, style: style, children: actionButton })) }), jsx(PopoverContent, { "data-testid": dataTestId ? `${dataTestId}-popover-content` : undefined, children: closeMenu => {
832
+ return (jsxs(Popover, { onOpenStateChange: isOpen => {
833
+ if (isOpen) {
834
+ return;
835
+ }
836
+ setIsCustomDateRangeOpen(false);
837
+ // Clear emitted range so a later external change landing on the same
838
+ // calendar days is not mistakenly treated as an echo of the user's selection.
839
+ // Note: search and periods are NOT cleared on close (original behavior).
840
+ // They are only cleared when the controlled range changes externally.
841
+ setEmittedDateRange(undefined);
842
+ }, placement: popoverPlacement, children: [jsx(PopoverTrigger, { ref: ref, children: !actionButton ? (jsx(Button, { className: cvaTriggerButton({ active: !!selectedDateRange, className }), "data-testid": dataTestId, disabled: disabled, fullWidth: fullWidth, prefix: jsx(Icon, { ariaLabel: t("input.icon.tooltip.calendar"), color: disabled ? "neutral" : selectedDateRange ? "primary" : undefined, name: "Calendar", size: size === "large" ? "medium" : "small" }), size: size, style: style, variant: "secondary", children: popoverTriggerLabel ?? t("trigger.selectDateRange") })) : (jsx("div", { className: className, "data-testid": dataTestId, style: style, children: actionButton })) }), jsx(PopoverContent, { "data-testid": dataTestId ? `${dataTestId}-popover-content` : undefined, children: closeMenu => {
781
843
  return !isCustomDateRangeOpen ? (jsxs(MenuContent, { className: "min-w-[280px]", children: [showPopoverHeader ? (jsxs("div", { children: [jsxs("div", { className: "flex flex-col gap-1", children: [showDateRangeSearch ? (jsx(Search, { className: "w-full", "data-testid": dataTestId ? `${dataTestId}-search-input` : undefined, onChange: event => handleDateRangeSearch(event.target.value), onClear: () => handleDateRangeSearch(""), placeholder: allowedDirection === "last"
782
844
  ? t("input.placeholder.customRange.last")
783
845
  : t("input.placeholder.customRange.next"), value: dateRangeSearchValue })) : null, showResetButton ? (jsx(Button, { className: "ml-auto", "data-testid": dataTestId ? `${dataTestId}-reset-button` : undefined, disabled: !selectedDateRange, onClick: handleReset, size: "small", variant: "ghost", children: t("layout.actions.reset") })) : null] }), jsx("hr", { className: "my-1 border-neutral-200", role: "separator" })] })) : null, jsx(DayRangeSelectOptions, { "data-testid": dataTestId ? `${dataTestId}-options` : undefined, onSelect: value => handleOptionsSelect(value, closeMenu), selectedDateRange: currentSelectedRange, temporalPeriods: temporalPeriods }), showCustomDateRangeOption ? (jsxs("section", { children: [jsx(MenuDivider, {}), jsx(MenuItem, { "data-testid": dataTestId ? `${dataTestId}-custom-range` : undefined, label: t("trigger.customRange"), onClick: () => setIsCustomDateRangeOpen(true), selected: isCustomDateRangeSelected, suffix: jsxs(Fragment, { children: [isCustomDateRangeSelected ? jsx(Icon, { color: "primary", name: "Check", size: "small" }) : null, jsx(Icon, { name: "ChevronRight", size: "small" })] }) })] })) : null] })) : (jsx(Card, { children: jsx(DayRangePicker, { cancelButtonLabel: t("layout.actions.back"), disabledDays: customDateRangeDisabledDays, language: "en", onClose: () => setIsCustomDateRangeOpen(false), onRangeSelect: dateRange => handleCustomDateRangeSelect(dateRange
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-date-and-time-components",
3
- "version": "2.17.7",
3
+ "version": "2.17.8",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns a function that checks if two dates fall on the same calendar day
3
+ * in the user's current timezone.
4
+ */
5
+ export declare const useSameCalendarDay: () => (left: Date | undefined, right: Date | undefined) => boolean;
@@ -0,0 +1,5 @@
1
+ import { DateRange } from "@trackunit/date-and-time-utils";
2
+ /**
3
+ * Checks if two date ranges are identical by comparing their start and end timestamps.
4
+ */
5
+ export declare const sameDateRange: (left: DateRange | undefined, right: DateRange | undefined) => boolean;