Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion packages/@react-stately/calendar/src/useCalendarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ 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.
* @default 'center'
*/
selectionAlignment?: 'start' | 'center' | 'end'
}
/**
Expand Down
20 changes: 17 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,29 @@ 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.
* @default 'center'
*/
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 +87,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
7 changes: 6 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,12 @@ 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.
* @default 'center'
*/
selectionAlignment?: 'start' | 'center' | 'end'
}

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

import {Button, Calendar, CalendarCell, CalendarGrid, CalendarStateContext, Heading, RangeCalendar} from 'react-aria-components';
import {CalendarDate, parseDate} from '@internationalized/date';
import {Meta, StoryObj} from '@storybook/react';
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 @@ -73,37 +74,96 @@ export const CalendarResetValue: CalendarStory = {
)
};

function CalendarMultiMonthExample(args) {
let defaultDate = new CalendarDate(2021, 7, 1);
let [focusedDate, setFocusedDate] = React.useState(defaultDate);

return (
<>
<button
style={{marginBottom: 20}}
onClick={() => setFocusedDate(defaultDate)}>
Reset focused date
</button>
<Calendar style={{width: 500}} visibleDuration={{months: 3}} focusedValue={focusedDate} onFocusChange={setFocusedDate} defaultValue={defaultDate} {...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}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
</CalendarGrid>
<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>
</>
);
};

export const CalendarMultiMonth: CalendarStory = {
render: (args) => <CalendarMultiMonthExample {...args} />,
args: {
selectionAlignment: 'center'
},
argTypes: {
selectionAlignment: {
control: 'select',
options: ['start', 'center', 'end']
}
}
};

export const RangeCalendarExample: RangeCalendarStory = {
render: () => (
<Calendar style={{width: 500}} visibleDuration={{months: 2}}>
<RangeCalendar style={{width: 220}}>
<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}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
</CalendarGrid>
<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>
</div>
</Calendar>
<CalendarGrid style={{width: '100%'}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
</CalendarGrid>
</RangeCalendar>
)
};

export const RangeCalendarExample: CalendarStory = {
render: () => (
<RangeCalendar style={{width: 220}}>

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>
<CalendarGrid style={{width: '100%'}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
</CalendarGrid>
<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