Skip to content

Commit

Permalink
feat(react/Calendar): add tabbing focus support to the Calendar com…
Browse files Browse the repository at this point in the history
…ponent
  • Loading branch information
cheton committed Aug 13, 2024
1 parent 0c31c63 commit 0bd9c04
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 9 deletions.
282 changes: 278 additions & 4 deletions packages/react/src/date-pickers/Calendar/Calendar.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { useConst, usePrevious } from '@tonic-ui/react-hooks';
import { isNullOrUndefined } from '@tonic-ui/utils';
import {
getActiveElement,
getAllFocusable,
isNullOrUndefined,
} from '@tonic-ui/utils';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import addMonths from 'date-fns/addMonths';
import endOfDay from 'date-fns/endOfDay';
import format from 'date-fns/format';
import isDate from 'date-fns/isDate';
import isSameMonth from 'date-fns/isSameMonth';
import isSameYear from 'date-fns/isSameYear';
import isValid from 'date-fns/isValid';
import startOfDay from 'date-fns/startOfDay';
import startOfMonth from 'date-fns/startOfMonth';
import startOfWeek from 'date-fns/startOfWeek';
import subMonths from 'date-fns/subMonths';
import memoize from 'micro-memoize';
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { Box } from '../../box';
import { validateDate } from '../validation';
import { CalendarProvider } from './context';
Expand Down Expand Up @@ -61,6 +70,8 @@ const Calendar = forwardRef((
// Return initial date if it is valid, otherwise return today
return isValid(initialDate) ? initialDate : today;
});
const monthViewRef = useRef();
const nextFocusIndexRef = useRef();
const [activeDate, setActiveDate] = useState(initialActiveDate);
const [date, setDate] = useState(initialDate);
const previousDate = usePrevious(date);
Expand Down Expand Up @@ -102,6 +113,26 @@ const Calendar = forwardRef((
}
}, [date, previousDate, activeDate]);

useEffect(() => {
if (isNullOrUndefined(nextFocusIndexRef.current)) {
return;
}

const nextFocusIndex = nextFocusIndexRef.current;
const focusableElements = getAllFocusable(monthViewRef.current);
const el = nextFocusIndex < 0
? focusableElements[focusableElements.length + nextFocusIndex]
: focusableElements[nextFocusIndex];

// Reset the reference to avoid unintended focus changes in future renders
nextFocusIndexRef.current = null;

// Use requestAnimationFrame to ensure that the focus is set after the DOM has been updated
requestAnimationFrame(() => {
el && el?.focus();
});
}, [activeDate]); // Re-run effect when activeDate changes

const onChange = useCallback((nextDate) => {
const isControlled = (dateProp !== undefined);
if (!isControlled) {
Expand All @@ -111,6 +142,243 @@ const Calendar = forwardRef((
onChangeProp?.(nextDate);
}, [dateProp, onChangeProp]);

const monthViewEventHandler = {};
monthViewEventHandler.onKeyDown = useCallback((event) => {
const key = event?.key;
const isShiftPressed = event.shiftKey;
const focusableElements = getAllFocusable(monthViewRef.current);
const activeElement = getActiveElement(event.target);

const focusOnDay = (offset) => {
const focusIndex = focusableElements.indexOf(activeElement);
if (focusIndex < 0) {
return;
}

const nextFocusIndex = focusIndex + offset;
if (nextFocusIndex < 0 || nextFocusIndex >= focusableElements.length) {
const nextActiveDate = offset < 0 ? subMonths(activeDate, 1) : addMonths(activeDate, 1);
setActiveDate(nextActiveDate);

if (nextFocusIndex < 0) {
// Example #1: No days from the previous month in the first row (focusIndex=0, offset=-7, nextFocusIndex=-7)
//
// With the current focus on day 1 in the first row and `nextFocusIndex` set to -7:
//
// ```
// Su Mo Tu We Th Fr Sa
// [ 1] 2 3 4 5 6 7
// 8 9 10 11 12 13 14
// 15 16 17 18 19 20 21
// 22 23 24 25 26 27 28
// 29 30 31 1 2 3 4
// ```
//
// The focus will be shifted to the appropriate position in the previous month, based on the offset:
//
// ```
// Su Mo Tu We Th Fr Sa
// 1 2 3 4 5 6 7
// 8 9 10 11 12 13 14
// 15 16 17 18 19 20 21
// 22 23 24 25 26 27 28
// [24] 25 26 27 28 29 30
// ```
//
// Example #2: Contains days from the previous month in the first row (focusIndex=4, offset=-7, nextFocusIndex=-3)
//
// With the current focus on day 1 in the first row and `nextFocusIndex` set to -3:
//
// ```
// Su Mo Tu We Th Fr Sa
// 27 28 29 30 [ 1] 2 3
// 4 5 6 7 8 9 10
// 11 12 13 14 15 16 17
// 18 19 20 21 22 23 24
// 25 26 27 28 29 30 31
// ```
//
// Days 27, 28, 29, and 30 in the first row are part of the previous month and are not focusable (tabIndex of -1).
// To correctly adjust the focus, subtract 7 to align with the corresponding day in the preceding week:
//
// ```
// Su Mo Tu We Th Fr Sa
// 30 31 1 2 3 4 5
// 6 7 8 9 10 11 12
// 13 14 15 16 17 18 19
// 20 21 22 23 [24] 25 26
// 27 28 29 30 1 2 3
// ```

nextFocusIndexRef.current = nextFocusIndex;

// Verify whether the first row includes days from the previous month
const firstFocusableElementInMonthView = focusableElements[0];
if (firstFocusableElementInMonthView?.tabIndex < 0) {
nextFocusIndexRef.current -= 7;
}
} else if (nextFocusIndex >= focusableElements.length) {
// Example #1: No days from the next month in the last row (focusIndex=34, offset=7, nextFocusIndex=41)
//
// With the current focus on day 31 in the last row and `nextFocusIndex` set to 41:
//
// ```
// Su Mo Tu We Th Fr Sa
// 27 28 29 30 1 2 3
// 4 5 6 7 8 9 10
// 11 12 13 14 15 16 17
// 18 19 20 21 22 23 24
// 25 26 27 28 29 30 [31]
// ```
//
// The focus will be shifted to the appropriate position in the next month, based on the offset:
//
// ```
// Su Mo Tu We Th Fr Sa
// 1 2 3 4 5 6 [ 7]
// 8 9 10 11 12 13 14
// 15 16 17 18 19 20 21
// 22 23 24 25 26 27 28
// 29 30 1 2 3 4 5
// ```
//
// Example #2: Contains days from the next month in the last row (focusIndex=30, offset=7, nextFocusIndex=37)
//
// With the current focus on day 31 in the last row and `nextFocusIndex` set to 37:
//
// ```
// Su Mo Tu We Th Fr Sa
// 1 2 3 4 5 6 7
// 8 9 10 11 12 13 14
// 15 16 17 18 19 20 21
// 22 23 24 25 26 27 28
// 29 30 [31] 1 2 3 4
// ```
//
// Days 1, 2, 3, and 4 in the last row are part of the next month and are not focusable (tabIndex of -1).
// To correctly adjust the focus, add 7 to align with the corresponding day in the following week:
//
// ```
// Su Mo Tu We Th Fr Sa
// 29 30 31 1 2 3 4
// 5 6 [ 7] 8 9 10 11
// 12 13 14 15 16 17 18
// 19 20 21 22 23 24 25
// 26 27 28 29 30 1 2
// ```

nextFocusIndexRef.current = nextFocusIndex - focusableElements.length;

// Verify whether the last row includes days from the next month
const lastFocusableElementInMonthView = focusableElements[focusableElements.length - 1];
if (lastFocusableElementInMonthView?.tabIndex < 0) {
nextFocusIndexRef.current += 7;
}
}
return;
}

const nextFocusableElement = focusableElements[nextFocusIndex];
if (!nextFocusableElement) {
return;
}

if (nextFocusableElement?.tabIndex < 0) {
const nextActiveDate = offset < 0 ? subMonths(activeDate, 1) : addMonths(activeDate, 1);
setActiveDate(nextActiveDate);
nextFocusIndexRef.current = offset < 0
? nextFocusIndex - 7
: (nextFocusIndex + 7) - focusableElements.length;
return;
}

nextFocusableElement?.focus();
};

const focusOnPreviousDay = () => focusOnDay(-1);
const focusOnNextDay = () => focusOnDay(1);
const focusOnPreviousWeek = () => focusOnDay(-7);
const focusOnNextWeek = () => focusOnDay(7);
const focusOnFirstDay = () => {
const firstFocusableNode = focusableElements.find(node => node?.tabIndex >= 0);
if (firstFocusableNode) {
firstFocusableNode?.focus();
}
};
const focusOnLastDay = () => {
const lastFocusableNode = focusableElements.reverse().find(node => node?.tabIndex >= 0);
if (lastFocusableNode) {
lastFocusableNode?.focus();
}
};

if (key === 'ArrowLeft') {
event.preventDefault();
focusOnPreviousDay();
}

if (key === 'ArrowRight') {
event.preventDefault();
focusOnNextDay();
}

if (key === 'ArrowUp') {
event.preventDefault();
focusOnPreviousWeek();
}

if (key === 'ArrowDown') {
event.preventDefault();
focusOnNextWeek();
}

if (key === 'Home') {
event.preventDefault();
focusOnFirstDay();
}

if (key === 'End') {
event.preventDefault();
focusOnLastDay();
}

if (key === 'Tab') {
// Handle TAB key navigation within the month view
if (focusableElements.indexOf(activeElement) >= 0) {
if (isShiftPressed) { // TAB + SHIFT pressed
// Move focus to the first focusable element in the month view
// This allows the user to cycle focus back to the navigation if needed
const firstFocusableElement = focusableElements[0];
firstFocusableElement.focus();
} else { // TAB pressed
// Move focus to the last focusable element in the month view
// This allows the user to move focus outside the calendar
const lastFocusableElement = focusableElements[focusableElements.length - 1];
lastFocusableElement.focus();
}
return;
}
}
}, [activeDate]);

monthViewEventHandler.onFocus = useCallback((event) => {
const losingFocusTarget = event.relatedTarget; // The element that is losing focus (if any)
const isFocusEnteringFromOutsideToMonthView = losingFocusTarget && !event.currentTarget.contains(losingFocusTarget);

if (isFocusEnteringFromOutsideToMonthView) {
const today = new Date();
// Determine the date to focus on (either provided date or today's date)
const nextFocusDate = date ? startOfDay(date) : startOfDay(today);
// Get the start date of the month view based on the next focus date
const startDateOfMonthView = startOfWeek(startOfMonth(nextFocusDate), {
weekStartsOn: firstDayOfWeek,
});
const nextFocusIndex = differenceInCalendarDays(nextFocusDate, startDateOfMonthView);
nextFocusIndexRef.current = nextFocusIndex;
setActiveDate(nextFocusDate);
}
}, [date, firstDayOfWeek]);

const context = getMemoizedState({
activeDate,
date,
Expand All @@ -122,17 +390,23 @@ const Calendar = forwardRef((
setActiveDate,
shouldDisableDate,
});
const styleProps = useCalendarStyle();

const tabIndex = -1;
const styleProps = useCalendarStyle({ tabIndex });

return (
<CalendarProvider value={context}>
<Box
ref={ref}
tabIndex={tabIndex}
{...styleProps}
{...rest}
>
<Navigation />
<MonthView />
<MonthView
ref={monthViewRef}
{...monthViewEventHandler}
/>
</Box>
</CalendarProvider>
);
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/date-pickers/Calendar/MonthView/Day.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEventCallback } from '@tonic-ui/react-hooks';
import { dataAttr } from '@tonic-ui/utils';
import formatISO from 'date-fns/formatISO';
import isAfter from 'date-fns/isAfter';
import isBefore from 'date-fns/isBefore';
import isSameDay from 'date-fns/isSameDay';
Expand Down Expand Up @@ -41,6 +42,9 @@ const Day = forwardRef((
})();
const isSelected = isSameDay(date, new Date(selectedDate));
const isToday = isSameDay(date, new Date());
// Manage focus on the active date within the month view by adjusting the `tabIndex`.
// If the date is within the same month as the `activeDate`, set `tabIndex` to 0 to make it focusable; otherwise, set it to -1.
const tabIndex = isSameMonth(date, activeDate) ? 0 : -1;
const styleProps = useDayStyle({
isSameMonth: isSameMonth(date, activeDate),
isSelectable,
Expand All @@ -54,9 +58,11 @@ const Day = forwardRef((
return (
<Box
ref={ref}
data-date={formatISO(date, { representation: 'date' })}
// Only use `aria-selected` with these roles: `option`, `tab`, `menuitemradio`, `treeitem`, `gridcell`, `row`, `rowheader`, and `columnheader`.
data-selected={dataAttr(isSelected)}
onClick={isSelectable ? handleClick : undefined}
tabIndex={tabIndex}
{...styleProps}
{...rest}
>
Expand Down
8 changes: 6 additions & 2 deletions packages/react/src/date-pickers/Calendar/Navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,15 @@ const Navigation = forwardRef((props, ref) => {
>
<AngleLeftIcon size="4x" />
</Button>
<Box {...currentMonthYearStyleProps}>
<Box
{...currentMonthYearStyleProps}
>
<Text>
{formatDate(activeDate, 'LLL yyyy')}
</Text>
<Box {...yearButtonGroupStyleProps}>
<Box
{...yearButtonGroupStyleProps}
>
<Button
aria-label="Previous year"
variant="ghost"
Expand Down
Loading

0 comments on commit 0bd9c04

Please sign in to comment.