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,