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
83 changes: 83 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -708,3 +708,86 @@ describe("Navigation enhancements (#112, #113, #114, #115)", () => {
expect(html).toHaveStyle("scroll-padding-top: 78px");
});
});

// #117 — mobile navigation menu
describe("Mobile navigation menu", () => {
it("renders the mobile menu toggle button", () => {
render(<App />);
expect(screen.getByRole("button", { name: /open navigation menu/i })).toBeInTheDocument();
});

it("toggles the mobile menu on click", () => {
render(<App />);
const toggle = screen.getByRole("button", { name: /open navigation menu/i });
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("button", { name: /close navigation menu/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /close navigation menu/i }));
expect(screen.getByRole("button", { name: /open navigation menu/i })).toHaveAttribute("aria-expanded", "false");
});

it("has accessible aria attributes on the toggle", () => {
render(<App />);
const toggle = screen.getByRole("button", { name: /open navigation menu/i });
expect(toggle).toHaveAttribute("aria-controls", "mobile-menu");
});
});

// #118 — module table of contents
describe("Module table of contents", () => {
it("renders the TOC toggle button", () => {
render(<App />);
expect(screen.getByRole("button", { name: /show table of contents/i })).toBeInTheDocument();
});

it("expands the TOC list on click", () => {
render(<App />);
const toggle = screen.getByRole("button", { name: /show table of contents/i });
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("navigation", { name: /module table of contents/i })).toBeInTheDocument();
});

it("lists all modules in the TOC", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: /show table of contents/i }));
const tocList = document.getElementById("module-toc-list");
expect(tocList).toBeInTheDocument();
expect(tocList!.querySelectorAll("li").length).toBeGreaterThan(0);
});
});

// #119 — changelog link in footer
describe("Footer changelog link", () => {
it("renders a changelog link in the footer", () => {
render(<App />);
const footer = screen.getByRole("contentinfo");
const changelogLink = within(footer).getByRole("link", { name: /changelog/i });
expect(changelogLink).toHaveAttribute("href", expect.stringContaining("/releases"));
});
});

// #116 — accessibility statement
describe("Accessibility statement", () => {
it("renders the accessibility link in the footer", () => {
render(<App />);
const footer = screen.getByRole("contentinfo");
expect(within(footer).getByRole("button", { name: /accessibility statement/i })).toBeInTheDocument();
});

it("opens the accessibility dialog on click", () => {
render(<App />);
const footer = screen.getByRole("contentinfo");
fireEvent.click(within(footer).getByRole("button", { name: /accessibility statement/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText(/accessibility statement/i)).toBeInTheDocument();
});

it("closes the dialog when the close button is clicked", () => {
render(<App />);
const footer = screen.getByRole("contentinfo");
fireEvent.click(within(footer).getByRole("button", { name: /accessibility statement/i }));
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
92 changes: 90 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,16 @@ export default function App() {
const [auditGuideOpen, setAuditGuideOpen] = useState(false);
const [checkedPrerequisites, setCheckedPrerequisites] = useState<Record<string, boolean>>({});
const comparePanelRef = useRef<HTMLDivElement>(null);
const [showBackToTop, setShowBackToTop] = useState(false);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [selectedModule, setSelectedModule] = useState<string | null>(null);
// #117 — mobile nav toggle
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
// #118 — module table of contents
const [tocOpen, setTocOpen] = useState(false);
// #116 — accessibility statement modal
const [a11yOpen, setA11yOpen] = useState(false);
const [activeSection, setActiveSection] = useActiveSection(navSectionIds);

useEffect(() => {
Expand Down Expand Up @@ -493,19 +503,34 @@ export default function App() {
<span className="brand-mark">S</span>
<span>Soroban Testkit</span>
</a>
<div className="nav-links">
{/* #117 — accessible mobile nav toggle */}
<button
type="button"
className="mobile-menu-toggle"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
aria-label={mobileMenuOpen ? "Close navigation menu" : "Open navigation menu"}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<span aria-hidden="true">{mobileMenuOpen ? "✕" : "☰"}</span>
</button>
<div className="nav-links" id="mobile-menu" ref={mobileMenuRef} data-open={mobileMenuOpen}>
{navSections.map((section) => (
<a
key={section.id}
href={`#${section.id}`}
aria-current={activeSection === section.id ? "location" : undefined}
onClick={() => setActiveSection(section.id)}
onClick={() => { setActiveSection(section.id); setMobileMenuOpen(false); }}
onFocus={() => setActiveSection(section.id)}
>
{section.label}
</a>
))}
<a href="https://github.com/soroban-testkit/testkit-blockchain">GitHub ↗</a>
{/* #116 — accessibility statement link */}
<button type="button" className="nav-a11y-link" onClick={() => { setA11yOpen(true); setMobileMenuOpen(false); }}>
Accessibility
</button>
</div>
</nav>

Expand Down Expand Up @@ -603,6 +628,35 @@ export default function App() {
<header>
<p className="eyebrow">One prelude. Seven focused modules.</p>
<h2>Everything a serious contract test needs.</h2>
{/* #118 — module table of contents */}
<nav className="module-toc" aria-label="Module table of contents">
<button
type="button"
className="module-toc-toggle"
aria-expanded={tocOpen}
aria-controls="module-toc-list"
onClick={() => setTocOpen(!tocOpen)}
>
{tocOpen ? "Hide" : "Show"} table of contents
</button>
{tocOpen && (
<ol id="module-toc-list" className="module-toc-list">
{modules.map(([name, description]) => (
<li key={name}>
<a
href={`#module-${name}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(`module-${name}`)?.scrollIntoView({ behavior: "smooth" });
}}
>
<strong>{name}</strong> — {description}
</a>
</li>
))}
</ol>
)}
</nav>
<div className="module-filter-bar">
<label htmlFor="module-search" className="sr-only">
Filter modules
Expand Down Expand Up @@ -722,6 +776,7 @@ export default function App() {
{filteredModules.map((mod, index) => (
<article
key={mod.name}
id={`module-${mod.name}`}
className="module-card"
tabIndex={0}
role="button"
Expand Down Expand Up @@ -1467,10 +1522,43 @@ export default function App() {
<span>Built for the Stellar developer ecosystem.</span>
<span className="footer-links">
<a href={CORE_REPO_URL}>Soroban Testkit core repository</a>
{/* #119 — changelog link */}
<a href={`${CORE_REPO_URL}/releases`}>Changelog</a>
{/* #116 — accessibility statement */}
<button type="button" className="footer-a11y-link" onClick={() => setA11yOpen(true)}>
Accessibility statement
</button>
</span>
<span>Apache-2.0</span>
</footer>

{/* #116 — accessibility statement dialog */}
{a11yOpen && (
<div className="a11y-overlay" role="dialog" aria-modal="true" aria-labelledby="a11y-title" onClick={() => setA11yOpen(false)}>
<div className="a11y-dialog" onClick={(e) => e.stopPropagation()}>
<h2 id="a11y-title">Accessibility Statement</h2>
<p>
The Soroban Testkit Interface is committed to WCAG 2.1 AA compliance. We aim to make every
feature keyboard-accessible, screen-reader friendly, and usable across devices.
</p>
<ul>
<li>All interactive controls are reachable via keyboard (Tab, Enter, Space, Escape).</li>
<li>ARIA labels and live regions announce dynamic state changes.</li>
<li>Color contrast meets or exceeds 4.5:1 for body text.</li>
<li>Responsive layout supports mobile and desktop viewports.</li>
</ul>
<p>
Found an accessibility issue? Open an issue on{" "}
<a href={INTERFACE_REPO_URL}>GitHub</a> or contact{" "}
<a href="mailto:accessibility@soroban-testkit.dev">accessibility@soroban-testkit.dev</a>.
</p>
<button type="button" className="button primary" onClick={() => setA11yOpen(false)} autoFocus>
Close
</button>
</div>
</div>
)}

{/* Back-to-top control (#125) */}
<button
type="button"
Expand Down
108 changes: 108 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,111 @@ footer { display: flex; justify-content: space-between; padding: 34px var(--page
transition: none;
}
}

/* #117 — mobile nav toggle */
.mobile-menu-toggle {
display: none;
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xs);
padding: var(--space-sm) var(--space-md);
font-size: 1.2rem;
cursor: pointer;
color: var(--color-text);
}
.mobile-menu-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
@media (max-width: 768px) {
.mobile-menu-toggle { display: block; }
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
flex-direction: column;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-md);
z-index: 100;
}
.nav-links[data-open="true"] { display: flex; }
}

/* #116 — accessibility statement dialog */
.a11y-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-lg);
}
.a11y-dialog {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: var(--space-xl);
max-width: 520px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.a11y-dialog h2 { margin-bottom: var(--space-md); }
.a11y-dialog p, .a11y-dialog ul { margin-bottom: var(--space-md); line-height: 1.6; }
.a11y-dialog ul { padding-left: 1.2rem; }
.a11y-dialog li { margin-bottom: var(--space-xs); }
.footer-a11y-link, .nav-a11y-link {
background: none;
border: none;
color: inherit;
cursor: pointer;
text-decoration: underline;
font-size: inherit;
padding: 0;
}
.footer-a11y-link:focus-visible, .nav-a11y-link:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}

/* #118 — module table of contents */
.module-toc { margin: var(--space-md) 0; }
.module-toc-toggle {
background: none;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xs);
padding: var(--space-xs) var(--space-md);
cursor: pointer;
color: var(--color-text);
font-size: 0.85rem;
}
.module-toc-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.module-toc-list {
margin-top: var(--space-sm);
padding-left: 1.4rem;
line-height: 1.8;
font-size: 0.9rem;
}
.module-toc-list a {
color: var(--color-text);
text-decoration: none;
}
.module-toc-list a:hover { color: var(--color-accent); text-decoration: underline; }
.module-toc-list a:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}

/* #119 — footer changelog link */
.footer-links {
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}