Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/components/SectionSpyNav/SectionSpyNav.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SectionSpyNav, type SectionSpyItem } from './SectionSpyNav';

const meta: Meta<typeof SectionSpyNav> = {
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<typeof meta>;

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 (
<main className="flex flex-col">
{ITEMS.map((it, i) => (
<section
key={it.id}
id={it.id}
className={
'flex min-h-[70vh] flex-col justify-center gap-2 px-8 ' +
(i % 2 ? 'bg-muted/40' : 'bg-background')
}
>
<h2 className="text-foreground text-2xl font-bold">{it.label}</h2>
<p className="text-muted-foreground max-w-lg text-sm">
Scroll to see the underline slide to the section in view. This
section stands in for the page&apos;s {it.label.toLowerCase()}{' '}
content.
</p>
</section>
))}
</main>
);
}

export const Default: Story = {
args: {
items: ITEMS,
cta: { label: 'Book a demo', href: '#pricing', tier: 'evaluate' },
},
render: (args) => (
<div>
<SectionSpyNav {...args} />
<DemoSections />
</div>
),
};

export const BrandTone: Story = {
args: {
items: ITEMS,
tone: 'brand',
cta: { label: 'Get started', href: '/signup', tier: 'commit' },
},
render: (args) => (
<div>
<SectionSpyNav {...args} />
<DemoSections />
</div>
),
};

export const WithoutCta: Story = {
args: { items: ITEMS },
render: (args) => (
<div>
<SectionSpyNav {...args} />
<DemoSections />
</div>
),
};
98 changes: 98 additions & 0 deletions src/components/SectionSpyNav/SectionSpyNav.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SectionSpyNav items={ITEMS} />);
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(<SectionSpyNav items={ITEMS} />);
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(<SectionSpyNav items={ITEMS} label="Jump to" />);
expect(
screen.getByRole('navigation', { name: 'Jump to' })
).toBeInTheDocument();
});

it('reports link clicks', () => {
const onItemClick = vi.fn();
renderWithTheme(<SectionSpyNav items={ITEMS} onItemClick={onItemClick} />);
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(
<SectionSpyNav items={ITEMS} cta={cta} onCtaClick={onCtaClick} />
);
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(<SectionSpyNav items={ITEMS} />);
expect(screen.getAllByRole('link')).toHaveLength(ITEMS.length);
});

it('applies the brand tone', () => {
renderWithTheme(<SectionSpyNav items={ITEMS} tone="brand" />);
expect(screen.getByRole('navigation')).toHaveClass('bg-primary-900');
});
});
Loading
Loading