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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useId, useRef } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
@@ -9,6 +9,7 @@ import { Button } from "./ui/Button";
|
|||||||
import Overlay from "./ui/Overlay";
|
import Overlay from "./ui/Overlay";
|
||||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||||
import { useLayer } from "../lib/layerStack";
|
import { useLayer } from "../lib/layerStack";
|
||||||
|
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||||
|
|
||||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||||
@@ -109,7 +110,26 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
// A layer in the shared stack, so Escape closes the wizard only when it is on top: a raw document
|
// A layer in the shared stack, so Escape closes the wizard only when it is on top: a raw document
|
||||||
// listener ran BEFORE the error dialog's (a sync that failed inside the wizard opens one), and one
|
// listener ran BEFORE the error dialog's (a sync that failed inside the wizard opens one), and one
|
||||||
// Esc closed the error AND ended onboarding, skipping the write-scope step (deep-review round 4).
|
// Esc closed the error AND ended onboarding, skipping the write-scope step (deep-review round 4).
|
||||||
useLayer(true, close);
|
const layer = useLayer(true, close);
|
||||||
|
|
||||||
|
// The rest of the dialog contract. This is a modal overlay in every way that matters to a user, but
|
||||||
|
// it renders its own shell (it is not a titled dialog with a header row), so Escape ordering was all
|
||||||
|
// it had: focus started on <body>, nothing announced a dialog, and Tab past the last control walked
|
||||||
|
// into the app behind the scrim — where the focus ring is invisible (deep-review round 5). The trap
|
||||||
|
// and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy.
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
useDialogChrome(panelRef);
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
// Only while nothing is above us: an error dialog opened from inside the wizard owns the Tab.
|
||||||
|
if (e.key !== "Tab" || !layer.isTopmost()) return;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (panel) trapTab(e, panel);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [layer]);
|
||||||
|
|
||||||
// Are we currently importing (or about to)? Show a progress screen instead of the
|
// Are we currently importing (or about to)? Show a progress screen instead of the
|
||||||
// "you're connected" step until the first import settles.
|
// "you're connected" step until the first import settles.
|
||||||
@@ -130,7 +150,14 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
|
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
/>
|
/>
|
||||||
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
||||||
@@ -144,7 +171,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||||
<Youtube className="w-6 h-6" />
|
<Youtube className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
|
<h2 id={titleId} className="text-lg font-semibold">
|
||||||
|
{t("onboarding.read.title")}
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="onboarding.read.body"
|
i18nKey="onboarding.read.body"
|
||||||
@@ -172,7 +201,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
|
<h2 id={titleId} className="text-lg font-semibold">
|
||||||
|
{t("onboarding.importing.title")}
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
|
||||||
{t("onboarding.importing.body")}
|
{t("onboarding.importing.body")}
|
||||||
</p>
|
</p>
|
||||||
@@ -184,7 +215,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||||
<Check className="w-6 h-6" />
|
<Check className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
|
<h2 id={titleId} className="text-lg font-semibold">
|
||||||
|
{t("onboarding.write.title")}
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||||
{importSubs.isError ? (
|
{importSubs.isError ? (
|
||||||
<>{t("onboarding.write.importFailed")}</>
|
<>{t("onboarding.write.importFailed")}</>
|
||||||
@@ -241,7 +274,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||||
<Check className="w-6 h-6" />
|
<Check className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
|
<h2 id={titleId} className="text-lg font-semibold">
|
||||||
|
{t("onboarding.done.title")}
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
||||||
{t("onboarding.done.body")}
|
{t("onboarding.done.body")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ButtonHTMLAttributes,
|
type ButtonHTMLAttributes,
|
||||||
@@ -37,7 +36,7 @@ import {
|
|||||||
type UseRoleProps,
|
type UseRoleProps,
|
||||||
} from "@floating-ui/react";
|
} from "@floating-ui/react";
|
||||||
import { useLayer } from "../../lib/layerStack";
|
import { useLayer } from "../../lib/layerStack";
|
||||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||||
|
|
||||||
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
||||||
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
||||||
@@ -528,19 +527,14 @@ function PopoverSurfaceInner({
|
|||||||
|
|
||||||
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
|
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
|
||||||
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
|
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
|
||||||
// fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
|
// fullscreen element. One persistent container div is re-parented (body ↔ fullscreen element)
|
||||||
// element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
|
// rather than handed to the portal as a changing container, so the open panel's caret, scroll
|
||||||
// focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
|
// offset and animation state survive the transition, and an element SWAP (A→B without exiting) is
|
||||||
// SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
|
// handled by the element-identity dependency. (Keying the portal to remount instead would rebuild
|
||||||
// remount instead would rebuild the subtree and lose all of that.)
|
// the subtree and lose all of that.) Focus does NOT survive on its own — `appendChild` on an
|
||||||
const fsRoot = useFullscreenElement();
|
// already-parented node is remove-then-insert, which blurs to <body>; the hook saves and restores
|
||||||
const [container] = useState<HTMLDivElement | null>(() =>
|
// it. This used to be a second copy of Modal's re-parent that lacked exactly that (round 5).
|
||||||
typeof document === "undefined" ? null : document.createElement("div"),
|
const container = useFullscreenPortalHost();
|
||||||
);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (container) (fsRoot ?? document.body).appendChild(container);
|
|
||||||
}, [container, fsRoot]);
|
|
||||||
useEffect(() => () => container?.remove(), [container]);
|
|
||||||
|
|
||||||
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
|
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
|||||||
@@ -50,3 +50,38 @@ describe("a dialog open while fullscreen is left", () => {
|
|||||||
stage.remove();
|
stage.remove();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a dialog with an autoFocus child", () => {
|
||||||
|
it("opens with the focus in that child, not on the close button", () => {
|
||||||
|
// The portal host is created detached and attached by the hook. If it is attached a phase too
|
||||||
|
// late (a `useLayoutEffect` instead of `useInsertionEffect`), every descendant's `autoFocus`
|
||||||
|
// has already called `.focus()` on a disconnected node — a no-op — and Modal's own fallback
|
||||||
|
// then puts the focus on the first focusable in the panel, the header's ✕. That is what the
|
||||||
|
// command palette, the download rename dialog and every non-danger confirm shipped with after
|
||||||
|
// deep-review round 4 (found in round 5).
|
||||||
|
render(
|
||||||
|
<Modal title="Search" onClose={() => {}}>
|
||||||
|
<input aria-label="query" autoFocus />
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(screen.getByLabelText("query"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still has it after a fullscreen transition moves the host", async () => {
|
||||||
|
const stage = document.createElement("div");
|
||||||
|
document.body.appendChild(stage);
|
||||||
|
render(
|
||||||
|
<Modal title="Search" onClose={() => {}}>
|
||||||
|
<input aria-label="query" autoFocus />
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const field = screen.getByLabelText("query");
|
||||||
|
expect(document.activeElement).toBe(field);
|
||||||
|
|
||||||
|
await setFullscreen(stage); // the player goes fullscreen under the open dialog
|
||||||
|
|
||||||
|
expect(stage.contains(field)).toBe(true);
|
||||||
|
expect(document.activeElement).toBe(field);
|
||||||
|
stage.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useBackToClose } from "../../lib/history";
|
import { useBackToClose } from "../../lib/history";
|
||||||
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
||||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||||
|
import { trapTab, useDialogChrome } from "./dialogFocus";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import Toaster, { useOverlayOrder } from "./Toaster";
|
import Toaster, { useOverlayOrder } from "./Toaster";
|
||||||
|
|
||||||
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
|
||||||
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
|
||||||
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
|
||||||
const FOCUSABLE =
|
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
||||||
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
|
||||||
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
|
||||||
|
|
||||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||||
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
||||||
@@ -44,31 +37,13 @@ export default function Modal({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose);
|
||||||
const fsRoot = useFullscreenElement();
|
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
|
||||||
// ONE container element for the dialog's whole life, MOVED between <body> and the fullscreen
|
// element (deep-review round 4) — and attached before the children commit, so an `autoFocus` child
|
||||||
// element rather than handed to `createPortal` as a changing container: React tears a portal down
|
// still gets the focus (round 5). Both rules live in the hook; `ui/AnchoredPopover` shares it.
|
||||||
// and builds it again when its container changes, and the dialog's contents went with it — a
|
const host = useFullscreenPortalHost();
|
||||||
// half-filled download form reset to its defaults, and focus fell to <body>, the moment the viewer
|
|
||||||
// left true fullscreen (deep-review round 4).
|
|
||||||
const [host] = useState(() => document.createElement("div"));
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const parent = fsRoot ?? document.body;
|
|
||||||
if (host.parentNode === parent) return;
|
|
||||||
// appendChild MOVES the node: the browser blurs whatever was focused inside it on the way out.
|
|
||||||
const focused = host.contains(document.activeElement)
|
|
||||||
? (document.activeElement as HTMLElement)
|
|
||||||
: null;
|
|
||||||
parent.appendChild(host);
|
|
||||||
focused?.focus();
|
|
||||||
}, [fsRoot, host]);
|
|
||||||
useEffect(() => () => host.remove(), [host]);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
useDialogChrome(panelRef);
|
||||||
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
|
|
||||||
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
|
|
||||||
// focus on <body>. useState initializer runs exactly once, on first render.
|
|
||||||
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
|
||||||
const overlayOrder = useOverlayOrder();
|
const overlayOrder = useOverlayOrder();
|
||||||
|
|
||||||
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
||||||
@@ -96,50 +71,20 @@ export default function Modal({
|
|||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
// The whole dialog, not just the panel: its toast host sits beside the panel (see ui/Toaster),
|
// The whole dialog, not just the panel: its toast host sits beside the panel (see ui/Toaster),
|
||||||
// and a toast's Undo or dismiss must be reachable from the keyboard like everything else.
|
// and a toast's Undo or dismiss must be reachable from the keyboard like everything else.
|
||||||
const scope = rootRef.current ?? panel;
|
trapTab(e, rootRef.current ?? panel, panel);
|
||||||
const items = focusablesIn(scope);
|
|
||||||
const first = items[0];
|
|
||||||
const last = items[items.length - 1];
|
|
||||||
if (!first || !last) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (!scope.contains(active) || active === panel) {
|
|
||||||
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
|
|
||||||
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
|
|
||||||
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
|
|
||||||
e.preventDefault();
|
|
||||||
(e.shiftKey ? last : first).focus();
|
|
||||||
} else if (e.shiftKey && active === first) {
|
|
||||||
e.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!e.shiftKey && active === last) {
|
|
||||||
e.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
const prev = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
|
||||||
// focus to the first control so the dialog opens focused, not stranded on the background.
|
|
||||||
const panel = panelRef.current;
|
|
||||||
if (panel && !panel.contains(document.activeElement)) {
|
|
||||||
(focusablesIn(panel)[0] ?? panel).focus();
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
removeLayer(id);
|
removeLayer(id);
|
||||||
document.body.style.overflow = prev;
|
|
||||||
// Restore focus to the trigger, if it's still in the document and focusable.
|
|
||||||
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
|
|
||||||
};
|
};
|
||||||
}, [restoreTo]);
|
}, []);
|
||||||
|
|
||||||
const drawer = placement === "drawer";
|
const drawer = placement === "drawer";
|
||||||
const top = placement === "top";
|
const top = placement === "top";
|
||||||
|
// Only on a server render, where the hook has no `document` to build a host from.
|
||||||
|
if (!host) return null;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// the player. An overlay mounts its own host, and exactly one host shows the toasts.
|
// the player. An overlay mounts its own host, and exactly one host shows the toasts.
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
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
|
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 () => {
|
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
|
// 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.
|
// dialog opened above the player, and the toasts went back under that dialog's scrim.
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ const topHost = () => {
|
|||||||
return top?.id ?? null;
|
return top?.id ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What the host that is currently top last rendered, for the next host to inherit. See `inherited`.
|
||||||
|
let lastTopIds: number[] = [];
|
||||||
|
|
||||||
export default function Toaster({
|
export default function Toaster({
|
||||||
overlay = false,
|
overlay = false,
|
||||||
order,
|
order,
|
||||||
@@ -69,10 +72,19 @@ export default function Toaster({
|
|||||||
// LATER task. Not in an effect: React flushes those in the same task as the render that inserted the
|
// LATER task. Not in an effect: React flushes those in the same task as the render that inserted the
|
||||||
// toasts, and a screen reader reads the region's state after the task — by which time it was polite
|
// toasts, and a screen reader reads the region's state after the task — by which time it was polite
|
||||||
// again, i.e. the arming did nothing.
|
// again, i.e. the arming did nothing.
|
||||||
const inherited = useMemo(
|
//
|
||||||
() => new Set(isTop ? getActiveToasts().map((n) => n.id) : []),
|
// "Inherited" is what the PREVIOUS top host last had on screen — not whatever is in the store at
|
||||||
[isTop],
|
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
|
||||||
);
|
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
|
||||||
|
// toast up in the very batch that handed the app host the top spot, so it was classified as
|
||||||
|
// inherited and announced by neither path (deep-review round 5). Written during render on purpose
|
||||||
|
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
|
||||||
|
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
|
||||||
|
// host's last screen either way.
|
||||||
|
const inherited = useMemo(() => new Set(isTop ? lastTopIds : []), [isTop]);
|
||||||
|
// AFTER the memo above has read the outgoing host's screen — this host's own first render as top
|
||||||
|
// would otherwise overwrite the handover with the toasts it is about to classify.
|
||||||
|
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||||||
const [armed, setArmed] = useState(false);
|
const [armed, setArmed] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTop) {
|
if (!isTop) {
|
||||||
@@ -110,7 +122,13 @@ export default function Toaster({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
role={urgent && !inherited.has(toast.id) ? "alert" : undefined}
|
// A toast this host did not inherit must be announced even in the unarmed window, where
|
||||||
|
// the region is still `off`: urgent ones assertively, the rest with their own polite
|
||||||
|
// `role="status"` (single-element, so its implied atomic re-read is just this toast).
|
||||||
|
// Once armed, the region itself does the announcing and the role comes back off.
|
||||||
|
role={
|
||||||
|
inherited.has(toast.id) ? undefined : urgent ? "alert" : armed ? undefined : "status"
|
||||||
|
}
|
||||||
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
|
|
||||||
|
// The focus half of the dialog contract, in ONE place. `ui/Modal` owns it for every dialog that goes
|
||||||
|
// through the shell; the onboarding wizard renders its own shell (a bare `ui/Overlay`, because it is
|
||||||
|
// not a titled dialog with a header row) and used to have NONE of this — no trap, no initial focus,
|
||||||
|
// no restore — so a keyboard user tabbed straight out of the first-login wizard into the app behind
|
||||||
|
// the scrim, where the focus ring is invisible (deep-review round 5). A second hand-written copy is
|
||||||
|
// how two shells drift apart; this is the shared one.
|
||||||
|
|
||||||
|
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
||||||
|
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
||||||
|
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||||
|
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||||
|
|
||||||
|
/** Keep Tab / Shift+Tab cycling inside `scope` instead of reaching the inert page behind the
|
||||||
|
* backdrop. Call from a keydown handler that has already decided this dialog is the topmost layer —
|
||||||
|
* a nested dialog must defer to its child. `container` is the scope's own tabIndex=-1 panel, if it
|
||||||
|
* has one: focus can land there by clicking dialog dead space, and `Node.contains(self)` is true, so
|
||||||
|
* without naming it a Shift+Tab from the panel would leak to the background. */
|
||||||
|
export function trapTab(
|
||||||
|
e: KeyboardEvent,
|
||||||
|
scope: HTMLElement,
|
||||||
|
container?: HTMLElement | null,
|
||||||
|
): void {
|
||||||
|
const items = focusablesIn(scope);
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (!first || !last) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!scope.contains(active) || (container != null && active === container)) {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.shiftKey ? last : first).focus();
|
||||||
|
} else if (e.shiftKey && active === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
||||||
|
* `scopeRef` is the panel the focus should land in. */
|
||||||
|
export function useDialogChrome(scopeRef: RefObject<HTMLElement | null>): void {
|
||||||
|
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||||
|
// dialog's autoFocus (or our own initial focus) and moves activeElement into the dialog. Reading it
|
||||||
|
// later (in the effect) would capture a now-inside element that unmounts on close, stranding focus
|
||||||
|
// on <body>. useState initializer runs exactly once, on first render.
|
||||||
|
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||||
|
// focus to the first control so the dialog opens focused, not stranded on the background.
|
||||||
|
const scope = scopeRef.current;
|
||||||
|
if (scope && !scope.contains(document.activeElement)) {
|
||||||
|
(focusablesIn(scope)[0] ?? scope).focus();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
// Restore focus to the trigger, if it's still in the document and focusable.
|
||||||
|
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
|
||||||
|
};
|
||||||
|
}, [restoreTo, scopeRef]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useInsertionEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
// The browser's Fullscreen API, WebKit included.
|
// The browser's Fullscreen API, WebKit included.
|
||||||
//
|
//
|
||||||
@@ -56,9 +62,48 @@ const fsSnapshot = () => fullscreenElement() as HTMLElement | null;
|
|||||||
const fsServerSnapshot = () => null;
|
const fsServerSnapshot = () => null;
|
||||||
|
|
||||||
/** The fullscreen element as reactive state, for code that must RENDER differently while something
|
/** The fullscreen element as reactive state, for code that must RENDER differently while something
|
||||||
* holds the lock — chiefly the popover platform, which has to portal INTO that element (a node
|
* holds the lock — today only `useFullscreenPortalHost` below, which has to portal INTO that element
|
||||||
* portaled to <body> is painted underneath the fullscreen element, i.e. invisible). Components that
|
* (a node portaled to <body> is painted underneath the fullscreen element, i.e. invisible).
|
||||||
* merely react to the transition should keep using `onFullscreenChange` directly. */
|
* Components that merely react to the transition should keep using `onFullscreenChange` directly. */
|
||||||
export function useFullscreenElement(): HTMLElement | null {
|
function useFullscreenElement(): HTMLElement | null {
|
||||||
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
|
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ONE container div for an overlay's whole life, re-parented between `<body>` and the fullscreen
|
||||||
|
* element instead of handed to `createPortal` as a changing container: React tears a portal down
|
||||||
|
* and builds it again when its container changes, and the overlay's contents go with it (a
|
||||||
|
* half-filled form reset to its defaults the moment the viewer left true fullscreen).
|
||||||
|
*
|
||||||
|
* Two rules this owns, which the two hand-written copies it replaced disagreed about (deep-review
|
||||||
|
* round 5):
|
||||||
|
*
|
||||||
|
* 1. **It is attached BEFORE the portal's children commit.** `useInsertionEffect` runs in the
|
||||||
|
* mutation phase, and React handles `autoFocus` in `commitMount` during the LAYOUT phase — one
|
||||||
|
* whole phase later. Attaching in a `useLayoutEffect` is too late: layout effects commit
|
||||||
|
* children-before-parent, so every `autoFocus` descendant called `.focus()` on a node that was
|
||||||
|
* still disconnected, where `focus()` is a no-op. Every autoFocus in every dialog was dead.
|
||||||
|
* 2. **`appendChild` MOVES the node, it does not teleport it.** Per the DOM spec an already-parented
|
||||||
|
* node is removed and re-inserted, and removing a focused element's ancestor resets focus to
|
||||||
|
* `<body>` — so the focused element is saved and re-focused across the move.
|
||||||
|
*/
|
||||||
|
export function useFullscreenPortalHost(): HTMLElement | null {
|
||||||
|
const fsRoot = useFullscreenElement();
|
||||||
|
const [host] = useState<HTMLElement | null>(() =>
|
||||||
|
typeof document === "undefined" ? null : document.createElement("div"),
|
||||||
|
);
|
||||||
|
useInsertionEffect(() => {
|
||||||
|
if (host && !host.parentNode) document.body.appendChild(host);
|
||||||
|
}, [host]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!host) return;
|
||||||
|
const parent = fsRoot ?? document.body;
|
||||||
|
if (host.parentNode === parent) return;
|
||||||
|
const focused = host.contains(document.activeElement)
|
||||||
|
? (document.activeElement as HTMLElement)
|
||||||
|
: null;
|
||||||
|
parent.appendChild(host);
|
||||||
|
focused?.focus();
|
||||||
|
}, [fsRoot, host]);
|
||||||
|
useEffect(() => () => host?.remove(), [host]);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user