Round 5 extracted `ui/dialogFocus` so no shell would hand-roll the focus contract, then introduced four ways to lose the focus anyway — all four measured in jsdom against the real components. **Ownership was narrower than the trap.** `ui/Modal` traps Tab over the whole dialog on purpose, so a toast's Undo or ✕ stays reachable; ownership was tracked over the panel alone. Tabbing to that ✕ therefore read as "the user left the dialog", and since removing a toast fires no `focusin` to re-arm, closing afterwards skipped the restore and left the focus on <body>. Before this hook existed the restore was unconditional, so it was a regression. **A dialog stacked above us is still ours.** A nested `ui/Modal` portals into its own host, so it is outside the outer dialog's root however wide that scope is drawn — widening alone could not fix it. Both closing in one commit left the focus on <body>: the outer skipped its restore, and the child's own target (a control inside the outer panel) was already detached, because passive cleanups run parent-first. Ownership now means "inside my dialog, or inside a modal layer above it", which is what the rule always intended. **The wizard disagreed with `ui/Modal` on its first line.** `trapTab(e, panel)`, with neither the dialog-root scope (so its own toast host was outside the cycle — the connection notice never auto-dismisses, and its ✕ was unreachable from the keyboard) nor the `container` argument the JSDoc exists for: `panel.contains( panel)` is true, so focus sitting ON the tabIndex=-1 panel matched no branch and a Shift+Tab walked into the app behind the scrim. Both call sites now pass the same three arguments. **The toast handover was a render-phase read-modify-write.** A `useMemo` read a module global that the next line overwrote; React re-invokes a component body and every `useMemo` factory in it on a StrictMode double render, and the LAST invocation commits — so the second pass re-read what the first had just written, the takeover host classified its OWN new toast as inherited, and the dev build behaved exactly like the code round 5 replaced. Snapshotted through a ref keyed on `isTop`, so a repeated invocation of one render is a no-op. And the fourth `aria-modal` shell, `Welcome`'s screenshot lightbox, is finally swept into the same contract: it declared itself a modal dialog while leaving the focus on the thumbnail behind an 80%-black scrim and letting Tab walk the landing page underneath. The headline fix of the previous commit — StrictMode's re-mount taking the focus off an `autoFocus` field — had no test at all; deleting it left all 1009 green. It has one now. 9 tests, mutation-checked against 8 mutations, each red on the test that names it.
146 lines
6.6 KiB
TypeScript
146 lines
6.6 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
|
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<string, unknown>).offsetParent;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const mount = () =>
|
|
render(
|
|
<QueryClientProvider
|
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
|
>
|
|
<OnboardingWizard me={me} onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
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<HTMLElement>("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<HTMLElement>("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 <body> (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 `<Toaster overlay>` 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<HTMLElement>("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());
|
|
}
|
|
});
|
|
});
|