Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions examples/RangeResetSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
34 changes: 10 additions & 24 deletions examples/RangeResetSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,32 @@ import React, { useState } from "react";

import {
type DateRange,
type DayEventHandler,
DayPicker,
type OnSelectHandler,
} from "react-day-picker";

export function RangeResetSelection() {
const [selected, setSelected] = useState<DateRange>();

// use onSelect event which properly handles valid range selection
// based on valid days in the calendar
const handleSelect: OnSelectHandler<DateRange | undefined> = (range) => {
// the other cases are handled by onDayClick handler
if (selected?.from && !selected.to) {
setSelected(range);
}
};

const handleDayClick: DayEventHandler<React.MouseEvent> = (date) => {
// handled by onSelect handler
if (selected?.from && !selected.to) {
return;
}
setSelected({ from: date });
setSelected(range);
};

return (
<DayPicker
mode="range"
selected={selected}
onSelect={handleSelect}
onDayClick={handleDayClick}
resetOnSelect
footer={
<div>
<p data-testid="from">
from: {selected?.from && format(selected?.from, "yyyy-MM-dd")}
</p>
<p data-testid="to">
to: {selected?.to && format(selected?.to, "yyyy-MM-dd")}
</p>
</div>
<p>
<span data-testid="from">
{selected?.from && format(selected?.from, "yyyy-MM-dd")}
</span>
<span data-testid="to">
{selected?.to && `—${format(selected?.to, "yyyy-MM-dd")}`}
</span>
</p>
}
/>
);
Expand Down
100 changes: 100 additions & 0 deletions src/selection/useRange.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
32 changes: 29 additions & 3 deletions src/selection/useRange.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function useRange<T extends DayPickerProps>(
const {
disabled,
excludeDisabled,
resetOnSelect,
selected: initiallySelected,
required,
onSelect,
Expand All @@ -48,9 +49,34 @@ export function useRange<T extends DayPickerProps>(
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<typeof addToRange>;
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 (
Expand Down
23 changes: 21 additions & 2 deletions src/types/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down
16 changes: 15 additions & 1 deletion website/docs/selections/range-mode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<DayPicker mode="range" />
Expand All @@ -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<DateRange \| undefined>`](../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. |
Expand Down Expand Up @@ -54,6 +55,19 @@ By setting the `required` prop, DayPicker ensures that the selected range cannot
<Examples.RangeRequired />
</BrowserWindow>

## 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
<DayPicker mode="range" resetOnSelect />
```

<BrowserWindow sourceUrl="https://github.com/gpbl/react-day-picker/blob/main/examples/RangeResetSelection.tsx">
<Examples.RangeResetSelection />
</BrowserWindow>

## 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.
Expand Down
13 changes: 13 additions & 0 deletions website/src/components/Playground/SelectionFieldset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export function SelectionFieldset({
Required
</label>
)}
{props.mode === "range" && (
<label>
<input
type="checkbox"
checked={props.resetOnSelect}
name="resetOnSelect"
onChange={(e) => {
setProps({ ...props, resetOnSelect: e.target.checked });
}}
/>
Reset range on select
</label>
)}
{props.mode === "range" || props.mode === "multiple" ? (
<label>
Min Selection:
Expand Down
2 changes: 2 additions & 0 deletions website/src/components/Playground/useQueryStringSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const qsProps = [
"noonSafe",
"pagedNavigation",
"required",
"resetOnSelect",
"reverseMonths",
"reverseYears",
"selected",
Expand Down Expand Up @@ -85,6 +86,7 @@ export function useQueryStringSync(basePath: string = "/playground") {
noonSafe: "boolean",
pagedNavigation: "boolean",
required: "boolean",
resetOnSelect: "boolean",
reverseMonths: "boolean",
reverseYears: "boolean",
selected: "string",
Expand Down