diff --git a/.changeset/gentle-ranges-start.md b/.changeset/gentle-ranges-start.md new file mode 100644 index 0000000000..b82af6b9e9 --- /dev/null +++ b/.changeset/gentle-ranges-start.md @@ -0,0 +1,5 @@ +--- +"react-day-picker": minor +--- + +Add `defaultSelected` to initialize uncontrolled single, multiple, and range selections. diff --git a/apps/website/docs/selections/multiple-mode.mdx b/apps/website/docs/selections/multiple-mode.mdx index 618a704b2b..f8e0b87aeb 100644 --- a/apps/website/docs/selections/multiple-mode.mdx +++ b/apps/website/docs/selections/multiple-mode.mdx @@ -16,13 +16,16 @@ Set the `mode` prop to `"multiple"` to enable the selection of multiple dates in ## Multiple Mode Props -| Prop Name | Type | Description | -| ---------- | -------------------------------------------------------------------------------- | ------------------------------------------------- | -| `selected` | `Date[] \| undefined` | The selected dates. | -| `onSelect` | [`OnSelectHandler`](../api/react/type-aliases/OnSelectHandler.md) | Event callback when a date is selected. | -| `min` | `number` | The minimum number of dates that can be selected. | -| `max` | `number` | The maximum number of dates that can be selected. | -| `required` | `boolean` | Make the selection required. | +| Prop Name | Type | Description | +| ----------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `defaultSelected` | `Date[] \| undefined` | The initially selected dates for uncontrolled selection. | +| `selected` | `Date[] \| undefined` | The selected dates. | +| `onSelect` | [`OnSelectHandler`](../api/react/type-aliases/OnSelectHandler.md) | Event callback when a date is selected. | +| `min` | `number` | The minimum number of dates that can be selected. | +| `max` | `number` | The maximum number of dates that can be selected. | +| `required` | `boolean` | Make the selection required. | + +Use `defaultSelected` to set the initial value for an uncontrolled selection. Use `selected` with `onSelect` to control the selected dates. For a read-only controlled selection, pass a no-op handler such as `onSelect={() => undefined}`. Use the `selected` and `onSelect` props to manage the selected dates: diff --git a/apps/website/docs/selections/range-mode.mdx b/apps/website/docs/selections/range-mode.mdx index 1d2872fa20..16498b41d9 100644 --- a/apps/website/docs/selections/range-mode.mdx +++ b/apps/website/docs/selections/range-mode.mdx @@ -16,15 +16,18 @@ Set the `mode` prop to `"range"` to enable the selection of a continuous range o ## Range Mode Props -| Prop Name | Type | Description | -| ----------------- | ----------------------------------------------------------------------------------- | ------------------------------------------ | -| `selected` | [`DateRange`](../api/react/type-aliases/DateRange.md) | The selected range. | -| `onSelect` | [`OnSelectHandler`](../api/react/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. | +| Prop Name | Type | Description | +| ----------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `defaultSelected` | [`DateRange`](../api/react/type-aliases/DateRange.md) | The initially selected range for uncontrolled selection. | +| `selected` | [`DateRange`](../api/react/type-aliases/DateRange.md) | The selected range. | +| `onSelect` | [`OnSelectHandler`](../api/react/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. | + +Use `defaultSelected` to set the initial value for an uncontrolled selection. Use `selected` with `onSelect` to control the selected range. For a read-only controlled selection, pass a no-op handler such as `onSelect={() => undefined}`. ## Min and Max Dates diff --git a/apps/website/docs/selections/selection-modes.mdx b/apps/website/docs/selections/selection-modes.mdx index 39787d3b27..cb9dec5c81 100644 --- a/apps/website/docs/selections/selection-modes.mdx +++ b/apps/website/docs/selections/selection-modes.mdx @@ -11,13 +11,16 @@ DayPicker offers predefined rules for date selection. - [Multiple mode](./multiple-mode.mdx): Allows the selection of multiple individual dates. - [Range mode](./range-mode.mdx): Allows the selection of a continuous range of dates. -| Prop Name | Type | Description | -| ---------- | ------------------------------------------------------------------------------------ | --------------------------------------- | -| `mode` | `"single"` \| `"multiple"` \| `"range"` | Enter a selection mode. | -| `disabled` | [`Matcher`](../api/react/type-aliases/Matcher.md) \| `Matcher[]` | Disabled dates that cannot be selected. | -| `selected` | `Date` \| `Date[]` \| [`DateRange`](../api/react/type-aliases/DateRange.md) \| `undefined` | The selected date(s). | -| `required` | `boolean` | When `true`, the selection is required. | -| `onSelect` | [`OnSelectHandler`](../api/react/type-aliases/OnSelectHandler.md) | Event callback when a date is selected. | +| Prop Name | Type | Description | +| ----------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| `mode` | `"single"` \| `"multiple"` \| `"range"` | Enter a selection mode. | +| `disabled` | [`Matcher`](../api/react/type-aliases/Matcher.md) \| `Matcher[]` | Disabled dates that cannot be selected. | +| `defaultSelected` | `Date` \| `Date[]` \| [`DateRange`](../api/react/type-aliases/DateRange.md) \| `undefined` | The initially selected date(s) for uncontrolled selection. | +| `selected` | `Date` \| `Date[]` \| [`DateRange`](../api/react/type-aliases/DateRange.md) \| `undefined` | The selected date(s). | +| `required` | `boolean` | When `true`, the selection is required. | +| `onSelect` | [`OnSelectHandler`](../api/react/type-aliases/OnSelectHandler.md) | Event callback when a date is selected. | + +Use `defaultSelected` to set the initial value for an uncontrolled selection. Use `selected` with `onSelect` to control the selection from your state. For a read-only controlled selection, pass a no-op handler such as `onSelect={() => undefined}`. ## Customizing Selections diff --git a/apps/website/docs/selections/single-mode.mdx b/apps/website/docs/selections/single-mode.mdx index 3bb6c78a78..c9b5e6d13d 100644 --- a/apps/website/docs/selections/single-mode.mdx +++ b/apps/website/docs/selections/single-mode.mdx @@ -16,11 +16,14 @@ When the `mode` prop is set to `"single"`, only one date can be selected at a ti ## Single Mode Props -| Prop Name | Type | Description | -| ---------- | ------------------------------------------------------------------------------ | --------------------------------------- | -| `selected` | `Date \| undefined` | The selected date. | -| `onSelect` | [`OnSelectHandler`](../api/react/type-aliases/OnSelectHandler.md) | Event callback when a date is selected. | -| `required` | `boolean` | Make the selection required. | +| Prop Name | Type | Description | +| ----------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `defaultSelected` | `Date \| undefined` | The initially selected date for uncontrolled selection. | +| `selected` | `Date \| undefined` | The selected date. | +| `onSelect` | [`OnSelectHandler`](../api/react/type-aliases/OnSelectHandler.md) | Event callback when a date is selected. | +| `required` | `boolean` | Make the selection required. | + +Use `defaultSelected` to set the initial value for an uncontrolled selection. Use `selected` with `onSelect` to control the selected date. For a read-only controlled selection, pass a no-op handler such as `onSelect={() => undefined}`. ## Controlled Selections diff --git a/packages/react-day-picker/src/DayPicker.test.tsx b/packages/react-day-picker/src/DayPicker.test.tsx index bc0359837b..ae1a6f4708 100644 --- a/packages/react-day-picker/src/DayPicker.test.tsx +++ b/packages/react-day-picker/src/DayPicker.test.tsx @@ -4,6 +4,7 @@ import { activeElement, dateButton, grid, + gridcell, nav, nextButton, previousButton, @@ -257,6 +258,23 @@ test("calls selection and day event callbacks with Date instances", async () => expect(handleDayClick.mock.calls[0][0]).toBeInstanceOf(Date); }); +test("renders defaultSelected as the initial selection", () => { + const defaultSelected = new Date(2024, 0, 15); + + render( + , + ); + + expect(gridcell(defaultSelected, true)).toHaveAttribute( + "aria-selected", + "true", + ); +}); + describe("when navigating with month callbacks", () => { const handleMonthChange = jest.fn(); const handleNextClick = jest.fn(); diff --git a/packages/react-day-picker/src/DayPicker.tsx b/packages/react-day-picker/src/DayPicker.tsx index d53d133d08..cbbbb4f9ac 100644 --- a/packages/react-day-picker/src/DayPicker.tsx +++ b/packages/react-day-picker/src/DayPicker.tsx @@ -65,21 +65,45 @@ export function DayPicker(initialProps: DayPickerProps) { if (props.endMonth) { props.endMonth = toTimeZone(props.endMonth, timeZone); } - if (props.mode === "single" && props.selected) { - props.selected = toTimeZone(props.selected, timeZone); - } else if (props.mode === "multiple" && props.selected) { - props.selected = props.selected?.map((date) => - toTimeZone(date, timeZone), - ); - } else if (props.mode === "range" && props.selected) { - props.selected = { - from: props.selected.from - ? toTimeZone(props.selected.from, timeZone) - : props.selected.from, - to: props.selected.to - ? toTimeZone(props.selected.to, timeZone) - : props.selected.to, - }; + if (props.mode === "single") { + if (props.selected) { + props.selected = toTimeZone(props.selected, timeZone); + } + if (props.defaultSelected) { + props.defaultSelected = toTimeZone(props.defaultSelected, timeZone); + } + } else if (props.mode === "multiple") { + if (props.selected) { + props.selected = props.selected.map((date) => + toTimeZone(date, timeZone), + ); + } + if (props.defaultSelected) { + props.defaultSelected = props.defaultSelected.map((date) => + toTimeZone(date, timeZone), + ); + } + } else if (props.mode === "range") { + if (props.selected) { + props.selected = { + from: props.selected.from + ? toTimeZone(props.selected.from, timeZone) + : props.selected.from, + to: props.selected.to + ? toTimeZone(props.selected.to, timeZone) + : props.selected.to, + }; + } + if (props.defaultSelected) { + props.defaultSelected = { + from: props.defaultSelected.from + ? toTimeZone(props.defaultSelected.from, timeZone) + : props.defaultSelected.from, + to: props.defaultSelected.to + ? toTimeZone(props.defaultSelected.to, timeZone) + : props.defaultSelected.to, + }; + } } if (props.disabled !== undefined) { props.disabled = convertMatchersToTimeZone(props.disabled, timeZone); diff --git a/packages/react-day-picker/src/selection/useMulti.test.tsx b/packages/react-day-picker/src/selection/useMulti.test.tsx index 0caa210749..7e4bc208a6 100644 --- a/packages/react-day-picker/src/selection/useMulti.test.tsx +++ b/packages/react-day-picker/src/selection/useMulti.test.tsx @@ -20,6 +20,18 @@ describe("useMulti", () => { expect(result.current.selected).toBe(selectedDates); }); + test("uses defaultSelected for uncontrolled selection", () => { + const defaultSelected = [new Date(2023, 9, 1), new Date(2023, 9, 2)]; + const props: DayPickerProps = { + mode: "multiple", + defaultSelected, + }; + + const { result } = renderHook(() => useMulti(props, defaultDateLib)); + + expect(result.current.selected).toBe(defaultSelected); + }); + test("uses the internally selected value when onSelect is not provided", () => { const initialSelectedDates = [new Date(2023, 9, 1), new Date(2023, 9, 2)]; const props: DayPickerProps = { diff --git a/packages/react-day-picker/src/selection/useMulti.tsx b/packages/react-day-picker/src/selection/useMulti.tsx index 9336e88b04..8e01d3e60a 100644 --- a/packages/react-day-picker/src/selection/useMulti.tsx +++ b/packages/react-day-picker/src/selection/useMulti.tsx @@ -23,13 +23,14 @@ export function useMulti( dateLib: DateLib, ): Selection { const { + defaultSelected, selected: initiallySelected, required, onSelect, } = props as PropsMulti; const [internallySelected, setSelected] = useControlledValue( - initiallySelected, + defaultSelected ?? initiallySelected, onSelect ? initiallySelected : undefined, ); diff --git a/packages/react-day-picker/src/selection/useRange.test.tsx b/packages/react-day-picker/src/selection/useRange.test.tsx index 548bc5eac1..ea287756ff 100644 --- a/packages/react-day-picker/src/selection/useRange.test.tsx +++ b/packages/react-day-picker/src/selection/useRange.test.tsx @@ -241,6 +241,21 @@ describe("useRange", () => { expect(result.current.selected).toBe(selectedRange); }); + test("uses defaultSelected for uncontrolled selection", () => { + const defaultSelected = { + from: new Date(2023, 9, 1), + to: new Date(2023, 9, 5), + }; + const props: DayPickerProps = { + mode: "range", + defaultSelected, + }; + + const { result } = renderHook(() => useRange(props, defaultDateLib)); + + expect(result.current.selected).toBe(defaultSelected); + }); + test("uses the internally selected value when onSelect is not provided", () => { const initialSelectedRange = { from: new Date(2023, 9, 1), diff --git a/packages/react-day-picker/src/selection/useRange.tsx b/packages/react-day-picker/src/selection/useRange.tsx index b58d533d46..46c38f3a0a 100644 --- a/packages/react-day-picker/src/selection/useRange.tsx +++ b/packages/react-day-picker/src/selection/useRange.tsx @@ -25,6 +25,7 @@ export function useRange( dateLib: DateLib, ): Selection { const { + defaultSelected, disabled, excludeDisabled, resetOnSelect, @@ -34,7 +35,7 @@ export function useRange( } = props as PropsRange; const [internallySelected, setSelected] = useControlledValue( - initiallySelected, + defaultSelected ?? initiallySelected, onSelect ? initiallySelected : undefined, ); diff --git a/packages/react-day-picker/src/selection/useSingle.test.tsx b/packages/react-day-picker/src/selection/useSingle.test.tsx index 52207267c9..e658abdde8 100644 --- a/packages/react-day-picker/src/selection/useSingle.test.tsx +++ b/packages/react-day-picker/src/selection/useSingle.test.tsx @@ -20,6 +20,18 @@ describe("useSingle", () => { expect(result.current.selected).toBe(selectedDate); }); + test("uses defaultSelected for uncontrolled selection", () => { + const defaultSelected = new Date(2023, 9, 1); + const props: DayPickerProps = { + mode: "single", + defaultSelected, + }; + + const { result } = renderHook(() => useSingle(props, defaultDateLib)); + + expect(result.current.selected).toBe(defaultSelected); + }); + test("uses the internally selected value when onSelect is not provided", () => { const initialSelectedDate = new Date(2023, 9, 1); const props: DayPickerProps = { diff --git a/packages/react-day-picker/src/selection/useSingle.tsx b/packages/react-day-picker/src/selection/useSingle.tsx index 88fe2216b6..e85b374e21 100644 --- a/packages/react-day-picker/src/selection/useSingle.tsx +++ b/packages/react-day-picker/src/selection/useSingle.tsx @@ -31,13 +31,14 @@ export function useSingle( dateLib: DateLib, ): Selection { const { + defaultSelected, selected: initiallySelected, required, onSelect, } = props as PropsSingle; const [internallySelected, setSelected] = useControlledValue( - initiallySelected, + defaultSelected ?? initiallySelected, onSelect ? initiallySelected : undefined, ); diff --git a/packages/react-day-picker/src/types/props.test.tsx b/packages/react-day-picker/src/types/props.test.tsx index 0133f147d2..ff992a2b4e 100644 --- a/packages/react-day-picker/src/types/props.test.tsx +++ b/packages/react-day-picker/src/types/props.test.tsx @@ -83,6 +83,7 @@ const Test = () => { + { {/** @ts-expect-error Wrong selected prop */} {}} /> + { onSelect={(_date: Date[]) => {}} /> + { {/* @ts-expect-error single selection is Date-shaped */} + {/* @ts-expect-error single default selection is Date-shaped */} + {/* @ts-expect-error multiple selection contains Date values */} + {/* @ts-expect-error multiple default selection contains Date values */} + {/* @ts-expect-error range endpoints must be Date values */} + {/* @ts-expect-error range default selection endpoints must be Date values */} + {/* @ts-expect-error matchers must use Date values */} {/* @ts-expect-error matcher arrays must use Date values */} diff --git a/packages/react-day-picker/src/types/props.ts b/packages/react-day-picker/src/types/props.ts index 75e201819a..1c75cb2dfb 100644 --- a/packages/react-day-picker/src/types/props.ts +++ b/packages/react-day-picker/src/types/props.ts @@ -529,6 +529,8 @@ export type OnSelectHandler = ( export interface PropsSingleRequired { mode: "single"; required: true; + /** The initially selected date for uncontrolled selection. */ + defaultSelected?: Date | undefined; /** The selected date. */ selected: Date | undefined; /** Event handler when a day is selected. */ @@ -544,6 +546,8 @@ export interface PropsSingleRequired { export interface PropsSingle { mode: "single"; required?: false | undefined; + /** The initially selected date for uncontrolled selection. */ + defaultSelected?: Date | undefined; /** The selected date. */ selected?: Date | undefined; /** Event handler when a day is selected. */ @@ -559,6 +563,8 @@ export interface PropsSingle { export interface PropsMultiRequired { mode: "multiple"; required: true; + /** The initially selected dates for uncontrolled selection. */ + defaultSelected?: Date[] | undefined; /** The selected dates. */ selected: Date[] | undefined; /** Event handler when days are selected. */ @@ -578,6 +584,8 @@ export interface PropsMultiRequired { export interface PropsMulti { mode: "multiple"; required?: false | undefined; + /** The initially selected dates for uncontrolled selection. */ + defaultSelected?: Date[] | undefined; /** The selected dates. */ selected?: Date[] | undefined; /** Event handler when days are selected. */ @@ -618,6 +626,8 @@ export interface PropsRangeRequired { * @see https://daypicker.dev/selections/range-mode#reset-selection */ resetOnSelect?: boolean | undefined; + /** The initially selected range for uncontrolled selection. */ + defaultSelected?: DateRange | undefined; /** The selected range. */ selected: DateRange | undefined; /** Event handler when a range is selected. */ @@ -660,6 +670,8 @@ export interface PropsRange { * @see https://daypicker.dev/selections/range-mode#reset-selection */ resetOnSelect?: boolean | undefined; + /** The initially selected range for uncontrolled selection. */ + defaultSelected?: DateRange | undefined; /** The selected range. */ selected?: DateRange | undefined; /** Event handler when the selection changes. */