// @vitest-environment jsdom // // The wizard is a modal overlay in every way that matters to a user, but it renders its own shell // instead of going through `ui/Modal` — and for a keyboard or screen-reader user it had nothing of // the dialog contract: no role, no label, no initial focus, no trap, no restore. Tab past its last // control walked into the app behind the scrim, where the focus ring is invisible (round 5). // // `focusablesIn` filters on `el.offsetParent !== null` and jsdom reports null for everything, so the // trap is untestable as written without the same stub `ui/Modal.dom.test.tsx` uses. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, cleanup, screen, fireEvent, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, }; }); vi.mock("../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.fn() })); import OnboardingWizard from "./OnboardingWizard"; import { api, type Me } from "../lib/api"; import { clearAll, notify } from "../lib/notifications"; const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me; let origDesc: PropertyDescriptor | undefined; beforeEach(() => { vi.spyOn(api, "myStatus").mockResolvedValue({ channels_total: 0 } as never); origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent"); Object.defineProperty(HTMLElement.prototype, "offsetParent", { configurable: true, get(this: HTMLElement) { return this.isConnected ? document.body : null; }, }); }); afterEach(() => { cleanup(); if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc); else delete (HTMLElement.prototype as unknown as Record).offsetParent; vi.restoreAllMocks(); }); const mount = () => render( {}} /> , ); describe("the onboarding wizard as a dialog", () => { it("announces itself as a labelled modal dialog", () => { mount(); const panel = screen.getByRole("dialog"); expect(panel.getAttribute("aria-modal")).toBe("true"); // The label is the step's own heading — the wizard has no header row to take a title from. const heading = document.getElementById(panel.getAttribute("aria-labelledby")!); expect(heading?.textContent).toBe("onboarding.read.title"); }); it("opens focused inside itself and keeps Tab from leaving", () => { const outside = document.createElement("button"); outside.textContent = "behind the scrim"; document.body.appendChild(outside); outside.focus(); const { unmount } = mount(); const panel = screen.getByRole("dialog"); expect(panel.contains(document.activeElement)).toBe(true); // From the last control, Tab wraps back to the first instead of reaching the page behind. const stops = [...panel.querySelectorAll("button, a[href]")]; stops[stops.length - 1]!.focus(); fireEvent.keyDown(window, { key: "Tab" }); expect(document.activeElement).toBe(stops[0]); // …and closing hands the focus back to whatever opened it. unmount(); expect(document.activeElement).toBe(outside); outside.remove(); }); it("holds a Shift+Tab that starts on the panel itself", () => { // `Node.contains(self)` is true, so focus sitting ON the tabIndex=-1 panel — where a click on // its dead space puts it — matched no branch of `trapTab` and the browser's native Shift+Tab // ran, into the app behind the scrim. That is what the third argument exists for, and this // caller was written without it on the very commit that extracted the helper (round 6). mount(); const panel = screen.getByRole("dialog"); panel.focus(); expect(document.activeElement).toBe(panel); const stops = [...panel.querySelectorAll("button, a[href]")]; fireEvent.keyDown(window, { key: "Tab", shiftKey: true }); expect(document.activeElement).toBe(stops[stops.length - 1]); }); it("still hands the focus back after it visited the wizard's own toast", () => { // Ownership has to be measured over the same scope the Tab trap uses. The wizard's toast host // sits beside the panel and INSIDE the dialog, so tabbing to a toast's ✕ is legal — but read // against the panel alone it said "the user left", and since removing the toast fires no // focusin to re-arm, closing afterwards stranded the focus on (round 6). Unlike // `ui/Modal`, whose root carries the aria-modal attribute, nothing else can rescue this one. const outside = document.createElement("button"); document.body.appendChild(outside); outside.focus(); const { unmount } = mount(); act(() => void notify({ level: "error", message: "offline" })); const dismissToast = screen.getByTitle("notifications.dismiss"); dismissToast.focus(); expect(document.activeElement).toBe(dismissToast); act(() => clearAll()); // the toast goes away under the focus — no focusin follows unmount(); expect(document.activeElement).toBe(outside); outside.remove(); }); it("keeps its own toast's dismiss button inside the Tab cycle", () => { // The wizard renders its own `` beside the panel, and the connection notice it // can raise never auto-dismisses — so a trap scoped to the panel alone left that ✕ permanently // unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason. mount(); const panel = screen.getByRole("dialog"); act(() => void notify({ level: "error", message: "offline" })); try { const dismissToast = screen.getByTitle("notifications.dismiss"); expect(panel.contains(dismissToast)).toBe(false); const stops = [...panel.querySelectorAll("button, a[href]")]; stops[stops.length - 1]!.focus(); fireEvent.keyDown(window, { key: "Tab" }); // No longer the edge: Tab carries on (natively) towards the toast instead of wrapping. expect(document.activeElement).toBe(stops[stops.length - 1]); dismissToast.focus(); fireEvent.keyDown(window, { key: "Tab" }); expect(document.activeElement).toBe(stops[0]); } finally { act(() => clearAll()); } }); });