♻️(react) refactor DatePicker component

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.
This commit is contained in:
Lebaud Antoine
2023-06-16 17:00:14 +02:00
committed by aleb_the_flash
parent 0d6b98ee1f
commit 114d0b5f2e
5 changed files with 137 additions and 204 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
Refactor DatePicker component

View File

@@ -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("<DatePicker/>", () => {
@@ -25,7 +25,11 @@ describe("<DatePicker/>", () => {
};
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("<DatePicker/>", () => {
expectCalendarToBeClosed();
});
it("focuses in the right order with no picked date", async () => {
it("toggles calendar with keyboard", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<DatePicker label="Pick a date" name="datepicker" />
</CunninghamProvider>
);
// 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("<DatePicker/>", () => {
expectCalendarToBeOpen();
});
it("focuses in the right order with no picked date", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<DatePicker label="Pick a date" name="datepicker" />
</CunninghamProvider>
);
// 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("<DatePicker/>", () => {
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("<DatePicker/>", () => {
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("<DatePicker/>", () => {
label="Pick a date"
name="datepicker"
value={value}
onChange={(e) => setValue(e)}
onChange={(e: string | null) => setValue(e)}
/>
</div>
</CunninghamProvider>
@@ -590,7 +622,7 @@ describe("<DatePicker/>", () => {
// Submit the form being empty.
await user.click(submitButton);
expect(formData).toEqual({
datepicker: null,
datepicker: "",
});
// Open calendar
@@ -632,7 +664,7 @@ describe("<DatePicker/>", () => {
// Make sure form's value is null.
expect(formData).toEqual({
datepicker: null,
datepicker: "",
});
});

View File

@@ -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<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const options: DatePickerStateOptions<DateValue> = {
...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 = <Calendar {...calendarProps} />;
return (
<DatePickerAux
{...{
...props,
labelAsPlaceholder,
isFocused,
pickerState,
pickerProps,
calendar,
onClear: () => pickerState.setValue(null as unknown as DateValue),
}}
ref={ref}
>
<DateFieldBox
{...{
...fieldProps,
label: props.label,
labelAsPlaceholder,
onFocusChange: setIsFocused,
}}
/>
</DatePickerAux>
);
};
export default DatePicker;

View File

@@ -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<string | Date>("2023-05-26");
const [value, setValue] = useState<StringOrDate | null>("2023-05-26");
return (
<CunninghamProvider>
<div>
@@ -93,7 +94,7 @@ export const Controlled = () => {
<DatePicker
label="Pick a date"
value={value}
onChange={(e: string) => {
onChange={(e) => {
setValue(e);
}}
/>

View File

@@ -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<DateValue> = {
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<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(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 <Button /> component.
// Remove it to avoid any warning.
const { onPress: onPressToggleButton, ...otherButtonProps } = buttonProps;
return (
<Field {...props}>
<div
ref={pickerRef}
className={classNames("c__date-picker", {
"c__date-picker--disabled": disabled,
"c__date-picker--invalid": isDateInvalid,
"c__date-picker--success": props.state === "success",
"c__date-picker--focused":
!isDateInvalid && !disabled && (state.isOpen || isFocused),
})}
>
<div
className={classNames("c__date-picker__wrapper", {
"c__date-picker__wrapper--clickable": labelAsPlaceholder,
})}
ref={wrapperRef}
{...groupProps}
aria-label={label}
role="button"
tabIndex={0}
onClick={() => !state.isOpen && state.open()}
>
{state.value && (
<input type="hidden" name={name} value={state.value?.toString()} />
)}
<div className="c__date-picker__wrapper__icon">
<Button
{...{
...otherButtonProps,
"aria-label": t(
state.isOpen
? "components.forms.date_picker.toggle_button_aria_label_close"
: "components.forms.date_picker.toggle_button_aria_label_open"
),
}}
color="tertiary"
size="small"
className="c__date-picker__wrapper__toggle"
onClick={state.toggle}
icon={<span className="material-icons">calendar_today</span>}
disabled={disabled}
/>
</div>
<LabelledBox label={label} labelAsPlaceholder={labelAsPlaceholder}>
<div className="c__date-picker__inner">
{!labelAsPlaceholder && (
<DateField
{...{
...fieldProps,
onFocusChange: (focus) => setIsFocused(focus),
}}
/>
)}
</div>
</LabelledBox>
<Button
className={classNames("c__date-picker__inner__action", {
"c__date-picker__inner__action--empty": !state.value,
"c__date-picker__inner__action--hidden":
labelAsPlaceholder || disabled,
})}
color="tertiary"
size="small"
icon={<span className="material-icons">cancel</span>}
// Intentionally pass a null value to reset date picker state
onClick={() => state.setValue(null as unknown as DateValue)}
aria-label={t(
"components.forms.date_picker.clear_button_aria_label"
)}
disabled={disabled}
/>
</div>
{state.isOpen && (
<Popover
parentRef={pickerRef}
onClickOutside={state.close}
borderless
>
<Calendar {...calendarProps} />
</Popover>
)}
</div>
</Field>
);
};
// todo : what do we export ?