@trackunit/react-date-and-time-components 2.17.33 → 2.17.34

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
@@ -231,6 +231,66 @@ const DayPicker = ({ onDaySelect, disabledDays, selectedDay, language, className
231
231
  }, value: selectedDay ?? null }) }));
232
232
  };
233
233
 
234
+ /**
235
+ * Checks if two date ranges are identical by comparing their start and end timestamps.
236
+ */
237
+ const sameDateRange = (left, right) => left?.start?.getTime() === right?.start?.getTime() && left?.end?.getTime() === right?.end?.getTime();
238
+
239
+ const emptyRange = { start: undefined, end: undefined };
240
+ const isSameLocalDay = (left, right) => left === right || (left !== undefined && right !== undefined && left.toDateString() === right.toDateString());
241
+ /** react-calendar picks days in local time, so compare days in local time too. */
242
+ const coversSameDays = (left, right) => isSameLocalDay(left.start, right.start) && isSameLocalDay(left.end, right.end);
243
+ const endOfLocalDay = (date) => {
244
+ const end = new Date(date);
245
+ end.setHours(23, 59, 59, 999);
246
+ return end;
247
+ };
248
+ const remount = (state) => ({
249
+ ...state,
250
+ calendarKey: state.calendarKey + 1,
251
+ });
252
+ /** The picker's state when it first renders with the given `selectedDays`. */
253
+ const initDayRangePickerState = (selectedDays) => ({
254
+ range: selectedDays ?? emptyRange,
255
+ trackedSelectedDays: selectedDays,
256
+ viewedMonth: undefined,
257
+ calendarKey: 0,
258
+ });
259
+ /**
260
+ * State transitions of `DayRangePicker`. Every transition that resets the range remounts the
261
+ * calendar, so a half-picked day never survives it.
262
+ */
263
+ const dayRangePickerReducer = (state, action) => {
264
+ switch (action.type) {
265
+ case "daySelected":
266
+ // A first click selects the whole day, the same range react-calendar returns when it is clicked twice.
267
+ return { ...state, range: { start: action.start, end: action.end ?? endOfLocalDay(action.start) } };
268
+ case "cleared":
269
+ return remount({ ...state, range: emptyRange });
270
+ case "cancelled":
271
+ return remount({ ...state, range: state.trackedSelectedDays ?? emptyRange });
272
+ case "monthViewed":
273
+ return { ...state, viewedMonth: action.month };
274
+ case "selectedDaysChanged": {
275
+ const { selectedDays } = action;
276
+ if (!selectedDays) {
277
+ return { ...state, trackedSelectedDays: undefined };
278
+ }
279
+ // A caller that keeps the picker open may pass back the applied range rounded to whole days.
280
+ // That is the same selection, so the range and the month on screen stay.
281
+ const keepRange = coversSameDays(selectedDays, state.range);
282
+ return remount({
283
+ ...state,
284
+ trackedSelectedDays: selectedDays,
285
+ range: keepRange ? state.range : selectedDays,
286
+ viewedMonth: keepRange ? state.viewedMonth : undefined,
287
+ });
288
+ }
289
+ default:
290
+ return state;
291
+ }
292
+ };
293
+
234
294
  /**
235
295
  * DayRangePicker renders an interactive calendar that allows the user to select a start and end date.
236
296
  * It supports disabled days, timezone-aware display, locale configuration, and an optional cancel button.
@@ -259,38 +319,25 @@ const DayPicker = ({ onDaySelect, disabledDays, selectedDay, language, className
259
319
  * @returns {ReactElement} DayRangePicker component
260
320
  */
261
321
  const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, "data-testid": dataTestId, language, className, style, classNameCalendar, timezone: _timezone, cancelButtonLabel, onClose, ref, }) => {
262
- const [newRange, setNewRange] = react.useState(selectedDays ?? { start: undefined, end: undefined });
322
+ const [{ range: newRange, trackedSelectedDays, viewedMonth, calendarKey }, dispatch] = react.useReducer(dayRangePickerReducer, selectedDays, initDayRangePickerState);
263
323
  const [t] = useTranslation();
264
324
  const { subtract } = reactDateAndTimeHooks.useDateAndTime();
265
325
  const calculateDateRange = useCalculateDateRange();
266
- react.useEffect(() => {
267
- if (selectedDays) {
268
- setNewRange(selectedDays);
269
- }
270
- }, [selectedDays]);
271
- const handleOnRangeSelect = react.useCallback((range) => {
272
- if (range && range.start) {
273
- if (range.end === undefined) {
274
- setNewRange({ start: range.start, end: range.start });
275
- }
276
- else {
277
- setNewRange({ start: range.start, end: range.end });
278
- }
279
- }
280
- else {
281
- setNewRange({ start: undefined, end: undefined });
282
- }
283
- }, []);
326
+ if (!sameDateRange(trackedSelectedDays, selectedDays)) {
327
+ dispatch({ type: "selectedDaysChanged", selectedDays });
328
+ }
284
329
  const handleCancel = react.useCallback(() => {
285
- if (selectedDays) {
286
- setNewRange(selectedDays);
287
- }
330
+ dispatch({ type: "cancelled" });
288
331
  if (onClose) {
289
332
  onClose();
290
333
  }
291
- }, [selectedDays, onClose]);
292
- const clearSelectedDays = () => {
293
- setNewRange({ start: undefined, end: undefined });
334
+ }, [onClose]);
335
+ // The year and decade views start on January 1, which isn't a month the user was looking at.
336
+ // Picking January from the year view keeps January 1 as the start date, so only the view changes.
337
+ const trackViewedMonth = ({ activeStartDate, view }) => {
338
+ if (view === "month") {
339
+ dispatch({ type: "monthViewed", month: activeStartDate ?? undefined });
340
+ }
294
341
  };
295
342
  const handleApply = react.useCallback(() => {
296
343
  if (onRangeSelect) {
@@ -300,11 +347,11 @@ const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, "data-testi
300
347
  onClose();
301
348
  }
302
349
  }, [onRangeSelect, newRange, onClose]);
303
- return (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("flex", "w-min", "flex-col", "overflow-hidden", "rounded-md", "border", "border-neutral-300", "bg-white", className), "data-testid": dataTestId, ref: ref, style: style, children: [jsxRuntime.jsx(Calendar, { allowPartialRange: true, className: tailwindMerge.twMerge("custom-day-picker", "range-picker", classNameCalendar), locale: language, onChange: value => {
304
- if (Array.isArray(value) && value[0] && value[1]) {
305
- handleOnRangeSelect({ start: value[0], end: value[1] });
350
+ return (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("flex", "w-min", "flex-col", "overflow-hidden", "rounded-md", "border", "border-neutral-300", "bg-white", className), "data-testid": dataTestId, ref: ref, style: style, children: [jsxRuntime.jsx(Calendar, { allowPartialRange: true, className: tailwindMerge.twMerge("custom-day-picker", "range-picker", classNameCalendar), defaultActiveStartDate: viewedMonth ?? trackedSelectedDays?.start, locale: language, onActiveStartDateChange: trackViewedMonth, onChange: value => {
351
+ if (Array.isArray(value) && value[0]) {
352
+ dispatch({ type: "daySelected", start: value[0], end: value[1] ?? undefined });
306
353
  }
307
- }, selectRange: true, tileDisabled: ({ date, view }) => {
354
+ }, onViewChange: trackViewedMonth, selectRange: true, tileDisabled: ({ date, view }) => {
308
355
  if (view === "month") {
309
356
  if (typeof disabledDays === "function") {
310
357
  return disabledDays(date);
@@ -334,7 +381,7 @@ const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, "data-testi
334
381
  }, value:
335
382
  // type is wrong here if we add array of 2 elements if end is undefined and set to null it will not highlight the start date
336
383
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
337
- newRange.end ? [newRange.start ?? null, newRange.end ?? null] : (newRange.start ?? null) }), 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": "range-popover-clear-button", onClick: clearSelectedDays, size: "small", variant: "secondary", children: t("layout.actions.clear") }), jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx(reactComponents.Button, { "data-testid": "range-popover-cancel-button", onClick: () => handleCancel(), size: "small", variant: "ghost-neutral", children: cancelButtonLabel ? cancelButtonLabel : t("layout.actions.cancel") }), jsxRuntime.jsx(reactComponents.Button, { "data-testid": "range-popover-apply-button", disabled: !newRange.start || !newRange.end, onClick: () => handleApply(), size: "small", children: t("layout.actions.apply") })] })] })] }));
384
+ newRange.end ? [newRange.start ?? null, newRange.end ?? null] : (newRange.start ?? null) }, calendarKey), 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": "range-popover-clear-button", onClick: () => dispatch({ type: "cleared" }), size: "small", variant: "secondary", children: t("layout.actions.clear") }), jsxRuntime.jsxs("div", { children: [jsxRuntime.jsx(reactComponents.Button, { "data-testid": "range-popover-cancel-button", onClick: () => handleCancel(), size: "small", variant: "ghost-neutral", children: cancelButtonLabel ? cancelButtonLabel : t("layout.actions.cancel") }), jsxRuntime.jsx(reactComponents.Button, { "data-testid": "range-popover-apply-button", disabled: !newRange.start || !newRange.end, onClick: () => handleApply(), size: "small", children: t("layout.actions.apply") })] })] })] }));
338
385
  };
339
386
 
340
387
  /**
@@ -505,6 +552,54 @@ const useFilterTemporalPeriodsInRange = () => {
505
552
  }, [getDayCountInRange]);
506
553
  };
507
554
 
555
+ /**
556
+ * Format a label representing the provided date range.
557
+ *
558
+ * @example
559
+ * formatCustomDateRangeLabel({ from: new Date("2025-06-16"), to: new Date("2025-06-17") }); // "June 16, 2025 - June 17, 2025"
560
+ */
561
+ const formatCustomDateRangeLabel = (dateRange) => {
562
+ return `${dateAndTimeUtils.formatDateUtil(dateRange.start ?? new Date(), {
563
+ dateFormat: "medium",
564
+ selectFormat: "dateOnly",
565
+ })} - ${dateAndTimeUtils.formatDateUtil(dateRange.end ?? new Date(), { dateFormat: "medium", selectFormat: "dateOnly" })}`;
566
+ };
567
+
568
+ /**
569
+ * Returns a function that checks if two dates fall on the same calendar day
570
+ * in the user's current timezone.
571
+ */
572
+ const useSameCalendarDay = () => {
573
+ const { startOf } = reactDateAndTimeHooks.useDateAndTime();
574
+ return react.useCallback((left, right) => {
575
+ if (left === undefined || right === undefined) {
576
+ return false;
577
+ }
578
+ // Compare the start-of-day timestamps in the user's timezone
579
+ return startOf(left, "day").getTime() === startOf(right, "day").getTime();
580
+ }, [startOf]);
581
+ };
582
+
583
+ /**
584
+ * Format a label representing a custom date range.
585
+ * A range that starts and ends on the same calendar day is shown as that one date.
586
+ * It is never shown as "Today": that label belongs to the Today preset.
587
+ *
588
+ * @example
589
+ * const formatLabel = useFormatCustomDateRangeLabel();
590
+ * formatLabel({ start: new Date("2025-01-15T08:00Z"), end: new Date("2025-01-15T20:00Z") }); // Jan 15, 2025
591
+ * formatLabel({ start: new Date("2025-01-01"), end: new Date("2025-01-02") }); // Jan 1, 2025 - Jan 2, 2025
592
+ */
593
+ const useFormatCustomDateRangeLabel = () => {
594
+ const isSameCalendarDay = useSameCalendarDay();
595
+ return react.useCallback((dateRange) => {
596
+ if (dateRange.start && isSameCalendarDay(dateRange.start, dateRange.end)) {
597
+ return dateAndTimeUtils.formatDateUtil(dateRange.start, { dateFormat: "medium", selectFormat: "dateOnly" });
598
+ }
599
+ return formatCustomDateRangeLabel(dateRange);
600
+ }, [isSameCalendarDay]);
601
+ };
602
+
508
603
  /**
509
604
  * Find the TemporalDirection type matching the value.
510
605
  */
@@ -580,21 +675,6 @@ const useParseTemporalPeriodFromText = () => {
580
675
  }, [t, findTemporalDirection, findTemporalUnit]);
581
676
  };
582
677
 
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
-
598
678
  const DEFAULT_DIRECTIONS = ["last", "next"];
599
679
  const DEFAULT_COUNT = 7;
600
680
  /**
@@ -633,19 +713,6 @@ const createTemporalPeriodCombinations = ({ direction, count, }) => {
633
713
  }));
634
714
  };
635
715
 
636
- /**
637
- * Format a label representing the provided date range.
638
- *
639
- * @example
640
- * formatCustomDateRangeLabel({ from: new Date("2025-06-16"), to: new Date("2025-06-17") }); // "June 16, 2025 - June 17, 2025"
641
- */
642
- const formatCustomDateRangeLabel = (dateRange) => {
643
- return `${dateAndTimeUtils.formatDateUtil(dateRange.start ?? new Date(), {
644
- dateFormat: "medium",
645
- selectFormat: "dateOnly",
646
- })} - ${dateAndTimeUtils.formatDateUtil(dateRange.end ?? new Date(), { dateFormat: "medium", selectFormat: "dateOnly" })}`;
647
- };
648
-
649
716
  const TEMPORAL_UNITS = ["day", "days", "hour", "hours", "month", "months"];
650
717
  const TEMPORAL_DIRECTIONS = ["last", "next"];
651
718
  /**
@@ -672,11 +739,6 @@ const isTemporalDirection = (direction) => {
672
739
  return typeof direction === "string" && TEMPORAL_DIRECTIONS.includes(direction);
673
740
  };
674
741
 
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
-
680
742
  /**
681
743
  * DayRangeSelect is a popover-based date range picker with preset temporal options (e.g., "Last 7 days", "Next 30 days")
682
744
  * and an optional custom calendar picker. Users can search for presets by typing natural language queries like "last 3 months".
@@ -707,6 +769,7 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
707
769
  const parseTemporalPeriodFromText = useParseTemporalPeriodFromText();
708
770
  const filterTemporalPeriodsInRange = useFilterTemporalPeriodsInRange();
709
771
  const formatTemporalPeriodLabel = useFormatTemporalPeriodLabel();
772
+ const formatCustomRangeLabel = useFormatCustomDateRangeLabel();
710
773
  const calculateDateRange = useCalculateDateRange();
711
774
  const sameCalendarDay = useSameCalendarDay();
712
775
  const calculateCustomDateRangeDisabledDays = useCalculateCustomDateRangeDisabledDays();
@@ -778,9 +841,9 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
778
841
  return currentSelectedRange
779
842
  ? currentSelectedRange.temporalPeriod
780
843
  ? formatTemporalPeriodLabel(currentSelectedRange.temporalPeriod)
781
- : formatCustomDateRangeLabel(currentSelectedRange.dateRange)
844
+ : formatCustomRangeLabel(currentSelectedRange.dateRange)
782
845
  : undefined;
783
- }, [currentSelectedRange, formatTemporalPeriodLabel]);
846
+ }, [currentSelectedRange, formatTemporalPeriodLabel, formatCustomRangeLabel]);
784
847
  const handleDateRangeSearch = react.useCallback((value) => {
785
848
  setDateRangeSearchValue(value);
786
849
  if (!sharedUtils.nonNullable(value) || value === "") {
package/index.esm.js CHANGED
@@ -3,7 +3,7 @@ import { registerTranslations, useNamespaceTranslation } from '@trackunit/i18n-l
3
3
  import { toDateUtil, formatDateUtil, isEqualUtil, timeSinceAuto, getHourCycle } from '@trackunit/date-and-time-utils';
4
4
  import { useLocale, useHourCycle, useTimezone, useDateAndTime } from '@trackunit/react-date-and-time-hooks';
5
5
  import { truthy, DateTimeFormat, nonNullable, objectKeys } from '@trackunit/shared-utils';
6
- import { useMemo, useCallback, useState, useEffect, useRef, Children, isValidElement } from 'react';
6
+ import { useMemo, useCallback, useReducer, useState, useRef, Children, isValidElement, useEffect } from 'react';
7
7
  import { twMerge } from 'tailwind-merge';
8
8
  import { Tooltip, Button, MenuItem, Icon, Popover, PopoverTrigger, PopoverContent, MenuContent, MenuDivider, Card, Text } from '@trackunit/react-components';
9
9
  import Calendar from 'react-calendar';
@@ -229,6 +229,66 @@ const DayPicker = ({ onDaySelect, disabledDays, selectedDay, language, className
229
229
  }, value: selectedDay ?? null }) }));
230
230
  };
231
231
 
232
+ /**
233
+ * Checks if two date ranges are identical by comparing their start and end timestamps.
234
+ */
235
+ const sameDateRange = (left, right) => left?.start?.getTime() === right?.start?.getTime() && left?.end?.getTime() === right?.end?.getTime();
236
+
237
+ const emptyRange = { start: undefined, end: undefined };
238
+ const isSameLocalDay = (left, right) => left === right || (left !== undefined && right !== undefined && left.toDateString() === right.toDateString());
239
+ /** react-calendar picks days in local time, so compare days in local time too. */
240
+ const coversSameDays = (left, right) => isSameLocalDay(left.start, right.start) && isSameLocalDay(left.end, right.end);
241
+ const endOfLocalDay = (date) => {
242
+ const end = new Date(date);
243
+ end.setHours(23, 59, 59, 999);
244
+ return end;
245
+ };
246
+ const remount = (state) => ({
247
+ ...state,
248
+ calendarKey: state.calendarKey + 1,
249
+ });
250
+ /** The picker's state when it first renders with the given `selectedDays`. */
251
+ const initDayRangePickerState = (selectedDays) => ({
252
+ range: selectedDays ?? emptyRange,
253
+ trackedSelectedDays: selectedDays,
254
+ viewedMonth: undefined,
255
+ calendarKey: 0,
256
+ });
257
+ /**
258
+ * State transitions of `DayRangePicker`. Every transition that resets the range remounts the
259
+ * calendar, so a half-picked day never survives it.
260
+ */
261
+ const dayRangePickerReducer = (state, action) => {
262
+ switch (action.type) {
263
+ case "daySelected":
264
+ // A first click selects the whole day, the same range react-calendar returns when it is clicked twice.
265
+ return { ...state, range: { start: action.start, end: action.end ?? endOfLocalDay(action.start) } };
266
+ case "cleared":
267
+ return remount({ ...state, range: emptyRange });
268
+ case "cancelled":
269
+ return remount({ ...state, range: state.trackedSelectedDays ?? emptyRange });
270
+ case "monthViewed":
271
+ return { ...state, viewedMonth: action.month };
272
+ case "selectedDaysChanged": {
273
+ const { selectedDays } = action;
274
+ if (!selectedDays) {
275
+ return { ...state, trackedSelectedDays: undefined };
276
+ }
277
+ // A caller that keeps the picker open may pass back the applied range rounded to whole days.
278
+ // That is the same selection, so the range and the month on screen stay.
279
+ const keepRange = coversSameDays(selectedDays, state.range);
280
+ return remount({
281
+ ...state,
282
+ trackedSelectedDays: selectedDays,
283
+ range: keepRange ? state.range : selectedDays,
284
+ viewedMonth: keepRange ? state.viewedMonth : undefined,
285
+ });
286
+ }
287
+ default:
288
+ return state;
289
+ }
290
+ };
291
+
232
292
  /**
233
293
  * DayRangePicker renders an interactive calendar that allows the user to select a start and end date.
234
294
  * It supports disabled days, timezone-aware display, locale configuration, and an optional cancel button.
@@ -257,38 +317,25 @@ const DayPicker = ({ onDaySelect, disabledDays, selectedDay, language, className
257
317
  * @returns {ReactElement} DayRangePicker component
258
318
  */
259
319
  const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, "data-testid": dataTestId, language, className, style, classNameCalendar, timezone: _timezone, cancelButtonLabel, onClose, ref, }) => {
260
- const [newRange, setNewRange] = useState(selectedDays ?? { start: undefined, end: undefined });
320
+ const [{ range: newRange, trackedSelectedDays, viewedMonth, calendarKey }, dispatch] = useReducer(dayRangePickerReducer, selectedDays, initDayRangePickerState);
261
321
  const [t] = useTranslation();
262
322
  const { subtract } = useDateAndTime();
263
323
  const calculateDateRange = useCalculateDateRange();
264
- useEffect(() => {
265
- if (selectedDays) {
266
- setNewRange(selectedDays);
267
- }
268
- }, [selectedDays]);
269
- const handleOnRangeSelect = useCallback((range) => {
270
- if (range && range.start) {
271
- if (range.end === undefined) {
272
- setNewRange({ start: range.start, end: range.start });
273
- }
274
- else {
275
- setNewRange({ start: range.start, end: range.end });
276
- }
277
- }
278
- else {
279
- setNewRange({ start: undefined, end: undefined });
280
- }
281
- }, []);
324
+ if (!sameDateRange(trackedSelectedDays, selectedDays)) {
325
+ dispatch({ type: "selectedDaysChanged", selectedDays });
326
+ }
282
327
  const handleCancel = useCallback(() => {
283
- if (selectedDays) {
284
- setNewRange(selectedDays);
285
- }
328
+ dispatch({ type: "cancelled" });
286
329
  if (onClose) {
287
330
  onClose();
288
331
  }
289
- }, [selectedDays, onClose]);
290
- const clearSelectedDays = () => {
291
- setNewRange({ start: undefined, end: undefined });
332
+ }, [onClose]);
333
+ // The year and decade views start on January 1, which isn't a month the user was looking at.
334
+ // Picking January from the year view keeps January 1 as the start date, so only the view changes.
335
+ const trackViewedMonth = ({ activeStartDate, view }) => {
336
+ if (view === "month") {
337
+ dispatch({ type: "monthViewed", month: activeStartDate ?? undefined });
338
+ }
292
339
  };
293
340
  const handleApply = useCallback(() => {
294
341
  if (onRangeSelect) {
@@ -298,11 +345,11 @@ const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, "data-testi
298
345
  onClose();
299
346
  }
300
347
  }, [onRangeSelect, newRange, onClose]);
301
- return (jsxs("div", { className: twMerge("flex", "w-min", "flex-col", "overflow-hidden", "rounded-md", "border", "border-neutral-300", "bg-white", className), "data-testid": dataTestId, ref: ref, style: style, children: [jsx(Calendar, { allowPartialRange: true, className: twMerge("custom-day-picker", "range-picker", classNameCalendar), locale: language, onChange: value => {
302
- if (Array.isArray(value) && value[0] && value[1]) {
303
- handleOnRangeSelect({ start: value[0], end: value[1] });
348
+ return (jsxs("div", { className: twMerge("flex", "w-min", "flex-col", "overflow-hidden", "rounded-md", "border", "border-neutral-300", "bg-white", className), "data-testid": dataTestId, ref: ref, style: style, children: [jsx(Calendar, { allowPartialRange: true, className: twMerge("custom-day-picker", "range-picker", classNameCalendar), defaultActiveStartDate: viewedMonth ?? trackedSelectedDays?.start, locale: language, onActiveStartDateChange: trackViewedMonth, onChange: value => {
349
+ if (Array.isArray(value) && value[0]) {
350
+ dispatch({ type: "daySelected", start: value[0], end: value[1] ?? undefined });
304
351
  }
305
- }, selectRange: true, tileDisabled: ({ date, view }) => {
352
+ }, onViewChange: trackViewedMonth, selectRange: true, tileDisabled: ({ date, view }) => {
306
353
  if (view === "month") {
307
354
  if (typeof disabledDays === "function") {
308
355
  return disabledDays(date);
@@ -332,7 +379,7 @@ const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, "data-testi
332
379
  }, value:
333
380
  // type is wrong here if we add array of 2 elements if end is undefined and set to null it will not highlight the start date
334
381
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
335
- newRange.end ? [newRange.start ?? null, newRange.end ?? null] : (newRange.start ?? null) }), jsx("hr", {}), jsxs("div", { className: "flex w-full justify-between gap-2 px-4 py-3", children: [jsx(Button, { className: "mr-auto", "data-testid": "range-popover-clear-button", onClick: clearSelectedDays, size: "small", variant: "secondary", children: t("layout.actions.clear") }), jsxs("div", { children: [jsx(Button, { "data-testid": "range-popover-cancel-button", onClick: () => handleCancel(), size: "small", variant: "ghost-neutral", children: cancelButtonLabel ? cancelButtonLabel : t("layout.actions.cancel") }), jsx(Button, { "data-testid": "range-popover-apply-button", disabled: !newRange.start || !newRange.end, onClick: () => handleApply(), size: "small", children: t("layout.actions.apply") })] })] })] }));
382
+ newRange.end ? [newRange.start ?? null, newRange.end ?? null] : (newRange.start ?? null) }, calendarKey), jsx("hr", {}), jsxs("div", { className: "flex w-full justify-between gap-2 px-4 py-3", children: [jsx(Button, { className: "mr-auto", "data-testid": "range-popover-clear-button", onClick: () => dispatch({ type: "cleared" }), size: "small", variant: "secondary", children: t("layout.actions.clear") }), jsxs("div", { children: [jsx(Button, { "data-testid": "range-popover-cancel-button", onClick: () => handleCancel(), size: "small", variant: "ghost-neutral", children: cancelButtonLabel ? cancelButtonLabel : t("layout.actions.cancel") }), jsx(Button, { "data-testid": "range-popover-apply-button", disabled: !newRange.start || !newRange.end, onClick: () => handleApply(), size: "small", children: t("layout.actions.apply") })] })] })] }));
336
383
  };
337
384
 
338
385
  /**
@@ -503,6 +550,54 @@ const useFilterTemporalPeriodsInRange = () => {
503
550
  }, [getDayCountInRange]);
504
551
  };
505
552
 
553
+ /**
554
+ * Format a label representing the provided date range.
555
+ *
556
+ * @example
557
+ * formatCustomDateRangeLabel({ from: new Date("2025-06-16"), to: new Date("2025-06-17") }); // "June 16, 2025 - June 17, 2025"
558
+ */
559
+ const formatCustomDateRangeLabel = (dateRange) => {
560
+ return `${formatDateUtil(dateRange.start ?? new Date(), {
561
+ dateFormat: "medium",
562
+ selectFormat: "dateOnly",
563
+ })} - ${formatDateUtil(dateRange.end ?? new Date(), { dateFormat: "medium", selectFormat: "dateOnly" })}`;
564
+ };
565
+
566
+ /**
567
+ * Returns a function that checks if two dates fall on the same calendar day
568
+ * in the user's current timezone.
569
+ */
570
+ const useSameCalendarDay = () => {
571
+ const { startOf } = useDateAndTime();
572
+ return useCallback((left, right) => {
573
+ if (left === undefined || right === undefined) {
574
+ return false;
575
+ }
576
+ // Compare the start-of-day timestamps in the user's timezone
577
+ return startOf(left, "day").getTime() === startOf(right, "day").getTime();
578
+ }, [startOf]);
579
+ };
580
+
581
+ /**
582
+ * Format a label representing a custom date range.
583
+ * A range that starts and ends on the same calendar day is shown as that one date.
584
+ * It is never shown as "Today": that label belongs to the Today preset.
585
+ *
586
+ * @example
587
+ * const formatLabel = useFormatCustomDateRangeLabel();
588
+ * formatLabel({ start: new Date("2025-01-15T08:00Z"), end: new Date("2025-01-15T20:00Z") }); // Jan 15, 2025
589
+ * formatLabel({ start: new Date("2025-01-01"), end: new Date("2025-01-02") }); // Jan 1, 2025 - Jan 2, 2025
590
+ */
591
+ const useFormatCustomDateRangeLabel = () => {
592
+ const isSameCalendarDay = useSameCalendarDay();
593
+ return useCallback((dateRange) => {
594
+ if (dateRange.start && isSameCalendarDay(dateRange.start, dateRange.end)) {
595
+ return formatDateUtil(dateRange.start, { dateFormat: "medium", selectFormat: "dateOnly" });
596
+ }
597
+ return formatCustomDateRangeLabel(dateRange);
598
+ }, [isSameCalendarDay]);
599
+ };
600
+
506
601
  /**
507
602
  * Find the TemporalDirection type matching the value.
508
603
  */
@@ -578,21 +673,6 @@ const useParseTemporalPeriodFromText = () => {
578
673
  }, [t, findTemporalDirection, findTemporalUnit]);
579
674
  };
580
675
 
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
-
596
676
  const DEFAULT_DIRECTIONS = ["last", "next"];
597
677
  const DEFAULT_COUNT = 7;
598
678
  /**
@@ -631,19 +711,6 @@ const createTemporalPeriodCombinations = ({ direction, count, }) => {
631
711
  }));
632
712
  };
633
713
 
634
- /**
635
- * Format a label representing the provided date range.
636
- *
637
- * @example
638
- * formatCustomDateRangeLabel({ from: new Date("2025-06-16"), to: new Date("2025-06-17") }); // "June 16, 2025 - June 17, 2025"
639
- */
640
- const formatCustomDateRangeLabel = (dateRange) => {
641
- return `${formatDateUtil(dateRange.start ?? new Date(), {
642
- dateFormat: "medium",
643
- selectFormat: "dateOnly",
644
- })} - ${formatDateUtil(dateRange.end ?? new Date(), { dateFormat: "medium", selectFormat: "dateOnly" })}`;
645
- };
646
-
647
714
  const TEMPORAL_UNITS = ["day", "days", "hour", "hours", "month", "months"];
648
715
  const TEMPORAL_DIRECTIONS = ["last", "next"];
649
716
  /**
@@ -670,11 +737,6 @@ const isTemporalDirection = (direction) => {
670
737
  return typeof direction === "string" && TEMPORAL_DIRECTIONS.includes(direction);
671
738
  };
672
739
 
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
-
678
740
  /**
679
741
  * DayRangeSelect is a popover-based date range picker with preset temporal options (e.g., "Last 7 days", "Next 30 days")
680
742
  * and an optional custom calendar picker. Users can search for presets by typing natural language queries like "last 3 months".
@@ -705,6 +767,7 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
705
767
  const parseTemporalPeriodFromText = useParseTemporalPeriodFromText();
706
768
  const filterTemporalPeriodsInRange = useFilterTemporalPeriodsInRange();
707
769
  const formatTemporalPeriodLabel = useFormatTemporalPeriodLabel();
770
+ const formatCustomRangeLabel = useFormatCustomDateRangeLabel();
708
771
  const calculateDateRange = useCalculateDateRange();
709
772
  const sameCalendarDay = useSameCalendarDay();
710
773
  const calculateCustomDateRangeDisabledDays = useCalculateCustomDateRangeDisabledDays();
@@ -776,9 +839,9 @@ const DayRangeSelect = ({ className, "data-testid": dataTestId, style, ref, disa
776
839
  return currentSelectedRange
777
840
  ? currentSelectedRange.temporalPeriod
778
841
  ? formatTemporalPeriodLabel(currentSelectedRange.temporalPeriod)
779
- : formatCustomDateRangeLabel(currentSelectedRange.dateRange)
842
+ : formatCustomRangeLabel(currentSelectedRange.dateRange)
780
843
  : undefined;
781
- }, [currentSelectedRange, formatTemporalPeriodLabel]);
844
+ }, [currentSelectedRange, formatTemporalPeriodLabel, formatCustomRangeLabel]);
782
845
  const handleDateRangeSearch = useCallback((value) => {
783
846
  setDateRangeSearchValue(value);
784
847
  if (!nonNullable(value) || value === "") {
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@trackunit/react-date-and-time-components",
3
- "version": "2.17.33",
3
+ "version": "2.17.34",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/react-components": "6.0.30",
11
- "@trackunit/date-and-time-utils": "1.20.37",
12
- "@trackunit/react-date-and-time-hooks": "2.17.32",
13
- "@trackunit/css-class-variance-utilities": "2.6.36",
14
- "@trackunit/ui-icons": "1.20.35",
15
- "@trackunit/shared-utils": "1.22.36",
16
- "@trackunit/i18n-library-translation": "2.14.28",
17
- "@trackunit/react-form-components": "2.17.32",
10
+ "@trackunit/react-components": "6.0.31",
11
+ "@trackunit/date-and-time-utils": "1.20.38",
12
+ "@trackunit/react-date-and-time-hooks": "2.17.33",
13
+ "@trackunit/css-class-variance-utilities": "2.6.37",
14
+ "@trackunit/ui-icons": "1.20.36",
15
+ "@trackunit/shared-utils": "1.22.37",
16
+ "@trackunit/i18n-library-translation": "2.14.29",
17
+ "@trackunit/react-form-components": "2.17.33",
18
18
  "string-ts": "^2.0.0",
19
19
  "tailwind-merge": "^3.7.0",
20
20
  "react-calendar": "^6.0.1"
@@ -0,0 +1,36 @@
1
+ import { DateRange } from "@trackunit/date-and-time-utils";
2
+ export type DayRangePickerState = {
3
+ /** The range being picked, which Apply passes to `onRangeSelect`. */
4
+ range: DateRange;
5
+ /** The last `selectedDays` seen, so a new object with the same dates isn't treated as a change. */
6
+ trackedSelectedDays: DateRange | undefined;
7
+ /** The month on screen, so a remount doesn't jump back to the month of the selection. */
8
+ viewedMonth: Date | undefined;
9
+ /**
10
+ * react-calendar keeps a half-finished range in its own state and ignores `value` until it is
11
+ * finished, so the only way to drop it is to remount the calendar with a new key.
12
+ */
13
+ calendarKey: number;
14
+ };
15
+ export type DayRangePickerAction = {
16
+ type: "daySelected";
17
+ start: Date;
18
+ end: Date | undefined;
19
+ } | {
20
+ type: "cleared";
21
+ } | {
22
+ type: "cancelled";
23
+ } | {
24
+ type: "monthViewed";
25
+ month: Date | undefined;
26
+ } | {
27
+ type: "selectedDaysChanged";
28
+ selectedDays: DateRange | undefined;
29
+ };
30
+ /** The picker's state when it first renders with the given `selectedDays`. */
31
+ export declare const initDayRangePickerState: (selectedDays: DateRange | undefined) => DayRangePickerState;
32
+ /**
33
+ * State transitions of `DayRangePicker`. Every transition that resets the range remounts the
34
+ * calendar, so a half-picked day never survives it.
35
+ */
36
+ export declare const dayRangePickerReducer: (state: DayRangePickerState, action: DayRangePickerAction) => DayRangePickerState;
@@ -0,0 +1,12 @@
1
+ import { DateRange } from "@trackunit/date-and-time-utils";
2
+ /**
3
+ * Format a label representing a custom date range.
4
+ * A range that starts and ends on the same calendar day is shown as that one date.
5
+ * It is never shown as "Today": that label belongs to the Today preset.
6
+ *
7
+ * @example
8
+ * const formatLabel = useFormatCustomDateRangeLabel();
9
+ * formatLabel({ start: new Date("2025-01-15T08:00Z"), end: new Date("2025-01-15T20:00Z") }); // Jan 15, 2025
10
+ * formatLabel({ start: new Date("2025-01-01"), end: new Date("2025-01-02") }); // Jan 1, 2025 - Jan 2, 2025
11
+ */
12
+ export declare const useFormatCustomDateRangeLabel: () => ((dateRange: DateRange) => string);