diff --git a/examples/ControlledSelection.test.tsx b/examples/ControlledSelection.test.tsx new file mode 100644 index 000000000..eb1b62b0b --- /dev/null +++ b/examples/ControlledSelection.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; + +import { dateButton, gridcell } from "@/test/elements"; +import { render } from "@/test/render"; +import { user } from "@/test/user"; + +import { ControlledSelection } from "./ControlledSelection"; + +const today = new Date(2024, 8, 17); +beforeAll(() => jest.setSystemTime(today)); +afterAll(() => jest.useRealTimers()); + +beforeEach(async () => {}); + +test("a range is selected after clicking two dates", async () => { + render(); + await user.click(dateButton(new Date(2024, 8, 1))); + await user.click(dateButton(new Date(2024, 8, 4))); + + expect(gridcell(new Date(2024, 8, 1), true)).toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 2), true)).toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 3), true)).toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 4), true)).toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 5), true)).not.toHaveAttribute( + "aria-selected", + "true" + ); +}); + +test("a range is reset after clicking a third date", async () => { + const consoleSpy = jest.spyOn(console, "log"); + render(); + await user.click(dateButton(new Date(2024, 8, 1))); + await user.click(dateButton(new Date(2024, 8, 4))); + await user.click(dateButton(new Date(2024, 8, 5))); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith("reset range"); + expect(gridcell(new Date(2024, 8, 1), true)).not.toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 2), true)).not.toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 3), true)).not.toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 4), true)).not.toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 5), true)).toHaveAttribute( + "aria-selected", + "true" + ); + expect(gridcell(new Date(2024, 8, 6), true)).not.toHaveAttribute( + "aria-selected", + "true" + ); + + consoleSpy.mockRestore(); +}); diff --git a/examples/ControlledSelection.tsx b/examples/ControlledSelection.tsx new file mode 100644 index 000000000..f3158d41b --- /dev/null +++ b/examples/ControlledSelection.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { useState } from "react"; + +import { DateRange, DayPicker } from "react-day-picker"; + +export function ControlledSelection() { + const [selected, setSelected] = useState(); + + function handleOnSelect(range: DateRange | undefined, triggerDate: Date) { + // Change the behavior of the selection when a range is already selected + if (selected?.from && selected?.to) { + // eslint-disable-next-line no-console + console.log("reset range"); + setSelected({ + from: triggerDate, + to: undefined + }); + } else { + setSelected(range); + } + } + + return ( + + ); +} diff --git a/examples/index.ts b/examples/index.ts index f425b8e1f..95e4a63d7 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -2,6 +2,7 @@ export * from "./AccessibleDatePicker"; export * from "./AutoFocus"; export * from "./ContainerAttributes"; export * from "./Controlled"; +export * from "./ControlledSelection"; export * from "./CssModules"; export * from "./CssVariables"; export * from "./CustomCaption"; diff --git a/src/helpers/useControlledValue.ts b/src/helpers/useControlledValue.ts index 0e8845b53..bc8e90211 100644 --- a/src/helpers/useControlledValue.ts +++ b/src/helpers/useControlledValue.ts @@ -3,16 +3,22 @@ import { useState } from "react"; export type DispatchStateAction = React.Dispatch>; /** - * A helper hook for handling controlled and uncontrolled values in a - * component's props. + * A custom hook for managing both controlled and uncontrolled component states. * - * If the value is uncontrolled, pass `undefined` as `controlledValue` and use - * the returned setter to update it. + * @example + * // Uncontrolled usage + * const [value, setValue] = useControlledValue(0, undefined); * - * If the value is controlled, pass the controlled value as the second argument, - * which will always be returned as `value`. + * // Controlled usage + * const [value, setValue] = useControlledValue(0, props.value); * * @template T - The type of the value. + * @param {T} defaultValue - The initial value for the uncontrolled state. + * @param {T | undefined} controlledValue - The value for the controlled state. + * If undefined, the component will use the uncontrolled state. + * @returns {[T, DispatchStateAction]} - Returns a tuple where the first + * element is the current value (either controlled or uncontrolled) and the + * second element is a setter function to update the value. */ export function useControlledValue( defaultValue: T, diff --git a/src/selection/useMulti.tsx b/src/selection/useMulti.tsx index 04a61323f..ab1131e4e 100644 --- a/src/selection/useMulti.tsx +++ b/src/selection/useMulti.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { useControlledValue } from "../helpers/useControlledValue.js"; import type { DateLib, DayPickerProps, @@ -15,20 +16,16 @@ export function useMulti( const { selected: initiallySelected, required, - onSelect, - mode + onSelect } = props as PropsMulti; - const [selected, setSelected] = React.useState( - initiallySelected + + const [selected, setSelected] = useControlledValue( + initiallySelected, + onSelect ? initiallySelected : undefined ); const { isSameDay } = dateLib; - // Update the selected date if the selected value from props changes. - React.useEffect(() => { - setSelected(initiallySelected); - }, [mode, initiallySelected]); - const isSelected = (date: Date) => { return selected?.some((d) => isSameDay(d, date)) ?? false; }; @@ -60,8 +57,8 @@ export function useMulti( newDates = [...newDates, triggerDate]; } } - onSelect?.(newDates, triggerDate, modifiers, e); setSelected(newDates); + onSelect?.(newDates, triggerDate, modifiers, e); return newDates; }; diff --git a/src/selection/useRange.test.tsx b/src/selection/useRange.test.tsx index 87076082d..d33de935e 100644 --- a/src/selection/useRange.test.tsx +++ b/src/selection/useRange.test.tsx @@ -1,6 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { DateRange, DayPickerProps } from "react-day-picker/types"; - import { act, renderHook } from "@/test/render"; import { dateLib } from "../lib"; @@ -23,31 +21,6 @@ describe("useRange", () => { expect(result.current.selected).toEqual(initiallySelected); }); - test("update the selected range when the initially selected value changes", () => { - const initiallySelected: DateRange = { - from: new Date(2023, 6, 1), - to: new Date(2023, 6, 5) - }; - const { result, rerender } = renderHook( - (props) => useRange(props, dateLib), - { - initialProps: { - mode: "range", - selected: initiallySelected, - required: false - } as DayPickerProps - } - ); - - rerender({ - mode: "range", - selected: undefined, - required: false - }); - - expect(result.current.selected).toEqual(undefined); - }); - test("update the selected range on select", () => { const initiallySelected = { from: new Date(2023, 6, 1), diff --git a/src/selection/useRange.tsx b/src/selection/useRange.tsx index 21cfeccbf..fd447d702 100644 --- a/src/selection/useRange.tsx +++ b/src/selection/useRange.tsx @@ -1,8 +1,8 @@ import React from "react"; +import { useControlledValue } from "../helpers/useControlledValue.js"; import type { DateLib, - DateRange, DayPickerProps, Modifiers, PropsRange, @@ -23,15 +23,11 @@ export function useRange( onSelect } = props as PropsRange; - const [selected, setSelected] = React.useState( - initiallySelected + const [selected, setSelected] = useControlledValue( + initiallySelected, + onSelect ? initiallySelected : undefined ); - // Update the selected date if the `selected` prop changes. - React.useEffect(() => { - setSelected(initiallySelected); - }, [initiallySelected]); - const isSelected = (date: Date) => selected && rangeIncludesDate(selected, date, false, dateLib); diff --git a/src/selection/useSingle.tsx b/src/selection/useSingle.tsx index b33ad9cb2..680029357 100644 --- a/src/selection/useSingle.tsx +++ b/src/selection/useSingle.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { useControlledValue } from "../helpers/useControlledValue.js"; import type { DateLib, DayPickerProps, @@ -26,17 +27,13 @@ export function useSingle( onSelect } = props as PropsSingle; - const [selected, setSelected] = React.useState( - initiallySelected + const [selected, setSelected] = useControlledValue( + initiallySelected, + onSelect ? initiallySelected : undefined ); const { isSameDay } = dateLib; - // Update the selected date if the `selected` value changes. - React.useEffect(() => { - setSelected(initiallySelected); - }, [initiallySelected]); - const isSelected = (compareDate: Date) => { return selected ? isSameDay(selected, compareDate) : false; };