feat(scrollspy): the "on this page" rail - #631
Conversation
<.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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
ScreenshotsTracking 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. Light Dark Verified independently
The 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 Gaps the author was straight about: keyboard-only, Images live on the |
…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
Greptile SummaryAdds a public scrollspy component, its LiveView hook, styling, showcase registration, playground, and component/JavaScript tests.
Confidence Score: 2/5The 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
|
| 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
Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
| @media (prefers-reduced-motion: reduce) { | ||
| .pc-collapsible__panel, | ||
| .pc-collapsible__chevron { | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| @media (prefers-reduced-motion: reduce) { | |
| .pc-collapsible__panel, | |
| .pc-collapsible__chevron { | |
| } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| .pc-collapsible__panel, | |
| .pc-collapsible__chevron { | |
| transition: none; | |
| } | |
| } |
| 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; | ||
| }); | ||
| } |
There was a problem hiding this comment.
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
| applyHash() { | ||
| const id = decodeURIComponent(window.location.hash.replace(/^#/, "")); | ||
| if (!id || !this.targets?.some((target) => target.id === id)) return false; |
There was a problem hiding this comment.
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.
| 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



Closes #613
Summary
A new
<.scrollspy>component plus aPetalScrollspyhook: the docs "on this page" rail that follows the reader down a long article.Two APIs, both first-class in the moduledoc:
items(label + target id, optionally one level ofchildren), get a<nav>with an animated indicator bar.offset,threshold,indicator(bar/none),heading,aria_label,class,rest.phx-hook="PetalScrollspy"on any container whose links carrydata-scrollspy-target, and the hook drives your own markup. It setsaria-current="location"andpc-scrollspy-link--activeon the active link and nothing else. Zeropc-scrollspyclasses needed.The hook uses an
IntersectionObserverover 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
lib/petal_components/scrollspy.exlib/petal_components/showcase/scrollspy.exassets/js/petal_components.jsPetalScrollspyhook + exportedscrollspyActive/2assets/default.csspc-scrollspysection, inside@layer componentsdev.exs/c/scrollspyplayground: a real mini guide with offset / nesting / indicator dialstest/petal/scrollspy_test.exstest/js/scrollspy.test.jslib/petal_components.ex,showcase/registry.ex,CHANGELOG.mdDeviations from the issue's API sketch
aria_labelattr (default"On this page"). The sketch hardcodedaria-labelnext to{@rest}; sincerestis a global, a consumer passing their ownaria-labelwould 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.pc-scrollspy--bar/pc-scrollspy--nonemodifier. The rail line is drawn on the list only in bar mode, soindicator="none"is genuinely a plain text list rather than a railed one with the bar removed.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.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.rootMarginis-25% 0px -70% 0px(activation line at 25% of the viewport), overridable viadata-thresholdexactly as specified.Observer cleanup
destroyed()disconnects the observer, removes thehashchangeandresizelisteners fromwindow, detaches thescrolllistener from the scroll root, cancels any pendingrequestAnimationFrame, and restores every style the hook borrowed - the container's previousscroll-behaviorand each target's previous inlinescroll-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 ahashchangeto 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
passiveand 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: smoothon the scroll root at mount and puts the previous value back on destroy. Underprefers-reduced-motion: reduceit 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 indefault.css- the bar still tracks the active link, it just arrives without the glide. Scrolling never moves focus in either case.Tests
mix testnpm testmix format --check-formatted,mix compile --force --warnings-as-errorsandmix credoare all clean - credo reports the same 14 refactoring opportunities / 31 readability issues asmain, 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 nopc-scrollspyclasses on it. Images are not committed - happy to attach them to this thread on request.