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}
)}
);
};