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:
2026-09-12 03:41:37 +02:00
parent 545996f807
commit 41b652f8fd
9 changed files with 366 additions and 99 deletions
@@ -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();
});
});