Skip to content

Commit

Permalink
fix: controlled vs. uncontrolled selections (#2462)
Browse files Browse the repository at this point in the history
* Update selection hook to use useControlledValue

* Update useControlledValue docs

* Add test case

* Improve example and add test

* Add console.log to better spy
  • Loading branch information
gpbl authored Sep 17, 2024
1 parent f79d58f commit 0912b80
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 58 deletions.
76 changes: 76 additions & 0 deletions examples/ControlledSelection.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ControlledSelection />);
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(<ControlledSelection />);
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();
});
31 changes: 31 additions & 0 deletions examples/ControlledSelection.tsx
Original file line number Diff line number Diff line change
@@ -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<DateRange | undefined>();

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 (
<DayPicker
mode="range"
min={1}
selected={selected}
onSelect={handleOnSelect}
/>
);
}
1 change: 1 addition & 0 deletions examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
18 changes: 12 additions & 6 deletions src/helpers/useControlledValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ import { useState } from "react";
export type DispatchStateAction<T> = React.Dispatch<React.SetStateAction<T>>;

/**
* 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<T>]} - 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<T>(
defaultValue: T,
Expand Down
17 changes: 7 additions & 10 deletions src/selection/useMulti.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";

import { useControlledValue } from "../helpers/useControlledValue.js";
import type {
DateLib,
DayPickerProps,
Expand All @@ -15,20 +16,16 @@ export function useMulti<T extends DayPickerProps>(
const {
selected: initiallySelected,
required,
onSelect,
mode
onSelect
} = props as PropsMulti;
const [selected, setSelected] = React.useState<Date[] | undefined>(
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;
};
Expand Down Expand Up @@ -60,8 +57,8 @@ export function useMulti<T extends DayPickerProps>(
newDates = [...newDates, triggerDate];
}
}
onSelect?.(newDates, triggerDate, modifiers, e);
setSelected(newDates);
onSelect?.(newDates, triggerDate, modifiers, e);
return newDates;
};

Expand Down
27 changes: 0 additions & 27 deletions src/selection/useRange.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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),
Expand Down
12 changes: 4 additions & 8 deletions src/selection/useRange.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react";

import { useControlledValue } from "../helpers/useControlledValue.js";
import type {
DateLib,
DateRange,
DayPickerProps,
Modifiers,
PropsRange,
Expand All @@ -23,15 +23,11 @@ export function useRange<T extends DayPickerProps>(
onSelect
} = props as PropsRange;

const [selected, setSelected] = React.useState<DateRange | undefined>(
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);

Expand Down
11 changes: 4 additions & 7 deletions src/selection/useSingle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";

import { useControlledValue } from "../helpers/useControlledValue.js";
import type {
DateLib,
DayPickerProps,
Expand All @@ -26,17 +27,13 @@ export function useSingle<T extends DayPickerProps>(
onSelect
} = props as PropsSingle;

const [selected, setSelected] = React.useState<Date | undefined>(
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;
};
Expand Down

0 comments on commit 0912b80

Please sign in to comment.