Repository: gpbl/react-day-picker
Pull request #2685 (2025-02-16 23:26:39)

fix: focus lost when navigation button is disabled
Fixes issue #2630 by replacing the `disabled` attribute with `aria-disabled`, to prevent the focus loss when navigating to the last month.

After some research, I believe this change won’t cause any other accessibility issues.

Breaking change (style): This change may affect the style of those who rely on the `[disabled]/:disabled` CSS selectors. To fix this, use `aria-disabled` instead.

```diff
- .rdp-button_next[disabled="true"],
- .rdp-button_next:disabled,
+ .rdp-button_next[aria-disabled="true"],
- .rdp-button_previous:disabled,
- .rdp-button_previous[disabled="true"],
+ .rdp-button_previous[aria-disabled="true"] {
   /* Existing custom style */
}
```

https://github.com/user-attachments/assets/986d55eb-18b0-431f-9eab-86e1a39e4262





rodgobbi: @gpbl
I discussed the https://github.com/gpbl/react-day-picker/issues/2630 issue with my friend, who has experience with a11y, and he mentioned that the core issue is not that the page loses focus when the button becomes disabled but that the action no longer makes sense and has no effect.
To illustrate with an example, with this PR, even if the button is still focused and not fully disabled, a user using AT would still read the description of the button as "Go to the Next Month", they would try to press it and not get any feedback.

It seems like this PR would not fully fix the a11y issues.
May I do more research and come back to you in this PR with more information?

My friend recommended checking the [Adobe Spectrum](https://react-spectrum.adobe.com/react-spectrum/index.html) design system, which has great accessibility, and they have a [DatePicker](https://react-spectrum.adobe.com/react-spectrum/DatePicker.html), I'll study how it behaves with AT to provide more feedback to the `React DayPicker`.

Let me know what you think.
gpbl: I agree that further research is required here.

At the very least, I would defer to the developer to address these kinds of issues. However, the issue of losing focus appears to be the most significant one to me. 

>  the core issue is not that the page loses focus 

Well, this is the accessibility issue we need to address. Is it indeed an issue, or are we mistaken? 

> the action no longer makes sense and has no effect.

Shouldn’t this be communicated via an aria-live region somehow? Which is worse - losing focus or having action become unavailable? :thinking: 
rodgobbi: @gpbl thanks for the patience

> Well, this is the accessibility issue we need to address. Is it indeed an issue, or are we mistaken?

I'll do some further research, I'm not an expert in a11y, so I'll need some time to come up with an answer that I feel confident to give.

> Shouldn’t this be communicated via an aria-live region somehow?

My initial impression is yes, I'll check what other applications and libs do.

> Which is worse - losing focus or having action become unavailable?

My friend gave the opinion that both are not enough for a11y. I also asked what if the focus changes to the other button to navigate the months to the other direction, but he mentioned it's also not great if the users using AT are not informed of what happened.

Patch:
diff --git a/examples/FocusedDisabledNav.tsx b/examples/FocusedDisabledNav.tsx
new file mode 100644
index 000000000..98f9c13ca
--- /dev/null
+++ b/examples/FocusedDisabledNav.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+
+import { addMonths } from "date-fns";
+import { DayPicker } from "react-day-picker";
+
+/** Example for bug #2630. */
+export function FocusedDisabledNav() {
+  return (
+    <DayPicker
+      mode="single"
+      startMonth={addMonths(new Date(), -2)}
+      endMonth={new Date()}
+    />
+  );
+}
diff --git a/examples/index.ts b/examples/index.ts
index 332127232..ba5486381 100644
--- a/examples/index.ts
+++ b/examples/index.ts
@@ -24,6 +24,7 @@ export * from "./DropdownMonths";
 export * from "./DropdownMultipleMonths";
 export * from "./Fixedweeks";
 export * from "./FocusRecursive";
+export * from "./FocusedDisabledNav";
 export * from "./Footer";
 export * from "./Formatters";
 export * from "./FromToMonth";
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx
index b9dc2c4d6..df4b171b1 100644
--- a/src/components/Nav.tsx
+++ b/src/components/Nav.tsx
@@ -1,4 +1,8 @@
-import React, { type MouseEventHandler, type HTMLAttributes } from "react";
+import React, {
+  type MouseEventHandler,
+  type HTMLAttributes,
+  useCallback
+} from "react";
 
 import { UI } from "../UI.js";
 import { useDayPicker } from "../useDayPicker.js";
@@ -31,15 +35,33 @@ export function Nav(
     labels: { labelPrevious, labelNext }
   } = useDayPicker();
 
+  const handleNextClick = useCallback(
+    (e: React.MouseEvent<HTMLButtonElement>) => {
+      if (nextMonth) {
+        onNextClick?.(e);
+      }
+    },
+    [nextMonth, onNextClick]
+  );
+
+  const handlePreviousClick = useCallback(
+    (e: React.MouseEvent<HTMLButtonElement>) => {
+      if (previousMonth) {
+        onPreviousClick?.(e);
+      }
+    },
+    [previousMonth, onPreviousClick]
+  );
+
   return (
     <nav {...navProps}>
       <components.PreviousMonthButton
         type="button"
         className={classNames[UI.PreviousMonthButton]}
         tabIndex={previousMonth ? undefined : -1}
-        disabled={previousMonth ? undefined : true}
+        aria-disabled={previousMonth ? undefined : true}
         aria-label={labelPrevious(previousMonth)}
-        onClick={props.onPreviousClick}
+        onClick={handlePreviousClick}
       >
         <components.Chevron
           disabled={previousMonth ? undefined : true}
@@ -51,9 +73,9 @@ export function Nav(
         type="button"
         className={classNames[UI.NextMonthButton]}
         tabIndex={nextMonth ? undefined : -1}
-        disabled={nextMonth ? undefined : true}
+        aria-disabled={nextMonth ? undefined : true}
         aria-label={labelNext(nextMonth)}
-        onClick={props.onNextClick}
+        onClick={handleNextClick}
       >
         <components.Chevron
           disabled={nextMonth ? undefined : true}
diff --git a/src/style.css b/src/style.css
index df652b807..4d8b1cac2 100644
--- a/src/style.css
+++ b/src/style.css
@@ -138,7 +138,9 @@
 }
 
 .rdp-button_next:disabled,
-.rdp-button_previous:disabled {
+.rdp-button_next[aria-disabled="true"],
+.rdp-button_previous:disabled,
+.rdp-button_previous[aria-disabled="true"] {
   cursor: revert;
 
   opacity: var(--rdp-nav_button-disabled-opacity);
diff --git a/src/style.module.css b/src/style.module.css
index f78a9168c..4c4bd60dd 100644
--- a/src/style.module.css
+++ b/src/style.module.css
@@ -138,7 +138,9 @@
 }
 
 .button_next:disabled,
-.button_previous:disabled {
+.button_next[aria-disabled="true"],
+.button_previous:disabled,
+.button_previous[aria-disabled="true"] {
   cursor: revert;
 
   opacity: var(--rdp-nav_button-disabled-opacity);


Test patch:
diff --git a/examples/FocusedDisabledNav.test.tsx b/examples/FocusedDisabledNav.test.tsx
new file mode 100644
index 000000000..1bbb5a30c
--- /dev/null
+++ b/examples/FocusedDisabledNav.test.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+
+import { dateButton, nextButton, previousButton } from "@/test/elements";
+import { render } from "@/test/render";
+import { user } from "@/test/user";
+
+import { FocusedDisabledNav } from "./FocusedDisabledNav";
+
+const today = new Date();
+
+describe("FocusedDisabledNav", () => {
+  test("should not focus the aria-disabled navigation button", async () => {
+    render(<FocusedDisabledNav />);
+    await user.tab();
+    expect(previousButton()).toHaveFocus();
+    await user.tab();
+    expect(nextButton()).not.toHaveFocus();
+    expect(dateButton(today)).toHaveFocus(); // should ignore next button and focus today's date
+  });
+  test("should keep focus on the disabled navigation button", async () => {
+    render(<FocusedDisabledNav />);
+    await user.tab();
+    await user.keyboard("{enter}");
+    expect(previousButton()).toHaveFocus();
+    await user.tab();
+    expect(nextButton()).toHaveFocus();
+    await user.keyboard("{enter}");
+    expect(nextButton()).toHaveFocus();
+    expect(nextButton()).toHaveAttribute("aria-disabled", "true");
+    expect(nextButton()).not.toBeDisabled();
+    expect(dateButton(today)).not.toHaveFocus();
+  });
+});
diff --git a/examples/FromToMonth.test.tsx b/examples/FromToMonth.test.tsx
index 02ec66fd6..04953a2c1 100644
--- a/examples/FromToMonth.test.tsx
+++ b/examples/FromToMonth.test.tsx
@@ -13,11 +13,11 @@ beforeEach(() => {
 });
 
 test("the previous button should be disabled", () => {
-  expect(previousButton()).toHaveAttribute("disabled");
+  expect(previousButton()).toHaveAttribute("aria-disabled", "true");
 });
 
 test("the next button should be enabled", () => {
-  expect(nextButton()).not.toHaveAttribute("disabled");
+  expect(nextButton()).not.toHaveAttribute("aria-disabled", "true");
 });
 
 describe("when navigating to the last month", () => {
@@ -31,10 +31,10 @@ describe("when navigating to the last month", () => {
   });
 
   test("the previous button should not be disabled", () => {
-    expect(previousButton()).not.toHaveAttribute("disabled");
+    expect(previousButton()).not.toHaveAttribute("aria-disabled", "true");
   });
 
   test("the next button should be disabled", () => {
-    expect(nextButton()).toHaveAttribute("disabled");
+    expect(nextButton()).toHaveAttribute("aria-disabled", "true");
   });
 });
diff --git a/examples/FromToYear.test.tsx b/examples/FromToYear.test.tsx
index 36ead17e2..162303016 100644
--- a/examples/FromToYear.test.tsx
+++ b/examples/FromToYear.test.tsx
@@ -20,10 +20,10 @@ beforeEach(() => {
 });
 
 test("the previous month button should be disabled", () => {
-  expect(previousButton()).toHaveAttribute("disabled");
+  expect(previousButton()).toHaveAttribute("aria-disabled", "true");
 });
 test("the next month button should not be disabled", () => {
-  expect(nextButton()).not.toHaveAttribute("disabled");
+  expect(nextButton()).not.toHaveAttribute("aria-disabled", "true");
 });
 
 describe("when navigating to the last month", () => {
@@ -34,9 +34,9 @@ describe("when navigating to the last month", () => {
     }
   });
   test("the previous month button should not be disabled", () => {
-    expect(previousButton()).not.toHaveAttribute("disabled");
+    expect(previousButton()).not.toHaveAttribute("aria-disabled", "true");
   });
   test("the next month button should be disabled", () => {
-    expect(nextButton()).toHaveAttribute("disabled");
+    expect(nextButton()).toHaveAttribute("aria-disabled", "true");
   });
 });
diff --git a/examples/Start.test.tsx b/examples/Start.test.tsx
index 636484c50..3299bb0bb 100644
--- a/examples/Start.test.tsx
+++ b/examples/Start.test.tsx
@@ -22,13 +22,13 @@ beforeEach(async () => {
 
 test("should display the next month button", async () => {
   expect(nextButton()).toBeVisible();
-  expect(nextButton()).not.toHaveAttribute("disabled");
+  expect(nextButton()).not.toHaveAttribute("aria-disabled", "true");
   expect(nextButton()).not.toHaveAttribute("disabled", "true");
 });
 
 test("should display the previous month buttons", async () => {
   expect(previousButton()).toBeVisible();
-  expect(previousButton()).not.toHaveAttribute("disabled");
+  expect(previousButton()).not.toHaveAttribute("aria-disabled", "true");
   expect(previousButton()).not.toHaveAttribute("disabled", "true");
 });