Skip to content

Commit 6618d38

Browse files
authored
feat: support selectionAlignment in Calendar (#8752)
* feat: support selectionAlignment in Calendar * add tests * update prop description * update styles in story * update story * clean up story, add jsdoc default * fix lint
1 parent eb01cee commit 6618d38

File tree

10 files changed

+218
-23
lines changed

10 files changed

+218
-23
lines changed

packages/@react-spectrum/calendar/stories/Calendar.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export default {
9292
},
9393
errorMessage: {
9494
control: 'text'
95+
},
96+
selectionAlignment: {
97+
control: 'select',
98+
options: ['start', 'center', 'end']
9599
}
96100
}
97101
} as Meta<typeof Calendar>;

packages/@react-spectrum/calendar/stories/RangeCalendar.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ export default {
8484
},
8585
errorMessage: {
8686
control: 'text'
87+
},
88+
selectionAlignment: {
89+
control: 'select',
90+
options: ['start', 'center', 'end']
8791
}
8892
}
8993
} as Meta<typeof RangeCalendar>;

packages/@react-spectrum/calendar/test/Calendar.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,24 @@ describe('Calendar', () => {
9191
expect(grids[1].contains(cell)).toBe(true);
9292
});
9393

94+
it.each([
95+
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
96+
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
97+
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
98+
])('should align the initial value $name', async ({alignment, expected}) => {
99+
const {getAllByRole} = render(
100+
<Calendar visibleMonths={3} defaultValue={new CalendarDate(2020, 2, 3)} selectionAlignment={alignment} />
101+
);
102+
103+
let grids = getAllByRole('grid');
104+
expect(grids).toHaveLength(3);
105+
106+
expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
107+
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
108+
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
109+
});
110+
111+
94112
it('should constrain the visible region depending on the minValue', () => {
95113
let {getAllByRole, getByLabelText} = render(<Calendar value={new CalendarDate(2019, 2, 3)} minValue={new CalendarDate(2019, 2, 1)} visibleMonths={3} />);
96114

packages/@react-spectrum/calendar/test/RangeCalendar.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,23 @@ describe('RangeCalendar', () => {
201201
expect(cells.every(cell => grids[1].contains(cell))).toBe(true);
202202
});
203203

204+
it.each([
205+
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
206+
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
207+
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
208+
])('should align the initial value $name', async ({alignment, expected}) => {
209+
const {getAllByRole} = render(
210+
<RangeCalendar visibleMonths={3} defaultValue={{start: new CalendarDate(2020, 2, 3), end: new CalendarDate(2020, 2, 10)}} selectionAlignment={alignment} />
211+
);
212+
213+
let grids = getAllByRole('grid');
214+
expect(grids).toHaveLength(3);
215+
216+
expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
217+
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
218+
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
219+
});
220+
204221
it('should constrain the visible region depending on the minValue', () => {
205222
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} />);
206223

packages/@react-stately/calendar/src/useCalendarState.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ export interface CalendarStateOptions<T extends DateValue = DateValue> extends C
5050
* @default {months: 1}
5151
*/
5252
visibleDuration?: DateDuration,
53-
/** Determines how to align the initial selection relative to the visible date range. */
53+
/**
54+
* Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection.
55+
* @default 'center'
56+
*/
5457
selectionAlignment?: 'start' | 'center' | 'end'
5558
}
5659
/**

packages/@react-stately/calendar/src/useRangeCalendarState.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,29 @@ export interface RangeCalendarStateOptions<T extends DateValue = DateValue> exte
3333
* The amount of days that will be displayed at once. This affects how pagination works.
3434
* @default {months: 1}
3535
*/
36-
visibleDuration?: DateDuration
36+
visibleDuration?: DateDuration,
37+
/**
38+
* Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection.
39+
* @default 'center'
40+
*/
41+
selectionAlignment?: 'start' | 'center' | 'end'
3742
}
3843

3944
/**
4045
* Provides state management for a range calendar component.
4146
* A range calendar displays one or more date grids and allows users to select a contiguous range of dates.
4247
*/
4348
export function useRangeCalendarState<T extends DateValue = DateValue>(props: RangeCalendarStateOptions<T>): RangeCalendarState {
44-
let {value: valueProp, defaultValue, onChange, createCalendar, locale, visibleDuration = {months: 1}, minValue, maxValue, ...calendarProps} = props;
49+
let {
50+
value: valueProp,
51+
defaultValue,
52+
onChange,
53+
createCalendar,
54+
locale,
55+
visibleDuration = {months: 1},
56+
minValue,
57+
maxValue,
58+
...calendarProps} = props;
4559
let [value, setValue] = useControlledState<RangeValue<T> | null, RangeValue<MappedDateValue<T>>>(
4660
valueProp!,
4761
defaultValue || null!,
@@ -73,7 +87,7 @@ export function useRangeCalendarState<T extends DateValue = DateValue>(props: Ra
7387
visibleDuration,
7488
minValue: min,
7589
maxValue: max,
76-
selectionAlignment: alignment
90+
selectionAlignment: props.selectionAlignment || alignment
7791
});
7892

7993
let updateAvailableRange = (date) => {

packages/@react-types/calendar/src/index.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ export interface CalendarPropsBase {
6666
/**
6767
* The day that starts the week.
6868
*/
69-
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
69+
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat',
70+
/**
71+
* Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection.
72+
* @default 'center'
73+
*/
74+
selectionAlignment?: 'start' | 'center' | 'end'
7075
}
7176

7277
export type DateRange = RangeValue<DateValue> | null;

packages/react-aria-components/stories/Calendar.stories.tsx

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {Button, Calendar, CalendarCell, CalendarGrid, CalendarStateContext, Heading, RangeCalendar} from 'react-aria-components';
14+
import {CalendarDate, parseDate} from '@internationalized/date';
1415
import {Meta, StoryObj} from '@storybook/react';
1516
import React, {useContext} from 'react';
1617
import './styles.css';
@@ -21,7 +22,7 @@ export default {
2122
} as Meta<typeof Calendar>;
2223

2324
export type CalendarStory = StoryObj<typeof Calendar>;
24-
25+
export type RangeCalendarStory = StoryObj<typeof RangeCalendar>;
2526

2627
function Footer() {
2728
const state = useContext(CalendarStateContext);
@@ -73,37 +74,96 @@ export const CalendarResetValue: CalendarStory = {
7374
)
7475
};
7576

77+
function CalendarMultiMonthExample(args) {
78+
let defaultDate = new CalendarDate(2021, 7, 1);
79+
let [focusedDate, setFocusedDate] = React.useState(defaultDate);
80+
81+
return (
82+
<>
83+
<button
84+
style={{marginBottom: 20}}
85+
onClick={() => setFocusedDate(defaultDate)}>
86+
Reset focused date
87+
</button>
88+
<Calendar style={{width: 500}} visibleDuration={{months: 3}} focusedValue={focusedDate} onFocusChange={setFocusedDate} defaultValue={defaultDate} {...args}>
89+
<div style={{display: 'flex', alignItems: 'center'}}>
90+
<Button slot="previous">&lt;</Button>
91+
<Heading style={{flex: 1, textAlign: 'center'}} />
92+
<Button slot="next">&gt;</Button>
93+
</div>
94+
<div style={{display: 'flex', gap: 20}}>
95+
<CalendarGrid style={{flex: 1}}>
96+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
97+
</CalendarGrid>
98+
<CalendarGrid style={{flex: 1}} offset={{months: 1}}>
99+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
100+
</CalendarGrid>
101+
<CalendarGrid style={{flex: 1}} offset={{months: 2}}>
102+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
103+
</CalendarGrid>
104+
</div>
105+
</Calendar>
106+
</>
107+
);
108+
};
109+
76110
export const CalendarMultiMonth: CalendarStory = {
111+
render: (args) => <CalendarMultiMonthExample {...args} />,
112+
args: {
113+
selectionAlignment: 'center'
114+
},
115+
argTypes: {
116+
selectionAlignment: {
117+
control: 'select',
118+
options: ['start', 'center', 'end']
119+
}
120+
}
121+
};
122+
123+
export const RangeCalendarExample: RangeCalendarStory = {
77124
render: () => (
78-
<Calendar style={{width: 500}} visibleDuration={{months: 2}}>
125+
<RangeCalendar style={{width: 220}}>
79126
<div style={{display: 'flex', alignItems: 'center'}}>
80127
<Button slot="previous">&lt;</Button>
81128
<Heading style={{flex: 1, textAlign: 'center'}} />
82129
<Button slot="next">&gt;</Button>
83130
</div>
84-
<div style={{display: 'flex', gap: 20}}>
85-
<CalendarGrid style={{flex: 1}}>
86-
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
87-
</CalendarGrid>
88-
<CalendarGrid style={{flex: 1}} offset={{months: 1}}>
89-
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({opacity: isOutsideMonth ? '0.5' : '', textAlign: 'center', cursor: 'default', background: isSelected && !isOutsideMonth ? 'blue' : ''})} />}
90-
</CalendarGrid>
91-
</div>
92-
</Calendar>
131+
<CalendarGrid style={{width: '100%'}}>
132+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
133+
</CalendarGrid>
134+
</RangeCalendar>
93135
)
94136
};
95137

96-
export const RangeCalendarExample: CalendarStory = {
97-
render: () => (
98-
<RangeCalendar style={{width: 220}}>
138+
139+
export const RangeCalendarMultiMonthExample: RangeCalendarStory = {
140+
render: (args) => (
141+
<RangeCalendar style={{width: 500}} visibleDuration={{months: 3}} defaultValue={{start: parseDate('2025-08-04'), end: parseDate('2025-08-10')}} {...args} >
99142
<div style={{display: 'flex', alignItems: 'center'}}>
100143
<Button slot="previous">&lt;</Button>
101144
<Heading style={{flex: 1, textAlign: 'center'}} />
102145
<Button slot="next">&gt;</Button>
103146
</div>
104-
<CalendarGrid style={{width: '100%'}}>
105-
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
106-
</CalendarGrid>
147+
<div style={{display: 'flex', gap: 20}}>
148+
<CalendarGrid style={{flex: 1}}>
149+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
150+
</CalendarGrid>
151+
<CalendarGrid style={{flex: 1}} offset={{months: 1}}>
152+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
153+
</CalendarGrid>
154+
<CalendarGrid style={{flex: 1}} offset={{months: 2}}>
155+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
156+
</CalendarGrid>
157+
</div>
107158
</RangeCalendar>
108-
)
159+
),
160+
args: {
161+
selectionAlignment: 'center'
162+
},
163+
argTypes: {
164+
selectionAlignment: {
165+
control: 'select',
166+
options: ['start', 'center', 'end']
167+
}
168+
}
109169
};

packages/react-aria-components/test/Calendar.test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,41 @@ describe('Calendar', () => {
172172
expect(grids[1]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone())));
173173
});
174174

175+
176+
it.each([
177+
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
178+
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
179+
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
180+
])('should align the initial value $name', async ({alignment, expected}) => {
181+
const {getAllByRole} = render(
182+
<Calendar visibleDuration={{months: 3}} defaultValue={new CalendarDate(2020, 2, 3)} selectionAlignment={alignment}>
183+
<header>
184+
<Button slot="previous"></Button>
185+
<Heading />
186+
<Button slot="next"></Button>
187+
</header>
188+
<div style={{display: 'flex', gap: 30}}>
189+
<CalendarGrid>
190+
{date => <CalendarCell date={date} />}
191+
</CalendarGrid>
192+
<CalendarGrid offset={{months: 1}}>
193+
{date => <CalendarCell date={date} />}
194+
</CalendarGrid>
195+
<CalendarGrid offset={{months: 2}}>
196+
{date => <CalendarCell date={date} />}
197+
</CalendarGrid>
198+
</div>
199+
</Calendar>
200+
);
201+
202+
let grids = getAllByRole('grid');
203+
expect(grids).toHaveLength(3);
204+
205+
expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
206+
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
207+
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
208+
});
209+
175210
it('should support hover', async () => {
176211
let hoverStartSpy = jest.fn();
177212
let hoverChangeSpy = jest.fn();

packages/react-aria-components/test/RangeCalendar.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,41 @@ describe('RangeCalendar', () => {
184184
expect(grids[1]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone())));
185185
});
186186

187+
it.each([
188+
{name: 'at the start', alignment: 'start', expected: ['February 2020', 'March 2020', 'April 2020']},
189+
{name: 'in the center', alignment: 'center', expected: ['January 2020', 'February 2020', 'March 2020']},
190+
{name: 'at the end', alignment: 'end', expected: ['December 2019', 'January 2020', 'February 2020']}
191+
])('should align the initial value $name', async ({alignment, expected}) => {
192+
const {getAllByRole} = render(
193+
<RangeCalendar visibleDuration={{months: 3}} defaultValue={{start: new CalendarDate(2020, 2, 3), end: new CalendarDate(2020, 2, 10)}} selectionAlignment={alignment as 'start' | 'center' | 'end'}>
194+
<header>
195+
<Button slot="previous"></Button>
196+
<Heading />
197+
<Button slot="next"></Button>
198+
</header>
199+
<div style={{display: 'flex', gap: 30}}>
200+
<CalendarGrid>
201+
{date => <CalendarCell date={date} />}
202+
</CalendarGrid>
203+
<CalendarGrid offset={{months: 1}}>
204+
{date => <CalendarCell date={date} />}
205+
</CalendarGrid>
206+
<CalendarGrid offset={{months: 2}}>
207+
{date => <CalendarCell date={date} />}
208+
</CalendarGrid>
209+
</div>
210+
</RangeCalendar>
211+
);
212+
213+
let grids = getAllByRole('grid');
214+
expect(grids).toHaveLength(3);
215+
216+
expect(grids[0]).toHaveAttribute('aria-label', expected[0]);
217+
expect(grids[1]).toHaveAttribute('aria-label', expected[1]);
218+
expect(grids[2]).toHaveAttribute('aria-label', expected[2]);
219+
});
220+
221+
187222
it('should support hover', async () => {
188223
let {getByRole} = renderCalendar({}, {}, {className: ({isHovered}) => isHovered ? 'hover' : ''});
189224
let grid = getByRole('grid');

0 commit comments

Comments
 (0)