Skip to content

Commit

Permalink
feat: DatePicker and DateRangePicker (#1544)
Browse files Browse the repository at this point in the history
## πŸ“ Changes

- Fix calendar styles
- Add DatePicker and DateRangePicker

## βœ… Checklist

Easy UI has certain UX standards that must be met. In general,
non-trivial changes should meet the following criteria:

- [ ] ~Visuals match Design Specs in Figma~(There is no design spec)
- [x] Stories accompany any component changes
- [x] Code is in accordance with our style guide
- [x] Design tokens are utilized
- [x] Unit tests accompany any component changes
- [x] TSDoc is written for any API surface area
- [x] Specs are up-to-date
- [x] Console is free from warnings
- [x] No accessibility violations are reported
- [x] Cross-browser check is performed (Chrome, Safari, Firefox)
- [x] Changeset is added

~Strikethrough~ any items that are not applicable to this pull request.

---------

Co-authored-by: Kevin Liu <[email protected]>
  • Loading branch information
kevinalexliu and Kevin Liu authored Dec 20, 2024
1 parent bc670f5 commit 15a0b00
Show file tree
Hide file tree
Showing 22 changed files with 1,400 additions and 42 deletions.
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

0 comments on commit 15a0b00

Please sign in to comment.