From a03b18f491451553c941cf6909b52360ac3dcf0b Mon Sep 17 00:00:00 2001 From: Nandor_Czegledi Date: Tue, 22 Oct 2024 21:16:48 +0200 Subject: [PATCH] chore(ui-calendar,ui-date-input): add keyboard navigation and focus controll for calendar --- packages/ui-calendar/src/Calendar/index.tsx | 133 ++++++++++++++++-- .../__new-tests__/DateInput.test.tsx | 8 +- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/packages/ui-calendar/src/Calendar/index.tsx b/packages/ui-calendar/src/Calendar/index.tsx index 446b9b0f9f..90926e0c43 100644 --- a/packages/ui-calendar/src/Calendar/index.tsx +++ b/packages/ui-calendar/src/Calendar/index.tsx @@ -23,7 +23,14 @@ */ /** @jsx jsx */ -import React, { Children, Component, ReactElement, MouseEvent } from 'react' +import React, { + Children, + Component, + ReactElement, + MouseEvent, + KeyboardEvent, + FocusEvent +} from 'react' import { View } from '@instructure/ui-view' import { @@ -82,6 +89,9 @@ class Calendar extends Component { role: 'table' } + // Create ref for each calendar day for programmatic focus management + dayRefs: (HTMLElement | null )[] + ref: Element | null = null private _weekdayHeaderIds = ( this.props.renderWeekdayLabels || this.defaultWeekdays @@ -90,6 +100,7 @@ class Calendar extends Component { }, {}) constructor(props: CalendarProps) { super(props) + this.dayRefs = [] this.state = this.calculateState( this.locale(), @@ -98,10 +109,79 @@ class Calendar extends Component { ) } + setFirstDayTabIndex = (tabIndex: 0 | -1) => { + // Manage the first day as the focus entry point and prevent focus + // from getting stuck by adjusting its tabIndex when focus moves to other days + if (this.dayRefs[0]) { + this.dayRefs[0].tabIndex = tabIndex + } + } + handleRef = (el: Element | null) => { this.ref = el } + handleDaysTableBlur = (e: FocusEvent) => { + const dataCid = e.relatedTarget?.getAttribute('data-cid') + + // If the focus leave the days table, + // reset the table focus entry point to its first calendar day + if (dataCid !== 'Day') { + this.setFirstDayTabIndex(0) + } + } + + handleKeyDown = (e: KeyboardEvent, dayIndex: number) => { + const totalDays = Calendar.DAY_COUNT + const daysPerWeek = 7 + let targetIndex: number + + const moveFocus = (targetIndex: number) => { + const targetDay = this.dayRefs[targetIndex] + + targetDay?.focus() + this.setFirstDayTabIndex(-1) + } + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault() + targetIndex = + dayIndex % daysPerWeek === 0 + ? dayIndex + daysPerWeek - 1 + : dayIndex - 1 + moveFocus(targetIndex) + break + + case 'ArrowRight': + e.preventDefault() + targetIndex = + (dayIndex + 1) % daysPerWeek === 0 + ? dayIndex - (daysPerWeek - 1) + : dayIndex + 1 + moveFocus(targetIndex) + break + + case 'ArrowUp': + e.preventDefault() + targetIndex = + dayIndex < daysPerWeek + ? dayIndex + totalDays - daysPerWeek + : dayIndex - daysPerWeek + moveFocus(targetIndex) + break + + case 'ArrowDown': + e.preventDefault() + targetIndex = + dayIndex >= totalDays - daysPerWeek + ? dayIndex - totalDays + daysPerWeek + : dayIndex + daysPerWeek + moveFocus(targetIndex) + break + } + } + componentDidMount() { this.props.makeStyles?.() } @@ -115,8 +195,9 @@ class Calendar extends Component { prevProps.visibleMonth !== this.props.visibleMonth if (isUpdated) { - this.setState(() => { + this.setState((prevState) => { return { + ...prevState, ...this.calculateState( this.locale(), this.timezone(), @@ -347,7 +428,12 @@ class Calendar extends Component { return ( {this.renderWeekdayHeaders()} - {this.renderDays()} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + this.handleDaysTableBlur(e)} + > + {this.renderDays()} +
) } @@ -433,17 +519,27 @@ class Calendar extends Component { days[index].push(day) return days // 7xN 2D array of `Day`s }, []) - .map((row) => ( + .map((row, rowIndex) => ( - {row.map((day, i) => ( - - {role === 'presentation' - ? safeCloneElement(day, { - 'aria-describedby': this._weekdayHeaderIds[i] - }) - : day} - - ))} + {row.map((day, i) => { + const dayIndex = rowIndex * 7 + i + + return ( + + {role === 'presentation' + ? safeCloneElement(day, { + 'aria-describedby': this._weekdayHeaderIds[i], + tabIndex: dayIndex === 0 ? 0 : -1, + }) + : safeCloneElement(day, { + tabIndex: dayIndex === 0 ? 0 : -1, + })} + + ) + })} )) } @@ -468,6 +564,8 @@ class Calendar extends Component { // date is returned as an ISO string, like 2021-09-14T22:00:00.000Z handleDayClick = (event: MouseEvent, { date }: { date: string }) => { + this.setFirstDayTabIndex(-1) + if (this.props.onDateSelected) { const parsedDate = DateTime.parse(date, this.locale(), this.timezone()) this.props.onDateSelected(parsedDate.toISOString(), parsedDate, event) @@ -490,6 +588,10 @@ class Calendar extends Component { return disabledDates(date.toISOString()) } + setDayRef = (index: number) => (el: Element | null) => { + this.dayRefs[index] = el as HTMLElement | null + } + renderDefaultdays() { const { selectedDate } = this.props const { visibleMonth, today } = this.state @@ -519,8 +621,9 @@ class Calendar extends Component { currDate.add({days: 1}) } - return arr.map((date) => { + return arr.map((date, dayIndex) => { const dateStr = date.toISOString() + return ( { label={date.format('D MMMM YYYY')} // used by screen readers onClick={this.handleDayClick} interaction={this.isDisabledDate(date) ? 'disabled' : 'enabled'} + elementRef={this.setDayRef(dayIndex)} + onKeyDown={(e) => this.handleKeyDown(e, dayIndex)} > {date.format('DD')} diff --git a/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx b/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx index f0823e6bbd..8dd92d5aa3 100644 --- a/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx +++ b/packages/ui-date-input/src/DateInput/__new-tests__/DateInput.test.tsx @@ -740,8 +740,12 @@ describe('', () => { expect(prevMonthButton).toHaveAttribute('tabIndex', '-1') expect(calendarDays).toHaveLength(42) - calendarDays.forEach((day) => { - expect(day).toHaveAttribute('tabIndex', '-1') + calendarDays.forEach((day, index) => { + if (index === 0) { + expect(day).toHaveAttribute('tabIndex', '0') + } else { + expect(day).toHaveAttribute('tabIndex', '-1') + } }) })