Skip to content

Add an accessible Table of Contents block - #288

Open
Castellon-ACM wants to merge 6 commits into
trunkfrom
table-of-contents-block
Open

Add an accessible Table of Contents block#288
Castellon-ACM wants to merge 6 commits into
trunkfrom
table-of-contents-block

Conversation

@Castellon-ACM

@Castellon-ACM Castellon-ACM commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #260.

Adds a dynamic frontblocks/table-of-contents block that generates accessible navigation from the headings in the current post.

How it works

Since the block usually appears before the headings it needs to link to, it can't discover them from its own render_callback (sibling blocks haven't rendered yet). Instead, render_callback() outputs a small config placeholder, and a the_content filter at priority 30 — after do_blocks() has rendered the whole post — scans the final HTML for every heading, assigns stable anchors, and replaces the placeholder with real navigation markup.

  • Heading discovery is regex-based against rendered <h1>-<h6> tags, not tied to any specific block — works with core Heading blocks, GenerateBlocks headings, or anything else that outputs a heading tag.
  • Stable, unique anchors without replacing author-supplied ids: any heading without an id gets one generated from its text (disambiguated with a -2, -3... suffix on collisions, and never colliding with an existing author-supplied id either); an author-set id (e.g. via core Heading's "HTML anchor" field) is always preserved untouched.
  • Focus-on-navigate: headings get tabindex="-1" so clicking a Table of Contents entry moves keyboard/screen-reader focus to the destination, not just the viewport.
  • Active-section highlighting: an IntersectionObserver marks the current section's link via aria-current="location", updated silently (no aria-live region) so it never triggers a screen-reader announcement on every scroll tick.
  • Reduced motion: scrolling is instant instead of smooth for visitors with prefers-reduced-motion: reduce.
  • Collapsible mode renders as a native <details>/<summary> — free, fully keyboard-accessible expand/collapse with zero custom JS.
  • Options: title, list style (bulleted/numbered/plain), heading-level range, sticky, accent color.
  • Assets load only when the block is present (has_block()).

Test plan

  • New PHPUnit coverage (TableOfContentsTest.php, 17 tests): id assignment, preserving author ids, avoiding id collisions, duplicate-text disambiguation, level-range filtering, empty-range no-render, collapsible/collapsed-by-default output, list style variants, sticky/accent-color, block-agnostic heading discovery, multiple independent TOC instances, the placeholder/injector contract, and skipping text-less headings.
  • New JS regression tests (table-of-contents-frontend.test.js, 6 tests): click-to-scroll-and-focus, instant scrolling under reduced motion, gracefully handling a missing target, and the IntersectionObserver active-section logic.
  • Full PHPUnit suite passes: 421 tests, 837 assertions. composer lint (phpcs) clean.
  • Live-verified in a real browser across 3 published posts:
    • Default TOC: generated ids, preserved author id, disambiguated duplicate headings, all with correct links; clicking a link moved real DOM focus (document.activeElement) to the heading, not just scroll; aria-current="location" correctly tracked the visible section while scrolling.
    • Collapsible TOC: rendered as a real <details>/<summary>, toggled open/closed by a plain click with .open verified before/after — no custom JS involved.
    • Negative control (no TOC block): frontend JS/CSS not loaded, and headings got no TOC-related attributes — confirming the block only affects pages where it's actually used.
    • No console/page errors in any scenario.
Open WordPress Playground Preview

Registers a dynamic frontblocks/table-of-contents block. Since it
usually appears before the headings it needs to link to, its own
render_callback can't see them yet (sibling blocks haven't rendered).
It instead outputs a small config placeholder, and a the_content
filter at priority 30 — after do_blocks() has rendered the whole
post — scans the final HTML for every heading, assigns a stable
unique anchor (and tabindex, for focus-on-navigate) to any heading
lacking one while always preserving author-supplied ids, and
replaces each placeholder with the real navigation markup filtered
to that instance's configured heading-level range.

Supports optional list style (bulleted/numbered/plain), accent
color, a collapsible mode (rendered as a native <details>/<summary>,
so expand/collapse is keyboard-accessible for free), and a sticky
position. Heading discovery is regex-based against the rendered HTML
tags themselves, so it works with core Heading blocks, GenerateBlocks
headings, or any other block that outputs an <h1>-<h6> tag.
- Clicking an entry scrolls to and moves keyboard/screen-reader focus
  onto its target heading, not just the viewport — using instant
  (not smooth) scrolling for visitors with prefers-reduced-motion.
- An IntersectionObserver marks whichever section is currently in
  view via aria-current="location" on its link, updated silently
  (no aria-live region) so it never triggers a screen-reader
  announcement on every scroll tick.
- Styles for the list/collapsible/sticky variants, a visible focus
  indicator scoped to the TOC's own focus-target headings (not a
  blanket [tabindex="-1"] selector, to avoid affecting unrelated
  elements elsewhere on the site), and a prefers-reduced-motion
  fallback disabling transitions and smooth scrolling.
Inspector controls for title, list style, heading-level range,
collapsible/collapsed-by-default, sticky, and accent color. The edit
preview lists core Heading blocks found anywhere in the current post
(recursing into innerBlocks) — GenerateBlocks and other third-party
heading output isn't a distinct, reliably identifiable block type in
the editor's block tree, so it's a best-effort preview only; the
published page is unaffected since the frontend discovers every
heading tag in the final rendered HTML regardless of its source.
Covers: heading id assignment, preserving author-supplied ids,
avoiding collisions between generated and author-supplied ids,
disambiguating duplicate heading text, level-range filtering, the
empty-range no-render case, collapsible/collapsed-by-default output,
list style variants, sticky/accent-color output, heading discovery
being independent of the originating block, multiple independent TOC
instances in one post, the render_placeholder/injector contract, and
skipping headings with no real text content.
Covers click-to-scroll-and-focus, instant scrolling under
prefers-reduced-motion, gracefully doing nothing for a link with no
matching heading, and the IntersectionObserver-driven active-section
highlighting (including that a previous active link loses
aria-current when a new section becomes active). Wired into CI.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@davidperezgar

Copy link
Copy Markdown
Contributor

Three things:

  • Title block: FrontBlocks Table of Content
  • Does not render from Template to public
  • Hierarchy does not show in editor. It works in public

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an accessible Table of Contents block

2 participants