diff --git a/src/components/SectionSpyNav/SectionSpyNav.stories.tsx b/src/components/SectionSpyNav/SectionSpyNav.stories.tsx new file mode 100644 index 00000000..a2e4430f --- /dev/null +++ b/src/components/SectionSpyNav/SectionSpyNav.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SectionSpyNav, type SectionSpyItem } from './SectionSpyNav'; + +const meta: Meta = { + title: 'Components/Navigation/SectionSpyNav', + component: SectionSpyNav, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + "Sticky horizontal in-page wayfinding: anchor links to a page's major sections, a " + + 'sliding underline tracking the section in view (via `useScrollSpy`), and an ' + + 'optional page-specific CTA whose `tier` sets its visual weight. The horizontal ' + + 'complement to `TableOfContents`. Section elements need matching `id`s.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + items: { + description: 'Sections to link to, in page order.', + control: false, + }, + cta: { + description: + 'Optional next-step CTA (`tier`: explore | evaluate | commit).', + control: false, + }, + label: { description: 'Eyebrow before the links.', control: 'text' }, + tone: { + description: 'Visual tone of the band.', + control: 'select', + options: ['surface', 'brand'], + }, + rootMargin: { + description: 'IntersectionObserver root margin tuning.', + control: 'text', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const ITEMS: SectionSpyItem[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'capabilities', label: 'Capabilities' }, + { id: 'compliance', label: 'Compliance' }, + { id: 'integrations', label: 'Integrations' }, + { id: 'pricing', label: 'Pricing' }, +]; + +function DemoSections() { + return ( +
+ {ITEMS.map((it, i) => ( +
+

{it.label}

+

+ Scroll to see the underline slide to the section in view. This + section stands in for the page's {it.label.toLowerCase()}{' '} + content. +

+
+ ))} +
+ ); +} + +export const Default: Story = { + args: { + items: ITEMS, + cta: { label: 'Book a demo', href: '#pricing', tier: 'evaluate' }, + }, + render: (args) => ( +
+ + +
+ ), +}; + +export const BrandTone: Story = { + args: { + items: ITEMS, + tone: 'brand', + cta: { label: 'Get started', href: '/signup', tier: 'commit' }, + }, + render: (args) => ( +
+ + +
+ ), +}; + +export const WithoutCta: Story = { + args: { items: ITEMS }, + render: (args) => ( +
+ + +
+ ), +}; diff --git a/src/components/SectionSpyNav/SectionSpyNav.test.tsx b/src/components/SectionSpyNav/SectionSpyNav.test.tsx new file mode 100644 index 00000000..d366860e --- /dev/null +++ b/src/components/SectionSpyNav/SectionSpyNav.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithTheme } from '../../test/test-utils'; +import { SectionSpyNav, type SectionSpyItem } from './SectionSpyNav'; + +const ITEMS: SectionSpyItem[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'pricing', label: 'Pricing' }, +]; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +beforeEach(() => { + // jsdom has no IntersectionObserver; the spy falls back to the first item + vi.stubGlobal( + 'IntersectionObserver', + vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + takeRecords: vi.fn(() => []), + })) + ); + for (const it of ITEMS) { + if (!document.getElementById(it.id)) { + const el = document.createElement('section'); + el.id = it.id; + document.body.appendChild(el); + } + } +}); + +describe('SectionSpyNav', () => { + it('renders anchor links for every section', () => { + renderWithTheme(); + expect(screen.getByRole('link', { name: 'Overview' })).toHaveAttribute( + 'href', + '#overview' + ); + expect(screen.getByRole('link', { name: 'Pricing' })).toHaveAttribute( + 'href', + '#pricing' + ); + }); + + it('marks the first section active before the spy runs', () => { + renderWithTheme(); + expect(screen.getByRole('link', { name: 'Overview' })).toHaveAttribute( + 'aria-current', + 'true' + ); + expect(screen.getByRole('link', { name: 'Pricing' })).not.toHaveAttribute( + 'aria-current' + ); + }); + + it('uses the label as the accessible nav name', () => { + renderWithTheme(); + expect( + screen.getByRole('navigation', { name: 'Jump to' }) + ).toBeInTheDocument(); + }); + + it('reports link clicks', () => { + const onItemClick = vi.fn(); + renderWithTheme(); + fireEvent.click(screen.getByRole('link', { name: 'Pricing' })); + expect(onItemClick).toHaveBeenCalledWith('pricing'); + }); + + it('renders the CTA and reports clicks', () => { + const onCtaClick = vi.fn(); + const cta = { + label: 'Book a demo', + href: '/demo', + tier: 'commit' as const, + }; + renderWithTheme( + + ); + const link = screen.getByRole('link', { name: /book a demo/i }); + expect(link).toHaveAttribute('href', '/demo'); + fireEvent.click(link); + expect(onCtaClick).toHaveBeenCalledWith(cta); + }); + + it('omits the CTA when not provided', () => { + renderWithTheme(); + expect(screen.getAllByRole('link')).toHaveLength(ITEMS.length); + }); + + it('applies the brand tone', () => { + renderWithTheme(); + expect(screen.getByRole('navigation')).toHaveClass('bg-primary-900'); + }); +}); diff --git a/src/components/SectionSpyNav/SectionSpyNav.tsx b/src/components/SectionSpyNav/SectionSpyNav.tsx new file mode 100644 index 00000000..20040ca8 --- /dev/null +++ b/src/components/SectionSpyNav/SectionSpyNav.tsx @@ -0,0 +1,231 @@ +'use client'; + +import * as React from 'react'; +import { ArrowDown, ArrowRight, ArrowUpRight } from 'lucide-react'; +import { cn } from '../../utils/cn'; +import { useScrollSpy } from '../../hooks/useScrollSpy'; +import { buttonVariants } from '../Button'; + +// ============================================================================= +// Types +// ============================================================================= + +/** One section the nav links to. The id must match a `section[id]` on the page. */ +export interface SectionSpyItem { + id: string; + label: string; +} + +/** + * Funnel intent for the bar's single contextual CTA — drives its visual + * weight so the in-page strip never out-shouts a page's primary CTA: + * `explore` (quiet next step) → `evaluate` (sales-ready ask) → `commit` + * (high-intent action). + */ +export type SectionSpyCtaTier = 'explore' | 'evaluate' | 'commit'; + +export interface SectionSpyCta { + label: string; + href: string; + tier?: SectionSpyCtaTier; +} + +export interface SectionSpyNavProps { + /** Sections to link to, in page order. */ + items: SectionSpyItem[]; + /** Optional single page-specific next-step CTA at the end of the bar. */ + cta?: SectionSpyCta; + /** Eyebrow before the links (default "On this page"). */ + label?: string; + /** + * Visual tone: `surface` sits on the page background; `brand` renders the + * inverted primary band. + */ + tone?: 'surface' | 'brand'; + /** IntersectionObserver root margin tuning for the scroll spy. */ + rootMargin?: string; + /** Called with the section id when a nav link is clicked. */ + onItemClick?: (id: string) => void; + /** Called when the CTA is clicked (e.g. for analytics). */ + onCtaClick?: (cta: SectionSpyCta) => void; + className?: string; +} + +// ============================================================================= +// Internals +// ============================================================================= + +/** Directional glyph inferred from the destination. */ +function CtaArrow({ href }: { href: string }) { + const Icon = href.startsWith('#') + ? ArrowDown + : href.startsWith('/') + ? ArrowRight + : ArrowUpRight; + return