Skip to content

Commit

Permalink
feat: RangeCalendar and refactor Calendar (#1529)
Browse files Browse the repository at this point in the history
## πŸ“ Changes

- Refactor <Calendar /> 
- Create <RangeCalendar />

## βœ… Checklist

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

- [x] Visuals match Design Specs in Figma
- [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 16, 2024
1 parent e9b952e commit 7d77749
Show file tree
Hide file tree
Showing 13 changed files with 672 additions and 97 deletions.
5 changes: 5 additions & 0 deletions .changeset/slimy-trees-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

feat: RangeCalendar
2 changes: 1 addition & 1 deletion easy-ui-react/src/Calendar/Calendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type Story = StoryObj<typeof Calendar>;
const Template = (args: CalendarProps) => <Calendar {...args} />;

const meta: Meta<typeof Calendar> = {
title: "Components/Calendar",
title: "Components/Calendar/Calendar",
args: {
isDisabled: false,
isReadOnly: false,
Expand Down
117 changes: 52 additions & 65 deletions easy-ui-react/src/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,11 @@
import React, { ReactNode } from "react";
import { Text } from "../Text";
import { VerticalStack } from "../VerticalStack";
import React from "react";
import { useCalendar, useLocale } from "react-aria";
import { useCalendarState } from "react-stately";
import { createCalendar } from "@internationalized/date";
import { DateValue, MappedDateValue } from "@react-types/calendar";
import { CalendarGrid } from "./CalendarGrid";
import { CalendarHeader } from "./CalendarHeader";
import styles from "./Calendar.module.scss";
import { CalendarBase, CalendarBaseStateProps } from "./CalendarBase";

export type CalendarProps = {
/**
* The minimum allowed date that a user may select.
*/
minValue?: DateValue | null;
/**
* The maximum allowed date that a user may select.
*/
maxValue?: DateValue | null;
/**
* Whether the calendar is disabled.
* @default false
*/
isDisabled?: boolean;
/**
* Whether the calendar value is immutable.
* @default false
*/
isReadOnly?: boolean;
export type CalendarProps = CalendarBaseStateProps & {
/**
* The current value (controlled).
*/
Expand All @@ -45,56 +23,65 @@ export type CalendarProps = {
* it returns true, then the date is unavailable.
*/
isDateUnavailable?: (date: DateValue) => boolean;
/**
* Whether the current selection is invalid according to application logic.
*/
isInvalid?: boolean;
/**
* An error message to display when the selected value is invalid.
*/
errorMessage?: ReactNode;
/**
* Display the days falling into the other months.
* @default false
*/
showDaysOutsideCurrentMonth?: boolean;
};

/**
*
* A `Calendar` displays a grid of days and allows users to select a single date.
*
* @example
* _Default Value:_
* ```tsx
* <Calendar defaultValue={new CalendarDate(2024, 7, 25)} />
* ```
*
* @example
* _Set limited available dates:_
* ```tsx
* <Calendar
* minValue={new CalendarDate(2024, 7, 24)}
* maxValue={new CalendarDate(2024, 8, 5)}
* />
* ```
*
* @example
* _Date availability:_
* ```tsx
* <Calendar
* isDateUnavailable={(date: DateValue) =>
* today(getLocalTimeZone()).compare(date) > 0
* }
* />
* ```
*
* @example
* _Controlled:_
* ```tsx
* const [date, setDate] = React.useState(null);
*
* <Calendar
* value={date}
* onChange={setDate}
* />
* ```
*/
export function Calendar(props: CalendarProps) {
const { locale } = useLocale();
const {
showDaysOutsideCurrentMonth = false,
isInvalid,
errorMessage,
} = props;
const calendarRef = React.useRef(null);
const state = useCalendarState({
...props,
locale,
createCalendar,
});
const { calendarProps, prevButtonProps, nextButtonProps, title } =
useCalendar(props, state);
const calendarProps = useCalendar(props, state);

return (
<VerticalStack gap="1">
<div {...calendarProps} className={styles.Calendar}>
<CalendarHeader
title={title}
state={state}
calendarProps={calendarProps}
prevButtonProps={prevButtonProps}
nextButtonProps={nextButtonProps}
/>
<CalendarGrid
state={state}
showDaysOutsideCurrentMonth={showDaysOutsideCurrentMonth}
/>
</div>
{isInvalid && (
<Text color="negative.500" variant="caption">
{errorMessage}
</Text>
)}
</VerticalStack>
<CalendarBase
{...props}
{...calendarProps}
state={state}
calendarRef={calendarRef}
/>
);
}

Expand Down
97 changes: 97 additions & 0 deletions easy-ui-react/src/Calendar/CalendarBase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React, { ReactNode, HTMLAttributes } from "react";
import { AriaButtonProps } from "react-aria";
import { VerticalStack } from "../VerticalStack";
import { Text } from "../Text";
import { CalendarState, RangeCalendarState } from "@react-stately/calendar";
import { RefObject } from "@react-types/shared";
import { DateValue } from "@react-types/calendar";
import { CalendarHeader } from "./CalendarHeader";
import { CalendarGrid } from "./CalendarGrid";
import styles from "./Calendar.module.scss";

export type CalendarBaseStateProps = {
/**
* The minimum allowed date that a user may select.
*/
minValue?: DateValue | null;
/**
* The maximum allowed date that a user may select.
*/
maxValue?: DateValue | null;
/**
* Whether the calendar is disabled.
* @default false
*/
isDisabled?: boolean;
/**
* Whether the calendar value is immutable.
* @default false
*/
isReadOnly?: boolean;
/**
* Callback that is called for each date of the calendar. If
* it returns true, then the date is unavailable.
*/
isDateUnavailable?: (date: DateValue) => boolean;
/**
* Whether the current selection is invalid according to application logic.
*/
isInvalid?: boolean;
/**
* An error message to display when the selected value is invalid.
*/
errorMessage?: ReactNode;
/**
* Display the days falling into the other months.
* @default false
*/
showDaysOutsideCurrentMonth?: boolean;
};

type CalendarBaseProps = {
state: CalendarState | RangeCalendarState;
isInvalid?: boolean;
showDaysOutsideCurrentMonth?: boolean;
errorMessage?: ReactNode;
calendarProps: HTMLAttributes<HTMLElement>;
nextButtonProps: AriaButtonProps;
prevButtonProps: AriaButtonProps;
errorMessageProps: HTMLAttributes<HTMLElement>;
calendarRef: RefObject<HTMLDivElement | null>;
};

export function CalendarBase(props: CalendarBaseProps) {
const {
state,
isInvalid,
errorMessage,
calendarProps,
nextButtonProps,
prevButtonProps,
calendarRef,
showDaysOutsideCurrentMonth,
...restProps
} = props;
return (
<VerticalStack gap="1">
<div {...calendarProps} className={styles.Calendar} ref={calendarRef}>
<CalendarHeader
state={state}
calendarProps={calendarProps}
prevButtonProps={prevButtonProps}
nextButtonProps={nextButtonProps}
/>
<CalendarGrid
state={state}
showDaysOutsideCurrentMonth={showDaysOutsideCurrentMonth}
{...restProps}
/>
</div>
{isInvalid && (
<Text color="negative.500" variant="caption">
{errorMessage}
</Text>
)}
</VerticalStack>
);
}
63 changes: 55 additions & 8 deletions easy-ui-react/src/Calendar/CalendarCell.module.scss
Original file line number Diff line number Diff line change
@@ -1,16 +1,50 @@
@use "../styles/common" as *;

@mixin rangeSelection {
content: "";
background-color: design-token("color.primary.100");
position: absolute;
height: 100%;
width: 100%;
z-index: -1;
// Move by half of the container size.
// prettier-ignore
left: calc(
(
component-token("calendar-cell", "mobile-size") + design-token("space.0-5")
) / 2
);
@include breakpoint-lg-up {
left: calc(
(component-token("calendar-cell", "size") + design-token("space.1")) / 2
);
}
}

.CellContainer {
padding: design-token("space.1.5") design-token("space.1");
padding: design-token("space.0-5");
.rangeSelectionStart:before {
@include rangeSelection;
}

@include breakpoint-lg-up {
padding: design-token("space.1.5") design-token("space.1");
}
}

.CalendarCell {
@include component-token("calendar-cell", "size", design-token("space.4-5"));
@include component-token(
"calendar-cell",
"mobile-size",
design-token("space.3")
);
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: component-token("calendar-cell", "size");
height: component-token("calendar-cell", "size");
width: component-token("calendar-cell", "mobile-size");
height: component-token("calendar-cell", "mobile-size");
cursor: pointer;
border-radius: design-token("shape.border_radius.lg");
span {
Expand All @@ -24,11 +58,6 @@
background-color: design-token("color.primary.100");
}

&.isOutsideCurrentMonth:hover:not(.isUnavailable) {
cursor: pointer;
background-color: design-token("color.primary.100");
}

&.isOutsideCurrentMonth,
&.isDisabled {
span {
Expand All @@ -54,4 +83,22 @@
text-decoration: line-through;
}
}
&.rangeSelection {
background-color: design-token("color.primary.100");
span {
color: design-token("color.primary.800");
}
&:before {
@include rangeSelection;
}
}

&.roundedRight:before {
content: none;
}

@include breakpoint-lg-up {
width: component-token("calendar-cell", "size");
height: component-token("calendar-cell", "size");
}
}
Loading

0 comments on commit 7d77749

Please sign in to comment.