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