diff --git a/packages/@react-spectrum/s2/src/Calendar.tsx b/packages/@react-spectrum/s2/src/Calendar.tsx index dcfd36a3292..68e61293b3a 100644 --- a/packages/@react-spectrum/s2/src/Calendar.tsx +++ b/packages/@react-spectrum/s2/src/Calendar.tsx @@ -11,7 +11,6 @@ */ import {ActionButton} from './ActionButton'; - import { Calendar as AriaCalendar, CalendarCell as AriaCalendarCell, @@ -24,6 +23,9 @@ import { CalendarGridBody, CalendarGridHeader, CalendarHeaderCellProps, + CalendarHeading, + CalendarSelectionMode, + CalendarState, CalendarStateContext, DateValue } from 'react-aria-components/Calendar'; @@ -34,30 +36,28 @@ import { getDayOfWeek, startOfMonth } from '@internationalized/date'; -import {CalendarState} from 'react-stately/useCalendarState'; import ChevronLeftIcon from '../s2wf-icons/S2_Icon_ChevronLeft_20_N.svg'; import ChevronRightIcon from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg'; import {ContextValue, Provider, useSlottedContext} from 'react-aria-components/slots'; import {focusRing, lightDark, style} from '../style' with {type: 'macro'}; import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {Header, HeaderContext, Heading, HeadingContext} from './Content'; +import {Header, HeaderContext, HeadingContext} from './Content'; import {helpTextStyles} from './Field'; // @ts-ignore import intlMessages from '../intl/*.json'; import {pressScale} from './pressScale'; import {RangeCalendarContext, RangeCalendarStateContext} from 'react-aria-components/RangeCalendar'; import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; -import React, {createContext, ForwardedRef, forwardRef, Fragment, PropsWithChildren, ReactElement, ReactNode, useContext, useMemo, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, PropsWithChildren, ReactElement, ReactNode, useContext, useMemo, useRef} from 'react'; import {Text} from 'react-aria-components/Text'; -import {useDateFormatter} from 'react-aria/useDateFormatter'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface CalendarProps - extends Omit, 'visibleDuration' | 'style' | 'className' | 'render' | 'styles' | 'children' | keyof GlobalDOMAttributes>, +export interface CalendarProps + extends Omit, 'visibleDuration' | 'weeksInMonth' | 'style' | 'className' | 'render' | 'styles' | 'children' | keyof GlobalDOMAttributes>, StyleProps { /** * The error message to display when the calendar is invalid. @@ -70,7 +70,7 @@ export interface CalendarProps visibleMonths?: number } -export const CalendarContext = createContext>, HTMLDivElement>>(null); +export const CalendarContext = createContext>, HTMLDivElement>>(null); const calendarStyles = style<{isMultiMonth?: boolean}>({ display: 'flex', @@ -109,7 +109,8 @@ const calendarStyles = style<{isMultiMonth?: boolean}>({ const headerStyles = style({ display: 'flex', alignItems: 'center', - justifyContent: 'space-between' + justifyContent: 'space-between', + columnGap: 24 }); const headingStyles = style({ @@ -117,7 +118,8 @@ const headingStyles = style({ alignItems: 'center', justifyContent: 'space-between', margin: 0, - flexGrow: 1 + flexGrow: 1, + width: 'full' }); const titleStyles = style({ @@ -429,7 +431,7 @@ const selectionBorderStyles = style<{isInvalid?: boolean, isFirstDayInWeek?: boo /** * Calendars display a grid of days in one or more months and allow users to select a single date. */ -export const Calendar = /*#__PURE__*/ (forwardRef as forwardRefType)(function Calendar(props: CalendarProps, ref: ForwardedRef) { +export const Calendar = /*#__PURE__*/ (forwardRef as forwardRefType)(function Calendar(props: CalendarProps, ref: ForwardedRef) { [props, ref] = useSpectrumContextProps(props, ref, CalendarContext); let { visibleMonths = 1, @@ -456,7 +458,7 @@ export const Calendar = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca [HeaderContext, null], [HeadingContext, null] ]}> - +
{ +export const CalendarHeader = ({visibleMonths}: {visibleMonths: number}): ReactElement => { return (
- - - + {Array.from({length: visibleMonths}).map((_, i) => ( +
+ {i === 0 && } + + {i === visibleMonths - 1 && } +
+ ))}
); }; @@ -522,61 +528,6 @@ export const CalendarGrid = (props: Omit & Pr ); }; -// Ordinarily the heading is a formatted date range, ie January 2025 - February 2025. -// However, we want to show each month individually. -const CalendarHeading = (): ReactElement => { - let calendarStateContext = useContext(CalendarStateContext); - let rangeCalendarStateContext = useContext(RangeCalendarStateContext); - let {visibleRange, timeZone} = calendarStateContext ?? rangeCalendarStateContext ?? {}; - let currentMonth = visibleRange?.start ?? visibleRange?.end; - let monthFormatter = useDateFormatter({ - month: 'long', - year: 'numeric', - era: currentMonth && currentMonth.calendar.identifier === 'gregory' && currentMonth.era === 'BC' ? 'short' : undefined, - calendar: visibleRange?.start.calendar.identifier, - timeZone - }); - let months = useMemo(() => { - if (!visibleRange) { - return []; - } - let months: string[] = []; - for (let i = visibleRange.start; i.compare(visibleRange.end) <= 0; i = i.add({months: 1})) { - // TODO: account for the first week possibly overlapping, like with a custom 454 calendar. - // there has to be a better way to do this... - if (i.month === visibleRange.start.month) { - i = i.add({weeks: 1}); - } - months.push(monthFormatter.format(i.toDate(timeZone!))); - } - return months; - }, [visibleRange, monthFormatter, timeZone]); - - return ( - - {months.map((month, i) => { - if (i === 0) { - return ( - -
{month}
-
- ); - } else { - return ( - - {/* Spacers to account for Next/Previous buttons and gap, spelled out to show the math */} -
-
-
-
{month}
- - ); - } - })} - - ); -}; - export const CalendarButton = (props: Omit & {children: ReactNode}): ReactElement => { let {direction} = useLocale(); return ( @@ -632,7 +583,7 @@ const CalendarCell = (props: Omit & {firstDayOfWe ); }; -const CalendarCellInner = (props: Omit & {isRangeSelection: boolean, state: CalendarState | RangeCalendarState, weekIndex: number, dayIndex: number, renderProps?: CalendarCellRenderProps, date: DateValue}): ReactElement => { +const CalendarCellInner = (props: Omit & {isRangeSelection: boolean, state: CalendarState | RangeCalendarState, weekIndex: number, dayIndex: number, renderProps?: CalendarCellRenderProps, date: DateValue}): ReactElement => { let {dayIndex, date, renderProps, state, isRangeSelection} = props; let ref = useRef(null); let {isUnavailable, formattedDate, isSelected, isSelectionStart, isSelectionEnd, isInvalid} = renderProps!; diff --git a/packages/@react-spectrum/s2/src/RangeCalendar.tsx b/packages/@react-spectrum/s2/src/RangeCalendar.tsx index f73ec8174cb..aa0250ccfc4 100644 --- a/packages/@react-spectrum/s2/src/RangeCalendar.tsx +++ b/packages/@react-spectrum/s2/src/RangeCalendar.tsx @@ -30,7 +30,7 @@ import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatte import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface RangeCalendarProps - extends Omit, 'visibleDuration' | 'style' | 'className' | 'render' | 'children' | 'styles' | keyof GlobalDOMAttributes>, + extends Omit, 'visibleDuration' | 'weeksInMonth' | 'style' | 'className' | 'render' | 'children' | 'styles' | keyof GlobalDOMAttributes>, StyleProps { /** * The error message to display when the calendar is invalid. @@ -110,7 +110,7 @@ export const RangeCalendar = /*#__PURE__*/ (forwardRef as forwardRefType)(functi [HeaderContext, null], [HeadingContext, null] ]}> - +
extends TableLayout { return layoutNode; } - // y is the height of the headers - protected buildBody(y: number): LayoutNode { - let layoutNode = super.buildBody(y); + protected buildRowGroup(y: number, node: GridNode): LayoutNode { + let layoutNode = super.buildRowGroup(y, node); + if (node.type !== 'tablebody') { + return layoutNode; + } let {layoutInfo} = layoutNode; // Needs overflow for sticky loader layoutInfo.allowOverflow = true; diff --git a/packages/@react-spectrum/s2/stories/Calendar.stories.tsx b/packages/@react-spectrum/s2/stories/Calendar.stories.tsx index 8cf0165932c..173fa3b5510 100644 --- a/packages/@react-spectrum/s2/stories/Calendar.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Calendar.stories.tsx @@ -14,6 +14,7 @@ import {ActionButton} from '../src/ActionButton'; import {Calendar, CalendarProps, DateValue} from '../exports/Calendar'; import {CalendarDate, getLocalTimeZone, today} from '@internationalized/date'; +import {CalendarSelectionMode} from 'react-stately/useCalendarState'; import {CalendarSwitcher, categorizeArgTypes, getActionArgs} from './utils'; import {Custom454Calendar} from '/packages/@internationalized/date/tests/customCalendarImpl'; import type {Meta, StoryObj} from '@storybook/react'; @@ -75,7 +76,7 @@ export const MinValue: Story = { } }; -function ControlledFocus(props: CalendarProps): ReactElement { +function ControlledFocus(props: CalendarProps): ReactElement { const defaultFocusedDate = props.focusedValue ?? new CalendarDate(2019, 6, 5); let [focusedDate, setFocusedDate] = useState(defaultFocusedDate); return ( @@ -92,7 +93,7 @@ function ControlledFocus(props: CalendarProps): ReactElement { ); } -function CustomCalendar(props: CalendarProps): ReactElement { +function CustomCalendar(props: CalendarProps): ReactElement { return ( new Custom454Calendar()} focusedValue={new CalendarDate(2023, 2, 5)} /> ); diff --git a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx index 15694bb96ca..7a3ec7657ce 100644 --- a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx @@ -21,14 +21,14 @@ export const description = 'Displays one or more date grids and allows users to component={VanillaCalendar} docs={vanillaDocs.exports.Calendar} links={vanillaDocs.links} - props={['isDisabled']} + props={['selectionMode', 'isDisabled']} type="vanilla" files={["starters/docs/src/Calendar.tsx", "starters/docs/src/Calendar.css"]} /> @@ -204,24 +204,13 @@ function Example(props) { ## Display options -Set the `visibleDuration` prop and render multiple `CalendarGrid` elements to display more than one month at a time. The `pageBehavior` prop controls whether pagination advances by a single month or multiple. The `firstDayOfWeek` prop overrides the locale-specified first day of the week. +Set the `visibleDuration` prop and render multiple `CalendarGrid` elements to display more than one month at a time. The `pageBehavior` prop controls whether pagination advances by a single month or multiple. The `firstDayOfWeek` and `weeksInMonth` props override the locale-specified defaults. -```tsx render docs={docs.exports.Calendar} links={docs.links} props={['visibleDuration', 'pageBehavior', 'firstDayOfWeek']} initialProps={{visibleDuration: {months: 2}}} wide +```tsx render docs={vanillaDocs.exports.Calendar} links={vanillaDocs.links} props={['visibleDuration', 'pageBehavior', 'firstDayOfWeek', 'weeksInMonth']} initialProps={{visibleDuration: {months: 2}}} wide "use client"; -import {Calendar, Heading} from 'react-aria-components/Calendar'; -import {CalendarGrid, CalendarCell} from 'vanilla-starter/Calendar'; -import {Button} from 'vanilla-starter/Button'; -import {useDateFormatter} from 'react-aria/useDateFormatter'; -import {ChevronLeft, ChevronRight} from 'lucide-react'; +import {Calendar} from 'vanilla-starter/Calendar'; -// TODO: move this into the starter example. function Example(props) { - let monthFormatter = useDateFormatter({ - month: 'long', - year: 'numeric', - timeZone: 'UTC' - }); - return ( - {({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 => } - -
- )) - )} -
+ /> ); } ``` @@ -293,33 +258,48 @@ function Example() { ### Month and year pickers -You can also control the focused date via `CalendarStateContext`. This example shows month and year dropdown components that work inside any ``. +Use the `` and `` components to allow the user to jump to a different month or year. This example uses the render prop function to render a [Select](Select). -```tsx render files={['packages/dev/s2-docs/pages/react-aria/MonthDropdown.tsx', 'packages/dev/s2-docs/pages/react-aria/YearDropdown.tsx']} +```tsx render "use client"; -import {Calendar} from 'react-aria-components/Calendar'; +import {Calendar, CalendarMonthPicker, CalendarYearPicker} from 'react-aria-components/Calendar'; import {CalendarGrid, CalendarCell} from 'vanilla-starter/Calendar'; -import {MonthDropdown} from './MonthDropdown'; -import {YearDropdown} from './YearDropdown'; import {Button} from 'vanilla-starter/Button'; +import {Select, SelectItem} from 'vanilla-starter/Select'; import {ChevronLeft, ChevronRight} from 'lucide-react'; -
- - {/*- begin highlight -*/} - - - {/*- end highlight -*/} - -
- - {(date) => } - +
+
+
+ + {/*- begin highlight -*/} + + {(props) => ( + + )} + + + {(props) => ( + + )} + + {/*- end highlight -*/} + +
+ + {(date) => } + +
+
``` @@ -329,10 +309,12 @@ 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', CalendarGrid: '#calendargrid', CalendarGridHeader: '#calendargridheader', CalendarHeaderCell: '#calendarheadercell', CalendarGridBody: '#calendargridbody', CalendarCell: '#calendarcell'}} +```tsx links={{Calendar: '#calendar', Button: 'Button', CalendarHeading: '#calendarheading', CalendarMonthPicker: '#calendarmonthpicker', CalendarYearPicker: '#calendaryearpicker', 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 => } - -
- )) - )} - + /> ); } ``` @@ -309,33 +277,48 @@ function Example() { ### Month and year pickers -You can also control the focused date via `CalendarStateContext`. This example shows month and year dropdown components that work inside any ``. +Use the `` and `` components to allow the user to jump to a different month or year. This example uses the render prop function to render a [Select](Select). -```tsx render files={['packages/dev/s2-docs/pages/react-aria/MonthDropdown.tsx', 'packages/dev/s2-docs/pages/react-aria/YearDropdown.tsx']} +```tsx render "use client"; -import {RangeCalendar} from 'react-aria-components/RangeCalendar'; +import {RangeCalendar, CalendarMonthPicker, CalendarYearPicker} from 'react-aria-components/RangeCalendar'; import {CalendarGrid, CalendarCell} from 'vanilla-starter/RangeCalendar'; -import {MonthDropdown} from './MonthDropdown'; -import {YearDropdown} from './YearDropdown'; import {Button} from 'vanilla-starter/Button'; +import {Select, SelectItem} from 'vanilla-starter/Select'; import {ChevronLeft, ChevronRight} from 'lucide-react'; -
- - {/*- begin highlight -*/} - - - {/*- end highlight -*/} - -
- - {(date) => } - +
+
+
+ + {/*- begin highlight -*/} + + {(props) => ( + + )} + + + {(props) => ( + + )} + + {/*- end highlight -*/} + +
+ + {(date) => } + +
+
``` @@ -345,10 +328,12 @@ 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', CalendarGrid: '#calendargrid', CalendarGridHeader: '#calendargridheader', CalendarHeaderCell: '#calendarheadercell', CalendarGridBody: '#calendargridbody', CalendarCell: '#calendarcell'}} +```tsx links={{RangeCalendar: '#rangecalendar', Button: 'Button', CalendarHeading: '#calendarheading', CalendarMonthPicker: '#calendarmonthpicker', CalendarYearPicker: '#calendaryearpicker', CalendarGrid: '#calendargrid', CalendarGridHeader: '#calendargridheader', CalendarHeaderCell: '#calendarheadercell', CalendarGridBody: '#calendargridbody', CalendarCell: '#calendarcell'}} + + + @@ -68,7 +71,13 @@ export const CalendarExample: CalendarStory = { {date => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />} - ) + ), + argTypes: { + selectionMode: { + control: 'radio', + options: ['single', 'multiple'] + } + } }; export const CalendarResetValue: CalendarStory = { @@ -99,21 +108,31 @@ function CalendarMultiMonthExample(args) { Reset focused date -
- - - -
-
- - {date => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />} - - - {date => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />} - - - {date => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />} - +
+
+
+ + +
+ + {date => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />} + +
+
+ + + {date => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />} + +
+
+
+ + +
+ + {date => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />} + +
@@ -133,6 +152,37 @@ export const CalendarMultiMonth: CalendarStory = { } }; +function MonthYearPickersExample(args) { + return ( + +
+ + + {({items, value, onChange, 'aria-label': ariaLabel}) => ( + + )} + + + {({items, value, onChange, 'aria-label': ariaLabel}) => ( + + )} + + +
+ + {date => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />} + +
+ ); +} + +export const MonthYearPickers: CalendarStory = { + render: (args) => +}; interface CalendarFirstDayOfWeekExampleProps extends Omit, 'render'> { locale: string diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 403f374986f..0e5076fcc36 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -12,9 +12,9 @@ import {action} from 'storybook/actions'; import {Button} from '../src/Button'; - import { Cell, + CellProps, Column, ColumnProps, ColumnResizer, @@ -24,10 +24,8 @@ import { TableBody, TableFooter, TableHeader, - TableLayout + TableLoadMoreItem } from '../src/Table'; - -import {CellProps, TableLoadMoreItem} from '../src/Table'; import {Checkbox, CheckboxProps} from '../src/Checkbox'; import {Collection} from 'react-aria/Collection'; import {Dialog, DialogTrigger} from '../src/Dialog'; @@ -41,6 +39,7 @@ import {Popover} from '../src/Popover'; import React, {JSX, startTransition, Suspense, useState} from 'react'; import {Selection} from '@react-types/shared'; import styles from '../example/index.css'; +import {TableLayout} from '../src/TableLayout'; import {useAsyncList} from 'react-stately/useAsyncList'; import {useListData} from 'react-stately/useListData'; import {Virtualizer} from '../src/Virtualizer'; diff --git a/packages/react-aria-components/test/Calendar.test.js b/packages/react-aria-components/test/Calendar.test.js index 470965baf2c..32b61455c68 100644 --- a/packages/react-aria-components/test/Calendar.test.js +++ b/packages/react-aria-components/test/Calendar.test.js @@ -21,19 +21,21 @@ import { CalendarGridBody, CalendarGridHeader, CalendarHeaderCell, - CalendarStateContext + CalendarHeading, + CalendarMonthPicker, + CalendarStateContext, + CalendarYearPicker } from '../src/Calendar'; import {CalendarDate, getLocalTimeZone, startOfMonth, startOfWeek, today} from '@internationalized/date'; -import {Heading} from '../src/Heading'; -import React, {useContext} from 'react'; +import React, {useContext, useState} from 'react'; import userEvent from '@testing-library/user-event'; let TestCalendar = ({calendarProps, gridProps, cellProps}) => (
- +
@@ -133,7 +135,7 @@ describe('Calendar', () => {
- +
@@ -176,17 +178,18 @@ describe('Calendar', () => { }); it('should support multi-month calendars', () => { - let {getAllByRole} = render( - + let {getAllByRole, container} = render( +
-
+ {date => } + {date => } @@ -200,6 +203,11 @@ describe('Calendar', () => { let formatter = new Intl.DateTimeFormat('en-US', {month: 'long', year: 'numeric'}); expect(grids[0]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(new Date())); expect(grids[1]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone()))); + + let headings = container.querySelectorAll('.react-aria-CalendarHeading'); + expect(headings).toHaveLength(2); + expect(headings[0]).toHaveTextContent('April 2026'); + expect(headings[1]).toHaveTextContent('May 2026'); }); @@ -212,7 +220,7 @@ describe('Calendar', () => {
- +
@@ -344,7 +352,7 @@ describe('Calendar', () => { <>
- +
@@ -387,7 +395,7 @@ describe('Calendar', () => {
- +
@@ -442,7 +450,7 @@ describe('Calendar', () => {
- + @@ -470,4 +478,112 @@ describe('Calendar', () => { await user.click(setFocusedDateButton); expect(setFocusedDateButton).toHaveFocus(); }); + + it('should support multiple selection', async () => { + let onChange = jest.fn(); + function Example() { + let [value, setValue] = useState([]); + return (onChange(v), setValue(v))}} />; + } + + let {getByRole} = render(); + let grid = getByRole('grid'); + expect(grid).toHaveAttribute('aria-multiselectable'); + + let cells = within(grid).getAllByRole('button'); + + expect(cells[10]).not.toHaveAttribute('data-selected'); + await user.click(cells[10]); + expect(cells[10]).toHaveAttribute('data-selected'); + expect(onChange).toHaveBeenLastCalledWith([new CalendarDate(2026, 4, 8)]); + + expect(cells[14]).not.toHaveAttribute('data-selected'); + await user.click(cells[14]); + expect(cells[14]).toHaveAttribute('data-selected'); + expect(onChange).toHaveBeenLastCalledWith([new CalendarDate(2026, 4, 8), new CalendarDate(2026, 4, 12)]); + + await user.click(cells[14]); + expect(cells[14]).not.toHaveAttribute('data-selected'); + expect(onChange).toHaveBeenLastCalledWith([new CalendarDate(2026, 4, 8)]); + }); + + it('should support month and year dropdowns', async () => { + let tree = render( + +
+ + + {({items, value, onChange, 'aria-label': ariaLabel}) => ( + + )} + + + {({items, value, onChange, 'aria-label': ariaLabel}) => ( + + )} + + +
+ + {(date) => } + +
+ ); + + let monthPicker = tree.getByLabelText('month'); + let yearPicker = tree.getByLabelText('year'); + let grid = tree.getByRole('grid'); + expect(grid).toHaveAttribute('aria-label', 'Appointment date, April 2026'); + + expect(monthPicker).toHaveValue('4'); + expect(within(monthPicker).getAllByRole('option').map(o => o.textContent)).toEqual(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']); + + await user.selectOptions(monthPicker, 'Jun'); + expect(monthPicker).toHaveValue('6'); + expect(grid).toHaveAttribute('aria-label', 'Appointment date, June 2026'); + + expect(yearPicker).toHaveValue('10'); + expect(within(yearPicker).getAllByRole('option').map(o => o.textContent)).toEqual(Array.from({length: 20}, (_, i) => String(i + 2016))); + + await user.selectOptions(yearPicker, '2030'); + expect(yearPicker).toHaveValue('10'); + expect(within(yearPicker).getAllByRole('option').map(o => o.textContent)).toEqual(Array.from({length: 20}, (_, i) => String(i + 2020))); + expect(grid).toHaveAttribute('aria-label', 'Appointment date, June 2030'); + }); + + it('should support weeksInMonth prop', async () => { + let tree = render(); + let rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(6); + }); + + it('should support week view', async () => { + let tree = render(); + let rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(1); + let heading = tree.container.querySelector('.react-aria-CalendarHeading'); + expect(heading).toHaveTextContent('March 29 – April 4, 2026'); + }); + + it('should support day view', async () => { + let tree = render(); + let rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(1); + expect(tree.getAllByRole('gridcell')).toHaveLength(2); + let heading = tree.container.querySelector('.react-aria-CalendarHeading'); + expect(heading).toHaveTextContent('April 1 – 2, 2026'); + }); + + it('should handle changing the visible duration', async () => { + let tree = render(); + let heading = tree.container.querySelector('.react-aria-CalendarHeading'); + expect(heading).toHaveTextContent('April 5 – 11, 2026'); + + tree.rerender(); + expect(heading).toHaveTextContent('April 2026'); + }); }); diff --git a/packages/react-aria-components/test/RangeCalendar.test.tsx b/packages/react-aria-components/test/RangeCalendar.test.tsx index 7f8f889e854..efda39b6d57 100644 --- a/packages/react-aria-components/test/RangeCalendar.test.tsx +++ b/packages/react-aria-components/test/RangeCalendar.test.tsx @@ -19,13 +19,15 @@ import { CalendarGridBody, CalendarGridHeader, CalendarHeaderCell, + CalendarHeading, + CalendarMonthPicker, + CalendarYearPicker, RangeCalendar, RangeCalendarContext } from '../src/Calendar'; import {CalendarDate, getLocalTimeZone, startOfMonth, startOfWeek, today} from '@internationalized/date'; import {DateValue} from 'react-stately/useRangeCalendarState'; -import {Heading} from '../src/Heading'; import {RangeValue} from '@react-types/shared'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -34,7 +36,7 @@ let TestCalendar = ({calendarProps = {}, gridProps = {}, cellProps = {}}) => (
- +
@@ -102,7 +104,7 @@ describe('RangeCalendar', () => {
- +
@@ -171,7 +173,7 @@ describe('RangeCalendar', () => { <>
- +
@@ -187,17 +189,18 @@ describe('RangeCalendar', () => { }); it('should support multi-month calendars', () => { - let {getAllByRole} = render( - + let {getAllByRole, container} = render( +
-
+ {date => } + {date => } @@ -211,6 +214,11 @@ describe('RangeCalendar', () => { let formatter = new Intl.DateTimeFormat('en-US', {month: 'long', year: 'numeric'}); expect(grids[0]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(new Date())); expect(grids[1]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone()))); + + let headings = container.querySelectorAll('.react-aria-CalendarHeading'); + expect(headings).toHaveLength(2); + expect(headings[0]).toHaveTextContent('April 2026'); + expect(headings[1]).toHaveTextContent('May 2026'); }); it.each([ @@ -222,7 +230,7 @@ describe('RangeCalendar', () => {
- +
@@ -357,7 +365,7 @@ describe('RangeCalendar', () => {
- +
@@ -423,6 +431,48 @@ describe('RangeCalendar', () => { expect(cell).toHaveClass('unavailable'); }); + it('should allow changing the unavailable dates based on the anchor date', async () => { + let tree = render( + ( + anchorDate ? Math.abs(date.compare(anchorDate)) > 7 : false + ) + }} /> + ); + + let grid = tree.getByRole('grid'); + let cells = within(grid).getAllByRole('button'); + + for (let cell of cells) { + if (!cell.hasAttribute('data-outside-month')) { + expect(cell).not.toHaveAttribute('data-disabled'); + } + } + + await user.click(cells[14]); + + for (let i = 0; i < 7; i++) { + expect(cells[i]).toHaveAttribute('data-disabled'); + } + + for (let i = 7; i < 22; i++) { + expect(cells[i]).not.toHaveAttribute('data-disabled'); + } + + for (let i = 22; i < cells.length; i++) { + expect(cells[i]).toHaveAttribute('data-disabled'); + } + + await user.click(cells[19]); + + for (let cell of cells) { + if (!cell.hasAttribute('data-outside-month')) { + expect(cell).not.toHaveAttribute('data-disabled'); + } + } + }); + it('should support disabled state', () => { let {getByRole} = renderCalendar({isDisabled: true}, {}, {className: ({isDisabled}) => isDisabled ? 'disabled' : ''}); let grid = getByRole('grid'); @@ -446,4 +496,75 @@ describe('RangeCalendar', () => { expect(cell).toHaveAttribute('aria-invalid', 'true'); expect(cell).toHaveClass('invalid'); }); + + it('should support month and year dropdowns', async () => { + let tree = render( + +
+ + + {({items, value, onChange, 'aria-label': ariaLabel}) => ( + + )} + + + {({items, value, onChange, 'aria-label': ariaLabel}) => ( + + )} + + +
+ + {(date) => } + +
+ ); + + let monthPicker = tree.getByLabelText('month'); + let yearPicker = tree.getByLabelText('year'); + let grid = tree.getByRole('grid'); + expect(grid).toHaveAttribute('aria-label', 'Appointment date, April 2026'); + + expect(monthPicker).toHaveValue('4'); + expect(within(monthPicker).getAllByRole('option').map(o => o.textContent)).toEqual(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']); + + await user.selectOptions(monthPicker, 'Jun'); + expect(monthPicker).toHaveValue('6'); + expect(grid).toHaveAttribute('aria-label', 'Appointment date, June 2026'); + + expect(yearPicker).toHaveValue('10'); + expect(within(yearPicker).getAllByRole('option').map(o => o.textContent)).toEqual(Array.from({length: 20}, (_, i) => String(i + 2016))); + + await user.selectOptions(yearPicker, '2030'); + expect(yearPicker).toHaveValue('10'); + expect(within(yearPicker).getAllByRole('option').map(o => o.textContent)).toEqual(Array.from({length: 20}, (_, i) => String(i + 2020))); + expect(grid).toHaveAttribute('aria-label', 'Appointment date, June 2030'); + }); + + it('should support weeksInMonth prop', async () => { + let tree = render(); + let rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(6); + }); + + it('should support week view', async () => { + let tree = render(); + let rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(1); + let heading = tree.container.querySelector('.react-aria-CalendarHeading'); + expect(heading).toHaveTextContent('March 29 – April 4, 2026'); + }); + + it('should support day view', async () => { + let tree = render(); + let rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(1); + expect(tree.getAllByRole('gridcell')).toHaveLength(2); + let heading = tree.container.querySelector('.react-aria-CalendarHeading'); + expect(heading).toHaveTextContent('April 1 – 2, 2026'); + }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index d900f920338..7da406a654d 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -23,7 +23,6 @@ import { TableBody, TableFooter, TableHeader, - TableLayout, TableLoadMoreItem, useTableOptions } from '../src/Table'; @@ -40,6 +39,7 @@ import React, {useMemo, useState} from 'react'; import {resizingTests} from 'react-aria/test/table/tableResizingTests.tsx'; import {setInteractionModality} from 'react-aria/private/interactions/useFocusVisible'; import * as stories from '../stories/Table.stories'; +import {TableLayout} from '../src/TableLayout'; import {Tag, TagGroup, TagList} from '../src/TagGroup'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 11bd1a354a3..df912dc0725 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -20,6 +20,9 @@ export {useToggleButtonGroup, useToggleButtonGroupItem} from '../src/button/useT export {useCalendar} from '../src/calendar/useCalendar'; export {useCalendarCell} from '../src/calendar/useCalendarCell'; export {useCalendarGrid} from '../src/calendar/useCalendarGrid'; +export {useCalendarMonthPicker} from '../src/calendar/useCalendarMonthPicker'; +export {useCalendarYearPicker} from '../src/calendar/useCalendarYearPicker'; +export {useCalendarHeading} from '../src/calendar/useCalendarHeading'; export {useRangeCalendar} from '../src/calendar/useRangeCalendar'; export {useCheckbox} from '../src/checkbox/useCheckbox'; export {useCheckboxGroup} from '../src/checkbox/useCheckboxGroup'; @@ -133,6 +136,7 @@ export {useTree} from '../src/tree/useTree'; export {useTreeItem} from '../src/tree/useTreeItem'; export {chain} from '../src/utils/chain'; export {mergeProps} from '../src/utils/mergeProps'; +export {mergeRefs} from '../src/utils/mergeRefs'; export {useId} from '../src/utils/useId'; export {useObjectRef} from '../src/utils/useObjectRef'; export {RouterProvider} from '../src/utils/openLink'; @@ -151,6 +155,9 @@ export type {AriaCalendarProps} from '../src/calendar/useCalendar'; export type {AriaRangeCalendarProps} from '../src/calendar/useRangeCalendar'; export type {CalendarAria} from '../src/calendar/useCalendarBase'; export type {CalendarProps} from 'react-stately/useCalendarState'; +export type {CalendarMonthPickerAria, CalendarMonthPickerItem, CalendarMonthPickerProps} from '../src/calendar/useCalendarMonthPicker'; +export type {CalendarYearPickerAria, CalendarYearPickerItem, CalendarYearPickerProps} from '../src/calendar/useCalendarYearPicker'; +export type {CalendarHeadingProps} from '../src/calendar/useCalendarHeading'; export type {RangeCalendarProps} from 'react-stately/useRangeCalendarState'; export type {AriaCheckboxGroupItemProps} from '../src/checkbox/useCheckboxGroupItem'; export type {AriaCheckboxGroupProps, CheckboxGroupAria} from '../src/checkbox/useCheckboxGroup'; diff --git a/packages/react-aria/exports/useCalendar.ts b/packages/react-aria/exports/useCalendar.ts index 208f0e51cb5..7b6fb91a120 100644 --- a/packages/react-aria/exports/useCalendar.ts +++ b/packages/react-aria/exports/useCalendar.ts @@ -13,9 +13,15 @@ export {useCalendar} from '../src/calendar/useCalendar'; export {useCalendarGrid} from '../src/calendar/useCalendarGrid'; export {useCalendarCell} from '../src/calendar/useCalendarCell'; +export {useCalendarMonthPicker} from '../src/calendar/useCalendarMonthPicker'; +export {useCalendarYearPicker} from '../src/calendar/useCalendarYearPicker'; +export {useCalendarHeading} from '../src/calendar/useCalendarHeading'; export type {AriaCalendarProps} from '../src/calendar/useCalendar'; export type {AriaCalendarGridProps, CalendarGridAria} from '../src/calendar/useCalendarGrid'; export type {AriaCalendarCellProps, CalendarCellAria} from '../src/calendar/useCalendarCell'; export type {CalendarAria} from '../src/calendar/useCalendarBase'; +export type {CalendarMonthPickerAria, CalendarMonthPickerItem, CalendarMonthPickerProps} from '../src/calendar/useCalendarMonthPicker'; +export type {CalendarYearPickerAria, CalendarYearPickerItem, CalendarYearPickerProps} from '../src/calendar/useCalendarYearPicker'; +export type {CalendarHeadingProps} from '../src/calendar/useCalendarHeading'; export type {CalendarProps, DateValue} from 'react-stately/useCalendarState'; diff --git a/packages/react-aria/exports/useRangeCalendar.ts b/packages/react-aria/exports/useRangeCalendar.ts index 94789b636c9..926c9cc1203 100644 --- a/packages/react-aria/exports/useRangeCalendar.ts +++ b/packages/react-aria/exports/useRangeCalendar.ts @@ -11,6 +11,18 @@ */ export {useRangeCalendar} from '../src/calendar/useRangeCalendar'; +export {useCalendarGrid} from '../src/calendar/useCalendarGrid'; +export {useCalendarCell} from '../src/calendar/useCalendarCell'; +export {useCalendarMonthPicker} from '../src/calendar/useCalendarMonthPicker'; +export {useCalendarYearPicker} from '../src/calendar/useCalendarYearPicker'; +export {useCalendarHeading} from '../src/calendar/useCalendarHeading'; + export type {AriaRangeCalendarProps} from '../src/calendar/useRangeCalendar'; +export type {AriaCalendarGridProps, CalendarGridAria} from '../src/calendar/useCalendarGrid'; +export type {AriaCalendarCellProps, CalendarCellAria} from '../src/calendar/useCalendarCell'; +export type {CalendarAria} from '../src/calendar/useCalendarBase'; +export type {CalendarMonthPickerAria, CalendarMonthPickerItem, CalendarMonthPickerProps} from '../src/calendar/useCalendarMonthPicker'; +export type {CalendarYearPickerAria, CalendarYearPickerItem, CalendarYearPickerProps} from '../src/calendar/useCalendarYearPicker'; +export type {CalendarHeadingProps} from '../src/calendar/useCalendarHeading'; export type {DateValue} from 'react-stately/useCalendarState'; export type {RangeCalendarProps} from 'react-stately/useRangeCalendarState'; diff --git a/packages/react-aria/src/calendar/useCalendar.ts b/packages/react-aria/src/calendar/useCalendar.ts index b21b3c28abf..c434fd46636 100644 --- a/packages/react-aria/src/calendar/useCalendar.ts +++ b/packages/react-aria/src/calendar/useCalendar.ts @@ -12,14 +12,14 @@ import {AriaLabelingProps, DOMProps} from '@react-types/shared'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; -import {CalendarProps, CalendarState, DateValue} from 'react-stately/useCalendarState'; +import {CalendarProps, CalendarSelectionMode, CalendarState, DateValue} from 'react-stately/useCalendarState'; -export interface AriaCalendarProps extends CalendarProps, DOMProps, AriaLabelingProps {} +export interface AriaCalendarProps extends CalendarProps, DOMProps, AriaLabelingProps {} /** * Provides the behavior and accessibility implementation for a calendar component. * A calendar displays one or more date grids and allows users to select a single date. */ -export function useCalendar(props: AriaCalendarProps, state: CalendarState): CalendarAria { +export function useCalendar(props: AriaCalendarProps, state: CalendarState): CalendarAria { return useCalendarBase(props, state); } diff --git a/packages/react-aria/src/calendar/useCalendarBase.ts b/packages/react-aria/src/calendar/useCalendarBase.ts index 93e023e0f59..5205f7a503f 100644 --- a/packages/react-aria/src/calendar/useCalendarBase.ts +++ b/packages/react-aria/src/calendar/useCalendarBase.ts @@ -11,18 +11,17 @@ */ import {announce} from '../live-announcer/LiveAnnouncer'; - import {AriaButtonProps} from '../button/useButton'; import {AriaLabelingProps, DOMAttributes, DOMProps} from '@react-types/shared'; -import {CalendarPropsBase, CalendarState} from 'react-stately/useCalendarState'; +import {CalendarPropsBase, CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; import {filterDOMProps} from '../utils/filterDOMProps'; import {hookData, useSelectedDateDescription, useVisibleRangeDescription} from './utils'; +// @ts-ignore import intlMessages from '../../intl/calendar/*.json'; import {mergeProps} from '../utils/mergeProps'; import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; import {useLabels} from '../utils/useLabels'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; -// @ts-ignore import {useSlotId} from '../utils/useId'; import {useState} from 'react'; import {useUpdateEffect} from '../utils/useUpdateEffect'; @@ -40,7 +39,7 @@ export interface CalendarAria { title: string } -export function useCalendarBase(props: CalendarPropsBase & DOMProps & AriaLabelingProps, state: CalendarState | RangeCalendarState): CalendarAria { +export function useCalendarBase(props: CalendarPropsBase & DOMProps & AriaLabelingProps, state: CalendarState | RangeCalendarState): CalendarAria { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/calendar'); let domProps = filterDOMProps(props); diff --git a/packages/react-aria/src/calendar/useCalendarCell.ts b/packages/react-aria/src/calendar/useCalendarCell.ts index 75efe52c296..dfd64b3a6c6 100644 --- a/packages/react-aria/src/calendar/useCalendarCell.ts +++ b/packages/react-aria/src/calendar/useCalendarCell.ts @@ -11,20 +11,20 @@ */ import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date'; -import {CalendarState} from 'react-stately/useCalendarState'; +import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; import {DOMAttributes, RefObject} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getEraFormat, hookData} from './utils'; import {getInteractionModality} from '../interactions/useFocusVisible'; import {getScrollParent} from '../utils/getScrollParent'; +// @ts-ignore import intlMessages from '../../intl/calendar/*.json'; import {mergeProps} from '../utils/mergeProps'; import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; import {scrollIntoViewport} from '../utils/scrollIntoView'; import {useDateFormatter} from '../i18n/useDateFormatter'; import {useDeepMemo} from '../utils/useDeepMemo'; -// @ts-ignore import {useDescription} from '../utils/useDescription'; import {useEffect, useMemo, useRef} from 'react'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; @@ -86,7 +86,7 @@ export interface CalendarCellAria { * Provides the behavior and accessibility implementation for a calendar cell component. * A calendar cell displays a date cell within a calendar grid which can be selected by the user. */ -export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarState | RangeCalendarState, ref: RefObject): CalendarCellAria { +export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarState | RangeCalendarState, ref: RefObject): CalendarCellAria { let {date, isDisabled} = props; let {errorMessageId, selectedDateDescription} = hookData.get(state)!; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/calendar'); @@ -98,18 +98,23 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta era: getEraFormat(date), timeZone: state.timeZone }); - let isSelected = state.isSelected(date); let isFocused = state.isCellFocused(date) && !props.isOutsideMonth; - isDisabled = isDisabled || state.isCellDisabled(date); + isDisabled = isDisabled || state.isCellDisabled(date) || !!props.isOutsideMonth; let isUnavailable = state.isCellUnavailable(date); let isSelectable = !isDisabled && !isUnavailable; - let isInvalid = state.isValueInvalid && Boolean( - 'highlightedRange' in state - ? !state.anchorDate && state.highlightedRange && date.compare(state.highlightedRange.start) >= 0 && date.compare(state.highlightedRange.end) <= 0 - : state.value && isSameDay(state.value, date) - ); + let isSelected = state.isSelected(date) && isSelectable; + let isInvalid = false; + if (state.isValueInvalid) { + if ('highlightedRange' in state) { + isInvalid = !state.anchorDate && state.highlightedRange != null && date.compare(state.highlightedRange.start) >= 0 && date.compare(state.highlightedRange.end) <= 0; + } else if (Array.isArray(state.value)) { + isInvalid = state.value.some(value => isSameDay(value, date)); + } else if (state.value) { + isInvalid = isSameDay(state.value as CalendarDate, date); + } + } - if (isInvalid) { + if (isInvalid && !isDisabled) { isSelected = true; } @@ -274,14 +279,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta // For mouse, this is unnecessary because users will see the indication on hover. For screen readers, // there will be an announcement to "click to finish selecting range" (above). state.selectDate(date); - let nextDay = date.add({days: 1}); - if (state.isInvalid(nextDay)) { - nextDay = date.subtract({days: 1}); - } - if (!state.isInvalid(nextDay)) { - state.setFocusedDate(nextDay); - state.setFocused(true); - } + state.focusNearestAvailableDate(date); } else if (e.pointerType === 'virtual') { // For screen readers, just select the date on click. state.selectDate(date); diff --git a/packages/react-aria/src/calendar/useCalendarGrid.ts b/packages/react-aria/src/calendar/useCalendarGrid.ts index 371e638b12f..fd0c9e8e829 100644 --- a/packages/react-aria/src/calendar/useCalendarGrid.ts +++ b/packages/react-aria/src/calendar/useCalendarGrid.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {CalendarDate, getWeeksInMonth, startOfWeek, today} from '@internationalized/date'; -import {CalendarState} from 'react-stately/useCalendarState'; +import {CalendarDate, startOfWeek, today} from '@internationalized/date'; +import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; import {DOMAttributes} from '@react-types/shared'; import {hookData, useVisibleRangeDescription} from './utils'; import {KeyboardEvent, useMemo} from 'react'; @@ -62,7 +62,7 @@ export interface CalendarGridAria { * A calendar grid displays a single grid of days within a calendar or range calendar which * can be keyboard navigated and selected by the user. */ -export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarState | RangeCalendarState): CalendarGridAria { +export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarState | RangeCalendarState): CalendarGridAria { let { startDate = state.visibleRange.start, endDate = state.visibleRange.end, @@ -147,21 +147,23 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta let dayFormatter = useDateFormatter({weekday: props.weekdayStyle || 'narrow', timeZone: state.timeZone}); let {locale} = useLocale(); let weekDays = useMemo(() => { - let weekStart = startOfWeek(today(state.timeZone), locale, firstDayOfWeek); - return [...new Array(7).keys()].map((index) => { + let isDayView = state.visibleDuration.days && state.visibleDuration.days < 7; + let weekStart = isDayView ? startDate : startOfWeek(today(state.timeZone), locale, firstDayOfWeek); + let days = isDayView ? state.visibleDuration.days! : 7; + return [...new Array(days).keys()].map((index) => { let date = weekStart.add({days: index}); let dateDay = date.toDate(state.timeZone); return dayFormatter.format(dateDay); }); - }, [locale, state.timeZone, dayFormatter, firstDayOfWeek]); - let weeksInMonth = getWeeksInMonth(startDate, locale, firstDayOfWeek); + }, [locale, state.timeZone, dayFormatter, firstDayOfWeek, startDate, state.visibleDuration.days]); + let weeksInMonth = state.getWeeksInMonth(startDate); return { gridProps: mergeProps(labelProps, { role: 'grid', 'aria-readonly': state.isReadOnly || undefined, 'aria-disabled': state.isDisabled || undefined, - 'aria-multiselectable': ('highlightedRange' in state) || undefined, + 'aria-multiselectable': ('highlightedRange' in state) || state.selectionMode === 'multiple' || undefined, onKeyDown, onFocus: () => state.setFocused(true), onBlur: () => state.setFocused(false) diff --git a/packages/react-aria/src/calendar/useCalendarHeading.ts b/packages/react-aria/src/calendar/useCalendarHeading.ts new file mode 100644 index 00000000000..3c184448651 --- /dev/null +++ b/packages/react-aria/src/calendar/useCalendarHeading.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; +import {DateDuration} from '@internationalized/date'; +import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; +import {useDateFormatter} from '../i18n/useDateFormatter'; +import {useMemo} from 'react'; + +export interface CalendarHeadingFormatOptions { + day?: 'numeric' | '2-digit', + month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow', + year?: 'numeric' | '2-digit', + era?: 'long' | 'short' | 'narrow' +} + +export interface CalendarHeadingProps { + /** + * The number of months from the start of the visible range to display. + * @default 0 + */ + offset?: DateDuration, + /** + * The format of the month heading. + */ + format?: CalendarHeadingFormatOptions +} + +export function useCalendarHeading(props: CalendarHeadingProps, state: CalendarState | RangeCalendarState): string { + let startDate = useMemo(() => { + let currentMonth = state.visibleRange.start; + if (props.offset) { + currentMonth = currentMonth.add(props.offset); + } + return currentMonth; + }, [state.visibleRange.start, props.offset]); + + let isDays = state.visibleDuration.days || state.visibleDuration.weeks; + let formatter = useDateFormatter({ + day: props.format?.day || (isDays ? 'numeric' : undefined), + month: props.format?.month || 'long', + year: props.format?.year || 'numeric', + era: props.format?.era || startDate && startDate.calendar.identifier === 'gregory' && startDate.era === 'BC' ? 'short' : undefined, + calendar: state.visibleRange?.start.calendar.identifier, + timeZone: state.timeZone + }); + + return useMemo(() => { + if (isDays) { + return formatter.formatRange(startDate.toDate(state.timeZone), state.visibleRange.end.toDate(state.timeZone)); + } + + return formatter.format(startDate.toDate(state.timeZone)); + }, [formatter, isDays, startDate, state.timeZone, state.visibleRange.end]); +} diff --git a/packages/react-aria/src/calendar/useCalendarMonthPicker.ts b/packages/react-aria/src/calendar/useCalendarMonthPicker.ts new file mode 100644 index 00000000000..3f36b8cfaca --- /dev/null +++ b/packages/react-aria/src/calendar/useCalendarMonthPicker.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate} from '@internationalized/date'; +import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; +import {Key} from '@react-types/shared'; +import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; +import {useDateFormatter} from '../i18n/useDateFormatter'; +import {useLocale} from '../i18n/I18nProvider'; +import {useMemo} from 'react'; + +export interface CalendarMonthPickerProps { + /** + * The format of the month. + */ + format?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow' +} + +export interface CalendarMonthPickerAria { + 'aria-label': string, + value: Key, + onChange: (key: Key | null) => void, + items: CalendarMonthPickerItem[] +} + +export interface CalendarMonthPickerItem { + id: number, + date: CalendarDate, + formatted: string +} + +export function useCalendarMonthPicker(props: CalendarMonthPickerProps, state: CalendarState | RangeCalendarState): CalendarMonthPickerAria { + let formatter = useDateFormatter({ + month: props.format || 'short', + calendar: state.focusedDate.calendar.identifier, + timeZone: state.timeZone + }); + + // Format the name of each month in the year according to the + // current locale and calendar system. Note that in some calendar + // systems, such as the Hebrew, the number of months may differ + // between years. + let months: CalendarMonthPickerItem[] = []; + let numMonths = state.focusedDate.calendar.getMonthsInYear(state.focusedDate); + for (let i = 1; i <= numMonths; i++) { + let date = state.focusedDate.set({month: i}); + months.push({ + id: i, + date, + formatted: formatter.format(date.toDate(state.timeZone)) + }); + } + + let {locale} = useLocale(); + let ariaLabel = useMemo(() => new Intl.DisplayNames(locale, {type: 'dateTimeField'}).of('month')!, [locale]); + + return { + 'aria-label': ariaLabel, + value: state.focusedDate.month, + onChange: key => { + if (key != null) { + state.setFocusedDate(months[Number(key) - 1].date); + } + }, + items: months + }; +} diff --git a/packages/react-aria/src/calendar/useCalendarYearPicker.ts b/packages/react-aria/src/calendar/useCalendarYearPicker.ts new file mode 100644 index 00000000000..b4bb09c7bbf --- /dev/null +++ b/packages/react-aria/src/calendar/useCalendarYearPicker.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CalendarDate, isSameYear, toCalendarDate} from '@internationalized/date'; +import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; +import {Key} from '@react-types/shared'; +import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; +import {useDateFormatter} from '../i18n/useDateFormatter'; +import {useLocale} from '../i18n/I18nProvider'; +import {useMemo} from 'react'; + +export interface CalendarYearPickerFormatOptions { + year?: 'numeric' | '2-digit', + era?: 'long' | 'short' | 'narrow' +} + +export interface CalendarYearPickerProps { + /** + * The number of years to display. + * @default 20 + */ + visibleYears?: number, + /** + * The format to display. + */ + format?: CalendarYearPickerFormatOptions +} + +export interface CalendarYearPickerAria { + 'aria-label': string, + value: Key, + onChange: (key: Key | null) => void, + items: CalendarYearPickerItem[] +} + +export interface CalendarYearPickerItem { + id: number, + date: CalendarDate, + formatted: string +} + +export function useCalendarYearPicker(props: CalendarYearPickerProps, state: CalendarState | RangeCalendarState): CalendarYearPickerAria { + let formatter = useDateFormatter({ + year: props.format?.year || 'numeric', + era: props.format?.era || (state.focusedDate.calendar.identifier === 'gregory' && state.focusedDate.era === 'BC' ? 'short' : undefined), + calendar: state.focusedDate.calendar.identifier, + timeZone: state.timeZone + }); + + // Determine the minimum and maximum date. By default, show an equal number of years on each side of the current year. + // However, this can be constrained by the calendar's minimum and maximum date. + let visibleYears = props.visibleYears || 20; + let minDate = state.focusedDate.subtract({years: Math.floor(visibleYears / 2)}); + let maxDate = state.focusedDate.add({years: Math.ceil(visibleYears / 2)}); + if (state.maxValue && maxDate.compare(state.maxValue) > 0) { + maxDate = toCalendarDate(state.maxValue); + minDate = maxDate.subtract({years: visibleYears}); + } + + if (state.minValue && minDate.compare(state.minValue) < 0) { + minDate = toCalendarDate(state.minValue); + maxDate = minDate.add({years: visibleYears}); + if (state.maxValue && maxDate.compare(state.maxValue) > 0) { + maxDate = toCalendarDate(state.maxValue); + } + } + + let years: CalendarYearPickerItem[] = []; + let date = minDate; + let value = 0; + while (date.compare(maxDate) < 0) { + if (isSameYear(date, state.focusedDate)) { + value = years.length; + } + + years.push({ + // Use the index as the id so we can retrieve the full + // date object from the list in onChange. We cannot only + // store the year number, because in some calendars, such + // as the Japanese, the era may also change. + id: years.length, + date, + formatted: formatter.format(date.toDate(state.timeZone)) + }); + + date = date.add({years: 1}); + } + + let {locale} = useLocale(); + let ariaLabel = useMemo(() => new Intl.DisplayNames(locale, {type: 'dateTimeField'}).of('year')!, [locale]); + + return { + 'aria-label': ariaLabel, + value, + onChange: key => { + if (key != null) { + state.setFocusedDate(years[key].date); + } + }, + items: years + }; +} diff --git a/packages/react-aria/src/calendar/useRangeCalendar.ts b/packages/react-aria/src/calendar/useRangeCalendar.ts index 04be782ab93..d4fa9ee4a49 100644 --- a/packages/react-aria/src/calendar/useRangeCalendar.ts +++ b/packages/react-aria/src/calendar/useRangeCalendar.ts @@ -55,7 +55,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr const commitBehaviorMapping = { clear: () => state.clearSelection(), reset: () => state.setAnchorDate(null), - select: () => state.selectFocusedDate() + select: () => state.commitSelection() }; // Execute method corresponding to `commitBehavior` when pressing or releasing a pointer outside the calendar body, diff --git a/packages/react-aria/src/calendar/utils.ts b/packages/react-aria/src/calendar/utils.ts index 55cccb4d2e0..3fd95ee0933 100644 --- a/packages/react-aria/src/calendar/utils.ts +++ b/packages/react-aria/src/calendar/utils.ts @@ -11,12 +11,13 @@ */ import {CalendarDate, DateFormatter, endOfMonth, isSameDay, startOfMonth} from '@internationalized/date'; -import {CalendarState} from 'react-stately/useCalendarState'; -import intlMessages from '../../intl/calendar/*.json'; +import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; // @ts-ignore +import intlMessages from '../../intl/calendar/*.json'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; import {useDateFormatter} from '../i18n/useDateFormatter'; +import {useLocale} from '../i18n/I18nProvider'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useMemo} from 'react'; @@ -27,20 +28,21 @@ interface HookData { selectedDateDescription: string } -export const hookData: WeakMap = new WeakMap(); +export const hookData: WeakMap | RangeCalendarState, HookData> = new WeakMap | RangeCalendarState, HookData>(); export function getEraFormat(date: CalendarDate | undefined): 'short' | undefined { return date?.calendar.identifier === 'gregory' && date.era === 'BC' ? 'short' : undefined; } -export function useSelectedDateDescription(state: CalendarState | RangeCalendarState): string { +export function useSelectedDateDescription(state: CalendarState<'single' | 'multiple'> | RangeCalendarState): string { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/calendar'); let start: CalendarDate | undefined, end: CalendarDate | undefined; if ('highlightedRange' in state) { ({start, end} = state.highlightedRange || {}); } else { - start = end = state.value ?? undefined; + start = Array.isArray(state.value) ? state.value[0] : state.value ?? undefined; + end = Array.isArray(state.value) ? state.value.at(-1) : state.value ?? undefined; } let dateFormatter = useDateFormatter({ @@ -52,6 +54,9 @@ export function useSelectedDateDescription(state: CalendarState | RangeCalendarS timeZone: state.timeZone }); + let {locale} = useLocale(); + let listFormatter = useMemo(() => new Intl.ListFormat(locale), [locale]); + let anchorDate = 'anchorDate' in state ? state.anchorDate : null; return useMemo(() => { // No message if currently selecting a range, or there is nothing highlighted. @@ -61,14 +66,17 @@ export function useSelectedDateDescription(state: CalendarState | RangeCalendarS if (isSameDay(start, end)) { let date = dateFormatter.format(start.toDate(state.timeZone)); return stringFormatter.format('selectedDateDescription', {date}); - } else { + } else if ('highlightedRange' in state) { let dateRange = formatRange(dateFormatter, stringFormatter, start, end, state.timeZone); - return stringFormatter.format('selectedRangeDescription', {dateRange}); + } else if (Array.isArray(state.value)) { + let dates = state.value.map(date => dateFormatter.format(date.toDate(state.timeZone))); + let formatted = listFormatter.format(dates); + return stringFormatter.format('selectedDateDescription', {date: formatted}); } } return ''; - }, [start, end, anchorDate, state.timeZone, stringFormatter, dateFormatter]); + }, [start, end, anchorDate, state, stringFormatter, dateFormatter, listFormatter]); } export function useVisibleRangeDescription(startDate: CalendarDate, endDate: CalendarDate, timeZone: string, isAria: boolean): string { diff --git a/packages/react-stately/docs/calendar/useCalendarState.mdx b/packages/react-stately/docs/calendar/useCalendarState.mdx index bb64025d6c1..b840c6e41fa 100644 --- a/packages/react-stately/docs/calendar/useCalendarState.mdx +++ b/packages/react-stately/docs/calendar/useCalendarState.mdx @@ -33,7 +33,7 @@ keywords: [date, calendar, state] ## Interface - + ## Example diff --git a/packages/react-stately/exports/useCalendarState.ts b/packages/react-stately/exports/useCalendarState.ts index 0dff2ed84e6..dbfaadd7768 100644 --- a/packages/react-stately/exports/useCalendarState.ts +++ b/packages/react-stately/exports/useCalendarState.ts @@ -13,4 +13,4 @@ export {useCalendarState} from '../src/calendar/useCalendarState'; export type {CalendarProps, CalendarStateOptions} from '../src/calendar/useCalendarState'; -export type {DateValue, CalendarState, CalendarPropsBase, PageBehavior} from '../src/calendar/types'; +export type {DateValue, CalendarState, CalendarPropsBase, PageBehavior, CalendarSelectionMode} from '../src/calendar/types'; diff --git a/packages/react-stately/src/calendar/types.ts b/packages/react-stately/src/calendar/types.ts index 37d2543781d..d5963ff4f6f 100644 --- a/packages/react-stately/src/calendar/types.ts +++ b/packages/react-stately/src/calendar/types.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CalendarDate, CalendarDateTime, ZonedDateTime} from '@internationalized/date'; +import {CalendarDate, CalendarDateTime, DateDuration, ZonedDateTime} from '@internationalized/date'; import {RangeValue, ValidationState} from '@react-types/shared'; import {ReactNode} from 'react'; @@ -27,8 +27,6 @@ export interface CalendarPropsBase { minValue?: DateValue | null, /** The maximum allowed date that a user may select. */ maxValue?: DateValue | null, - /** 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 calendar is disabled. * @default false @@ -72,7 +70,11 @@ export interface CalendarPropsBase { * Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection. * @default 'center' */ - selectionAlignment?: 'start' | 'center' | 'end' + selectionAlignment?: 'start' | 'center' | 'end', + /** + * The number of weeks in a month. This overrides the default set by the locale. + */ + weeksInMonth?: number } interface CalendarStateBase { @@ -80,6 +82,8 @@ interface CalendarStateBase { readonly isDisabled: boolean, /** Whether the calendar is in a read only state. */ readonly isReadOnly: boolean, + /** The visible duration in the calendar. */ + 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. */ @@ -157,14 +161,21 @@ interface CalendarStateBase { * Returns an array of dates in the week index counted from the provided start date, or the first visible date if not given. * The returned array always has 7 elements, but may include null if the date does not exist according to the calendar system. */ - getDatesInWeek(weekIndex: number, startDate?: CalendarDate): Array + getDatesInWeek(weekIndex: number, startDate?: CalendarDate): Array, + /** Returns the number of weeks in a month. */ + getWeeksInMonth(startDate?: CalendarDate): number } -export interface CalendarState extends CalendarStateBase { +export type CalendarSelectionMode = 'single' | 'multiple'; +export type CalendarValueType = M extends 'single' ? T : readonly T[]; + +export interface CalendarState extends CalendarStateBase { + /** Whether single or multiple selection is enabled. */ + readonly selectionMode: M, /** The currently selected date. */ - readonly value: CalendarDate | null, + readonly value: CalendarValueType, /** Sets the currently selected date. */ - setValue(value: CalendarDate | null): void + setValue(value: CalendarValueType): void } export interface RangeCalendarState extends CalendarStateBase { @@ -185,5 +196,9 @@ export interface RangeCalendarState extends Cal /** Sets whether the user is dragging over the calendar. */ setDragging(isDragging: boolean): void, /** Clears the current selection. */ - clearSelection(): void + clearSelection(): void, + /** Commits the current selection. */ + commitSelection(): void, + /** Focuses the next available day before or after the anchor date. */ + focusNearestAvailableDate(anchorDate: CalendarDate): void } diff --git a/packages/react-stately/src/calendar/useCalendarState.ts b/packages/react-stately/src/calendar/useCalendarState.ts index fe8bde14454..825600baaaa 100644 --- a/packages/react-stately/src/calendar/useCalendarState.ts +++ b/packages/react-stately/src/calendar/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, alignEnd, alignStart, constrainStart, constrainValue, isEqualDuration, isInvalid, previousAvailableDate} from './utils'; import { Calendar, CalendarDate, @@ -20,6 +20,7 @@ import { endOfMonth, endOfWeek, getDayOfWeek, + getWeeksInMonth, GregorianCalendar, isEqualCalendar, isSameDay, @@ -29,14 +30,22 @@ import { toCalendarDate, today } from '@internationalized/date'; -import {CalendarPropsBase, CalendarState, DateValue, MappedDateValue} from './types'; +import {CalendarPropsBase, CalendarSelectionMode, CalendarState, CalendarValueType, DateValue, MappedDateValue} from './types'; import {useControlledState} from '../utils/useControlledState'; import {useMemo, useState} from 'react'; import {ValidationState, ValueBase} from '@react-types/shared'; -export interface CalendarProps extends CalendarPropsBase, ValueBase> {} +export interface CalendarProps extends CalendarPropsBase, ValueBase, CalendarValueType, M>> { + /** + * Whether single or multiple selection is enabled. + * @default "single" + */ + selectionMode?: M, + /** Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. */ + isDateUnavailable?: (date: DateValue) => boolean +} -export interface CalendarStateOptions extends CalendarProps { +export interface CalendarStateOptions extends CalendarProps { /** The locale to display and edit the value according to. */ locale: string, /** @@ -61,7 +70,7 @@ export interface CalendarStateOptions extends C * Provides state management for a calendar component. * A calendar displays one or more date grids and allows users to select a single date. */ -export function useCalendarState(props: CalendarStateOptions): CalendarState { +export function useCalendarState(props: CalendarStateOptions): CalendarState { let defaultFormatter = useMemo(() => new DateFormatter(props.locale), [props.locale]); let resolvedOptions = useMemo(() => defaultFormatter.resolvedOptions(), [defaultFormatter]); let { @@ -73,29 +82,54 @@ export function useCalendarState(props: Calenda selectionAlignment, isDateUnavailable, pageBehavior = 'visible', - firstDayOfWeek + selectionMode = 'single' as M, + firstDayOfWeek, + weeksInMonth } = props; let calendar = useMemo(() => createCalendar(resolvedOptions.calendar as CalendarIdentifier), [createCalendar, resolvedOptions.calendar]); - - let [value, setControlledValue] = useControlledState>(props.value!, props.defaultValue ?? null!, props.onChange); - let calendarDateValue = useMemo(() => value ? toCalendar(toCalendarDate(value), calendar) : null, [value, calendar]); - let timeZone = useMemo(() => value && 'timeZone' in value ? value.timeZone : resolvedOptions.timeZone, [value, resolvedOptions.timeZone]); + + let [value, setControlledValue] = useControlledState | MappedDateValue[] | null>(props.value as any, props.defaultValue as any ?? null, props.onChange as any); + let calendarDateValue = useMemo(() => { + if (Array.isArray(value)) { + return value.map(value => toCalendar(toCalendarDate(value), calendar)); + } else { + return value ? toCalendar(toCalendarDate(value), calendar) : null; + } + }, [value, calendar]); + let timeZone = useMemo(() => { + let val = Array.isArray(value) ? value[0] : value; + return val && 'timeZone' in val ? val.timeZone : resolvedOptions.timeZone; + }, [value, resolvedOptions.timeZone]); let focusedCalendarDate = useMemo(() => ( props.focusedValue ? constrainValue(toCalendar(toCalendarDate(props.focusedValue), calendar), minValue, maxValue) : undefined ), [props.focusedValue, calendar, minValue, maxValue]); - let defaultFocusedCalendarDate = useMemo(() => ( - constrainValue( - props.defaultFocusedValue - ? toCalendar(toCalendarDate(props.defaultFocusedValue), calendar) - : calendarDateValue || toCalendar(today(timeZone), calendar), + let defaultFocusedCalendarDate = useMemo(() => { + if (props.defaultFocusedValue) { + return constrainValue( + toCalendar(toCalendarDate(props.defaultFocusedValue), calendar), + minValue, + maxValue + ); + } + + if (calendarDateValue) { + return constrainValue( + Array.isArray(calendarDateValue) ? calendarDateValue[0] : calendarDateValue, + minValue, + maxValue + ); + } + + return constrainValue( + toCalendar(today(timeZone), calendar), minValue, maxValue - ) - ), [props.defaultFocusedValue, calendarDateValue, timeZone, calendar, minValue, maxValue]); + ); + }, [props.defaultFocusedValue, calendarDateValue, timeZone, calendar, minValue, maxValue]); let [focusedDate, setFocusedDate] = useControlledState(focusedCalendarDate, defaultFocusedCalendarDate, props.onFocusChange); - let [startDate, setStartDate] = useState(() => { + let getStartDate = () => { switch (selectionAlignment) { case 'start': return alignStart(focusedDate, visibleDuration, locale, minValue, maxValue); @@ -105,9 +139,17 @@ export function useCalendarState(props: Calenda default: return alignCenter(focusedDate, visibleDuration, locale, minValue, maxValue); } - }); + }; + + let [startDate, setStartDate] = useState(getStartDate); let [isFocused, setFocused] = useState(props.autoFocus || false); + let [lastVisibleDuration, setLastVisibleDuration] = useState(visibleDuration); + if (!isEqualDuration(visibleDuration, lastVisibleDuration)) { + setLastVisibleDuration(visibleDuration); + setStartDate(getStartDate()); + } + let endDate = useMemo(() => { let duration = {...visibleDuration}; if (duration.days) { @@ -142,28 +184,40 @@ export function useCalendarState(props: Calenda setFocusedDate(date); } - function setValue(newValue: CalendarDate | null) { + function normalizeValue(newValue: CalendarDate) { + let constrained = constrainValue(newValue, minValue, maxValue); + let prev = previousAvailableDate(constrained, startDate, isDateUnavailable); + if (!prev) { + return null; + } + + // The display calendar should not have any effect on the emitted value. + // Emit dates in the same calendar as the original value, if any, otherwise gregorian. + let baseValue = Array.isArray(value) ? value[0] : value; + let calendarValue = toCalendar(prev, baseValue?.calendar || new GregorianCalendar()); + + // Preserve time if the input value had one. + if (baseValue && 'hour' in baseValue) { + return baseValue.set(calendarValue) as T; + } + + return calendarValue as T; + } + + function setValue(newValue: CalendarDate | CalendarDate[] | null) { if (!props.isDisabled && !props.isReadOnly) { - let localValue = newValue; - if (localValue === null) { - setControlledValue(null); + if (newValue === null) { + setControlledValue(selectionMode === 'multiple' ? [] : null); return; } - localValue = constrainValue(localValue, minValue, maxValue); - localValue = previousAvailableDate(localValue, startDate, isDateUnavailable); - if (!localValue) { - return; - } - - // The display calendar should not have any effect on the emitted value. - // Emit dates in the same calendar as the original value, if any, otherwise gregorian. - localValue = toCalendar(localValue, value?.calendar || new GregorianCalendar()); - // Preserve time if the input value had one. - if (value && 'hour' in value) { - setControlledValue(value.set(localValue)); + if (Array.isArray(newValue)) { + setControlledValue(newValue.map(normalizeValue).filter(Boolean) as any); } else { - setControlledValue(localValue); + let localValue = normalizeValue(newValue); + if (localValue) { + setControlledValue(localValue as any); + } } } } @@ -173,11 +227,11 @@ export function useCalendarState(props: Calenda return false; } - if (isDateUnavailable && isDateUnavailable(calendarDateValue)) { - return true; + if (Array.isArray(calendarDateValue)) { + return calendarDateValue.some(date => isDateUnavailable?.(date) || isInvalid(date, minValue, maxValue)); } - return isInvalid(calendarDateValue, minValue, maxValue); + return isDateUnavailable?.(calendarDateValue) || isInvalid(calendarDateValue, minValue, maxValue); }, [calendarDateValue, isDateUnavailable, minValue, maxValue]); let isValueInvalid = props.isInvalid || props.validationState === 'invalid' || isUnavailable; let validationState: ValidationState | null = isValueInvalid ? 'invalid' : null; @@ -193,8 +247,10 @@ export function useCalendarState(props: Calenda return { isDisabled: props.isDisabled ?? false, isReadOnly: props.isReadOnly ?? false, - value: calendarDateValue, - setValue, + value: calendarDateValue as any, + setValue: setValue as any, + selectionMode, + visibleDuration, visibleRange: { start: startDate, end: endDate @@ -298,11 +354,35 @@ export function useCalendarState(props: Calenda }, selectFocusedDate() { if (!(isDateUnavailable && isDateUnavailable(focusedDate))) { - setValue(focusedDate); + this.selectDate(focusedDate); } }, selectDate(date) { - setValue(date); + if (props.isDisabled || props.isReadOnly) { + return; + } + + if (selectionMode === 'multiple' && date != null) { + let newDate = normalizeValue(date); + if (!newDate) { + return; + } + + let baseValue: T[] = []; + if (Array.isArray(value)) { + baseValue = value; + } else if (value != null) { + baseValue = [value]; + } + + let index = baseValue.findIndex(value => isSameDay(value, newDate)); + let newValue = index >= 0 + ? baseValue.slice(0, index).concat(baseValue.slice(index + 1)) + : [...baseValue, newDate]; + setControlledValue(newValue); + } else { + setValue(date); + } }, isFocused, setFocused, @@ -310,7 +390,10 @@ export function useCalendarState(props: Calenda return isInvalid(date, minValue, maxValue); }, isSelected(date) { - return calendarDateValue != null && isSameDay(date, calendarDateValue) && !this.isCellDisabled(date) && !this.isCellUnavailable(date); + if (!calendarDateValue || this.isCellDisabled(date) || this.isCellUnavailable(date)) { + return false; + } + return Array.isArray(calendarDateValue) ? calendarDateValue.some(value => isSameDay(value, date)) : isSameDay(date, calendarDateValue); }, isCellFocused(date) { return isFocused && focusedDate && isSameDay(date, focusedDate); @@ -335,16 +418,19 @@ export function useCalendarState(props: Calenda let date = from.add({weeks: weekIndex}); let dates: (CalendarDate | null)[] = []; - date = startOfWeek(date, locale, firstDayOfWeek); + let days = visibleDuration.days && visibleDuration.days < 7 ? visibleDuration.days : 7; + if (days === 7) { + date = startOfWeek(date, locale, firstDayOfWeek); - // startOfWeek will clamp dates within the calendar system's valid range, which may - // start in the middle of a week. In this case, add null placeholders. - let dayOfWeek = getDayOfWeek(date, locale, firstDayOfWeek); - for (let i = 0; i < dayOfWeek; i++) { - dates.push(null); + // startOfWeek will clamp dates within the calendar system's valid range, which may + // start in the middle of a week. In this case, add null placeholders. + let dayOfWeek = getDayOfWeek(date, locale, firstDayOfWeek); + for (let i = 0; i < dayOfWeek; i++) { + dates.push(null); + } } - while (dates.length < 7) { + while (dates.length < days) { dates.push(date); let nextDate = date.add({days: 1}); if (isSameDay(date, nextDate)) { @@ -355,11 +441,21 @@ export function useCalendarState(props: Calenda } // Add null placeholders if at the end of the calendar system. - while (dates.length < 7) { + while (dates.length < days) { dates.push(null); } return dates; + }, + getWeeksInMonth(date = startDate) { + let weeks = weeksInMonth || getWeeksInMonth(date, locale, firstDayOfWeek); + if (visibleDuration.weeks || visibleDuration.days) { + weeks = visibleDuration.weeks ?? 0; + if (visibleDuration.days) { + weeks += Math.ceil(visibleDuration.days / 7); + } + } + return weeks; } }; } diff --git a/packages/react-stately/src/calendar/useRangeCalendarState.ts b/packages/react-stately/src/calendar/useRangeCalendarState.ts index 1128dec1866..47f4b86f809 100644 --- a/packages/react-stately/src/calendar/useRangeCalendarState.ts +++ b/packages/react-stately/src/calendar/useRangeCalendarState.ts @@ -11,12 +11,12 @@ */ import {alignCenter, constrainValue, isInvalid, previousAvailableDate} from './utils'; -import {Calendar, CalendarDate, CalendarIdentifier, DateDuration, GregorianCalendar, isEqualDay, maxDate, minDate, toCalendar, toCalendarDate} from '@internationalized/date'; -import {CalendarPropsBase, CalendarState, DateValue, MappedDateValue, RangeCalendarState} from './types'; +import {Calendar, CalendarDate, CalendarIdentifier, DateDuration, GregorianCalendar, maxDate, minDate, toCalendar, toCalendarDate} from '@internationalized/date'; +import {CalendarPropsBase, DateValue, MappedDateValue, RangeCalendarState} from './types'; import {RangeValue, ValidationState, ValueBase} from '@react-types/shared'; import {useCalendarState} from './useCalendarState'; +import {useCallback, useMemo, useState} from 'react'; import {useControlledState} from '../utils/useControlledState'; -import {useMemo, useRef, useState} from 'react'; export type DateRange = RangeValue | null; export interface RangeCalendarProps extends CalendarPropsBase, ValueBase | null, RangeValue>> { @@ -24,7 +24,13 @@ export interface RangeCalendarProps extends CalendarPropsBa * When combined with `isDateUnavailable`, determines whether non-contiguous ranges, * i.e. ranges containing unavailable dates, may be selected. */ - allowsNonContiguousRanges?: boolean + allowsNonContiguousRanges?: boolean, + /** + * Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. + * The second argument provides the current selection anchor date, if any. This can be used to adjust the available + * dates based on the user's first selected date. + */ + isDateUnavailable?: (date: DateValue, anchorDate: CalendarDate | null) => boolean } export interface RangeCalendarStateOptions extends RangeCalendarProps { @@ -70,7 +76,7 @@ export function useRangeCalendarState(props: Ra onChange ); - let [anchorDate, setAnchorDateState] = useState(null); + let [anchorDate, setAnchorDate] = useState(null); let alignment: 'center' | 'start' = 'center'; if (value && value.start && value.end) { let start = alignCenter(toCalendarDate(value.start), visibleDuration, locale, minValue, maxValue); @@ -81,9 +87,29 @@ export function useRangeCalendarState(props: Ra } } - // Available range must be stored in a ref so we have access to the updated version immediately in `isInvalid`. - let availableRangeRef = useRef> | null>(null); - let [availableRange, setAvailableRange] = useState>|null>(null); + let isDateUnavailable = useMemo(() => { + let isDateUnavailable = props.isDateUnavailable; + if (!isDateUnavailable) { + return undefined; + } + + return (date: DateValue) => isDateUnavailable(date, anchorDate); + }, [props.isDateUnavailable, anchorDate]); + + let getAvailableRange = useCallback((anchorDate: CalendarDate | null) => { + if (anchorDate && isDateUnavailable && !props.allowsNonContiguousRanges) { + const nextAvailableStartDate = nextUnavailableDate(anchorDate, isDateUnavailable, visibleDuration, -1); + const nextAvailableEndDate = nextUnavailableDate(anchorDate, isDateUnavailable, visibleDuration, 1); + return { + start: nextAvailableStartDate, + end: nextAvailableEndDate + }; + } else { + return null; + } + }, [isDateUnavailable, visibleDuration, props.allowsNonContiguousRanges]); + + let availableRange = useMemo(() => getAvailableRange(anchorDate), [getAvailableRange, anchorDate]); let min = useMemo(() => maxDate(minValue, availableRange?.start), [minValue, availableRange]); let max = useMemo(() => minDate(maxValue, availableRange?.end), [maxValue, availableRange]); @@ -95,41 +121,10 @@ export function useRangeCalendarState(props: Ra visibleDuration, minValue: min, maxValue: max, - selectionAlignment: props.selectionAlignment || alignment + selectionAlignment: props.selectionAlignment || alignment, + isDateUnavailable }); - let updateAvailableRange = (date) => { - if (date && props.isDateUnavailable && !props.allowsNonContiguousRanges) { - const nextAvailableStartDate = nextUnavailableDate(date, calendar, -1); - const nextAvailableEndDate = nextUnavailableDate(date, calendar, 1); - availableRangeRef.current = { - start: nextAvailableStartDate, - end: nextAvailableEndDate - }; - setAvailableRange(availableRangeRef.current); - } else { - availableRangeRef.current = null; - setAvailableRange(null); - } - }; - - // If the visible range changes, we need to update the available range. - let [lastVisibleRange, setLastVisibleRange] = useState(calendar.visibleRange); - if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.end)) { - updateAvailableRange(anchorDate); - setLastVisibleRange(calendar.visibleRange); - } - - let setAnchorDate = (date: CalendarDate | null) => { - if (date) { - setAnchorDateState(date); - updateAvailableRange(date); - } else { - setAnchorDateState(null); - updateAvailableRange(null); - } - }; - let highlightedRange = anchorDate ? makeRange(anchorDate, calendar.focusedDate) : value && makeRange(value.start, value.end); let selectDate = (date: CalendarDate) => { if (props.isReadOnly) { @@ -137,7 +132,7 @@ export function useRangeCalendarState(props: Ra } const constrainedDate = constrainValue(date, min, max); - const previousAvailableConstrainedDate = previousAvailableDate(constrainedDate, calendar.visibleRange.start, props.isDateUnavailable); + const previousAvailableConstrainedDate = previousAvailableDate(constrainedDate, calendar.visibleRange.start, isDateUnavailable); if (!previousAvailableConstrainedDate) { return; } @@ -158,7 +153,6 @@ export function useRangeCalendarState(props: Ra let [isDragging, setDragging] = useState(false); - let {isDateUnavailable} = props; let isInvalidSelection = useMemo(() => { if (!value || anchorDate) { return false; @@ -184,6 +178,11 @@ export function useRangeCalendarState(props: Ra validationState, isValueInvalid, selectFocusedDate() { + if (!calendar.isCellUnavailable(calendar.focusedDate)) { + selectDate(calendar.focusedDate); + } + }, + commitSelection() { selectDate(calendar.focusedDate); }, selectDate, @@ -196,13 +195,25 @@ export function useRangeCalendarState(props: Ra return Boolean(highlightedRange && date.compare(highlightedRange.start) >= 0 && date.compare(highlightedRange.end) <= 0 && !calendar.isCellDisabled(date) && !calendar.isCellUnavailable(date)); }, isInvalid(date) { - return calendar.isInvalid(date) || isInvalid(date, availableRangeRef.current?.start, availableRangeRef.current?.end); + return calendar.isInvalid(date) || isInvalid(date, availableRange?.start, availableRange?.end); }, isDragging, setDragging, clearSelection() { setAnchorDate(null); setValue(null); + }, + focusNearestAvailableDate(anchorDate) { + let availableRange = getAvailableRange(anchorDate); + let isDateInvalid = (date: CalendarDate) => this.isInvalid(date) || isInvalid(date, availableRange?.start, availableRange?.end); + let nextDay = anchorDate.add({days: 1}); + if (isDateInvalid(nextDay)) { + nextDay = anchorDate.subtract({days: 1}); + } + if (!isDateInvalid(nextDay)) { + this.setFocusedDate(nextDay); + this.setFocused(true); + } } }; } @@ -232,17 +243,23 @@ function convertValue(newValue: CalendarDate, oldValue?: DateValue): DateValue { return newValue; } -function nextUnavailableDate(anchorDate: CalendarDate, state: CalendarState, dir: number): CalendarDate | undefined { +function nextUnavailableDate( + anchorDate: CalendarDate, + isDateUnavailable: (date: CalendarDate) => boolean, + visibleDuration: DateDuration, + dir: number +): CalendarDate | undefined { let nextDate = anchorDate.add({days: dir}); + let minDate = anchorDate.subtract(visibleDuration); + let maxDate = anchorDate.add(visibleDuration); while ( - (dir < 0 ? nextDate.compare(state.visibleRange.start) >= 0 : nextDate.compare(state.visibleRange.end) <= 0) && - !state.isCellUnavailable(nextDate) + (dir < 0 ? nextDate.compare(minDate) >= 0 : nextDate.compare(maxDate) <= 0) && + !isDateUnavailable(nextDate) ) { nextDate = nextDate.add({days: dir}); } - if (state.isCellUnavailable(nextDate)) { + if (isDateUnavailable(nextDate)) { return nextDate.add({days: -dir}); } - } diff --git a/packages/react-stately/src/calendar/utils.ts b/packages/react-stately/src/calendar/utils.ts index 36408bd6257..a0664af5753 100644 --- a/packages/react-stately/src/calendar/utils.ts +++ b/packages/react-stately/src/calendar/utils.ts @@ -46,7 +46,7 @@ export function alignStart(date: CalendarDate, duration: DateDuration, locale: s aligned = startOfYear(date); } else if (duration.months) { aligned = startOfMonth(date); - } else if (duration.weeks) { + } else if (duration.weeks || (duration.days && duration.days > 7)) { aligned = startOfWeek(date, locale); } @@ -132,3 +132,16 @@ export function previousAvailableDate(date: CalendarDate, minValue: DateValue, i } return null; } + +export function isEqualDuration(a: DateDuration, b: DateDuration): boolean { + if (a === b) { + return true; + } + + return ( + a.days === b.days && + a.weeks === b.weeks && + a.months === b.months && + a.years === b.years + ); +} diff --git a/packages/react-stately/src/datepicker/types.ts b/packages/react-stately/src/datepicker/types.ts index 01e88b77a7d..2099c4f725c 100644 --- a/packages/react-stately/src/datepicker/types.ts +++ b/packages/react-stately/src/datepicker/types.ts @@ -72,12 +72,14 @@ interface DatePickerBase extends DateFieldBase, OverlayT export interface DatePickerProps extends DatePickerBase, ValueBase | null> {} -export interface DateRangePickerProps extends Omit, 'validate'>, Validation>>, ValueBase | null, RangeValue> | null> { +export interface DateRangePickerProps extends Omit, 'validate' | 'isDateUnavailable'>, Validation>>, ValueBase | null, RangeValue> | null> { /** * When combined with `isDateUnavailable`, determines whether non-contiguous ranges, * i.e. ranges containing unavailable dates, may be selected. */ allowsNonContiguousRanges?: boolean, + /** Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. */ + isDateUnavailable?: (date: DateValue, anchorDate: CalendarDate | null) => boolean, /** * The name of the start date input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). */ diff --git a/packages/react-stately/src/datepicker/useDateRangePickerState.ts b/packages/react-stately/src/datepicker/useDateRangePickerState.ts index 6527b6e9c94..f83ea663fd8 100644 --- a/packages/react-stately/src/datepicker/useDateRangePickerState.ts +++ b/packages/react-stately/src/datepicker/useDateRangePickerState.ts @@ -179,7 +179,7 @@ export function useDateRangePickerState(props: value, minValue, maxValue, - isDateUnavailable, + isDateUnavailable ? (date => isDateUnavailable(date, null)) : undefined, formatOpts ), [value, minValue, maxValue, isDateUnavailable, formatOpts]); diff --git a/starters/docs/src/Calendar.css b/starters/docs/src/Calendar.css index 668d14bab68..8277991c54f 100644 --- a/starters/docs/src/Calendar.css +++ b/starters/docs/src/Calendar.css @@ -2,18 +2,33 @@ @import "./utilities.css"; .react-aria-Calendar { - width: calc(var(--spacing-9) * 7); - max-width: 100%; - container-type: inline-size; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + overflow: auto; + width: 100%; + max-width: fit-content; color: var(--text-color); font: var(--font-size) system-ui; + .months { + display: flex; + gap: var(--spacing-3); + } + + .month { + width: calc(var(--spacing-9) * 7); + min-width: calc(var(--spacing-7) * 7); + container-type: inline-size; + } + header { display: flex; align-items: center; margin: 0 var(--spacing-1) var(--spacing-4) var(--spacing-1); + min-height: var(--spacing-8); - .react-aria-Heading { + .react-aria-CalendarHeading { flex: 1; margin: 0; text-align: center; @@ -58,8 +73,8 @@ } } - &[data-outside-month] { - display: none; + &[data-disabled] { + opacity: 0.5; } &[data-pressed] { diff --git a/starters/docs/src/Calendar.tsx b/starters/docs/src/Calendar.tsx index 72c1052800e..bb2aa719fe4 100644 --- a/starters/docs/src/Calendar.tsx +++ b/starters/docs/src/Calendar.tsx @@ -3,38 +3,51 @@ import { Calendar as AriaCalendar, CalendarCell as AriaCalendarCell, CalendarGrid as AriaCalendarGrid, + CalendarHeading, type CalendarProps as AriaCalendarProps, type DateValue, type CalendarCellProps, type CalendarGridProps, } from 'react-aria-components/Calendar'; -import {Heading, Text} from './Content'; +import {Text} from './Content'; import {ChevronLeft, ChevronRight} from 'lucide-react'; import {Button} from './Button'; import './Calendar.css'; -export interface CalendarProps - extends AriaCalendarProps { +export interface CalendarProps extends AriaCalendarProps { errorMessage?: string; } export function Calendar( { errorMessage, ...props }: CalendarProps ) { + let months = props.visibleDuration?.months || 1; return ( - ( - -
- - - -
- - {(date) => } - - {errorMessage && {errorMessage}} -
- ) + +
+ {Array.from({length: months}, (_, i) => ( +
+
+ {i === 0 && + + } + + {i === months - 1 && + + } +
+ + {date => } + +
+ ))} +
+ {errorMessage && {errorMessage}} +
); } diff --git a/starters/docs/src/RangeCalendar.css b/starters/docs/src/RangeCalendar.css index 5312b7d4be3..2d7deb40a6c 100644 --- a/starters/docs/src/RangeCalendar.css +++ b/starters/docs/src/RangeCalendar.css @@ -1,18 +1,33 @@ @import "./theme.css"; .react-aria-RangeCalendar { - width: calc(var(--spacing-9) * 7); - max-width: 100%; - container-type: inline-size; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + overflow: auto; + width: 100%; + max-width: fit-content; font: var(--font-size) system-ui; color: var(--text-color); + .months { + display: flex; + gap: var(--spacing-3); + } + + .month { + width: calc(var(--spacing-9) * 7); + min-width: calc(var(--spacing-7) * 7); + container-type: inline-size; + } + header { display: flex; align-items: center; margin: 0 var(--spacing-1) var(--spacing-4) var(--spacing-1); + min-height: var(--spacing-8); - .react-aria-Heading { + .react-aria-CalendarHeading { flex: 1; margin: 0; text-align: center; diff --git a/starters/docs/src/RangeCalendar.tsx b/starters/docs/src/RangeCalendar.tsx index 2fa67f83246..8aa7da8ddb5 100644 --- a/starters/docs/src/RangeCalendar.tsx +++ b/starters/docs/src/RangeCalendar.tsx @@ -2,7 +2,7 @@ import { CalendarCell as AriaCalendarCell, RangeCalendar as AriaRangeCalendar, - Heading, + CalendarHeading, Text, type DateValue, type RangeCalendarProps as AriaRangeCalendarProps, @@ -22,20 +22,33 @@ export interface RangeCalendarProps export function RangeCalendar( { errorMessage, ...props }: RangeCalendarProps ) { + let months = props.visibleDuration?.months || 1; return ( - ( - -
- - - -
- - {(date) => } - - {errorMessage && {errorMessage}} -
- ) + +
+ {Array.from({length: months}, (_, i) => ( +
+
+ {i === 0 && + + } + + {i === months - 1 && + + } +
+ + {date => } + +
+ ))} +
+ {errorMessage && {errorMessage}} +
); } diff --git a/starters/tailwind/src/Calendar.tsx b/starters/tailwind/src/Calendar.tsx index 1cbbbace291..08c63f167b8 100644 --- a/starters/tailwind/src/Calendar.tsx +++ b/starters/tailwind/src/Calendar.tsx @@ -9,7 +9,7 @@ import { CalendarGrid, CalendarGridBody, CalendarHeaderCell, - Heading, + CalendarHeading, Text, type DateValue, } from 'react-aria-components/Calendar'; @@ -32,43 +32,45 @@ const cellStyles = tv({ } }); -export interface CalendarProps extends Omit, 'visibleDuration'> { +export interface CalendarProps extends AriaCalendarProps { errorMessage?: string; } export function Calendar( { errorMessage, ...props }: CalendarProps ) { + let {direction} = useLocale(); + let months = props.visibleDuration?.months || 1; return ( - - - - - - {(date) => } - - + + {Array.from({length: months}, (_, i) => ( +
+
+ {i === 0 && + + } + + {i === months - 1 && + + } +
+ + + + {(date) => } + + +
+ ))} {errorMessage && {errorMessage}}
); } -export function CalendarHeader() { - let {direction} = useLocale(); - - return ( -
- - - -
- ); -} - export function CalendarGridHeader() { return ( diff --git a/starters/tailwind/src/RangeCalendar.tsx b/starters/tailwind/src/RangeCalendar.tsx index 8084b04cc1f..bef9c1af0a0 100644 --- a/starters/tailwind/src/RangeCalendar.tsx +++ b/starters/tailwind/src/RangeCalendar.tsx @@ -1,16 +1,20 @@ 'use client'; import React from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import { RangeCalendar as AriaRangeCalendar, CalendarCell, CalendarGrid, CalendarGridBody, + CalendarHeading, Text, type DateValue, type RangeCalendarProps as AriaRangeCalendarProps } from 'react-aria-components/RangeCalendar'; +import { useLocale } from 'react-aria-components/I18nProvider'; import { tv } from 'tailwind-variants'; -import { CalendarGridHeader, CalendarHeader } from './Calendar'; +import { Button } from './Button'; +import { CalendarGridHeader } from './Calendar'; import { composeTailwindRenderProps, focusRing } from './utils'; export interface RangeCalendarProps extends Omit, 'visibleDuration'> { @@ -40,26 +44,44 @@ const cell = tv({ export function RangeCalendar( { errorMessage, ...props }: RangeCalendarProps ) { + let {direction} = useLocale(); + let months = props.visibleDuration?.months || 1; return ( - - - - - - {(date) => - {({formattedDate, isSelected, isSelectionStart, isSelectionEnd, isFocusVisible, isDisabled}) => - - {formattedDate} - + + {Array.from({length: months}, (_, i) => ( +
+
+ {i === 0 && + } - } - - + + {i === months - 1 && + + } +
+ + + + {(date) => + {({formattedDate, isSelected, isSelectionStart, isSelectionEnd, isFocusVisible, isDisabled}) => + + {formattedDate} + + } + } + + +
+ ))} {errorMessage && {errorMessage}}
);