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:
@@ -6,6 +6,7 @@
|
||||
// the player. An overlay mounts its own host, and exactly one host shows the toasts.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -89,6 +90,41 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
expect(region.getAttribute("aria-live")).toBe("polite"); // and polite for what comes next
|
||||
});
|
||||
|
||||
it("announces a toast RAISED in the same commit as the takeover, and still not the inherited ones", () => {
|
||||
// A dialog that notifies and closes in ONE handler (UploadDialog: "added to the queue" + close)
|
||||
// puts its toast into the very batch that hands the app host the top spot. Classifying it from
|
||||
// the store live at the takeover render counted it among the inherited, so it went in with no
|
||||
// role into a region that was still `aria-live="off"` — announced by neither path (round 5).
|
||||
function Stage() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
{open && (
|
||||
<>
|
||||
<Toaster overlay />
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "upload queued" });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
upload
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Stage />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
fireEvent.click(screen.getByText("upload"));
|
||||
|
||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||
expect(card("upload queued").getAttribute("role")).toBe("status");
|
||||
expect(card("old news").getAttribute("role")).toBeNull();
|
||||
});
|
||||
|
||||
it("ranks hosts by when their OVERLAY opened, not by when the host mounted", async () => {
|
||||
// PlayerModal's host moves into the fullscreen stage and back: remounted, it used to jump over a
|
||||
// dialog opened above the player, and the toasts went back under that dialog's scrim.
|
||||
|
||||
Reference in New Issue
Block a user