fix: storybook font rendering and shared fonts module #1

Merged
ilia merged 74 commits from feat/portfolio-setup into main 2026-05-18 18:45:22 +00:00
5 changed files with 69 additions and 67 deletions
Showing only changes of commit 9fa2156ee8 - Show all commits
@@ -23,7 +23,7 @@ export const Active: Story = {
title: 'Biography', title: 'Biography',
id: 'bio', id: 'bio',
isActive: true, isActive: true,
onClick: () => {}, href: '/bio',
children: <p>This is the expanded section content. It is visible because isActive is true.</p>, children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
}, },
}; };
@@ -34,7 +34,7 @@ export const Collapsed: Story = {
title: 'Work', title: 'Work',
id: 'work', id: 'work',
isActive: false, isActive: false,
onClick: () => console.log('section clicked'), href: '/work',
children: <p>This content is hidden in collapsed state.</p>, children: <p>This content is hidden in collapsed state.</p>,
}, },
}; };
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SectionAccordion } from './SectionAccordion'; import { SectionAccordion } from './SectionAccordion';
const defaultProps = { const defaultProps = {
@@ -7,7 +6,7 @@ const defaultProps = {
title: 'About', title: 'About',
id: 'about', id: 'about',
isActive: false, isActive: false,
onClick: vi.fn(), href: '/about',
children: <p>Content here</p>, children: <p>Content here</p>,
}; };
@@ -17,19 +16,25 @@ describe('SectionAccordion', () => {
const { container } = render(<SectionAccordion {...defaultProps} />); const { container } = render(<SectionAccordion {...defaultProps} />);
expect(container.querySelector('section#about')).toBeInTheDocument(); expect(container.querySelector('section#about')).toBeInTheDocument();
}); });
it('renders a button with number and title', () => {
it('renders a link with number and title', () => {
render(<SectionAccordion {...defaultProps} />); render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
}); });
it('link points to the correct href', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
});
it('does not render children', () => { it('does not render children', () => {
render(<SectionAccordion {...defaultProps} />); render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByText('Content here')).not.toBeInTheDocument(); expect(screen.queryByText('Content here')).not.toBeInTheDocument();
}); });
it('calls onClick when button is clicked', async () => {
const onClick = vi.fn(); it('does not render a button', () => {
render(<SectionAccordion {...defaultProps} onClick={onClick} />); render(<SectionAccordion {...defaultProps} />);
await userEvent.click(screen.getByRole('button')); expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(onClick).toHaveBeenCalledOnce();
}); });
}); });
@@ -40,14 +45,17 @@ describe('SectionAccordion', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
}); });
it('renders children', () => { it('renders children', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.getByText('Content here')).toBeInTheDocument(); expect(screen.getByText('Content here')).toBeInTheDocument();
}); });
it('does not render a button', () => {
it('does not render a link', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument();
}); });
it('content wrapper has animate-fadeIn class', () => { it('content wrapper has animate-fadeIn class', () => {
const { container } = render(<SectionAccordion {...activeProps} />); const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument(); expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
@@ -1,3 +1,4 @@
import Link from 'next/link';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
interface SectionAccordionProps { interface SectionAccordionProps {
@@ -18,9 +19,9 @@ interface SectionAccordionProps {
*/ */
isActive: boolean; isActive: boolean;
/** /**
* Called when the collapsed header is clicked * Navigation URL for the collapsed heading link
*/ */
onClick: () => void; href: string;
/** /**
* Section content, shown when active * Section content, shown when active
*/ */
@@ -28,9 +29,9 @@ interface SectionAccordionProps {
} }
/** /**
* Accordion-style section that collapses to a heading button when inactive. * Accordion-style section that collapses to a navigation link when inactive.
*/ */
export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) { export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
return ( return (
<section id={id} className="scroll-mt-8"> <section id={id} className="scroll-mt-8">
{isActive ? ( {isActive ? (
@@ -46,10 +47,9 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
<div className="animate-fadeIn">{children}</div> <div className="animate-fadeIn">{children}</div>
</div> </div>
) : ( ) : (
<button <Link
type="button" href={href}
onClick={onClick} className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
> >
<h2 <h2
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity" className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
@@ -57,7 +57,7 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
> >
{number}. {title} {number}. {title}
</h2> </h2>
</button> </Link>
)} )}
</section> </section>
); );
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { SectionRecord } from '$entities/Section'; import type { SectionRecord } from '$entities/Section';
import { SectionsAccordion } from './SectionsAccordion'; import { SectionsAccordion } from './SectionsAccordion';
@@ -12,10 +11,10 @@ const sections: SectionRecord[] = [
]; ];
describe('SectionsAccordion', () => { describe('SectionsAccordion', () => {
describe('initial state', () => { describe('active section rendering', () => {
it('renders the first section as active (h1)', () => { it('renders the active section as an h1', () => {
render( render(
<SectionsAccordion sections={sections}> <SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div> <div>Intro content</div>
<div>Bio content</div> <div>Bio content</div>
<div>Skills content</div> <div>Skills content</div>
@@ -24,56 +23,49 @@ describe('SectionsAccordion', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro'); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
}); });
it('renders remaining sections as collapsed buttons', () => { it('renders inactive sections as links', () => {
render( render(
<SectionsAccordion sections={sections}> <SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div> <div>Intro content</div>
<div>Bio content</div> <div>Bio content</div>
<div>Skills content</div> <div>Skills content</div>
</SectionsAccordion>, </SectionsAccordion>,
); );
const buttons = screen.getAllByRole('button'); const links = screen.getAllByRole('link');
expect(buttons).toHaveLength(2); expect(links).toHaveLength(2);
}); });
});
describe('interaction', () => { it('inactive section links point to correct hrefs', () => {
it('activates clicked section', async () => {
const user = userEvent.setup();
render( render(
<SectionsAccordion sections={sections}> <SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
});
it('renders the correct active section for a given activeSlug', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Intro content</div> <div>Intro content</div>
<div>Bio content</div> <div>Bio content</div>
<div>Skills content</div> <div>Skills content</div>
</SectionsAccordion>, </SectionsAccordion>,
); );
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio'); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
}); });
it('deactivates previously active section after click', async () => { it('only one section is active at a time', () => {
const user = userEvent.setup();
render( render(
<SectionsAccordion sections={sections}> <SectionsAccordion sections={sections} activeSlug="skills">
<div>Intro content</div> <div>Intro content</div>
<div>Bio content</div> <div>Bio content</div>
<div>Skills content</div> <div>Skills content</div>
</SectionsAccordion>, </SectionsAccordion>,
); );
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.queryByRole('heading', { level: 1, name: /01\. Intro/i })).not.toBeInTheDocument();
});
it('only one section is active at a time', async () => {
const user = userEvent.setup();
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /03\. Skills/i }));
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
}); });
}); });
@@ -81,7 +73,7 @@ describe('SectionsAccordion', () => {
describe('content slots', () => { describe('content slots', () => {
it('shows active section content', () => { it('shows active section content', () => {
render( render(
<SectionsAccordion sections={sections}> <SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div> <div>Intro content</div>
<div>Bio content</div> <div>Bio content</div>
<div>Skills content</div> <div>Skills content</div>
@@ -90,17 +82,16 @@ describe('SectionsAccordion', () => {
expect(screen.getByText('Intro content')).toBeInTheDocument(); expect(screen.getByText('Intro content')).toBeInTheDocument();
}); });
it('shows correct content after switching sections', async () => { it('does not show inactive section content', () => {
const user = userEvent.setup();
render( render(
<SectionsAccordion sections={sections}> <SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div> <div>Intro content</div>
<div>Bio content</div> <div>Bio content</div>
<div>Skills content</div> <div>Skills content</div>
</SectionsAccordion>, </SectionsAccordion>,
); );
await user.click(screen.getByRole('button', { name: /02\. Bio/i })); expect(screen.queryByText('Bio content')).not.toBeInTheDocument();
expect(screen.getByText('Bio content')).toBeInTheDocument(); expect(screen.queryByText('Skills content')).not.toBeInTheDocument();
}); });
}); });
}); });
@@ -1,7 +1,5 @@
'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Children, useState } from 'react'; import { Children } from 'react';
import type { SectionRecord } from '$entities/Section'; import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section'; import { SectionAccordion } from '$entities/Section';
@@ -10,6 +8,11 @@ type Props = {
* Ordered section metadata — drives navigation labels and IDs * Ordered section metadata — drives navigation labels and IDs
*/ */
sections: SectionRecord[]; sections: SectionRecord[];
/**
* Slug of the currently active section.
* Must match one of the slugs in the sections array.
*/
activeSlug: string;
/** /**
* Pre-rendered RSC content slots, one per section, matched by index * Pre-rendered RSC content slots, one per section, matched by index
*/ */
@@ -17,11 +20,11 @@ type Props = {
}; };
/** /**
* Manages accordion open/close state across all portfolio sections. * Renders all portfolio sections as an accordion list.
* Receives RSC content as opaque children slots, matched positionally to sections. * Active section is determined by the URL (activeSlug prop); inactive sections
* render as navigation links so the browser handles routing.
*/ */
export function SectionsAccordion({ sections, children }: Props) { export function SectionsAccordion({ sections, activeSlug, children }: Props) {
const [activeSlug, setActiveSlug] = useState(sections[0]?.slug ?? '');
const slots = Children.toArray(children); const slots = Children.toArray(children);
return ( return (
@@ -33,7 +36,7 @@ export function SectionsAccordion({ sections, children }: Props) {
number={section.number} number={section.number}
title={section.title} title={section.title}
isActive={activeSlug === section.slug} isActive={activeSlug === section.slug}
onClick={() => setActiveSlug(section.slug)} href={`/${section.slug}`}
> >
{slots[i]} {slots[i]}
</SectionAccordion> </SectionAccordion>