Skip to content

a11y: UsersPage refuses a load with no role=alert, so the refusal is silent to screen readers #389

Description

@mforce

Found while writing the #385 Playwright E2E ReadOnly persona spec — a deep-link refusal is announced on one screen and silent on another.

What differs

Both screens are reachable by URL for any authenticated user (there is no route-level gate in the SPA by design — the server is the boundary), and both refuse a ReadOnly user with a 403 that they render as an error paragraph. Only one of them announces it:

Screen Load-failure markup
AuditPage.tsx <p className="error" role="alert">{error}</p>
UsersPage.tsx (error && users === null branch) <p className="error">{error}</p> — no role

ReportsPage, ExportPage, StockPage and HistoryPage all use role="alert" for the same situation, so UsersPage is the outlier rather than AuditPage being unusually careful.

Why it matters

role="alert" is an implicit live region. Without it, a screen-reader user who follows a link to /users and is refused hears the heading and then nothing — the page appears to have simply loaded. The visual user sees red text; the assistive-technology user gets silence and no reason to look further.

This is the same class of gap the SPA otherwise takes care over: the app ships a skip link, aria-labelled nav landmarks, role="status" on BusyButton's working announcement and on the daily-entry grading readout, and aria-live="polite" on the PWA update banner. One un-announced refusal stands out against that.

Suggested fix

Add role="alert" to the load-failure paragraph in UsersPage.tsx:347, matching the sibling screens. Worth a sweep at the same time: UsersPage has several other bare <p className="error">{error}</p> sites (dialog-level validation errors around lines 399, 409, 484, 501) — a dialog error that is never announced has the same problem in a tighter loop, since the user is actively waiting for the result of a submit.

Not necessarily one rule for all of them: a dialog error adjacent to the control that caused it may be better served by aria-describedby on the field than by an alert. The load-failure case is the unambiguous one.

What #385 does in the meantime

The ReadOnly deep-link spec asserts the app's own p.error element rather than role="alert", so it tests the authorization boundary it is actually about and does not fail for an accessibility reason. That choice is commented in the spec with a pointer here. If this lands, the spec can tighten to getByRole("alert") for both routes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions