diff --git a/frontend/src/components/Modals.tsx b/frontend/src/components/Modals.tsx index f7c935e..10a4565 100644 --- a/frontend/src/components/Modals.tsx +++ b/frontend/src/components/Modals.tsx @@ -45,6 +45,7 @@ export function RunModal(p: { const current = run.plan.steps.find((s) => stateOf(s) === "running"); const logRef = useRef(null); const dialog = useRef(null); + const headingRef = useRef(null); const [now, setNow] = useState(Date.now()); const [end, setEnd] = useState(null); const [showLog, setShowLog] = useState(p.advanced); @@ -54,6 +55,13 @@ export function RunModal(p: { // installation is a deliberate act with its own button. useModal(dialog, run.running ? undefined : p.onClose); + // The button pressed to start (or Stop, when the run ends) disappears, which would drop focus to the page. + // The heading is a stable place that also announces the new state. + useEffect(() => { + const a = document.activeElement; + if (!a || a === document.body || !dialog.current?.contains(a)) headingRef.current?.focus({preventScroll: true}); + }, [started, finished]); + useEffect(() => { logRef.current?.scrollTo?.(0, logRef.current.scrollHeight); }, [run.log, showLog]); useEffect(() => { if (!run.running) return; @@ -81,6 +89,10 @@ export function RunModal(p: { : ; }; const currentApp = current && current.kind !== "recipe" ? p.byId.get(current.id) : undefined; + // Spoken once per step, not per tick: the elapsed time and the download percent stay out of it. + const announce = !started ? "" + : run.running ? `${current ? t("run.working", {name: current.name}) : t("run.installing")} ${t("run.stepOf", {done, total: cells.length})}` + : `${heading}. ${t("run.stepOf", {done: done - failedCount, total: cells.length})}${failedCount > 0 ? `, ${t("run.status.failed")}: ${failedCount}` : ""}`; return (
@@ -91,7 +103,7 @@ export function RunModal(p: { {finished ? : {done}/{cells.length}}
-

{heading}

+

{heading}

{current && (

{currentApp ? : null} @@ -103,7 +115,8 @@ export function RunModal(p: { {t("run.stepOf", {done, total: cells.length})}{elapsed && ` · ${elapsed}`}

- ) :

{heading}

} + ) :

{heading}

} +

{announce}

{!started && run.plan.needsAdmin && !p.admin && (
@@ -124,7 +137,7 @@ export function RunModal(p: { {s.admin && } {st === "running" && (run.percent[stepKey(s)] ?? 0) > 0 && ( - + )} {st === "failed" && reasonOf(s) &&

{t(`run.reason.${reasonOf(s)}` as Key)}

} diff --git a/frontend/src/style.css b/frontend/src/style.css index 09abfcd..81cf0a1 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -179,6 +179,10 @@ aside .row.on { background: var(--surface); border-color: var(--line); box-shado /* dialogs */ .overlay { position: fixed; inset: 0; background: rgba(6, 9, 12, .6); display: grid; place-items: center; padding: 20px; } +/* A definite row lets the dialog's max-height apply, so its steps and log shrink instead of pushing the buttons off a short window. */ +.overlay { grid-template: minmax(0, 1fr) / minmax(0, 1fr); } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.modal h3[tabindex="-1"]:focus-visible { outline: none; } .modal { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 22px; width: min(720px, 100%); max-height: 100%; display: flex; flex-direction: column; gap: 14px; box-shadow: 0 24px 60px rgba(0, 0, 0, .45); } .modal.small { width: 440px; } .modal h3 { margin: 0; font: 600 20px var(--font-display); } diff --git a/frontend/src/test/app.test.tsx b/frontend/src/test/app.test.tsx index 170b5ae..fdff6ec 100644 --- a/frontend/src/test/app.test.tsx +++ b/frontend/src/test/app.test.tsx @@ -98,6 +98,38 @@ describe("installing", () => { expect(within(dialog).queryByLabelText("ok")).not.toBeInTheDocument(); }); + it("keeps focus in the run dialog when the start button goes away, and announces each step", async () => { + const user = await openApp(); + const dialog = await openFirefoxPlan(user); + await user.click(within(dialog).getByRole("button", {name: "Run 1 steps"})); + // The pressed button is gone; focus must not fall to the page behind the dialog. + await waitFor(() => expect(document.activeElement).toBe(within(dialog).getByRole("heading", {name: "Installing…"}))); + const live = dialog.querySelector("[aria-live=polite]") as HTMLElement; + expect(live).toHaveTextContent("Installing… 0 of 1"); + await emit.install({step: step("firefox", "Firefox"), status: "start"}); + await waitFor(() => expect(live).toHaveTextContent("Installing Firefox… 0 of 1")); + await emit.install({step: step("firefox", "Firefox"), status: "output", line: "Downloading 40%", percent: 40}); + expect(await within(dialog).findByRole("progressbar", {name: "Firefox: 40%"})).toBeInTheDocument(); + await emit.install({step: step("firefox", "Firefox"), status: "ok"}); + await emit.done([]); + // Stop disappeared too: focus moves to the new heading, which says how it ended. + await waitFor(() => expect(document.activeElement).toBe(within(dialog).getByRole("heading", {name: "Done"}))); + expect(live).toHaveTextContent("Done. 1 of 1"); + }); + + it("announces a failed run with its count and keeps the retry button reachable", async () => { + const user = await openApp(); + const dialog = await openFirefoxPlan(user); + await user.click(within(dialog).getByRole("button", {name: "Run 1 steps"})); + await emit.install({step: step("firefox", "Firefox"), status: "start"}); + await emit.install({step: step("firefox", "Firefox"), status: "failed", error: "exit 1603", reason: "network"}); + await emit.done(["winget:firefox"]); + const live = dialog.querySelector("[aria-live=polite]") as HTMLElement; + await waitFor(() => expect(live).toHaveTextContent("Finished with errors. 0 of 1, Failed: 1")); + expect(document.activeElement).toBe(within(dialog).getByRole("heading", {name: "Finished with errors"})); + expect(within(dialog).getByRole("button", {name: /Retry/})).toBeInTheDocument(); + }); + it("starts the run once even if the confirm button is double-clicked", async () => { const user = await openApp(); const dialog = await openFirefoxPlan(user); diff --git a/frontend/src/test/features.test.tsx b/frontend/src/test/features.test.tsx index d3e619d..f91c7ef 100644 --- a/frontend/src/test/features.test.tsx +++ b/frontend/src/test/features.test.tsx @@ -75,9 +75,9 @@ describe("when a step fails", () => { await user.click(await screen.findByRole("button", {name: "Run 1 steps"})); await emit.install({step: step("firefox", "Firefox"), status: "start"}); await emit.install({step: step("firefox", "Firefox"), status: "output", line: " 42%", percent: 42}); - expect(await screen.findByRole("progressbar", {name: "42%"})).toBeInTheDocument(); + expect(await screen.findByRole("progressbar", {name: "Firefox: 42%"})).toBeInTheDocument(); await emit.install({step: step("firefox", "Firefox"), status: "ok"}); - expect(screen.queryByRole("progressbar", {name: "42%"})).not.toBeInTheDocument(); + expect(screen.queryByRole("progressbar", {name: "Firefox: 42%"})).not.toBeInTheDocument(); }); it("tells the person to restart Windows when a step needs it", async () => {