Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Calendar): Add firstDayOfWeek prop #7363

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 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
10 changes: 9 additions & 1 deletion packages/@react-aria/calendar/docs/useCalendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function Calendar(props) {
<Button {...prevButtonProps}>&lt;</Button>
<Button {...nextButtonProps}>&gt;</Button>
</div>
<CalendarGrid state={state} />
<CalendarGrid state={state} firstDayOfWeek={props.firstDayOfWeek} />
</div>
);
}
Expand Down Expand Up @@ -458,6 +458,14 @@ The `isReadOnly` boolean prop makes the Calendar's value immutable. Unlike `isDi
<Calendar aria-label="Event date" value={today(getLocalTimeZone())} isReadOnly />
```

### Custom first day of week

By default, the first day of the week is Sunday. This can be changed by setting the `firstDayOfWeek` prop to `'Sun'`, `'Mon'`, `'Tue'`, `'Wed'`, `'Thu'`, `'Fri'`, or `'Sat'`.
reidbarber marked this conversation as resolved.
Show resolved Hide resolved

```tsx example
<Calendar aria-label="Event date" value={today(getLocalTimeZone())} firstDayOfWeek="Mon" />
```

### Labeling

An aria-label must be provided to the `Calendar` for accessibility. If it is labeled by a separate element, an `aria-labelledby` prop must be provided using the `id` of the labeling element instead.
Expand Down
10 changes: 9 additions & 1 deletion packages/@react-aria/calendar/docs/useRangeCalendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function RangeCalendar(props) {
<Button {...prevButtonProps}>&lt;</Button>
<Button {...nextButtonProps}>&gt;</Button>
</div>
<CalendarGrid state={state} />
<CalendarGrid state={state} firstDayOfWeek={props.firstDayOfWeek} />
</div>
);
}
Expand Down Expand Up @@ -477,6 +477,14 @@ The `isReadOnly` boolean prop makes the RangeCalendar's value immutable. Unlike
<RangeCalendar aria-label="Trip dates" value={{start: today(getLocalTimeZone()), end: today(getLocalTimeZone()).add({ weeks: 1 })}} isReadOnly />
```

### Custom first day of week

By default, the first day of the week is Sunday. This can be changed by setting the `firstDayOfWeek` prop to `'Sun'`, `'Mon'`, `'Tue'`, `'Wed'`, `'Thu'`, `'Fri'`, or `'Sat'`.

```tsx example
<RangeCalendar aria-label="Trip dates" firstDayOfWeek="Mon" />
```

### Labeling

An aria-label must be provided to the `RangeCalendar` for accessibility. If it is labeled by a separate element, an `aria-labelledby` prop must be provided using the `id` of the labeling element instead.
Expand Down
24 changes: 20 additions & 4 deletions packages/@react-aria/calendar/src/useCalendarGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ import {KeyboardEvent, useMemo} from 'react';
import {mergeProps, useLabels} from '@react-aria/utils';
import {useDateFormatter, useLocale} from '@react-aria/i18n';

const DAY_MAP = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6
};

export interface AriaCalendarGridProps {
/**
* The first date displayed in the calendar grid.
Expand All @@ -36,7 +46,12 @@ export interface AriaCalendarGridProps {
* e.g. single letter, abbreviation, or full day name.
* @default "narrow"
*/
weekdayStyle?: 'narrow' | 'short' | 'long'
weekdayStyle?: 'narrow' | 'short' | 'long',
/**
* The day that starts the week.
* @default 'Sun'
*/
firstDayOfWeek?: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat'
}

export interface CalendarGridAria {
Expand All @@ -56,7 +71,8 @@ export interface CalendarGridAria {
export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarState | RangeCalendarState): CalendarGridAria {
let {
startDate = state.visibleRange.start,
endDate = state.visibleRange.end
endDate = state.visibleRange.end,
firstDayOfWeek = 'Sun'
} = props;

let {direction} = useLocale();
Expand Down Expand Up @@ -137,13 +153,13 @@ 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);
let weekStart = startOfWeek(today(state.timeZone), locale).add({days: DAY_MAP[firstDayOfWeek]});
return [...new Array(7).keys()].map((index) => {
let date = weekStart.add({days: index});
let dateDay = date.toDate(state.timeZone);
return dayFormatter.format(dateDay);
});
}, [locale, state.timeZone, dayFormatter]);
}, [locale, state.timeZone, dayFormatter, firstDayOfWeek]);

return {
gridProps: mergeProps(labelProps, {
Expand Down
46 changes: 46 additions & 0 deletions packages/@react-aria/calendar/stories/Example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,49 @@ function Cell(props) {
</div>
);
}

export function ExampleCustomFirstDay(props) {
let {locale} = useLocale();
const {firstDayOfWeek} = props;

let state = useCalendarState({
...props,
locale,
createCalendar
});

let {calendarProps, prevButtonProps, nextButtonProps} = useCalendar(props, state);

return (
<div {...calendarProps}>
<div style={{textAlign: 'center'}} data-testid={'range'}>
{calendarProps['aria-label']}
</div>
<div style={{display: 'grid', gridTemplateColumns: 'repeat(1, 1fr)', gap: '1em'}}>
<ExampleFirstDayCalendarGrid state={state} firstDayOfWeek={firstDayOfWeek} />
</div>
<div>
<Button variant={'secondary'} {...prevButtonProps}>prev</Button>
<Button variant={'secondary'} {...nextButtonProps}>next</Button>
</div>
</div>
);
}

function ExampleFirstDayCalendarGrid({state, firstDayOfWeek}: {state: CalendarState | RangeCalendarState, firstDayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat'}) {
let {locale} = useLocale();
let {gridProps} = useCalendarGrid({firstDayOfWeek}, state);
let startDate = state.visibleRange.start;
let weeksInMonth = getWeeksInMonth(startDate, locale);
return (
<div {...gridProps}>
{[...new Array(weeksInMonth).keys()].map(weekIndex => (
<div key={weekIndex} role="row">
{state.getDatesInWeek(weekIndex, startDate).map((date, i) => (
<Cell key={i} state={state} date={date} />
))}
</div>
))}
</div>
);
}
25 changes: 24 additions & 1 deletion packages/@react-aria/calendar/test/useCalendar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {act, pointerMap, render} from '@react-spectrum/test-utils-internal';
import {CalendarDate} from '@internationalized/date';
import {Example} from '../stories/Example';
import {Example, ExampleCustomFirstDay} from '../stories/Example';
import React from 'react';
import userEvent from '@testing-library/user-event';

Expand Down Expand Up @@ -63,6 +63,13 @@ describe('useCalendar', () => {
unmount();
}

async function testFirstDayOfWeek(defaultValue, firstDayOfWeek, expectedFirstDay) {
let {getAllByRole, unmount} = render(<ExampleCustomFirstDay defaultValue={defaultValue} firstDayOfWeek={firstDayOfWeek} />);
let cells = getAllByRole('gridcell');
expect(cells[0].children[0]).toHaveAttribute('aria-label', expectedFirstDay);
unmount();
}

describe('visibleDuration: 3 days', () => {
it('should move the focused date by one day with the left/right arrows', async () => {
await testKeyboard(new CalendarDate(2019, 6, 5), 'June 4 to 6, 2019', 'ArrowLeft', 1, 'Tuesday, June 4, 2019', 'June 4 to 6, 2019', {visibleDuration: {days: 3}});
Expand Down Expand Up @@ -227,4 +234,20 @@ describe('useCalendar', () => {
await testPagination(defaultValue, rangeBefore, rangeAfter, rel, count, visibleDuration, pageBehavior);
});
});

describe('firstDayOfWeek', () => {
it.each`
Name | defaultValue | firstDayOfWeek | expectedFirstDay
${'default'} | ${new CalendarDate(2024, 1, 1)} | ${undefined} | ${'Sunday, December 31, 2023'}
${'Sunday'} | ${new CalendarDate(2024, 1, 1)} | ${'Sun'} | ${'Sunday, December 31, 2023'}
${'Monday'} | ${new CalendarDate(2024, 1, 1)} | ${'Mon'} | ${'Monday, January 1, 2024 selected'}
${'Tuesday'} | ${new CalendarDate(2024, 1, 1)} | ${'Tue'} | ${'Tuesday, December 26, 2023'}
${'Wednesday'} | ${new CalendarDate(2024, 1, 1)} | ${'Wed'} | ${'Wednesday, December 27, 2023'}
${'Thursday'} | ${new CalendarDate(2024, 1, 1)} | ${'Thu'} | ${'Thursday, December 28, 2023'}
${'Friday'} | ${new CalendarDate(2024, 1, 1)} | ${'Fri'} | ${'Friday, December 29, 2023'}
${'Saturday'} | ${new CalendarDate(2024, 1, 1)} | ${'Sat'} | ${'Saturday, December 30, 2023'}
`('should use firstDayOfWeek $Name', async ({defaultValue, firstDayOfWeek, expectedFirstDay}) => {
await testFirstDayOfWeek(defaultValue, firstDayOfWeek, expectedFirstDay);
});
});
});
12 changes: 10 additions & 2 deletions packages/@react-aria/datepicker/docs/useDatePicker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function DatePicker(props) {
{state.isOpen &&
<Popover state={state} triggerRef={ref} placement="bottom start">
<Dialog {...dialogProps}>
<Calendar {...calendarProps} />
<Calendar {...calendarProps} firstDayOfWeek={props.firstDayOfWeek} />
</Dialog>
</Popover>
}
Expand Down Expand Up @@ -346,7 +346,7 @@ function Calendar(props) {
<Button {...prevButtonProps}>&lt;</Button>
<Button {...nextButtonProps}>&gt;</Button>
</div>
<CalendarGrid state={state} />
<CalendarGrid state={state} firstDayOfWeek={props.firstDayOfWeek} />
</div>
);
}
Expand Down Expand Up @@ -690,3 +690,11 @@ By default, `useDatePicker` displays times in either 12 or 24 hour hour format d
granularity="minute"
hourCycle={24} />
```

### Custom first day of week

By default, the first day of the week is Sunday. This can be changed by setting the `firstDayOfWeek` prop to `'Sun'`, `'Mon'`, `'Tue'`, `'Wed'`, `'Thu'`, `'Fri'`, or `'Sat'`.

```tsx example
<DatePicker label="Appointment time" firstDayOfWeek="Mon" />
```
12 changes: 10 additions & 2 deletions packages/@react-aria/datepicker/docs/useDateRangePicker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function DateRangePicker(props) {
{state.isOpen &&
<Popover state={state} triggerRef={ref} placement="bottom start">
<Dialog {...dialogProps}>
<RangeCalendar {...calendarProps} />
<RangeCalendar {...calendarProps} firstDayOfWeek={props.firstDayOfWeek} />
</Dialog>
</Popover>
}
Expand Down Expand Up @@ -359,7 +359,7 @@ function RangeCalendar(props) {
<Button {...prevButtonProps}>&lt;</Button>
<Button {...nextButtonProps}>&gt;</Button>
</div>
<CalendarGrid state={state} />
<CalendarGrid state={state} firstDayOfWeek={props.firstDayOfWeek} />
</div>
);
}
Expand Down Expand Up @@ -755,3 +755,11 @@ By default, `useDateRangePicker` displays times in either 12 or 24 hour hour for
granularity="minute"
hourCycle={24} />
```

### Custom first day of week

By default, the first day of the week is Sunday. This can be changed by setting the `firstDayOfWeek` prop to `'Sun'`, `'Mon'`, `'Tue'`, `'Wed'`, `'Thu'`, `'Fri'`, or `'Sat'`.

```tsx example
<DateRangePicker label="Date range" firstDayOfWeek="Mon" />
```
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ export const Invalid = () => <Calendar value={date} isInvalid />;
export const ErrorMessage = () => <Calendar value={date} isInvalid errorMessage="Selection invalid." />;
export const UnavailableInvalid = () => <Calendar value={date} isDateUnavailable={d => d.compare(date) === 0} />;
export const DisabledInvalid = () => <Calendar value={date} minValue={new CalendarDate(2022, 2, 5)} />;
export const CustomWeekStartMonday = () => <Calendar value={date} firstDayOfWeek="Mon" />;
export const CustomWeekStartSaturday = () => <Calendar value={date} firstDayOfWeek="Sat" />;
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ export const NonContiguousInvalid = () => {
);
};

export const CustomWeekStartMonday = () => <RangeCalendar value={value} firstDayOfWeek="Mon" />;
export const CustomWeekStartSaturday = () => <RangeCalendar value={value} firstDayOfWeek="Sat" />;
10 changes: 10 additions & 0 deletions packages/@react-spectrum/calendar/docs/Calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,13 @@ By default, when pressing the next or previous buttons, pagination will advance
<Calendar aria-label="Event date" visibleMonths={3} pageBehavior="single" />
</div>
```

### Custom first day of week

By default, the first day of the week is Sunday. This can be changed by setting the `firstDayOfWeek` prop to `'Sun'`, `'Mon'`, `'Tue'`, `'Wed'`, `'Thu'`, `'Fri'`, or `'Sat'`.

```tsx example
<div style={{maxWidth: '100%', overflow: 'auto'}}>
<Calendar aria-label="Event date" firstDayOfWeek="Mon" />
</div>
```
10 changes: 10 additions & 0 deletions packages/@react-spectrum/calendar/docs/RangeCalendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,13 @@ By default, when pressing the next or previous buttons, pagination will advance
<RangeCalendar aria-label="Trip dates" visibleMonths={3} pageBehavior="single" />
</div>
```

### Custom first day of week

By default, the first day of the week is Sunday. This can be changed by setting the `firstDayOfWeek` prop to `'Sun'`, `'Mon'`, `'Tue'`, `'Wed'`, `'Thu'`, `'Fri'`, or `'Sat'`.

```tsx example
<div style={{maxWidth: '100%', overflow: 'auto'}}>
<RangeCalendar aria-label="Trip dates" firstDayOfWeek="Mon" />
</div>
```
6 changes: 4 additions & 2 deletions packages/@react-spectrum/calendar/src/CalendarBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export function CalendarBase<T extends CalendarState | RangeCalendarState>(props
prevButtonProps,
errorMessageProps,
calendarRef: ref,
visibleMonths = 1
visibleMonths = 1,
firstDayOfWeek = 'Sun'
} = props;
let {styleProps} = useStyleProps(props);
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/calendar');
Expand Down Expand Up @@ -97,7 +98,8 @@ export function CalendarBase<T extends CalendarState | RangeCalendarState>(props
{...props}
key={i}
state={state}
startDate={d} />
startDate={d}
firstDayOfWeek={firstDayOfWeek} />
);
}

Expand Down
19 changes: 15 additions & 4 deletions packages/@react-spectrum/calendar/src/CalendarCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,23 @@ import {useFocusRing} from '@react-aria/focus';
import {useHover} from '@react-aria/interactions';
import {useLocale} from '@react-aria/i18n';

const DAY_MAP = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6
};

interface CalendarCellProps extends AriaCalendarCellProps {
state: CalendarState | RangeCalendarState,
currentMonth: CalendarDate
currentMonth: CalendarDate,
firstDayOfWeek?: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat'
}

export function CalendarCell({state, currentMonth, ...props}: CalendarCellProps) {
export function CalendarCell({state, currentMonth, firstDayOfWeek = 'Sun', ...props}: CalendarCellProps) {
let ref = useRef<HTMLElement>(null);
let {
cellProps,
Expand All @@ -49,8 +60,8 @@ export function CalendarCell({state, currentMonth, ...props}: CalendarCellProps)
let isSelectionEnd = isSelected && highlightedRange && isSameDay(props.date, highlightedRange.end);
let {locale} = useLocale();
let dayOfWeek = getDayOfWeek(props.date, locale);
let isRangeStart = isSelected && (isFirstSelectedAfterDisabled || dayOfWeek === 0 || props.date.day === 1);
let isRangeEnd = isSelected && (isLastSelectedBeforeDisabled || dayOfWeek === 6 || props.date.day === currentMonth.calendar.getDaysInMonth(currentMonth));
let isRangeStart = isSelected && (isFirstSelectedAfterDisabled || dayOfWeek === DAY_MAP[firstDayOfWeek] || props.date.day === 1);
let isRangeEnd = isSelected && (isLastSelectedBeforeDisabled || ((dayOfWeek - DAY_MAP[firstDayOfWeek] + 7) % 7) === 6 || props.date.day === currentMonth.calendar.getDaysInMonth(currentMonth));
let {focusProps, isFocusVisible} = useFocusRing();
let {hoverProps, isHovered} = useHover({isDisabled: isDisabled || isUnavailable || state.isReadOnly});

Expand Down
6 changes: 4 additions & 2 deletions packages/@react-spectrum/calendar/src/CalendarMonth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ interface CalendarMonthProps extends CalendarPropsBase, DOMProps, StyleProps {
export function CalendarMonth(props: CalendarMonthProps) {
let {
state,
startDate
startDate,
firstDayOfWeek
} = props;
let {
gridProps,
Expand Down Expand Up @@ -69,7 +70,8 @@ export function CalendarMonth(props: CalendarMonthProps) {
key={i}
state={state}
date={date}
currentMonth={startDate} />
currentMonth={startDate}
firstDayOfWeek={firstDayOfWeek} />
) : <td key={i} />
))}
</tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export default {
control: 'select',
options: [null, 'single', 'visible']
},
firstDayOfWeek: {
control: 'select',
options: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
},
isInvalid: {
control: 'boolean'
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export default {
control: 'select',
options: [null, 'single', 'visible']
},
firstDayOfWeek: {
control: 'select',
options: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
},
isInvalid: {
control: 'boolean'
},
Expand Down
Loading