diff --git a/examples/RangeResetSelection.test.tsx b/examples/RangeResetSelection.test.tsx index f52f843227..a308ede913 100644 --- a/examples/RangeResetSelection.test.tsx +++ b/examples/RangeResetSelection.test.tsx @@ -17,24 +17,24 @@ const getTo = () => screen.getByTestId("to"); test("select same day range", async () => { await user.click(dateButton(today)); - expect(getFrom()).toHaveTextContent("from: 2022-09-12"); - expect(getTo()).toHaveTextContent("to:"); + expect(getFrom()).toHaveTextContent("2022-09-12"); + expect(getTo()).toHaveTextContent(""); await user.click(dateButton(today)); - expect(getFrom()).toHaveTextContent("from: 2022-09-12"); - expect(getTo()).toHaveTextContent("to: 2022-09-12"); + expect(getFrom()).toHaveTextContent("2022-09-12"); + expect(getTo()).toHaveTextContent("—2022-09-12"); }); test("start range after click on day with range selected", async () => { await user.click(dateButton(today)); - expect(getFrom()).toHaveTextContent("from: 2022-09-12"); - expect(getTo()).toHaveTextContent("to:"); + expect(getFrom()).toHaveTextContent("2022-09-12"); + expect(getTo()).toHaveTextContent(""); await user.click(dateButton(addDays(today, 1))); - expect(getFrom()).toHaveTextContent("from: 2022-09-12"); - expect(getTo()).toHaveTextContent("to: 2022-09-13"); + expect(getFrom()).toHaveTextContent("2022-09-12"); + expect(getTo()).toHaveTextContent("—2022-09-13"); await user.click(dateButton(addDays(today, 4))); - expect(getFrom()).toHaveTextContent("from: 2022-09-16"); - expect(getTo()).toHaveTextContent("to:"); + expect(getFrom()).toHaveTextContent("2022-09-16"); + expect(getTo()).toHaveTextContent(""); await user.click(dateButton(today)); - expect(getFrom()).toHaveTextContent("from: 2022-09-12"); - expect(getTo()).toHaveTextContent("to: 2022-09-16"); + expect(getFrom()).toHaveTextContent("2022-09-12"); + expect(getTo()).toHaveTextContent("—2022-09-16"); }); diff --git a/examples/RangeResetSelection.tsx b/examples/RangeResetSelection.tsx index 6d1adf2dd7..c9f7a0580e 100644 --- a/examples/RangeResetSelection.tsx +++ b/examples/RangeResetSelection.tsx @@ -3,7 +3,6 @@ import React, { useState } from "react"; import { type DateRange, - type DayEventHandler, DayPicker, type OnSelectHandler, } from "react-day-picker"; @@ -11,21 +10,8 @@ import { export function RangeResetSelection() { const [selected, setSelected] = useState(); - // use onSelect event which properly handles valid range selection - // based on valid days in the calendar const handleSelect: OnSelectHandler = (range) => { - // the other cases are handled by onDayClick handler - if (selected?.from && !selected.to) { - setSelected(range); - } - }; - - const handleDayClick: DayEventHandler = (date) => { - // handled by onSelect handler - if (selected?.from && !selected.to) { - return; - } - setSelected({ from: date }); + setSelected(range); }; return ( @@ -33,16 +19,16 @@ export function RangeResetSelection() { mode="range" selected={selected} onSelect={handleSelect} - onDayClick={handleDayClick} + resetOnSelect footer={ -
-

- from: {selected?.from && format(selected?.from, "yyyy-MM-dd")} -

-

- to: {selected?.to && format(selected?.to, "yyyy-MM-dd")} -

-
+

+ + {selected?.from && format(selected?.from, "yyyy-MM-dd")} + + + {selected?.to && `—${format(selected?.to, "yyyy-MM-dd")}`} + +

} /> ); diff --git a/src/selection/useRange.test.tsx b/src/selection/useRange.test.tsx index 5ff3c5039c..ffbf509331 100644 --- a/src/selection/useRange.test.tsx +++ b/src/selection/useRange.test.tsx @@ -123,6 +123,106 @@ describe("useRange", () => { to: new Date(2023, 6, 10), }); }); + + describe("resetOnSelect", () => { + test("sets only from when selected is undefined", () => { + const date = new Date(2023, 6, 15); + const { result } = renderHook(() => + useRange( + { + mode: "range", + selected: undefined, + required: false, + resetOnSelect: true, + }, + defaultDateLib, + ), + ); + + act(() => { + result.current.select?.(date, {}, {} as React.MouseEvent); + }); + + expect(result.current.selected).toEqual({ + from: date, + to: undefined, + }); + }); + + test("reset range when full range is selected", () => { + const fullRange = { + from: new Date(2023, 6, 1), + to: new Date(2023, 6, 5), + }; + const anotherDate = new Date(2023, 6, 15); + const { result } = renderHook(() => + useRange( + { + mode: "range", + selected: fullRange, + required: false, + resetOnSelect: true, + }, + defaultDateLib, + ), + ); + + act(() => { + result.current.select?.(anotherDate, {}, {} as React.MouseEvent); + }); + + expect(result.current.selected).toEqual({ + from: anotherDate, + to: undefined, + }); + }); + + test("clears a single-day range when required is false", () => { + const day = new Date(2023, 6, 15); + const { result } = renderHook(() => + useRange( + { + mode: "range", + selected: { from: day, to: day }, + required: false, + resetOnSelect: true, + }, + defaultDateLib, + ), + ); + + act(() => { + result.current.select?.(day, {}, {} as React.MouseEvent); + }); + + expect(result.current.selected).toBeUndefined(); + }); + + test("resets to an open range when required is true", () => { + const day = new Date(2023, 6, 15); + const { result } = renderHook(() => + useRange( + { + mode: "range", + selected: { from: day, to: day }, + required: true, + resetOnSelect: true, + }, + defaultDateLib, + ), + ); + + act(() => { + result.current.select?.(day, {}, {} as React.MouseEvent); + }); + + expect(result.current.selected).toEqual({ + from: day, + to: undefined, + }); + }); + }); + it("uses the selected value from props when onSelect is provided", () => { const mockOnSelect = jest.fn(); const selectedRange = { diff --git a/src/selection/useRange.tsx b/src/selection/useRange.tsx index d536655995..b58d533d46 100644 --- a/src/selection/useRange.tsx +++ b/src/selection/useRange.tsx @@ -27,6 +27,7 @@ export function useRange( const { disabled, excludeDisabled, + resetOnSelect, selected: initiallySelected, required, onSelect, @@ -48,9 +49,34 @@ export function useRange( e: React.MouseEvent | React.KeyboardEvent, ) => { const { min, max } = props as PropsRange; - const newRange = triggerDate - ? addToRange(triggerDate, selected, min, max, required, dateLib) - : undefined; + let newRange: ReturnType; + if (triggerDate) { + const selectedFrom = selected?.from; + const selectedTo = selected?.to; + const hasFullRange = !!selectedFrom && !!selectedTo; + const isClickingSingleDayRange = + !!selectedFrom && + !!selectedTo && + dateLib.isSameDay(selectedFrom, selectedTo) && + dateLib.isSameDay(triggerDate, selectedFrom); + + if (resetOnSelect && (hasFullRange || !selected?.from)) { + if (!required && isClickingSingleDayRange) { + newRange = undefined; + } else { + newRange = { from: triggerDate, to: undefined }; + } + } else { + newRange = addToRange( + triggerDate, + selected, + min, + max, + required, + dateLib, + ); + } + } if (excludeDisabled && disabled && newRange?.from && newRange.to) { if ( diff --git a/src/types/props.ts b/src/types/props.ts index 9f73a14ca5..7abd554cc0 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -699,9 +699,18 @@ export interface PropsRangeRequired { /** * When `true`, the range will reset when including a disabled day. * - * @since V9.0.2 + * @since 9.0.2 */ excludeDisabled?: boolean | undefined; + /** + * When `true`, clicking a day starts a new range if there is no current start + * date or if a range is already complete. In those cases, the clicked day + * becomes the start of the new range. + * + * @since 9.14 + * @see https://daypicker.dev/selections/range-mode#reset-selection + */ + resetOnSelect?: boolean | undefined; /** The selected range. */ selected: DateRange | undefined; /** Event handler when a range is selected. */ @@ -730,10 +739,20 @@ export interface PropsRange { /** * When `true`, the range will reset when including a disabled day. * - * @since V9.0.2 + * @since 9.0.2 * @see https://daypicker.dev/docs/selection-modes#exclude-disabled */ excludeDisabled?: boolean | undefined; + /** + * When `true`, clicking a day starts a new range if there is no current start + * date or if a range is already complete. In those cases, the clicked day + * becomes the start of the new range. When `required` is `false`, clicking + * the same day of a single-day range clears the selection. + * + * @since 9.14 + * @see https://daypicker.dev/selections/range-mode#reset-selection + */ + resetOnSelect?: boolean | undefined; /** The selected range. */ selected?: DateRange | undefined; /** Event handler when the selection changes. */ diff --git a/website/docs/selections/range-mode.mdx b/website/docs/selections/range-mode.mdx index c8c791c2e2..f968f4ec21 100644 --- a/website/docs/selections/range-mode.mdx +++ b/website/docs/selections/range-mode.mdx @@ -4,7 +4,7 @@ sidebar_position: 4 # Range Mode -Set the `mode` prop to `"range"` to enable the selection of a continuous range of dates in DayPicker. +Set the `mode` prop to `"range"` to enable the selection of a continuous range of dates in `DayPicker`. ```tsx @@ -21,6 +21,7 @@ Set the `mode` prop to `"range"` to enable the selection of a continuous range o | `selected` | [`DateRange`](../api/type-aliases/DateRange.md) | The selected range. | | `onSelect` | [`OnSelectHandler`](../api/type-aliases/OnSelectHandler.md) | Event callback when a date is selected. | | `required` | `boolean` | Make the selection required. | +| `resetOnSelect` | `boolean` | Start a new range after a completed one. | | `min` | `number` | The minimum number of nights in the range. | | `max` | `number` | The maximum number of nights in the range. | | `excludeDisabled` | `boolean` | Exclude disabled dates from the range. | @@ -54,6 +55,19 @@ By setting the `required` prop, DayPicker ensures that the selected range cannot +## Reset Range On Select {#reset-selection} + +By default, once a range is complete, clicking another day updates the current `from` or `to`. +Use `resetOnSelect` to start a new range when there is no current start date or when a full range is already selected. In those cases, the clicked day becomes `from`, `to` is cleared, and the next click completes the range. + +```tsx + +``` + + + + + ## Excluding Disabled Dates {#exclude-disabled} In `range` mode, disabled dates are included in the selected range by default. To exclude disabled dates from the range, use the `excludeDisabled` prop. If a disabled date is selected, the range will reset. diff --git a/website/src/components/Playground/SelectionFieldset.tsx b/website/src/components/Playground/SelectionFieldset.tsx index da4d15df74..bbe9679087 100644 --- a/website/src/components/Playground/SelectionFieldset.tsx +++ b/website/src/components/Playground/SelectionFieldset.tsx @@ -89,6 +89,19 @@ export function SelectionFieldset({ Required )} + {props.mode === "range" && ( + + )} {props.mode === "range" || props.mode === "multiple" ? (