-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(calendar): calendar structure completed, styles in progress
- Loading branch information
1 parent
2c4f97b
commit 79b7a37
Showing
12 changed files
with
541 additions
and
92 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); |
Oops, something went wrong.