Skip to content

ENH: Implement sticky RHS table of contents with scroll highlighting#350

Merged
mmcky merged 25 commits intomainfrom
feature/sticky-toc
Feb 19, 2026
Merged

ENH: Implement sticky RHS table of contents with scroll highlighting#350
mmcky merged 25 commits intomainfrom
feature/sticky-toc

Conversation

@mmcky
Copy link
Copy Markdown
Contributor

@mmcky mmcky commented Dec 11, 2025

Summary

This PR implements a sticky right-hand side table of contents (TOC) that remains fixed while scrolling, highlights the currently active section, and provides several quality-of-life features for navigating long pages.

Fixes

Features

  • Sticky TOC: When enabled, the TOC remains fixed at the top of the viewport while scrolling
  • Scroll Spy: Automatically highlights the current section in the TOC as you scroll through the page
  • Auto-expand subsections: Subsections automatically expand/collapse to show the active section hierarchy (configurable via contents_autoexpand)
  • Copy section link: Hover over any TOC entry to reveal a copy icon — click it to copy the full URL with anchor to your clipboard for easy sharing
  • Back to top button: A discrete "Back to top" button appears after scrolling down 300px
  • Configurable: Feature is disabled by default and can be enabled via sticky_contents: true

Technical Approach

  • Uses position: fixed instead of position: sticky for reliable behavior regardless of parent container positioning
  • Pure vanilla JavaScript with no jQuery dependency
  • Uses requestAnimationFrame for throttled scroll handling (smooth performance)
  • Modular design with new scrollspy.js module
  • Copy icon uses zero-width inline technique (display: inline-block; width: 0; overflow: visible) to appear after TOC text without affecting layout or causing line wrapping
  • Clipboard API (navigator.clipboard.writeText) with visual "Copied!" feedback

Configuration

Add to your Jupyter Book _config.yml:

sphinx:
  config:
    html_theme_options:
      sticky_contents: true        # Enable sticky TOC (default: false)
      contents_autoexpand: true     # Auto-expand subsections (default: true)

Or in Sphinx conf.py:

html_theme_options = {
    "sticky_contents": True,
    "contents_autoexpand": True,
}

When contents_autoexpand is False, only top-level sections are shown with bold highlighting for the active section — providing a cleaner, more compact TOC.

Files Changed

  • src/quantecon_book_theme/assets/scripts/scrollspy.js (NEW) — Scroll spy + copy-to-clipboard functionality
  • src/quantecon_book_theme/assets/scripts/index.js — Import and initialize scrollspy
  • src/quantecon_book_theme/assets/styles/_page.scss — Sticky positioning, active highlights, auto-expand/collapse, back-to-top button, copy-link icon styles
  • src/quantecon_book_theme/theme/quantecon_book_theme/layout.html — Conditional sticky class with data-autoexpand attribute, back-to-top button
  • src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf — New sticky_contents and contents_autoexpand options
  • docs/configure.md — Full documentation for the Sticky TOC feature
  • tests/test_build.py — Unit tests for sticky TOC configuration

Testing

  • Unit tests cover: default off, enabled with/without autoexpand, string boolean coercion
  • Pre-commit checks passing
  • CI workflow configured to test with sticky_contents: true in lecture-python-programming.myst
  • Playwright visual regression tests passing (46 tests)

- Add sticky_contents config option (defaults to False)
- Use position: fixed for reliable sticky behavior
- Add scrollspy.js module for tracking scroll position
- Highlight active section in TOC as user scrolls
- Support scrollable TOC for long content lists

Fixes #133

Usage:
  html_theme_options:
    sticky_contents: true
@codecov
Copy link
Copy Markdown

codecov bot commented Dec 11, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@6f7c6c3). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #350   +/-   ##
=======================================
  Coverage        ?   45.92%           
=======================================
  Files           ?        2           
  Lines           ?      405           
  Branches        ?        0           
=======================================
  Hits            ?      186           
  Misses          ?      219           
  Partials        ?        0           
Flag Coverage Δ
pytests 45.92% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Dec 11, 2025

🎭 Visual Regression Test Results

passed  45 passed
skipped  1 skipped

Details

stats  46 tests across 1 suite
duration  1 minute
commit  a54b04d

Skipped tests

mobile-chrome › theme.spec.ts › Theme Features › f-string interpolation styling

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Dec 11, 2025

@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 00:29 Inactive
- Shows floating button at bottom right when page is scrolled down
- Only appears when sticky_contents is enabled
- Uses modern Sass color.adjust() function
- Animated appearance with fade and slide transition
- Button hidden until user scrolls past 300px threshold
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 00:42 Inactive
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 01:20 Inactive
- Subsections are hidden by default in sticky TOC mode
- When a section becomes active, its parent sections expand
- When scrolling away, subsections collapse back
- Smooth animated transitions for expand/collapse
- Only affects sticky mode - non-sticky TOC unchanged
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 01:32 Inactive
- Subsections now stay expanded while scrolling through content
- Parent section stays expanded when any child is active
- Also expands parent's children when parent heading is active
- Only collapses when scrolling to a different top-level section
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 01:46 Inactive
- Track which top-level TOC section each item belongs to
- When active item changes, keep its entire top-level section expanded
- Expand ALL nested items within the active top-level section
- Subsections only collapse when scrolling to a different top-level section
- Uses :has() selector for finding items with children
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 04:32 Inactive
- Fix parent traversal to correctly find top-level TOC item
- Replace :has() selector with :scope > ul for broader browser support
- Ensure all nested items with children get expanded class
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 04:57 Inactive
- New theme option: contents_autoexpand (default: false)
- When false: TOC shows all sections normally (no collapse/expand)
- When true: Subsections auto-collapse/expand based on scroll position
- Allows sticky TOC to ship while auto-expand is refined
- Uses data-autoexpand attribute to pass config to JavaScript

Usage in _config.yml:
  html_theme_options:
    sticky_contents: true
    contents_autoexpand: true  # optional, defaults to false
- Expand all ancestors of the active item (so it's visible in collapsed tree)
- Expand the active item itself if it has children (show its subsections)
- Removed complex top-level tracking - simpler ancestor traversal
- When on 4.2, shows 4.2.1, 4.2.2 etc
- When on 4.2.1, keeps 4.2 expanded so 4.2.1 is visible
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 05:24 Inactive
- Start from parent ul of active item, not the item itself
- Walk up ul->li->ul->li chain correctly
- Ensures parent sections get expanded class when in subsection
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 05:37 Inactive
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 05:49 Inactive
- Theme config values come as strings ('True'/'False') not booleans
- Updated conditionals to check for both boolean and string values
- Fixes autoexpand not being enabled despite default True in theme.conf
@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 06:04 Inactive
@mmcky
Copy link
Copy Markdown
Contributor Author

mmcky commented Dec 11, 2025

  • check playright diffs and update fixtures
  • for long CONTENTS due to subsection expansion, can content just expand and collapse dropping content off the bottom if needed to remove the need for scroll bar.
Screenshot 2025-12-11 at 5 19 51 pm
  • test both options for autoexpand: True/False
  • get feedback from the team on the proposed new Top floating button

@github-actions github-actions bot temporarily deployed to pull request December 11, 2025 06:26 Inactive
- Change overflow-y from 'auto' to 'hidden' for cleaner appearance
- Content that exceeds viewport height will be clipped at bottom
- Also adds Core Rules section to copilot-instructions.md
@github-actions github-actions bot temporarily deployed to pull request December 12, 2025 01:24 Inactive
@mmcky
Copy link
Copy Markdown
Contributor Author

mmcky commented Dec 12, 2025

@jstac @DrDrij I have implemented the sticky contents feature we discussed in #133.
(@DrDrij I took what you started working on in 2021 and built on that).

This implementation is enabled through an option for the theme.

Here is a video of the new implementation which keeps the RHS contents visible while you scroll the page, and continuously provides context as you scroll through sections and subsections in the document. It also has a new Top floating button at the bottom right of the page which is in theme blue.

demo-stick-contents-autoexpand.mov

Interested in thoughts / comments / suggestions.

@mmcky
Copy link
Copy Markdown
Contributor Author

mmcky commented Dec 12, 2025

Here is demo of contents_autoexpand = False showing just top level sections, not sub-sections.

demo-autoexapand-false.mov

- When contents_autoexpand is false, highlight the top-level parent section
  instead of the hidden subsection as user scrolls through content
- Move back-to-top button outside .inner container to prevent clipping
  from overflow-y: hidden
@github-actions github-actions bot temporarily deployed to pull request December 12, 2025 04:06 Inactive
@DrDrij
Copy link
Copy Markdown
Member

DrDrij commented Dec 16, 2025

demo-stick-contents-autoexpand.mov

Interested in thoughts / comments / suggestions.

Functionality look perfect @mmcky. Nothing to add.

Just a thought, since you're introducing anchor points, why not make these accessible for users to share links to specific context..

image

@mmcky
Copy link
Copy Markdown
Contributor Author

mmcky commented Dec 17, 2025

Thanks @DrDrij.

Nice idea, would you add that to Contents or next to the titles themselves (when reading the doc) do you think?

- Fix test_git_functions_unit being accidentally merged into test_sticky_toc
- Move .logo/.powered selectors out of .back-to-top-btn SCSS nesting
- Extract repeated Jinja boolean checks into is_sticky/is_autoexpand variables
Adds a small copy icon next to each TOC entry in the sticky sidebar.
On hover the icon appears; clicking copies the full URL with section
anchor to the clipboard for easy sharing. Includes 'Copied!' feedback.

Addresses suggestion by @DrDrij in PR #350.
@github-actions github-actions bot temporarily deployed to pull request February 19, 2026 04:13 Inactive
Changed from a sibling <button> after the <a> to an inline <span>
inside the <a>, so the icon flows naturally at the end of the title
rather than appearing on a separate line.
- Increase settle time from 500ms to 1000ms for MathJax font metrics
- Switch from maxDiffPixels:300 to maxDiffPixelRatio:0.15 to handle
  font rendering variance across environments (CI reported 0.10 ratio)
@github-actions github-actions bot temporarily deployed to pull request February 19, 2026 04:41 Inactive
Position the icon absolutely 18px right of the <a> link, vertically
centered. This prevents the icon from taking up inline space and
causing text to wrap to the next line on longer TOC entries.
@mmcky mmcky requested a review from Copilot February 19, 2026 04:53
@github-actions github-actions bot temporarily deployed to pull request February 19, 2026 04:56 Inactive
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements an optional sticky right-hand table of contents for the QuantEcon Book Theme, including scroll-position highlighting (“scroll spy”) and related UI affordances, controlled via new html_theme_options.

Changes:

  • Add a new scrollspy.js module and initialize it from the theme’s main JS entrypoint.
  • Update theme layout + SCSS to support a fixed-position TOC, active-section styling, copy-link affordance, and a sticky-only back-to-top button.
  • Add/adjust tests and documentation for the new theme options (sticky_contents, contents_autoexpand).

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/quantecon_book_theme/assets/scripts/scrollspy.js New scroll spy + TOC copy-link/back-to-top behavior for sticky TOC mode.
src/quantecon_book_theme/assets/scripts/index.js Imports/initializes scroll spy on DOM ready.
src/quantecon_book_theme/assets/styles/_page.scss Adds sticky positioning and styling for active TOC items, copy-link icon, and back-to-top button.
src/quantecon_book_theme/theme/quantecon_book_theme/layout.html Conditionally renders sticky TOC container + data attributes and sticky-only back-to-top link.
src/quantecon_book_theme/theme/quantecon_book_theme/theme.conf Introduces default values for sticky_contents and contents_autoexpand.
tests/test_build.py Adds build-time assertions for sticky TOC option behavior and defaults.
tests/visual/theme.spec.ts Makes MathJax screenshot assertions less environment-sensitive.
docs/configure.md Documents configuration and behavior of sticky TOC + auto-expand.
.github/copilot-instructions.md Adds internal workflow rules for CLI output capture, long-running commands, and test/build workflow.
Comments suppressed due to low confidence (6)

src/quantecon_book_theme/assets/scripts/scrollspy.js:223

  • The copy control is a span with role="button" appended inside an <a>, which nests interactive elements and is not keyboard accessible (no tabindex / key handlers). Consider rendering a real <button type="button"> as a sibling to the link (or otherwise avoid nesting) and add keyboard interaction for accessibility.
    const icon = document.createElement("span");
    icon.className = "toc-copy-link";
    icon.setAttribute("role", "button");
    icon.setAttribute("aria-label", "Copy link to section");
    icon.setAttribute("title", "Copy link to section");
    // Use a simple clipboard SVG icon

src/quantecon_book_theme/theme/quantecon_book_theme/layout.html:147

  • The is_sticky / is_autoexpand boolean parsing is verbose and repeated. You can simplify and make it more robust by normalizing to string (e.g., |string|lower) once and comparing to 'true', which will handle both Python booleans and theme.conf string values consistently.
                    {# Determine sticky TOC and autoexpand settings once #}
                    {% set is_sticky = theme_sticky_contents is defined and (theme_sticky_contents is sameas true or theme_sticky_contents == "True" or theme_sticky_contents == "true") %}
                    {% set is_autoexpand = theme_contents_autoexpand is defined and (theme_contents_autoexpand is sameas true or theme_contents_autoexpand == 'True' or theme_contents_autoexpand == 'true') %}

                    {% if is_sticky %}
                    <div class="inner sticky" data-autoexpand="{% if is_autoexpand %}true{% else %}false{% endif %}">
                    {%- else %}

docs/configure.md:234

  • In this bullet, “discrete” appears to be used in the sense of “unobtrusive”; the usual word is “discreet”.
- **Back to top button**: A discrete "Back to top" button appears after scrolling down 300px
- **Auto-expand subsections**: Subsections automatically expand to show the current hierarchy

src/quantecon_book_theme/assets/styles/_page.scss:61

  • .inner.sticky sets overflow-y: hidden, which will clip long TOCs with no way to scroll/click headings that fall below the viewport. Consider using overflow-y: auto (or scroll) and optionally hiding the scrollbar via CSS if desired.
    .inner.sticky {
      position: fixed;
      top: 7rem;
      width: 200px;
      max-height: calc(100vh - 8rem);
      overflow-y: hidden;  // Clip content at bottom, no scrollbar
    }

src/quantecon_book_theme/assets/styles/_page.scss:113

  • The copy-link icon is hidden via opacity: 0 but will still receive pointer events, which can make clicks near the right edge unexpectedly trigger a copy instead of following the TOC link. Consider disabling pointer events (or using visibility: hidden) when hidden and enabling them on hover.
            .toc-copy-link {
              position: absolute;
              right: -18px;
              top: 50%;
              transform: translateY(-50%);
              cursor: pointer;
              color: colors.$body;
              opacity: 0;
              transition: opacity 0.15s ease, color 0.15s ease;

src/quantecon_book_theme/assets/scripts/scrollspy.js:247

  • navigator.clipboard.writeText(...) is called without checking for Clipboard API support and without a .catch(...). In non-secure contexts or when permissions are denied, this can throw or create an unhandled rejection. Consider feature-detecting navigator.clipboard?.writeText, adding error handling, and providing a fallback (or at least no-op gracefully).
      const href = link.getAttribute("href");
      const url =
        window.location.origin + window.location.pathname + href;

      navigator.clipboard.writeText(url).then(() => {
        // Show brief "Copied!" feedback
        icon.classList.add("copied");
        const originalTitle = icon.getAttribute("title");
        icon.setAttribute("title", "Copied!");
        setTimeout(() => {
          icon.classList.remove("copied");
          icon.setAttribute("title", originalTitle);
        }, 1500);
      });

The absolute positioning pushed the icon outside the 200px container
with overflow:hidden, making it invisible. Instead, use display:
inline-block with width:0 and overflow:visible — the icon visually
appears after the text via margin-left on the SVG, but takes zero
width in the flow so it can never cause a line break.
@github-actions github-actions bot temporarily deployed to pull request February 19, 2026 05:07 Inactive
@mmcky
Copy link
Copy Markdown
Contributor Author

mmcky commented Feb 19, 2026

thanks @DrDrij -- I have implemented your suggestion. Looks nice.

Screenshot 2026-02-19 at 4 12 33 pm

@mmcky mmcky merged commit 804bbab into main Feb 19, 2026
10 checks passed
@mmcky mmcky deleted the feature/sticky-toc branch February 19, 2026 05:15
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.

Adjustments for RHS TOC

3 participants