diff --git a/src/Calendar.jsx b/src/Calendar.jsx
index a1bf3d41..3a7b4086 100644
--- a/src/Calendar.jsx
+++ b/src/Calendar.jsx
@@ -1,12 +1,13 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
import clsx from 'clsx';
+import PropTypes from 'prop-types';
+import React, { Component, createRef } from 'react';
import Navigation from './Calendar/Navigation';
import CenturyView from './CenturyView';
import DecadeView from './DecadeView';
-import YearView from './YearView';
+import FocusContainer, { FocusContext } from './FocusContainer';
import MonthView from './MonthView';
+import YearView from './YearView';
import { getBegin, getBeginNext, getEnd, getValueRange } from './shared/dates';
import {
@@ -252,6 +253,8 @@ export default class Calendar extends Component {
view: this.props.defaultView,
};
+ containerRef = createRef(null);
+
get activeStartDate() {
const { activeStartDate: activeStartDateProps } = this.props;
const { activeStartDate: activeStartDateState } = this.state;
@@ -555,7 +558,7 @@ export default class Calendar extends Component {
this.setState({ hover: null });
};
- renderContent(next) {
+ renderContent(activeTabDate, next) {
const { activeStartDate: currentActiveStartDate, onMouseOver, valueType, value, view } = this;
const {
calendarType,
@@ -577,6 +580,7 @@ export default class Calendar extends Component {
const commonProps = {
activeStartDate,
+ activeTabDate,
hover,
locale,
maxDate,
@@ -704,8 +708,8 @@ export default class Calendar extends Component {
}
render() {
- const { className, inputRef, selectRange, showDoubleView } = this.props;
- const { onMouseLeave, value } = this;
+ const { className, inputRef, maxDate, minDate, selectRange, showDoubleView } = this.props;
+ const { onMouseLeave, value, view, activeStartDate, setActiveStartDate } = this;
const valueArray = [].concat(value);
return (
@@ -719,14 +723,32 @@ export default class Calendar extends Component {
ref={inputRef}
>
{this.renderNavigation()}
-
- {this.renderContent()}
- {showDoubleView ? this.renderContent(true) : null}
-
+
+
+ {({ activeTabDate }) => (
+ <>
+ {this.renderContent(activeTabDate)}
+ {showDoubleView ? this.renderContent(activeTabDate, true) : null}
+ >
+ )}
+
+
+
);
}
diff --git a/src/Calendar/Navigation.jsx b/src/Calendar/Navigation.jsx
index 2357fb7c..df982eec 100644
--- a/src/Calendar/Navigation.jsx
+++ b/src/Calendar/Navigation.jsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { getUserLocale } from 'get-user-locale';
@@ -76,6 +76,14 @@ export default function Navigation({
const next2ButtonDisabled =
shouldShowPrevNext2Buttons && maxDate && maxDate < nextActiveStartDate2;
+ // Make sure the navigation is not navigable at the first render
+ // so that the calendar takes the initial focus.
+ const [tabIndex, setTabIndex] = useState(-1);
+ useEffect(() => {
+ setTabIndex(-1);
+ setTimeout(() => setTabIndex(0), 0);
+ }, [view]);
+
function onClickPrevious() {
setActiveStartDate(previousActiveStartDate, 'prev');
}
@@ -128,6 +136,7 @@ export default function Navigation({
disabled={!drillUpAvailable}
onClick={drillUp}
style={{ flexGrow: 1 }}
+ tabIndex={tabIndex}
type="button"
>
@@ -153,6 +162,7 @@ export default function Navigation({
className={`${className}__arrow ${className}__prev2-button`}
disabled={prev2ButtonDisabled}
onClick={onClickPrevious2}
+ tabIndex={tabIndex}
type="button"
>
{prev2Label}
@@ -164,6 +174,7 @@ export default function Navigation({
className={`${className}__arrow ${className}__prev-button`}
disabled={prevButtonDisabled}
onClick={onClickPrevious}
+ tabIndex={tabIndex}
type="button"
>
{prevLabel}
@@ -176,6 +187,7 @@ export default function Navigation({
className={`${className}__arrow ${className}__next-button`}
disabled={nextButtonDisabled}
onClick={onClickNext}
+ tabIndex={tabIndex}
type="button"
>
{nextLabel}
@@ -187,6 +199,7 @@ export default function Navigation({
className={`${className}__arrow ${className}__next2-button`}
disabled={next2ButtonDisabled}
onClick={onClickNext2}
+ tabIndex={tabIndex}
type="button"
>
{next2Label}
diff --git a/src/CenturyView.spec.jsx b/src/CenturyView.spec.jsx
index 5775859f..5f757a4b 100644
--- a/src/CenturyView.spec.jsx
+++ b/src/CenturyView.spec.jsx
@@ -8,6 +8,7 @@ import CenturyView from './CenturyView';
describe('CenturyView', () => {
const defaultProps = {
activeStartDate: new Date(2017, 0, 1),
+ activeTabDate: new Date(2017, 0, 1),
};
it('renders proper view when given activeStartDate', () => {
diff --git a/src/CenturyView/Decade.jsx b/src/CenturyView/Decade.jsx
index 7c494b70..0d182eed 100644
--- a/src/CenturyView/Decade.jsx
+++ b/src/CenturyView/Decade.jsx
@@ -10,13 +10,19 @@ import { tileProps } from '../shared/propTypes';
const className = 'react-calendar__century-view__decades__decade';
-export default function Decade({ classes, formatYear = defaultFormatYear, ...otherProps }) {
+export default function Decade({
+ activeTabDate,
+ classes,
+ formatYear = defaultFormatYear,
+ ...otherProps
+}) {
const { date, locale } = otherProps;
return (
= getDecadeStart(date)}
maxDateTransform={getDecadeEnd}
minDateTransform={getDecadeStart}
view="century"
diff --git a/src/DecadeView.spec.jsx b/src/DecadeView.spec.jsx
index 93722796..e7c0bbcf 100644
--- a/src/DecadeView.spec.jsx
+++ b/src/DecadeView.spec.jsx
@@ -7,6 +7,7 @@ import DecadeView from './DecadeView';
describe('DecadeView', () => {
const defaultProps = {
activeStartDate: new Date(2017, 0, 1),
+ activeTabDate: new Date(2017, 0, 1),
};
it('renders proper view when given activeStartDate', () => {
diff --git a/src/DecadeView/Year.jsx b/src/DecadeView/Year.jsx
index 047cd005..64aec659 100644
--- a/src/DecadeView/Year.jsx
+++ b/src/DecadeView/Year.jsx
@@ -9,13 +9,19 @@ import { tileProps } from '../shared/propTypes';
const className = 'react-calendar__decade-view__years__year';
-export default function Year({ classes, formatYear = defaultFormatYear, ...otherProps }) {
+export default function Year({
+ activeTabDate,
+ classes,
+ formatYear = defaultFormatYear,
+ ...otherProps
+}) {
const { date, locale } = otherProps;
return (
- {React.Children.map(children, (child, index) =>
- React.cloneElement(child, {
- ...child.props,
- style: {
- flexBasis: toPercent(100 / count),
- flexShrink: 0,
- flexGrow: 0,
- overflow: 'hidden',
- marginLeft: offset && index === 0 ? toPercent((100 * offset) / count) : null,
- },
- }),
- )}
-
- );
-}
+const Flex = forwardRef(
+ ({ children, className, count, direction, offset, style, wrap, ...otherProps }, ref) => {
+ return (
+
+ {React.Children.map(children, (child, index) =>
+ React.cloneElement(child, {
+ ...child.props,
+ style: {
+ flexBasis: toPercent(100 / count),
+ flexShrink: 0,
+ flexGrow: 0,
+ overflow: 'hidden',
+ marginLeft: offset && index === 0 ? toPercent((100 * offset) / count) : null,
+ },
+ }),
+ )}
+
+ );
+ },
+);
+Flex.displayName = 'Flex';
Flex.propTypes = {
children: PropTypes.node,
@@ -51,3 +46,5 @@ Flex.propTypes = {
style: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
wrap: PropTypes.bool,
};
+
+export default Flex;
diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx
new file mode 100644
index 00000000..7f79aa81
--- /dev/null
+++ b/src/FocusContainer.jsx
@@ -0,0 +1,263 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { getBeginNext, getBeginPrevious, getEndPrevious } from './shared/dates';
+import { isMaxDate, isMinDate, isValue, isView } from './shared/propTypes';
+
+const DefaultFocusContext = {
+ activeTabDate: new Date(),
+ setActiveTabDate: () => null,
+};
+
+export const FocusContext = React.createContext(DefaultFocusContext);
+FocusContext.displayName = 'FocusContext';
+
+function clearTimeFromDate(date) {
+ if (date !== null && date !== undefined) {
+ return new Date(new Date(date).toDateString());
+ } else {
+ return date;
+ }
+}
+
+const getTopRowYearOffset = (year) => {
+ const yearDigit = `${year}`.slice(-1);
+
+ if (yearDigit === '1') {
+ return 1;
+ } else if (yearDigit === '2' || yearDigit === '3') {
+ return 4;
+ } else {
+ return 3;
+ }
+};
+
+const getBottomRowYearOffset = (year) => {
+ const yearDigit = `${year}`.slice(-1);
+
+ if (yearDigit === '0') {
+ return 1;
+ } else {
+ return 3;
+ }
+};
+
+const getTopRowDecadeOffset = (year) => {
+ const decadeDigit = `${year}`.slice(-2, -1);
+
+ if (decadeDigit === '0') {
+ return 10;
+ } else if (decadeDigit === '1' || decadeDigit === '2') {
+ return 40;
+ } else {
+ return 30;
+ }
+};
+
+const getBottomRowDecaderOffset = (year) => {
+ const decadeDigit = `${year}`.slice(-2, -1);
+
+ if (decadeDigit === '9') {
+ return 10;
+ } else if (decadeDigit === '7' || decadeDigit === '8') {
+ return 40;
+ } else {
+ return 30;
+ }
+};
+
+export default function FocusContainer({
+ activeStartDate,
+ children,
+ containerRef,
+ maxDate,
+ minDate,
+ setActiveStartDate,
+ showDoubleView,
+ value,
+ view,
+}) {
+ const currentValue = Array.isArray(value) ? value[0] : value;
+ const [activeTabDateState, setActiveTabDateState] = useState(
+ clearTimeFromDate(currentValue) ?? activeStartDate,
+ );
+ const activeTabeDateRef = useRef(activeTabDateState);
+ const shouldSetFocusRef = useRef(false);
+
+ const setActiveTabDateAndFocus = useCallback((date) => {
+ shouldSetFocusRef.current = true;
+ setActiveTabDateState(date);
+ }, []);
+
+ // Move the focus to the current focusable element
+ useEffect(() => {
+ // We are applying the focus async, to ensure that the calendar view
+ // was already updated
+ setTimeout(() => {
+ const focusableElement = containerRef.current?.querySelector('[tabindex="0"]');
+ if (shouldSetFocusRef.current) {
+ shouldSetFocusRef.current = false;
+ focusableElement?.focus();
+ }
+ }, 0);
+ }, [activeTabDateState, containerRef]);
+
+ // Using a ref in the below `useEffect`, rather than the actual `activeTabDate` value
+ // prevents the `useEffect` from firing every time the `activeTabDate` changes.
+ useEffect(() => {
+ activeTabeDateRef.current = activeTabDateState;
+ }, [activeTabDateState]);
+
+ // Set the focusable element to the active start date when the
+ // active start date changes and the previous focusable element goes
+ // out of view (e.g. when using the navigation buttons)
+ useEffect(() => {
+ const beginNext = getBeginNext(view, activeStartDate);
+ const endPrevious = getEndPrevious(view, activeStartDate);
+
+ if (
+ activeTabeDateRef.current <= endPrevious ||
+ (!showDoubleView && activeTabeDateRef.current >= beginNext) ||
+ (showDoubleView && activeTabeDateRef.current >= getBeginNext(view, beginNext))
+ ) {
+ setActiveTabDateState(activeStartDate);
+ }
+ }, [view, activeStartDate, activeTabeDateRef, showDoubleView]);
+
+ // Handle arrow keyboard interactions by moving the focusable element around
+ useEffect(() => {
+ const handleKeyPress = (event) => {
+ // Only handle keyboard events when we're focused within the calendar grid
+ if (!containerRef.current?.contains(document.activeElement)) {
+ return;
+ }
+
+ const nextTabDate = new Date(activeTabDateState);
+
+ if (
+ event.key === 'ArrowUp' ||
+ event.key === 'ArrowDown' ||
+ event.key === 'ArrowRight' ||
+ event.key === 'ArrowLeft'
+ ) {
+ event.preventDefault();
+ }
+
+ switch (true) {
+ case event.key === 'ArrowUp' && view === 'month':
+ nextTabDate.setDate(activeTabDateState.getDate() - 7);
+ break;
+ case event.key === 'ArrowUp' && view === 'year':
+ nextTabDate.setMonth(activeTabDateState.getMonth() - 3);
+ break;
+ case event.key === 'ArrowUp' && view === 'decade':
+ nextTabDate.setFullYear(
+ activeTabDateState.getFullYear() -
+ getTopRowYearOffset(activeTabDateState.getFullYear()),
+ );
+ break;
+ case event.key === 'ArrowUp' && view === 'century':
+ nextTabDate.setFullYear(
+ activeTabDateState.getFullYear() -
+ getTopRowDecadeOffset(activeTabDateState.getFullYear()),
+ );
+ break;
+ case event.key === 'ArrowDown' && view === 'month':
+ nextTabDate.setDate(activeTabDateState.getDate() + 7);
+ break;
+ case event.key === 'ArrowDown' && view === 'year':
+ nextTabDate.setMonth(activeTabDateState.getMonth() + 3);
+ break;
+ case event.key === 'ArrowDown' && view === 'decade':
+ nextTabDate.setFullYear(
+ activeTabDateState.getFullYear() +
+ getBottomRowYearOffset(activeTabDateState.getFullYear()),
+ );
+ break;
+ case event.key === 'ArrowDown' && view === 'century':
+ nextTabDate.setFullYear(
+ activeTabDateState.getFullYear() +
+ getBottomRowDecaderOffset(activeTabDateState.getFullYear()),
+ );
+ break;
+ case event.key === 'ArrowLeft' && view === 'month':
+ nextTabDate.setDate(activeTabDateState.getDate() - 1);
+ break;
+ case event.key === 'ArrowLeft' && view === 'year':
+ nextTabDate.setMonth(activeTabDateState.getMonth() - 1);
+ break;
+ case event.key === 'ArrowLeft' && view === 'decade':
+ nextTabDate.setFullYear(activeTabDateState.getFullYear() - 1);
+ break;
+ case event.key === 'ArrowLeft' && view === 'century':
+ nextTabDate.setFullYear(activeTabDateState.getFullYear() - 10);
+ break;
+ case event.key === 'ArrowRight' && view === 'month':
+ nextTabDate.setDate(activeTabDateState.getDate() + 1);
+ break;
+ case event.key === 'ArrowRight' && view === 'year':
+ nextTabDate.setMonth(activeTabDateState.getMonth() + 1);
+ break;
+ case event.key === 'ArrowRight' && view === 'decade':
+ nextTabDate.setFullYear(activeTabDateState.getFullYear() + 1);
+ break;
+ case event.key === 'ArrowRight' && view === 'century':
+ nextTabDate.setFullYear(activeTabDateState.getFullYear() + 10);
+ break;
+ default:
+ break;
+ }
+
+ // If the focusable element is unchanged, exit
+ if (nextTabDate.getTime() === activeTabDateState.getTime()) {
+ return;
+ }
+
+ // If the focusable element is outside the allowable bounds, exit
+ if (nextTabDate.getTime() > maxDate.getTime() || nextTabDate.getTime() < minDate.getTime()) {
+ return;
+ }
+
+ setActiveTabDateAndFocus(nextTabDate);
+
+ // If the new focusable element is out of view, adjust the view
+ // by changing the active start date
+ const beginNext = getBeginNext(view, activeStartDate);
+ if (nextTabDate < activeStartDate) {
+ setActiveStartDate(getBeginPrevious(view, activeStartDate));
+ } else if (
+ (!showDoubleView && nextTabDate >= beginNext) ||
+ (showDoubleView && nextTabDate >= getBeginNext(view, beginNext))
+ ) {
+ setActiveStartDate(beginNext);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyPress);
+ return () => {
+ window.removeEventListener('keydown', handleKeyPress);
+ };
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+FocusContainer.propTypes = {
+ activeStartDate: PropTypes.instanceOf(Date).isRequired,
+ children: PropTypes.node.isRequired,
+ containerRef: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
+ ]),
+ maxDate: isMaxDate,
+ minDate: isMinDate,
+ setActiveStartDate: PropTypes.func.isRequired,
+ showDoubleView: PropTypes.bool,
+ value: PropTypes.oneOfType([PropTypes.string, isValue]),
+ view: isView.isRequired,
+};
diff --git a/src/FocusContainer.spec.jsx b/src/FocusContainer.spec.jsx
new file mode 100644
index 00000000..1e96abb1
--- /dev/null
+++ b/src/FocusContainer.spec.jsx
@@ -0,0 +1,661 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import Calendar from './Calendar';
+
+describe('FocusContainer', () => {
+ const defaultMonthProps = {
+ defaultValue: new Date('March 09, 2023'),
+ defaultActiveStartDate: new Date('March 01, 2023'),
+ view: 'month',
+ };
+
+ const defaultYearProps = {
+ defaultValue: new Date('May 01, 2023'),
+ defaultActiveStartDate: new Date('January 01, 2023'),
+ view: 'year',
+ };
+
+ const defaultDecadeProps = {
+ defaultValue: new Date('January 01, 2025'),
+ defaultActiveStartDate: new Date('January 01, 2021'),
+ view: 'decade',
+ };
+
+ const defaultCenturyProps = {
+ defaultValue: new Date('January 01, 2041'),
+ defaultActiveStartDate: new Date('January 01, 2001'),
+ view: 'century',
+ };
+
+ const renderCalendar = (props) => {
+ return render();
+ };
+
+ it('Should focus the activeTabDate when grid receives focus', () => {
+ renderCalendar(defaultMonthProps);
+
+ screen.getByRole('grid').focus();
+ expect(document.activeElement.textContent).toBe('9');
+ });
+
+ it('Should return focus to the activeTabDate if it has changed, when focus returns to the grid', async () => {
+ renderCalendar(defaultMonthProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('10'));
+ expect(document.activeElement.textContent).toBe('10');
+
+ document.activeElement.blur();
+ await waitFor(() => expect(document.activeElement).toBe(document.body));
+
+ screen.getByRole('grid').focus();
+ await waitFor(() => expect(document.activeElement.textContent).toBe('10'));
+ });
+
+ describe('keyboard navigation', () => {
+ describe('arrowRight', () => {
+ describe('monthView', () => {
+ it('moves to the next day', async () => {
+ renderCalendar(defaultMonthProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('10'));
+ });
+
+ it('wraps to the next row, if day is at the end of a row', async () => {
+ renderCalendar({ ...defaultMonthProps, defaultValue: new Date('march 12, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('13'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({ ...defaultMonthProps, maxDate: new Date('March 9, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('9'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowRight' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('8'));
+ });
+ });
+
+ describe('yearView', () => {
+ it('moves to the next month', async () => {
+ renderCalendar(defaultYearProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('June'));
+ });
+
+ it('wraps to the next row, if month is at the end of a row', async () => {
+ renderCalendar({
+ ...defaultYearProps,
+ defaultValue: new Date('March 01, 2023'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('April'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({
+ ...defaultYearProps,
+ maxDate: new Date('May 31, 2023'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('May'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowRight' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('April'));
+ });
+ });
+
+ describe('decadeView', () => {
+ it('moves to the next month', async () => {
+ renderCalendar(defaultDecadeProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2026'));
+ });
+
+ it('wraps to the next row, if year is at the end of a row', async () => {
+ renderCalendar({
+ ...defaultDecadeProps,
+ defaultValue: new Date('January 01, 2023'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2024'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({
+ ...defaultDecadeProps,
+ maxDate: new Date('November 25, 2025'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2025'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowRight' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2024'));
+ });
+ });
+
+ describe('centuryView', () => {
+ it('moves to the next decade', async () => {
+ renderCalendar(defaultCenturyProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2051 – 2060'));
+ });
+
+ it('wraps to the next row, if decade is at the end of a row', async () => {
+ renderCalendar({
+ ...defaultCenturyProps,
+ defaultValue: new Date('January 01, 2055'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2061 – 2070'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({
+ ...defaultCenturyProps,
+ maxDate: new Date('November 25, 2045'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2041 – 2050'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowRight' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2031 – 2040'));
+ });
+ });
+ });
+
+ describe('arrowLeft', () => {
+ describe('monthView', () => {
+ it('moves to the previous day', async () => {
+ renderCalendar(defaultMonthProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('8'));
+ });
+
+ it('wraps to the previous row, if day is at the start of a row', async () => {
+ renderCalendar({ ...defaultMonthProps, defaultValue: new Date('march 6, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('5'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({ ...defaultMonthProps, minDate: new Date('March 09, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('9'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowLeft' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('10'));
+ });
+ });
+
+ describe('yearView', () => {
+ it('moves to the previous month', async () => {
+ renderCalendar(defaultYearProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('April'));
+ });
+
+ it('wraps to the previous row, if month is at the start of a row', async () => {
+ renderCalendar({ ...defaultYearProps, defaultValue: new Date('April 01, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('March'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({ ...defaultYearProps, minDate: new Date('April 09, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('May'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowLeft' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('June'));
+ });
+ });
+
+ describe('decadeView', () => {
+ it('moves to the previous month', async () => {
+ renderCalendar(defaultDecadeProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2024'));
+ });
+
+ it('wraps to the previous row, if month is at the start of a row', async () => {
+ renderCalendar({ ...defaultDecadeProps, defaultValue: new Date('January 01, 2024') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2023'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({ ...defaultDecadeProps, minDate: new Date('April 09, 2024') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2025'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowLeft' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2026'));
+ });
+ });
+
+ describe('centuryView', () => {
+ it('moves to the previous month', async () => {
+ renderCalendar(defaultCenturyProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2031 – 2040'));
+ });
+
+ it('wraps to the previous row, if decade is at the start of a row', async () => {
+ renderCalendar({ ...defaultCenturyProps, defaultValue: new Date('January 01, 2032') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2021 – 2030'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({ ...defaultCenturyProps, minDate: new Date('April 09, 2035') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowLeft' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2041 – 2050'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowLeft' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowRight' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2051 – 2060'));
+ });
+ });
+ });
+
+ describe('arrowUp', () => {
+ describe('monthView', () => {
+ it('moves to the previous row', async () => {
+ renderCalendar(defaultMonthProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2'));
+ });
+
+ it('wraps to the previous month, if day is in the first row', async () => {
+ renderCalendar({ ...defaultMonthProps, defaultValue: new Date('march 2, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('23'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({
+ ...defaultMonthProps,
+ defaultValue: new Date('March 2, 2023'),
+ minDate: new Date('February 24, 2023'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowUp' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('9'));
+ });
+ });
+
+ describe('yearView', () => {
+ it('moves to the previous row', async () => {
+ renderCalendar(defaultYearProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('February'));
+ });
+
+ it('wraps to the previous year, if month is in the first row', async () => {
+ renderCalendar({ ...defaultYearProps, defaultValue: new Date('February 01, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('November'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({
+ ...defaultYearProps,
+ defaultValue: new Date('May 1, 2023'),
+ minDate: new Date('April 20, 2023'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('May'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowUp' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('August'));
+ });
+ });
+
+ describe('decadeView', () => {
+ it('moves to the previous row', async () => {
+ renderCalendar(defaultDecadeProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2022'));
+ });
+
+ it('wraps to the previous decade, if year ends in "1"', async () => {
+ renderCalendar({ ...defaultDecadeProps, defaultValue: new Date('January 01, 2021') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2020'));
+ });
+
+ it('wraps to the previous decade, if year ends in "2"', async () => {
+ renderCalendar({ ...defaultDecadeProps, defaultValue: new Date('January 01, 2022') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2018'));
+ });
+
+ it('wraps to the previous decade, if year ends in "3"', async () => {
+ renderCalendar({ ...defaultDecadeProps, defaultValue: new Date('January 01, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2019'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({
+ ...defaultDecadeProps,
+ defaultValue: new Date('January 1, 2025'),
+ minDate: new Date('April 20, 2024'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2025'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowUp' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2028'));
+ });
+ });
+
+ describe('centuryView', () => {
+ it('moves to the previous row', async () => {
+ renderCalendar(defaultCenturyProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2011 – 2020'));
+ });
+
+ it('wraps to the previous century, if decade is in the 00s', async () => {
+ renderCalendar({ ...defaultCenturyProps, defaultValue: new Date('January 01, 2001') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('1991 – 2000'));
+ });
+
+ it('wraps to the previous century, if decade is in the 10s', async () => {
+ renderCalendar({ ...defaultCenturyProps, defaultValue: new Date('January 01, 2012') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('1971 – 1980'));
+ });
+
+ it('wraps to the previous century, if decade is in the 20s', async () => {
+ renderCalendar({ ...defaultCenturyProps, defaultValue: new Date('January 01, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('1981 – 1990'));
+ });
+
+ it('will not move focus beyond `minDate`', async () => {
+ renderCalendar({
+ ...defaultCenturyProps,
+ defaultValue: new Date('January 1, 2045'),
+ minDate: new Date('April 20, 2045'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2041 – 2050'));
+
+ // The previous expect will return a false positive even if not capped by `minDate`
+ // This additional expect will fail however, if the 'ArrowUp' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2071 – 2080'));
+ });
+ });
+ });
+
+ describe('arrowDown', () => {
+ describe('monthView', () => {
+ it('moves to the next row', async () => {
+ renderCalendar(defaultMonthProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('16'));
+ });
+
+ it('wraps to the next month, if day is in the last row', async () => {
+ renderCalendar({ ...defaultMonthProps, defaultValue: new Date('march 30, 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('6'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({
+ ...defaultMonthProps,
+ defaultValue: new Date('march 30, 2023'),
+ maxDate: new Date('April 5, 2023'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('30'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowDown' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('23'));
+ });
+ });
+
+ describe('yearView', () => {
+ it('moves to the next row', async () => {
+ renderCalendar(defaultYearProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('August'));
+ });
+
+ it('wraps to the next year, if month is in the last row', async () => {
+ renderCalendar({ ...defaultYearProps, defaultValue: new Date('November 01 2023') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('February'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({
+ ...defaultYearProps,
+ defaultValue: new Date('May 01, 2023'),
+ maxDate: new Date('May 5, 2023'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('May'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowDown' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('February'));
+ });
+ });
+
+ describe('decadeView', () => {
+ it('moves to the next row', async () => {
+ renderCalendar(defaultDecadeProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2028'));
+ });
+
+ it('wraps to the next decade, if year is in the last row', async () => {
+ renderCalendar({ ...defaultDecadeProps, defaultValue: new Date('January 01 2030') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2031'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({
+ ...defaultDecadeProps,
+ defaultValue: new Date('January 01, 2025'),
+ maxDate: new Date('May 5, 2025'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2025'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowDown' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2022'));
+ });
+ });
+
+ describe('centuryView', () => {
+ it('moves to the next row', async () => {
+ renderCalendar(defaultCenturyProps);
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2071 – 2080'));
+ });
+
+ it('wraps to the next century, if decade is in the 90s', async () => {
+ renderCalendar({ ...defaultCenturyProps, defaultValue: new Date('January 01 2091') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2101 – 2110'));
+ });
+
+ it('wraps to the next century, if decade is in the 80s', async () => {
+ renderCalendar({ ...defaultCenturyProps, defaultValue: new Date('January 01 2083') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2121 – 2130'));
+ });
+
+ it('wraps to the next century, if decade is in the 70s', async () => {
+ renderCalendar({ ...defaultCenturyProps, defaultValue: new Date('January 01 2078') });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2111 – 2120'));
+ });
+
+ it('will not move focus beyond `maxDate`', async () => {
+ renderCalendar({
+ ...defaultCenturyProps,
+ defaultValue: new Date('January 01, 2041'),
+ maxDate: new Date('May 5, 2041'),
+ });
+ screen.getByRole('grid').focus();
+
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowDown' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2041 – 2050'));
+
+ // The previous expect will return a false positive even if not capped by `maxDate`
+ // This additional expect will fail however, if the 'ArrowDown' dosen't no-op.
+ fireEvent.keyDown(document.activeElement, { key: 'ArrowUp' });
+ await waitFor(() => expect(document.activeElement.textContent).toBe('2011 – 2020'));
+ });
+ });
+ });
+ });
+});
diff --git a/src/MonthView.spec.jsx b/src/MonthView.spec.jsx
index a48026bd..1fe26190 100644
--- a/src/MonthView.spec.jsx
+++ b/src/MonthView.spec.jsx
@@ -14,6 +14,7 @@ const { format } = new Intl.DateTimeFormat('en-US', {
describe('MonthView', () => {
const defaultProps = {
activeStartDate: new Date(2017, 0, 1),
+ activeTabDate: new Date(2017, 0, 1),
};
it('renders proper view when given activeStartDate', () => {
diff --git a/src/MonthView/Day.jsx b/src/MonthView/Day.jsx
index e27165bb..f0823497 100644
--- a/src/MonthView/Day.jsx
+++ b/src/MonthView/Day.jsx
@@ -14,6 +14,7 @@ import { tileProps } from '../shared/propTypes';
const className = 'react-calendar__month-view__days__day';
export default function Day({
+ activeTabDate,
calendarType,
classes,
currentMonthIndex,
@@ -33,6 +34,9 @@ export default function Day({
date.getMonth() !== currentMonthIndex ? `${className}--neighboringMonth` : null,
)}
formatAbbr={formatLongDate}
+ isFocusable={
+ activeTabDate.getTime() === date.getTime() && date.getMonth() === currentMonthIndex
+ }
maxDateTransform={getDayEnd}
minDateTransform={getDayStart}
view="month"
diff --git a/src/MonthView/Day.spec.jsx b/src/MonthView/Day.spec.jsx
index 781257aa..7889ac01 100644
--- a/src/MonthView/Day.spec.jsx
+++ b/src/MonthView/Day.spec.jsx
@@ -6,6 +6,7 @@ import Day from './Day';
const tileProps = {
activeStartDate: new Date(2018, 0, 1),
+ activeTabDate: new Date(2018, 0, 1),
classes: ['react-calendar__tile'],
currentMonthIndex: 0,
date: new Date(2018, 0, 1),
diff --git a/src/Tile.jsx b/src/Tile.jsx
index 419f0416..f9ae1360 100644
--- a/src/Tile.jsx
+++ b/src/Tile.jsx
@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
+import { FocusContext } from './FocusContainer';
import { tileProps } from './shared/propTypes';
@@ -55,6 +56,11 @@ export default class Tile extends Component {
state = {};
+ handleClick = (event) => {
+ this.props.onClick?.(this.props.date, event);
+ this.context.setActiveTabDate(this.props.date);
+ };
+
render() {
const {
activeStartDate,
@@ -67,8 +73,8 @@ export default class Tile extends Component {
maxDateTransform,
minDate,
minDateTransform,
- onClick,
onMouseOver,
+ isFocusable,
style,
tileDisabled,
view,
@@ -83,10 +89,12 @@ export default class Tile extends Component {
(maxDate && maxDateTransform(maxDate) < date) ||
(tileDisabled && tileDisabled({ activeStartDate, date, view }))
}
- onClick={onClick ? (event) => onClick(date, event) : undefined}
+ onClick={this.handleClick}
onFocus={onMouseOver ? () => onMouseOver(date) : undefined}
onMouseOver={onMouseOver ? () => onMouseOver(date) : undefined}
+ role="gridcell"
style={style}
+ tabIndex={isFocusable ? 0 : -1}
type="button"
>
{formatAbbr ? {children} : children}
@@ -95,3 +103,4 @@ export default class Tile extends Component {
);
}
}
+Tile.contextType = FocusContext;
diff --git a/src/TileGroup.jsx b/src/TileGroup.jsx
index 50ba422d..f2acda3b 100644
--- a/src/TileGroup.jsx
+++ b/src/TileGroup.jsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import Flex from './Flex';
@@ -22,6 +22,8 @@ export default function TileGroup({
...tileProps
}) {
const tiles = [];
+ const gridRef = useRef(null);
+ const [isFocusWithin, setIsFocusWithin] = useState(false);
for (let point = start; point <= end; point += step) {
const date = dateTransform(point);
@@ -42,8 +44,35 @@ export default function TileGroup({
);
}
+ const handleGridBlur = useCallback((event) => {
+ const focusHasLeftGrid = !gridRef.current.contains(event.relatedTarget);
+ if (focusHasLeftGrid) {
+ setIsFocusWithin(false);
+ }
+ }, []);
+
+ const handleGridFocus = useCallback(() => {
+ if (!isFocusWithin) {
+ setIsFocusWithin(true);
+
+ // set focus to the first focusable tile
+ const focusableTile = gridRef.current.querySelector('[tabindex="0"]');
+ focusableTile?.focus();
+ }
+ }, [isFocusWithin]);
+
return (
-
+
{tiles}
);
diff --git a/src/YearView.spec.jsx b/src/YearView.spec.jsx
index cb3cd598..52ca40e7 100644
--- a/src/YearView.spec.jsx
+++ b/src/YearView.spec.jsx
@@ -9,6 +9,7 @@ const { format } = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'nume
describe('YearView', () => {
const defaultProps = {
activeStartDate: new Date(2017, 0, 1),
+ activeTabDate: new Date(2017, 0, 1),
};
it('renders proper view when given activeStartDate', () => {
diff --git a/src/YearView/Month.jsx b/src/YearView/Month.jsx
index 91f2b671..26d996e9 100644
--- a/src/YearView/Month.jsx
+++ b/src/YearView/Month.jsx
@@ -13,6 +13,7 @@ import { tileProps } from '../shared/propTypes';
const className = 'react-calendar__year-view__months__month';
export default function Month({
+ activeTabDate,
classes,
formatMonth = defaultFormatMonth,
formatMonthYear = defaultFormatMonthYear,
@@ -25,6 +26,10 @@ export default function Month({
{...otherProps}
classes={[].concat(classes, className)}
formatAbbr={formatMonthYear}
+ isFocusable={
+ activeTabDate.getMonth() === date.getMonth() &&
+ activeTabDate.getFullYear() === date.getFullYear()
+ }
maxDateTransform={getMonthEnd}
minDateTransform={getMonthStart}
view="year"
diff --git a/src/YearView/Month.spec.jsx b/src/YearView/Month.spec.jsx
index a726e65d..31907376 100644
--- a/src/YearView/Month.spec.jsx
+++ b/src/YearView/Month.spec.jsx
@@ -6,6 +6,7 @@ import Month from './Month';
const tileProps = {
activeStartDate: new Date(2018, 0, 1),
+ activeTabDate: new Date(2018, 0, 1),
classes: ['react-calendar__tile'],
date: new Date(2018, 0, 1),
};
diff --git a/src/shared/propTypes.js b/src/shared/propTypes.js
index 7105f837..4b5ea87b 100644
--- a/src/shared/propTypes.js
+++ b/src/shared/propTypes.js
@@ -106,6 +106,7 @@ isView.isRequired = (props, propName, componentName) => {
export const tileGroupProps = {
activeStartDate: PropTypes.instanceOf(Date).isRequired,
+ activeTabDate: PropTypes.instanceOf(Date).isRequired,
hover: PropTypes.instanceOf(Date),
locale: PropTypes.string,
maxDate: isMaxDate,
@@ -122,6 +123,7 @@ export const tileProps = {
activeStartDate: PropTypes.instanceOf(Date).isRequired,
classes: PropTypes.arrayOf(PropTypes.string).isRequired,
date: PropTypes.instanceOf(Date).isRequired,
+ isFocusable: PropTypes.bool,
locale: PropTypes.string,
maxDate: isMaxDate,
minDate: isMinDate,