useSearchDateRangePicker.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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. import {
  12. normalizeDayClickArgs,
  13. buildCalendarState,
  14. } from "@/lib/frontend/search/dateRangePickerUtils";
  15. const ACTIVE_FIELD = Object.freeze({
  16. FROM: "from",
  17. TO: "to",
  18. });
  19. function hasOwn(obj, key) {
  20. return Object.prototype.hasOwnProperty.call(obj || {}, key);
  21. }
  22. function focusRef(ref) {
  23. requestAnimationFrame(() => ref?.current?.focus?.());
  24. }
  25. export function useSearchDateRangePicker({
  26. from,
  27. to,
  28. onDateRangeChange,
  29. isSubmitting,
  30. }) {
  31. const disabled = Boolean(isSubmitting);
  32. const [open, setOpen] = React.useState(false);
  33. const [activeField, setActiveField] = React.useState(ACTIVE_FIELD.FROM);
  34. const fromRef = React.useRef(null);
  35. const toRef = React.useRef(null);
  36. const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
  37. const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
  38. const validation = React.useMemo(() => {
  39. return getSearchDateRangeValidation(from ?? null, to ?? null);
  40. }, [from, to]);
  41. const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
  42. // Presets should reflect the current day even if the app is open across midnight.
  43. // We refresh presets on popover open, and also compute a fresh preset on click.
  44. const [presetsNow, setPresetsNow] = React.useState(() => new Date());
  45. const presets = React.useMemo(() => {
  46. return buildDatePresets({ now: presetsNow });
  47. }, [presetsNow]);
  48. const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]);
  49. const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]);
  50. const [month, setMonth] = React.useState(() => {
  51. return fromDate || toDate || new Date();
  52. });
  53. React.useEffect(() => {
  54. if (!open) return;
  55. // Refresh preset reference "now" whenever the popover opens.
  56. setPresetsNow(new Date());
  57. // Only run on open transition.
  58. // We intentionally do not depend on from/to to avoid focus jumps while clicking.
  59. if (from && !to) {
  60. setActiveField(ACTIVE_FIELD.TO);
  61. focusRef(toRef);
  62. } else {
  63. setActiveField(ACTIVE_FIELD.FROM);
  64. focusRef(fromRef);
  65. }
  66. setMonth(fromDate || toDate || new Date());
  67. // eslint-disable-next-line react-hooks/exhaustive-deps
  68. }, [open]);
  69. const safeOnDateRangeChange = React.useCallback(
  70. (patch) => {
  71. if (typeof onDateRangeChange !== "function") return;
  72. const hasFrom = hasOwn(patch, "from");
  73. const hasTo = hasOwn(patch, "to");
  74. onDateRangeChange({
  75. from: hasFrom ? patch.from : (from ?? null),
  76. to: hasTo ? patch.to : (to ?? null),
  77. });
  78. },
  79. [onDateRangeChange, from, to],
  80. );
  81. const { calendarSelected, calendarModifiers, calendarModifiersClassNames } =
  82. React.useMemo(() => {
  83. return buildCalendarState({ fromDate, toDate, isRangeInvalid });
  84. }, [fromDate, toDate, isRangeInvalid]);
  85. const calendarKey = React.useMemo(() => {
  86. return `${from || ""}|${to || ""}|${isRangeInvalid ? "inv" : "ok"}`;
  87. }, [from, to, isRangeInvalid]);
  88. const summary = React.useMemo(() => {
  89. return formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum auswählen";
  90. }, [from, to]);
  91. const fromDisplay = React.useMemo(() => formatIsoDateDe(from) || "", [from]);
  92. const toDisplay = React.useMemo(() => formatIsoDateDe(to) || "", [to]);
  93. const handlePickDay = React.useCallback(
  94. (...args) => {
  95. if (disabled) return;
  96. const { day, modifiers } = normalizeDayClickArgs(args);
  97. if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
  98. if (modifiers?.disabled) return;
  99. const iso = toIsoDateYmdFromDate(day);
  100. if (!iso) return;
  101. if (activeField === ACTIVE_FIELD.FROM) {
  102. safeOnDateRangeChange({ from: iso });
  103. setActiveField(ACTIVE_FIELD.TO);
  104. focusRef(toRef);
  105. return;
  106. }
  107. safeOnDateRangeChange({ to: iso });
  108. setActiveField(ACTIVE_FIELD.TO);
  109. focusRef(toRef);
  110. },
  111. [disabled, activeField, safeOnDateRangeChange],
  112. );
  113. const handleClearFrom = React.useCallback(
  114. (e) => {
  115. e?.preventDefault?.();
  116. e?.stopPropagation?.();
  117. if (disabled) return;
  118. safeOnDateRangeChange({ from: null });
  119. setActiveField(ACTIVE_FIELD.FROM);
  120. focusRef(fromRef);
  121. },
  122. [disabled, safeOnDateRangeChange],
  123. );
  124. const handleClearTo = React.useCallback(
  125. (e) => {
  126. e?.preventDefault?.();
  127. e?.stopPropagation?.();
  128. if (disabled) return;
  129. safeOnDateRangeChange({ to: null });
  130. setActiveField(ACTIVE_FIELD.TO);
  131. focusRef(toRef);
  132. },
  133. [disabled, safeOnDateRangeChange],
  134. );
  135. const handleReset = React.useCallback(() => {
  136. if (disabled) return;
  137. safeOnDateRangeChange({ from: null, to: null });
  138. setActiveField(ACTIVE_FIELD.FROM);
  139. setMonth(new Date());
  140. focusRef(fromRef);
  141. }, [disabled, safeOnDateRangeChange]);
  142. const applyPreset = React.useCallback(
  143. (preset) => {
  144. if (disabled) return;
  145. if (!preset?.key) return;
  146. // Defensive: compute a fresh preset at click-time (midnight edge-case).
  147. const freshList = buildDatePresets({ now: new Date() });
  148. const fresh = freshList.find((p) => p.key === preset.key) || preset;
  149. if (!fresh?.from || !fresh?.to) return;
  150. safeOnDateRangeChange({ from: fresh.from, to: fresh.to });
  151. const nextMonth =
  152. toDateFromIsoDateYmd(fresh.from) ||
  153. toDateFromIsoDateYmd(fresh.to) ||
  154. new Date();
  155. setMonth(nextMonth);
  156. setActiveField(ACTIVE_FIELD.TO);
  157. focusRef(toRef);
  158. },
  159. [disabled, safeOnDateRangeChange],
  160. );
  161. return {
  162. disabled,
  163. open,
  164. setOpen,
  165. activeField,
  166. setActiveField,
  167. fromRef,
  168. toRef,
  169. month,
  170. setMonth,
  171. summary,
  172. fromDisplay,
  173. toDisplay,
  174. presetsRow1,
  175. presetsRow2,
  176. calendarKey,
  177. calendarSelected,
  178. calendarModifiers,
  179. calendarModifiersClassNames,
  180. isRangeInvalid,
  181. handlePickDay,
  182. handleClearFrom,
  183. handleClearTo,
  184. handleReset,
  185. applyPreset,
  186. };
  187. }