Skip to content

Commit

Permalink
feat(Calendar): Add firstDayOfWeek prop (#7363)
Browse files Browse the repository at this point in the history
* add firstDayOfWeek prop

* pass to DatePicker/DateRangePicker

* add storybook controls

* add to RAC

* add docs

* add v3 tests

* add hook tests

* lint

* add chromatic stories

* update v3 RangeCalendar styles for first and last day of week

* lint

* lint

* use number instead of enum for type

* update type to enum

* update tests

* fix v3 cell style

* use lowercase and update docs

* update to use firstDayOfWeek day regardless of locale

* lint

* fix cell style

* more tests

* update test

* update getDatesInWeek logic

* fix test

* fix null dates

* fix minimum dates case

* add offset logic to startOfWeek

* lint

* pass firstDayOfWeek into getWeeksInMonth

* add firstDayOfWeek param to endOfWeek

* add firstDayOfWeek to getDayOfWeek

* pass firstDayOfWeek to getDayOfWeek

* slight simplification and add tests

* add docs

---------

Co-authored-by: Devon Govett <devongovett@gmail.com>
  • Loading branch information
reidbarber and devongovett authored Jan 9, 2025
1 parent d87cc44 commit 82a4de5
Show file tree
Hide file tree
Showing 35 changed files with 484 additions and 58 deletions.
18 changes: 18 additions & 0 deletions packages/@internationalized/date/docs/CalendarDate.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@ startOfWeek(date, 'en-US'); // 2022-01-30
startOfWeek(date, 'fr-FR'); // 2022-01-31
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
startOfWeek(date, 'en-US', 'mon'); // 2022-01-31
```

### Day of week

The <TypeLink links={docs.links} type={docs.exports.getDayOfWeek} /> function returns the day of the week for the given date and locale. Days are numbered from zero to six, where zero is the first day of the week in the given locale. For example, in the United States, the first day of the week is Sunday, but in France it is Monday.
Expand All @@ -328,6 +334,12 @@ getDayOfWeek(date, 'en-US'); // 0
getDayOfWeek(date, 'fr-FR'); // 6
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
getDayOfWeek(date, 'en-US', 'mon'); // 6
```

### Weekdays and weekends

The <TypeLink links={docs.links} type={docs.exports.isWeekday} /> and <TypeLink links={docs.links} type={docs.exports.isWeekend} /> functions can be used to determine if a date is weekday or weekend respectively. This depends on the locale. For example, in the United States, weekends are Saturday and Sunday, but in Israel they are Friday and Saturday.
Expand Down Expand Up @@ -356,3 +368,9 @@ let date = new CalendarDate(2021, 1, 1);
getWeeksInMonth(date, 'en-US'); // 6
getWeeksInMonth(date, 'fr-FR'); // 5
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
getWeeksInMonth(date, 'en-US', 'mon'); // 5
```
18 changes: 18 additions & 0 deletions packages/@internationalized/date/docs/CalendarDateTime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@ startOfWeek(date, 'en-US'); // 2022-01-30T09:45
startOfWeek(date, 'fr-FR'); // 2022-01-31T09:45
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
startOfWeek(date, 'en-US', 'mon'); // 2022-01-31T09:45
```

### Day of week

The <TypeLink links={docs.links} type={docs.exports.getDayOfWeek} /> function returns the day of the week for the given date and locale. Days are numbered from zero to six, where zero is the first day of the week in the given locale. For example, in the United States, the first day of the week is Sunday, but in France it is Monday.
Expand All @@ -380,6 +386,12 @@ getDayOfWeek(date, 'en-US'); // 0
getDayOfWeek(date, 'fr-FR'); // 6
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
getDayOfWeek(date, 'en-US', 'mon'); // 6
```

### Weekdays and weekends

The <TypeLink links={docs.links} type={docs.exports.isWeekday} /> and <TypeLink links={docs.links} type={docs.exports.isWeekend} /> functions can be used to determine if a date is weekday or weekend respectively. This depends on the locale. For example, in the United States, weekends are Saturday and Sunday, but in Israel they are Friday and Saturday.
Expand Down Expand Up @@ -408,3 +420,9 @@ let date = new CalendarDateTime(2021, 1, 1, 8, 30);
getWeeksInMonth(date, 'en-US'); // 6
getWeeksInMonth(date, 'fr-FR'); // 5
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
getWeeksInMonth(date, 'en-US', 'mon'); // 5
```
18 changes: 18 additions & 0 deletions packages/@internationalized/date/docs/ZonedDateTime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,12 @@ startOfWeek(date, 'en-US'); // 2022-01-30T09:45[America/Los_Angeles]
startOfWeek(date, 'fr-FR'); // 2022-01-31T09:45[America/Los_Angeles]
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
startOfWeek(date, 'en-US', 'mon'); // 2022-01-31T09:45[America/Los_Angeles]
```

### Day of week

The <TypeLink links={docs.links} type={docs.exports.getDayOfWeek} /> function returns the day of the week for the given date and locale. Days are numbered from zero to six, where zero is the first day of the week in the given locale. For example, in the United States, the first day of the week is Sunday, but in France it is Monday.
Expand All @@ -488,6 +494,12 @@ getDayOfWeek(date, 'en-US'); // 0
getDayOfWeek(locale, 'fr-FR'); // 6
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
getDayOfWeek(date, 'en-US', 'mon'); // 6
```

### Weekdays and weekends

The <TypeLink links={docs.links} type={docs.exports.isWeekday} /> and <TypeLink links={docs.links} type={docs.exports.isWeekend} /> functions can be used to determine if a date is weekday or weekend respectively. This depends on the locale. For example, in the United States, weekends are Saturday and Sunday, but in Israel they are Friday and Saturday.
Expand Down Expand Up @@ -516,3 +528,9 @@ let date = parseZonedDateTime('2023-01-01T08:30[America/Los_Angeles]');
getWeeksInMonth(date, 'en-US'); // 5
getWeeksInMonth(date, 'fr-FR'); // 6
```

You can also provide an optional `firstDayOfWeek` argument to override the default first day of the week determined by the locale. It accepts a week day abbreviation, e.g. `sun`, `mon`, `tue`, etc.

```tsx
getWeeksInMonth(date, 'en-US', 'mon'); // 6
```
45 changes: 29 additions & 16 deletions packages/@internationalized/date/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,30 @@ export function isToday(date: DateValue, timeZone: string): boolean {
return isSameDay(date, today(timeZone));
}

const DAY_MAP = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6
};

type DayOfWeek = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';

/**
* Returns the day of week for the given date and locale. Days are numbered from zero to six,
* where zero is the first day of the week in the given locale. For example, in the United States,
* the first day of the week is Sunday, but in France it is Monday.
*/
export function getDayOfWeek(date: DateValue, locale: string): number {
export function getDayOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): number {
let julian = date.calendar.toJulianDay(date);

// If julian is negative, then julian % 7 will be negative, so we adjust
// accordingly. Julian day 0 is Monday.
let dayOfWeek = Math.ceil(julian + 1 - getWeekStart(locale)) % 7;
let weekStart = firstDayOfWeek ? DAY_MAP[firstDayOfWeek] : getWeekStart(locale);
let dayOfWeek = Math.ceil(julian + 1 - weekStart) % 7;
if (dayOfWeek < 0) {
dayOfWeek += 7;
}
Expand Down Expand Up @@ -181,22 +194,22 @@ export function getMinimumDayInMonth(date: AnyCalendarDate) {
}

/** Returns the first date of the week for the given date and locale. */
export function startOfWeek(date: ZonedDateTime, locale: string): ZonedDateTime;
export function startOfWeek(date: CalendarDateTime, locale: string): CalendarDateTime;
export function startOfWeek(date: CalendarDate, locale: string): CalendarDate;
export function startOfWeek(date: DateValue, locale: string): DateValue;
export function startOfWeek(date: DateValue, locale: string): DateValue {
let dayOfWeek = getDayOfWeek(date, locale);
export function startOfWeek(date: ZonedDateTime, locale: string, firstDayOfWeek?: DayOfWeek): ZonedDateTime;
export function startOfWeek(date: CalendarDateTime, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDateTime;
export function startOfWeek(date: CalendarDate, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDate;
export function startOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
export function startOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue {
let dayOfWeek = getDayOfWeek(date, locale, firstDayOfWeek);
return date.subtract({days: dayOfWeek});
}

/** Returns the last date of the week for the given date and locale. */
export function endOfWeek(date: ZonedDateTime, locale: string): ZonedDateTime;
export function endOfWeek(date: CalendarDateTime, locale: string): CalendarDateTime;
export function endOfWeek(date: CalendarDate, locale: string): CalendarDate;
export function endOfWeek(date: DateValue, locale: string): DateValue;
export function endOfWeek(date: DateValue, locale: string): DateValue {
return startOfWeek(date, locale).add({days: 6});
export function endOfWeek(date: ZonedDateTime, locale: string, firstDayOfWeek?: DayOfWeek): ZonedDateTime;
export function endOfWeek(date: CalendarDateTime, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDateTime;
export function endOfWeek(date: CalendarDate, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDate;
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue {
return startOfWeek(date, locale, firstDayOfWeek).add({days: 6});
}

const cachedRegions = new Map<string, string>();
Expand Down Expand Up @@ -233,9 +246,9 @@ function getWeekStart(locale: string): number {
}

/** Returns the number of weeks in the given month and locale. */
export function getWeeksInMonth(date: DateValue, locale: string): number {
export function getWeeksInMonth(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): number {
let days = date.calendar.getDaysInMonth(date);
return Math.ceil((getDayOfWeek(startOfMonth(date), locale) + days) / 7);
return Math.ceil((getDayOfWeek(startOfMonth(date), locale, firstDayOfWeek) + days) / 7);
}

/** Returns the lesser of the two provider dates. */
Expand Down
30 changes: 28 additions & 2 deletions packages/@internationalized/date/tests/queries.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@ describe('queries', function () {
it('should return the day of week in fr', function () {
expect(getDayOfWeek(new CalendarDate(2021, 8, 4), 'fr')).toBe(2);
});

it('should return the day of the week with a custom firstDayOfWeek', function () {
expect(getDayOfWeek(new CalendarDate(2021, 8, 4), 'en-US', 'mon')).toBe(2);
expect(getDayOfWeek(new CalendarDate(2021, 8, 4), 'en-US', 'tue')).toBe(1);
expect(getDayOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR', 'mon')).toBe(2);
expect(getDayOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR', 'tue')).toBe(1);
});
});

describe('startOfWeek', function () {
Expand All @@ -256,16 +263,28 @@ describe('queries', function () {
it('should return the start of week in fr-FR', function () {
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR')).toEqual(new CalendarDate(2021, 8, 2));
});

it('should return the start of the week with a custom firstDayOfWeek', function () {
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'en-US', 'mon')).toEqual(new CalendarDate(2021, 8, 2));
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'en-US', 'tue')).toEqual(new CalendarDate(2021, 8, 3));
expect(startOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR', 'sun')).toEqual(new CalendarDate(2021, 8, 1));
});
});

describe('endOfWeek', function () {
it('should return the start of week in en-US', function () {
it('should return the end of week in en-US', function () {
expect(endOfWeek(new CalendarDate(2021, 8, 4), 'en-US')).toEqual(new CalendarDate(2021, 8, 7));
});

it('should return the start of week in fr-FR', function () {
it('should return the end of week in fr-FR', function () {
expect(endOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR')).toEqual(new CalendarDate(2021, 8, 8));
});

it('should return the end of the week with a custom firstDayOfWeek', function () {
expect(endOfWeek(new CalendarDate(2021, 8, 4), 'en-US', 'mon')).toEqual(new CalendarDate(2021, 8, 8));
expect(endOfWeek(new CalendarDate(2021, 8, 4), 'en-US', 'tue')).toEqual(new CalendarDate(2021, 8, 9));
expect(endOfWeek(new CalendarDate(2021, 8, 4), 'fr-FR', 'sun')).toEqual(new CalendarDate(2021, 8, 7));
});
});

describe('getWeeksInMonth', function () {
Expand All @@ -280,6 +299,13 @@ describe('queries', function () {
it('should work for other calendars', function () {
expect(getWeeksInMonth(new CalendarDate(new EthiopicCalendar(), 2013, 13, 4), 'en-US')).toBe(1);
});

it('should support custom firstDayOfWeek', function () {
expect(getWeeksInMonth(new CalendarDate(2021, 8, 4), 'en-US', 'sun')).toBe(5);
expect(getWeeksInMonth(new CalendarDate(2021, 8, 4), 'en-US', 'mon')).toBe(6);
expect(getWeeksInMonth(new CalendarDate(2021, 10, 4), 'en-US', 'sun')).toBe(6);
expect(getWeeksInMonth(new CalendarDate(2021, 10, 4), 'en-US', 'mon')).toBe(5);
});
});

describe('getMinimumMonthInYear', function () {
Expand Down
12 changes: 10 additions & 2 deletions 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 All @@ -152,7 +152,7 @@ function CalendarGrid({state, ...props}) {
let {gridProps, headerProps, weekDays} = useCalendarGrid(props, state);

// Get the number of weeks in the month so we can render the proper number of rows.
let weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale);
let weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale, props.firstDayOfWeek);

return (
<table {...gridProps}>
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
12 changes: 10 additions & 2 deletions 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 All @@ -152,7 +152,7 @@ function CalendarGrid({state, ...props}) {
let {gridProps, headerProps, weekDays} = useCalendarGrid(props, state);

// Get the number of weeks in the month so we can render the proper number of rows.
let weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale);
let weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale, props.firstDayOfWeek);

return (
<table {...gridProps}>
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
13 changes: 9 additions & 4 deletions packages/@react-aria/calendar/src/useCalendarGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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 +60,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 +142,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, 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
Loading

1 comment on commit 82a4de5

@rspbot
Copy link

@rspbot rspbot commented on 82a4de5 Jan 9, 2025

Choose a reason for hiding this comment

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

Please sign in to comment.