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 12, 2024
1 parent 046cc71 commit 0c97e2f
Show file tree
Hide file tree
Showing 4 changed files with 317 additions and 8 deletions.
298 changes: 293 additions & 5 deletions packages/react/src/date-pickers/Calendar/Calendar.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { useConst, usePrevious } from '@tonic-ui/react-hooks';
import { isNullOrUndefined } from '@tonic-ui/utils';
import {
callEventHandlers,
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 @@ -46,6 +56,7 @@ const Calendar = forwardRef((
maxDate: maxDateProp,
minDate: minDateProp,
onChange: onChangeProp,
onKeyDown: onKeyDownProp,
onError: onErrorProp,
shouldDisableDate,
...rest
Expand All @@ -61,6 +72,9 @@ const Calendar = forwardRef((
// Return initial date if it is valid, otherwise return today
return isValid(initialDate) ? initialDate : today;
});
const navigationRef = useRef();
const monthViewRef = useRef();
const nextFocusIndexRef = useRef();
const [activeDate, setActiveDate] = useState(initialActiveDate);
const [date, setDate] = useState(initialDate);
const previousDate = usePrevious(date);
Expand All @@ -77,6 +91,256 @@ const Calendar = forwardRef((
const minDate = mapValueToStartOfDay(minDateProp);
const validationError = validateDate(date, { maxDate, minDate, shouldDisableDate });
const previousValidationError = usePrevious(validationError);
const getFocusableNavigationElements = useCallback(() => {
if (!navigationRef.current) {
return [];

Check warning on line 96 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L95-L96

Added lines #L95 - L96 were not covered by tests
}
const focusableElements = getAllFocusable(navigationRef.current);
return focusableElements;

Check warning on line 99 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L98-L99

Added lines #L98 - L99 were not covered by tests
}, []);
const getFocusableMonthViewElements = useCallback(() => {
if (!monthViewRef.current) {
return [];

Check warning on line 103 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L102-L103

Added lines #L102 - L103 were not covered by tests
}
const focusableElements = getAllFocusable(monthViewRef.current);
return focusableElements;

Check warning on line 106 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L105-L106

Added lines #L105 - L106 were not covered by tests
}, []);
const eventHandler = {};

// Navigate the menu items using keyboard.
eventHandler.onKeyDown = (event) => {
const key = event?.key;
const shiftKey = event?.shiftKey;
const focusableMonthViewElements = getFocusableMonthViewElements();
const activeElement = getActiveElement(event.target);

Check warning on line 115 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L112-L115

Added lines #L112 - L115 were not covered by tests

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

Check warning on line 120 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L117-L120

Added lines #L117 - L120 were not covered by tests
}

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

Check warning on line 126 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L123-L126

Added lines #L123 - L126 were not covered by tests

if (nextFocusIndex < 0) {

Check warning on line 128 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L128

Added line #L128 was not covered by tests
// 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;

Check warning on line 178 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L178

Added line #L178 was not covered by tests

// Verify whether the first row includes days from the previous month
const firstFocusableElementInMonthView = focusableMonthViewElements[0];
if (firstFocusableElementInMonthView?.tabIndex < 0) {
nextFocusIndexRef.current -= 7;

Check warning on line 183 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L181-L183

Added lines #L181 - L183 were not covered by tests
}
} else if (nextFocusIndex >= focusableMonthViewElements.length) {

Check warning on line 185 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L185

Added line #L185 was not covered by tests
// 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 - focusableMonthViewElements.length;

Check warning on line 235 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L235

Added line #L235 was not covered by tests

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

Check warning on line 240 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L238-L240

Added lines #L238 - L240 were not covered by tests
}
}
return;

Check warning on line 243 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L243

Added line #L243 was not covered by tests
}

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

Check warning on line 248 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L246-L248

Added lines #L246 - L248 were not covered by tests
}

if (nextFocusableElement?.tabIndex < 0) {
const nextActiveDate = offset < 0 ? subMonths(activeDate, 1) : addMonths(activeDate, 1);
setActiveDate(nextActiveDate);
nextFocusIndexRef.current = offset < 0

Check warning on line 254 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L251-L254

Added lines #L251 - L254 were not covered by tests
? nextFocusIndex - 7
: (nextFocusIndex + 7) - focusableMonthViewElements.length;
return;

Check warning on line 257 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L257

Added line #L257 was not covered by tests
}

nextFocusableElement?.focus();

Check warning on line 260 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L260

Added line #L260 was not covered by tests
};

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

Check warning on line 270 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L263-L270

Added lines #L263 - L270 were not covered by tests
}
};
const focusOnLastDay = () => {
const lastFocusableNode = focusableMonthViewElements.reverse().find(node => node?.tabIndex >= 0);
if (lastFocusableNode) {
lastFocusableNode?.focus();

Check warning on line 276 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L273-L276

Added lines #L273 - L276 were not covered by tests
}
};

// Prevents default page scrolling for ArrowLeft, ArrowRight, ArrowDown, ArrowUp, Home, End, and Tab keys.
if (key === 'ArrowLeft') {
event.preventDefault();
focusOnPreviousDay();

Check warning on line 283 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L281-L283

Added lines #L281 - L283 were not covered by tests
}
if (key === 'ArrowRight') {
event.preventDefault();
focusOnNextDay();

Check warning on line 287 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L285-L287

Added lines #L285 - L287 were not covered by tests
}
if (key === 'ArrowUp') {
event.preventDefault();
focusOnPreviousWeek();

Check warning on line 291 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L289-L291

Added lines #L289 - L291 were not covered by tests
}
if (key === 'ArrowDown') {
event.preventDefault();
focusOnNextWeek();

Check warning on line 295 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L293-L295

Added lines #L293 - L295 were not covered by tests
}
if (key === 'Home') {
event.preventDefault();
focusOnFirstDay();

Check warning on line 299 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L297-L299

Added lines #L297 - L299 were not covered by tests
}
if (key === 'End') {
event.preventDefault();
focusOnLastDay();

Check warning on line 303 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L301-L303

Added lines #L301 - L303 were not covered by tests
}
if (key === 'Tab') {
const focusableNavigationElements = getFocusableNavigationElements();
const focusableMonthViewElements = getFocusableMonthViewElements();
const activeElement = getActiveElement(event.target);
const isLastNavigationElementFocused = !isNullOrUndefined(activeElement) &&

Check warning on line 309 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L305-L309

Added lines #L305 - L309 were not covered by tests
(activeElement === focusableNavigationElements[focusableNavigationElements.length - 1]);

// If the last focusable navigation element (e.g., "Next month" button) is focused and the TAB key is pressed
if (isLastNavigationElementFocused && !shiftKey) {
const today = new Date();

Check warning on line 314 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L313-L314

Added lines #L313 - L314 were not covered by tests
// Determine the date to focus on (either provided date or today's date)
const nextFocusDate = date ? startOfDay(date) : startOfDay(today);

Check warning on line 316 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L316

Added line #L316 was not covered by tests
// Get the start date of the month view based on the next focus date
const startDateOfMonthView = startOfWeek(startOfMonth(nextFocusDate), {

Check warning on line 318 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L318

Added line #L318 was not covered by tests
weekStartsOn: firstDayOfWeek,
});
const nextFocusIndex = differenceInCalendarDays(nextFocusDate, startDateOfMonthView);
nextFocusIndexRef.current = nextFocusIndex;
setActiveDate(nextFocusDate);
return;

Check warning on line 324 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L321-L324

Added lines #L321 - L324 were not covered by tests
}

// Handle TAB key navigation within the month view
if (focusableMonthViewElements.indexOf(activeElement) >= 0) {
if (shiftKey) { // TAB + SHIFT pressed

Check warning on line 329 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L328-L329

Added lines #L328 - L329 were not covered by tests
// 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 firstFocusableMonthViewElement = focusableMonthViewElements[0];
firstFocusableMonthViewElement.focus();

Check warning on line 333 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L332-L333

Added lines #L332 - L333 were not covered by tests
} 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 lastFocusableMonthViewElement = focusableMonthViewElements[focusableMonthViewElements.length - 1];
lastFocusableMonthViewElement.focus();

Check warning on line 338 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L337-L338

Added lines #L337 - L338 were not covered by tests
}
return;

Check warning on line 340 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L340

Added line #L340 was not covered by tests
}
}
};

useEffect(() => {
if (validationError !== previousValidationError) {
Expand All @@ -102,6 +366,26 @@ const Calendar = forwardRef((
}
}, [date, previousDate, activeDate]);

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

const nextFocusIndex = nextFocusIndexRef.current;
const focusableMonthViewElements = getFocusableMonthViewElements();
const el = nextFocusIndex < 0

Check warning on line 376 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L374-L376

Added lines #L374 - L376 were not covered by tests
? focusableMonthViewElements[focusableMonthViewElements.length + nextFocusIndex]
: focusableMonthViewElements[nextFocusIndex];

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

Check warning on line 381 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L381

Added line #L381 was not covered by tests

// Use requestAnimationFrame to ensure that the focus is set after the DOM has been updated
requestAnimationFrame(() => {
el && el?.focus();

Check warning on line 385 in packages/react/src/date-pickers/Calendar/Calendar.js

View check run for this annotation

Codecov / codecov/patch

packages/react/src/date-pickers/Calendar/Calendar.js#L384-L385

Added lines #L384 - L385 were not covered by tests
});
}, [activeDate, getFocusableMonthViewElements]); // Re-run effect when activeDate changes

const onChange = useCallback((nextDate) => {
const isControlled = (dateProp !== undefined);
if (!isControlled) {
Expand All @@ -122,17 +406,21 @@ const Calendar = forwardRef((
setActiveDate,
shouldDisableDate,
});
const styleProps = useCalendarStyle();

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

return (
<CalendarProvider value={context}>
<Box
ref={ref}
onKeyDown={callEventHandlers(onKeyDownProp, eventHandler.onKeyDown)}
tabIndex={tabIndex}
{...styleProps}
{...rest}
>
<Navigation />
<MonthView />
<Navigation ref={navigationRef} />
<MonthView ref={monthViewRef} />
</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
Loading

0 comments on commit 0c97e2f

Please sign in to comment.