Skip to content

feat(scrollspy): the "on this page" rail - #631

Merged
nhobes merged 4 commits into
mainfrom
feat/scrollspy
Aug 24, 2026
Merged

nhobes merged 4 commits into
mainfrom
feat/scrollspy

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026 •

Copy link
Copy Markdown
Member

Closes #613

Summary

A new <.scrollspy> component plus a PetalScrollspy hook: the docs "on this page" rail that follows the reader down a long article.

Two APIs, both first-class in the moduledoc:

  1. The built-in renderer. Pass items (label + target id, optionally one level of children), get a <nav> with an animated indicator bar. offset, threshold, indicator (bar / none), heading, aria_label, class, rest.
  2. The bare data-attribute contract. phx-hook="PetalScrollspy" on any container whose links carry data-scrollspy-target, and the hook drives your own markup. It sets aria-current="location" and pc-scrollspy-link--active on the active link and nothing else. Zero pc-scrollspy classes needed.

The hook uses an IntersectionObserver over a reading band near the top of the viewport, but only as a trigger - every decision is recomputed from live rects. That keeps the rail honest when a section grows, the window resizes, or a LiveView patch swaps the content, and it makes the selection logic a pure function.

Files

File What
lib/petal_components/scrollspy.ex new component
lib/petal_components/showcase/scrollspy.ex 3 registry examples (basic, nested, bare markup)
assets/js/petal_components.js PetalScrollspy hook + exported scrollspyActive/2
assets/default.css pc-scrollspy section, inside @layer components
dev.exs /c/scrollspy playground: a real mini guide with offset / nesting / indicator dials
test/petal/scrollspy_test.exs 19 component tests
test/js/scrollspy.test.js 22 vitest specs
lib/petal_components.ex, showcase/registry.ex, CHANGELOG.md registration

Deviations from the issue's API sketch

  • Added an aria_label attr (default "On this page"). The sketch hardcoded aria-label next to {@rest}; since rest is a global, a consumer passing their own aria-label would have produced a duplicate attribute on the element. An explicit attr is the only way to let them override it.
  • "topmost wins" is implemented as "the last section to have reached the activation line". Read literally, "closest to the top of the viewport" is ambiguous when three short sections are all crowded above the line. The rule shipped is: among the sections that have reached the line, the one furthest down the page wins - i.e. the section that owns the reading position. It agrees with the issue's example cases and is unambiguous in the crowded one. Both are covered in the spec.
  • The nav carries a pc-scrollspy--bar / pc-scrollspy--none modifier. The rail line is drawn on the list only in bar mode, so indicator="none" is genuinely a plain text list rather than a railed one with the bar removed.
  • Nested children render in a pc-scrollspy__sublist <ul> inside the parent <li>. The sketch only named the link class; the sublist element is what makes the nesting real markup rather than indentation.
  • The selection logic is a named export, scrollspyActive(sections, {line, atBottom}), alongside the hook object. That is what lets the vitest suite test the behaviour directly rather than through a fake observer.
  • Bottom snap only fires on a container that actually scrolls. Without that guard a short page whose content fits entirely on screen would highlight its last entry forever, which is not a bottom any reader would recognise.
  • Default rootMargin is -25% 0px -70% 0px (activation line at 25% of the viewport), overridable via data-threshold exactly as specified.

Observer cleanup

destroyed() disconnects the observer, removes the hashchange and resize listeners from window, detaches the scroll listener from the scroll root, cancels any pending requestAnimationFrame, and restores every style the hook borrowed - the container's previous scroll-behavior and each target's previous inline scroll-margin-top. scan() disconnects the old observer before creating a new one, and the scroll listener moves with the scroll root rather than being left on the old one when a patch relocates the sections. There is a spec that destroys a hook and then fires a hashchange to prove the stale hook no longer drives the page.

The scroll listener exists because bottom snap is invisible to the observer: a closing section too short to reach the activation line never generates a callback. It is passive and coalesced to one pass per animation frame.

Reduced motion and smooth scroll

Smooth scrolling is a property of the scroll container, not of the click, so the hook sets scroll-behavior: smooth on the scroll root at mount and puts the previous value back on destroy. Under prefers-reduced-motion: reduce it never sets it at all, so jumps are instant. The indicator bar is positioned by JS (transform + height) and animated by CSS, which means the reduced-motion opt-out for the transition is one media query in default.css - the bar still tracks the active link, it just arrives without the glide. Scrolling never moves focus in either case.

Tests

Suite Before After
mix test 913 tests, 0 failures, 1 skipped 932 tests, 0 failures, 1 skipped
npm test 157 passing 179 passing

mix format --check-formatted, mix compile --force --warnings-as-errors and mix credo are all clean - credo reports the same 14 refactoring opportunities / 31 readability issues as main, so zero new entries.

Screenshots

Captured and eyeballed locally at 1280x1800 on /c/scrollspy: light, dark, and a scrolled shot where a non-first entry is active (a scrollspy screenshot with the first item highlighted proves nothing). Verified in both schemes that the active link reads stronger than the rest, the indicator sits against it, the nested example indents correctly, and the bare-markup example highlights with no pc-scrollspy classes on it. Images are not committed - happy to attach them to this thread on request.

<.scrollspy> renders a docs nav from an items list and the PetalScrollspy
hook keeps it pointed at whatever the reader is on.

The hook is where the work is. An IntersectionObserver over a reading
band near the top of the viewport is only the trigger; every decision is
made from live rects, so a section that grew, a resize, or a LiveView
patch can't leave the rail describing a page that no longer exists.

- topmost wins: the last section to have reached the activation line is
  active, so a new heading takes over as it arrives rather than when the
  previous one finally scrolls away
- bottom snap: at the bottom of a genuinely scrollable container the last
  section activates, which is the only way a short closing section is
  ever reachable. A page with nothing to scroll does not snap
- hash on mount and on hashchange beats the observer, so a deep link is
  right from the first paint
- data-offset applies scroll-margin-top to the targets (fixed headers),
  data-threshold overrides the rootMargin (the activation line)
- smooth scroll is set on the scroll container and handed back on
  destroy; prefers-reduced-motion opts out of it and of the indicator
  transition
- the observer, both listeners and every borrowed style are released in
  destroyed(); the scroll listener follows the scroll root across patches

Two APIs, both documented: the built-in renderer (flat or one level of
nesting, indicator="bar" or "none", optional heading), and a bare
data-attribute contract - phx-hook plus data-scrollspy-target on your own
markup, no pc-scrollspy classes required.

a11y: nav landmark with aria-label, aria-current="location" on the active
link so the state is not colour-only, plain anchors for native keyboard
operation, decorative indicator aria-hidden, and scrolling never moves
focus.

The selection logic is exported as a pure scrollspyActive/2 so the vitest
suite drives it without an IntersectionObserver.

19 component tests, 22 JS specs. 932 Elixir / 179 JS. Playground at
/c/scrollspy: a real mini guide with offset, nesting and indicator dials,
plus the bare-markup scenario.

Closes #613

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.44%. Comparing base (871b2cf) to head (c5fa3d9).
⚠️ Report is 24 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #631      +/-   ##
==========================================
+ Coverage   92.40%   92.44%   +0.04%     
==========================================
  Files         119      121       +2     
  Lines        5066     5094      +28     
==========================================
+ Hits         4681     4709      +28     
  Misses        385      385              

☔ View full report in Codecov by Harness.
📢 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.

mplatts added a commit that referenced this pull request Aug 12, 2026
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Tracking a non-first section — scrolled so "Wiring it up" owns the reading position. The indicator bar sits beside it, and the nested example below tracks independently.

Scrollspy active on item 2

Light

Scrollspy, light mode

Dark

Scrollspy, dark mode

Verified independently

Check Result
mix test 932 tests, 0 failures, 1 skipped (+19)
npm test 179 passing (+22)
mix format --check-formatted clean
mix compile --force --warnings-as-errors clean
mix credo zero new entries vs main
new dependencies none
slugs : render_page clauses 61 : 61

The dark:text-primary-400 → primary-300 fix is a good catch and the kind of thing that only shows up if you actually look: with the playground's default neutral primary, 400 lands at the same lightness as gray-400, so the active state was invisible in dark mode. 300 is also the more common convention in default.css (10 uses vs 7).

Two decisions worth a maintainer eye, both flagged in the PR body: "topmost wins" was ambiguous in the issue and shipped as "the last section to reach the activation line"; and a passive scroll listener rides alongside the observer because bottom-snap is invisible to IntersectionObserver by construction.

Gaps the author was straight about: keyboard-only, prefers-reduced-motion and hash-on-load are covered by unit specs but were not clicked through in a real browser.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

nhobes added 3 commits August 13, 2026 10:01
…live motion pref

Audit round, all on the smooth-scroll lifecycle. (1) Two rails sharing
one scroller each snapshotted the prior scroll-behavior into instance
state - destroyed in mount order, A restored '' and B re-restored
'smooth', leaving the page smooth forever. The FIRST smoother now
snapshots onto the ELEMENT (data-pc-smooth-prior) and a ref-count
(data-pc-smooth-count) decides who turns the lights off; test-pinned
with a two-rail mount/destroy spec. (2) When a LiveView patch moved the
sections into a different scroller, smoothing stayed on the old root -
enableSmoothScroll is now idempotent-and-migrating (restore old, smooth
new) and scan() re-invokes it. (3) The OS reduced-motion preference is
now followed live via a MediaQueryList change listener instead of being
frozen at mount. Also: the dev.exs scrollspy helpers had been inserted
between examples_for/2 and its doc comment, orphaning the comment onto
the wrong function - reunited. New spec also pins that updated()
applies data-offset to targets added by a patch (it does; the first
draft of the spec failed only on a test-env id collision, documented in
the test).
Sections inside an overflow pane were measured against the viewport and
the activation line was a fraction of the viewport's height, so the
highlight was a function of where the pane happened to sit on the page:
right at one page-scroll position, wrong at every other (the playground's
nested example made it visible). One scrollFrame() now answers the
page-vs-pane question for every measurement - section tops subtract the
pane's own top, the line is a fraction of the pane's height, and the
IntersectionObserver gets the pane as its root so rootMargin crops the
box that actually scrolls. Page-scrolled rails are byte-for-byte
unchanged. New spec pins the pane case and fails without the fix
(verified stashed: 1 failed / 24 passed).
# Conflicts:
#	CHANGELOG.md
#	assets/default.css
#	lib/petal_components.ex
#	lib/petal_components/showcase/registry.ex
@nhobes
nhobes marked this pull request as ready for review August 24, 2026 03:21
@nhobes
nhobes merged commit 645d348 into main Aug 24, 2026
3 checks passed
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

Adds a public scrollspy component, its LiveView hook, styling, showcase registration, playground, and component/JavaScript tests.

  • Renders flat or one-level nested in-page navigation with active-link and indicator states.
  • Tracks page or overflow-pane scrolling, hash navigation, offsets, reduced-motion preferences, and LiveView updates.
  • Registers the component and hook through the package’s public Elixir and JavaScript entry points.

Confidence Score: 2/5

The PR should not merge until reduced-motion behavior, patched offset cleanup, and malformed-fragment handling are corrected.

The stylesheet re-enables collapsible animation for reduced-motion users, while the hook can retain stale target spacing after LiveView updates and abort synchronization on malformed URL fragments.

Files Needing Attention: assets/default.css, assets/js/petal_components.js

Important Files Changed

Filename Overview
assets/js/petal_components.js Adds the complete scrollspy lifecycle and selection behavior, but update-time style cleanup and malformed-fragment handling can fail.
assets/default.css Adds scrollspy presentation and reduced-motion rules while accidentally deleting the existing collapsible reduced-motion declaration.
lib/petal_components/scrollspy.ex Introduces the documented public HEEx renderer, attributes, nested item markup, and hook data contract.
test/js/scrollspy.test.js Provides broad selection, lifecycle, cleanup, pane, and accessibility-state coverage but omits offset-removal and malformed-hash paths.
test/petal/scrollspy_test.exs Covers the component’s rendered markup, supported variants, attributes, and item structures.

Sequence Diagram

sequenceDiagram
  participant LV as LiveView
  participant Nav as Scrollspy nav
  participant Hook as PetalScrollspy
  participant Sections as Article sections
  participant Browser as Browser scroll/hash
  LV->>Nav: Render links and data attributes
  Nav->>Hook: Mount phx-hook
  Hook->>Sections: Resolve targets and apply offset
  Hook->>Browser: Observe sections and listen for scroll/hash
  Browser->>Hook: Scroll, resize, or hashchange
  Hook->>Sections: Read live section positions
  Hook->>Nav: Set active class, aria-current, and indicator
  LV->>Nav: Patch attributes or links
  Nav->>Hook: updated()
  Hook->>Sections: Rescan targets and synchronize state
Loading

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread assets/default.css
Comment on lines 9079 to +9083
@media (prefers-reduced-motion: reduce) {
.pc-collapsible__panel,
.pc-collapsible__chevron {
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Reduced-motion rule emptied

When a user enables reduced motion, this now-empty rule leaves the collapsible panel and chevron transitions active, causing those elements to continue animating despite the operating-system preference.

Suggested change
@media (prefers-reduced-motion: reduce) {
.pc-collapsible__panel,
.pc-collapsible__chevron {
}
}
@media (prefers-reduced-motion: reduce) {
.pc-collapsible__panel,
.pc-collapsible__chevron {
transition: none;
}
}

Comment on lines +6033 to +6041
const offset = this.el.dataset.offset;
if (offset) {
this.targets.forEach((target) => {
if (!this.targetStyles.has(target)) {
this.targetStyles.set(target, target.style.scrollMarginTop);
}
target.style.scrollMarginTop = offset;
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Patched offsets leave stale styles

When a LiveView patch clears data-offset or removes a previously linked target while preserving the hook, scan() never restores the borrowed scroll-margin-top, causing later anchor navigation to land with stale spacing.

Knowledge Base Used: Browser hooks

Comment on lines +6178 to +6180
applyHash() {
const id = decodeURIComponent(window.location.hash.replace(/^#/, ""));
if (!id || !this.targets?.some((target) => target.id === id)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Malformed hashes abort synchronization

When the page loads or receives a hashchange containing a malformed percent escape, decodeURIComponent throws before synchronization runs, causing the scrollspy to fail to initialize or refresh its active state.

Suggested change
applyHash() {
const id = decodeURIComponent(window.location.hash.replace(/^#/, ""));
if (!id || !this.targets?.some((target) => target.id === id)) return false;
applyHash() {
let id;
try {
id = decodeURIComponent(window.location.hash.replace(/^#/, ""));
} catch {
return false;
}
if (!id || !this.targets?.some((target) => target.id === id)) return false;

Knowledge Base Used: Browser hooks

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.

Component: Scrollspy

2 participants