Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions frontend/src/components/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function RunModal(p: {
const current = run.plan.steps.find((s) => stateOf(s) === "running");
const logRef = useRef<HTMLPreElement>(null);
const dialog = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const [now, setNow] = useState(Date.now());
const [end, setEnd] = useState<number | null>(null);
const [showLog, setShowLog] = useState(p.advanced);
Expand All @@ -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;
Expand Down Expand Up @@ -81,6 +89,10 @@ export function RunModal(p: {
: <span className="avatar recipe" style={{width: 26, height: 26}}><Icon name="settings" size={14}/></span>;
};
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 (
<div className="overlay">
Expand All @@ -91,7 +103,7 @@ export function RunModal(p: {
{finished ? <Icon name={failedCount ? "alert" : "check"} size={30}/> : <span className="ringnum">{done}<small>/{cells.length}</small></span>}
</Ring>
<div className="grow">
<h3>{heading}</h3>
<h3 tabIndex={-1} ref={headingRef}>{heading}</h3>
{current && (
<p className="now">
{currentApp ? <AppIcon id={currentApp.id} name={currentApp.name} category={currentApp.category} size={28}/> : null}
Expand All @@ -103,7 +115,8 @@ export function RunModal(p: {
<span className="mono muted small">{t("run.stepOf", {done, total: cells.length})}{elapsed && ` · ${elapsed}`}</span>
</div>
</div>
) : <h3>{heading}</h3>}
) : <h3 tabIndex={-1} ref={headingRef}>{heading}</h3>}
<p className="sr-only" aria-live="polite" aria-atomic="true">{announce}</p>

{!started && run.plan.needsAdmin && !p.admin && (
<div className="banner warn">
Expand All @@ -124,7 +137,7 @@ export function RunModal(p: {
{s.admin && <span className="tag"><Icon name="lock" size={11}/></span>}
<span className={`stat ${st}`} role="img" aria-label={t(`run.status.${st}` as Key)}><Icon name={ICON[st]} size={15}/></span>
{st === "running" && (run.percent[stepKey(s)] ?? 0) > 0 && (
<progress className="dl" value={run.percent[stepKey(s)]} max={100} aria-label={`${run.percent[stepKey(s)]}%`}/>
<progress className="dl" value={run.percent[stepKey(s)]} max={100} aria-label={`${s.name}: ${run.percent[stepKey(s)]}%`}/>
)}
{st === "failed" && reasonOf(s) && <p className="why">{t(`run.reason.${reasonOf(s)}` as Key)}</p>}
</li>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/test/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/test/features.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading