From bd317b60659c703a2ec671b3d70ec6f85b9c1699 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sun, 15 Sep 2024 17:10:12 -0500 Subject: [PATCH 1/5] Update selection hook to use useControlledValue --- src/selection/useMulti.tsx | 17 +++++++---------- src/selection/useRange.test.tsx | 27 --------------------------- src/selection/useRange.tsx | 12 ++++-------- src/selection/useSingle.tsx | 11 ++++------- 4 files changed, 15 insertions(+), 52 deletions(-) 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; }; From 6b8f577a7325997ed928319f0d402721b016ff0c Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sun, 15 Sep 2024 17:10:43 -0500 Subject: [PATCH 2/5] Update useControlledValue docs --- src/helpers/useControlledValue.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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, From c436b93eb775d57de79c8cf30921835ae195ebe4 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sun, 15 Sep 2024 17:10:49 -0500 Subject: [PATCH 3/5] Add test case --- examples/ControlledSelection.tsx | 28 ++++++++++++++++++++++++++++ examples/index.ts | 1 + 2 files changed, 29 insertions(+) create mode 100644 examples/ControlledSelection.tsx diff --git a/examples/ControlledSelection.tsx b/examples/ControlledSelection.tsx new file mode 100644 index 000000000..f77d5a2ed --- /dev/null +++ b/examples/ControlledSelection.tsx @@ -0,0 +1,28 @@ +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, triggerDate: Date) { + if (selected?.from && selected?.to) { + setSelected({ + from: triggerDate, + to: undefined + }); + return; + } + 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"; From 0058edf05d88ba090a6406afd7d1e65425d3b335 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Tue, 17 Sep 2024 18:50:21 -0500 Subject: [PATCH 4/5] Improve example and add test --- examples/ControlledSelection.test.tsx | 72 +++++++++++++++++++++++++++ examples/ControlledSelection.tsx | 8 +-- 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 examples/ControlledSelection.test.tsx diff --git a/examples/ControlledSelection.test.tsx b/examples/ControlledSelection.test.tsx new file mode 100644 index 000000000..30b4555dd --- /dev/null +++ b/examples/ControlledSelection.test.tsx @@ -0,0 +1,72 @@ +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 following the", 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("the selected range is reset on third click", async () => { + 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))); + + 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" + ); +}); diff --git a/examples/ControlledSelection.tsx b/examples/ControlledSelection.tsx index f77d5a2ed..f689b60da 100644 --- a/examples/ControlledSelection.tsx +++ b/examples/ControlledSelection.tsx @@ -6,13 +6,13 @@ import { DateRange, DayPicker } from "react-day-picker"; export function ControlledSelection() { const [selected, setSelected] = useState(); - function handleOnSelect(range: DateRange, triggerDate: Date) { + function handleOnSelect(range: DateRange | undefined, triggerDate: Date) { + // Change the behavior of the selection when a range is already selected if (selected?.from && selected?.to) { - setSelected({ + return setSelected({ from: triggerDate, to: undefined }); - return; } setSelected(range); } @@ -20,7 +20,7 @@ export function ControlledSelection() { return ( From 9d043c044ba5ca0413ec4133a13ebaebd962b779 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Tue, 17 Sep 2024 18:52:56 -0500 Subject: [PATCH 5/5] Add console.log to better spy --- examples/ControlledSelection.test.tsx | 10 +++++++--- examples/ControlledSelection.tsx | 7 +++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/ControlledSelection.test.tsx b/examples/ControlledSelection.test.tsx index 30b4555dd..eb1b62b0b 100644 --- a/examples/ControlledSelection.test.tsx +++ b/examples/ControlledSelection.test.tsx @@ -12,7 +12,7 @@ afterAll(() => jest.useRealTimers()); beforeEach(async () => {}); -test("a range is selected following the", 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))); @@ -39,12 +39,14 @@ test("a range is selected following the", async () => { ); }); -test("the selected range is reset on third click", async () => { +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" @@ -69,4 +71,6 @@ test("the selected range is reset on third click", async () => { "aria-selected", "true" ); + + consoleSpy.mockRestore(); }); diff --git a/examples/ControlledSelection.tsx b/examples/ControlledSelection.tsx index f689b60da..f3158d41b 100644 --- a/examples/ControlledSelection.tsx +++ b/examples/ControlledSelection.tsx @@ -9,12 +9,15 @@ export function ControlledSelection() { function handleOnSelect(range: DateRange | undefined, triggerDate: Date) { // Change the behavior of the selection when a range is already selected if (selected?.from && selected?.to) { - return setSelected({ + // eslint-disable-next-line no-console + console.log("reset range"); + setSelected({ from: triggerDate, to: undefined }); + } else { + setSelected(range); } - setSelected(range); } return (