feat(SectionSpyNav): sticky in-page section nav with sliding underline - #392
feat(SectionSpyNav): sticky in-page section nav with sliding underline#392ebellamy-bh wants to merge 3 commits into
Conversation
Ported from the Enterprise Health frontdoor's shared spy-nav strip: anchor links to page sections, a sliding underline tracking the section in view (built on useScrollSpy), surface/brand tones, and an optional tiered CTA (explore/evaluate/commit). The horizontal complement to TableOfContents.
There was a problem hiding this comment.
Pull request overview
Ports an in-page “wayfinding” navigation strip into the design system as SectionSpyNav: a sticky horizontal anchor-nav that highlights the active section via useScrollSpy, includes a sliding underline indicator, and optionally renders a tiered CTA.
Changes:
- Added
SectionSpyNavcomponent (sticky rail, scroll-spy active state, sliding underline, optional tiered CTA). - Added unit tests and Storybook stories for the new component.
- Exported the component from the package entrypoints (root barrel + tsup entry).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tsup.config.ts | Adds a build entry for components/SectionSpyNav/index. |
| src/index.ts | Re-exports SectionSpyNav from the library root. |
| src/components/SectionSpyNav/SectionSpyNav.tsx | Implements the new sticky in-page section nav with scroll spy + sliding underline + optional CTA. |
| src/components/SectionSpyNav/SectionSpyNav.test.tsx | Adds unit tests for anchor rendering, active fallback, aria labeling/current, callbacks, and tone. |
| src/components/SectionSpyNav/SectionSpyNav.stories.tsx | Adds Storybook demos (default, brand tone, no CTA) with scrollable sections. |
| src/components/SectionSpyNav/index.ts | Adds the component’s local barrel exports. |
Suppressed comments (1)
src/components/SectionSpyNav/SectionSpyNav.test.tsx:15
- This suite appends
sectionelements todocument.bodyinbeforeEachbut never removes them, and it stubsIntersectionObserverwithout cleanup. That can pollute unrelated tests (especially other DOM-querying hooks/components). Track created elements and remove them inafterEach, and callvi.unstubAllGlobals()there as well.
beforeEach(() => {
// jsdom has no IntersectionObserver; the spy falls back to the first item
vi.stubGlobal(
'IntersectionObserver',
vi.fn(() => ({
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Deploying ui with
|
| Latest commit: |
b5476ef
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://fe7c577f.ui-6d0.pages.dev |
| Branch Preview URL: | https://feat-section-spy-nav.ui-6d0.pages.dev |
…the IntersectionObserver stub
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/components/SectionSpyNav/SectionSpyNav.tsx:168
- This component introduces many new Tailwind class strings (including arbitrary/variant forms like
[scrollbar-width:none],[&::-webkit-scrollbar]:hidden, andtransition-[left,width]). Persrc/tailwind-preset.tsthe library maintainsmiewebUISafelistso Tailwind CSS 3 consumers who don’t scan node_modules still get required utilities; please add the new classes used by SectionSpyNav to that safelist.
<nav
className={cn(
'sticky top-0 z-30 w-full',
brand
? 'bg-primary-900 text-white'
: 'border-border bg-card/95 border-b backdrop-blur',
className
)}
aria-label={label}
>
<div className="flex items-center gap-4 px-4 py-0.5">
<span
className={cn(
'shrink-0 text-[11px] font-semibold tracking-wide uppercase max-md:hidden',
brand ? 'text-white/60' : 'text-muted-foreground'
)}
aria-hidden="true"
>
{label}
</span>
<div
ref={railRef}
className="relative flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
src/components/SectionSpyNav/SectionSpyNav.tsx:132
- Same
window.CSS.escapeissue here: ifCSS.escapeis unavailable this effect will throw and break the nav. Use the same dataset-based lookup as insyncMarker.
const link = railRef.current?.querySelector<HTMLAnchorElement>(
`a[data-id="${window.CSS.escape(active)}"]`
);
… by dataset.id instead of a built selector
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (5)
Previously missed (5) — in code that hasn't changed since the last review.
src/components/SectionSpyNav/SectionSpyNav.tsx:157
- New Tailwind class strings are introduced here (e.g. bg-primary-900, bg-card/95, backdrop-blur, text-white/60, text-white/70, bg-primary-400, transition-[left,width], [scrollbar-width:none], [&::-webkit-scrollbar]:hidden). These are not currently present in miewebUISafelist (see src/tailwind-preset.ts safelist), so Tailwind CSS 3 consumers that don’t scan node_modules may miss the required styles.
className={cn(
'sticky top-0 z-30 w-full',
brand
? 'bg-primary-900 text-white'
: 'border-border bg-card/95 border-b backdrop-blur',
src/components/SectionSpyNav/SectionSpyNav.tsx:111
- useScrollSpy is invoked even when items is empty, which causes useScrollSpy to set up a MutationObserver on document.body and keep retrying forever (until unmount) because there are no ids to observe. Guard the hook with enabled: ids.length > 0 (or early-return) to avoid unnecessary observers when there are no sections.
const ids = React.useMemo(() => items.map((it) => it.id), [items]);
const { activeId } = useScrollSpy({ ids, rootMargin });
const active = activeId ?? items[0]?.id ?? '';
src/components/SectionSpyNav/SectionSpyNav.tsx:142
- This effect measures layout (offsetLeft/offsetWidth) and updates inline styles. Using useEffect can cause the underline to render at the wrong position for a frame (visible jump) before it syncs. Prefer useLayoutEffect for this measurement+mutation to avoid flicker.
React.useEffect(() => {
const link = findActiveLink();
link?.scrollIntoView?.({ inline: 'center', block: 'nearest' });
syncMarker();
}, [findActiveLink, syncMarker]);
src/components/SectionSpyNav/SectionSpyNav.tsx:205
- The underline marker is an absolutely-positioned element rendered after the links, so it can sit on top of the anchors and intercept pointer events near the bottom edge of the links. Add pointer-events-none so it can’t block link clicks.
className={cn(
'absolute bottom-0 h-0.5 rounded-full transition-[left,width] duration-300',
brand ? 'bg-primary-400' : 'bg-primary-500'
)}
src/components/SectionSpyNav/SectionSpyNav.test.tsx:13
- The beforeEach appends
nodes to document.body but they are never removed. This can leak DOM state across tests (React cleanup won’t remove nodes created outside the render tree) and make unrelated tests order-dependent. Remove the seeded sections in afterEach.
afterEach(() => {
vi.unstubAllGlobals();
});
Ports the Enterprise Health frontdoor's shared in-page wayfinding strip into the design system — the horizontal complement to
TableOfContents.What it does
#anchorlinks (works even if the scroll spy never runs), with the in-view section highlighted by a sliding underline that tracks the active link — including while the rail is scrolled horizontallyuseScrollSpyhook (ids mode, tunablerootMargin) instead of a hand-rolled IntersectionObserver; the active link auto-scrolls into view on the railsurface(page-background band with backdrop blur) andbrand(inverted primary band)explore/evaluate/commitmap to ghost/outline/primarybuttonVariantsso the in-page strip never out-shouts a page's global CTA; the directional arrow (lucideArrowDown/ArrowRight/ArrowUpRight) is inferred from the destinationonItemClick/onCtaClickcallbacks replace the source site's hard-wired analytics couplingScreenshots
Scroll-spy active on a mid-page section (
surfacetone) and thebrandtone with a commit-tier CTA:Provenance
Port of
enterprise-health-frontdoor/components/ui/SectionSpyNav.tsx(promoted there from the vertical industry hubs into a shared component). Next.jsLink→ plain anchors,.spynav*global CSS → Tailwind--mieweb-*tokens, text arrows → lucide icons.Testing
aria-current, nav labeling, item/CTA callbacks, tone classes)typecheck/lint/format/rtl:scanclean; full suite 596/596