From d08b0e07b0298978eaad070b0c2d5c17cce96dc5 Mon Sep 17 00:00:00 2001 From: Regan Maharjan Date: Mon, 19 Oct 2026 15:00:07 -0400 Subject: [PATCH 1/2] Reports: one Actions menu per site; Scan again on Issues Reports replaces its "Report" column with "Actions": a disclosure button per site row that opens "Open latest scan" and "Scan again". A site with no completed scan gets only "Scan again", from its latest scan. The panel is position: fixed so the table's scroll region cannot clip it, opens upward near the bottom, and supports Escape, arrow keys, Home and End like the Export menu. ScanAgainLink opens New scan with a report's settings filled in. It is used in the Reports Actions menu and beside Export report on Issues (not for sign-in reports). --- .../src/components/RowActionsMenu.tsx | 217 ++++++++++++++++++ .../frontend/src/components/ScanAgainLink.tsx | 55 +++++ src/audit/web/frontend/src/routes/Issues.tsx | 22 +- src/audit/web/frontend/src/routes/Scans.tsx | 54 ++--- tests/ui/test_reports_table.py | 45 +++- 5 files changed, 357 insertions(+), 36 deletions(-) create mode 100644 src/audit/web/frontend/src/components/RowActionsMenu.tsx create mode 100644 src/audit/web/frontend/src/components/ScanAgainLink.tsx diff --git a/src/audit/web/frontend/src/components/RowActionsMenu.tsx b/src/audit/web/frontend/src/components/RowActionsMenu.tsx new file mode 100644 index 00000000..02d0ed3f --- /dev/null +++ b/src/audit/web/frontend/src/components/RowActionsMenu.tsx @@ -0,0 +1,217 @@ +import { + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { ChevronDown, FileText } from "lucide-react"; +import { Button, LinkButton } from "./ui"; +import ScanAgainLink from "./ScanAgainLink"; +import { cn } from "../lib/cn"; + +/** The panel's links, in order. */ +function panelLinks(panel: HTMLElement | null): HTMLAnchorElement[] { + return Array.from(panel?.querySelectorAll("a") ?? []); +} + +/** + * Arrow keys, Home and End move between the panel's links. On the document + * rather than the panel, so the panel stays a plain container and the links + * keep their own native keyboard handling. + */ +function moveBetweenLinks(event: KeyboardEvent, panel: HTMLElement | null) { + const links = panelLinks(panel); + const current = links.indexOf(document.activeElement as HTMLAnchorElement); + const target = + event.key === "ArrowDown" + ? Math.min(current + 1, links.length - 1) + : event.key === "ArrowUp" + ? Math.max(current - 1, 0) + : event.key === "Home" + ? 0 + : event.key === "End" + ? links.length - 1 + : null; + if (target === null || current === -1) return; + event.preventDefault(); + links[target]?.focus(); +} + +/** + * A site row's actions on Reports, behind one "Actions" button: "Open latest + * scan" (when the site has a completed scan) and "Scan again". + * + * - The developer asked for a dropdown (Oct 2026). Two links shown in the + * cell were offered instead, because a dropdown hides the row's main + * action, opening the report, behind one more click and makes it harder + * to find (W3C COGA, "Making Content Usable", + * https://www.w3.org/TR/coga-usable/, paraphrased: keep the things people + * need visible). The developer chose the dropdown. The Issues count earlier + * in the row still opens the same report in one click. + * - The APG disclosure pattern + * (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/), not the menu + * pattern, for the same reasons as ExportMenu: the contents are ordinary + * links, so Tab, Enter and a screen reader's link list work as people + * expect, and `role="menu"` would take them out of the tab order. Arrow + * keys, Home and End also move between the links, as in ExportMenu, so + * the two dropdowns behave alike (SC 3.2.4 Consistent Identification, + * Level AA). + * - The button reads "Actions" and, for screen readers only, which site + * (technique C7), so each row's button has its own name out of context + * (SC 2.4.6 Headings and Labels, Level AA) and its name starts with the + * visible word (SC 2.5.3 Label in Name, Level A). No `aria-label`. + * - The panel is `position: fixed`, placed from the button. The table sits + * in a sideways-scrolling region, and a panel placed inside it was cut off + * at the region's edge on the last rows. It opens upward when there is not + * room below, and it closes when the page or table scrolls rather than + * drifting away from its button. + * - A new component rather than ExportMenu: that one carries one download's + * state (sizes, progress, the DRAFT notice) and its own heading. FilterMenu + * holds form controls, not links. Neither could take a list of links + * without changing what it does. + */ +export default function RowActionsMenu({ + site, + openScanId, + scanAgainFromId, +}: { + /** The site as it is shown, for the screen-reader-only words. */ + site: string; + /** The latest completed scan to open; leave out when there is none. */ + openScanId?: number; + /** The scan whose settings "Scan again" starts from. */ + scanAgainFromId: number; +}) { + const [open, setOpen] = useState(false); + const [place, setPlace] = useState({}); + const panelId = useId(); + const containerRef = useRef(null); + const buttonRef = useRef(null); + const panelRef = useRef(null); + const focusFirstOnOpen = useRef(false); + + useLayoutEffect(() => { + if (!open) return; + const button = buttonRef.current?.getBoundingClientRect(); + const panelHeight = panelRef.current?.offsetHeight ?? 0; + if (!button) return; + const gap = 6; + const below = window.innerHeight - button.bottom; + const upward = below < panelHeight + gap && button.top > below; + setPlace({ + right: Math.max(window.innerWidth - button.right, 8), + ...(upward + ? { bottom: window.innerHeight - button.top + gap } + : { top: button.bottom + gap }), + }); + }, [open]); + + useEffect(() => { + if (!open) return; + if (focusFirstOnOpen.current) { + focusFirstOnOpen.current = false; + panelRef.current?.querySelector("a")?.focus(); + } + const close = () => setOpen(false); + const closeOnOutsidePointer = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) close(); + }; + const closeOnOutsideFocus = (event: FocusEvent) => { + // Coming back to the window refocuses ; that is not leaving. + if (event.target === document.body) return; + if (!containerRef.current?.contains(event.target as Node)) close(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // Escape returns focus to the button, never to the top of the page. + close(); + buttonRef.current?.focus(); + return; + } + moveBetweenLinks(event, panelRef.current); + }; + document.addEventListener("mousedown", closeOnOutsidePointer); + document.addEventListener("focusin", closeOnOutsideFocus); + document.addEventListener("keydown", onKeyDown); + // Capture, so scrolling the table's own region closes it too. + window.addEventListener("scroll", close, true); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("mousedown", closeOnOutsidePointer); + document.removeEventListener("focusin", closeOnOutsideFocus); + document.removeEventListener("keydown", onKeyDown); + window.removeEventListener("scroll", close, true); + window.removeEventListener("resize", close); + }; + }, [open]); + + function onTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key !== "ArrowDown") return; + event.preventDefault(); + // Opening adds the document listener before this key press reaches the + // document; without this it would move on to the second link. + event.stopPropagation(); + if (open) { + panelLinks(panelRef.current)[0]?.focus(); + } else { + focusFirstOnOpen.current = true; + setOpen(true); + } + } + + return ( +
+ + +
+ ); +} diff --git a/src/audit/web/frontend/src/components/ScanAgainLink.tsx b/src/audit/web/frontend/src/components/ScanAgainLink.tsx new file mode 100644 index 00000000..9c6e4a84 --- /dev/null +++ b/src/audit/web/frontend/src/components/ScanAgainLink.tsx @@ -0,0 +1,55 @@ +import { RotateCw } from "lucide-react"; +import { LinkButton } from "./ui"; +import { cn } from "../lib/cn"; + +/** + * "Scan again": opens New scan with one report's settings filled in + * (`/scans/new?from=`, the same path as "Change settings first" on a + * failed scan). Used in each site's Actions menu on Reports (RowActionsMenu) + * and beside "Export report" on Issues, so the same action reads the same, + * with the same icon, in both places (WCAG 2.2 SC 3.2.4 Consistent + * Identification, Level AA). + * + * - A link to the form, not a button that starts the scan. A scan crawls a + * real site and only one can run at a time; one stray click must not + * start one. The form is the confirmation step, and it shows what the + * scan will do before it starts. Starting at once was considered and + * rejected for that reason (the developer chose the form, Oct 2026). A + * sign-in scan also needs someone to sign in, which only the form offers. + * - The icon comes with words. The developer first asked for an icon-only + * button; an icon alone makes a sighted reader guess what the arrow means, + * and a voice-control user cannot say its name (W3C COGA, "Making Content + * Usable", https://www.w3.org/TR/coga-usable/: clear words, not only + * symbols; docs/plain-language.md rule 8, a verb and an object). The + * developer chose the icon with "Scan again". + * - The visible words come first in the name, then screen-reader-only words + * that say which site and whose settings (technique C7), so the name + * contains the visible label (SC 2.5.3 Label in Name, Level A) and the + * link makes sense out of its row (SC 2.4.9 Link Purpose (Link Only), + * Level AAA). No `aria-label`: real words carry the meaning. + */ +export default function ScanAgainLink({ + scanId, + site, + variant = "secondary", + className, +}: { + /** The report whose settings the form starts from. */ + scanId: number; + /** The site as it is shown, for the screen-reader-only words. */ + site: string; + variant?: "secondary" | "ghost"; + className?: string; +}) { + return ( + + + Scan again + : {site}, starting from the settings of Report #{scanId} + + ); +} diff --git a/src/audit/web/frontend/src/routes/Issues.tsx b/src/audit/web/frontend/src/routes/Issues.tsx index 1d4bc324..889c22ba 100644 --- a/src/audit/web/frontend/src/routes/Issues.tsx +++ b/src/audit/web/frontend/src/routes/Issues.tsx @@ -8,6 +8,7 @@ import { Card, withReturnTrail } from "../components/ui"; import { siteLabel } from "../components/ReportCrumb"; import ConformanceBadge from "../components/ConformanceBadge"; import ExportMenu from "../components/ExportMenu"; +import ScanAgainLink from "../components/ScanAgainLink"; import { TablePagination, usePagedRows } from "../components/TablePagination"; import { Cell, @@ -219,14 +220,21 @@ export default function IssuesRoute() { // the topbar trail, and the counts are in the summary line below. // A report that never recorded its finish time says when it started. meta={basedOn ? {basedOn} : undefined} + // Scan again sits before Export report: Export stays the header's + // last action, the edge its panel hangs from (see ExportMenu). A + // protected report has none: its settings belong to the sign-in + // workflow, and the server refuses to copy them into New scan. actions={ - + <> + {!scan.protection && } + + } /> diff --git a/src/audit/web/frontend/src/routes/Scans.tsx b/src/audit/web/frontend/src/routes/Scans.tsx index ed7b136d..dd3b7c60 100644 --- a/src/audit/web/frontend/src/routes/Scans.tsx +++ b/src/audit/web/frontend/src/routes/Scans.tsx @@ -26,6 +26,7 @@ import type { ProtectedScanStatus, ProtectedScanSummary, ScanSummary, SiteGroup import { confirmDestructive } from "../hooks/usePreferences"; import BreakableUrl from "../components/BreakableUrl"; import LastScannedSite from "../components/LastScannedSite"; +import RowActionsMenu from "../components/RowActionsMenu"; import { Cell, ColumnHeader, @@ -111,20 +112,21 @@ const PROTECTED_STATUS_LABEL: Record = { * that screen readers support unevenly. See Adrian Roselli, "Table with * Expando Rows" (https://adrianroselli.com/2019/09/table-with-expando-rows.html). * - Column order: the site that names the row, when its latest completed - * scan finished, that scan's numbers, then the link to open it. The link - * stays last, the usual place for a row's action: the row is read (by eye - * or, cell by cell, by a screen reader) as "this site, as of then, with - * these results", and only then offers to open it. It was considered as - * the second column; that split the site from its date and numbers, and - * the Issues count is already a link into the same report earlier in the - * row. The Site column is sticky, so the row keeps its name when a narrow - * screen scrolls the table sideways to reach the link. - * - "Open latest scan" is a link, styled as one, because it goes to a - * page; a button acts on this one. One quiet link per row rather than an - * outlined button in every row, which outweighed the numbers. Its - * accessible name starts with its visible words (WCAG 2.2 SC 2.5.3 Label - * in Name, Level A: "the name contains the text that is presented - * visually"). + * scan finished, that scan's numbers, then Actions. Actions stays last, + * the usual place for a row's actions: the row is read (by eye or, cell + * by cell, by a screen reader) as "this site, as of then, with these + * results", and only then offers what to do with it. The Site column is + * sticky, so the row keeps its name when a narrow screen scrolls the + * table sideways to reach Actions. + * - Actions is one dropdown per row holding "Open latest scan" and "Scan + * again" (see RowActionsMenu). It replaced a "Report" column with the + * open link alone and a "Scan again" link beside the site's name. Two + * links shown in the cell were offered instead, since a dropdown hides + * opening the report behind one more click (W3C COGA, "Making Content + * Usable", https://www.w3.org/TR/coga-usable/); the developer chose the + * dropdown (Oct 2026). The Issues count is still a one-click link into + * the same report. A site with no completed scan gets the same button + * with only "Scan again", from its latest scan. */ type SortKey = "site" | "completed" | "pages" | "issues" | "images" | "states"; @@ -408,7 +410,7 @@ export default function ScansRoute() { {/* The height of a sort button, words at its foot like theirs, so its label lines up with them. */} - Report + Actions @@ -594,20 +596,20 @@ const SiteRows = memo(function SiteRows({ site, index, rowId, expanded, onToggle {(completed.dom_state_count ?? 0).toLocaleString()} - {/* A link styled as a link: it goes to a page (SC 2.5.3 for its name). */} - - Open latest scan - + {/* Open the report and Scan again, behind one button. See RowActionsMenu. */} + ) : ( - - No completed scan yet. Expand the site to open its other scans. - + <> + + No completed scan yet. Expand the site to open its other scans. + + + {/* Nothing to open yet, so only "Scan again", from the latest scan. */} + + + )} {expanded && ( diff --git a/tests/ui/test_reports_table.py b/tests/ui/test_reports_table.py index 0879072c..23166ee7 100644 --- a/tests/ui/test_reports_table.py +++ b/tests/ui/test_reports_table.py @@ -100,7 +100,7 @@ async def test_reports_table_keyboard_and_columns( await playwright_async.expect(table.locator("td[headers]")).to_have_count(0) # The sorted header also carries its direction chip, so match the labels. await playwright_async.expect(table.locator("thead tr").first.locator("th")).to_contain_text( - ["Site", "Completed", "Pages", "Issues", "Images with text", "Page states", "Report"] + ["Site", "Completed", "Pages", "Issues", "Images with text", "Page states", "Actions"] ) await playwright_async.expect( page.get_by_text( @@ -141,11 +141,39 @@ async def test_reports_table_keyboard_and_columns( ) await playwright_async.expect(row.locator("td").nth(3)).to_have_text(str(count)) await playwright_async.expect(row.locator("td").nth(4)).to_have_text("4") + # The row's actions sit behind one disclosure button, opened here by + # keyboard: Enter shows both links, ArrowDown moves between them, and + # Escape closes it and puts focus back on the button. + actions = row.get_by_role("button", name=f"Actions for site{newest}.example", exact=True) + await playwright_async.expect(actions).to_have_attribute("aria-expanded", "false") open_link = row.get_by_role( "link", name=f"Open latest scan of site{newest}.example, the most recent completed scan", ) + scan_again = row.get_by_role("link", name=re.compile(rf"^Scan again\W+site{newest}\.example")) + await playwright_async.expect(open_link).to_be_hidden() + await actions.focus() + await page.keyboard.press("Enter") + await playwright_async.expect(actions).to_have_attribute("aria-expanded", "true") + await playwright_async.expect(open_link).to_be_visible() await playwright_async.expect(open_link).to_have_attribute("href", f"/app/scans/{headline_id}") + await playwright_async.expect(scan_again).to_have_attribute( + "href", f"/app/scans/new?from={headline_id}" + ) + await page.keyboard.press("Tab") + await playwright_async.expect(open_link).to_be_focused() + await page.keyboard.press("ArrowDown") + await playwright_async.expect(scan_again).to_be_focused() + violations = await _run_axe(page) + assert not violations, _render_violations(violations) + await page.keyboard.press("Escape") + await playwright_async.expect(actions).to_have_attribute("aria-expanded", "false") + await playwright_async.expect(actions).to_be_focused() + await playwright_async.expect(open_link).to_be_hidden() + # ArrowDown on the closed button opens it on the first link. + await page.keyboard.press("ArrowDown") + await playwright_async.expect(open_link).to_be_focused() + await page.keyboard.press("Escape") await playwright_async.expect(table.locator('a[href$="/findings"]')).to_have_count(0) violations = await _run_axe(page) assert not violations, _render_violations(violations) @@ -205,6 +233,15 @@ async def test_reports_table_keyboard_and_columns( # The failed-only site is last: no completed scan, so no numbers. last = table.locator(":scope > tbody > tr").last await playwright_async.expect(last).to_contain_text("No completed scan yet") + # Its Actions menu has only "Scan again": there is nothing to open. + await last.get_by_role("button", name=re.compile(r"^Actions for ")).click() + await playwright_async.expect( + last.get_by_role("link", name=re.compile(r"^Scan again\b")) + ).to_be_visible() + await playwright_async.expect( + last.get_by_role("link", name=re.compile(r"^Open latest scan")) + ).to_have_count(0) + await page.keyboard.press("Escape") violations = await _run_axe(page) assert not violations, _render_violations(violations) await previous.click() @@ -342,13 +379,15 @@ async def respond(route: Any) -> None: await playwright_async.expect(page).to_have_url(re.compile(r"open=https%3A%2F%2Fsite3")) assert await page.evaluate("history.length") == 2 await page.evaluate("window.scrollTo(0, 400)") + actions = table.get_by_role("button", name="Actions for site5.example", exact=True) link = table.get_by_role("link", name=re.compile(r"^Open latest scan of site5\.example")) - # Clicking scrolls the link into view first; the place to come back to + # Clicking scrolls the button into view first; the place to come back to # is where the page is when the reader leaves it. - await link.scroll_into_view_if_needed() + await actions.scroll_into_view_if_needed() scrolled = await page.evaluate("window.scrollY") assert scrolled > 0, scrolled + await actions.click() await link.click() await page.wait_for_url(re.compile(r"/app/scans/\d+$")) await page.go_back() From 4158f63b31c098225b4cd3cf139f9a45b968fada Mon Sep 17 00:00:00 2001 From: Regan Maharjan Date: Tue, 20 Oct 2026 12:32:32 -0400 Subject: [PATCH 2/2] Actions menu: return focus on scroll close; fix UI test failures - Scrolling or resizing closes the Actions panel; if focus was on one of its links, it now goes back to the Actions button (preventScroll), as Escape does, instead of staying on a hidden link. - Scan again's screen-reader text says "report #N" mid-sentence, the sentence case the other controls use (test_control_label_case). - test_reports_refresh finds the site toggle by its own name; the row's "Actions for " button also matched the bare site name. --- .../src/components/RowActionsMenu.tsx | 19 +++++++++++++++---- .../frontend/src/components/ScanAgainLink.tsx | 2 +- tests/ui/test_reports_refresh.py | 3 ++- tests/ui/test_reports_table.py | 5 ++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/audit/web/frontend/src/components/RowActionsMenu.tsx b/src/audit/web/frontend/src/components/RowActionsMenu.tsx index 02d0ed3f..82de9f4e 100644 --- a/src/audit/web/frontend/src/components/RowActionsMenu.tsx +++ b/src/audit/web/frontend/src/components/RowActionsMenu.tsx @@ -136,15 +136,26 @@ export default function RowActionsMenu({ document.addEventListener("mousedown", closeOnOutsidePointer); document.addEventListener("focusin", closeOnOutsideFocus); document.addEventListener("keydown", onKeyDown); + // Scrolling or resizing closes the panel. If focus was on one of its + // links, it goes back to the button, as Escape does, so a keyboard user + // who scrolled with Page Down is not left focused on a hidden link + // (SC 2.4.7 Focus Visible, Level AA). `preventScroll`, so returning + // focus does not scroll the page back. Focus elsewhere is left alone. + const closeOnMove = () => { + if (panelRef.current?.contains(document.activeElement)) { + buttonRef.current?.focus({ preventScroll: true }); + } + close(); + }; // Capture, so scrolling the table's own region closes it too. - window.addEventListener("scroll", close, true); - window.addEventListener("resize", close); + window.addEventListener("scroll", closeOnMove, true); + window.addEventListener("resize", closeOnMove); return () => { document.removeEventListener("mousedown", closeOnOutsidePointer); document.removeEventListener("focusin", closeOnOutsideFocus); document.removeEventListener("keydown", onKeyDown); - window.removeEventListener("scroll", close, true); - window.removeEventListener("resize", close); + window.removeEventListener("scroll", closeOnMove, true); + window.removeEventListener("resize", closeOnMove); }; }, [open]); diff --git a/src/audit/web/frontend/src/components/ScanAgainLink.tsx b/src/audit/web/frontend/src/components/ScanAgainLink.tsx index 9c6e4a84..5f713433 100644 --- a/src/audit/web/frontend/src/components/ScanAgainLink.tsx +++ b/src/audit/web/frontend/src/components/ScanAgainLink.tsx @@ -49,7 +49,7 @@ export default function ScanAgainLink({ > Scan again - : {site}, starting from the settings of Report #{scanId} + : {site}, starting from the settings of report #{scanId} ); } diff --git a/tests/ui/test_reports_refresh.py b/tests/ui/test_reports_refresh.py index b564c279..d2582514 100644 --- a/tests/ui/test_reports_refresh.py +++ b/tests/ui/test_reports_refresh.py @@ -85,7 +85,8 @@ async def sites(route: Any) -> None: # Well inside the five seconds the old list counted as fresh: the site's # row, and in its list of scans the report, marked as scanning. table = page.get_by_role("table", name="Public reports by site", exact=False) - site = table.get_by_role("button", name=re.compile("first.example")) + # The toggle, not the row's "Actions for first.example" button. + site = table.get_by_role("button", name=re.compile(r"^Show all .*first\.example")) await playwright_async.expect(site).to_be_visible(timeout=3000) await site.click() scans = page.get_by_role("table", name=re.compile("All scans for first.example")) diff --git a/tests/ui/test_reports_table.py b/tests/ui/test_reports_table.py index 23166ee7..9806c339 100644 --- a/tests/ui/test_reports_table.py +++ b/tests/ui/test_reports_table.py @@ -173,7 +173,10 @@ async def test_reports_table_keyboard_and_columns( # ArrowDown on the closed button opens it on the first link. await page.keyboard.press("ArrowDown") await playwright_async.expect(open_link).to_be_focused() - await page.keyboard.press("Escape") + # Scrolling closes it; focus on its link goes back to the button. + await page.evaluate("window.dispatchEvent(new Event('scroll'))") + await playwright_async.expect(open_link).to_be_hidden() + await playwright_async.expect(actions).to_be_focused() await playwright_async.expect(table.locator('a[href$="/findings"]')).to_have_count(0) violations = await _run_axe(page) assert not violations, _render_violations(violations)