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
13 changes: 11 additions & 2 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Accessibility review

This review covers keyboard-only use, screen reader structure, and 200% zoom
(Stellar Wave issues #108, #109, #110, #111). It records what was verified
This review covers keyboard-only use, screen reader structure, 200% zoom,
link cues, heading hierarchy, command summaries, and high contrast mode
(Stellar Wave issues #104, #105, #106, #107, #108, #109, #110, #111). It records what was verified
automatically, what was fixed, and what still needs a manual pass.

## What is verified automatically
Expand All @@ -11,11 +12,15 @@ automatically, what was fixed, and what still needs a manual pass.
| Keyboard | Skip link is first in tab order and moves focus to the page content | `tests/visual/accessibility.spec.ts` |
| Keyboard | Every focusable control shows an outline on keyboard focus | `tests/visual/accessibility.spec.ts` |
| Keyboard | Workflow tabs respond to arrow, Home and End keys | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` |
| Non-color cues | Links have underline, offset, and cues on hover/active states beyond color (#104) | `src/styles.css`, `src/App.test.tsx` |
| Screen reader | Module cards follow logical heading hierarchy h2 -> h3 -> h4 with labelled regions (#105) | `src/App.test.tsx` |
| Screen reader | Active command and option settings are summarized in an accessible live region (#106) | `src/App.test.tsx` |
| Keyboard | Each command in the limits example is reachable in order with Tab | `tests/visual/accessibility.spec.ts` |
| Screen reader | Landmarks, a single `h1`, and named navigation links | `tests/visual/accessibility.spec.ts` |
| Screen reader | Command selector is a labelled group of toggle buttons | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` |
| Screen reader | Copy results are announced through a polite live region | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` |
| Screen reader | Audit prerequisites are a labelled fieldset of checkboxes, each with its own accessible name | `src/App.test.tsx` |
| High contrast | Controls, buttons, tabs, inputs, and cards retain boundaries in high-contrast/forced-colors modes (#107) | `src/styles.css`, `src/App.test.tsx` |
| Zoom | No horizontal page scroll and no clipped controls at 640px and 320px CSS widths | `tests/visual/accessibility.spec.ts` |

These checks read the accessibility tree and computed styles. They do not
Expand Down Expand Up @@ -52,3 +57,7 @@ browser and screen reader pair per platform.
| 8 | The limits workflow needed a discoverable, ready-to-run command reference. | Fixed: added a focusable reference section and a build-and-run example with keyboard-focusable command steps. | `src/App.tsx`, `src/styles.css`, `tests/visual/accessibility.spec.ts` |
| 9 | Scrollable `pre` blocks are not keyboard-focusable in every browser, so long lines may be unreachable by keyboard on some browsers. | Open. | `src/styles.css` |
| 10 | The stored visual baselines in `tests/visual/__screenshots__` predate the current layout and do not match a fresh render. | Open. Baselines should be regenerated on the CI platform, not locally. | `tests/visual/` |
| 11 | Hover and active link and button states relied solely on color changes (#104). | Fixed: added underlines, offsets, and elevation cues. | `src/styles.css` |
| 12 | Module cards lacked structured heading levels for cards and their workflow groups (#105). | Fixed: established h2 -> h3 -> h4 hierarchy and region grouping. | `src/App.tsx` |
| 13 | Active command parameters lacked a screen-reader accessible summary (#106). | Fixed: added polite live region and focusable summary card. | `src/App.tsx` |
| 14 | Interactive controls and cards lost clear boundaries in high-contrast/forced-colors modes (#107). | Fixed: added forced-colors and prefers-contrast: more styling. | `src/styles.css` |
96 changes: 96 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,102 @@ describe("Navigation enhancements (#112, #113, #114, #115)", () => {
});
});

describe("Accessibility enhancements (#104, #105, #106, #107)", () => {
it("provides logical heading hierarchy in modules section and cards (#105)", () => {
render(<App />);
const modulesSection = document.getElementById("modules");
expect(modulesSection).toHaveAttribute("aria-labelledby", "modules-heading");

const modulesHeading = document.getElementById("modules-heading");
expect(modulesHeading).not.toBeNull();
expect(modulesHeading).toHaveAttribute("id", "modules-heading");

const articles = document.querySelectorAll("article.module-card");
expect(articles.length).toBeGreaterThan(0);

articles.forEach((article) => {
const heading = article.querySelector("h3");
expect(heading).not.toBeNull();
const headingId = heading?.getAttribute("id");
expect(headingId).toBeTruthy();
expect(article).toHaveAttribute("aria-labelledby", headingId);

const workflowGroup = article.querySelector(".module-card-links");
expect(workflowGroup).toHaveAttribute("role", "group");
const h4 = workflowGroup?.querySelector("h4");
expect(h4).not.toBeNull();
expect(h4).toHaveTextContent("Available workflows");
expect(h4).toHaveClass("sr-only");
});
});

it("provides empty state heading when no modules match search filter (#105)", () => {
render(<App />);
const searchInput = screen.getByLabelText("Search modules by name or description");
fireEvent.change(searchInput, { target: { value: "nonexistent-module-xyz" } });

const emptyStatus = screen.getByRole("status");
expect(emptyStatus).toHaveAttribute("aria-labelledby", "module-empty-heading");

const emptyHeading = screen.getByRole("heading", { level: 3, name: /No modules match/i });
expect(emptyHeading).toHaveAttribute("id", "module-empty-heading");
});

it("provides screen-reader summary of active command configuration (#106)", () => {
render(<App />);
const summaryRegion = screen.getByRole("region", {
name: "Active command configuration summary",
});
expect(summaryRegion).toBeInTheDocument();
expect(summaryRegion).toHaveAttribute("tabindex", "0");

const srSummary = screen.getByTestId("active-command-sr-summary");
expect(srSummary).toHaveAttribute("aria-live", "polite");
expect(srSummary).toHaveAttribute("aria-atomic", "true");
expect(srSummary).toHaveTextContent("Active coverage command settings");

// Switching workflow updates the SR summary
const limitsTab = screen.getByRole("button", { name: "limits" });
fireEvent.click(limitsTab);

expect(srSummary).toHaveTextContent("Active limits command settings");
});

it("includes live region announcement for generated command output (#106)", () => {
render(<App />);
const generatedSrSummary = screen.getByTestId("generated-command-sr-summary");
expect(generatedSrSummary).toHaveAttribute("aria-live", "polite");
expect(generatedSrSummary).toHaveTextContent("soroban-testkit coverage");
});

it("supports non-color cues on links and active states (#104)", () => {
render(<App />);
const navLinks = screen.getAllByRole("link", { name: /^(Install|Modules|Commands)$/ });
const commandsLink = navLinks.find((l) => l.textContent === "Commands");
expect(commandsLink).toBeDefined();

fireEvent.click(commandsLink!);
expect(commandsLink).toHaveAttribute("aria-current", "location");

// All module card quick links have non-empty accessible text
const moduleLinks = document.querySelectorAll(".module-card-links a");
expect(moduleLinks.length).toBeGreaterThan(0);
moduleLinks.forEach((link) => {
expect(link.textContent?.trim().length).toBeGreaterThan(0);
});
});

it("ensures controls and summary cards have container boundaries for high contrast (#107)", () => {
render(<App />);
const summaryCard = document.querySelector(".summary-card");
expect(summaryCard).not.toBeNull();
expect(summaryCard).toHaveClass("summary-card");

const moduleCards = document.querySelectorAll(".module-card");
expect(moduleCards.length).toBeGreaterThan(0);
});
});

// #117 — mobile navigation menu
describe("Mobile navigation menu", () => {
it("renders the mobile menu toggle button", () => {
Expand Down
44 changes: 33 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,7 @@ export default function App() {
</aside>
</section>

<section className="modules" id="modules" tabIndex={-1}>
<section className="modules" id="modules" aria-labelledby="modules-heading" tabIndex={-1}>
{selectedModuleData ? (
<article className="module-detail">
{/* #136 — visible page title for deep-linked views */}
Expand All @@ -830,7 +830,7 @@ export default function App() {
<span aria-current="page">{selectedModuleData.name}</span>
</nav>
<p className="eyebrow">{selectedModuleData.category}</p>
<h2>{selectedModuleData.name}</h2>
<h2 id="modules-heading">{selectedModuleData.name}</h2>
<p>{selectedModuleData.description}</p>
<button type="button" className="button secondary" onClick={() => setSelectedModule(null)}>
Back to all modules
Expand All @@ -843,7 +843,7 @@ export default function App() {
<div className="module-empty-icon" aria-hidden="true">
!
</div>
<h2>Module not found</h2>
<h2 id="modules-heading">Module not found</h2>
<p>
The module "{selectedModule}" does not exist in the Soroban Testkit library.
Check the module name or browse the available modules below.
Expand All @@ -860,7 +860,7 @@ export default function App() {
<>
<header>
<p className="eyebrow">One prelude. Seven focused modules.</p>
<h2>Everything a serious contract test needs.</h2>
<h2 id="modules-heading">Everything a serious contract test needs.</h2>
<div className="module-filter-bar">
<label htmlFor="module-search" className="sr-only">
Filter modules
Expand Down Expand Up @@ -912,19 +912,20 @@ export default function App() {
{filteredModules.length > 0 ? (
<div className="module-grid" id="module-grid">
{filteredModules.map((mod, index) => (
<article key={mod.name} className="module-card">
<article key={mod.name} className="module-card" aria-labelledby={`module-heading-${mod.name}`}>
<button
type="button"
className="module-card-select"
aria-label={`View details for ${mod.name} module`}
onClick={() => setSelectedModule(mod.name)}
>
<span aria-hidden="true">0{index + 1}</span>
<h3>{mod.name}</h3>
<h3 id={`module-heading-${mod.name}`}>{mod.name}</h3>
<p>{mod.description}</p>
<span className="module-category-tag">{mod.category}</span>
</button>
<div className="module-card-links">
<div className="module-card-links" role="group" aria-label={`Workflows for ${mod.name} module`}>
<h4 className="sr-only">Available workflows</h4>
<a href="#commands" aria-label={`Open the command builder from the ${mod.name} module`}>
Command builder
</a>
Expand All @@ -943,11 +944,11 @@ export default function App() {
))}
</div>
) : (
<div className="module-empty-state" role="status" aria-live="polite">
<div className="module-empty-state" role="status" aria-live="polite" aria-labelledby="module-empty-heading">
<div className="module-empty-icon" aria-hidden="true">
∅
</div>
<h3>{moduleFilter ? `No modules match “${moduleFilter}”` : "No modules match your filters"}</h3>
<h3 id="module-empty-heading">{moduleFilter ? `No modules match “${moduleFilter}”` : "No modules match your filters"}</h3>
<p>
{categoryFilter
? `No testing modules in the ${categoryFilter} category match this search.`
Expand Down Expand Up @@ -1268,8 +1269,22 @@ export default function App() {
</button>
</div>

<section className="summary-card" aria-labelledby="command-summary-heading">
<section
className="summary-card"
aria-labelledby="command-summary-heading"
tabIndex={0}
role="region"
aria-label="Active command configuration summary"
>
<h3 id="command-summary-heading">Command summary</h3>
<div
className="sr-only"
aria-live="polite"
aria-atomic="true"
data-testid="active-command-sr-summary"
>
Active {command} command settings: {summaryRows.map((row) => `${row.label} is ${row.value}`).join(", ")}.
</div>
<p className="summary-workflow">{command}</p>
<dl className="summary-settings">
{summaryRows.map((row) => (
Expand Down Expand Up @@ -1495,7 +1510,14 @@ export default function App() {
)}
</section>

<div className="output">
<div className="output" role="region" aria-label="Generated CLI command">
<div
className="sr-only"
aria-live="polite"
data-testid="generated-command-sr-summary"
>
Active generated command: {generated}
</div>
<code data-testid="command-output">{highlightCommand(generated)}</code>
<button onClick={copy}>{copied ? "Copied" : "Copy"}</button>
{/* #146 — download action for generated shell script */}
Expand Down
Loading