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 21 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion packages/@internationalized/date/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ export {
minDate,
maxDate,
isWeekend,
isWeekday
isWeekday,
getWeekStart
} from './queries';
export {
parseDate,
Expand Down
2 changes: 1 addition & 1 deletion packages/@internationalized/date/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ function getRegion(locale: string): string | undefined {
return part === 'u' ? undefined : part;
}

function getWeekStart(locale: string): number {
export function getWeekStart(locale: string): number {
// TODO: use Intl.Locale for this once browsers support the weekInfo property
// https://github.com/tc39/proposal-intl-locale-info
let region = getRegion(locale);
Expand Down
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 automatically set based on the current locale. This can be changed by setting the `firstDayOfWeek` prop to `'sun'`, `'mon'`, `'tue'`, `'wed'`, `'thu'`, `'fri'`, or `'sat'`.

```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 automatically set based on the current locale. 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
26 changes: 21 additions & 5 deletions packages/@react-aria/calendar/src/useCalendarGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@
* governing permissions and limitations under the License.
*/

import {CalendarDate, startOfWeek, today} from '@internationalized/date';
import {CalendarDate, getWeekStart, startOfWeek, today} from '@internationalized/date';
import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
import {DOMAttributes} from '@react-types/shared';
import {hookData, useVisibleRangeDescription} from './utils';
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,11 @@ 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.
*/
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
}

export interface CalendarGridAria {
Expand All @@ -56,7 +70,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
} = props;

let {direction} = useLocale();
Expand Down Expand Up @@ -137,13 +152,14 @@ 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 offset = firstDayOfWeek ? (DAY_MAP[firstDayOfWeek] - getWeekStart(locale) + 7) % 7 : 0;
let weekStart = startOfWeek(today(state.timeZone), locale).add({days: offset});
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>
);
}
38 changes: 37 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,8 @@

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 {I18nProvider} from '@react-aria/i18n';
import React from 'react';
import userEvent from '@testing-library/user-event';

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

async function testFirstDayOfWeek(defaultValue, firstDayOfWeek, expectedFirstDay, locale = 'en-US') {
let {getAllByRole, unmount} = render(
<I18nProvider locale={locale}>
<ExampleCustomFirstDay defaultValue={defaultValue} firstDayOfWeek={firstDayOfWeek} />
</I18nProvider>
);
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 +239,28 @@ describe('useCalendar', () => {
await testPagination(defaultValue, rangeBefore, rangeAfter, rel, count, visibleDuration, pageBehavior);
});
});

describe('firstDayOfWeek', () => {
it.each`
Name | defaultValue | firstDayOfWeek | expectedFirstDay | locale
${'default'} | ${new CalendarDate(2024, 1, 1)} | ${undefined} | ${'Sunday, December 31, 2023'} | ${'en-US'}
${'Sunday'} | ${new CalendarDate(2024, 1, 1)} | ${'sun'} | ${'Sunday, December 31, 2023'} | ${'en-US'}
${'Monday'} | ${new CalendarDate(2024, 1, 1)} | ${'mon'} | ${'Monday, December 25, 2023'} | ${'en-US'}
Copy link
Member Author

Choose a reason for hiding this comment

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

This doesn't seem correct, it should be Jan 1. I'm seeing the correct behavior in storybook though. Investigating.

Copy link
Member

@snowystinger snowystinger Nov 27, 2024

Choose a reason for hiding this comment

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

maybe need a newer Node version? it passed for me locally in v18.20.3
or are you saying the test itself is wrong right now?

${'Tuesday'} | ${new CalendarDate(2024, 1, 1)} | ${'tue'} | ${'Tuesday, December 26, 2023'} | ${'en-US'}
${'Wednesday'} | ${new CalendarDate(2024, 1, 1)} | ${'wed'} | ${'Wednesday, December 27, 2023'} | ${'en-US'}
${'Thursday'} | ${new CalendarDate(2024, 1, 1)} | ${'thu'} | ${'Thursday, December 28, 2023'} | ${'en-US'}
${'Friday'} | ${new CalendarDate(2024, 1, 1)} | ${'fri'} | ${'Friday, December 29, 2023'} | ${'en-US'}
${'Saturday'} | ${new CalendarDate(2024, 1, 1)} | ${'sat'} | ${'Saturday, December 30, 2023'} | ${'en-US'}
${'default (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${undefined} | ${'lundi 1 janvier 2024 sélectionné'} | ${'fr-FR'}
${'Sunday (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${'sun'} | ${'dimanche 31 décembre 2023'} | ${'fr-FR'}
${'Monday (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${'mon'} | ${'lundi 1 janvier 2024 sélectionné'} | ${'fr-FR'}
${'Tuesday (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${'tue'} | ${'mardi 26 décembre 2023'} | ${'fr-FR'}
${'Wednesday (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${'wed'} | ${'mercredi 27 décembre 2023'} | ${'fr-FR'}
${'Thursday (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${'thu'} | ${'jeudi 28 décembre 2023'} | ${'fr-FR'}
${'Friday (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${'fri'} | ${'vendredi 29 décembre 2023'} | ${'fr-FR'}
${'Saturday (fr-FR)'} | ${new CalendarDate(2024, 1, 1)} | ${'sat'} | ${'samedi 30 décembre 2023'} | ${'fr-FR'}
`('should use firstDayOfWeek $Name', async ({defaultValue, firstDayOfWeek, expectedFirstDay, locale}) => {
await testFirstDayOfWeek(defaultValue, firstDayOfWeek, expectedFirstDay, locale);
});
});
});
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 automatically set based on the current locale. 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 automatically set based on the current locale. 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 automatically set based on the current locale. 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 automatically set based on the current locale. 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
} = 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) {
reidbarber marked this conversation as resolved.
Show resolved Hide resolved
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
Loading