Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: DatePicker and DateRangePicker #1544

Merged
merged 13 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/popular-mayflies-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

feat: Create DatePicker and DaterangePicker
204 changes: 172 additions & 32 deletions documentation/specs/DatePicker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<DateValue> | 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<DateValue> | null;
/**
* The current value (controlled).
*/
value?: RangeValue<DateValue> | null;
/**
* Handler that is called when the value changes.
*/
onChange?: (value: RangeValue<MappedDateValue<DateValue>> | 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";
};
```

Expand All @@ -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 <DatePicker />;
}
```

_Default value:_

```tsx
import { DatePicker } from "@easypost/easy-ui/DatePicker";

function PageWithDatePicker() {
return <DatePicker defaultValue={today(getLocalTimeZone())} />;
}
```

_Disabled:_

```tsx
import { DatePicker } from "@easypost/easy-ui/DatePicker";

function PageWithDatePicker() {
return <DatePicker isDisabled />;
}
```

_Minimum and maximum allowed dates:_

```tsx
import { DatePicker } from "@easypost/easy-ui/DatePicker";

function PageWithDatePicker() {
return (
<DatePicker
minValue={today(getLocalTimeZone()).subtract({ days: 10 })}
maxValue={today(getLocalTimeZone())}
/>
);
}
```

_Dates availabilty:_

```tsx
import { DatePicker } from "@easypost/easy-ui/DatePicker";

function PageWithDatePicker() {
return (
<DatePicker
isDateUnavailable={(date) => today(getLocalTimeZone()).compare(date) > 0}
/>
);
}
```

_Controlled:_

```tsx
import { DatePicker } from "@easypost/easy-ui/DatePicker";

function PageWithDatePicker() {
const [date, setDate] = React.useState<MappedDateValue<DateValue> | null>(
null,
);

return <DatePicker value={date} onChange={setDate} />;
}
```

_Invalid:_

```tsx
import { DatePicker } from "@easypost/easy-ui/DatePicker";

function PageWithDatePicker() {
const { locale } = useLocale();
const [date, setDate] = React.useState<MappedDateValue<DateValue> | null>(
endOfWeek(today(getLocalTimeZone()), locale),
);

return (
<DatePicker>
<DatePicker.Trigger>
<Button>01/01/2024 - 02/01/2024</Button>
</DatePicker.Trigger>
<DatePicker.Overlay>
<DateField />
<Calendar />
</DatePicker.Overlay>
</DatePicker>
<DatePicker
value={date}
onChange={setDate}
isInvalid={isInvalid}
errorMessage={isInvalid && "Weekend is not available"}
/>
);
}
```
Expand Down
10 changes: 5 additions & 5 deletions easy-ui-react/src/Calendar/CalendarCell.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand All @@ -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",
Expand Down Expand Up @@ -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");
}
Expand Down
4 changes: 3 additions & 1 deletion easy-ui-react/src/Calendar/CalendarCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion easy-ui-react/src/Calendar/CalendarGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function CalendarGrid({ state, ...props }: CalendarGridProps) {
<tr>
{weekDays.map((day, index) => (
<th key={index}>
<Text variant="overline" color="neutral.000">
<Text variant="caption3" color="neutral.000">
{day}
</Text>
</th>
Expand Down
3 changes: 1 addition & 2 deletions easy-ui-react/src/Calendar/CalendarHeader.module.scss
Original file line number Diff line number Diff line change
@@ -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");
}
Expand Down
64 changes: 64 additions & 0 deletions easy-ui-react/src/DatePicker/DateField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div {...fieldProps} ref={dateFieldRef}>
<HorizontalStack blockAlign="center">
{state.segments.map((segment, i) => (
<DateSegment key={i} segment={segment} state={state} />
))}
</HorizontalStack>
</div>
);
}

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 (
<div
{...segmentProps}
ref={dateSegmentRef}
className={classNames(
styles.DateSegment,
type === "literal" && !state.value && styles.literalSegment,
)}
>
{segment.text}
</div>
);
}
Loading
Loading