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

fix: focus and autofocus improvements #2265

Merged
merged 2 commits into from
Jul 13, 2024
Merged
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
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).

```

```