Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ export default {
},
errorMessage: {
control: 'text'
},
selectionAlignment: {
control: 'select',
options: ['start', 'center', 'end']
}
}
} as Meta<typeof Calendar>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export default {
},
errorMessage: {
control: 'text'
},
selectionAlignment: {
control: 'select',
options: ['start', 'center', 'end']
}
}
} as Meta<typeof RangeCalendar>;
Expand Down
18 changes: 18 additions & 0 deletions packages/@react-spectrum/calendar/test/Calendar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@ describe('Calendar', () => {
expect(grids[1].contains(cell)).toBe(true);
});

it.each([
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
])('should align the initial value $name', async ({alignment, expected}) => {
const {getAllByRole} = render(
<Calendar visibleMonths={3} defaultValue={new CalendarDate(2020, 2, 3)} selectionAlignment={alignment} />
);

let grids = getAllByRole('grid');
expect(grids).toHaveLength(3);

expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
});


it('should constrain the visible region depending on the minValue', () => {
let {getAllByRole, getByLabelText} = render(<Calendar value={new CalendarDate(2019, 2, 3)} minValue={new CalendarDate(2019, 2, 1)} visibleMonths={3} />);

Expand Down
17 changes: 17 additions & 0 deletions packages/@react-spectrum/calendar/test/RangeCalendar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,23 @@ describe('RangeCalendar', () => {
expect(cells.every(cell => grids[1].contains(cell))).toBe(true);
});

it.each([
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
])('should align the initial value $name', async ({alignment, expected}) => {
const {getAllByRole} = render(
<RangeCalendar visibleMonths={3} defaultValue={{start: new CalendarDate(2020, 2, 3), end: new CalendarDate(2020, 2, 10)}} selectionAlignment={alignment} />
);

let grids = getAllByRole('grid');
expect(grids).toHaveLength(3);

expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
});

it('should constrain the visible region depending on the minValue', () => {
let {getAllByRole, getAllByLabelText} = render(<RangeCalendar value={{start: new CalendarDate(2019, 2, 3), end: new CalendarDate(2019, 2, 10)}} minValue={new CalendarDate(2019, 2, 1)} visibleMonths={3} />);

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-stately/calendar/src/useCalendarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface CalendarStateOptions<T extends DateValue = DateValue> extends C
* @default {months: 1}
*/
visibleDuration?: DateDuration,
/** Determines how to align the initial selection relative to the visible date range. */
/** Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection. */
selectionAlignment?: 'start' | 'center' | 'end'
}
/**
Expand Down
17 changes: 14 additions & 3 deletions packages/@react-stately/calendar/src/useRangeCalendarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,26 @@ export interface RangeCalendarStateOptions<T extends DateValue = DateValue> exte
* The amount of days that will be displayed at once. This affects how pagination works.
* @default {months: 1}
*/
visibleDuration?: DateDuration
visibleDuration?: DateDuration,
/** Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection. */
Copy link
Member

@snowystinger snowystinger Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realised this probably also affects focusedValue so it's probably not just the initial render, it's probably anytime that that prop changes as well?
Could you add something to control that value? Maybe a second story based on https://react-spectrum.adobe.com/react-aria/Calendar.html#controlling-the-focused-date ? Just so we know what the interaction is

I may be wrong and it does nothing, but I'd like to make sure

Copy link
Member Author

@yihuiliao yihuiliao Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, i updated one of the stories to have a focused date. it doesn't seem like selectionAlignment does anything when you reset the focusedValue, but maybe people would expect it to? opinions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe not? Otherwise if some is controlling the focus, it could cause too much "movement" just moving one month to the next.

The reset is where I get tripped up because then I think it should...

selectionAlignment?: 'start' | 'center' | 'end'
}

/**
* Provides state management for a range calendar component.
* A range calendar displays one or more date grids and allows users to select a contiguous range of dates.
*/
export function useRangeCalendarState<T extends DateValue = DateValue>(props: RangeCalendarStateOptions<T>): RangeCalendarState {
let {value: valueProp, defaultValue, onChange, createCalendar, locale, visibleDuration = {months: 1}, minValue, maxValue, ...calendarProps} = props;
let {
value: valueProp,
defaultValue,
onChange,
createCalendar,
locale,
visibleDuration = {months: 1},
minValue,
maxValue,
...calendarProps} = props;
let [value, setValue] = useControlledState<RangeValue<T> | null, RangeValue<MappedDateValue<T>>>(
valueProp!,
defaultValue || null!,
Expand Down Expand Up @@ -73,7 +84,7 @@ export function useRangeCalendarState<T extends DateValue = DateValue>(props: Ra
visibleDuration,
minValue: min,
maxValue: max,
selectionAlignment: alignment
selectionAlignment: props.selectionAlignment || alignment
});

let updateAvailableRange = (date) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-types/calendar/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ export interface CalendarPropsBase {
/**
* The day that starts the week.
*/
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat',
/** Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection. */
selectionAlignment?: 'start' | 'center' | 'end'
}

export type DateRange = RangeValue<DateValue> | null;
Expand Down
56 changes: 51 additions & 5 deletions packages/react-aria-components/stories/Calendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import {Button, Calendar, CalendarCell, CalendarGrid, CalendarStateContext, Heading, RangeCalendar} from 'react-aria-components';
import {Meta, StoryObj} from '@storybook/react';
import {parseDate} from '@internationalized/date';
import React, {useContext} from 'react';
import './styles.css';

Expand All @@ -21,7 +22,7 @@ export default {
} as Meta<typeof Calendar>;

export type CalendarStory = StoryObj<typeof Calendar>;

export type RangeCalendarStory = StoryObj<typeof RangeCalendar>;

function Footer() {
const state = useContext(CalendarStateContext);
Expand Down Expand Up @@ -74,8 +75,8 @@ export const CalendarResetValue: CalendarStory = {
};

export const CalendarMultiMonth: CalendarStory = {
render: () => (
<Calendar style={{width: 500}} visibleDuration={{months: 2}}>
render: (args) => (
<Calendar style={{width: 500}} visibleDuration={{months: 3}} {...args}>
<div style={{display: 'flex', alignItems: 'center'}}>
<Button slot="previous">&lt;</Button>
<Heading style={{flex: 1, textAlign: 'center'}} />
Expand All @@ -88,12 +89,24 @@ export const CalendarMultiMonth: CalendarStory = {
<CalendarGrid style={{flex: 1}} offset={{months: 1}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
</CalendarGrid>
<CalendarGrid style={{flex: 1}} offset={{months: 2}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
</CalendarGrid>
</div>
</Calendar>
)
),
args: {
selectionAlignment: 'center'
},
argTypes: {
selectionAlignment: {
control: 'select',
options: ['start', 'center', 'end']
}
}
};

export const RangeCalendarExample: CalendarStory = {
export const RangeCalendarExample: RangeCalendarStory = {
render: () => (
<RangeCalendar style={{width: 220}}>
<div style={{display: 'flex', alignItems: 'center'}}>
Expand All @@ -107,3 +120,36 @@ export const RangeCalendarExample: CalendarStory = {
</RangeCalendar>
)
};


export const RangeCalendarMultiMonthExample: RangeCalendarStory = {
render: (args) => (
<RangeCalendar style={{width: 500}} visibleDuration={{months: 3}} defaultValue={{start: parseDate('2025-08-04'), end: parseDate('2025-08-10')}} {...args} >
<div style={{display: 'flex', alignItems: 'center'}}>
<Button slot="previous">&lt;</Button>
<Heading style={{flex: 1, textAlign: 'center'}} />
<Button slot="next">&gt;</Button>
</div>
<div style={{display: 'flex', gap: 20}}>
<CalendarGrid style={{flex: 1}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
</CalendarGrid>
<CalendarGrid style={{flex: 1}} offset={{months: 1}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
</CalendarGrid>
<CalendarGrid style={{flex: 1}} offset={{months: 2}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
</CalendarGrid>
</div>
</RangeCalendar>
),
args: {
selectionAlignment: 'center'
},
argTypes: {
selectionAlignment: {
control: 'select',
options: ['start', 'center', 'end']
}
}
};
35 changes: 35 additions & 0 deletions packages/react-aria-components/test/Calendar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,41 @@ describe('Calendar', () => {
expect(grids[1]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone())));
});


it.each([
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
])('should align the initial value $name', async ({alignment, expected}) => {
const {getAllByRole} = render(
<Calendar visibleDuration={{months: 3}} defaultValue={new CalendarDate(2020, 2, 3)} selectionAlignment={alignment}>
<header>
<Button slot="previous">◀</Button>
<Heading />
<Button slot="next">▶</Button>
</header>
<div style={{display: 'flex', gap: 30}}>
<CalendarGrid>
{date => <CalendarCell date={date} />}
</CalendarGrid>
<CalendarGrid offset={{months: 1}}>
{date => <CalendarCell date={date} />}
</CalendarGrid>
<CalendarGrid offset={{months: 2}}>
{date => <CalendarCell date={date} />}
</CalendarGrid>
</div>
</Calendar>
);

let grids = getAllByRole('grid');
expect(grids).toHaveLength(3);

expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
});

it('should support hover', async () => {
let hoverStartSpy = jest.fn();
let hoverChangeSpy = jest.fn();
Expand Down
35 changes: 35 additions & 0 deletions packages/react-aria-components/test/RangeCalendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,41 @@ describe('RangeCalendar', () => {
expect(grids[1]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone())));
});

it.each([
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
])('should align the initial value $name', async ({alignment, expected}) => {
const {getAllByRole} = render(
<RangeCalendar visibleDuration={{months: 3}} defaultValue={{start: new CalendarDate(2020, 2, 3), end: new CalendarDate(2020, 2, 10)}} selectionAlignment={alignment as 'start' | 'center' | 'end'}>
<header>
<Button slot="previous">◀</Button>
<Heading />
<Button slot="next">▶</Button>
</header>
<div style={{display: 'flex', gap: 30}}>
<CalendarGrid>
{date => <CalendarCell date={date} />}
</CalendarGrid>
<CalendarGrid offset={{months: 1}}>
{date => <CalendarCell date={date} />}
</CalendarGrid>
<CalendarGrid offset={{months: 2}}>
{date => <CalendarCell date={date} />}
</CalendarGrid>
</div>
</RangeCalendar>
);

let grids = getAllByRole('grid');
expect(grids).toHaveLength(3);

expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
});


it('should support hover', async () => {
let {getByRole} = renderCalendar({}, {}, {className: ({isHovered}) => isHovered ? 'hover' : ''});
let grid = getByRole('grid');
Expand Down