Skip to content

Commit

Permalink
feat(calendar): calendar structure completed, styles in progress
Browse files Browse the repository at this point in the history
  • Loading branch information
jrgarciadev committed Mar 6, 2024
1 parent 2c4f97b commit 79b7a37
Show file tree
Hide file tree
Showing 12 changed files with 541 additions and 92 deletions.
6 changes: 6 additions & 0 deletions packages/components/calendar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,17 @@
"@internationalized/date": "^3.5.2",
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/button": "workspace:*",
"@react-aria/calendar": "^3.5.6",
"@react-aria/focus": "^3.14.3",
"@react-aria/i18n": "^3.8.4",
"@react-stately/calendar": "^3.4.4",
"@react-types/button": "^3.9.0",
"@react-aria/visually-hidden": "^3.8.6",
"@react-aria/utils": "^3.21.1",
"@react-types/calendar": "^3.4.4",
"@react-aria/interactions": "^3.19.1",
"@react-types/shared": "^3.19.0"
},
"devDependencies": {
Expand Down
146 changes: 146 additions & 0 deletions packages/components/calendar/src/calendar-base.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type {CalendarState, RangeCalendarState} from "@react-stately/calendar";
import type {RefObject, HTMLAttributes} from "react";
import type {AriaButtonProps} from "@react-types/button";
import type {CalendarSlots, SlotsToClasses, CalendarReturnType} from "@nextui-org/theme";

import {As, HTMLNextUIProps} from "@nextui-org/system";
import {useDateFormatter, useLocale} from "@react-aria/i18n";
import {VisuallyHidden} from "@react-aria/visually-hidden";
import {Button} from "@nextui-org/button";
import {mergeProps} from "@react-aria/utils";

import {ChevronLeftIcon} from "./chevron-left";
import {ChevronRightIcon} from "./chevron-right";
import {CalendarMonth} from "./calendar-month";

export interface CalendarBaseProps<T extends CalendarState | RangeCalendarState>
extends HTMLNextUIProps<"div"> {
state: T;
slots?: CalendarReturnType;
Component?: As;
visibleMonths?: number;
calendarProps: HTMLAttributes<HTMLElement>;
nextButtonProps: AriaButtonProps;
prevButtonProps: AriaButtonProps;
errorMessageProps: HTMLAttributes<HTMLElement>;
calendarRef: RefObject<HTMLDivElement>;
classNames?: SlotsToClasses<CalendarSlots>;
}

export function CalendarBase<T extends CalendarState | RangeCalendarState>(
props: CalendarBaseProps<T>,
) {
const {
state,
slots,
Component = "div",
calendarProps,
nextButtonProps,
prevButtonProps,
// errorMessageProps,
calendarRef: ref,
classNames,
visibleMonths = 1,
...otherProps
} = props;

const {direction} = useLocale();

const currentMonth = state.visibleRange.start;

const monthDateFormatter = useDateFormatter({
month: "long",
year: "numeric",
era:
currentMonth.calendar.identifier === "gregory" && currentMonth.era === "BC"
? "short"
: undefined,
calendar: currentMonth.calendar.identifier,
timeZone: state.timeZone,
});

const headers = [];
const calendars = [];

for (let i = 0; i < visibleMonths; i++) {
let d = currentMonth.add({months: i});

headers.push(
<header key={i} className={slots?.header({class: classNames?.header})} data-slot="header">
{i === 0 && (
<Button {...prevButtonProps}>
{direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</Button>
)}
<span
// We have a visually hidden heading describing the entire visible range,
// and the calendar itself describes the individual month
// so we don't need to repeat that here for screen reader users.
aria-hidden
className={slots?.title({class: classNames?.title})}
data-slot="title"
>
{monthDateFormatter.format(d.toDate(state.timeZone))}
</span>
{i === visibleMonths - 1 && (
<Button {...nextButtonProps}>
{direction === "rtl" ? <ChevronLeftIcon /> : <ChevronRightIcon />}
</Button>
)}
</header>,
);

calendars.push(<CalendarMonth {...props} key={i} startDate={d} state={state} />);
}

return (
<Component {...mergeProps(calendarProps, otherProps)} ref={ref}>
{/* Add a screen reader only description of the entire visible range rather than
* a separate heading above each month grid. This is placed first in the DOM order
* so that it is the first thing a touch screen reader user encounters.
* In addition, VoiceOver on iOS does not announce the aria-label of the grid
* elements, so the aria-label of the Calendar is included here as well. */}
<VisuallyHidden>
<h2>{calendarProps["aria-label"]}</h2>
</VisuallyHidden>
<div
className={slots?.headerWrapper({class: classNames?.headerWrapper})}
data-slot="header-wrapper"
>
{headers}
</div>
<div
className={slots?.gridWrapper({class: classNames?.gridWrapper})}
data-slot="grid-wrapper"
>
{calendars}
</div>
{/* For touch screen readers, add a visually hidden next button after the month grid
* so it's easy to navigate after reaching the end without going all the way
* back to the start of the month. */}
<VisuallyHidden>
<button
aria-label={nextButtonProps["aria-label"]}
disabled={nextButtonProps.isDisabled}
tabIndex={-1}
onClick={() => state.focusNextPage()}
/>
</VisuallyHidden>
{/* {state.isValueInvalid && (
<HelpText
showErrorIcon
errorMessage={
props.errorMessage ||
stringFormatter.format("invalidSelection", {
selectedCount: "highlightedRange" in state ? 2 : 1,
})
}
errorMessageProps={errorMessageProps}
isInvalid
// Intentionally a global class name so it can be targeted in DatePicker CSS...
UNSAFE_className="spectrum-Calendar-helpText"
/>
)} */}
</Component>
);
}
95 changes: 95 additions & 0 deletions packages/components/calendar/src/calendar-cell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type {CalendarState, RangeCalendarState} from "@react-stately/calendar";
import type {CalendarSlots, SlotsToClasses, CalendarReturnType} from "@nextui-org/theme";

import {CalendarDate, getDayOfWeek, isSameDay, isSameMonth, isToday} from "@internationalized/date";
import {AriaCalendarCellProps, useCalendarCell} from "@react-aria/calendar";
import {HTMLNextUIProps} from "@nextui-org/system";
import {mergeProps} from "@react-aria/utils";
import {useLocale} from "@react-aria/i18n";
import {useFocusRing} from "@react-aria/focus";
import {useHover} from "@react-aria/interactions";
import {useRef} from "react";
import {dataAttr} from "@nextui-org/shared-utils";

export interface CalendarCellProps extends HTMLNextUIProps<"td">, AriaCalendarCellProps {
state: CalendarState | RangeCalendarState;
slots?: CalendarReturnType;
classNames?: SlotsToClasses<CalendarSlots>;
currentMonth: CalendarDate;
}

export function CalendarCell(originalProps: CalendarCellProps) {
const {state, slots, currentMonth, classNames, ...props} = originalProps;

const ref = useRef<HTMLButtonElement>(null);

const {
cellProps,
buttonProps,
isPressed,
isSelected,
isDisabled,
isFocused,
isInvalid,
formattedDate,
} = useCalendarCell(
{
...props,
isDisabled: !isSameMonth(props.date, currentMonth),
},
state,
ref,
);

const isUnavailable = state.isCellUnavailable(props.date) && !isDisabled;
const isLastSelectedBeforeDisabled =
!isDisabled && !isInvalid && state.isCellUnavailable(props.date.add({days: 1}));
const isFirstSelectedAfterDisabled =
!isDisabled && !isInvalid && state.isCellUnavailable(props.date.subtract({days: 1}));
const highlightedRange = "highlightedRange" in state && state.highlightedRange;
const isSelectionStart =
isSelected && highlightedRange && isSameDay(props.date, highlightedRange.start);
const isSelectionEnd =
isSelected && highlightedRange && isSameDay(props.date, highlightedRange.end);
const {locale} = useLocale();
const dayOfWeek = getDayOfWeek(props.date, locale);
const isRangeStart =
isSelected && (isFirstSelectedAfterDisabled || dayOfWeek === 0 || props.date.day === 1);
const isRangeEnd =
isSelected &&
(isLastSelectedBeforeDisabled ||
dayOfWeek === 6 ||
props.date.day === currentMonth.calendar.getDaysInMonth(currentMonth));
const {focusProps, isFocusVisible} = useFocusRing();
const {hoverProps, isHovered} = useHover({
isDisabled: isDisabled || isUnavailable || state.isReadOnly,
});

return (
<td className={slots?.cell({class: classNames?.cell})} data-slot="cell" {...cellProps}>
<button
{...mergeProps(buttonProps, hoverProps, focusProps)}
ref={ref}
className={slots?.cellButton({class: classNames?.cellButton})}
data-disabled={dataAttr(isDisabled && !isInvalid)}
data-focus-visible={dataAttr(isFocused && isFocusVisible)}
data-hover={dataAttr(isHovered)}
data-invalid={dataAttr(isInvalid)}
data-outside-month={dataAttr(!isSameMonth(props.date, currentMonth))}
data-pressed={dataAttr(isPressed && !state.isReadOnly)}
data-range-end={dataAttr(isRangeEnd)}
data-range-selection={dataAttr(isSelected && "highlightedRange" in state)}
data-range-start={dataAttr(isRangeStart)}
data-selected={dataAttr(isSelected)}
data-selection-end={dataAttr(isSelectionEnd)}
data-selection-start={dataAttr(isSelectionStart)}
data-today={dataAttr(isToday(props.date, state.timeZone))}
data-unavailable={dataAttr(isUnavailable)}
>
<span>
<span>{formattedDate}</span>
</span>
</button>
</td>
);
}
83 changes: 83 additions & 0 deletions packages/components/calendar/src/calendar-month.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type {CalendarState, RangeCalendarState} from "@react-stately/calendar";
import type {CalendarSlots, SlotsToClasses, CalendarReturnType} from "@nextui-org/theme";

import {CalendarDate, endOfMonth, getWeeksInMonth} from "@internationalized/date";
import {CalendarPropsBase} from "@react-types/calendar";
import {HTMLNextUIProps} from "@nextui-org/system";
import {useLocale} from "@react-aria/i18n";
import {useCalendarGrid} from "@react-aria/calendar";

import {CalendarCell} from "./calendar-cell";

export interface CalendarMonthProps extends HTMLNextUIProps<"table">, CalendarPropsBase {
state: CalendarState | RangeCalendarState;
slots?: CalendarReturnType;
startDate: CalendarDate;
classNames?: SlotsToClasses<CalendarSlots>;
}

export function CalendarMonth(props: CalendarMonthProps) {
const {state, startDate, slots, classNames} = props;

const {locale} = useLocale();
const weeksInMonth = getWeeksInMonth(startDate, locale);

const {gridProps, headerProps, weekDays} = useCalendarGrid(
{
...props,
endDate: endOfMonth(startDate),
},
state,
);

return (
<table {...gridProps} className={slots?.grid({class: classNames?.grid})} data-slot="grid">
<thead
{...headerProps}
className={slots?.gridHeader({class: classNames?.gridHeader})}
data-slot="grid-header"
>
<tr
className={slots?.gridHeaderRow({class: classNames?.gridHeaderRow})}
data-slot="grid-header-row"
>
{weekDays.map((day, index) => (
<th
key={index}
className={slots?.gridHeaderCell({class: classNames?.gridHeaderCell})}
data-slot="grid-header-cell"
>
<span>{day}</span>
</th>
))}
</tr>
</thead>
<tbody className={slots?.gridBody({class: classNames?.gridBody})} data-slot="grid-body">
{[...new Array(weeksInMonth).keys()].map((weekIndex) => (
<tr
key={weekIndex}
className={slots?.gridBodyRow({class: classNames?.gridBodyRow})}
data-slot="grid-body-row"
>
{state
.getDatesInWeek(weekIndex, startDate)
.map((date, i) =>
date ? (
<CalendarCell
key={i}
classNames={classNames}
currentMonth={startDate}
date={date}
slots={slots}
state={state}
/>
) : (
<td key={i} />
),
)}
</tr>
))}
</tbody>
</table>
);
}
14 changes: 5 additions & 9 deletions packages/components/calendar/src/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,19 @@ import type {ForwardedRef, ReactElement, Ref} from "react";
import {forwardRef} from "@nextui-org/system";

import {UseCalendarProps, useCalendar} from "./use-calendar";
import {CalendarBase} from "./calendar-base";

interface Props<T extends DateValue> extends UseCalendarProps<T> {}

function Calendar<T extends DateValue>(props: Props<T>, ref: ForwardedRef<HTMLDivElement>) {
const {Component, domRef, children, styles, ...otherProps} = useCalendar({...props, ref});

return (
<Component ref={domRef} className={styles} {...otherProps}>
{/* TODO: CalendarBase */}
{children}
</Component>
);
const {getCalendarProps} = useCalendar({...props, ref});

return <CalendarBase {...getCalendarProps()} />;
}

Calendar.displayName = "NextUI.Calendar";

export type CalendarProps<T extends DateValue> = Props<T> & {ref?: Ref<HTMLElement>};
export type CalendarProps<T extends DateValue = DateValue> = Props<T> & {ref?: Ref<HTMLElement>};

// forwardRef doesn't support generic parameters, so cast the result to the correct type
export default forwardRef(Calendar) as <T extends DateValue>(
Expand Down
22 changes: 22 additions & 0 deletions packages/components/calendar/src/chevron-left.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {IconSvgProps} from "@nextui-org/shared-icons";

export const ChevronLeftIcon = (props: IconSvgProps) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 16 16"
width="1em"
{...props}
>
<path
d="M10 3.33334L6 8.00001L10 12.6667"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
Loading

0 comments on commit 79b7a37

Please sign in to comment.