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:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -9,6 +9,7 @@ import { Button } from "./ui/Button";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||
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
|
||||
// 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
|
||||
// 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).
|
||||
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
|
||||
// "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]"
|
||||
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
|
||||
onClick={close}
|
||||
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">
|
||||
<Youtube className="w-6 h-6" />
|
||||
</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">
|
||||
<Trans
|
||||
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">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</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">
|
||||
{t("onboarding.importing.body")}
|
||||
</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">
|
||||
<Check className="w-6 h-6" />
|
||||
</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">
|
||||
{importSubs.isError ? (
|
||||
<>{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">
|
||||
<Check className="w-6 h-6" />
|
||||
</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">
|
||||
{t("onboarding.done.body")}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user