(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 { import {
CalendarDate, CalendarDate,
createCalendar, createCalendar,
@@ -11,15 +11,24 @@ import {
useDateFormatter, useDateFormatter,
useLocale, useLocale,
} from "@react-aria/i18n"; } from "@react-aria/i18n";
import { useCalendarState } from "@react-stately/calendar"; import {
import { useCalendar } from "@react-aria/calendar"; CalendarState,
RangeCalendarState,
useCalendarState,
useRangeCalendarState,
} from "@react-stately/calendar";
import {
CalendarAria,
useCalendar,
useRangeCalendar,
} from "@react-aria/calendar";
import { import {
useSelect, useSelect,
UseSelectReturnValue, UseSelectReturnValue,
UseSelectStateChange, UseSelectStateChange,
} from "downshift"; } from "downshift";
import classNames from "classnames"; import classNames from "classnames";
import { CalendarProps } from "react-aria"; import { CalendarProps, RangeCalendarProps } from "react-aria";
import { range } from ":/utils"; import { range } from ":/utils";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { CalendarGrid } from ":/components/Forms/DatePicker/CalendarGrid"; import { CalendarGrid } from ":/components/Forms/DatePicker/CalendarGrid";
@@ -75,231 +84,253 @@ const DropdownValues = ({ options, downShift }: DropdownValuesProps) => (
</div> </div>
); );
interface CalendarSubProps extends CalendarProps<DateValue> { interface CalendarAuxProps extends CalendarAria {
minYear?: number; minYear?: number;
maxYear?: number; maxYear?: number;
state: RangeCalendarState | CalendarState;
} }
export const Calendar = ({ const CalendarAux = forwardRef(
minYear = 1900, // in gregorian calendar. (
maxYear = 2050, // in gregorian calendar. {
...props state,
}: CalendarSubProps) => { minYear = 1900, // in gregorian calendar.
const { locale } = useLocale(); maxYear = 2050, // in gregorian calendar.
const { t } = useCunningham(); prevButtonProps,
const ref = useRef(null); 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({ const state = useCalendarState({
...props, ...props,
locale, locale,
createCalendar, createCalendar,
}); });
const { calendarProps, prevButtonProps, nextButtonProps } = useCalendar( const calendarProps = useCalendar(props, state);
props, return <CalendarAux {...calendarProps} state={state} ref={ref} />;
state };
);
export const CalendarRange = (props: RangeCalendarProps<DateValue>) => {
const useTimeZoneFormatter = (formatOptions: DateFormatterOptions) => { const { locale } = useLocale();
return useDateFormatter({ const ref = useRef<HTMLDivElement>(null);
...formatOptions, const state = useRangeCalendarState({
timeZone: state.timeZone, ...props,
}); locale,
}; createCalendar,
});
const monthItemsFormatter = useTimeZoneFormatter({ month: "long" }); const calendarProps = useRangeCalendar(props, state, ref);
const selectedMonthItemFormatter = useTimeZoneFormatter({ month: "short" }); return <CalendarAux {...calendarProps} state={state} ref={ref} />;
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>
);
}; };

View File

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