Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(ui-calendar): add keyboard navigation and focus controll for cale… #1709

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 119 additions & 14 deletions packages/ui-calendar/src/Calendar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -82,6 +89,9 @@ class Calendar extends Component<CalendarProps, CalendarState> {
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
Expand All @@ -90,6 +100,7 @@ class Calendar extends Component<CalendarProps, CalendarState> {
}, {})
constructor(props: CalendarProps) {
super(props)
this.dayRefs = []

this.state = this.calculateState(
this.locale(),
Expand All @@ -98,10 +109,79 @@ class Calendar extends Component<CalendarProps, CalendarState> {
)
}

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<Element>) => {
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') {
git-nandor marked this conversation as resolved.
Show resolved Hide resolved
this.setFirstDayTabIndex(0)
}
}

handleKeyDown = (e: KeyboardEvent<Element>, 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?.()
}
Expand All @@ -115,8 +195,9 @@ class Calendar extends Component<CalendarProps, CalendarState> {
prevProps.visibleMonth !== this.props.visibleMonth

if (isUpdated) {
this.setState(() => {
this.setState((prevState) => {
return {
...prevState,
...this.calculateState(
this.locale(),
this.timezone(),
Expand Down Expand Up @@ -347,7 +428,12 @@ class Calendar extends Component<CalendarProps, CalendarState> {
return (
<table role={this.role}>
<thead>{this.renderWeekdayHeaders()}</thead>
<tbody>{this.renderDays()}</tbody>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<tbody
onBlur={(e) => this.handleDaysTableBlur(e)}
>
{this.renderDays()}
</tbody>
</table>
)
}
Expand Down Expand Up @@ -433,17 +519,27 @@ class Calendar extends Component<CalendarProps, CalendarState> {
days[index].push(day)
return days // 7xN 2D array of `Day`s
}, [])
.map((row) => (
.map((row, rowIndex) => (
<tr key={`row${row[0].props.date}`} role={role}>
{row.map((day, i) => (
<td key={day.props.date} role={role}>
{role === 'presentation'
? safeCloneElement(day, {
'aria-describedby': this._weekdayHeaderIds[i]
})
: day}
</td>
))}
{row.map((day, i) => {
const dayIndex = rowIndex * 7 + i

return (
<td
key={day.props.date}
role={role}
>
{role === 'presentation'
? safeCloneElement(day, {
'aria-describedby': this._weekdayHeaderIds[i],
tabIndex: dayIndex === 0 ? 0 : -1,
})
: safeCloneElement(day, {
tabIndex: dayIndex === 0 ? 0 : -1,
})}
</td>
)
})}
</tr>
))
}
Expand All @@ -468,6 +564,8 @@ class Calendar extends Component<CalendarProps, CalendarState> {

// date is returned as an ISO string, like 2021-09-14T22:00:00.000Z
handleDayClick = (event: MouseEvent<any>, { 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)
Expand All @@ -490,6 +588,10 @@ class Calendar extends Component<CalendarProps, CalendarState> {
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
Expand Down Expand Up @@ -519,8 +621,9 @@ class Calendar extends Component<CalendarProps, CalendarState> {

currDate.add({days: 1})
}
return arr.map((date) => {
return arr.map((date, dayIndex) => {
const dateStr = date.toISOString()

return (
<Calendar.Day
key={dateStr}
Expand All @@ -531,6 +634,8 @@ class Calendar extends Component<CalendarProps, CalendarState> {
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')}
</Calendar.Day>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,12 @@ describe('<DateInput />', () => {
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')
}
})
})

Expand Down
Loading