import React, { FC, ReactNode, RefObject, useEffect, useMemo, useRef, useState, } from "react"; import classNames from "classnames"; import { format, isAfter, isBefore, parse } from "date-fns"; import { Box } from "../Box"; import { bemHOF } from "../../utilities/bem"; import { Button, BUTTON_ICON_POSITION, BUTTON_VARIANT } from "../Button"; import { MenuRenderer } from "../MenuRenderer"; import { PopoverMenu } from "../PopoverMenu"; import { Icon, ICON_TYPE } from "../Icon"; import { Flex } from "../Flex"; import { Text } from "../Text"; import { Heading } from "../Heading"; import { CustomDateRangeInput } from "./CustomDateRangeInput"; import { DATE_FORMAT, parseDate } from "../../utilities"; import { DatePicker } from "../DatePicker"; import { isValidDate } from "../../utilities/isValidDate"; const cn = bemHOF("DateRangePicker"); export interface DateRangePickerValueWithDates { startDate: Date; endDate: Date; } export interface DateRangePickerPreset { name: string; startDate: Date; endDate: Date; } export type DateRangePickerValue = | DateRangePickerValueWithDates | null | DateRangePickerPreset; export interface DateRangePickerProps { /** * Accepts a `DateRangePickerPreset` (`{ name: string; startDate: Date; endDate: Date}`), * a custom value (same as a preset but without a `name`), or `null` */ value: DateRangePickerValue; /** * Called every time a new date range is selected */ onChange: (newValue: DateRangePickerValue) => void; /** * These are the options that appear above "Custom" when the `DateRangePicker` is opened. * Each preset object should include a `name`, `startDate`, and `endDate`. */ presets: DateRangePickerPreset[]; /** * This determines how the menu aligns with the dropdown control. If right aligned, the * custom date selection UI will appear to the left of the list of presets. */ align?: "left" | "right"; /** * The earliest custom date that can be selected */ minDate?: Date; /** * The latest custom date that can be selected */ maxDate?: Date; /** * Classes to be added to the outermost element */ className?: string; } interface DateRangePickerPresetProps { isSelected: boolean; children: ReactNode; onClick: () => void; } const RangePickerPreset = ({ isSelected, children, onClick, }: DateRangePickerPresetProps) => { const classes = classNames( cn({ e: "preset" }), isSelected && cn({ e: "preset", m: "isSelected" }), ); return ( ); }; const CUSTOM_PRESET = "Custom"; const getMatchingPreset = ( value: DateRangePickerValue, presets: DateRangePickerPreset[], ) => { if (!value || !("name" in value)) return null; return presets.find((p) => p.name === value.name); }; const focusInput = (ref: RefObject) => { const input = ref.current; const { activeElement } = document; if (input && input !== activeElement) { input.focus(); } }; export const DateRangePicker: FC = ({ className, onChange, align = "left", presets, value, minDate, maxDate, ...rest }) => { const placement = align === "left" ? "bottom-start" : "bottom-end"; const [selectingCustomDate, setSelectingCustomDate] = useState( !getMatchingPreset(value, presets) && value !== null, ); const [ customStartDateInputVal, setCustomStartDateInputVal, ] = useState(""); const [customStartDateError, setCustomStartDateError] = useState(""); const [customEndDateInputVal, setCustomEndDateInputVal] = useState( "", ); const [customEndDateError, setCustomEndDateError] = useState(""); const [customSelectingWhichDate, setCustomSelectingWhichDate] = useState< "start" | "end" >("start"); const startInput = useRef(null); const endInput = useRef(null); const handleClose = () => { setCustomStartDateError(""); if (selectingCustomDate && value && "name" in value) { setSelectingCustomDate(false); } }; const selectedPreset = useMemo(() => { if (!value) return null; if (selectingCustomDate) return CUSTOM_PRESET; const matchingPreset = getMatchingPreset(value, presets); return matchingPreset ? matchingPreset.name : CUSTOM_PRESET; }, [presets, value, selectingCustomDate]); const customDateInputsValid = useMemo(() => { const parsedStart = parseDate(customStartDateInputVal); const parsedEnd = parseDate(customEndDateInputVal); return !!(parsedStart && parsedEnd); }, [customStartDateInputVal, customEndDateInputVal]); const displayText = useMemo(() => { if (!value) return "Select Date Range"; const matchingPreset = getMatchingPreset(value, presets); if (selectingCustomDate && matchingPreset) return CUSTOM_PRESET; return selectedPreset === CUSTOM_PRESET ? `${format(value.startDate, DATE_FORMAT)} to ${format( value.endDate, DATE_FORMAT, )}` : selectedPreset; }, [selectedPreset, value, selectingCustomDate, presets]); useEffect(() => { if (!selectingCustomDate || customDateInputsValid) return; const inputToFocus = customSelectingWhichDate === "start" ? startInput : endInput; focusInput(inputToFocus); }, [customSelectingWhichDate, selectingCustomDate, customDateInputsValid]); const validDates = useMemo(() => { let start = null; let end = null; // Parse start date if (customStartDateInputVal) { const parsed = parse(customStartDateInputVal, DATE_FORMAT, new Date()); start = isValidDate(parsed) ? parsed : null; } // Parse end date if (customEndDateInputVal) { const parsed = parse(customEndDateInputVal, DATE_FORMAT, new Date()); end = isValidDate(parsed) ? parsed : null; } // Start date before minDate or after maxDate if ( start && ((minDate && isBefore(start, minDate)) || (maxDate && isAfter(start, maxDate))) ) { setCustomStartDateError("Outside allowed date range"); start = null; } // End date before minDate or after maxDate if ( end && ((minDate && isBefore(end, minDate)) || (maxDate && isAfter(end, maxDate))) ) { setCustomEndDateError("Outside allowed date range"); end = null; } // Start date after endDate if (start && end && isAfter(start, end)) { setCustomEndDateError("End date cannot be before start date"); end = null; } // If start is a Date, remove any error if (start) { setCustomStartDateError(""); } // If end is a Date, remove any error if (end) { setCustomEndDateError(""); } return { start, end }; }, [customStartDateInputVal, customEndDateInputVal, minDate, maxDate]); const handleDatePickerChange = ([newStartDate, newEndDate]: [Date, Date]) => { if (!newEndDate) focusInput(endInput); const newStartString = newStartDate ? format(newStartDate, DATE_FORMAT) : ""; const newEndString = newEndDate ? format(newEndDate, DATE_FORMAT) : ""; setCustomStartDateInputVal(newStartString); setCustomEndDateInputVal(newEndString); }; const handleDatePickerSelect = (date: Date) => { // Handles situation where an end date is provided // without a start date if (!validDates.start && validDates.end) { setCustomStartDateInputVal(format(date, DATE_FORMAT)); } }; const customApplyBtnDisabled = !validDates.start || !validDates.end || !!customStartDateError || !!customEndDateError; return ( ( )} {...rest} > {({ listProps, onClose }) => ( {presets.map((p) => ( { onChange(p); if (onClose) { onClose(); } }} isSelected={p.name === selectedPreset} > {p.name} ))} { if (selectingCustomDate) return; setSelectingCustomDate(true); }} isSelected={selectedPreset === CUSTOM_PRESET} > Custom {selectedPreset === CUSTOM_PRESET ? ( setCustomSelectingWhichDate("start")} /> to setCustomSelectingWhichDate("end")} /> ) : null} )} ); };