useSearchDateRangePicker.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. "use client";
  2. import React from "react";
  3. import { buildDatePresets } from "@/lib/frontend/search/datePresets";
  4. import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
  5. import {
  6. formatIsoDateDe,
  7. formatIsoDateRangeLabelDe,
  8. toDateFromIsoDateYmd,
  9. toIsoDateYmdFromDate,
  10. } from "@/lib/frontend/search/dateRange";
  11. const ACTIVE_FIELD = Object.freeze({
  12. FROM: "from",
  13. TO: "to",
  14. });
  15. function hasOwn(obj, key) {
  16. return Object.prototype.hasOwnProperty.call(obj || {}, key);
  17. }
  18. function focusRef(ref) {
  19. requestAnimationFrame(() => ref?.current?.focus?.());
  20. }
  21. function normalizeDayClickArgs(args) {
  22. // react-day-picker handler signatures have differed across versions.
  23. // We normalize both of these variants:
  24. // - onDayClick(day, modifiers, event)
  25. // - onDayClick(event, day, modifiers)
  26. const a0 = args?.[0];
  27. const a1 = args?.[1];
  28. const a2 = args?.[2];
  29. // Common: (day, modifiers)
  30. if (a0 instanceof Date) return { day: a0, modifiers: a1 || null };
  31. // Event-first: (event, day, modifiers)
  32. if (a1 instanceof Date) return { day: a1, modifiers: a2 || null };
  33. return { day: null, modifiers: null };
  34. }
  35. function buildCalendarState({ fromDate, toDate, isRangeInvalid }) {
  36. let calendarSelected = undefined;
  37. let invalidInterval = null;
  38. if (fromDate && toDate) {
  39. if (isRangeInvalid) {
  40. const min = fromDate < toDate ? fromDate : toDate;
  41. const max = fromDate < toDate ? toDate : fromDate;
  42. calendarSelected = { from: min, to: max };
  43. invalidInterval = { from: min, to: max };
  44. } else {
  45. calendarSelected = { from: fromDate, to: toDate };
  46. }
  47. } else if (fromDate) {
  48. calendarSelected = { from: fromDate, to: undefined };
  49. } else if (toDate) {
  50. // "to only" -> visually represent as a single-day range
  51. calendarSelected = { from: toDate, to: toDate };
  52. }
  53. const calendarModifiers =
  54. isRangeInvalid && invalidInterval
  55. ? {
  56. invalid_range: invalidInterval,
  57. invalid_range_edge: [fromDate, toDate].filter(Boolean),
  58. }
  59. : undefined;
  60. const calendarModifiersClassNames =
  61. isRangeInvalid && invalidInterval
  62. ? {
  63. invalid_range:
  64. "bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive",
  65. invalid_range_edge:
  66. "!bg-destructive !text-white hover:!bg-destructive/90",
  67. }
  68. : undefined;
  69. return { calendarSelected, calendarModifiers, calendarModifiersClassNames };
  70. }
  71. export function useSearchDateRangePicker({
  72. from,
  73. to,
  74. onDateRangeChange,
  75. isSubmitting,
  76. }) {
  77. const disabled = Boolean(isSubmitting);
  78. const [open, setOpen] = React.useState(false);
  79. const [activeField, setActiveField] = React.useState(ACTIVE_FIELD.FROM);
  80. const fromRef = React.useRef(null);
  81. const toRef = React.useRef(null);
  82. const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
  83. const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
  84. const validation = React.useMemo(() => {
  85. return getSearchDateRangeValidation(from ?? null, to ?? null);
  86. }, [from, to]);
  87. const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
  88. const now = React.useMemo(() => new Date(), []);
  89. const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
  90. const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]);
  91. const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]);
  92. const [month, setMonth] = React.useState(() => {
  93. return fromDate || toDate || new Date();
  94. });
  95. React.useEffect(() => {
  96. if (!open) return;
  97. // Only run on open transition.
  98. // We intentionally do not depend on from/to to avoid focus jumps while clicking.
  99. if (from && !to) {
  100. setActiveField(ACTIVE_FIELD.TO);
  101. focusRef(toRef);
  102. } else {
  103. setActiveField(ACTIVE_FIELD.FROM);
  104. focusRef(fromRef);
  105. }
  106. setMonth(fromDate || toDate || new Date());
  107. // eslint-disable-next-line react-hooks/exhaustive-deps
  108. }, [open]);
  109. const safeOnDateRangeChange = React.useCallback(
  110. (patch) => {
  111. if (typeof onDateRangeChange !== "function") return;
  112. const hasFrom = hasOwn(patch, "from");
  113. const hasTo = hasOwn(patch, "to");
  114. onDateRangeChange({
  115. from: hasFrom ? patch.from : from ?? null,
  116. to: hasTo ? patch.to : to ?? null,
  117. });
  118. },
  119. [onDateRangeChange, from, to]
  120. );
  121. const { calendarSelected, calendarModifiers, calendarModifiersClassNames } =
  122. React.useMemo(() => {
  123. return buildCalendarState({ fromDate, toDate, isRangeInvalid });
  124. }, [fromDate, toDate, isRangeInvalid]);
  125. const calendarKey = React.useMemo(() => {
  126. return `${from || ""}|${to || ""}|${isRangeInvalid ? "inv" : "ok"}`;
  127. }, [from, to, isRangeInvalid]);
  128. const summary = React.useMemo(() => {
  129. return formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum auswählen";
  130. }, [from, to]);
  131. const fromDisplay = React.useMemo(() => formatIsoDateDe(from) || "", [from]);
  132. const toDisplay = React.useMemo(() => formatIsoDateDe(to) || "", [to]);
  133. const handlePickDay = React.useCallback(
  134. (...args) => {
  135. if (disabled) return;
  136. const { day, modifiers } = normalizeDayClickArgs(args);
  137. if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
  138. if (modifiers?.disabled) return;
  139. const iso = toIsoDateYmdFromDate(day);
  140. if (!iso) return;
  141. if (activeField === ACTIVE_FIELD.FROM) {
  142. safeOnDateRangeChange({ from: iso });
  143. setActiveField(ACTIVE_FIELD.TO);
  144. focusRef(toRef);
  145. return;
  146. }
  147. safeOnDateRangeChange({ to: iso });
  148. setActiveField(ACTIVE_FIELD.TO);
  149. focusRef(toRef);
  150. },
  151. [disabled, activeField, safeOnDateRangeChange]
  152. );
  153. const handleClearFrom = React.useCallback(
  154. (e) => {
  155. e?.preventDefault?.();
  156. e?.stopPropagation?.();
  157. if (disabled) return;
  158. safeOnDateRangeChange({ from: null });
  159. setActiveField(ACTIVE_FIELD.FROM);
  160. focusRef(fromRef);
  161. },
  162. [disabled, safeOnDateRangeChange]
  163. );
  164. const handleClearTo = React.useCallback(
  165. (e) => {
  166. e?.preventDefault?.();
  167. e?.stopPropagation?.();
  168. if (disabled) return;
  169. safeOnDateRangeChange({ to: null });
  170. setActiveField(ACTIVE_FIELD.TO);
  171. focusRef(toRef);
  172. },
  173. [disabled, safeOnDateRangeChange]
  174. );
  175. const handleReset = React.useCallback(() => {
  176. if (disabled) return;
  177. safeOnDateRangeChange({ from: null, to: null });
  178. setActiveField(ACTIVE_FIELD.FROM);
  179. setMonth(new Date());
  180. focusRef(fromRef);
  181. }, [disabled, safeOnDateRangeChange]);
  182. const applyPreset = React.useCallback(
  183. (preset) => {
  184. if (disabled) return;
  185. if (!preset?.from || !preset?.to) return;
  186. safeOnDateRangeChange({ from: preset.from, to: preset.to });
  187. const nextMonth =
  188. toDateFromIsoDateYmd(preset.from) ||
  189. toDateFromIsoDateYmd(preset.to) ||
  190. new Date();
  191. setMonth(nextMonth);
  192. setActiveField(ACTIVE_FIELD.TO);
  193. focusRef(toRef);
  194. },
  195. [disabled, safeOnDateRangeChange]
  196. );
  197. return {
  198. disabled,
  199. open,
  200. setOpen,
  201. activeField,
  202. setActiveField,
  203. fromRef,
  204. toRef,
  205. month,
  206. setMonth,
  207. summary,
  208. fromDisplay,
  209. toDisplay,
  210. presetsRow1,
  211. presetsRow2,
  212. calendarKey,
  213. calendarSelected,
  214. calendarModifiers,
  215. calendarModifiersClassNames,
  216. handlePickDay,
  217. handleClearFrom,
  218. handleClearTo,
  219. handleReset,
  220. applyPreset,
  221. };
  222. }