From 114d0b5f2eda67c66487aa8f5f5612c85799f284 Mon Sep 17 00:00:00 2001 From: Lebaud Antoine Date: Fri, 16 Jun 2023 17:00:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(react)=20refactor=20DatePick?= =?UTF-8?q?er=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the original DatePicker component to utilize the newly created shared common DatePickerAux component. This modification enhances code consistency, and promotes the reuse of the DatePickerAux across date inputs variants. --- .changeset/tricky-dryers-joke.md | 5 + .../{index.spec.tsx => DatePicker.spec.tsx} | 50 ++++- .../Forms/DatePicker/DatePicker.tsx | 86 ++++++++ .../Forms/DatePicker/index.stories.tsx | 7 +- .../src/components/Forms/DatePicker/index.tsx | 193 +----------------- 5 files changed, 137 insertions(+), 204 deletions(-) create mode 100644 .changeset/tricky-dryers-joke.md rename packages/react/src/components/Forms/DatePicker/{index.spec.tsx => DatePicker.spec.tsx} (96%) create mode 100644 packages/react/src/components/Forms/DatePicker/DatePicker.tsx 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), - }} - /> - )} -
-
-