Skip to content

Commit

Permalink
fix: focus and autofocus improvements (#2265)
Browse files Browse the repository at this point in the history
  • Loading branch information
gpbl authored Jul 13, 2024
1 parent be74e38 commit 8b7b732
Show file tree
Hide file tree
Showing 10 changed files with 127 additions and 90 deletions.
12 changes: 12 additions & 0 deletions examples/AutoFocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from "react";

import { DayPicker } from "react-day-picker";

/** Test for the next focus day to not cause an infinite recursion. */
export function AutoFocus() {
return (
<div>
<DayPicker autoFocus mode="single" />
</div>
);
}
39 changes: 18 additions & 21 deletions examples/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,16 @@ export function Dialog() {
// Function to toggle the dialog visibility
const toggleDialog = () => setIsDialogOpen(!isDialogOpen);

// Hook to handle the body scroll behavior and focus trapping.
// Hook to handle the body scroll behavior and focus trapping. You may want to
// use your own trapping library as the body.style overflow will break the
// scroll position.
useEffect(() => {
const handleBodyScroll = (isOpen: boolean) => {
document.body.style.overflow = isOpen ? "hidden" : "";
};
if (!dialogRef.current) return;
if (isDialogOpen) {
handleBodyScroll(true);
dialogRef.current.showModal();
} else {
handleBodyScroll(false);
dialogRef.current.close();
}
return () => {
handleBodyScroll(false);
};
}, [isDialogOpen]);

/**
Expand Down Expand Up @@ -108,18 +102,21 @@ export function Dialog() {
aria-labelledby={headerId}
onClose={() => setIsDialogOpen(false)}
>
<DayPicker
month={month}
onMonthChange={setMonth}
mode="single"
selected={selectedDate}
onSelect={handleDayPickerSelect}
footer={
selectedDate !== undefined && (
<>Selected: {selectedDate.toDateString()}</>
)
}
/>
{isDialogOpen && (
<DayPicker
defaultMonth={selectedDate || month}
onMonthChange={setMonth}
autoFocus
mode="single"
selected={selectedDate}
onSelect={handleDayPickerSelect}
footer={
selectedDate !== undefined && (
<>Selected: {selectedDate.toDateString()}</>
)
}
/>
)}
</dialog>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions examples/__snapshots__/Range.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ exports[`should match the snapshot 1`] = `
<button
aria-label="Monday, June 1st, 2020"
class="rdp-day_button"
tabindex="0"
tabindex="-1"
>
1
</button>
Expand Down Expand Up @@ -353,7 +353,7 @@ exports[`should match the snapshot 1`] = `
<button
aria-label="Monday, June 15th, 2020, selected"
class="rdp-day_button"
tabindex="-1"
tabindex="0"
>
15
</button>
Expand Down
1 change: 1 addition & 0 deletions examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from "./DisableNavigation";
export * from "./Dropdown";
export * from "./DropdownMultipleMonths";
export * from "./Fixedweeks";
export * from "./AutoFocus";
export * from "./FocusRecursive";
export * from "./Footer";
export * from "./Formatters";
Expand Down
4 changes: 2 additions & 2 deletions src/DayPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ export function DayPicker(props: DayPickerProps) {

const modifiers = useModifiers(props, calendar, dateLib);
const selection = useSelection(props, dateLib);
const focus = useFocus(props, calendar, modifiers, dateLib);

const focus = useFocus(props, calendar, modifiers, selection, dateLib);
const {
captionLayout,
dir,
Expand Down Expand Up @@ -537,6 +536,7 @@ export function DayPicker(props: DayPickerProps) {
style={styles?.[UI.DayButton]}
day={day}
modifiers={m}
focused={isFocused}
disabled={m.disabled || undefined}
tabIndex={
focus.isFocusTarget(day) ? 0 : -1
Expand Down
11 changes: 9 additions & 2 deletions src/components/DayButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,21 @@ import type { Modifiers } from "../types/index.js";
*/
export function DayButton(
props: {
/** Whether the day is focused. */
focused: boolean;
/** The day to render. */
day: CalendarDay;
/** The modifiers for the day. */
modifiers: Modifiers;
} & JSX.IntrinsicElements["button"]
) {
const { day, modifiers, ...buttonProps } = props;
return <button {...buttonProps} />;
const { day, modifiers, focused, ...buttonProps } = props;

const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (focused) ref.current?.focus();
}, [focused]);
return <button ref={ref} {...buttonProps} />;
}

export type DayButtonProps = Parameters<typeof DayButton>[0];
48 changes: 48 additions & 0 deletions src/helpers/calculateFocusTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { DayFlag } from "../UI.js";
import type { CalendarDay } from "../classes/index.js";
import type { Modifiers } from "../types/index.js";
import { UseCalendar } from "../useCalendar.js";

export function calculateFocusTarget(
calendar: UseCalendar,
getModifiers: (day: CalendarDay) => Modifiers,
isSelected: (date: Date) => boolean,
lastFocused: CalendarDay | undefined
) {
let focusTarget: CalendarDay | undefined;

let index = 0;
let found = false;

while (index < calendar.days.length && !found) {
const day = calendar.days[index];
const m = getModifiers(day);

if (!m[DayFlag.disabled] && !m[DayFlag.hidden] && !m[DayFlag.outside]) {
if (m[DayFlag.focused]) {
focusTarget = day;
found = true;
} else if (lastFocused?.isEqualTo(day)) {
focusTarget = day;
found = true;
} else if (isSelected(day.date)) {
focusTarget = day;
found = true;
} else if (m[DayFlag.today]) {
focusTarget = day;
found = true;
}
}

index++;
}

if (!focusTarget) {
// return the first day that is focusable
focusTarget = calendar.days.find((day) => {
const m = getModifiers(day);
return !m[DayFlag.disabled] && !m[DayFlag.hidden] && !m[DayFlag.outside];
});
}
return focusTarget;
}
3 changes: 3 additions & 0 deletions src/types/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,11 @@ export interface PropsBase {
*
* Use this prop when you need to focus DayPicker after a user actions, for
* improved accessibility.
*
* @see https://daypicker.dev/next/using-daypicker/accessibility#autofocus
*/
autoFocus?: boolean;

/** Apply the `disabled` modifier to the matching days. */
disabled?: Matcher | Matcher[] | undefined;
/**
Expand Down
79 changes: 16 additions & 63 deletions src/useFocus.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { useEffect, useState } from "react";
import { useState } from "react";

import { DayFlag } from "./UI.js";
import type { CalendarDay } from "./classes/index.js";
import { calculateFocusTarget } from "./helpers/calculateFocusTarget.js";
import { getNextFocus } from "./helpers/getNextFocus.js";
import type {
MoveFocusBy,
MoveFocusDir,
DateLib,
DayPickerProps
DayPickerProps,
Mode
} from "./types/index.js";
import { UseCalendar } from "./useCalendar.js";
import { UseModifiers } from "./useModifiers.js";
import { UseSelection } from "./useSelection.js";

export type UseFocus = {
/** The date that is currently focused. */
Expand Down Expand Up @@ -65,52 +67,28 @@ export function useFocus(
>,
calendar: UseCalendar,
modifiers: UseModifiers,
selection: UseSelection<{ mode: Mode }>,
dateLib: DateLib
): UseFocus {
const { getModifiers } = modifiers;

const [focusedDay, setFocused] = useState<CalendarDay | undefined>();
const { autoFocus } = props;
const [lastFocused, setLastFocused] = useState<CalendarDay | undefined>();

useEffect(() => {
if (focusedDay) {
getDayCell(focusedDay, (props.numberOfMonths ?? 1) > 1, dateLib)?.focus();
}
}, [dateLib, focusedDay, props.numberOfMonths]);
const focusTarget = calculateFocusTarget(
calendar,
getModifiers,
selection.isSelected,
lastFocused
);
const [focusedDay, setFocused] = useState<CalendarDay | undefined>(
autoFocus ? focusTarget : undefined
);

const blur = () => {
setLastFocused(focusedDay);
setFocused(undefined);
};

let focusTarget: CalendarDay | undefined;

calendar.days.map((day) => {
const m = getModifiers(day);
if (m[DayFlag.disabled]) return;
if (m[DayFlag.hidden]) return;
if (m[DayFlag.outside]) return;

if (m[DayFlag.focused]) {
focusTarget = day;
return;
}
if (lastFocused?.isEqualTo(day)) {
focusTarget = day;
return;
}

if (m[DayFlag.today]) {
focusTarget = day;
return;
}

if (!focusTarget && dateLib.isSameDay(day.date, calendar.months[0].date)) {
focusTarget = day;
return;
}
});

const moveFocus = (moveBy: MoveFocusBy, moveDir: MoveFocusDir) => {
if (!focusedDay) return;
const nextFocus = getNextFocus(
Expand All @@ -133,8 +111,6 @@ export function useFocus(
};

const useFocus: UseFocus = {
// focusTarget,
// initiallyFocused,
isFocusTarget,
setFocused,
focused: focusedDay,
Expand All @@ -154,26 +130,3 @@ export function useFocus(

return useFocus;
}

/**
* Get the day cell element for the given day from the data-day and data-month
* attribute.
*
* @private
*/
function getDayCell(
focused: CalendarDay,
multipleMonths: boolean,
dateLib: DateLib
) {
const dataDay = dateLib.format(focused.date, "yyyy-MM-dd");
const dataMonth = dateLib.format(focused.displayMonth, "yyyy-MM");
let selector = `[data-day="${dataDay}"]`;
if (multipleMonths) {
selector += `[data-month="${dataMonth}"]`;
}
const dayCell = window.document.querySelector(
`${selector} button`
) as HTMLButtonElement | null;
return dayCell;
}
16 changes: 16 additions & 0 deletions website/docs/using-daypicker/accessibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ export function AccessibleDatePicker() {
<Examples.AccessibleDatePicker />
</BrowserWindow>

## Autofocusing the Calendar {#autofocus}

DayPicker manages focus automatically when the user interacts with the calendar. However, for better accessibility you may need to autofocus the calendar when it opens. To do this, you can use the `autofocus` prop:

```tsx
<DayPicker mode="single" autoFocus />
```

<BrowserWindow>
<Examples.AutoFocus />
</BrowserWindow>

## Keyboard Navigation

DayPicker supports keyboard navigation to make it easier for users to navigate the calendar. The following keys are supported:
Expand All @@ -87,3 +99,7 @@ DayPicker supports keyboard navigation to make it easier for users to navigate t
Accessibility is an evolving field. If you find any issues with DayPicker, please [open an issue](https://github.com/gpbl/react-day-picker/issues/new/choose). Your feedback helps improve our library's accessibility.

Check out the [current accessibility issues](https://github.com/gpbl/react-day-picker/issues?q=is%3Aopen+label%3Aaccessibility+sort%3Aupdated-desc).

```
```

0 comments on commit 8b7b732

Please sign in to comment.