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 type { NavItem } from '../model/types';
|
||||||
import { SidebarNav } from './SidebarNav';
|
import { SidebarNav } from './SidebarNav';
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
usePathname: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
const ITEMS: NavItem[] = [
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
{ id: 'bio', label: 'Bio', number: '01' },
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
{ 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('SidebarNav', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(usePathname).mockReturnValue('/bio');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a nav element', () => {
|
it('renders a nav element', () => {
|
||||||
render(<SidebarNav items={ITEMS} />);
|
render(<SidebarNav items={ITEMS} />);
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||||
@@ -50,10 +52,33 @@ describe('SidebarNav', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
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} />);
|
render(<SidebarNav items={ITEMS} />);
|
||||||
const buttons = screen.getAllByRole('button');
|
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
|
||||||
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
|
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';
|
'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 { CONTACT_LINKS, cn } from '$shared/lib';
|
||||||
import type { NavItem } from '../model/types';
|
import type { NavItem } from '../model/types';
|
||||||
|
|
||||||
@@ -13,41 +14,23 @@ interface Props {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed sidebar navigation, visible on lg+ screens.
|
* Fixed sidebar navigation, visible on lg+ screens.
|
||||||
|
* Active section determined by current URL pathname.
|
||||||
*/
|
*/
|
||||||
export function SidebarNav({ items }: Props) {
|
export function SidebarNav({ items }: Props) {
|
||||||
const [activeSection, setActiveSection] = useState('bio');
|
const pathname = usePathname();
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
function isActive(item: NavItem): boolean {
|
||||||
const el = document.getElementById(id);
|
if (pathname === `/${item.id}`) {
|
||||||
if (el) {
|
return true;
|
||||||
const top = el.getBoundingClientRect().top + window.scrollY - 40;
|
|
||||||
window.scrollTo({ top, behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
|
if (pathname === '/' && items[0]?.id === item.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,27 +43,23 @@ export function SidebarNav({ items }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.map((item) => {
|
{items.map((item) => (
|
||||||
const isActive = activeSection === item.id;
|
<Link
|
||||||
return (
|
key={item.id}
|
||||||
<button
|
href={`/${item.id}`}
|
||||||
type="button"
|
className={cn(
|
||||||
key={item.id}
|
'block w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
|
||||||
onClick={() => scrollToSection(item.id)}
|
isActive(item)
|
||||||
className={cn(
|
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
|
||||||
'w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
|
: 'opacity-40 shadow-none hover:opacity-60',
|
||||||
isActive
|
)}
|
||||||
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
|
>
|
||||||
: 'opacity-40 shadow-none hover:opacity-60',
|
<div className="flex items-baseline gap-4">
|
||||||
)}
|
<span className="text-sm opacity-60">{item.number}</span>
|
||||||
>
|
<span className="font-heading text-xl font-black">{item.label}</span>
|
||||||
<div className="flex items-baseline gap-4">
|
</div>
|
||||||
<span className="text-sm opacity-60">{item.number}</span>
|
</Link>
|
||||||
<span className="font-heading text-xl font-black">{item.label}</span>
|
))}
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="mt-12 pt-12 brutal-border-top">
|
<div className="mt-12 pt-12 brutal-border-top">
|
||||||
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user