| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- "use client";
- import React from "react";
- import { buildDatePresets } from "@/lib/frontend/search/datePresets";
- import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
- import {
- formatIsoDateDe,
- formatIsoDateRangeLabelDe,
- toDateFromIsoDateYmd,
- toIsoDateYmdFromDate,
- } from "@/lib/frontend/search/dateRange";
- import {
- normalizeDayClickArgs,
- buildCalendarState,
- } from "@/lib/frontend/search/dateRangePickerUtils";
- const ACTIVE_FIELD = Object.freeze({
- FROM: "from",
- TO: "to",
- });
- function hasOwn(obj, key) {
- return Object.prototype.hasOwnProperty.call(obj || {}, key);
- }
- function focusRef(ref) {
- requestAnimationFrame(() => ref?.current?.focus?.());
- }
- export function useSearchDateRangePicker({
- from,
- to,
- onDateRangeChange,
- isSubmitting,
- }) {
- const disabled = Boolean(isSubmitting);
- const [open, setOpen] = React.useState(false);
- const [activeField, setActiveField] = React.useState(ACTIVE_FIELD.FROM);
- const fromRef = React.useRef(null);
- const toRef = React.useRef(null);
- const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
- const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
- const validation = React.useMemo(() => {
- return getSearchDateRangeValidation(from ?? null, to ?? null);
- }, [from, to]);
- const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
- // Presets should reflect the current day even if the app is open across midnight.
- // We refresh presets on popover open, and also compute a fresh preset on click.
- const [presetsNow, setPresetsNow] = React.useState(() => new Date());
- const presets = React.useMemo(() => {
- return buildDatePresets({ now: presetsNow });
- }, [presetsNow]);
- const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]);
- const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]);
- const [month, setMonth] = React.useState(() => {
- return fromDate || toDate || new Date();
- });
- React.useEffect(() => {
- if (!open) return;
- // Refresh preset reference "now" whenever the popover opens.
- setPresetsNow(new Date());
- // Only run on open transition.
- // We intentionally do not depend on from/to to avoid focus jumps while clicking.
- if (from && !to) {
- setActiveField(ACTIVE_FIELD.TO);
- focusRef(toRef);
- } else {
- setActiveField(ACTIVE_FIELD.FROM);
- focusRef(fromRef);
- }
- setMonth(fromDate || toDate || new Date());
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open]);
- const safeOnDateRangeChange = React.useCallback(
- (patch) => {
- if (typeof onDateRangeChange !== "function") return;
- const hasFrom = hasOwn(patch, "from");
- const hasTo = hasOwn(patch, "to");
- onDateRangeChange({
- from: hasFrom ? patch.from : (from ?? null),
- to: hasTo ? patch.to : (to ?? null),
- });
- },
- [onDateRangeChange, from, to],
- );
- const { calendarSelected, calendarModifiers, calendarModifiersClassNames } =
- React.useMemo(() => {
- return buildCalendarState({ fromDate, toDate, isRangeInvalid });
- }, [fromDate, toDate, isRangeInvalid]);
- const calendarKey = React.useMemo(() => {
- return `${from || ""}|${to || ""}|${isRangeInvalid ? "inv" : "ok"}`;
- }, [from, to, isRangeInvalid]);
- const summary = React.useMemo(() => {
- return formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum auswählen";
- }, [from, to]);
- const fromDisplay = React.useMemo(() => formatIsoDateDe(from) || "", [from]);
- const toDisplay = React.useMemo(() => formatIsoDateDe(to) || "", [to]);
- const handlePickDay = React.useCallback(
- (...args) => {
- if (disabled) return;
- const { day, modifiers } = normalizeDayClickArgs(args);
- if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
- if (modifiers?.disabled) return;
- const iso = toIsoDateYmdFromDate(day);
- if (!iso) return;
- if (activeField === ACTIVE_FIELD.FROM) {
- safeOnDateRangeChange({ from: iso });
- setActiveField(ACTIVE_FIELD.TO);
- focusRef(toRef);
- return;
- }
- safeOnDateRangeChange({ to: iso });
- setActiveField(ACTIVE_FIELD.TO);
- focusRef(toRef);
- },
- [disabled, activeField, safeOnDateRangeChange],
- );
- const handleClearFrom = React.useCallback(
- (e) => {
- e?.preventDefault?.();
- e?.stopPropagation?.();
- if (disabled) return;
- safeOnDateRangeChange({ from: null });
- setActiveField(ACTIVE_FIELD.FROM);
- focusRef(fromRef);
- },
- [disabled, safeOnDateRangeChange],
- );
- const handleClearTo = React.useCallback(
- (e) => {
- e?.preventDefault?.();
- e?.stopPropagation?.();
- if (disabled) return;
- safeOnDateRangeChange({ to: null });
- setActiveField(ACTIVE_FIELD.TO);
- focusRef(toRef);
- },
- [disabled, safeOnDateRangeChange],
- );
- const handleReset = React.useCallback(() => {
- if (disabled) return;
- safeOnDateRangeChange({ from: null, to: null });
- setActiveField(ACTIVE_FIELD.FROM);
- setMonth(new Date());
- focusRef(fromRef);
- }, [disabled, safeOnDateRangeChange]);
- const applyPreset = React.useCallback(
- (preset) => {
- if (disabled) return;
- if (!preset?.key) return;
- // Defensive: compute a fresh preset at click-time (midnight edge-case).
- const freshList = buildDatePresets({ now: new Date() });
- const fresh = freshList.find((p) => p.key === preset.key) || preset;
- if (!fresh?.from || !fresh?.to) return;
- safeOnDateRangeChange({ from: fresh.from, to: fresh.to });
- const nextMonth =
- toDateFromIsoDateYmd(fresh.from) ||
- toDateFromIsoDateYmd(fresh.to) ||
- new Date();
- setMonth(nextMonth);
- setActiveField(ACTIVE_FIELD.TO);
- focusRef(toRef);
- },
- [disabled, safeOnDateRangeChange],
- );
- return {
- disabled,
- open,
- setOpen,
- activeField,
- setActiveField,
- fromRef,
- toRef,
- month,
- setMonth,
- summary,
- fromDisplay,
- toDisplay,
- presetsRow1,
- presetsRow2,
- calendarKey,
- calendarSelected,
- calendarModifiers,
- calendarModifiersClassNames,
- isRangeInvalid,
- handlePickDay,
- handleClearFrom,
- handleClearTo,
- handleReset,
- applyPreset,
- };
- }
|