From a2eebfb7009bce63f93919dd5862c6f4e9a3ae98 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Thu, 23 Jul 2026 23:52:44 +0100 Subject: [PATCH] a11y(docs): add proper heading structure --- src/app/docs/Section.test.tsx | 58 +++++++++++++++++++++++++++++++++++ src/app/docs/Section.tsx | 27 ++++++++++++++++ src/app/docs/page.test.tsx | 18 +++++++++++ src/app/docs/page.tsx | 16 +++++----- 4 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 src/app/docs/Section.test.tsx create mode 100644 src/app/docs/Section.tsx diff --git a/src/app/docs/Section.test.tsx b/src/app/docs/Section.test.tsx new file mode 100644 index 0000000..bcdc947 --- /dev/null +++ b/src/app/docs/Section.test.tsx @@ -0,0 +1,58 @@ +import { render } from "@testing-library/react"; +import { DocsSection } from "./Section"; + +function getHeadingOutline(container: HTMLElement) { + const headings = Array.from( + container.querySelectorAll("h1, h2, h3, h4, h5, h6"), + ); + return headings.map((h) => parseInt(h.tagName[1], 10)); +} + +function assertValidOutline(levels: number[]) { + if (levels.length === 0) return; + for (let i = 1; i < levels.length; i++) { + const prev = levels[i - 1]; + const curr = levels[i]; + expect(curr).toBeLessThanOrEqual(prev + 1); + } +} + +describe("DocsSection heading outline", () => { + it("forms a valid outline without skipping levels (single section page)", () => { + const { container } = render( +
+

Title

+ +

content

+
+
, + ); + const outline = getHeadingOutline(container); + assertValidOutline(outline); + expect(outline).toEqual([1, 2]); + }); + + it("forms a valid outline without skipping levels (deeply nested section)", () => { + const { container } = render( +
+

Title

+ + + + + + +

content

+
+
+
+
+
+
+
, + ); + const outline = getHeadingOutline(container); + assertValidOutline(outline); + expect(outline).toEqual([1, 2, 3, 4, 5, 6, 6]); + }); +}); diff --git a/src/app/docs/Section.tsx b/src/app/docs/Section.tsx new file mode 100644 index 0000000..17c994e --- /dev/null +++ b/src/app/docs/Section.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; + +// Context to track the current heading level +export const LevelContext = React.createContext(2); + +interface DocsSectionProps { + heading: React.ReactNode; + children: React.ReactNode; +} + +export function DocsSection({ heading, children }: DocsSectionProps) { + const level = React.useContext(LevelContext); + const HeadingTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements; + + return ( +
+ {heading} +
+ + {children} + +
+
+ ); +} diff --git a/src/app/docs/page.test.tsx b/src/app/docs/page.test.tsx index 50428b0..3679e66 100644 --- a/src/app/docs/page.test.tsx +++ b/src/app/docs/page.test.tsx @@ -21,6 +21,24 @@ describe('DocsPage', () => { expect(screen.getByText(/GET \/api\/v1\/quote/i)).toBeInTheDocument(); }); + it('forms a valid heading outline without skipping levels', () => { + const { container } = render(); + const headings = Array.from( + container.querySelectorAll('h1, h2, h3, h4, h5, h6') + ); + const levels = headings.map((h) => parseInt(h.tagName[1], 10)); + + expect(levels.length).toBeGreaterThan(0); + expect(levels[0]).toBe(1); // Page starts with h1 + + for (let i = 1; i < levels.length; i++) { + const prev = levels[i - 1]; + const curr = levels[i]; + // A heading can be any level up to prev + 1 + expect(curr).toBeLessThanOrEqual(prev + 1); + } + }); + it('marks openapi.json as an external link', () => { render(); const link = screen.getByRole('link', { name: /openapi\.json/i }); diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 93fae5d..5b82454 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -1,10 +1,11 @@ import type { Metadata } from 'next'; import { OpenApiLink } from './OpenApiLink'; +import { DocsSection } from "./Section"; export const dynamic = 'force-static'; export const metadata: Metadata = { - title: 'Docs', + title: 'Docs — StableRoute', description: 'Short reference for the StableRoute HTTP API common endpoints.', }; @@ -42,16 +43,13 @@ export default function DocsPage() { Companion to (opens external API spec) — short prose for the most common endpoints.

-
+
{sections.map((s) => ( -
-
{s.h}
-
- {s.p} -
-
+ +

{s.p}

+
))} -
+ ); }