Skip to content

Commit

Permalink
chore(ui-calendar,ui-date-input): add keyboard navigation and focus c…
Browse files Browse the repository at this point in the history
…ontroll for calendar
  • Loading branch information
git-nandor committed Oct 22, 2024
1 parent 1c039d9 commit a03b18f
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 16 deletions.
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') {
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

0 comments on commit a03b18f

Please sign in to comment.