diff --git a/.changeset/popular-mayflies-rescue.md b/.changeset/popular-mayflies-rescue.md new file mode 100644 index 00000000..ed94a5c1 --- /dev/null +++ b/.changeset/popular-mayflies-rescue.md @@ -0,0 +1,5 @@ +--- +"@easypost/easy-ui": minor +--- + +feat: Create DatePicker and DaterangePicker diff --git a/documentation/specs/DatePicker.md b/documentation/specs/DatePicker.md index 26c026e5..2db01e71 100644 --- a/documentation/specs/DatePicker.md +++ b/documentation/specs/DatePicker.md @@ -11,6 +11,7 @@ DatePicker combine a date field and a calendar popver to allow users to enter or ### Features - Supports setting dates availability +- Support minimum and maximum allowed dates - Supports being controlled ### Prior Art @@ -23,56 +24,122 @@ DatePicker combine a date field and a calendar popver to allow users to enter or ## Design -`DatePicker` will use `useDatePicker` and `useDateRangePicker` from `React Aria` to helps achieve accessible date picker. +The `DatePicker` will utilize `useDatePicker` and `useDateRangePicker` from `React Aria` to ensure an accessible date picker experience. -A `DatePicker` composes several other components to product a composite element that can be used to enter dates with keyboard, or select them on a calendar. The component consist of `DatePicker` wrapper, `DatePicker.Trigger` to open a `DatePicker.Overlay` containing a `Calendar` and `DateField` for selecting and inputing dates. +The component includes a `DatePickerBase` that determines whether it's a `DatePicker` or a `DateRangePicker`, depending on the state is passed into. + +`DatePickerTiger` features a `DateField` that enables users to input dates, along with a calendar icon that opens the `DatePickerOverlay`, allowing users to select dates from the calendar component. ### API ```ts -type DatePickerProps = { +export type DatePickerProps = { + /** + * Accessibility label for input field. + */ + "aria-label"?: string; /** * The content to display as the label. */ - label: ReactNode; + label?: string; + /** + * The default value (uncontrolled). + */ + defaultValue?: DateValue | null; + /** + * The current value (controlled). + */ + value?: DateValue | null; + /** + * Handler that is called when the value changes. + */ + onChange?: (value: MappedDateValue | null) => void; + // onChange?: (value: DateValue | null) => void; /** * The minimum allowed date that a user may select. */ - minValue: DateValue; + minValue?: DateValue; /** * The maximum allowed date that a user may select. */ - maxValue: DateValue; + maxValue?: DateValue; /** - * Callback that is called for each date of the calendar. If it returns - * true, then the date is unavailable. + * Whether the input is disabled. */ - isDateUnavailable: (date: DateValue) => boolean; + isDisabled?: boolean; /** - * A placeholder date that influences the format of the placeholder shown - * when no value is selected. Defaults to today's date at midnight. + * Whether the input value is invalid. */ - placeholderValue: DateValue; + isInvalid?: boolean; /** - * Whether the input is disabled. + * An error message to display when the selected value is invalid. + */ + errorMessage?: ReactNode; + /** + * Callback that is called for each date of the calendar. If + * it returns true, then the date is unavailable. + */ + isDateUnavailable?: (date: DateValue) => boolean; + /** + * The size of the DatePicker. + * @default md + */ + size?: "sm" | "md"; +}; +``` + +```ts +export type DateRangePickerProps = { + /** + * Accessibility label for input field. + */ + "aria-label"?: string; + /** + * The content to display as the label. + */ + label?: string; + /** + * The default value (uncontrolled). + */ + defaultValue?: RangeValue | null; + /** + * The current value (controlled). + */ + value?: RangeValue | null; + /** + * Handler that is called when the value changes. + */ + onChange?: (value: RangeValue> | null) => void; + /** + * The minimum allowed date that a user may select. */ - isDisabled: boolean; + minValue?: DateValue; /** - * Whether the input can be selected but not changed by the user. + * The maximum allowed date that a user may select. */ - isReadOnly: boolean; + maxValue?: DateValue; /** - * Whether user input is required on the input before form submission. + * Whether the input is disabled. */ - isRequired: boolean; + isDisabled?: boolean; /** * Whether the input value is invalid. */ - isInvalid: boolean; + isInvalid?: boolean; /** - * An error message for the field. + * An error message to display when the selected value is invalid. */ - errorMessage: ReactNode; + errorMessage?: ReactNode; + /** + * Callback that is called for each date of the calendar. If + * it returns true, then the date is unavailable. + */ + isDateUnavailable?: (date: DateValue) => boolean; + /** + * The size of the DateRangePicker. + * @default md + */ + size?: "sm" | "md"; }; ``` @@ -82,20 +149,93 @@ _Standalone_: ```tsx import { DatePicker } from "@easypost/easy-ui/DatePicker"; -import { Calendar } from "@easypost/easy-ui/Calendar"; -import { DateField } from "@easypost/easy-ui/DateField"; function PageWithDatePicker() { + return ; +} +``` + +_Default value:_ + +```tsx +import { DatePicker } from "@easypost/easy-ui/DatePicker"; + +function PageWithDatePicker() { + return ; +} +``` + +_Disabled:_ + +```tsx +import { DatePicker } from "@easypost/easy-ui/DatePicker"; + +function PageWithDatePicker() { + return ; +} +``` + +_Minimum and maximum allowed dates:_ + +```tsx +import { DatePicker } from "@easypost/easy-ui/DatePicker"; + +function PageWithDatePicker() { + return ( + + ); +} +``` + +_Dates availabilty:_ + +```tsx +import { DatePicker } from "@easypost/easy-ui/DatePicker"; + +function PageWithDatePicker() { + return ( + today(getLocalTimeZone()).compare(date) > 0} + /> + ); +} +``` + +_Controlled:_ + +```tsx +import { DatePicker } from "@easypost/easy-ui/DatePicker"; + +function PageWithDatePicker() { + const [date, setDate] = React.useState | null>( + null, + ); + + return ; +} +``` + +_Invalid:_ + +```tsx +import { DatePicker } from "@easypost/easy-ui/DatePicker"; + +function PageWithDatePicker() { + const { locale } = useLocale(); + const [date, setDate] = React.useState | null>( + endOfWeek(today(getLocalTimeZone()), locale), + ); + return ( - - - - - - - - - + ); } ``` diff --git a/easy-ui-react/src/Calendar/CalendarCell.module.scss b/easy-ui-react/src/Calendar/CalendarCell.module.scss index 850c7d60..92627a10 100644 --- a/easy-ui-react/src/Calendar/CalendarCell.module.scss +++ b/easy-ui-react/src/Calendar/CalendarCell.module.scss @@ -14,7 +14,7 @@ component-token("calendar-cell", "mobile-size") + design-token("space.0-5") ) / 2 ); - @include breakpoint-lg-up { + @include breakpoint-md-up { left: calc( (component-token("calendar-cell", "size") + design-token("space.1")) / 2 ); @@ -27,13 +27,13 @@ @include rangeSelection; } - @include breakpoint-lg-up { - padding: design-token("space.1.5") design-token("space.1"); + @include breakpoint-md-up { + padding: 10px design-token("space.1"); } } .CalendarCell { - @include component-token("calendar-cell", "size", design-token("space.4-5")); + @include component-token("calendar-cell", "size", design-token("space.4")); @include component-token( "calendar-cell", "mobile-size", @@ -97,7 +97,7 @@ content: none; } - @include breakpoint-lg-up { + @include breakpoint-md-up { width: component-token("calendar-cell", "size"); height: component-token("calendar-cell", "size"); } diff --git a/easy-ui-react/src/Calendar/CalendarCell.tsx b/easy-ui-react/src/Calendar/CalendarCell.tsx index 6ec8ae39..3ed46f6e 100644 --- a/easy-ui-react/src/Calendar/CalendarCell.tsx +++ b/easy-ui-react/src/Calendar/CalendarCell.tsx @@ -44,7 +44,9 @@ export function CalendarCell({ state, date }: CalendarCellProps) { } if (!state.isInvalid(date)) { if (isRangeCalendar) { - rangeState.setValue(rangeState.highlightedRange); + if (!rangeState.anchorDate) { + rangeState.setValue(rangeState.highlightedRange); + } } else { singleState.setValue(date); } diff --git a/easy-ui-react/src/Calendar/CalendarGrid.tsx b/easy-ui-react/src/Calendar/CalendarGrid.tsx index 85552b06..5211cc29 100644 --- a/easy-ui-react/src/Calendar/CalendarGrid.tsx +++ b/easy-ui-react/src/Calendar/CalendarGrid.tsx @@ -27,7 +27,7 @@ export function CalendarGrid({ state, ...props }: CalendarGridProps) { {weekDays.map((day, index) => ( - + {day} diff --git a/easy-ui-react/src/Calendar/CalendarHeader.module.scss b/easy-ui-react/src/Calendar/CalendarHeader.module.scss index cfaf18c5..35316c73 100644 --- a/easy-ui-react/src/Calendar/CalendarHeader.module.scss +++ b/easy-ui-react/src/Calendar/CalendarHeader.module.scss @@ -1,14 +1,13 @@ @use "../styles/common" as *; .CalendarHeader { - @include component-token("calendar-header", "padding", 6px); @include component-token("calendar-header", "line-height", 19.5px); @include component-token("calendar-header", "border-radius", 7px); width: 100%; background-color: design-token("color.primary.800"); border-top-left-radius: component-token("calendar-header", "border-radius"); border-top-right-radius: component-token("calendar-header", "border-radius"); - padding: component-token("calendar-header", "padding"); + padding: design-token("space.1"); span { line-height: component-token("calendar-header", "line-height"); } diff --git a/easy-ui-react/src/DatePicker/DateField.tsx b/easy-ui-react/src/DatePicker/DateField.tsx new file mode 100644 index 00000000..04977f45 --- /dev/null +++ b/easy-ui-react/src/DatePicker/DateField.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { useDateField, useDateSegment, useLocale } from "react-aria"; +import { + useDateFieldState, + DateFieldState, + DateSegment as DateSegmentType, +} from "react-stately"; +import { createCalendar } from "@internationalized/date"; +import { DateValue } from "@react-types/calendar"; +import { HorizontalStack } from "../HorizontalStack"; +import { classNames } from "../utilities/css"; + +import styles from "./DatePicker.module.scss"; + +type DateFieldFieldProps = { + isDisabled?: boolean; + isReadOnly?: boolean; + isInvalid?: boolean; + isOpen?: boolean; + defaultOpen?: boolean; + value?: DateValue | null; + onChange?: (value: DateValue | null) => void; +}; +export function DateFieldField(props: DateFieldFieldProps) { + const dateFieldRef = React.useRef(null); + const { locale } = useLocale(); + const state = useDateFieldState({ ...props, locale, createCalendar }); + const { fieldProps } = useDateField(props, state, dateFieldRef); + + return ( +
+ + {state.segments.map((segment, i) => ( + + ))} + +
+ ); +} + +type DateSegmentProps = { + segment: DateSegmentType; + state: DateFieldState; +}; + +function DateSegment(props: DateSegmentProps) { + const { segment, state } = props; + const { type } = segment; + const dateSegmentRef = React.useRef(null); + const { segmentProps } = useDateSegment(segment, state, dateSegmentRef); + + return ( +
+ {segment.text} +
+ ); +} diff --git a/easy-ui-react/src/DatePicker/DatePicker.mdx b/easy-ui-react/src/DatePicker/DatePicker.mdx new file mode 100644 index 00000000..ac7c4a4e --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePicker.mdx @@ -0,0 +1,62 @@ +import React from "react"; +import { ArgTypes, Canvas, Meta, Controls } from "@storybook/blocks"; +import { DatePicker } from "./DatePicker"; +import * as DatePickerStories from "./DatePicker.stories"; + + + +# DatePicker + +DatePicker combine a DateField and a Calendar popover to allow users to enter or select a date. + + + +## Default value + +A `` component allows users to set default selected date (uncontrolled). + + + +## Sizes + +Use `size="sm"` to render a smaller ``. + + + + + +## Set minimum and maximum value + +Set minimum and maximum allowed date that a user may select or enter. Dates outside the minimum and maximum value are disabled and unreachable. + + + +## Set Date Availability + +Callback that is called for each date of the calendar. If it returns `true`, then the date is unavailable. + + + +## Disabled DatePicker + +Use `isDisabled` to disabled the DatePicker entirely. + + + + + +## Controlled Selection + +For controlled selection, use `onChange` and `value`. Uncontrolled selection is possible with `defaultValue`. See associated code snippet. + + + +## Invalid Selection + +An error message is displayed when the selected value is invalid according to application logic. Select a weekend date to see an invalid selection. + + + +## Properties + + diff --git a/easy-ui-react/src/DatePicker/DatePicker.module.scss b/easy-ui-react/src/DatePicker/DatePicker.module.scss new file mode 100644 index 00000000..ad6a56bd --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePicker.module.scss @@ -0,0 +1,83 @@ +@use "../styles/common" as *; +@use "../InputField/mixins" as Input; +@use "../Menu/mixins" as Menu; + +.DatePicker { + @include Input.root; +} + +.datePickerTrigger { + display: flex; + align-items: center; + @include Input.input; + + &:hover { + @include Input.hovered; + } + @include Input.iconEndInput; +} + +.datePickerTriggerContainer { + @include Input.inputIconContainer; +} + +.datePickerSm .datePickerTrigger { + @include Input.inputSm; + @include Input.iconEndInput; +} + +.datePickerTrigger:not(.disabled) > :last-child { + color: design-token("color.neutral.700"); + cursor: pointer; + + &:hover { + color: design-token("color.neutral.800"); + } +} + +.datePickerTrigger > :last-child { + top: calc(design-token("space.1-5") - design-token("shape.border_width.1")); +} + +.datePickerSm .datePickerTrigger > :last-child { + top: calc(design-token("space.1") - design-token("shape.border_width.1")); +} + +.errorInput { + @include Input.error; +} + +.underlay { + position: fixed; + inset: 0; +} + +.dialog { + background: theme-token("color.neutral.000"); + border-radius: design-token("shape.border_radius.lg"); + box-shadow: design-token("shadow.modal"); + pointer-events: auto; + outline: none; + position: relative; + z-index: 1; +} + +.DateSegment { + @include font-style("body1"); + padding: 0 design-token("space.0-5"); + color: design-token("color.primary.800"); + &[data-placeholder="true"], + &.literalSegment { + color: design-token("color.neutral.500"); + } + &:focus-visible { + outline: none; + background-color: design-token("color.primary.500"); + border-radius: design-token("shape.border_radius.md"); + color: design-token("color.neutral.000"); + } +} + +.datePickerSm .DateSegment { + @include font-style("body2"); +} diff --git a/easy-ui-react/src/DatePicker/DatePicker.stories.tsx b/easy-ui-react/src/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000..ea580e18 --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { useLocale } from "react-aria"; +import { + today, + getLocalTimeZone, + isWeekend, + endOfWeek, +} from "@internationalized/date"; +import { DateValue, MappedDateValue } from "@react-types/calendar"; +import { InputDecorator } from "../utilities/storybook"; +import { DatePicker, DatePickerProps } from "./DatePicker"; + +type Story = StoryObj; + +const meta: Meta = { + title: "Components/DatePicker/DatePicker", + args: { "aria-label": "Date picker" }, + component: DatePicker, + decorators: [InputDecorator], +}; + +export default meta; + +const Template = (args: DatePickerProps) => ; + +export const Standalone: Story = { + render: Template.bind({}), +}; + +export const DefaultValue: Story = { + render: Template.bind({}), + args: { + defaultValue: today(getLocalTimeZone()), + }, +}; + +export const Sizes: Story = { + render: Template.bind({}), + args: { + size: "sm", + }, +}; + +export const LimitAvailableDates: Story = { + render: Template.bind({}), + args: { + minValue: today(getLocalTimeZone()).subtract({ days: 10 }), + maxValue: today(getLocalTimeZone()), + }, +}; + +export const DatesAvailability: Story = { + render: Template.bind({}), + args: { + isDateUnavailable: (date) => today(getLocalTimeZone()).compare(date) > 0, + }, +}; + +export const ControlledSelection: Story = { + render: () => { + const [date, setDate] = React.useState | null>( + null, + ); + return ( + + ); + }, +}; + +export const InvalidSelection: Story = { + render: () => { + const { locale } = useLocale(); + const [date, setDate] = React.useState | null>( + endOfWeek(today(getLocalTimeZone()), locale), + ); + + const isInvalid = date ? isWeekend(date, locale) : false; + + return ( + + ); + }, +}; + +export const Disabled: Story = { + render: Template.bind({}), + args: { + isDisabled: true, + }, +}; diff --git a/easy-ui-react/src/DatePicker/DatePicker.test.tsx b/easy-ui-react/src/DatePicker/DatePicker.test.tsx new file mode 100644 index 00000000..87b28dc3 --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePicker.test.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { + CalendarDate, + startOfMonth, + today, + getLocalTimeZone, +} from "@internationalized/date"; +import { DateValue } from "@react-types/calendar"; +import { render } from "../utilities/test"; +import { DatePicker } from "./DatePicker"; +import { clickElement } from "../RangeCalendar/RangeCalendar.test"; + +const month = 7; +const year = 2024; +const defaultValue = new CalendarDate(year, month, 4); + +describe("", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("should render a date picker", () => { + render(); + expect( + screen.getByRole("button", { name: /calendar/i }), + ).toBeInTheDocument(); + }); + + it("should open up a popover with calendar", async () => { + const { user } = render(); + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + const dateObject = startOfMonth(today(getLocalTimeZone())).toDate( + getLocalTimeZone(), + ); + const name = dateObject.toLocaleString("default", { + month: "long", + year: "numeric", + }); + expect(screen.getByRole("heading", { name })).toBeInTheDocument(); + }); + + it("should have default selected value", async () => { + const { user } = render(); + // Date display on the DateField + expect(screen.getByRole("spinbutton", { name: /month/i })).toHaveValue( + month, + ); + expect(screen.getByRole("spinbutton", { name: /day/i })).toHaveValue(4); + expect(screen.getByRole("spinbutton", { name: /year/i })).toHaveValue(year); + // Open popover + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + // Date selected in Calendar + expect(screen.getByRole("gridcell", { name: "4" })).toHaveAttribute( + "aria-selected", + ); + }); + + it("should have minimum and maximun date that a user may select", async () => { + const { user } = render( + , + ); + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + expect( + screen.getByRole("button", { name: /Thursday, July 25, 2024/i }), + ).toHaveAttribute("aria-disabled"); + expect( + screen.getByRole("button", { name: /Friday, July 5, 2024/i }), + ).not.toHaveAttribute("aria-disabled"); + }); + + it("should render past date unavailable", async () => { + const setDateUnavailable = (date: DateValue) => + defaultValue.compare(date) > 0; + const { user } = render( + , + ); + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + expect( + screen.getByRole("heading", { name: /July 2024/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Wednesday, July 3, 2024/i }), + ).toHaveAttribute("aria-disabled"); + expect( + screen.getByRole("button", { name: /Friday, July 5, 2024/i }), + ).not.toHaveAttribute("aria-disabled"); + }); + + it("should be disabled", () => { + render(); + expect(screen.getByRole("group")).toHaveAttribute("aria-disabled"); + const dateFields = screen.getAllByRole("spinbutton"); + dateFields.every((field) => expect(field).toHaveAttribute("aria-disabled")); + }); + + it("should show error message when date is invalid", async () => { + render(); + expect(screen.getByText("This date is invalid")).toBeInTheDocument(); + }); +}); diff --git a/easy-ui-react/src/DatePicker/DatePicker.tsx b/easy-ui-react/src/DatePicker/DatePicker.tsx new file mode 100644 index 00000000..fb919e85 --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePicker.tsx @@ -0,0 +1,141 @@ +import React, { ReactNode } from "react"; +import { useDatePicker } from "react-aria"; +import { useDatePickerState } from "react-stately"; +import { DateValue, MappedDateValue } from "@react-types/calendar"; +import { DatePickerBase } from "./DatePickerBase"; +import { Calendar } from "../Calendar"; + +export type DatePickerProps = { + /** + * Accessibility label for input field. + */ + "aria-label"?: string; + /** + * The content to display as the label. + */ + label?: string; + /** + * The default value (uncontrolled). + */ + defaultValue?: DateValue | null; + /** + * The current value (controlled). + */ + value?: DateValue | null; + /** + * Handler that is called when the value changes. + */ + onChange?: (value: MappedDateValue | null) => void; + // onChange?: (value: DateValue | null) => void; + /** + * The minimum allowed date that a user may select. + */ + minValue?: DateValue; + /** + * The maximum allowed date that a user may select. + */ + maxValue?: DateValue; + /** + * Whether the input is disabled. + */ + isDisabled?: boolean; + /** + * Whether the input value is invalid. + */ + isInvalid?: boolean; + /** + * An error message to display when the selected value is invalid. + */ + errorMessage?: ReactNode; + /** + * Callback that is called for each date of the calendar. If + * it returns true, then the date is unavailable. + */ + isDateUnavailable?: (date: DateValue) => boolean; + /** + * The size of the DatePicker. + * @default md + */ + size?: "sm" | "md"; +}; + +/** + * A `DatePicker` has a `DateField` and a calendar popover to + * allow users to enter or select a date. + * + * @remarks + * Use a DatePciker when you want to provide a view that allows + * the users to select a date. + * + * @example + * _Standalone:_ + * ```tsx + * import { DatePicker } from "@easypost/easy-ui/DatePicker"; + * + * function PageWithDatePicker() { + * return ; + * } + * ``` + * + * @example + * _Controlled:_ + * ```tsx + * import { DatePicker } from "@easypost/easy-ui/DatePicker"; + * + * function PageWithDatePicker() { + * const [date, setDate] = React.useState(null); + * return ( + * + * ); + * } + * ``` + */ +export function DatePicker(props: DatePickerProps) { + const { + label, + size = "md", + isDisabled, + isInvalid, + errorMessage, + "aria-label": ariaLabel, + } = props; + const datePickerRef = React.useRef(null); + const state = useDatePickerState(props); + const { + groupProps, + labelProps, + fieldProps, + buttonProps, + dialogProps, + calendarProps, + } = useDatePicker(props, state, datePickerRef); + + const triggerProps = { + datePickerRef, + buttonProps, + groupProps, + fieldProps, + isDisabled, + size, + isInvalid, + errorMessage: errorMessage || calendarProps.errorMessage, + }; + const overlayProps = { dialogProps }; + + return ( + + {/** When DatePicker is invalid, error message display under both DatePicker and Calendar. Set calendar to valid prevent error message displaying twice */} + + + ); +} + +DatePicker.displayName = "DatePicker"; diff --git a/easy-ui-react/src/DatePicker/DatePickerBase.tsx b/easy-ui-react/src/DatePicker/DatePickerBase.tsx new file mode 100644 index 00000000..afe793b0 --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePickerBase.tsx @@ -0,0 +1,87 @@ +import React, { DOMAttributes, MutableRefObject, ReactNode } from "react"; +import { + AriaButtonProps, + AriaDatePickerProps, + AriaDialogProps, +} from "react-aria"; +import { DatePickerState, DateRangePickerState } from "react-stately"; +import { DateValue } from "@react-types/calendar"; +import { FocusableElement, GroupDOMAttributes } from "@react-types/shared"; +import { DatePickerTrigger } from "./DatePickerTrigger"; +import { DatePickerOverlay } from "./DatePickerOverlay"; +import { Text } from "../Text"; +import { logWarningForMissingAriaLabel } from "../InputField/utilities"; +import { classNames, variationName } from "../utilities/css"; +import styles from "./DatePicker.module.scss"; + +type TriggerProps = { + datePickerRef: MutableRefObject; + buttonProps: AriaButtonProps; + groupProps: GroupDOMAttributes; + startFieldProps?: AriaDatePickerProps; + endFieldProps?: AriaDatePickerProps; + fieldProps?: AriaDatePickerProps; + isDisabled?: boolean; + size?: "sm" | "md"; + isInvalid?: boolean; + errorMessage?: ReactNode; +}; + +type OverlayProps = { + dialogProps: AriaDialogProps; +}; + +export type DatePickerProps = { + "aria-label"?: string; + label?: string; + triggerProps: TriggerProps; + overlayProps: OverlayProps; + labelProps: DOMAttributes; + children: ReactNode; + state: DatePickerState | DateRangePickerState; +}; +export function DatePickerBase(props: DatePickerProps) { + const { + label, + "aria-label": ariaLabel, + triggerProps, + overlayProps, + labelProps, + children, + state, + } = props; + const { size } = triggerProps; + const triggerRef = React.useRef(null); + + logWarningForMissingAriaLabel(label, ariaLabel); + + const className = classNames( + styles.DatePicker, + size && styles[variationName("datePicker", size)], + ); + return ( +
+ {label && ( + + {label} + + )} + + + {children} + +
+ ); +} diff --git a/easy-ui-react/src/DatePicker/DatePickerOverlay.tsx b/easy-ui-react/src/DatePicker/DatePickerOverlay.tsx new file mode 100644 index 00000000..a65e1315 --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePickerOverlay.tsx @@ -0,0 +1,60 @@ +import React, { ReactNode, MutableRefObject } from "react"; +import { + AriaDialogProps, + DismissButton, + Overlay, + usePopover, +} from "react-aria"; +import { DatePickerState, DateRangePickerState } from "react-stately"; +import { + DEFAULT_PLACEMENT, + OVERLAY_OFFSET, + OVERLAY_PADDING_FROM_CONTAINER, +} from "../Menu/utilities"; +import styles from "./DatePicker.module.scss"; + +type DatePickerOverlayProps = { + children: ReactNode; + triggerRef: MutableRefObject; + dialogProps: AriaDialogProps; + state: DatePickerState | DateRangePickerState; +}; + +export function DatePickerOverlay(props: DatePickerOverlayProps) { + const { state } = props; + if (!state.isOpen) { + return null; + } + + return ; +} + +function DatePickerContent(props: DatePickerOverlayProps) { + const { children, triggerRef, dialogProps, state } = props; + + const popoverRef = React.useRef(null); + + const { popoverProps, underlayProps } = usePopover( + { + containerPadding: OVERLAY_PADDING_FROM_CONTAINER, + offset: OVERLAY_OFFSET, + placement: DEFAULT_PLACEMENT, + popoverRef, + triggerRef, + }, + state, + ); + + return ( + +
+
+ +
+ {children} +
+ +
+ + ); +} diff --git a/easy-ui-react/src/DatePicker/DatePickerTrigger.tsx b/easy-ui-react/src/DatePicker/DatePickerTrigger.tsx new file mode 100644 index 00000000..1770f085 --- /dev/null +++ b/easy-ui-react/src/DatePicker/DatePickerTrigger.tsx @@ -0,0 +1,81 @@ +import React, { MutableRefObject, ReactNode } from "react"; +import CalendarMonth from "@easypost/easy-ui-icons/CalendarMonth"; +import { GroupDOMAttributes } from "@react-types/shared"; +import { AriaDatePickerProps, DateValue, AriaButtonProps } from "react-aria"; +import { DatePickerState, DateRangePickerState } from "react-stately"; +import { InputIcon } from "../InputField/InputIcon"; +import { UnstyledButton } from "../UnstyledButton"; +import { DateFieldField } from "./DateField"; +import { VerticalStack } from "../VerticalStack"; +import { Text } from "../Text"; +import { classNames } from "../utilities/css"; +import styles from "./DatePicker.module.scss"; + +export type DatePickerTriggerProps = { + isDisabled?: boolean; + size?: "sm" | "md"; + triggerRef: MutableRefObject; + datePickerRef: MutableRefObject; + buttonProps: AriaButtonProps; + groupProps: GroupDOMAttributes; + startFieldProps?: AriaDatePickerProps; + endFieldProps?: AriaDatePickerProps; + fieldProps?: AriaDatePickerProps; + isInvalid?: boolean; + errorMessage?: ReactNode; + state: DatePickerState | DateRangePickerState; +}; + +export function DatePickerTrigger(props: DatePickerTriggerProps) { + const { + isDisabled, + size, + triggerRef, + datePickerRef, + buttonProps, + groupProps, + startFieldProps, + endFieldProps, + fieldProps, + isInvalid, + errorMessage, + state, + } = props; + + const className = classNames( + styles.datePickerTrigger, + (isInvalid || state.isInvalid) && styles.errorInput, + isDisabled && styles.disabled, + ); + + return ( + +
+ + + {endFieldProps && ( + <> + + + + )} + + +
+ {(isInvalid || state.isInvalid) && ( + + {errorMessage} + + )} +
+ ); +} diff --git a/easy-ui-react/src/DatePicker/index.ts b/easy-ui-react/src/DatePicker/index.ts new file mode 100644 index 00000000..2f4cf5f5 --- /dev/null +++ b/easy-ui-react/src/DatePicker/index.ts @@ -0,0 +1 @@ +export * from "./DatePicker"; diff --git a/easy-ui-react/src/DateRangePicker/DateRangePicker.mdx b/easy-ui-react/src/DateRangePicker/DateRangePicker.mdx new file mode 100644 index 00000000..8de4a9d2 --- /dev/null +++ b/easy-ui-react/src/DateRangePicker/DateRangePicker.mdx @@ -0,0 +1,62 @@ +import React from "react"; +import { ArgTypes, Canvas, Meta, Controls } from "@storybook/blocks"; +import { DateRangePicker } from "./DateRangePicker"; +import * as DateRangePickerStories from "./DateRangePicker.stories"; + + + +# DateRangePicker + +DateRangePicker combine DateFields and a Calendar popover to allow users to enter or select a range of dates. + + + +## Default values + +A `` component allows users to set default selected date (uncontrolled). + + + +## Sizes + +Use `size="sm"` to render a smaller ``. + + + + + +## Set minimum and maximum value + +Set minimum and maximum allowed date that a user may select or enter. Dates outside the minimum and maximum value are disabled and unreachable. + + + +## Set Date Availability + +Callback that is called for each date of the calendar. If it returns `true`, then the date is unavailable. + + + +## Disabled DateRangePicker + +Use `isDisabled` to disabled the DateRangePicker entirely. + + + + + +## Controlled Selection + +For controlled selection, use `onChange` and `value`. Uncontrolled selection is possible with `defaultValue`. See associated code snippet. + + + +## Invalid Selection + +An error message is displayed when the selected value is invalid according to application logic. Select more than 7 days to see the error message. + + + +## Properties + + diff --git a/easy-ui-react/src/DateRangePicker/DateRangePicker.stories.tsx b/easy-ui-react/src/DateRangePicker/DateRangePicker.stories.tsx new file mode 100644 index 00000000..3ddf04fb --- /dev/null +++ b/easy-ui-react/src/DateRangePicker/DateRangePicker.stories.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { today, getLocalTimeZone } from "@internationalized/date"; +import { DateRange } from "@react-types/calendar"; +import { InputDecorator } from "../utilities/storybook"; +import { DateRangePicker, DateRangePickerProps } from "./DateRangePicker"; + +type Story = StoryObj; + +const meta: Meta = { + title: "Components/DatePicker/DateRangePicker", + component: DateRangePicker, + args: { "aria-label": "Range date picker" }, + decorators: [InputDecorator], +}; + +export default meta; + +const Template = (args: DateRangePickerProps) => ; + +export const Standalone: Story = { + render: Template.bind({}), +}; + +export const DefaultValue: Story = { + render: Template.bind({}), + args: { + defaultValue: { + start: today(getLocalTimeZone()).subtract({ days: 7 }), + end: today(getLocalTimeZone()), + }, + }, +}; + +export const Sizes: Story = { + render: Template.bind({}), + args: { + size: "sm", + }, +}; + +export const LimitAvailableDates: Story = { + render: Template.bind({}), + args: { + minValue: today(getLocalTimeZone()).subtract({ days: 10 }), + maxValue: today(getLocalTimeZone()), + }, +}; + +export const DatesAvailability: Story = { + render: Template.bind({}), + args: { + isDateUnavailable: (date) => today(getLocalTimeZone()).compare(date) > 0, + }, +}; + +export const ControlledSelection: Story = { + render: () => { + const [date, setDate] = React.useState(); + return ( + + ); + }, +}; + +export const InvalidSelection: Story = { + render: () => { + const [date, setDate] = React.useState({ + start: today(getLocalTimeZone()).subtract({ days: 7 }), + end: today(getLocalTimeZone()), + }); + + const isInvalid = date ? date.end.compare(date.start) >= 7 : false; + + return ( + + ); + }, +}; + +export const Disabled: Story = { + render: Template.bind({}), + args: { + isDisabled: true, + }, +}; diff --git a/easy-ui-react/src/DateRangePicker/DateRangePicker.test.tsx b/easy-ui-react/src/DateRangePicker/DateRangePicker.test.tsx new file mode 100644 index 00000000..41761a6a --- /dev/null +++ b/easy-ui-react/src/DateRangePicker/DateRangePicker.test.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { + CalendarDate, + startOfMonth, + today, + getLocalTimeZone, +} from "@internationalized/date"; +import { DateValue } from "@react-types/calendar"; +import { render } from "../utilities/test"; +import { DateRangePicker } from "./DateRangePicker"; +import { clickElement } from "../RangeCalendar/RangeCalendar.test"; + +const month = 7; +const year = 2024; +const defaultValue = { + start: new CalendarDate(year, month, 4), + end: new CalendarDate(year, month, 10), +}; + +describe("", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("should render a date range picker", () => { + render(); + expect( + screen.getByRole("button", { name: /calendar/i }), + ).toBeInTheDocument(); + }); + + it("should open up a popover with calendar", async () => { + const { user } = render(); + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + const dateObject = startOfMonth(today(getLocalTimeZone())).toDate( + getLocalTimeZone(), + ); + const name = dateObject.toLocaleString("default", { + month: "long", + year: "numeric", + }); + expect(screen.getByRole("heading", { name })).toBeInTheDocument(); + }); + + it("should have default selected value", async () => { + const { user } = render(); + // Dates display on the DateField + expect( + screen.getByRole("spinbutton", { name: /month, start date/i }), + ).toHaveValue(month); + expect( + screen.getByRole("spinbutton", { name: /day, start date/i }), + ).toHaveValue(4); + expect( + screen.getByRole("spinbutton", { name: /year, start date/i }), + ).toHaveValue(year); + expect( + screen.getByRole("spinbutton", { name: /month, end date/i }), + ).toHaveValue(month); + expect( + screen.getByRole("spinbutton", { name: /day, end date/i }), + ).toHaveValue(10); + expect( + screen.getByRole("spinbutton", { name: /year, end date/i }), + ).toHaveValue(year); + // Open popover + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + // Dates selected in Calendar + expect(screen.getAllByRole("gridcell", { selected: true })).toHaveLength(7); + }); + + it("should have minimum and maximun date that a user may select", async () => { + const { user } = render( + , + ); + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + expect( + screen.getByRole("button", { name: /Thursday, July 25, 2024/i }), + ).toHaveAttribute("aria-disabled"); + expect( + screen.getByRole("button", { name: /Friday, July 5, 2024/i }), + ).not.toHaveAttribute("aria-disabled"); + }); + + it("should render past date unavailable", async () => { + const setDateUnavailable = (date: DateValue) => + defaultValue.start.compare(date) > 0; + const { user } = render( + , + ); + await clickElement(user, screen.getByRole("button", { name: /calendar/i })); + expect( + screen.getByRole("heading", { name: /July 2024/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Wednesday, July 3, 2024/i }), + ).toHaveAttribute("aria-disabled"); + expect( + screen.getByRole("button", { name: /Friday, July 5, 2024/i }), + ).not.toHaveAttribute("aria-disabled"); + }); + + it("should be disabled", () => { + render(); + expect(screen.getByRole("group")).toHaveAttribute("aria-disabled"); + const dateFields = screen.getAllByRole("spinbutton"); + dateFields.every((field) => expect(field).toHaveAttribute("aria-disabled")); + }); + + it("should show error message when date is invalid", async () => { + render(); + expect(screen.getByText("This date is invalid")).toBeInTheDocument(); + }); +}); diff --git a/easy-ui-react/src/DateRangePicker/DateRangePicker.tsx b/easy-ui-react/src/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 00000000..0908a9f4 --- /dev/null +++ b/easy-ui-react/src/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,141 @@ +import React, { ReactNode } from "react"; +import { useDateRangePicker } from "react-aria"; +import { useDateRangePickerState } from "react-stately"; +import { RangeValue } from "@react-types/shared"; +import { DateValue, MappedDateValue } from "@react-types/calendar"; +import { RangeCalendar } from "../RangeCalendar"; +import { DatePickerBase } from "../DatePicker/DatePickerBase"; + +export type DateRangePickerProps = { + /** + * Accessibility label for input field. + */ + "aria-label"?: string; + /** + * The content to display as the label. + */ + label?: string; + /** + * The default value (uncontrolled). + */ + defaultValue?: RangeValue | null; + /** + * The current value (controlled). + */ + value?: RangeValue | null; + /** + * Handler that is called when the value changes. + */ + onChange?: (value: RangeValue> | null) => void; + /** + * The minimum allowed date that a user may select. + */ + minValue?: DateValue; + /** + * The maximum allowed date that a user may select. + */ + maxValue?: DateValue; + /** + * Whether the input is disabled. + */ + isDisabled?: boolean; + /** + * Whether the input value is invalid. + */ + isInvalid?: boolean; + /** + * An error message to display when the selected value is invalid. + */ + errorMessage?: ReactNode; + /** + * Callback that is called for each date of the calendar. If + * it returns true, then the date is unavailable. + */ + isDateUnavailable?: (date: DateValue) => boolean; + /** + * The size of the DateRangePicker. + * @default md + */ + size?: "sm" | "md"; +}; + +/** + * A `DateRangePicker` has a `DateField` and a calendar popover + * to allow users to enter or select a date. + * + * @remarks + * Use a DateRangePicker when you want to provide a view that + * allows the users to select a date. + * + * @example + * _Standalone:_ + * ```tsx + * import { DateRangePicker } from "@easypost/easy-ui/DateRangePicker"; + * + * function PageWithDateRangePicker() { + * return ; + * } + * ``` + * + * @example + * _Controlled:_ + * ```tsx + * import { DateRangePicker } from "@easypost/easy-ui/DateRangePicker"; + * + * function PageWithDateRangePicker() { + * const [date, setDate] = React.useState(null); + * return ( + * + * ); + * } + * ``` + */ +export function DateRangePicker(props: DateRangePickerProps) { + const { + label, + size = "md", + isDisabled, + isInvalid, + errorMessage, + "aria-label": ariaLabel, + } = props; + const datePickerRef = React.useRef(null); + const state = useDateRangePickerState(props); + const { + groupProps, + labelProps, + startFieldProps, + endFieldProps, + buttonProps, + dialogProps, + calendarProps, + } = useDateRangePicker(props, state, datePickerRef); + + const triggerProps = { + datePickerRef, + buttonProps, + groupProps, + startFieldProps, + endFieldProps, + isDisabled, + size, + isInvalid, + errorMessage: errorMessage || calendarProps.errorMessage, + }; + const overlayProps = { dialogProps }; + + return ( + + {/** When DatePicker is invalid, error message display under both DatePicker and Calendar. Set calendar to valid prevent error message displaying twice */} + + + ); +} diff --git a/easy-ui-react/src/DateRangePicker/index.ts b/easy-ui-react/src/DateRangePicker/index.ts new file mode 100644 index 00000000..78c1d866 --- /dev/null +++ b/easy-ui-react/src/DateRangePicker/index.ts @@ -0,0 +1 @@ +export * from "./DateRangePicker"; diff --git a/easy-ui-react/src/RangeCalendar/RangeCalendar.test.tsx b/easy-ui-react/src/RangeCalendar/RangeCalendar.test.tsx index ddc57045..63dcacaa 100644 --- a/easy-ui-react/src/RangeCalendar/RangeCalendar.test.tsx +++ b/easy-ui-react/src/RangeCalendar/RangeCalendar.test.tsx @@ -109,6 +109,6 @@ describe("", () => { }); }); -async function clickElement(user: UserEvent, el: HTMLElement) { +export async function clickElement(user: UserEvent, el: HTMLElement) { await userClick(user, el); }