(react) extend Calendar functionalities with range selection

Extend the functionality of the DatePicker component to include support
for a range calendar. This enhancement allows users to select a date
range spanning multiple calendar cells, enabling more flexible date
selection.
This commit is contained in:
Lebaud Antoine
2023-06-15 16:05:37 +02:00
committed by aleb_the_flash
parent 219d08c82e
commit 3631367e14
2 changed files with 254 additions and 223 deletions

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef } from "react";
import React, { forwardRef, Ref, useMemo, useRef } from "react";
import {
CalendarDate,
createCalendar,
@@ -11,15 +11,24 @@ import {
useDateFormatter,
useLocale,
} from "@react-aria/i18n";
import { useCalendarState } from "@react-stately/calendar";
import { useCalendar } from "@react-aria/calendar";
import {
CalendarState,
RangeCalendarState,
useCalendarState,
useRangeCalendarState,
} from "@react-stately/calendar";
import {
CalendarAria,
useCalendar,
useRangeCalendar,
} from "@react-aria/calendar";
import {
useSelect,
UseSelectReturnValue,
UseSelectStateChange,
} from "downshift";
import classNames from "classnames";
import { CalendarProps } from "react-aria";
import { CalendarProps, RangeCalendarProps } from "react-aria";
import { range } from ":/utils";
import { Button } from ":/components/Button";
import { CalendarGrid } from ":/components/Forms/DatePicker/CalendarGrid";
@@ -75,231 +84,253 @@ const DropdownValues = ({ options, downShift }: DropdownValuesProps) => (
</div>
);
interface CalendarSubProps extends CalendarProps<DateValue> {
interface CalendarAuxProps extends CalendarAria {
minYear?: number;
maxYear?: number;
state: RangeCalendarState | CalendarState;
}
export const Calendar = ({
minYear = 1900, // in gregorian calendar.
maxYear = 2050, // in gregorian calendar.
...props
}: CalendarSubProps) => {
const { locale } = useLocale();
const { t } = useCunningham();
const ref = useRef(null);
const CalendarAux = forwardRef(
(
{
state,
minYear = 1900, // in gregorian calendar.
maxYear = 2050, // in gregorian calendar.
prevButtonProps,
nextButtonProps,
calendarProps,
}: CalendarAuxProps,
ref: Ref<HTMLDivElement>
) => {
const { t } = useCunningham();
const useTimeZoneFormatter = (formatOptions: DateFormatterOptions) => {
return useDateFormatter({
...formatOptions,
timeZone: state.timeZone,
});
};
const monthItemsFormatter = useTimeZoneFormatter({ month: "long" });
const selectedMonthItemFormatter = useTimeZoneFormatter({ month: "short" });
const yearItemsFormatter = useTimeZoneFormatter({ year: "numeric" });
const monthItems: Array<Option> = useMemo(() => {
// Note that in some calendar systems, such as the Hebrew, the number of months may differ between years.
const numberOfMonths = state.focusedDate.calendar.getMonthsInYear(
state.focusedDate
);
return range(1, numberOfMonths).map((monthNumber) => {
const date = state.focusedDate.set({ month: monthNumber });
return {
value: monthNumber,
label: monthItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.month > monthNumber) ||
(!!state.maxValue && state.maxValue.month < monthNumber),
};
});
}, [state.maxValue, state.minValue, state.focusedDate.year]);
const yearItems: Array<Option> = useMemo(() => {
const calendarCurrentUser = createCalendar(
new Intl.DateTimeFormat().resolvedOptions().calendar
);
const minDate = toCalendar(
new CalendarDate(new GregorianCalendar(), minYear, 1, 1),
calendarCurrentUser
);
const maxDate = toCalendar(
new CalendarDate(new GregorianCalendar(), maxYear, 12, 31),
calendarCurrentUser
);
return range(minDate.year, maxDate.year).map((yearNumber) => {
const date = state.focusedDate.set({ year: yearNumber });
return {
value: yearNumber,
label: yearItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.year > yearNumber) ||
(!!state.maxValue && state.maxValue.year < yearNumber),
};
});
}, [state.focusedDate, state.timeZone, state.maxValue, state.minValue]);
const useDownshiftSelect = (
key: string,
items: Array<Option>
): UseSelectReturnValue<Option> => {
return useSelect({
items,
itemToString: optionToString,
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
const updatedFocusedDate = state.focusedDate.set({
[key]: e?.selectedItem?.value,
});
state.setFocusedDate(updatedFocusedDate);
},
});
};
const downshiftMonth = useDownshiftSelect("month", monthItems);
const downshiftYear = useDownshiftSelect("year", yearItems);
// isDisabled and onPress props don't exist on the <Button /> component.
// remove them to avoid any warning.
const {
isDisabled: isPrevButtonDisabled,
onPress: onPressPrev,
...prevButtonOtherProps
} = prevButtonProps;
const {
isDisabled: isNextButtonDisabled,
onPress: onPressNext,
...nextButtonOtherProps
} = nextButtonProps;
const getToggleButtonProps = (
key: string,
items: Array<Option>,
downshift: UseSelectReturnValue<Option>
) => ({
...downshift.getToggleButtonProps(),
onClick: () => {
const selectedItem = items.find(
(item) => item.value === state.focusedDate[key as keyof CalendarDate]
);
if (selectedItem) {
downshift.selectItem(selectedItem);
}
downshift.toggleMenu();
},
"aria-label": t(
`components.forms.date_picker.${key}_select_button_aria_label`
),
});
return (
<div className="c__calendar">
<div
ref={ref}
{...calendarProps}
className={classNames("c__calendar__wrapper", {
"c__calendar__wrapper--opened":
!downshiftMonth.isOpen && !downshiftYear.isOpen,
})}
>
<div className="c__calendar__wrapper__header">
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_before</span>}
{...{
...prevButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.previous_month_button_aria_label"
),
}}
disabled={isPrevButtonDisabled}
onClick={() => state.focusPreviousSection()}
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
{...getToggleButtonProps("month", monthItems, downshiftMonth)}
>
{selectedMonthItemFormatter.format(
state.focusedDate.toDate(state.timeZone)
)}
</Button>
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_next</span>}
{...{
...nextButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.next_month_button_aria_label"
),
}}
disabled={isNextButtonDisabled}
onClick={() => state.focusNextSection()}
/>
</div>
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_before</span>}
onClick={() => state.focusPreviousSection(true)}
disabled={
!!state.minValue &&
state.minValue.year >
state.focusedDate.add({ years: -1 }).year
}
aria-label={t(
"components.forms.date_picker.previous_year_button_aria_label"
)}
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
{...getToggleButtonProps("year", yearItems, downshiftYear)}
>
{yearItemsFormatter.format(
state.focusedDate.toDate(state.timeZone)
)}
</Button>
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_next</span>}
onClick={() => state.focusNextSection(true)}
disabled={
!!state.maxValue &&
state.maxValue.year < state.focusedDate.add({ years: 1 }).year
}
aria-label={t(
"components.forms.date_picker.next_year_button_aria_label"
)}
/>
</div>
</div>
{!downshiftMonth.isOpen && !downshiftYear.isOpen && (
<CalendarGrid state={state} />
)}
</div>
<DropdownValues options={monthItems} downShift={downshiftMonth} />
<DropdownValues options={yearItems} downShift={downshiftYear} />
</div>
);
}
);
export const Calendar = (props: CalendarProps<DateValue>) => {
const { locale } = useLocale();
const ref = useRef<HTMLDivElement>(null);
const state = useCalendarState({
...props,
locale,
createCalendar,
});
const { calendarProps, prevButtonProps, nextButtonProps } = useCalendar(
props,
state
);
const useTimeZoneFormatter = (formatOptions: DateFormatterOptions) => {
return useDateFormatter({
...formatOptions,
timeZone: state.timeZone,
});
};
const monthItemsFormatter = useTimeZoneFormatter({ month: "long" });
const selectedMonthItemFormatter = useTimeZoneFormatter({ month: "short" });
const yearItemsFormatter = useTimeZoneFormatter({ year: "numeric" });
const monthItems: Array<Option> = useMemo(() => {
// Note that in some calendar systems, such as the Hebrew, the number of months may differ between years.
const numberOfMonths = state.focusedDate.calendar.getMonthsInYear(
state.focusedDate
);
return range(1, numberOfMonths).map((monthNumber) => {
const date = state.focusedDate.set({ month: monthNumber });
return {
value: monthNumber,
label: monthItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.month > monthNumber) ||
(!!state.maxValue && state.maxValue.month < monthNumber),
};
});
}, [state.maxValue, state.minValue, state.focusedDate.year]);
const yearItems: Array<Option> = useMemo(() => {
const calendarCurrentUser = createCalendar(
new Intl.DateTimeFormat().resolvedOptions().calendar
);
const minDate = toCalendar(
new CalendarDate(new GregorianCalendar(), minYear, 1, 1),
calendarCurrentUser
);
const maxDate = toCalendar(
new CalendarDate(new GregorianCalendar(), maxYear, 12, 31),
calendarCurrentUser
);
return range(minDate.year, maxDate.year).map((yearNumber) => {
const date = state.focusedDate.set({ year: yearNumber });
return {
value: yearNumber,
label: yearItemsFormatter.format(date.toDate(state.timeZone)),
disabled:
(!!state.minValue && state.minValue.year > yearNumber) ||
(!!state.maxValue && state.maxValue.year < yearNumber),
};
});
}, [state.focusedDate, state.timeZone, state.maxValue, state.minValue]);
const useDownshiftSelect = (
key: string,
items: Array<Option>
): UseSelectReturnValue<Option> => {
return useSelect({
items,
itemToString: optionToString,
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
const updatedFocusedDate = state.focusedDate.set({
[key]: e?.selectedItem?.value,
});
state.setFocusedDate(updatedFocusedDate);
},
});
};
const downshiftMonth = useDownshiftSelect("month", monthItems);
const downshiftYear = useDownshiftSelect("year", yearItems);
// isDisabled and onPress props don't exist on the <Button /> component.
// remove them to avoid any warning.
const {
isDisabled: isPrevButtonDisabled,
onPress: onPressPrev,
...prevButtonOtherProps
} = prevButtonProps;
const {
isDisabled: isNextButtonDisabled,
onPress: onPressNext,
...nextButtonOtherProps
} = nextButtonProps;
const getToggleButtonProps = (
key: string,
items: Array<Option>,
downshift: UseSelectReturnValue<Option>
) => ({
...downshift.getToggleButtonProps(),
onClick: () => {
const selectedItem = items.find(
(item) => item.value === state.focusedDate[key as keyof CalendarDate]
);
if (selectedItem) {
downshift.selectItem(selectedItem);
}
downshift.toggleMenu();
},
"aria-label": t(
`components.forms.date_picker.${key}_select_button_aria_label`
),
});
return (
<div className="c__calendar">
<div
ref={ref}
{...calendarProps}
className={classNames("c__calendar__wrapper", {
"c__calendar__wrapper--opened":
!downshiftMonth.isOpen && !downshiftYear.isOpen,
})}
>
<div className="c__calendar__wrapper__header">
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_before</span>}
{...{
...prevButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.previous_month_button_aria_label"
),
}}
disabled={isPrevButtonDisabled}
onClick={() => state.focusPreviousSection()}
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
{...getToggleButtonProps("month", monthItems, downshiftMonth)}
>
{selectedMonthItemFormatter.format(
state.focusedDate.toDate(state.timeZone)
)}
</Button>
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_next</span>}
{...{
...nextButtonOtherProps,
"aria-label": t(
"components.forms.date_picker.next_month_button_aria_label"
),
}}
disabled={isNextButtonDisabled}
onClick={() => state.focusNextSection()}
/>
</div>
<div className="c__calendar__wrapper__header__actions">
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_before</span>}
onClick={() => state.focusPreviousSection(true)}
disabled={
!!state.minValue &&
state.minValue.year > state.focusedDate.add({ years: -1 }).year
}
aria-label={t(
"components.forms.date_picker.previous_year_button_aria_label"
)}
/>
<Button
className="c__calendar__wrapper__header__actions__dropdown"
color="tertiary"
size="small"
iconPosition="right"
icon={<span className="material-icons">arrow_drop_down</span>}
{...getToggleButtonProps("year", yearItems, downshiftYear)}
>
{yearItemsFormatter.format(
state.focusedDate.toDate(state.timeZone)
)}
</Button>
<Button
color="tertiary"
size="small"
icon={<span className="material-icons">navigate_next</span>}
onClick={() => state.focusNextSection(true)}
disabled={
!!state.maxValue &&
state.maxValue.year < state.focusedDate.add({ years: 1 }).year
}
aria-label={t(
"components.forms.date_picker.next_year_button_aria_label"
)}
/>
</div>
</div>
{!downshiftMonth.isOpen && !downshiftYear.isOpen && (
<CalendarGrid state={state} />
)}
</div>
<DropdownValues options={monthItems} downShift={downshiftMonth} />
<DropdownValues options={yearItems} downShift={downshiftYear} />
</div>
);
const calendarProps = useCalendar(props, state);
return <CalendarAux {...calendarProps} state={state} ref={ref} />;
};
export const CalendarRange = (props: RangeCalendarProps<DateValue>) => {
const { locale } = useLocale();
const ref = useRef<HTMLDivElement>(null);
const state = useRangeCalendarState({
...props,
locale,
createCalendar,
});
const calendarProps = useRangeCalendar(props, state, ref);
return <CalendarAux {...calendarProps} state={state} ref={ref} />;
};

View File

@@ -7,12 +7,12 @@ import {
today,
} from "@internationalized/date";
import { useCalendarGrid } from "react-aria";
import { CalendarState } from "@react-stately/calendar";
import { CalendarState, RangeCalendarState } from "@react-stately/calendar";
import { CalendarCell } from ":/components/Forms/DatePicker/CalendarCell";
import { range } from ":/utils";
interface CalendarGridProps {
state: CalendarState;
state: CalendarState | RangeCalendarState;
defaultDaysInWeek?: number;
}