Skip to content

feat(SectionSpyNav): sticky in-page section nav with sliding underline - #392

Open
ebellamy-bh wants to merge 3 commits into
mainfrom
feat/section-spy-nav
Open

feat(SectionSpyNav): sticky in-page section nav with sliding underline#392
ebellamy-bh wants to merge 3 commits into
mainfrom
feat/section-spy-nav

Conversation

@ebellamy-bh

Copy link
Copy Markdown

Ports the Enterprise Health frontdoor's shared in-page wayfinding strip into the design system — the horizontal complement to TableOfContents.

What it does

  • Sticky horizontal section nav: pure #anchor links (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 horizontally
  • Scroll spy reuses the library's useScrollSpy hook (ids mode, tunable rootMargin) instead of a hand-rolled IntersectionObserver; the active link auto-scrolls into view on the rail
  • Two tones: surface (page-background band with backdrop blur) and brand (inverted primary band)
  • Optional tiered CTAexplore / evaluate / commit map to ghost/outline/primary buttonVariants so the in-page strip never out-shouts a page's global CTA; the directional arrow (lucide ArrowDown/ArrowRight/ArrowUpRight) is inferred from the destination
  • onItemClick / onCtaClick callbacks replace the source site's hard-wired analytics coupling

Screenshots

Scroll-spy active on a mid-page section (surface tone) and the brand tone with a commit-tier CTA:

Light (surface) Dark (surface)
SectionSpyNav light SectionSpyNav dark
Brand tone (light) Brand tone (dark)
SectionSpyNav brand light SectionSpyNav brand dark

Provenance

Port of enterprise-health-frontdoor/components/ui/SectionSpyNav.tsx (promoted there from the vertical industry hubs into a shared component). Next.js Link → plain anchors, .spynav* global CSS → Tailwind --mieweb-* tokens, text arrows → lucide icons.

Testing

  • 7 unit tests (anchor rendering, first-item fallback before the spy runs, aria-current, nav labeling, item/CTA callbacks, tone classes)
  • 3 stories with scrollable demo sections (default + brand tone + no CTA)
  • typecheck / lint / format / rtl:scan clean; full suite 596/596

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.
Copilot AI lite review requested due to automatic review settings August 23, 2026 03:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 SectionSpyNav component (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 section elements to document.body in beforeEach but never removes them, and it stubs IntersectionObserver without cleanup. That can pollute unrelated tests (especially other DOM-querying hooks/components). Track created elements and remove them in afterEach, and call vi.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.

Comment thread src/components/SectionSpyNav/SectionSpyNav.test.tsx Outdated
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying ui with  Cloudflare Pages  Cloudflare Pages

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

View logs

Copilot AI review requested due to automatic review settings August 23, 2026 04:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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, and transition-[left,width]). Per src/tailwind-preset.ts the library maintains miewebUISafelist so 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.escape issue here: if CSS.escape is unavailable this effect will throw and break the nav. Use the same dataset-based lookup as in syncMarker.
    const link = railRef.current?.querySelector<HTMLAnchorElement>(
      `a[data-id="${window.CSS.escape(active)}"]`
    );

Comment thread src/components/SectionSpyNav/SectionSpyNav.tsx Outdated
Copilot AI review requested due to automatic review settings August 23, 2026 04:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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();
});

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.

2 participants