@trackunit/react-date-and-time-components 2.17.32 → 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,
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
286
|
-
setNewRange(selectedDays);
|
|
287
|
-
}
|
|
330
|
+
dispatch({ type: "cancelled" });
|
|
288
331
|
if (onClose) {
|
|
289
332
|
onClose();
|
|
290
333
|
}
|
|
291
|
-
}, [
|
|
292
|
-
|
|
293
|
-
|
|
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]
|
|
305
|
-
|
|
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:
|
|
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
|
-
:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
284
|
-
setNewRange(selectedDays);
|
|
285
|
-
}
|
|
328
|
+
dispatch({ type: "cancelled" });
|
|
286
329
|
if (onClose) {
|
|
287
330
|
onClose();
|
|
288
331
|
}
|
|
289
|
-
}, [
|
|
290
|
-
|
|
291
|
-
|
|
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]
|
|
303
|
-
|
|
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:
|
|
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
|
-
:
|
|
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.
|
|
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.
|
|
11
|
-
"@trackunit/date-and-time-utils": "1.20.
|
|
12
|
-
"@trackunit/react-date-and-time-hooks": "2.17.
|
|
13
|
-
"@trackunit/css-class-variance-utilities": "2.6.
|
|
14
|
-
"@trackunit/ui-icons": "1.20.
|
|
15
|
-
"@trackunit/shared-utils": "1.22.
|
|
16
|
-
"@trackunit/i18n-library-translation": "2.14.
|
|
17
|
-
"@trackunit/react-form-components": "2.17.
|
|
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;
|
package/src/DayRangeSelect/hooks/useFormatCustomDateRangeLabel/useFormatCustomDateRangeLabel.d.ts
ADDED
|
@@ -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);
|