fix: storybook font rendering and shared fonts module #1
@@ -2,21 +2,23 @@ import { render, screen } from '@testing-library/react';
|
||||
import type { NavItem } from '../model/types';
|
||||
import { SidebarNav } from './SidebarNav';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{ id: 'bio', label: 'Bio', number: '01' },
|
||||
{ id: 'work', label: 'Work', number: '02' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
global.IntersectionObserver = class {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
describe('SidebarNav', () => {
|
||||
describe('rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||
});
|
||||
|
||||
it('renders a nav element', () => {
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
@@ -50,10 +52,33 @@ describe('SidebarNav', () => {
|
||||
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a button for each item', () => {
|
||||
it('renders a link for each nav item', () => {
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
|
||||
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('active state', () => {
|
||||
it('marks matching pathname item as active (no opacity-40)', () => {
|
||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
const activeLink = screen.getByRole('link', { name: /Bio/i });
|
||||
expect(activeLink).not.toHaveClass('opacity-40');
|
||||
});
|
||||
|
||||
it('marks non-matching item as inactive (opacity-40)', () => {
|
||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
const inactiveLink = screen.getByRole('link', { name: /Work/i });
|
||||
expect(inactiveLink).toHaveClass('opacity-40');
|
||||
});
|
||||
|
||||
it('marks first item active at root path', () => {
|
||||
vi.mocked(usePathname).mockReturnValue('/');
|
||||
render(<SidebarNav items={ITEMS} />);
|
||||
const firstLink = screen.getByRole('link', { name: /Bio/i });
|
||||
expect(firstLink).not.toHaveClass('opacity-40');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { CONTACT_LINKS, cn } from '$shared/lib';
|
||||
import type { NavItem } from '../model/types';
|
||||
|
||||
@@ -13,41 +14,23 @@ interface Props {
|
||||
|
||||
/**
|
||||
* Fixed sidebar navigation, visible on lg+ screens.
|
||||
* Active section determined by current URL pathname.
|
||||
*/
|
||||
export function SidebarNav({ items }: Props) {
|
||||
const [activeSection, setActiveSection] = useState('bio');
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
||||
);
|
||||
|
||||
items.forEach((item) => {
|
||||
const el = document.getElementById(item.id);
|
||||
if (el) {
|
||||
observer.observe(el);
|
||||
}
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [items]);
|
||||
const pathname = usePathname();
|
||||
|
||||
/**
|
||||
* Scrolls to the section by id with a 40px offset.
|
||||
* An item is active when its slug matches the current pathname,
|
||||
* or when the pathname is root and it is the first item.
|
||||
*/
|
||||
function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 40;
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (pathname === `/${item.id}`) {
|
||||
return true;
|
||||
}
|
||||
if (pathname === '/' && items[0]?.id === item.id) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -60,16 +43,13 @@ export function SidebarNav({ items }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item) => {
|
||||
const isActive = activeSection === item.id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
onClick={() => scrollToSection(item.id)}
|
||||
href={`/${item.id}`}
|
||||
className={cn(
|
||||
'w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
|
||||
isActive
|
||||
'block w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
|
||||
isActive(item)
|
||||
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
|
||||
: 'opacity-40 shadow-none hover:opacity-60',
|
||||
)}
|
||||
@@ -78,9 +58,8 @@ export function SidebarNav({ items }: Props) {
|
||||
<span className="text-sm opacity-60">{item.number}</span>
|
||||
<span className="font-heading text-xl font-black">{item.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="mt-12 pt-12 brutal-border-top">
|
||||
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
||||
|
||||
Reference in New Issue
Block a user