diff --git a/.changeset/tricky-dryers-joke.md b/.changeset/tricky-dryers-joke.md new file mode 100644 index 0000000..9662904 --- /dev/null +++ b/.changeset/tricky-dryers-joke.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +Refactor DatePicker component diff --git a/packages/react/src/components/Forms/DatePicker/index.spec.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx similarity index 96% rename from packages/react/src/components/Forms/DatePicker/index.spec.tsx rename to packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx index f1fe195..497f6eb 100644 --- a/packages/react/src/components/Forms/DatePicker/index.spec.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.spec.tsx @@ -3,7 +3,7 @@ import { render, screen, within } from "@testing-library/react"; import React, { FormEvent, useState } from "react"; import { expect, vi, afterEach } from "vitest"; import { CunninghamProvider } from ":/components/Provider"; -import { DatePicker } from ":/components/Forms/DatePicker/index"; +import DatePicker from ":/components/Forms/DatePicker/DatePicker"; import { Button } from ":/components/Button"; describe("", () => { @@ -25,7 +25,11 @@ describe("", () => { }; const expectDateFieldToBeHidden = () => { - expect(screen.queryByRole("presentation")).toBeNull(); + const dateField = screen.queryByRole("presentation"); + expect(dateField).toBeTruthy(); + expect(Array.from(dateField!.parentElement!.classList)).contains( + "c__date-picker__inner--collapsed" + ); }; const expectDateFieldToBeDisplayed = () => { @@ -130,14 +134,13 @@ describe("", () => { expectCalendarToBeClosed(); }); - it("focuses in the right order with no picked date", async () => { + it("toggles calendar with keyboard", 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}"); @@ -150,6 +153,35 @@ describe("", () => { expectCalendarToBeOpen(); }); + it("focuses in the right order with no picked date", async () => { + const user = userEvent.setup(); + render( + + + + ); + // Get elements that should receive focus when no date is picked. + const [input, toggleButton] = await screen.findAllByRole("button")!; + const [monthSegment, daySegment, yearSegment] = await screen.findAllByRole( + "spinbutton" + )!; + + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(monthSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(daySegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(yearSegment).toHaveFocus(); + }); + it("focuses in the right order with a default value", async () => { const user = userEvent.setup(); render( @@ -167,7 +199,7 @@ describe("", () => { const clearButton = screen.getByRole("button", { name: "Clear date", }); - const [monthSegment, daySegment, YearSegment] = await screen.findAllByRole( + const [monthSegment, daySegment, yearSegment] = await screen.findAllByRole( "spinbutton" )!; @@ -185,7 +217,7 @@ describe("", () => { expect(daySegment).toHaveFocus(); await user.keyboard("{Tab}"); - expect(YearSegment).toHaveFocus(); + expect(yearSegment).toHaveFocus(); await user.keyboard("{Tab}"); expect(clearButton).toHaveFocus(); @@ -468,7 +500,7 @@ describe("", () => { label="Pick a date" name="datepicker" value={value} - onChange={(e) => setValue(e)} + onChange={(e: string | null) => setValue(e)} /> @@ -590,7 +622,7 @@ describe("", () => { // Submit the form being empty. await user.click(submitButton); expect(formData).toEqual({ - datepicker: null, + datepicker: "", }); // Open calendar @@ -632,7 +664,7 @@ describe("", () => { // Make sure form's value is null. expect(formData).toEqual({ - datepicker: null, + datepicker: "", }); }); diff --git a/packages/react/src/components/Forms/DatePicker/DatePicker.tsx b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx new file mode 100644 index 0000000..3117655 --- /dev/null +++ b/packages/react/src/components/Forms/DatePicker/DatePicker.tsx @@ -0,0 +1,86 @@ +import React, { useMemo, useRef, useState } from "react"; +import { + DatePickerStateOptions, + useDatePickerState, +} from "@react-stately/datepicker"; +import { DateValue } from "@internationalized/date"; +import { useDatePicker } from "@react-aria/datepicker"; +import DatePickerAux, { + DatePickerAuxSubProps, +} from ":/components/Forms/DatePicker/DatePickerAux"; +import { Calendar } from ":/components/Forms/DatePicker/Calendar"; +import DateFieldBox from ":/components/Forms/DatePicker/DateField"; +import { StringOrDate } from ":/components/Forms/DatePicker/types"; +import { + getDefaultPickerOptions, + parseCalendarDate, +} from ":/components/Forms/DatePicker/utils"; + +export type DatePickerProps = DatePickerAuxSubProps & { + value?: null | StringOrDate; + label: string; + defaultValue?: StringOrDate; + onChange?: (value: string | null) => void | undefined; +}; + +const DatePicker = (props: DatePickerProps) => { + if (props.defaultValue && props.value) { + throw new Error( + "You cannot use both defaultValue and value props on DatePicker component" + ); + } + const ref = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + const options: DatePickerStateOptions = { + ...getDefaultPickerOptions(props), + value: + // Force clear the component's value when passing null or an empty string. + props.value === "" || props.value === null + ? null + : parseCalendarDate(props.value), + defaultValue: parseCalendarDate(props.defaultValue), + onChange: (value: DateValue | null) => { + props.onChange?.(value?.toString() || ""); + }, + }; + const pickerState = useDatePickerState(options); + const { fieldProps, calendarProps, ...pickerProps } = useDatePicker( + options, + pickerState, + ref + ); + + const labelAsPlaceholder = useMemo( + () => !isFocused && !pickerState.isOpen && !pickerState.value, + [pickerState.value, pickerState.isOpen, isFocused] + ); + + const calendar = ; + + return ( + pickerState.setValue(null as unknown as DateValue), + }} + ref={ref} + > + + + ); +}; + +export default DatePicker; diff --git a/packages/react/src/components/Forms/DatePicker/index.stories.tsx b/packages/react/src/components/Forms/DatePicker/index.stories.tsx index 0c0928f..498d6ec 100644 --- a/packages/react/src/components/Forms/DatePicker/index.stories.tsx +++ b/packages/react/src/components/Forms/DatePicker/index.stories.tsx @@ -2,7 +2,8 @@ import { Meta, StoryFn } from "@storybook/react"; import React, { useState } from "react"; import { CunninghamProvider } from ":/components/Provider"; import { Button } from ":/components/Button"; -import { DatePicker } from "./index"; +import DatePicker from ":/components/Forms/DatePicker/DatePicker"; +import { StringOrDate } from ":/components/Forms/DatePicker/types"; export default { title: "Components/Forms/DatePicker", @@ -83,7 +84,7 @@ export const WithText = { }; export const Controlled = () => { - const [value, setValue] = useState("2023-05-26"); + const [value, setValue] = useState("2023-05-26"); return (
@@ -93,7 +94,7 @@ export const Controlled = () => { { + onChange={(e) => { setValue(e); }} /> diff --git a/packages/react/src/components/Forms/DatePicker/index.tsx b/packages/react/src/components/Forms/DatePicker/index.tsx index 1750cf4..220021b 100644 --- a/packages/react/src/components/Forms/DatePicker/index.tsx +++ b/packages/react/src/components/Forms/DatePicker/index.tsx @@ -1,192 +1 @@ -import React, { PropsWithChildren, useMemo, useRef, useState } from "react"; -import { - CalendarDate, - DateValue, - parseAbsoluteToLocal, - toCalendarDate, -} from "@internationalized/date"; -import { - DatePickerStateOptions, - useDatePickerState, -} from "@react-stately/datepicker"; -import { useDatePicker } from "@react-aria/datepicker"; -import classNames from "classnames"; -import { Button } from ":/components/Button"; -import { Field, FieldProps } from ":/components/Forms/Field"; -import { LabelledBox } from ":/components/Forms/LabelledBox"; -import { DateField } from ":/components/Forms/DatePicker/DateField"; -import { Calendar } from ":/components/Forms/DatePicker/Calendar"; -import { Popover } from ":/components/Popover"; -import { useCunningham } from ":/components/Provider"; - -type DatePickerProps = PropsWithChildren & - FieldProps & { - label: string; - name?: string; - value?: null | Date | string; - defaultValue?: Date | string; - minValue?: Date | string; - maxValue?: Date | string; - onChange?: (value: string) => void | undefined; - disabled?: boolean; - }; - -export const DatePicker = ({ - label, - name, - disabled = false, - ...props -}: DatePickerProps) => { - if (props.defaultValue && props.value) { - throw new Error( - "You cannot use both defaultValue and value props on DatePicker component" - ); - } - - const parseCalendarDate = ( - rawDate: Date | string | undefined - ): undefined | CalendarDate => { - if (!rawDate) { - return undefined; - } - try { - const ISODateString = new Date(rawDate).toISOString(); - return toCalendarDate(parseAbsoluteToLocal(ISODateString)); - } catch (e) { - throw new Error( - "Invalid date format when initializing props on DatePicker component" - ); - } - }; - - const datePickerOptions: DatePickerStateOptions = { - value: - // Force clear the component's value when passing null or an empty string. - props.value === "" || props.value === null - ? null - : parseCalendarDate(props.value), - defaultValue: parseCalendarDate(props.defaultValue), - minValue: parseCalendarDate(props.minValue), - maxValue: parseCalendarDate(props.maxValue), - onChange: (value: DateValue | null) => { - props.onChange?.(value?.toString() || ""); - }, - shouldCloseOnSelect: true, - granularity: "day", - isDisabled: disabled, - label, - }; - - const state = useDatePickerState(datePickerOptions); - const pickerRef = useRef(null); - const wrapperRef = useRef(null); - const { t } = useCunningham(); - - const { fieldProps, buttonProps, groupProps, calendarProps } = useDatePicker( - datePickerOptions, - state, - wrapperRef - ); - - const [isFocused, setIsFocused] = useState(false); - - const labelAsPlaceholder = useMemo( - () => !state.isOpen && !state.value, - [state.value, state.isOpen] - ); - - const isDateInvalid = useMemo( - () => state.validationState === "invalid" || props.state === "error", - [state.validationState, props.state] - ); - - // onPress props don't exist on the
- -
- {!labelAsPlaceholder && ( - setIsFocused(focus), - }} - /> - )} -
-
-