From 66377d6ed11ae4812be1bccfc05d6938696eb92f Mon Sep 17 00:00:00 2001 From: Maciek Pekala Date: Mon, 26 Oct 2020 22:01:27 +0100 Subject: [PATCH 01/18] Implement WAI ARIA recommended focus handling --- src/Calendar.jsx | 39 ++++++---- src/Calendar/Navigation.jsx | 15 +++- src/CenturyView/Decade.jsx | 8 +- src/DecadeView/Year.jsx | 8 +- src/FocusContainer.jsx | 151 ++++++++++++++++++++++++++++++++++++ src/MonthView/Day.jsx | 4 + src/Tile.jsx | 2 + src/YearView/Month.jsx | 5 ++ src/shared/propTypes.js | 2 + 9 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 src/FocusContainer.jsx diff --git a/src/Calendar.jsx b/src/Calendar.jsx index a1bf3d41..7fdca14f 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 } from 'react'; import Navigation from './Calendar/Navigation'; import CenturyView from './CenturyView'; import DecadeView from './DecadeView'; -import YearView from './YearView'; +import FocusContainer from './FocusContainer'; import MonthView from './MonthView'; +import YearView from './YearView'; import { getBegin, getBeginNext, getEnd, getValueRange } from './shared/dates'; import { @@ -16,7 +17,7 @@ import { isMinDate, isRef, isValue, - isView, + isView } from './shared/propTypes'; import { between } from './shared/utils'; @@ -555,7 +556,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 +578,7 @@ export default class Calendar extends Component { const commonProps = { activeStartDate, + activeTabDate, hover, locale, maxDate, @@ -705,7 +707,7 @@ export default class Calendar extends Component { render() { const { className, inputRef, selectRange, showDoubleView } = this.props; - const { onMouseLeave, value } = this; + const { onMouseLeave, value, view, activeStartDate, setActiveStartDate } = this; const valueArray = [].concat(value); return ( @@ -719,14 +721,25 @@ export default class Calendar extends Component { ref={inputRef} > {this.renderNavigation()} -
- {this.renderContent()} - {showDoubleView ? this.renderContent(true) : null} -
+ {({ activeTabDate, containerRef }) => ( +
+ {this.renderContent(activeTabDate)} + {showDoubleView && this.renderContent(activeTabDate, true)} +
+ )} + ); } 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/Decade.jsx b/src/CenturyView/Decade.jsx index 7c494b70..4bf7f6c7 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/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 ( { + // If the previously focused element was not in the grid, + // (e.g. it was a navigation button), we don't move the focus + if (!containerRef.current?.contains(document.activeElement)) { + return; + } + + // We are applying the focus async, to ensure that the calendar view + // was already updated + setTimeout(() => { + const focusableElement = containerRef.current?.querySelector('[tabindex="0"]'); + if (focusableElement) { + focusableElement.focus(); + } + }, 0); + }, [activeTabDate]); + + // 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 ( + activeTabDate <= endPrevious || + (!showDoubleView && activeTabDate >= beginNext) || + (showDoubleView && activeTabDate >= getBeginNext(view, beginNext)) + ) { + setActiveTabDate(activeStartDate); + } + }, [view, activeStartDate]); + + // 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(activeTabDate); + + switch (true) { + case event.key === 'ArrowUp' && view === 'month': + nextTabDate.setDate(activeTabDate.getDate() - 7); + break; + case event.key === 'ArrowUp' && view === 'year': + nextTabDate.setMonth(activeTabDate.getMonth() - 3); + break; + case event.key === 'ArrowUp' && view === 'decade': + nextTabDate.setFullYear(activeTabDate.getFullYear() - 3); + break; + case event.key === 'ArrowUp' && view === 'century': + nextTabDate.setFullYear(activeTabDate.getFullYear() - 30); + break; + case event.key === 'ArrowDown' && view === 'month': + nextTabDate.setDate(activeTabDate.getDate() + 7); + break; + case event.key === 'ArrowDown' && view === 'year': + nextTabDate.setMonth(activeTabDate.getMonth() + 3); + break; + case event.key === 'ArrowDown' && view === 'decade': + nextTabDate.setFullYear(activeTabDate.getFullYear() + 3); + break; + case event.key === 'ArrowDown' && view === 'century': + nextTabDate.setFullYear(activeTabDate.getFullYear() + 30); + break; + case event.key === 'ArrowLeft' && view === 'month': + nextTabDate.setDate(activeTabDate.getDate() - 1); + break; + case event.key === 'ArrowLeft' && view === 'year': + nextTabDate.setMonth(activeTabDate.getMonth() - 1); + break; + case event.key === 'ArrowLeft' && view === 'decade': + nextTabDate.setFullYear(activeTabDate.getFullYear() - 1); + break; + case event.key === 'ArrowLeft' && view === 'century': + nextTabDate.setFullYear(activeTabDate.getFullYear() - 10); + break; + case event.key === 'ArrowRight' && view === 'month': + nextTabDate.setDate(activeTabDate.getDate() + 1); + break; + case event.key === 'ArrowRight' && view === 'year': + nextTabDate.setMonth(activeTabDate.getMonth() + 1); + break; + case event.key === 'ArrowRight' && view === 'decade': + nextTabDate.setFullYear(activeTabDate.getFullYear() + 1); + break; + case event.key === 'ArrowRight' && view === 'century': + nextTabDate.setFullYear(activeTabDate.getFullYear() + 10); + break; + default: + break; + } + + // If the focusable element is unchanged, exit + if (nextTabDate.getTime() === activeTabDate.getTime()) { + return; + } + + setActiveTabDate(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({ activeTabDate, containerRef })}; +} + +FocusContainer.propTypes = { + activeStartDate: PropTypes.instanceOf(Date).isRequired, + children: PropTypes.func.isRequired, + setActiveStartDate: PropTypes.func.isRequired, + showDoubleView: PropTypes.bool, + value: PropTypes.oneOfType([PropTypes.string, isValue]), + view: isView.isRequired, +}; 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/Tile.jsx b/src/Tile.jsx index 419f0416..1dd4149f 100644 --- a/src/Tile.jsx +++ b/src/Tile.jsx @@ -69,6 +69,7 @@ export default class Tile extends Component { minDateTransform, onClick, onMouseOver, + isFocusable, style, tileDisabled, view, @@ -87,6 +88,7 @@ export default class Tile extends Component { onFocus={onMouseOver ? () => onMouseOver(date) : undefined} onMouseOver={onMouseOver ? () => onMouseOver(date) : undefined} style={style} + tabIndex={isFocusable ? 0 : -1} type="button" > {formatAbbr ? {children} : children} 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/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, From 990ad31cb79e757e342be7660dc8d83f786511be Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Tue, 7 Mar 2023 22:35:53 -0800 Subject: [PATCH 02/18] eslint and prettier fixes --- src/Calendar.jsx | 4 ++-- src/FocusContainer.jsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Calendar.jsx b/src/Calendar.jsx index 7fdca14f..29f85cf2 100644 --- a/src/Calendar.jsx +++ b/src/Calendar.jsx @@ -17,7 +17,7 @@ import { isMinDate, isRef, isValue, - isView + isView, } from './shared/propTypes'; import { between } from './shared/utils'; @@ -736,7 +736,7 @@ export default class Calendar extends Component { ref={containerRef} > {this.renderContent(activeTabDate)} - {showDoubleView && this.renderContent(activeTabDate, true)} + {showDoubleView ? this.renderContent(activeTabDate, true) : null} )} diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx index 2494d81e..f60f7ab7 100644 --- a/src/FocusContainer.jsx +++ b/src/FocusContainer.jsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; +import React, { useEffect, useRef, useState } from 'react'; import { getBeginNext, getBeginPrevious, getEndPrevious } from './shared/dates'; import { isValue, isView } from './shared/propTypes'; @@ -47,7 +47,7 @@ export default function FocusContainer({ ) { setActiveTabDate(activeStartDate); } - }, [view, activeStartDate]); + }, [view, activeStartDate, activeTabDate, showDoubleView]); // Handle arrow keyboard interactions by moving the focusable element around useEffect(() => { From e5edaa23e0c0d42aa58d5a62cdc059f6fcd7394d Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Tue, 7 Mar 2023 22:36:06 -0800 Subject: [PATCH 03/18] Prevent page scroll on arrow usage --- src/FocusContainer.jsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx index f60f7ab7..f2eb21c6 100644 --- a/src/FocusContainer.jsx +++ b/src/FocusContainer.jsx @@ -59,6 +59,15 @@ export default function FocusContainer({ const nextTabDate = new Date(activeTabDate); + 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(activeTabDate.getDate() - 7); From e2153562637fe9c735fe2aea96cdddfa6c278604 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Tue, 7 Mar 2023 23:03:46 -0800 Subject: [PATCH 04/18] Fix failing tests --- src/DecadeView.spec.jsx | 1 + src/DecadeView/Year.spec.jsx | 1 + src/MonthView.spec.jsx | 1 + src/MonthView/Day.spec.jsx | 1 + src/YearView.spec.jsx | 1 + src/YearView/Month.spec.jsx | 1 + 6 files changed, 6 insertions(+) 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.spec.jsx b/src/DecadeView/Year.spec.jsx index 5382b2c4..25140fbb 100644 --- a/src/DecadeView/Year.spec.jsx +++ b/src/DecadeView/Year.spec.jsx @@ -6,6 +6,7 @@ import Year from './Year'; const tileProps = { activeStartDate: new Date(2018, 0, 1), + activeTabDate: new Date(2018, 0, 1), classes: ['react-calendar__tile'], date: new Date(2018, 0, 1), point: 2018, 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.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/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.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), }; From 75b625a9b0a60d6c46974eaefb17e61bde42c13b Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Wed, 8 Mar 2023 00:20:38 -0800 Subject: [PATCH 05/18] Use React.Context --- src/Calendar.jsx | 33 ++++++++++++++++++++------------- src/FocusContainer.jsx | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/Calendar.jsx b/src/Calendar.jsx index 29f85cf2..75a4bcf6 100644 --- a/src/Calendar.jsx +++ b/src/Calendar.jsx @@ -1,11 +1,11 @@ import clsx from 'clsx'; import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React, { Component, createRef } from 'react'; import Navigation from './Calendar/Navigation'; import CenturyView from './CenturyView'; import DecadeView from './DecadeView'; -import FocusContainer from './FocusContainer'; +import FocusContainer, { FocusContext } from './FocusContainer'; import MonthView from './MonthView'; import YearView from './YearView'; @@ -253,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; @@ -723,22 +725,27 @@ export default class Calendar extends Component { {this.renderNavigation()} - {({ activeTabDate, containerRef }) => ( -
- {this.renderContent(activeTabDate)} - {showDoubleView ? this.renderContent(activeTabDate, true) : null} -
- )} +
+ + {({ activeTabDate }) => ( + <> + {this.renderContent(activeTabDate)} + {showDoubleView ? this.renderContent(activeTabDate, true) : null} + + )} + +
); diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx index f2eb21c6..434ee9cb 100644 --- a/src/FocusContainer.jsx +++ b/src/FocusContainer.jsx @@ -1,10 +1,27 @@ import PropTypes from 'prop-types'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { getBeginNext, getBeginPrevious, getEndPrevious } from './shared/dates'; import { 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; + } +} + export default function FocusContainer({ children, + containerRef, value, activeStartDate, setActiveStartDate, @@ -12,8 +29,9 @@ export default function FocusContainer({ view, }) { const currentValue = Array.isArray(value) ? value[0] : value; - const [activeTabDate, setActiveTabDate] = useState(currentValue ?? activeStartDate); - const containerRef = useRef(null); + const [activeTabDate, setActiveTabDate] = useState( + clearTimeFromDate(currentValue) ?? activeStartDate, + ); // Move the focus to the current focusable element useEffect(() => { @@ -31,7 +49,7 @@ export default function FocusContainer({ focusableElement.focus(); } }, 0); - }, [activeTabDate]); + }, [activeTabDate, containerRef]); // Set the focusable element to the active start date when the // active start date changes and the previous focusable element goes @@ -147,12 +165,20 @@ export default function FocusContainer({ }; }); - return <>{children({ activeTabDate, containerRef })}; + return ( + + {children} + + ); } FocusContainer.propTypes = { activeStartDate: PropTypes.instanceOf(Date).isRequired, - children: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, + containerRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), setActiveStartDate: PropTypes.func.isRequired, showDoubleView: PropTypes.bool, value: PropTypes.oneOfType([PropTypes.string, isValue]), From 942830ae76d0e043537fc54c9bb1f90319ca38a5 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Wed, 8 Mar 2023 00:24:02 -0800 Subject: [PATCH 06/18] Set tile to activeTabDate on click --- src/Tile.jsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Tile.jsx b/src/Tile.jsx index 1dd4149f..4463cf64 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,7 +73,6 @@ export default class Tile extends Component { maxDateTransform, minDate, minDateTransform, - onClick, onMouseOver, isFocusable, style, @@ -84,7 +89,7 @@ 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} style={style} @@ -97,3 +102,4 @@ export default class Tile extends Component { ); } } +Tile.contextType = FocusContext; From 6acd9d21082282b33a690a0cdf101cf2ec25d456 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Wed, 8 Mar 2023 00:51:21 -0800 Subject: [PATCH 07/18] Fix keyboard nav moving into new months --- src/FocusContainer.jsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx index 434ee9cb..6c0eb227 100644 --- a/src/FocusContainer.jsx +++ b/src/FocusContainer.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { getBeginNext, getBeginPrevious, getEndPrevious } from './shared/dates'; import { isValue, isView } from './shared/propTypes'; @@ -32,6 +32,7 @@ export default function FocusContainer({ const [activeTabDate, setActiveTabDate] = useState( clearTimeFromDate(currentValue) ?? activeStartDate, ); + const activeTabeDateRef = useRef(activeTabDate); // Move the focus to the current focusable element useEffect(() => { @@ -51,6 +52,12 @@ export default function FocusContainer({ }, 0); }, [activeTabDate, 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 = activeTabDate; + }, [activeTabDate]); + // 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) @@ -59,13 +66,13 @@ export default function FocusContainer({ const endPrevious = getEndPrevious(view, activeStartDate); if ( - activeTabDate <= endPrevious || - (!showDoubleView && activeTabDate >= beginNext) || - (showDoubleView && activeTabDate >= getBeginNext(view, beginNext)) + activeTabeDateRef.current <= endPrevious || + (!showDoubleView && activeTabeDateRef.current >= beginNext) || + (showDoubleView && activeTabeDateRef.current >= getBeginNext(view, beginNext)) ) { setActiveTabDate(activeStartDate); } - }, [view, activeStartDate, activeTabDate, showDoubleView]); + }, [view, activeStartDate, activeTabeDateRef, showDoubleView]); // Handle arrow keyboard interactions by moving the focusable element around useEffect(() => { From 71fb3567eb8e9ec424f79bf31e5da41474e25f99 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Thu, 9 Mar 2023 13:05:54 -0800 Subject: [PATCH 08/18] Alphabetize props --- src/FocusContainer.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx index 6c0eb227..931d7dd1 100644 --- a/src/FocusContainer.jsx +++ b/src/FocusContainer.jsx @@ -20,12 +20,12 @@ function clearTimeFromDate(date) { } export default function FocusContainer({ + activeStartDate, children, containerRef, - value, - activeStartDate, setActiveStartDate, showDoubleView, + value, view, }) { const currentValue = Array.isArray(value) ? value[0] : value; From ca732d9a497b67d8c5e92fae0810c00cd8e66e99 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Thu, 9 Mar 2023 13:11:57 -0800 Subject: [PATCH 09/18] Prevent moving focus outside acceptable range of dates --- src/Calendar.jsx | 4 +++- src/FocusContainer.jsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Calendar.jsx b/src/Calendar.jsx index 75a4bcf6..3a7b4086 100644 --- a/src/Calendar.jsx +++ b/src/Calendar.jsx @@ -708,7 +708,7 @@ export default class Calendar extends Component { } render() { - const { className, inputRef, selectRange, showDoubleView } = this.props; + const { className, inputRef, maxDate, minDate, selectRange, showDoubleView } = this.props; const { onMouseLeave, value, view, activeStartDate, setActiveStartDate } = this; const valueArray = [].concat(value); @@ -726,6 +726,8 @@ export default class Calendar extends Component { maxDate.getTime() || nextTabDate.getTime() < minDate.getTime()) { + return; + } + setActiveTabDate(nextTabDate); // If the new focusable element is out of view, adjust the view @@ -186,6 +193,8 @@ FocusContainer.propTypes = { 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]), From caf39c635b722fd5866bd5ac781c04fab0f0fdf2 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Thu, 9 Mar 2023 15:59:41 -0800 Subject: [PATCH 10/18] Add role='grid' to TileGroup --- src/Flex.jsx | 71 +++++++++++++++++++++++------------------------ src/Tile.jsx | 1 + src/TileGroup.jsx | 33 ++++++++++++++++++++-- 3 files changed, 66 insertions(+), 39 deletions(-) diff --git a/src/Flex.jsx b/src/Flex.jsx index cdfcd0f2..4a83d206 100644 --- a/src/Flex.jsx +++ b/src/Flex.jsx @@ -1,46 +1,41 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import PropTypes from 'prop-types'; function toPercent(num) { return `${num}%`; } -export default function Flex({ - children, - className, - count, - direction, - offset, - style, - wrap, - ...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/Tile.jsx b/src/Tile.jsx index 4463cf64..f9ae1360 100644 --- a/src/Tile.jsx +++ b/src/Tile.jsx @@ -92,6 +92,7 @@ export default class Tile extends Component { onClick={this.handleClick} onFocus={onMouseOver ? () => onMouseOver(date) : undefined} onMouseOver={onMouseOver ? () => onMouseOver(date) : undefined} + role="gridcell" style={style} tabIndex={isFocusable ? 0 : -1} type="button" 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} ); From 2654790d3b2a046961426f577ead16e23e9b763e Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Thu, 9 Mar 2023 15:59:53 -0800 Subject: [PATCH 11/18] FocusContainer MonthView tests --- src/FocusContainer.spec.jsx | 181 ++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/FocusContainer.spec.jsx diff --git a/src/FocusContainer.spec.jsx b/src/FocusContainer.spec.jsx new file mode 100644 index 00000000..cfd0bfce --- /dev/null +++ b/src/FocusContainer.spec.jsx @@ -0,0 +1,181 @@ +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 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('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('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('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')); + }); + }); + }); + }); +}); From 85fce024ad8185d78ee533c1c2bf75c484949646 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Fri, 10 Mar 2023 14:29:34 -0800 Subject: [PATCH 12/18] FocusContainer YearView tests --- src/FocusContainer.spec.jsx | 144 ++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/FocusContainer.spec.jsx b/src/FocusContainer.spec.jsx index cfd0bfce..a567d116 100644 --- a/src/FocusContainer.spec.jsx +++ b/src/FocusContainer.spec.jsx @@ -11,6 +11,12 @@ describe('FocusContainer', () => { view: 'month', }; + const defaultYearProps = { + defaultValue: new Date('May 01, 2023'), + defaultActiveStartDate: new Date('January 01, 2023'), + view: 'year', + }; + const renderCalendar = (props) => { return render(); }; @@ -69,6 +75,43 @@ describe('FocusContainer', () => { 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('arrowLeft', () => { @@ -102,6 +145,37 @@ describe('FocusContainer', () => { 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('arrowUp', () => { @@ -139,6 +213,41 @@ describe('FocusContainer', () => { 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('arrowDown', () => { @@ -176,6 +285,41 @@ describe('FocusContainer', () => { 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')); + }); + }); }); }); }); From ec6e60c9aaeaef98e99894d07048e1fcf16e2c3f Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Fri, 10 Mar 2023 14:51:11 -0800 Subject: [PATCH 13/18] Fix logic for ArrowUp and ArrowDown in top and bottom rows --- src/FocusContainer.jsx | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx index ac15d5fc..b4024be7 100644 --- a/src/FocusContainer.jsx +++ b/src/FocusContainer.jsx @@ -19,6 +19,28 @@ function clearTimeFromDate(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; + } +}; + export default function FocusContainer({ activeStartDate, children, @@ -103,7 +125,9 @@ export default function FocusContainer({ nextTabDate.setMonth(activeTabDate.getMonth() - 3); break; case event.key === 'ArrowUp' && view === 'decade': - nextTabDate.setFullYear(activeTabDate.getFullYear() - 3); + nextTabDate.setFullYear( + activeTabDate.getFullYear() - getTopRowYearOffset(activeTabDate.getFullYear()), + ); break; case event.key === 'ArrowUp' && view === 'century': nextTabDate.setFullYear(activeTabDate.getFullYear() - 30); @@ -115,7 +139,9 @@ export default function FocusContainer({ nextTabDate.setMonth(activeTabDate.getMonth() + 3); break; case event.key === 'ArrowDown' && view === 'decade': - nextTabDate.setFullYear(activeTabDate.getFullYear() + 3); + nextTabDate.setFullYear( + activeTabDate.getFullYear() + getBottomRowYearOffset(activeTabDate.getFullYear()), + ); break; case event.key === 'ArrowDown' && view === 'century': nextTabDate.setFullYear(activeTabDate.getFullYear() + 30); From 3cf73966cccfa500fc68d7187bd0a9645ee224f5 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Fri, 10 Mar 2023 14:51:30 -0800 Subject: [PATCH 14/18] FocusContainer DecadeView tests --- src/FocusContainer.spec.jsx | 160 ++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/src/FocusContainer.spec.jsx b/src/FocusContainer.spec.jsx index a567d116..2f01939d 100644 --- a/src/FocusContainer.spec.jsx +++ b/src/FocusContainer.spec.jsx @@ -17,6 +17,12 @@ describe('FocusContainer', () => { view: 'year', }; + const defaultDecadeProps = { + defaultValue: new Date('January 01, 2025'), + defaultActiveStartDate: new Date('January 01, 2021'), + view: 'decade', + }; + const renderCalendar = (props) => { return render(); }; @@ -112,6 +118,43 @@ describe('FocusContainer', () => { 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('arrowLeft', () => { @@ -176,6 +219,37 @@ describe('FocusContainer', () => { 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('arrowUp', () => { @@ -248,6 +322,57 @@ describe('FocusContainer', () => { 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('arrowDown', () => { @@ -320,6 +445,41 @@ describe('FocusContainer', () => { 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')); + }); + }); }); }); }); From 9232d5a7ff4ba186f759ce2f0740b9dce6586861 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Fri, 10 Mar 2023 16:04:16 -0800 Subject: [PATCH 15/18] Fix logic for decade selection --- src/CenturyView/Decade.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CenturyView/Decade.jsx b/src/CenturyView/Decade.jsx index 4bf7f6c7..0d182eed 100644 --- a/src/CenturyView/Decade.jsx +++ b/src/CenturyView/Decade.jsx @@ -22,7 +22,7 @@ export default function Decade({ getDecadeStart(date)} + isFocusable={activeTabDate <= getDecadeEnd(date) && activeTabDate >= getDecadeStart(date)} maxDateTransform={getDecadeEnd} minDateTransform={getDecadeStart} view="century" From b1a954655e2f23efcf1367371b7270436f015431 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Fri, 10 Mar 2023 16:08:24 -0800 Subject: [PATCH 16/18] Correct behavior around decade focusing, and focusing after loading a new TileGroup --- src/FocusContainer.jsx | 105 +++++++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 35 deletions(-) diff --git a/src/FocusContainer.jsx b/src/FocusContainer.jsx index b4024be7..7f79aa81 100644 --- a/src/FocusContainer.jsx +++ b/src/FocusContainer.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { getBeginNext, getBeginPrevious, getEndPrevious } from './shared/dates'; import { isMaxDate, isMinDate, isValue, isView } from './shared/propTypes'; @@ -41,6 +41,30 @@ const getBottomRowYearOffset = (year) => { } }; +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, @@ -53,34 +77,35 @@ export default function FocusContainer({ view, }) { const currentValue = Array.isArray(value) ? value[0] : value; - const [activeTabDate, setActiveTabDate] = useState( + const [activeTabDateState, setActiveTabDateState] = useState( clearTimeFromDate(currentValue) ?? activeStartDate, ); - const activeTabeDateRef = useRef(activeTabDate); + 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(() => { - // If the previously focused element was not in the grid, - // (e.g. it was a navigation button), we don't move the focus - if (!containerRef.current?.contains(document.activeElement)) { - return; - } - // We are applying the focus async, to ensure that the calendar view // was already updated setTimeout(() => { const focusableElement = containerRef.current?.querySelector('[tabindex="0"]'); - if (focusableElement) { - focusableElement.focus(); + if (shouldSetFocusRef.current) { + shouldSetFocusRef.current = false; + focusableElement?.focus(); } }, 0); - }, [activeTabDate, containerRef]); + }, [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 = activeTabDate; - }, [activeTabDate]); + 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 @@ -94,7 +119,7 @@ export default function FocusContainer({ (!showDoubleView && activeTabeDateRef.current >= beginNext) || (showDoubleView && activeTabeDateRef.current >= getBeginNext(view, beginNext)) ) { - setActiveTabDate(activeStartDate); + setActiveTabDateState(activeStartDate); } }, [view, activeStartDate, activeTabeDateRef, showDoubleView]); @@ -106,7 +131,7 @@ export default function FocusContainer({ return; } - const nextTabDate = new Date(activeTabDate); + const nextTabDate = new Date(activeTabDateState); if ( event.key === 'ArrowUp' || @@ -119,63 +144,71 @@ export default function FocusContainer({ switch (true) { case event.key === 'ArrowUp' && view === 'month': - nextTabDate.setDate(activeTabDate.getDate() - 7); + nextTabDate.setDate(activeTabDateState.getDate() - 7); break; case event.key === 'ArrowUp' && view === 'year': - nextTabDate.setMonth(activeTabDate.getMonth() - 3); + nextTabDate.setMonth(activeTabDateState.getMonth() - 3); break; case event.key === 'ArrowUp' && view === 'decade': nextTabDate.setFullYear( - activeTabDate.getFullYear() - getTopRowYearOffset(activeTabDate.getFullYear()), + activeTabDateState.getFullYear() - + getTopRowYearOffset(activeTabDateState.getFullYear()), ); break; case event.key === 'ArrowUp' && view === 'century': - nextTabDate.setFullYear(activeTabDate.getFullYear() - 30); + nextTabDate.setFullYear( + activeTabDateState.getFullYear() - + getTopRowDecadeOffset(activeTabDateState.getFullYear()), + ); break; case event.key === 'ArrowDown' && view === 'month': - nextTabDate.setDate(activeTabDate.getDate() + 7); + nextTabDate.setDate(activeTabDateState.getDate() + 7); break; case event.key === 'ArrowDown' && view === 'year': - nextTabDate.setMonth(activeTabDate.getMonth() + 3); + nextTabDate.setMonth(activeTabDateState.getMonth() + 3); break; case event.key === 'ArrowDown' && view === 'decade': nextTabDate.setFullYear( - activeTabDate.getFullYear() + getBottomRowYearOffset(activeTabDate.getFullYear()), + activeTabDateState.getFullYear() + + getBottomRowYearOffset(activeTabDateState.getFullYear()), ); break; case event.key === 'ArrowDown' && view === 'century': - nextTabDate.setFullYear(activeTabDate.getFullYear() + 30); + nextTabDate.setFullYear( + activeTabDateState.getFullYear() + + getBottomRowDecaderOffset(activeTabDateState.getFullYear()), + ); break; case event.key === 'ArrowLeft' && view === 'month': - nextTabDate.setDate(activeTabDate.getDate() - 1); + nextTabDate.setDate(activeTabDateState.getDate() - 1); break; case event.key === 'ArrowLeft' && view === 'year': - nextTabDate.setMonth(activeTabDate.getMonth() - 1); + nextTabDate.setMonth(activeTabDateState.getMonth() - 1); break; case event.key === 'ArrowLeft' && view === 'decade': - nextTabDate.setFullYear(activeTabDate.getFullYear() - 1); + nextTabDate.setFullYear(activeTabDateState.getFullYear() - 1); break; case event.key === 'ArrowLeft' && view === 'century': - nextTabDate.setFullYear(activeTabDate.getFullYear() - 10); + nextTabDate.setFullYear(activeTabDateState.getFullYear() - 10); break; case event.key === 'ArrowRight' && view === 'month': - nextTabDate.setDate(activeTabDate.getDate() + 1); + nextTabDate.setDate(activeTabDateState.getDate() + 1); break; case event.key === 'ArrowRight' && view === 'year': - nextTabDate.setMonth(activeTabDate.getMonth() + 1); + nextTabDate.setMonth(activeTabDateState.getMonth() + 1); break; case event.key === 'ArrowRight' && view === 'decade': - nextTabDate.setFullYear(activeTabDate.getFullYear() + 1); + nextTabDate.setFullYear(activeTabDateState.getFullYear() + 1); break; case event.key === 'ArrowRight' && view === 'century': - nextTabDate.setFullYear(activeTabDate.getFullYear() + 10); + nextTabDate.setFullYear(activeTabDateState.getFullYear() + 10); break; default: break; } // If the focusable element is unchanged, exit - if (nextTabDate.getTime() === activeTabDate.getTime()) { + if (nextTabDate.getTime() === activeTabDateState.getTime()) { return; } @@ -184,7 +217,7 @@ export default function FocusContainer({ return; } - setActiveTabDate(nextTabDate); + setActiveTabDateAndFocus(nextTabDate); // If the new focusable element is out of view, adjust the view // by changing the active start date @@ -206,7 +239,9 @@ export default function FocusContainer({ }); return ( - + {children} ); From 31912dc4a80cb85e151037bf291ad58fdeb2e964 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Fri, 10 Mar 2023 16:08:43 -0800 Subject: [PATCH 17/18] FocusContainer CenturyView tests --- src/FocusContainer.spec.jsx | 176 ++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/FocusContainer.spec.jsx b/src/FocusContainer.spec.jsx index 2f01939d..1e96abb1 100644 --- a/src/FocusContainer.spec.jsx +++ b/src/FocusContainer.spec.jsx @@ -23,6 +23,12 @@ describe('FocusContainer', () => { view: 'decade', }; + const defaultCenturyProps = { + defaultValue: new Date('January 01, 2041'), + defaultActiveStartDate: new Date('January 01, 2001'), + view: 'century', + }; + const renderCalendar = (props) => { return render(); }; @@ -155,6 +161,43 @@ describe('FocusContainer', () => { 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', () => { @@ -250,6 +293,37 @@ describe('FocusContainer', () => { 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', () => { @@ -373,6 +447,57 @@ describe('FocusContainer', () => { 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', () => { @@ -480,6 +605,57 @@ describe('FocusContainer', () => { 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')); + }); + }); }); }); }); From b02bff65ee194fff1ef877d96ffb6c63d36fdc91 Mon Sep 17 00:00:00 2001 From: iLan Epstein Date: Mon, 13 Mar 2023 10:09:26 -0700 Subject: [PATCH 18/18] Fix test warning --- src/CenturyView.spec.jsx | 1 + 1 file changed, 1 insertion(+) 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', () => {