diff --git a/packages/@react-stately/calendar/src/types.ts b/packages/@react-stately/calendar/src/types.ts index ec7b26470c6..111e548f8d3 100644 --- a/packages/@react-stately/calendar/src/types.ts +++ b/packages/@react-stately/calendar/src/types.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CalendarDate} from '@internationalized/date'; +import {CalendarDate, DateDuration} from '@internationalized/date'; import {DateValue} from '@react-types/calendar'; import {RangeValue, ValidationState} from '@react-types/shared'; @@ -19,6 +19,8 @@ interface CalendarStateBase { readonly isDisabled: boolean, /** Whether the calendar is in a read only state. */ readonly isReadOnly: boolean, + /** The duration of visible dates. */ + readonly visibleDuration: DateDuration, /** The date range that is currently visible in the calendar. */ readonly visibleRange: RangeValue, /** The minimum allowed date that a user may select. */ @@ -37,7 +39,7 @@ interface CalendarStateBase { /** The currently focused date. */ readonly focusedDate: CalendarDate, /** Sets the focused date. */ - setFocusedDate(value: CalendarDate): void, + setFocusedDate(value: CalendarDate, align?: 'start' | 'center' | 'end'): void, /** Moves focus to the next calendar date. */ focusNextDay(): void, /** Moves focus to the previous calendar date. */ diff --git a/packages/@react-stately/calendar/src/useCalendarState.ts b/packages/@react-stately/calendar/src/useCalendarState.ts index 4d289535a4e..fc1ec172ab7 100644 --- a/packages/@react-stately/calendar/src/useCalendarState.ts +++ b/packages/@react-stately/calendar/src/useCalendarState.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {alignCenter, alignEnd, alignStart, constrainStart, constrainValue, isInvalid, previousAvailableDate} from './utils'; +import {alignCenter, alignDate, alignEnd, alignStart, constrainStart, constrainValue, isInvalid, previousAvailableDate} from './utils'; import { Calendar, CalendarDate, @@ -56,6 +56,9 @@ export interface CalendarStateOptions extends C */ selectionAlignment?: 'start' | 'center' | 'end' } + +const DEFAULT_VISIBLE_DURATION: DateDuration = {months: 1}; + /** * Provides state management for a calendar component. * A calendar displays one or more date grids and allows users to select a single date. @@ -66,7 +69,7 @@ export function useCalendarState(props: Calenda let { locale, createCalendar, - visibleDuration = {months: 1}, + visibleDuration = DEFAULT_VISIBLE_DURATION, minValue, maxValue, selectionAlignment, @@ -95,15 +98,7 @@ export function useCalendarState(props: Calenda ), [props.defaultFocusedValue, calendarDateValue, timeZone, calendar, minValue, maxValue]); let [focusedDate, setFocusedDate] = useControlledState(focusedCalendarDate, defaultFocusedCalendarDate, props.onFocusChange); let [startDate, setStartDate] = useState(() => { - switch (selectionAlignment) { - case 'start': - return alignStart(focusedDate, visibleDuration, locale, minValue, maxValue); - case 'end': - return alignEnd(focusedDate, visibleDuration, locale, minValue, maxValue); - case 'center': - default: - return alignCenter(focusedDate, visibleDuration, locale, minValue, maxValue); - } + return alignDate(focusedDate, selectionAlignment || 'center', visibleDuration, locale, minValue, maxValue); }); let [isFocused, setFocused] = useState(props.autoFocus || false); @@ -194,6 +189,7 @@ export function useCalendarState(props: Calenda isReadOnly: props.isReadOnly ?? false, value: calendarDateValue, setValue, + visibleDuration, visibleRange: { start: startDate, end: endDate @@ -204,9 +200,13 @@ export function useCalendarState(props: Calenda timeZone, validationState, isValueInvalid, - setFocusedDate(date) { + setFocusedDate(date, align) { focusCell(date); setFocused(true); + + if (align && (date.compare(startDate) < 0 || date.compare(endDate) > 0)) { + setStartDate(alignDate(date, align, visibleDuration, locale, minValue, maxValue)); + } }, focusNextDay() { focusCell(focusedDate.add({days: 1})); diff --git a/packages/@react-stately/calendar/src/useRangeCalendarState.ts b/packages/@react-stately/calendar/src/useRangeCalendarState.ts index 284500aa184..1b80af90be5 100644 --- a/packages/@react-stately/calendar/src/useRangeCalendarState.ts +++ b/packages/@react-stately/calendar/src/useRangeCalendarState.ts @@ -41,6 +41,8 @@ export interface RangeCalendarStateOptions exte selectionAlignment?: 'start' | 'center' | 'end' } +const DEFAULT_VISIBLE_DURATION: DateDuration = {months: 1}; + /** * Provides state management for a range calendar component. * A range calendar displays one or more date grids and allows users to select a contiguous range of dates. @@ -52,7 +54,7 @@ export function useRangeCalendarState(props: Ra onChange, createCalendar, locale, - visibleDuration = {months: 1}, + visibleDuration = DEFAULT_VISIBLE_DURATION, minValue, maxValue, ...calendarProps} = props; diff --git a/packages/@react-stately/calendar/src/utils.ts b/packages/@react-stately/calendar/src/utils.ts index d9bcdeee614..dd048f389ed 100644 --- a/packages/@react-stately/calendar/src/utils.ts +++ b/packages/@react-stately/calendar/src/utils.ts @@ -70,6 +70,18 @@ export function alignEnd(date: CalendarDate, duration: DateDuration, locale: str return constrainStart(date, aligned, duration, locale, minValue, maxValue); } +export function alignDate(date: CalendarDate, selectionAlignment: 'start' | 'center' | 'end', duration: DateDuration, locale: string, minValue?: DateValue | null, maxValue?: DateValue | null): CalendarDate { + switch (selectionAlignment) { + case 'start': + return alignStart(date, duration, locale, minValue, maxValue); + case 'end': + return alignEnd(date, duration, locale, minValue, maxValue); + case 'center': + default: + return alignCenter(date, duration, locale, minValue, maxValue); + } +} + export function constrainStart( date: CalendarDate, aligned: CalendarDate, diff --git a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx index 4325cdc604c..68a2e8d6eb2 100644 --- a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx @@ -183,7 +183,7 @@ Set the `visibleDuration` prop and render multiple `CalendarGrid` elements to di ```tsx render docs={docs.exports.Calendar} links={docs.links} props={['visibleDuration', 'pageBehavior', 'firstDayOfWeek']} initialProps={{visibleDuration: {months: 2}}} wide "use client"; -import {Calendar, Heading} from 'react-aria-components'; +import {Calendar, Heading, CalendarCarousel} from 'react-aria-components'; import {CalendarGrid, CalendarCell} from 'vanilla-starter/Calendar'; import {Button} from 'vanilla-starter/Button'; import {useDateFormatter} from 'react-aria'; @@ -204,29 +204,38 @@ function Example(props) { ///- begin highlight -/// /* PROPS */ ///- end highlight -/// - style={{display: 'flex', gap: 12, overflow: 'auto'}} > {({state}) => ( - [...Array(props.visibleDuration.months).keys()].map(i => ( -
-
- {i === 0 && - - } - {monthFormatter.format(state.visibleRange.start.add({months: i}).toDate(state.timeZone))} - {i === props.visibleDuration.months - 1 && - - } -
- - {date => } - + <> +
+ {[...Array(props.visibleDuration.months).keys()].map(i => ( +
+ {i === 0 && + + } + {monthFormatter.format(state.visibleRange.start.add({months: i}).toDate(state.timeZone))} + {i === props.visibleDuration.months - 1 && + + } +
+ ))}
- )) + +
+ {[...Array(props.visibleDuration.months).keys()].map(i => ( +
+ + {date => } + +
+ ))} +
+
+ )} ); @@ -304,19 +313,21 @@ import {ChevronLeft, ChevronRight} from 'lucide-react'; role="img" aria-label="Anatomy diagram of a calendar component, which consists of a heading, grid of cells, previous, and next buttons." /> -```tsx links={{Calendar: '#calendar', Button: 'Button.html', CalendarGrid: '#calendargrid', CalendarGridHeader: '#calendargridheader', CalendarHeaderCell: '#calendarheadercell', CalendarGridBody: '#calendargridbody', CalendarCell: '#calendarcell'}} +```tsx links={{Calendar: '#calendar', Button: 'Button.html', CalendarCarousel: '#calendarcarousel', CalendarGrid: '#calendargrid', CalendarGridHeader: '#calendargridheader', CalendarHeaderCell: '#calendarheadercell', CalendarGridBody: '#calendargridbody', CalendarCell: '#calendarcell'}} - } - {monthFormatter.format(state.visibleRange.start.add({months: i}).toDate(state.timeZone))} - {i === props.visibleDuration.months - 1 && - - } - - - {date => } - + <> +
+ {[...Array(props.visibleDuration.months).keys()].map(i => ( +
+ {i === 0 && + + } + {monthFormatter.format(state.visibleRange.start.add({months: i}).toDate(state.timeZone))} + {i === props.visibleDuration.months - 1 && + + } +
+ ))}
- )) + +
+ {[...Array(props.visibleDuration.months).keys()].map(i => ( +
+ + {date => } + +
+ ))} +
+
+ )} ); @@ -322,19 +331,21 @@ import {ChevronLeft, ChevronRight} from 'lucide-react'; role="img" aria-label="Anatomy diagram of a range calendar component, which consists of a heading, grid of cells, previous, and next buttons." /> -```tsx links={{RangeCalendar: '#rangecalendar', Button: 'Button.html', CalendarGrid: '#calendargrid', CalendarGridHeader: '#calendargridheader', CalendarHeaderCell: '#calendarheadercell', CalendarGridBody: '#calendargridbody', CalendarCell: '#calendarcell'}} +```tsx links={{RangeCalendar: '#rangecalendar', Button: 'Button.html', CalendarCarousel: '#calendarcarousel', CalendarGrid: '#calendargrid', CalendarGridHeader: '#calendargridheader', CalendarHeaderCell: '#calendarheadercell', CalendarGridBody: '#calendargridbody', CalendarCell: '#calendarcell'}}
diff --git a/packages/react-aria-components/src/Calendar.tsx b/packages/react-aria-components/src/Calendar.tsx index 9949dbb9145..ee492ccf32d 100644 --- a/packages/react-aria-components/src/Calendar.tsx +++ b/packages/react-aria-components/src/Calendar.tsx @@ -19,6 +19,7 @@ import { useCalendarGrid, useFocusRing, useHover, + useIsSSR, useLocale, useRangeCalendar, VisuallyHidden @@ -26,6 +27,7 @@ import { import {ButtonContext} from './Button'; import {CalendarDate, CalendarIdentifier, createCalendar, DateDuration, endOfMonth, Calendar as ICalendar, isSameDay, isSameMonth, isToday} from '@internationalized/date'; import {CalendarState, RangeCalendarState, useCalendarState, useRangeCalendarState} from 'react-stately'; +import {chain, filterDOMProps, useLayoutEffect} from '@react-aria/utils'; import { ClassNameOrFunction, ContextValue, @@ -39,9 +41,8 @@ import { useSlottedContext } from './utils'; import {DOMAttributes, FocusableElement, forwardRefType, GlobalDOMAttributes, HoverEvents} from '@react-types/shared'; -import {filterDOMProps} from '@react-aria/utils'; import {HeadingContext} from './RSPContexts'; -import React, {createContext, ForwardedRef, forwardRef, ReactElement, useContext, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, ReactElement, ReactNode, useContext, useMemo, useReducer, useRef} from 'react'; import {TextContext} from './Text'; export interface CalendarRenderProps { @@ -263,6 +264,152 @@ export const RangeCalendar = /*#__PURE__*/ (forwardRef as forwardRefType)(functi ); }); +// Display a large number of pages on either side of the center date to +// give the illusion of infinite scrolling. When the user stops scrolling, +// reset the scroll position back to the center. +const PAGES = 100; + +interface State { + centerDate: CalendarDate, + currentPage: number +} + +type Action = + | {type: 'SCROLL', page: number} + | {type: 'SCROLL_END', visibleMonths: number} + | {type: 'SET_FOCUSED_DATE', date: CalendarDate}; + +function reducer(state: State, action: Action) { + let {centerDate, currentPage} = state; + switch (action.type) { + case 'SCROLL': + if (action.page === currentPage) { + return state; + } + return {centerDate, currentPage: action.page}; + case 'SCROLL_END': + if (currentPage === PAGES) { + return state; + } + return { + centerDate: centerDate.add({months: (currentPage - PAGES) * action.visibleMonths}), + currentPage: PAGES + }; + case 'SET_FOCUSED_DATE': + return {centerDate: action.date, currentPage: PAGES}; + } +} + +export interface CalendarCarouselProps extends StyleProps, GlobalDOMAttributes { + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. + * @default 'react-aria-CalendarCarousel' + */ + className?: string, + /** One or more CalendarGrid elements representing a single page. */ + children: ReactNode +} + +/** + * A CalendarCarousel displays one or more CalendarGrids, + * and allows a user to swipe to navigate between pages. + */ +export function CalendarCarousel(props: CalendarCarouselProps) { + let calendarState = useContext(CalendarStateContext); + let rangeCalendarState = useContext(RangeCalendarStateContext); + let state = calendarState ?? rangeCalendarState!; + let [{centerDate, currentPage}, dispatch] = useReducer( + reducer, + null, + () => ({centerDate: state.focusedDate, currentPage: PAGES}) + ); + + // Whenever the center date changes, reset the scroll position. + let ref = useRef(null); + let isSSR = useIsSSR(); + useLayoutEffect(() => { + if (!isSSR) { + ref.current!.scrollLeft = ref.current!.offsetWidth * PAGES; + } + }, [isSSR, centerDate, state.visibleDuration]); + + // If the focused date changes, update the center date. + if (currentPage === PAGES && state.focusedDate.compare(centerDate) !== 0) { + dispatch({ + type: 'SET_FOCUSED_DATE', + date: state.focusedDate + }); + } + + let timeout = useRef>(undefined); + let onScroll = () => { + // Update the calendar's focused date when scrolling between pages, + // and adjust the current page within the visible range. + let el = ref.current!; + let index = Math.round(el.scrollLeft / el.offsetWidth); + if (currentPage !== index) { + // setFocusedDate also forces DOM focus, but we don't want to affect that. + let isFocused = state.isFocused; + state.setFocusedDate(centerDate.add({months: (index - PAGES) * state.visibleDuration.months!}), 'start'); + state.setFocused(isFocused); + dispatch({ + type: 'SCROLL', + page: index + }); + } + + // After scrolling stops, re-center the scroll position. + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + let index = el.scrollLeft / el.offsetWidth; + if (Math.abs(Math.round(index) - index) < 0.01) { + dispatch({type: 'SCROLL_END', visibleMonths: state.visibleDuration.months!}); + } + }, 500); + }; + + return ( +
+ {/* If SSR, only display the current page. After hydration, display an extra page on either side plus placeholders. */} + {isSSR ? props.children : <> + {/* Placeholder to hold space in the scroll width for more pages. */} +
+ {/* contain: 'inline-size' makes these extra pages not affect the width of the parent */} +
+ + {props.children} + +
+ {/* Center (visible) page */} +
+ {props.children} +
+
+ + {props.children} + +
+
+ } +
+ ); +} + export interface CalendarCellRenderProps { /** The date that the cell represents. */ date: CalendarDate, @@ -377,6 +524,7 @@ interface InternalCalendarGridContextValue { weeksInMonth: number } +const CalendarGridContext = createContext>(null); const InternalCalendarGridContext = createContext(null); /** @@ -384,14 +532,28 @@ const InternalCalendarGridContext = createContext) { + // Merge offset from context with props. + let ctx = useSlottedContext(CalendarGridContext); + let offset = useMemo(() => { + let offset = props.offset || ctx?.offset; + if (props.offset && ctx?.offset) { + offset = {...ctx.offset}; + for (let key in offset) { + offset[key] += props.offset[key] ?? 0; + } + } + return offset; + }, [props.offset, ctx?.offset]); + + [props, ref] = useContextProps(props, ref, CalendarGridContext); let calendarState = useContext(CalendarStateContext); let rangeCalendarState = useContext(RangeCalendarStateContext); let calenderProps = useSlottedContext(CalendarContext)!; let rangeCalenderProps = useSlottedContext(RangeCalendarContext)!; let state = calendarState ?? rangeCalendarState!; let startDate = state.visibleRange.start; - if (props.offset) { - startDate = startDate.add(props.offset); + if (offset) { + startDate = startDate.add(offset); } let firstDayOfWeek = calenderProps?.firstDayOfWeek ?? rangeCalenderProps?.firstDayOfWeek; diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 6d4bc432c8b..c06cc96d6f1 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -19,7 +19,7 @@ export {CheckboxContext, ColorAreaContext, ColorFieldContext, ColorSliderContext export {Autocomplete, AutocompleteContext, AutocompleteStateContext} from './Autocomplete'; export {Breadcrumbs, BreadcrumbsContext, Breadcrumb} from './Breadcrumbs'; export {Button, ButtonContext} from './Button'; -export {Calendar, CalendarGrid, CalendarGridHeader, CalendarGridBody, CalendarHeaderCell, CalendarCell, RangeCalendar, CalendarContext, RangeCalendarContext, CalendarStateContext, RangeCalendarStateContext} from './Calendar'; +export {Calendar, CalendarCarousel, CalendarGrid, CalendarGridHeader, CalendarGridBody, CalendarHeaderCell, CalendarCell, RangeCalendar, CalendarContext, RangeCalendarContext, CalendarStateContext, RangeCalendarStateContext} from './Calendar'; export {Checkbox, CheckboxGroup, CheckboxGroupContext, CheckboxGroupStateContext} from './Checkbox'; export {ColorArea, ColorAreaStateContext} from './ColorArea'; export {ColorField, ColorFieldStateContext} from './ColorField'; @@ -90,7 +90,7 @@ export {Layout, LayoutInfo, Size, Rect, Point} from '@react-stately/virtualizer' export type {AutocompleteProps} from './Autocomplete'; export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbRenderProps} from './Breadcrumbs'; export type {ButtonProps, ButtonRenderProps} from './Button'; -export type {CalendarCellProps, CalendarProps, CalendarRenderProps, CalendarGridProps, CalendarGridHeaderProps, CalendarGridBodyProps, CalendarHeaderCellProps, CalendarCellRenderProps, RangeCalendarProps, RangeCalendarRenderProps} from './Calendar'; +export type {CalendarCellProps, CalendarProps, CalendarRenderProps, CalendarCarouselProps, CalendarGridProps, CalendarGridHeaderProps, CalendarGridBodyProps, CalendarHeaderCellProps, CalendarCellRenderProps, RangeCalendarProps, RangeCalendarRenderProps} from './Calendar'; export type {CheckboxGroupProps, CheckboxGroupRenderProps, CheckboxRenderProps, CheckboxProps} from './Checkbox'; export type {ColorAreaProps, ColorAreaRenderProps} from './ColorArea'; export type {ColorFieldProps, ColorFieldRenderProps} from './ColorField'; diff --git a/starters/docs/src/Calendar.css b/starters/docs/src/Calendar.css index 9556f7fdfa0..84faa17086f 100644 --- a/starters/docs/src/Calendar.css +++ b/starters/docs/src/Calendar.css @@ -3,7 +3,6 @@ .react-aria-Calendar { width: fit-content; - max-width: 100%; color: var(--text-color); font: var(--font-size) system-ui; @@ -26,7 +25,6 @@ } } - .react-aria-CalendarHeaderCell { font-size: var(--font-size-sm); } diff --git a/starters/docs/src/Calendar.tsx b/starters/docs/src/Calendar.tsx index 1af061acae7..17dd36d512a 100644 --- a/starters/docs/src/Calendar.tsx +++ b/starters/docs/src/Calendar.tsx @@ -7,7 +7,7 @@ import { DateValue, CalendarCellProps, CalendarGridProps, - composeRenderProps + CalendarCarousel, } from 'react-aria-components'; import {Heading, Text} from './Content'; import {ChevronLeft, ChevronRight} from 'lucide-react'; @@ -32,9 +32,11 @@ export function Calendar( - - {(date) => } - + + + {(date) => } + + {errorMessage && {errorMessage}} ) diff --git a/starters/docs/src/RangeCalendar.css b/starters/docs/src/RangeCalendar.css index 350fd10c7bd..4d5e7a297d4 100644 --- a/starters/docs/src/RangeCalendar.css +++ b/starters/docs/src/RangeCalendar.css @@ -2,7 +2,6 @@ .react-aria-RangeCalendar { width: fit-content; - max-width: 100%; font: var(--font-size) system-ui; color: var(--text-color); @@ -33,6 +32,7 @@ padding: 0 2px; position: relative; z-index: 1; + outline: none; span { display: block; @@ -57,7 +57,7 @@ } &[data-selection-start], - &:is(td:first-child > *, [aria-disabled] + td > *) { + &:is(td:first-child > *, [aria-disabled] + td > *, td:has([data-outside-month]) + td > *) { border-start-start-radius: 9999px; border-end-start-radius: 9999px; border-inline-start-width: 0.5px; @@ -66,7 +66,7 @@ } &[data-selection-end], - &:is(td:last-child > *, td:has(+ [aria-disabled]) > *) { + &:is(td:last-child > *, td:has(+ [aria-disabled], + td > [data-outside-month]) > *) { border-end-end-radius: 9999px; border-start-end-radius: 9999px; border-inline-end-width: 0.5px; @@ -95,7 +95,6 @@ } &[data-focus-visible] { - outline: none; z-index: 2; span { outline: 2px solid var(--focus-ring-color); diff --git a/starters/docs/src/RangeCalendar.tsx b/starters/docs/src/RangeCalendar.tsx index fd9b9594163..eb0c16de58f 100644 --- a/starters/docs/src/RangeCalendar.tsx +++ b/starters/docs/src/RangeCalendar.tsx @@ -7,7 +7,8 @@ import { RangeCalendarProps as AriaRangeCalendarProps, Text, composeRenderProps, - CalendarCellProps + CalendarCellProps, + CalendarCarousel } from 'react-aria-components'; import {Button} from './Button'; import {ChevronLeft, ChevronRight} from 'lucide-react'; @@ -30,9 +31,11 @@ export function RangeCalendar( - - {(date) => } - + + + {(date) => } + + {errorMessage && {errorMessage}} )