Skip to content

Commit 62f870f

Browse files
committed
fix(a11y): use robust tooltip tab order
1 parent c5831bf commit 62f870f

3 files changed

Lines changed: 21 additions & 24 deletions

File tree

src/frontend/src/components/core/canvasControlsComponent/CanvasControls.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ const CanvasControls = ({
225225
// break their flow.
226226
onOpenAutoFocus={(e) => e.preventDefault()}
227227
onCloseAutoFocus={(e) => e.preventDefault()}
228+
onFocusOutside={markDiscovered}
229+
onEscapeKeyDown={markDiscovered}
228230
data-testid="assistant-onboarding-tooltip"
229231
onKeyDown={handleOnboardingTooltipTabBoundary}
230232
// Canvas-level stacking: kept BELOW the z-50 modal/dialog/dropdown

src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { act, fireEvent, render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
23
import CanvasControls from "../CanvasControls";
34

45
// The modal/dialog/dropdown overlay layer in the app all sits at `z-50`
@@ -201,9 +202,10 @@ describe("CanvasControls", () => {
201202
}
202203
});
203204

204-
it("should_allow_tab_to_leave_onboarding_tooltip", () => {
205+
it("should_allow_tab_to_leave_onboarding_tooltip", async () => {
205206
localStorage.clear();
206207
jest.useFakeTimers();
208+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
207209
const nextButton = document.createElement("button");
208210
nextButton.textContent = "Next focus target";
209211

@@ -216,11 +218,10 @@ describe("CanvasControls", () => {
216218

217219
document.body.appendChild(nextButton);
218220

219-
const openButton = screen.getByTestId("assistant-onboarding-open");
220-
openButton.focus();
221-
fireEvent.keyDown(screen.getByTestId("assistant-onboarding-tooltip"), {
222-
key: "Tab",
223-
});
221+
screen.getByTestId("assistant-onboarding-dismiss").focus();
222+
await user.tab();
223+
expect(screen.getByTestId("assistant-onboarding-open")).toHaveFocus();
224+
await user.tab();
224225

225226
expect(nextButton).toHaveFocus();
226227
expect(

src/frontend/src/components/core/canvasControlsComponent/utils/use-dismiss-on-tab-boundary.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
import { type KeyboardEvent, useCallback, useRef } from "react";
2+
import { tabbable } from "tabbable";
23

3-
const TABBABLE_SELECTOR = [
4-
"a[href]",
5-
"button:not([disabled])",
6-
"input:not([disabled])",
7-
"select:not([disabled])",
8-
"textarea:not([disabled])",
9-
'[tabindex]:not([tabindex="-1"])',
10-
].join(",");
11-
12-
const getTabbableElements = (root: ParentNode) =>
13-
Array.from(root.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter(
4+
const getTabbableElements = (root: HTMLElement) =>
5+
tabbable(root, {
6+
displayCheck: process.env.NODE_ENV === "test" ? "none" : "full",
7+
}).filter(
148
(element) =>
15-
element.tabIndex >= 0 &&
16-
element.getAttribute("aria-hidden") !== "true" &&
17-
!element.hasAttribute("data-radix-focus-guard"),
9+
!element.hasAttribute("data-radix-focus-guard") &&
10+
!element.closest("[inert]") &&
11+
!element.closest('[aria-hidden="true"]'),
1812
);
1913

2014
export function useDismissOnTabBoundary<TElement extends HTMLElement>(
@@ -35,14 +29,14 @@ export function useDismissOnTabBoundary<TElement extends HTMLElement>(
3529
const containerTabbables = getTabbableElements(container);
3630
const firstContainerTabbable = containerTabbables[0];
3731
const lastContainerTabbable = containerTabbables.at(-1);
38-
const leavingBackward =
32+
const isLeavingBackward =
3933
event.shiftKey && activeElement === firstContainerTabbable;
40-
const leavingForward =
34+
const isLeavingForward =
4135
!event.shiftKey && activeElement === lastContainerTabbable;
4236

43-
if (!leavingBackward && !leavingForward) return;
37+
if (!isLeavingBackward && !isLeavingForward) return;
4438

45-
const documentTabbables = getTabbableElements(document);
39+
const documentTabbables = getTabbableElements(document.body);
4640
const activeIndex = documentTabbables.indexOf(activeElement);
4741
const candidates = event.shiftKey
4842
? documentTabbables.slice(0, activeIndex).reverse()

0 commit comments

Comments
 (0)