diff --git a/.changeset/smooth-boxes-bow.md b/.changeset/smooth-boxes-bow.md new file mode 100644 index 0000000..170cf0c --- /dev/null +++ b/.changeset/smooth-boxes-bow.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +Introduce a DateRangePicker component diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx new file mode 100644 index 0000000..7b3bcf4 --- /dev/null +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.spec.tsx @@ -0,0 +1,1034 @@ +import { render, screen, within } from "@testing-library/react"; +import React, { FormEvent, useState } from "react"; +import { expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { CunninghamProvider } from ":/components/Provider"; +import { DateRangePicker } from ":/components/Forms/DatePicker/DateRangePicker"; +import { Button } from ":/components/Button"; + +describe("", () => { + const expectDatesToBeEqual = ( + firstDate: Date | string | undefined | null, + secondDate: Date | string | undefined | null + ) => { + expect(firstDate).toBeDefined(); + expect(secondDate).toBeDefined(); + expect(new Date(firstDate!).toLocaleDateString()).eq( + new Date(secondDate!).toLocaleDateString() + ); + }; + + const expectCalendarToBeClosed = () => { + expect(screen.queryByRole("group")).toBeNull(); + }; + + const expectDateFieldsToBeDisplayed = () => { + expect(screen.queryAllByRole("presentation")).toBeTruthy(); + }; + + const expectDateFieldsToBeHidden = async () => { + const dateFields = await screen.queryAllByRole("presentation"); + expect(dateFields.length).eq(2); + dateFields.forEach((dateField) => { + expect(Array.from(dateField.parentElement!.classList)).contains( + "c__date-picker__inner--collapsed" + ); + }); + }; + + const expectDateRangePickerStateToBe = async (state: string) => { + const input = (await screen.findAllByRole("button"))![0]; + const classNames = + input.parentElement && Array.from(input.parentElement.classList); + expect(classNames).contains(`c__date-picker--${state}`); + }; + + const expectCalendarToBeOpen = () => { + const calendar = screen.queryByRole("group"); + expect(calendar).toBeDefined(); + expect(calendar).not.toBeNull(); + expect(Array.from(calendar!.classList)).contains( + "c__calendar__wrapper--opened" + ); + }; + + const expectedSelectedCells = (selectionSize: number) => { + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + }); + expect(selectedCells.length).eq(selectionSize); + }; + + it("toggles calendar", async () => { + const user = userEvent.setup(); + render( + + + + ); + + const [input, button] = await screen.findAllByRole("button"); + + // It returns the clickable div. + expect(input.tagName).toEqual("DIV"); + + // It returns the toggle button. + expect(button.tagName).toEqual("BUTTON"); + + // Calendar is initially closed. + expectCalendarToBeClosed(); + await expectDateFieldsToBeHidden(); + + // Toggle button opens the calendar. + await user.click(button); + expectCalendarToBeOpen(); + expectDateFieldsToBeDisplayed(); + + // Clicking again closes the calendar. + await user.click(button); + expectCalendarToBeClosed(); + await expectDateFieldsToBeHidden(); + + // Click on the input opens the calendar. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldsToBeDisplayed(); + + // Clicking again on the input should not close the calendar. + // Click could happen while selecting the date field input. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldsToBeDisplayed(); + + // While the calendar open, clicking on + // the toggle button closes the calendar. + await user.click(button); + expectCalendarToBeClosed(); + }); + + it("focuses in the right order with no picked date range", async () => { + const user = userEvent.setup(); + render( + + + + ); + // Get elements that should receive focus when no date is picked. + const [input, toggleButton] = await screen.findAllByRole("button")!; + + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + expectCalendarToBeClosed(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + expectCalendarToBeClosed(); + + await user.keyboard("{Enter}"); + expectCalendarToBeOpen(); + + expectedSelectedCells(0); + }); + + it.each([ + ["2022-05-25", "2022-05-26"], + ["25 Mar 2025", "2029-05-26"], + ["2023-06-01T13:50", "2024-05-26"], + ])("has a default value", async (start: string, end: string) => { + render( + + + + ); + // Get picked date. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(start, startInput.textContent); + expectDatesToBeEqual(end, endInput.textContent); + expectDateFieldsToBeDisplayed(); + }); + + it("focuses in the right order with a default value", async () => { + const user = userEvent.setup(); + render( + + + + ); + + // Get elements that should receive focus when a date is already picked. + const [input, toggleButton] = await screen.findAllByRole("button")!; + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + const [ + monthStartSegment, + dayStartSegment, + yearStartSegment, + monthEndSegment, + dayEndSegment, + yearEndSegment, + ] = await screen.findAllByRole("spinbutton")!; + + // Navigate through elements using Tab. + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(monthStartSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(dayStartSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(yearStartSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(monthEndSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(dayEndSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(yearEndSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(clearButton).toHaveFocus(); + }); + + it("picks a date range", async () => { + const user = userEvent.setup(); + render( + + + + ); + const [input, toggleButton] = await screen.findAllByRole("button"); + await user.click(input); + + // Select all grid-cells. + const gridCells = await screen.findAllByRole("gridcell"); + + // Select the first clickable grid-cell. + const startGridCellButton = within( + gridCells.filter( + (gridCell) => !gridCell.getAttribute("aria-disabled") + )![0] + ).getByRole("button")!; + + // Pick a start date. + const gridCellStartDate = startGridCellButton.getAttribute("aria-label"); + await user.click(startGridCellButton); + + expectCalendarToBeOpen(); + + // Select the second clickable grid-cell. + const endGridCellButton = within( + gridCells.filter( + (gridCell) => !gridCell.getAttribute("aria-disabled") + )![1] + ).getByRole("button")!; + + // Pick an end date. + const gridCellEndDate = endGridCellButton.getAttribute("aria-label"); + await user.click(endGridCellButton); + + // Calendar should close on range's selection. + expectCalendarToBeClosed(); + expectDateFieldsToBeDisplayed(); + + // Reopen the calendar. + await user.click(toggleButton); + + // Get the selected cells in calendar grid. + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + })!; + + // Extract the selected range + const selectedRange = within(selectedCells[0]) + .getByRole("button")! + .getAttribute("aria-label") + ?.replace("selected", ""); + + // Make sure start and end dates are in the selected range, + // that describes the range with its start and end date. + expect(selectedRange).contains(gridCellStartDate); + expect(selectedRange).contains(gridCellEndDate); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(gridCellStartDate, startInput.textContent); + expectDatesToBeEqual(gridCellEndDate, endInput.textContent); + }); + + it("picks a date range with start and end date equal", async () => { + const user = userEvent.setup(); + render( + + + + ); + const [input, toggleButton] = await screen.findAllByRole("button"); + await user.click(input); + + // Select all grid-cells. + const gridCells = await screen.findAllByRole("gridcell"); + + // Select the first clickable grid-cell. + const gridCellButton = within( + gridCells.filter( + (gridCell) => !gridCell.getAttribute("aria-disabled") + )![0] + ).getByRole("button")!; + + // Pick a start date. + const gridCellDate = gridCellButton.getAttribute("aria-label"); + await user.click(gridCellButton); + + expectCalendarToBeOpen(); + + // Click again on the same date, to select the end date. + await user.click(gridCellButton); + + // Calendar should close on range's selection. + expectCalendarToBeClosed(); + expectDateFieldsToBeDisplayed(); + + // Reopen the calendar. + await user.click(toggleButton); + + // Get the selected cells in calendar grid. + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + })!; + + // Extract the selected range + const selectedRange = within(selectedCells[0]) + .getByRole("button")! + .getAttribute("aria-label") + ?.replace("selected", ""); + + // Make sure start and end dates are in the selected range, + // that describes the range with its start and end date. + expect(selectedRange).contains(gridCellDate); + expect(selectedRange).contains(gridCellDate); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(gridCellDate, startInput.textContent); + expectDatesToBeEqual(gridCellDate, endInput.textContent); + }); + + it("picks a date range using keyboard", async () => { + const user = userEvent.setup(); + render( + + + + ); + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + expectCalendarToBeOpen(); + + // Start range selection using keyboard. + await user.keyboard("{Enter}"); + expectCalendarToBeOpen(); + expectedSelectedCells(1); + + // Select the next cell. + await user.keyboard("{ArrowRight}"); + expectCalendarToBeOpen(); + expectedSelectedCells(2); + + // Select the next cell. + await user.keyboard("{ArrowRight}"); + expectCalendarToBeOpen(); + expectedSelectedCells(3); + + // End range selection using keyboard. + await user.keyboard("{Enter}"); + expectCalendarToBeClosed(); + + await user.click(input); + expectCalendarToBeOpen(); + + expectedSelectedCells(3); + + const selectedCells = screen.getAllByRole("gridcell", { + selected: true, + }); + + // Extract the selected range + const selectedRange = within(selectedCells[0]) + .getByRole("button")! + .getAttribute("aria-label") + ?.replace("selected", ""); + + // Make sure start and end dates are in the selected range. + expect(selectedRange).contains("January 1"); + expect(selectedRange).not.contains("January 2"); + expect(selectedRange).contains("January 3"); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual("2023-01-01", startInput.textContent); + expectDatesToBeEqual("2023-01-03", endInput.textContent); + }); + + it("picks a date range spanning multiple months", async () => { + const user = userEvent.setup(); + render( + + + + ); + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + expectCalendarToBeOpen(); + + const startDate = "Tuesday, January 3, 2023"; + const endDate = "Tuesday, February 28, 2023"; + + // Select the start date. + const startGridCellButton = await screen.getByRole("button", { + name: startDate, + })!; + await user.click(startGridCellButton); + expectCalendarToBeOpen(); + + // Navigate to the next month. + const nextMonthButton = screen.getByRole("button", { + name: "Next month", + }); + await user.click(nextMonthButton); + expectCalendarToBeOpen(); + + // Select the end date. + const endGridCellButton = await screen.getByRole("button", { + name: endDate, + })!; + await user.click(endGridCellButton); + expectCalendarToBeClosed(); + + // Reopen calendar. + await user.click(input); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(startDate, startInput.textContent); + expectDatesToBeEqual(endDate, endInput.textContent); + }); + + it("picks a date range spanning multiple years", async () => { + const user = userEvent.setup(); + render( + + + + ); + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + expectCalendarToBeOpen(); + + const startDate = "Tuesday, January 3, 2023"; + const endDate = "Thursday, January 4, 2024"; + + // Select the start date. + const startGridCellButton = await screen.getByRole("button", { + name: startDate, + })!; + await user.click(startGridCellButton); + expectCalendarToBeOpen(); + + // Navigate to the next year. + const nextYearButton = screen.getByRole("button", { + name: "Next year", + }); + await user.click(nextYearButton); + expectCalendarToBeOpen(); + + // Select the end date. + const endGridCellButton = await screen.getByRole("button", { + name: endDate, + })!; + await user.click(endGridCellButton); + expectCalendarToBeClosed(); + + // Reopen calendar. + await user.click(input); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(startDate, startInput.textContent); + expectDatesToBeEqual(endDate, endInput.textContent); + }); + + it("types a date range", async () => { + const user = userEvent.setup(); + render( + + + + ); + + // Open calendar to display text segment + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + + const [ + monthStartSegment, + dayStartSegment, + yearStartSegment, + monthEndSegment, + dayEndSegment, + yearEndSegment, + ] = await screen.findAllByRole("spinbutton")!; + + // Select the first segment, month one. + await user.click(monthStartSegment); + expect(monthStartSegment).toHaveFocus(); + + // Type month's start value. + await user.keyboard("{1}{2}"); + expect(dayStartSegment).toHaveFocus(); + + // Type day's start value. + await user.keyboard("{5}"); + expect(yearStartSegment).toHaveFocus(); + + // Type year's start value. + await user.keyboard("{2}{0}{2}{3}"); + expect(monthEndSegment).toHaveFocus(); + + // Type month's end value. + await user.keyboard("{1}{2}"); + expect(dayEndSegment).toHaveFocus(); + + // Type day's end value. + await user.keyboard("{6}"); + expect(yearEndSegment).toHaveFocus(); + + // Type year's end value. + await user.keyboard("{2}{0}{2}{3}"); + + // Check date field value. + expectDateFieldsToBeDisplayed(); + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expect(startInput.textContent).eq("12/5/2023"); + expect(endInput.textContent).eq("12/6/2023"); + }); + + it("types an invalid date range", async () => { + const user = userEvent.setup(); + render( + + + + ); + + // Open calendar to display text segment + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + + const [ + monthStartSegment, + dayStartSegment, + yearStartSegment, + monthEndSegment, + dayEndSegment, + yearEndSegment, + ] = await screen.findAllByRole("spinbutton")!; + + // Select the first segment, month one. + await user.click(monthStartSegment); + expect(monthStartSegment).toHaveFocus(); + + // Type month's start value. + await user.keyboard("{1}{2}"); + expect(dayStartSegment).toHaveFocus(); + + // Type day's start value. + await user.keyboard("{6}"); + expect(yearStartSegment).toHaveFocus(); + + // Type year's start value. + await user.keyboard("{2}{0}{2}{3}"); + expect(monthEndSegment).toHaveFocus(); + + // Type month's end value. + await user.keyboard("{1}{2}"); + expect(dayEndSegment).toHaveFocus(); + + // Type day's end value. + await user.keyboard("{5}"); + expect(yearEndSegment).toHaveFocus(); + + // Type year's end value. + await user.keyboard("{2}{0}{2}{3}"); + + // Check date field value. + expectDateFieldsToBeDisplayed(); + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expect(startInput.textContent).eq("12/6/2023"); + expect(endInput.textContent).eq("12/5/2023"); + + // Make sure the date picker is showing error. + await expectDateRangePickerStateToBe("invalid"); + }); + + it("has an uncontrolled and a controlled value", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(() => + render( + + + + ) + ).toThrow( + "You cannot use both defaultValue and value props on DateRangePicker component" + ); + }); + + it.each([ + ["this_not_a_valid_date", "2022-05-12"], + ["2022-05-12", "2023-13-13"], + ["2025-25-05", "2022-05-12"], + ])("has not a valid range value", async (start: string, end: string) => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(() => + render( + + + + ) + ).toThrow( + "Invalid date format when initializing props on DatePicker component" + ); + }); + + it("has not a valid range value", async () => { + // Start date is superior to end date in the default range. + render( + + + + ); + await expectDateRangePickerStateToBe("invalid"); + }); + + it.each([[new Date(2022, 5, 25), new Date(2022, 5, 27)]])( + "clears date", + async (start: Date, end: Date) => { + const user = userEvent.setup(); + render( + + + + ); + + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectCalendarToBeOpen(); + + // Date field's value should be set to a placeholder value. + const [startInput, endInput] = await screen.queryAllByRole( + "presentation" + ); + expect(startInput.textContent).eq("mm/dd/yyyy"); + expect(endInput.textContent).eq("mm/dd/yyyy"); + + const startGridCell = screen.getByRole("gridcell", { + name: `${start.getDate()}`, + })!; + + // Make sure start grid-cell is not selected anymore. + expect(startGridCell.getAttribute("aria-selected")).toBeNull(); + expect( + startGridCell.classList.contains( + "c__calendar__wrapper__grid__week-row__background--range--start" + ) + ).toBe(false); + + // Make sure end grid-cell is not selected anymore. + const endGridCell = screen.getByRole("gridcell", { + name: `${end.getDate()}`, + })!; + expect(endGridCell.getAttribute("aria-selected")).toBeNull(); + expect( + endGridCell.classList.contains( + "c__calendar__wrapper__grid__week-row__background--range--end" + ) + ).toBe(false); + + // Close the calendar. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Make sure the empty date field is hidden when closing the calendar. + await expectDateFieldsToBeHidden(); + } + ); + + it.each([ + ["2023-01-01", "2023-01-01"], + ["2023-01-01", "2023-03-01"], + ])( + "has a start or a end date inferior to minValue", + async (start: string, end: string) => { + render( + + + + ); + await expectDateRangePickerStateToBe("invalid"); + } + ); + + it.each([ + ["2023-01-01", "2023-03-01"], + ["2023-03-01", "2023-03-01"], + ])( + "has a start or a end date superior to maxValue", + async (start: string, end: string) => { + render( + + + + ); + await expectDateRangePickerStateToBe("invalid"); + } + ); + + it("renders disabled", async () => { + render( + + + + ); + await expectDateRangePickerStateToBe("disabled"); + + const [input, button] = await screen.findAllByRole("button"); + + // Make sure toggle button and click on input are disabled. + expect(input.getAttribute("aria-disabled")).eq("true"); + expect(button).toBeDisabled(); + + // Make sure the clear button is not visible and disabled. + expect( + screen.queryByRole("button", { name: "Clear date", hidden: true }) + ).toBeDisabled(); + + // Make sure each segment of the date field is disabled. + const dateFieldInputs = await screen.queryAllByRole("spinbutton"); + dateFieldInputs.forEach((dateFieldInput) => + expect(dateFieldInput).toHaveAttribute("aria-disabled") + ); + }); + + it("renders focused", async () => { + const user = userEvent.setup(); + render( + + + + ); + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + await expectDateRangePickerStateToBe("focused"); + }); + + it("renders a selected range", async () => { + const user = userEvent.setup(); + render( + + + + ); + // Toggle button opens the calendar. + const button = (await screen.findAllByRole("button"))![0]; + await user.click(button); + + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + }); + + // Make sure all selected cells render the selected styling. + selectedCells.forEach((selectedCell) => { + const cellButton = within(selectedCell).getByRole("button")!; + expect(Array.from(cellButton!.classList)).contains( + "c__calendar__wrapper__grid__week-row__button--selected" + ); + }); + const commonClassName = + "c__calendar__wrapper__grid__week-row__background--range"; + // Make sure the start of selection has the start styling. + const startCellButton = within(selectedCells[0]).getByRole("button")!; + expect(Array.from(startCellButton.parentElement!.classList)).contains( + `${commonClassName}--start` + ); + // Make sure the end of selection has the end styling. + const endCellButton = within( + selectedCells[selectedCells.length - 1] + ).getByRole("button")!; + expect(Array.from(endCellButton.parentElement!.classList)).contains( + `${commonClassName}--end` + ); + }); + + it("works controlled", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState<[string, string] | null>([ + "2023-04-25", + "2023-04-26", + ]); + return ( + +
+
Value = {value?.join(" ")}|
+ + setValue(e)} + /> +
+
+ ); + }; + render(); + + // Make sure value is selected. + screen.getByText("Value = 2023-04-25 2023-04-26|"); + + // Make sure value is initially render in the date field component. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual("2023-04-25", startInput.textContent); + expectDatesToBeEqual("2023-04-26", endInput.textContent); + + // Open the calendar grid. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + + const startGridCell = within( + await screen.getByRole("gridcell", { name: "12" }) + ).getByRole("button")!; + + const endGridCell = within( + await screen.getByRole("gridcell", { name: "14" }) + ).getByRole("button")!; + + // Select a new value in the calendar grid. + await user.click(startGridCell); + await user.click(endGridCell); + expectCalendarToBeClosed(); + + // Make sure value is selected. + screen.getByText(`Value = 2023-04-12 2023-04-14|`); + + // Clear value. + const clearButton = screen.getByRole("button", { + name: "Clear", + }); + await user.click(clearButton); + + // Make sure value is cleared. + await expectDateFieldsToBeHidden(); + screen.getByText("Value = |"); + }); + + it("submits forms data", async () => { + let formData: any; + const Wrapper = () => { + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + formData = { + datepickerStart: data.get("datepicker_start"), + datepickerEnd: data.get("datepicker_end"), + }; + }; + + return ( + +
+
+ + + +
+
+ ); + }; + render(); + + const user = userEvent.setup(); + const submitButton = screen.getByRole("button", { + name: "Submit", + }); + + // Submit the form being empty. + await user.click(submitButton); + expect(formData).toEqual({ + datepickerStart: "", + datepickerEnd: "", + }); + + // Open calendar + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + const allSegments = await screen.getAllByRole("spinbutton"); + const startMonthSegment = allSegments![0]; + + // Select the first segment, month one. + await user.click(startMonthSegment); + expect(startMonthSegment).toHaveFocus(); + + // Type start date's value. + await user.keyboard("{1}{0}{5}{2}{0}{2}{3}"); + + // Type end date's value. + await user.keyboard("{1}{2}{5}{2}{0}{2}{3}"); + + // Submit form being filled with a date. + await user.click(submitButton); + expectCalendarToBeClosed(); + expectDateFieldsToBeDisplayed(); + + // Make sure form's value matches. + expect(formData).toEqual({ + datepickerStart: "2023-10-05", + datepickerEnd: "2023-12-05", + }); + + // Clear picked date. + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectDateFieldsToBeDisplayed(); + + // Submit the form being empty. + await user.click(submitButton); + + // Date field disappears when the user click outside the comppnent. + await expectDateFieldsToBeHidden(); + + // Make sure form's value is null. + expect(formData).toEqual({ + datepickerStart: "", + datepickerEnd: "", + }); + }); +}); diff --git a/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx new file mode 100644 index 0000000..26d395f --- /dev/null +++ b/packages/react/src/components/Forms/DatePicker/DateRangePicker.tsx @@ -0,0 +1,107 @@ +import React, { useMemo, useRef, useState } from "react"; +import { DateValue } from "@internationalized/date"; +import { + DateRangePickerStateOptions, + useDateRangePickerState, +} from "@react-stately/datepicker"; +import { useDateRangePicker, DateRange } from "@react-aria/datepicker"; +import { CalendarRange } from ":/components/Forms/DatePicker/Calendar"; +import DatePickerAux, { + DatePickerAuxSubProps, +} from ":/components/Forms/DatePicker/DatePickerAux"; +import DateFieldBox from ":/components/Forms/DatePicker/DateField"; +import { StringsOrDateRange } from ":/components/Forms/DatePicker/types"; +import { + getDefaultPickerOptions, + parseRangeCalendarDate, +} from ":/components/Forms/DatePicker/utils"; + +export type DateRangePickerProps = DatePickerAuxSubProps & { + startLabel: string; + endLabel: string; + value?: null | StringsOrDateRange; + defaultValue?: StringsOrDateRange; + onChange?: (value: [string, string] | null) => void; +}; + +const DateRangePicker = ({ + startLabel, + endLabel, + ...props +}: DateRangePickerProps) => { + if (props.defaultValue && props.value) { + throw new Error( + "You cannot use both defaultValue and value props on DateRangePicker component" + ); + } + const ref = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + const options: DateRangePickerStateOptions = { + ...getDefaultPickerOptions(props), + value: props.value === null ? null : parseRangeCalendarDate(props.value), + defaultValue: parseRangeCalendarDate(props.defaultValue), + onChange: (value: DateRange) => { + props.onChange?.( + value?.start && value.end + ? [value.start.toString(), value.end.toString()] + : null + ); + }, + }; + const pickerState = useDateRangePickerState(options); + const { startFieldProps, endFieldProps, calendarProps, ...pickerProps } = + useDateRangePicker(options, pickerState, ref); + + const labelAsPlaceholder = useMemo( + () => + !isFocused && + !pickerState.isOpen && + !pickerState.value.start && + !pickerState.value.end, + [pickerState.value, pickerState.isOpen, isFocused] + ); + + const calendar = ; + + return ( + { + pickerState.setValue({ + start: null as unknown as DateValue, + end: null as unknown as DateValue, + }); + }, + calendar, + }} + ref={ref} + > + +
+ + + ); +}; + +export default DateRangePicker;