@trackunit/react-date-and-time-components 1.3.221 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/index.cjs.js +419 -73
  2. package/index.esm.js +417 -75
  3. package/package.json +13 -12
  4. package/src/DayPicker/DayRangePicker.d.ts +7 -14
  5. package/src/DayPicker/DayRangePicker.stories.d.ts +0 -1
  6. package/src/DayPicker/index.d.ts +0 -1
  7. package/src/DayRangeSelect/DayRangeSelect.d.ts +56 -0
  8. package/src/DayRangeSelect/DayRangeSelect.stories.d.ts +10 -0
  9. package/src/DayRangeSelect/DayRangeSelect.variants.d.ts +3 -0
  10. package/src/DayRangeSelect/components/DayRangeSelectOptions.d.ts +11 -0
  11. package/src/DayRangeSelect/hooks/useCalculateCustomDateRangeDisabledDays/useCalculateCustomDateRangeDisabledDays.d.ts +11 -0
  12. package/src/DayRangeSelect/hooks/useCalculateDateRange/useCalculateDateRange.d.ts +9 -0
  13. package/src/DayRangeSelect/hooks/useFilterTemporalPeriodsInRange/useFilterTemporalPeriodsInRange.d.ts +18 -0
  14. package/src/DayRangeSelect/hooks/useFormatTemporalPeriodLabel/useFormatTemporalPeriodLabel.d.ts +9 -0
  15. package/src/DayRangeSelect/hooks/useParseTemporalPeriodFromText/hooks/useFindTemporalDirection.d.ts +5 -0
  16. package/src/DayRangeSelect/hooks/useParseTemporalPeriodFromText/hooks/useFindTemporalUnit.d.ts +5 -0
  17. package/src/DayRangeSelect/hooks/useParseTemporalPeriodFromText/useParseTemporalPeriodFromText.d.ts +10 -0
  18. package/src/DayRangeSelect/index.d.ts +6 -0
  19. package/src/DayRangeSelect/types.d.ts +16 -0
  20. package/src/DayRangeSelect/utils/createTemporalPeriodCombinations/createTemporalPeriodCombinations.d.ts +12 -0
  21. package/src/DayRangeSelect/utils/formatCustomDateRangeLabel/formatCustomDateRangeLabel.d.ts +8 -0
  22. package/src/DayRangeSelect/utils/isTemporalPeriod/isTemporalPeriod.d.ts +8 -0
  23. package/src/DayRangeSelect/utils/temporalUnitMappings.d.ts +3 -0
  24. package/src/index.d.ts +1 -0
  25. package/src/translation.d.ts +2 -2
  26. package/src/DayPicker/DayRangePickerPopover.d.ts +0 -45
  27. package/src/DayPicker/DayRangePickerPopover.stories.d.ts +0 -10
package/index.esm.js CHANGED
@@ -4,20 +4,36 @@ import { toDateUtil, formatDateUtil, isEqualUtil, timeSinceAuto, getHourCycle }
4
4
  import { useLocale as useLocale$1, useDateAndTime } from '@trackunit/react-date-and-time-hooks';
5
5
  import { color } from '@trackunit/ui-design-tokens';
6
6
  import { useMemo, useCallback, useState, useEffect, useRef, Children, isValidElement } from 'react';
7
- import { Tooltip, IconButton, Icon, Button, Popover, PopoverTrigger, PopoverContent, Card, Text } from '@trackunit/react-components';
8
- import { DateTimeFormat } from '@trackunit/shared-utils';
7
+ import { Tooltip, Button, MenuItem, Icon, Popover, PopoverTrigger, PopoverContent, MenuList, MenuDivider, Card, Text } from '@trackunit/react-components';
8
+ import { DateTimeFormat, nonNullable, objectKeys } from '@trackunit/shared-utils';
9
9
  import { DayPicker as DayPicker$1 } from 'react-day-picker';
10
10
  import { twMerge } from 'tailwind-merge';
11
11
  import { enGB, enUS, da, de, cs, nl, fr, fi, hu, it, nb, pl, pt, ru, ro, es, sv, ja, th } from 'date-fns/locale';
12
- import { useCurrentUserLanguage } from '@trackunit/react-core-hooks';
12
+ import { Search } from '@trackunit/react-form-components';
13
13
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
14
+ import { lowerCase } from 'string-ts';
14
15
 
15
16
  var defaultTranslations = {
16
17
  "dateTime.instant.now": "Now",
17
- "dayRangePickerPopover.icon.tooltip.calendar": "Calendar",
18
+ "input.combined": "{{direction}} {{count}} {{unit}}",
19
+ "input.direction.last": "Last",
20
+ "input.direction.next": "Next",
21
+ "input.icon.tooltip.calendar": "Calendar",
22
+ "input.noOptions": "No options available",
23
+ "input.placeholder.customRange.last": "Type a date range (e.g., “Last 4 days”)",
24
+ "input.placeholder.customRange.next": "Type a date range (e.g., “Next 4 days”)",
25
+ "input.today": "Today",
26
+ "input.unit.day": "day",
27
+ "input.unit.days": "days",
28
+ "input.unit.month": "month",
29
+ "input.unit.months": "months",
30
+ "input.unit.week": "week",
31
+ "input.unit.weeks": "weeks",
18
32
  "layout.actions.apply": "Apply",
33
+ "layout.actions.back": "Back",
19
34
  "layout.actions.cancel": "Cancel",
20
35
  "layout.actions.clear": "Clear",
36
+ "layout.actions.reset": "Reset",
21
37
  "shared.timePeriods.days": "{{count}} day",
22
38
  "shared.timePeriods.days_plural": "{{count}} days",
23
39
  "shared.timePeriods.hours": "{{count}} hour",
@@ -27,7 +43,9 @@ var defaultTranslations = {
27
43
  "shared.timePeriods.today": "Today",
28
44
  "shared.timePeriods.weeks": "{{count}} week",
29
45
  "shared.timePeriods.weeks_plural": "{{count}} weeks",
30
- "timeline.loadMore": "Click for more..."
46
+ "timeline.loadMore": "Click for more...",
47
+ "trigger.customRange": "Custom date range",
48
+ "trigger.selectDateRange": "Select date range"
31
49
  };
32
50
 
33
51
  /** The translation namespace for this library */
@@ -167,7 +185,7 @@ const DayPicker = ({ onDaySelect, disabledDays, selectedDays, language, classNam
167
185
  * @param {DayRangePickerProps} props - The props for the DayRangePicker component
168
186
  * @returns {ReactElement} DayRangePicker component
169
187
  */
170
- const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, dataTestId, language, className, max, showQuickOptions, timezone, onClose, }) => {
188
+ const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, dataTestId, language, className, max, timezone, cancelButtonLabel, onClose, }) => {
171
189
  const [newRange, setNewRange] = useState(selectedDays ?? { from: undefined, to: undefined });
172
190
  const [t] = useTranslation();
173
191
  const locale = useLocale(language);
@@ -202,91 +220,415 @@ const DayRangePicker = ({ onRangeSelect, selectedDays, disabledDays, dataTestId,
202
220
  onRangeSelect && onRangeSelect(newRange);
203
221
  onClose && onClose();
204
222
  }, [onRangeSelect, newRange, onClose]);
205
- return (jsxs("div", { className: "z-popover flex w-min flex-col gap-4", children: [jsxs("div", { className: "flex gap-2", children: [jsx("div", { className: "flex flex-wrap gap-2", children: !!showQuickOptions &&
206
- calcQuickOptions(showQuickOptions.dataRetention).map(option => (jsx(QuickOptionButton, { onClick: handleOnRangeSelect, option: option, timezone: timezone }, `${option.unit}${option.last}${option.includeExtraDay ? "-include-extra-day" : ""}`))) }), !!onClose && (jsx("div", { className: "flex-grow text-right", children: jsx(IconButton, { dataTestId: "close-button", icon: jsx(Icon, { name: "XMark", size: "small" }), onClick: () => handleApply(), variant: "ghost-neutral" }) }))] }), jsx(DayPicker$1, { className: twMerge("custom-day-picker", "range-picker", className), defaultMonth: selectedDays ? selectedDays.from : undefined, disabled: disabledDays, footer: jsx("div", { "data-testid": dataTestId }), locale: locale, max: max, mode: "range", onSelect: handleOnRangeSelect, selected: newRange }), jsxs("div", { className: "flex w-full gap-2", children: [jsx(Button, { className: "mr-auto", dataTestId: "range-popover-clear-button", onClick: clearSelectedDays, variant: "secondary", children: t("layout.actions.clear") }), jsx(Button, { dataTestId: "range-popover-cancel-button", onClick: () => handleCancel(), variant: "secondary", children: t("layout.actions.cancel") }), jsx(Button, { dataTestId: "range-popover-apply-button", disabled: !newRange.from || !newRange.to, onClick: () => handleApply(), children: t("layout.actions.apply") })] })] }));
223
+ return (jsxs("div", { className: "flex w-min flex-col gap-4 p-2", children: [jsx(DayPicker$1, { className: twMerge("custom-day-picker", "range-picker", className, "p-0"), defaultMonth: selectedDays ? selectedDays.from : undefined, disabled: disabledDays, footer: jsx("div", { "data-testid": dataTestId }), locale: locale, max: max, mode: "range", onSelect: handleOnRangeSelect, selected: newRange }), jsxs("div", { className: "flex w-full gap-2", children: [jsx(Button, { className: "mr-auto", dataTestId: "range-popover-clear-button", onClick: clearSelectedDays, variant: "secondary", children: t("layout.actions.clear") }), jsx(Button, { dataTestId: "range-popover-cancel-button", onClick: () => handleCancel(), variant: "ghost-neutral", children: cancelButtonLabel ? cancelButtonLabel : t("layout.actions.cancel") }), jsx(Button, { dataTestId: "range-popover-apply-button", disabled: !newRange.from || !newRange.to, onClick: () => handleApply(), children: t("layout.actions.apply") })] })] }));
207
224
  };
208
- const calcQuickOptions = (numberOfDays) => {
209
- const quickOptions = [];
210
- if (numberOfDays > 7 && numberOfDays < 35) {
211
- quickOptions.push({ last: 1, unit: "days" }, { last: numberOfDays, unit: "days", includeExtraDay: true });
212
- }
213
- else if (numberOfDays >= 35 && numberOfDays < 185) {
214
- quickOptions.push({ last: 1, unit: "days" }, { last: 7, unit: "days", includeExtraDay: false }, { last: 30, unit: "days", includeExtraDay: false }, { last: numberOfDays, unit: "days", includeExtraDay: false });
215
- }
216
- else if (numberOfDays >= 185) {
217
- quickOptions.push({ last: 1, unit: "days" }, { last: 14, unit: "days", includeExtraDay: false }, { last: 30, unit: "days", includeExtraDay: false }, { last: numberOfDays, unit: "days", includeExtraDay: false });
218
- }
219
- return quickOptions;
225
+
226
+ const temporalArithmeticTypeMapping = {
227
+ day: "days",
228
+ days: "days",
229
+ week: "weeks",
230
+ weeks: "weeks",
231
+ month: "months",
232
+ months: "months",
233
+ };
234
+
235
+ /**
236
+ * Calculate a date range based on a temporal period.
237
+ *
238
+ * @example
239
+ * getDataRange({ direction: "next", count: 1, unit: "month" }); // { from: 2025-06-01, to: 2025-07-01 }
240
+ * getDataRange({ direction: "last", count: 2, unit: "weeks" }); // { from: 2025-05-24, to: 2025-06-01 }
241
+ */
242
+ const useCalculateDateRange = () => {
243
+ const { nowDate, startOf, endOf, subtract, add } = useDateAndTime();
244
+ return useCallback(({ direction, count, unit }) => {
245
+ const unitType = temporalArithmeticTypeMapping[unit];
246
+ if (direction === "next") {
247
+ // To get the right number of days, calculate the end date and subtract 1 day
248
+ const endDate = add(nowDate, count, unitType);
249
+ const adjustedEndDate = subtract(endDate, 1, "days");
250
+ return {
251
+ from: startOf(nowDate, "day"),
252
+ to: endOf(adjustedEndDate, "day"),
253
+ };
254
+ }
255
+ else {
256
+ // To get the right number of days, calculate the start date and add 1 day
257
+ const startDate = subtract(nowDate, count, unitType);
258
+ const adjustedStartDate = add(startDate, 1, "days");
259
+ return {
260
+ from: startOf(adjustedStartDate, "day"),
261
+ to: endOf(nowDate, "day"),
262
+ };
263
+ }
264
+ }, [nowDate, startOf, endOf, subtract, add]);
220
265
  };
221
- const QuickOptionButton = ({ option, onClick, timezone }) => {
266
+
267
+ /**
268
+ * Format a temporal period into a label.
269
+ *
270
+ * @example
271
+ * formatTemporalPeriodLabel({ direction: "last", count: 2, unit: "days" }); // Last 2 days
272
+ * formatTemporalPeriodLabel({ direction: "next", count: 1, unit: "week" }); // Next week
273
+ */
274
+ const useFormatTemporalPeriodLabel = () => {
275
+ const [t] = useTranslation();
276
+ return useCallback((option) => {
277
+ // Return "Today" for "1 day" no matter the direction as in the current implementation it's the same day.
278
+ if (option.count === 1 && option.unit === "day") {
279
+ return t("input.today");
280
+ }
281
+ return t("input.combined", {
282
+ direction: t(`input.direction.${option.direction === "next" ? "next" : "last"}`),
283
+ count: option.count > 1 ? option.count : undefined,
284
+ unit: t(`input.unit.${option.unit}`),
285
+ });
286
+ }, [t]);
287
+ };
288
+
289
+ /**
290
+ * Renders a list of options for the DayRangeSelect component.
291
+ */
292
+ const DayRangeSelectOptions = ({ dataTestId, temporalPeriods, selectedDateRange, onSelect, }) => {
222
293
  const [t] = useTranslation();
223
- const { nowDate, startOf, endOf, subtract } = useDateAndTime();
224
- const handleClick = useCallback(() => {
225
- const from = subtract(startOf(nowDate, "day"), option.includeExtraDay ? option.last : option.last - 1, option.unit);
226
- onClick({
227
- from,
228
- to: endOf(nowDate, "day"),
294
+ const formatTemporalPeriodLabel = useFormatTemporalPeriodLabel();
295
+ const calculateDateRange = useCalculateDateRange();
296
+ const handleSelect = useCallback((index) => {
297
+ const temporalPeriod = temporalPeriods[index];
298
+ if (!temporalPeriod) {
299
+ return;
300
+ }
301
+ onSelect({
302
+ dateRange: calculateDateRange(temporalPeriod),
303
+ temporalPeriod: temporalPeriod,
229
304
  });
230
- }, [subtract, startOf, endOf, nowDate, option.last, option.unit, option.includeExtraDay, onClick]);
231
- const translatedUnits = (unit) => {
232
- switch (unit) {
233
- case "hours":
234
- case "days":
235
- case "months":
236
- case "weeks":
237
- return `shared.timePeriods.${unit}`;
238
- default:
239
- return null;
305
+ }, [calculateDateRange, onSelect, temporalPeriods]);
306
+ const options = useMemo(() => {
307
+ if (!temporalPeriods.length) {
308
+ return (jsx(MenuItem, { dataTestId: dataTestId ? `${dataTestId}-no-options` : undefined, disabled: true, label: t("input.noOptions") }));
240
309
  }
241
- };
242
- const getLabel = () => {
243
- if (option.label) {
244
- return option.label;
310
+ return temporalPeriods.map((temporalPeriod, index) => {
311
+ // NOTE: Check for temporalPeriod as well to make sure not to select an options when it's a custom date range selection
312
+ const isSelected = selectedDateRange &&
313
+ selectedDateRange.temporalPeriod &&
314
+ stringifyTemporalPeriod(selectedDateRange.temporalPeriod) === stringifyTemporalPeriod(temporalPeriod);
315
+ return (jsx(MenuItem, { dataTestId: dataTestId ? `${dataTestId}-${index.toString()}` : undefined, label: formatTemporalPeriodLabel(temporalPeriod), onClick: () => handleSelect(index), selected: isSelected, suffix: isSelected ? jsx(Icon, { color: "primary", name: "Check", size: "small" }) : null }, index));
316
+ });
317
+ }, [temporalPeriods, selectedDateRange, formatTemporalPeriodLabel, handleSelect, dataTestId, t]);
318
+ return options;
319
+ };
320
+ const stringifyTemporalPeriod = (temporalPeriod) => `${temporalPeriod.direction}-${temporalPeriod.count}-${temporalPeriod.unit}`;
321
+
322
+ const cvaTriggerButton = cvaMerge(["justify-start"], {
323
+ variants: {
324
+ active: {
325
+ true: "text-primary-600 hover:text-primary-700 active:text-primary-800 focus:text-primary-800",
326
+ false: "",
327
+ },
328
+ },
329
+ });
330
+
331
+ /**
332
+ * Calculate the disabled days for the custom date range picker based on the maxDaysInRange and allowedDirection.
333
+ * The range is inclusive of both start and end dates, so we subtract 1 from maxDaysInRange to get the correct number of days.
334
+ */
335
+ const useCalculateCustomDateRangeDisabledDays = () => {
336
+ const { subtract, add, nowDate } = useDateAndTime();
337
+ return useCallback(({ maxDaysInRange, allowedDirection, }) => {
338
+ if (!allowedDirection) {
339
+ return undefined;
340
+ }
341
+ if (!nonNullable(maxDaysInRange)) {
342
+ if (allowedDirection === "last") {
343
+ return {
344
+ after: nowDate,
345
+ };
346
+ }
347
+ else {
348
+ return {
349
+ before: nowDate,
350
+ };
351
+ }
245
352
  }
246
- else if (option.last === 1 && option.unit === "days") {
247
- return t("shared.timePeriods.today");
353
+ // Subtract 1 from maxDaysInRange since the range is inclusive of both start and end dates
354
+ const adjustedMaxDaysInRangeCount = maxDaysInRange - 1;
355
+ if (allowedDirection === "last") {
356
+ return {
357
+ before: subtract(nowDate, adjustedMaxDaysInRangeCount, "days"),
358
+ after: nowDate,
359
+ };
248
360
  }
249
361
  else {
250
- const tKey = translatedUnits(option.unit);
251
- return tKey
252
- ? t(tKey, {
253
- count: option.last,
254
- })
255
- : null;
362
+ return {
363
+ before: nowDate,
364
+ after: add(nowDate, adjustedMaxDaysInRangeCount, "days"),
365
+ };
256
366
  }
257
- };
258
- return (jsx(Button, { dataTestId: `${option.unit}${option.last}${option.includeExtraDay ? "-include-extra-day" : ""}`, onClick: handleClick, size: "small", variant: "secondary", children: getLabel() }));
367
+ }, [nowDate, subtract, add]);
368
+ };
369
+
370
+ /**
371
+ * Filter temporal periods to only include those within a max day count.
372
+ *
373
+ * @example
374
+ * getTemporalPeriodsInRange({
375
+ * temporalPeriods: [
376
+ * { direction: "next", count: 1, unit: "week" },
377
+ * { direction: "next", count: 1, unit: "month" },
378
+ * ],
379
+ * maxDaysInRange: 7,
380
+ * });
381
+ * ); // [{ direction: "next", count: 1, unit: "week" }]
382
+ */
383
+ const useFilterTemporalPeriodsInRange = () => {
384
+ const { difference } = useDateAndTime();
385
+ const calculateDateRange = useCalculateDateRange();
386
+ const getDayCountInRange = useCallback((temporalPeriod) => {
387
+ const { from, to } = calculateDateRange(temporalPeriod);
388
+ return difference(from, to, "day");
389
+ }, [calculateDateRange, difference]);
390
+ return useCallback(({ temporalPeriods, maxDaysInRange, }) => {
391
+ if (!maxDaysInRange) {
392
+ return temporalPeriods;
393
+ }
394
+ return temporalPeriods.filter(period => {
395
+ const daysInRange = getDayCountInRange(period);
396
+ return nonNullable(daysInRange) && daysInRange <= maxDaysInRange;
397
+ });
398
+ }, [getDayCountInRange]);
399
+ };
400
+
401
+ /**
402
+ * Find the TemporalDirection type matching the value.
403
+ */
404
+ const useFindTemporalDirection = () => {
405
+ const [t] = useTranslation();
406
+ const temporalDirectionsMap = useMemo(() => ({
407
+ last: t("input.direction.last"),
408
+ next: t("input.direction.next"),
409
+ }), [t]);
410
+ return useCallback((value) => {
411
+ if (!value) {
412
+ return undefined;
413
+ }
414
+ return objectKeys(temporalDirectionsMap).find(direction => lowerCase(temporalDirectionsMap[direction]) === lowerCase(value));
415
+ }, [temporalDirectionsMap]);
259
416
  };
260
417
 
261
418
  /**
262
- * A popover to select a range of dates. Consider providing quick options.
263
- * quickOptions takes optional "includeExtraDay". This is because customers often want to see 8 days when selecting "week" so they can compare this monday to last weeks monday. Same goes for months. This option will not impact the label of the button.
419
+ * Find the TemporalUnit type matching the value.
420
+ */
421
+ const useFindTemporalUnit = () => {
422
+ const [t] = useTranslation();
423
+ const temporalUnitsMap = useMemo(() => ({
424
+ day: t("input.unit.day"),
425
+ days: t("input.unit.days"),
426
+ week: t("input.unit.week"),
427
+ weeks: t("input.unit.weeks"),
428
+ month: t("input.unit.month"),
429
+ months: t("input.unit.months"),
430
+ }), [t]);
431
+ return useCallback((value) => {
432
+ if (!value) {
433
+ return undefined;
434
+ }
435
+ return objectKeys(temporalUnitsMap).find(unit => lowerCase(temporalUnitsMap[unit]) === lowerCase(value));
436
+ }, [temporalUnitsMap]);
437
+ };
438
+
439
+ /**
440
+ * Parse a text string into a temporal period object.
264
441
  *
265
- * @param { DayRangePickerPopoverProps} props - The properties for the DayRangePickerPopover component.
266
- * @param props.interval initial date range
267
- * @param props.showQuickOptions display additional buttons for quick date range selection
268
- * @param props.disabledDays disable days from selecting possibility
269
- * @param props.className custom className to be added to the component
270
- * @param props.dataTestId id used for tests
271
- * @param props.onRangeSelect hook function to handle applied date range
272
- * @param props.variant popover button variant
273
- * @param props.size popover button size
274
- * @param props.placement popover button placement
275
- * @param props.timezone timezone for DayRangePicker
276
- * @param props.fullwidth popover button fill width
277
- * @param props.buttonContent popover button content
278
- * @param props.max The maximum amount of days that can be selected
279
- * @param props.disabled disabling popover button
442
+ * @example
443
+ * parseTemporalPeriodFromText("Last 2 days"); // { direction: "last", count: 2, unit: "days" }
444
+ * parseTemporalPeriodFromText("next 3"); // { direction: "next", count: 3 }
445
+ * parseTemporalPeriodFromText("1 month"); // {count: 1, unit: "month" }
280
446
  */
281
- const DayRangePickerPopover = ({ interval = { from: undefined, to: undefined }, showQuickOptions, disabledDays, className, dataTestId, onRangeSelect, variant = "secondary", size = "small", placement, timezone, fullwidth, bindTo, buttonContent, max, disabled, }) => {
282
- const { language } = useCurrentUserLanguage();
447
+ const useParseTemporalPeriodFromText = () => {
283
448
  const [t] = useTranslation();
284
- const handleOnRangeSelect = useCallback((range) => {
285
- if (range) {
286
- onRangeSelect(range);
449
+ const findTemporalDirection = useFindTemporalDirection();
450
+ const findTemporalUnit = useFindTemporalUnit();
451
+ return useCallback((value) => {
452
+ const directionMatch = value.match(
453
+ // Matches any of the translated direction strings (next, last)
454
+ new RegExp(`${t("input.direction.next")}|${t("input.direction.last")}`, "i"))?.[0];
455
+ const countMatch = value.match(/\d+/)?.[0];
456
+ const unitMatch = value.match(
457
+ // Matches any of the translated time unit strings both plural and singular (days, day, weeks, week, months, month)
458
+ new RegExp(`${t("input.unit.days")}|${t("input.unit.day")}|${t("input.unit.weeks")}|${t("input.unit.week")}|${t("input.unit.months")}|${t("input.unit.month")}`, "i"))?.[0];
459
+ return {
460
+ direction: findTemporalDirection(directionMatch),
461
+ count: nonNullable(countMatch) ? parseInt(countMatch) : undefined,
462
+ unit: findTemporalUnit(unitMatch),
463
+ };
464
+ }, [t, findTemporalDirection, findTemporalUnit]);
465
+ };
466
+
467
+ const DEFAULT_DIRECTIONS = ["last", "next"];
468
+ const DEFAULT_COUNT = 1;
469
+ /**
470
+ * Generate all possible temporal period combinations for a given temporal period.
471
+ * If direction is not provided, both "last" and "next" directions are used.
472
+ * If count is not provided, the count defaults to 1.
473
+ * If unit is not provided, both "day", "week", and "month" are used in plural or singular form depending on the count.
474
+ *
475
+ * @example
476
+ * createTemporalPeriodCombinations({ direction: "last", count: 2, unit: "days" }); // [{ direction: "last", count: 2, unit: "days" }]
477
+ * createTemporalPeriodCombinations({ direction: "next", count: 1 }); // [{ direction: "next", count: 1, unit: "day" }, { direction: "next", count: 1, unit: "week" }, { direction: "next", count: 1, unit: "month" }]
478
+ */
479
+ const createTemporalPeriodCombinations = ({ direction, count, unit, }) => {
480
+ const directions = direction ? [direction] : DEFAULT_DIRECTIONS;
481
+ const isCountPlural = nonNullable(count) ? count > 1 : false;
482
+ const units = unit
483
+ ? [unit]
484
+ : isCountPlural
485
+ ? ["days", "weeks", "months"]
486
+ : ["day", "week", "month"];
487
+ return (directions
488
+ .flatMap(_direction => {
489
+ return units.map((_unit) => {
490
+ return {
491
+ direction: _direction,
492
+ count: count ?? DEFAULT_COUNT,
493
+ unit: _unit,
494
+ };
495
+ });
496
+ })
497
+ // If temporal direction isn't provided, filter out either of the 1 day options as they are both rendered as "Today"
498
+ .filter((period, index, array) => {
499
+ const isOneDayPeriod = period.count === 1 && period.unit === "day";
500
+ if (!isOneDayPeriod) {
501
+ return true;
502
+ }
503
+ const hasEarlierOneDayPeriod = array
504
+ .slice(0, index)
505
+ .some(_period => _period.count === 1 && _period.unit === "day");
506
+ return !hasEarlierOneDayPeriod;
507
+ }));
508
+ };
509
+
510
+ /**
511
+ * Format a label representing the provided date range.
512
+ *
513
+ * @example
514
+ * formatCustomDateRangeLabel({ from: new Date("2025-06-16"), to: new Date("2025-06-17") }); // "June 16, 2025 - June 17, 2025"
515
+ */
516
+ const formatCustomDateRangeLabel = (dateRange) => {
517
+ return (formatDateUtil(dateRange.from, { dateFormat: "medium", selectFormat: "dateOnly" }) +
518
+ " - " +
519
+ formatDateUtil(dateRange.to, { dateFormat: "medium", selectFormat: "dateOnly" }));
520
+ };
521
+
522
+ /**
523
+ * Check if a value is a temporal period.
524
+ *
525
+ * @param value - The value to check.
526
+ * @returns {boolean} - Whether the value is a temporal period.
527
+ */
528
+ const isTemporalPeriod = (value) => !!value && typeof value === "object" && "direction" in value && "count" in value && "unit" in value;
529
+
530
+ /**
531
+ * Day range select component.
532
+ */
533
+ const DayRangeSelect = ({ className, dataTestId, disabled, selectedDateRange, onRangeSelect, allowedDirection = undefined, initialDateRangeOptions, showDateRangeSearch = true, showCustomDateRangeOption = true, timezone, maxDaysInRange, size = "medium", fullWidth = false, }) => {
534
+ const [t] = useTranslation();
535
+ const parseTemporalPeriodFromText = useParseTemporalPeriodFromText();
536
+ const filterTemporalPeriodsInRange = useFilterTemporalPeriodsInRange();
537
+ const formatTemporalPeriodLabel = useFormatTemporalPeriodLabel();
538
+ const calculateDateRange = useCalculateDateRange();
539
+ const calculateCustomDateRangeDisabledDays = useCalculateCustomDateRangeDisabledDays();
540
+ const customDateRangeDisabledDays = useMemo(() => calculateCustomDateRangeDisabledDays({ maxDaysInRange, allowedDirection }), [calculateCustomDateRangeDisabledDays, maxDaysInRange, allowedDirection]);
541
+ const defaultTemporalPeriods = useMemo(() => {
542
+ if (initialDateRangeOptions) {
543
+ return filterTemporalPeriodsInRange({ temporalPeriods: initialDateRangeOptions, maxDaysInRange });
544
+ }
545
+ const defaultCombinations = [
546
+ { direction: allowedDirection ?? "last", count: 1, unit: "day" },
547
+ { direction: allowedDirection ?? "last", count: 1, unit: "week" },
548
+ { direction: allowedDirection ?? "last", count: 1, unit: "month" },
549
+ { direction: allowedDirection ?? "last", count: 12, unit: "months" },
550
+ ];
551
+ return filterTemporalPeriodsInRange({ temporalPeriods: defaultCombinations, maxDaysInRange });
552
+ }, [initialDateRangeOptions, filterTemporalPeriodsInRange, allowedDirection, maxDaysInRange]);
553
+ const [temporalPeriods, setTemporalPeriods] = useState(defaultTemporalPeriods);
554
+ const [dateRangeSearchValue, setDateRangeSearchValue] = useState();
555
+ const [selectedRange, setSelectedRange] = useState();
556
+ const currentSelectedRange = useMemo(() => {
557
+ // Internal selected range takes precedence
558
+ if (selectedRange) {
559
+ return selectedRange;
287
560
  }
561
+ if (!selectedDateRange) {
562
+ return undefined;
563
+ }
564
+ // Renders the selected date range as a selected option
565
+ if (isTemporalPeriod(selectedDateRange)) {
566
+ return { dateRange: calculateDateRange(selectedDateRange), temporalPeriod: selectedDateRange };
567
+ }
568
+ // Renders the selected date range as a custom date range
569
+ return { dateRange: selectedDateRange, temporalPeriod: undefined };
570
+ }, [selectedRange, selectedDateRange, calculateDateRange]);
571
+ const isCustomDateRangeSelected = useMemo(() => currentSelectedRange && !currentSelectedRange.temporalPeriod, [currentSelectedRange]);
572
+ const [isCustomDateRangeOpen, setIsCustomDateRangeOpen] = useState(false);
573
+ const popoverTriggerLabel = useMemo(() => {
574
+ return currentSelectedRange
575
+ ? currentSelectedRange.temporalPeriod
576
+ ? formatTemporalPeriodLabel(currentSelectedRange.temporalPeriod)
577
+ : formatCustomDateRangeLabel(currentSelectedRange.dateRange)
578
+ : undefined;
579
+ }, [currentSelectedRange, formatTemporalPeriodLabel]);
580
+ const handleDateRangeSearch = useCallback((value) => {
581
+ setDateRangeSearchValue(value);
582
+ if (!nonNullable(value) || value === "") {
583
+ setTemporalPeriods(defaultTemporalPeriods);
584
+ return;
585
+ }
586
+ const temporalPeriod = parseTemporalPeriodFromText(value);
587
+ const uniqueCombinations = createTemporalPeriodCombinations({
588
+ ...temporalPeriod,
589
+ direction: allowedDirection ?? temporalPeriod.direction,
590
+ });
591
+ const combinationsInRange = filterTemporalPeriodsInRange({
592
+ temporalPeriods: uniqueCombinations,
593
+ maxDaysInRange,
594
+ });
595
+ setTemporalPeriods(combinationsInRange);
596
+ }, [
597
+ parseTemporalPeriodFromText,
598
+ allowedDirection,
599
+ filterTemporalPeriodsInRange,
600
+ defaultTemporalPeriods,
601
+ maxDaysInRange,
602
+ ]);
603
+ const handleReset = useCallback(() => {
604
+ setTemporalPeriods(defaultTemporalPeriods);
605
+ setSelectedRange(undefined);
606
+ onRangeSelect(undefined);
607
+ handleDateRangeSearch("");
608
+ }, [handleDateRangeSearch, onRangeSelect, defaultTemporalPeriods]);
609
+ const handleOptionsSelect = useCallback((value, closeMenu) => {
610
+ closeMenu();
611
+ setSelectedRange(value);
612
+ onRangeSelect(value);
288
613
  }, [onRangeSelect]);
289
- return (jsxs(Popover, { placement: placement, children: [jsx(PopoverTrigger, { children: jsx(Button, { className: className, dataTestId: dataTestId ?? "show-date-range", disabled: disabled, fullWidth: fullwidth, prefix: jsx(Icon, { ariaLabel: t("dayRangePickerPopover.icon.tooltip.calendar"), name: "Calendar", size: "small" }), size: size, variant: variant, children: buttonContent }) }), jsx(PopoverContent, { children: close => (jsx(Card, { className: "min-w-min p-4", dataTestId: "range-popover-list", children: jsx(DayRangePicker, { disabledDays: disabledDays, language: language || "en", max: max, onClose: close, onRangeSelect: handleOnRangeSelect, selectedDays: interval, showQuickOptions: showQuickOptions, timezone: timezone }) })) })] }));
614
+ const handleCustomDateRangeSelect = useCallback((dateRange, closeMenu) => {
615
+ if (!dateRange || !dateRange.from || !dateRange.to) {
616
+ handleReset();
617
+ return;
618
+ }
619
+ closeMenu();
620
+ const _selectedDateRange = {
621
+ dateRange: { from: dateRange.from, to: dateRange.to },
622
+ temporalPeriod: undefined,
623
+ };
624
+ setSelectedRange(_selectedDateRange);
625
+ onRangeSelect(_selectedDateRange);
626
+ }, [onRangeSelect, handleReset]);
627
+ return (jsxs(Popover, { onOpenStateChange: state => !state && setIsCustomDateRangeOpen(false), placement: "bottom-start", children: [jsx(PopoverTrigger, { children: jsx(Button, { className: cvaTriggerButton({ active: !!selectedDateRange, className }), dataTestId: dataTestId, disabled: disabled, fullWidth: fullWidth, prefix: jsx(Icon, { ariaLabel: t("input.icon.tooltip.calendar"), color: disabled ? "secondary" : selectedDateRange ? "primary" : undefined, name: "Calendar", size: size === "large" ? "medium" : "small" }), size: size, variant: "secondary", children: popoverTriggerLabel ?? t("trigger.selectDateRange") }) }), jsx(PopoverContent, { dataTestId: dataTestId ? `${dataTestId}-popover-content` : undefined, children: closeMenu => {
628
+ return !isCustomDateRangeOpen ? (jsxs(MenuList, { className: "min-w-[280px]", children: [jsxs("div", { className: "flex flex-col gap-1", children: [showDateRangeSearch ? (jsx(Search, { className: "w-full", dataTestId: dataTestId ? `${dataTestId}-search-input` : undefined, onChange: event => handleDateRangeSearch(event.target.value), onClear: () => handleDateRangeSearch(""), placeholder: allowedDirection === "last"
629
+ ? t("input.placeholder.customRange.last")
630
+ : t("input.placeholder.customRange.next"), value: dateRangeSearchValue })) : null, jsx(Button, { className: "ml-auto", dataTestId: dataTestId ? `${dataTestId}-reset-button` : undefined, disabled: !selectedDateRange, onClick: handleReset, size: "small", variant: "ghost", children: t("layout.actions.reset") })] }), jsx("hr", { className: "border-secondary-200 my-1", role: "separator" }), jsx(DayRangeSelectOptions, { dataTestId: dataTestId ? `${dataTestId}-options` : undefined, onSelect: value => handleOptionsSelect(value, closeMenu), selectedDateRange: currentSelectedRange, temporalPeriods: temporalPeriods }), showCustomDateRangeOption ? (jsxs("section", { children: [jsx(MenuDivider, {}), jsx(MenuItem, { dataTestId: 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", max: maxDaysInRange, onClose: () => setIsCustomDateRangeOpen(false), onRangeSelect: dateRange => handleCustomDateRangeSelect(dateRange, closeMenu), selectedDays: currentSelectedRange?.dateRange, timezone: timezone }) }));
631
+ } })] }));
290
632
  };
291
633
 
292
634
  const cvaTimelineElement = cvaMerge([
@@ -520,4 +862,4 @@ const customDotElement = (name, hovered, selected, color, keepOnSelection) => (h
520
862
  */
521
863
  setupLibraryTranslations();
522
864
 
523
- export { DateTime, DateTimeHumanized, DayPicker, DayRangePicker, DayRangePickerPopover, MS_PER_HOUR, Timeline, TimelineElement };
865
+ export { DateTime, DateTimeHumanized, DayPicker, DayRangePicker, DayRangeSelect, MS_PER_HOUR, Timeline, TimelineElement, formatCustomDateRangeLabel, isTemporalPeriod, useCalculateDateRange, useFormatTemporalPeriodLabel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-date-and-time-components",
3
- "version": "1.3.221",
3
+ "version": "1.4.0",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -8,19 +8,20 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "date-fns": "^2.30.0",
11
- "tailwind-merge": "^2.0.0",
12
11
  "react-day-picker": "9.5.1",
13
12
  "react": "19.0.0",
14
- "@trackunit/react-components": "1.4.192",
15
- "@trackunit/react-date-and-time-hooks": "1.3.186",
16
- "@trackunit/react-core-hooks": "1.3.172",
17
- "@trackunit/date-and-time-utils": "1.3.157",
18
- "@trackunit/css-class-variance-utilities": "1.3.157",
19
- "@trackunit/ui-icons": "1.3.157",
20
- "@trackunit/ui-design-tokens": "1.3.155",
21
- "@trackunit/shared-utils": "1.5.157",
22
- "@trackunit/i18n-library-translation": "1.3.180",
23
- "@trackunit/react-test-setup": "1.0.47"
13
+ "@trackunit/react-components": "1.4.194",
14
+ "@trackunit/react-date-and-time-hooks": "1.3.188",
15
+ "@trackunit/date-and-time-utils": "1.3.159",
16
+ "@trackunit/css-class-variance-utilities": "1.3.159",
17
+ "@trackunit/ui-icons": "1.3.159",
18
+ "@trackunit/ui-design-tokens": "1.3.157",
19
+ "@trackunit/shared-utils": "1.5.159",
20
+ "@trackunit/i18n-library-translation": "1.3.182",
21
+ "@trackunit/react-test-setup": "1.0.49",
22
+ "@trackunit/react-form-components": "1.3.220",
23
+ "string-ts": "^2.0.0",
24
+ "tailwind-merge": "^2.0.0"
24
25
  },
25
26
  "module": "./index.esm.js",
26
27
  "main": "./index.cjs.js",
@@ -2,18 +2,11 @@ import { CommonProps } from "@trackunit/react-components";
2
2
  import { ReactElement } from "react";
3
3
  import { DateRange, Matcher } from "react-day-picker";
4
4
  import { TimeZone } from "./index";
5
- export interface ShowQuickOption {
6
- /**
7
- * Number of days of historical data that customer can view - info should come from useUserSubscription hook
8
- */
9
- dataRetention: number;
10
- }
11
- export type IDateRange = DateRange;
12
5
  export interface DayRangePickerProps extends CommonProps {
13
6
  /**
14
7
  * A callback function for when a range is selected
15
8
  */
16
- onRangeSelect?: (range: IDateRange | undefined) => void;
9
+ onRangeSelect?: (range: DateRange | undefined) => void;
17
10
  /**
18
11
  * Define any days which may not be selected
19
12
  */
@@ -21,7 +14,7 @@ export interface DayRangePickerProps extends CommonProps {
21
14
  /**
22
15
  * Set any days which are selected
23
16
  */
24
- selectedDays?: IDateRange;
17
+ selectedDays?: DateRange;
25
18
  /**
26
19
  * Set the language
27
20
  */
@@ -30,14 +23,14 @@ export interface DayRangePickerProps extends CommonProps {
30
23
  * The maximum amount of days that can be selected
31
24
  */
32
25
  max?: number;
33
- /**
34
- * Display quick options buttons above calendar which will preselect date range
35
- */
36
- showQuickOptions?: ShowQuickOption;
37
26
  /**
38
27
  * Shown time ranges will take timezone into consideration
39
28
  */
40
29
  timezone?: TimeZone;
30
+ /**
31
+ * The label for the cancel button
32
+ */
33
+ cancelButtonLabel?: string;
41
34
  /**
42
35
  * Callback for closing component view
43
36
  */
@@ -49,4 +42,4 @@ export interface DayRangePickerProps extends CommonProps {
49
42
  * @param {DayRangePickerProps} props - The props for the DayRangePicker component
50
43
  * @returns {ReactElement} DayRangePicker component
51
44
  */
52
- export declare const DayRangePicker: ({ onRangeSelect, selectedDays, disabledDays, dataTestId, language, className, max, showQuickOptions, timezone, onClose, }: DayRangePickerProps) => ReactElement;
45
+ export declare const DayRangePicker: ({ onRangeSelect, selectedDays, disabledDays, dataTestId, language, className, max, timezone, cancelButtonLabel, onClose, }: DayRangePickerProps) => ReactElement;