fix(ui): a dialog opens focused where it said it would, and the wizard is a dialog
Deep-review round 5, over round 4's own fixes. - Round 4 gave `ui/Modal` a persistent portal host so a fullscreen transition would stop tearing the dialog down. The host was attached in a `useLayoutEffect` — one commit phase AFTER React runs `autoFocus` in `commitMount`, and `focus()` on a disconnected node is a no-op. Every autoFocus in every dialog was dead: Mod+K opened the command palette with the focus on the ✕, so typing searched nothing and Enter closed it; the same for the download rename field and every non-danger confirm. The host is attached in a `useInsertionEffect` now, a whole phase earlier, and the whole re-parent — including the focus save/restore across the move, which `appendChild` needs because it is remove-then-insert — lives in one `useFullscreenPortalHost`. `ui/AnchoredPopover` had a second copy of that re-parent WITHOUT the focus restore, and a comment claiming focus survived it; both now come from the hook. - `ui/Toaster` classified a toast raised in the SAME commit as a host takeover among the ones it inherits, because it read the store live at the takeover render. A dialog that notifies and closes in one handler (UploadDialog) therefore put its toast up with no role, into a region still `aria-live="off"` — announced by neither path. "Inherited" is now what the outgoing host last had on screen, and a toast that is NOT inherited is announced even inside the unarmed window. - The onboarding wizard renders its own shell, and Escape ordering was all of the dialog contract it had: no role, no label, no initial focus, no trap, no restore. A keyboard user tabbed out of the first-login wizard into the app behind the scrim. The focus half of the contract is now one shared `ui/dialogFocus` that both `ui/Modal` and the wizard use. Every fix has a regression test, each mutation-checked (6 mutations, all red on the assertion they are named after).
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// @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 } 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";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user