diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.stories.tsx b/src/entities/Section/ui/SectionAccordion/SectionAccordion.stories.tsx
index 1ad4866..3523df2 100644
--- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.stories.tsx
+++ b/src/entities/Section/ui/SectionAccordion/SectionAccordion.stories.tsx
@@ -23,7 +23,7 @@ export const Active: Story = {
title: 'Biography',
id: 'bio',
isActive: true,
- onClick: () => {},
+ href: '/bio',
children:
This is the expanded section content. It is visible because isActive is true.
,
},
};
@@ -34,7 +34,7 @@ export const Collapsed: Story = {
title: 'Work',
id: 'work',
isActive: false,
- onClick: () => console.log('section clicked'),
+ href: '/work',
children: This content is hidden in collapsed state.
,
},
};
diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx b/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx
index 2d01031..aa978cf 100644
--- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx
+++ b/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import { SectionAccordion } from './SectionAccordion';
const defaultProps = {
@@ -7,7 +6,7 @@ const defaultProps = {
title: 'About',
id: 'about',
isActive: false,
- onClick: vi.fn(),
+ href: '/about',
children: Content here
,
};
@@ -17,19 +16,25 @@ describe('SectionAccordion', () => {
const { container } = render();
expect(container.querySelector('section#about')).toBeInTheDocument();
});
- it('renders a button with number and title', () => {
+
+ it('renders a link with number and title', () => {
render();
- 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();
+ expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
+ });
+
it('does not render children', () => {
render();
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
});
- it('calls onClick when button is clicked', async () => {
- const onClick = vi.fn();
- render();
- await userEvent.click(screen.getByRole('button'));
- expect(onClick).toHaveBeenCalledOnce();
+
+ it('does not render a button', () => {
+ render();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
@@ -40,14 +45,17 @@ describe('SectionAccordion', () => {
render();
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
});
+
it('renders children', () => {
render();
expect(screen.getByText('Content here')).toBeInTheDocument();
});
- it('does not render a button', () => {
+
+ it('does not render a link', () => {
render();
- expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
+
it('content wrapper has animate-fadeIn class', () => {
const { container } = render();
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx b/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx
index 27efb08..18da052 100644
--- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx
+++ b/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx
@@ -1,3 +1,4 @@
+import Link from 'next/link';
import type { ReactNode } from 'react';
interface SectionAccordionProps {
@@ -18,9 +19,9 @@ interface SectionAccordionProps {
*/
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
*/
@@ -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 (
{isActive ? (
@@ -46,10 +47,9 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
{children}
) : (
-
+
)}
);
diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx
index 9c544f5..703ba5c 100644
--- a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx
+++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import type { SectionRecord } from '$entities/Section';
import { SectionsAccordion } from './SectionsAccordion';
@@ -12,10 +11,10 @@ const sections: SectionRecord[] = [
];
describe('SectionsAccordion', () => {
- describe('initial state', () => {
- it('renders the first section as active (h1)', () => {
+ describe('active section rendering', () => {
+ it('renders the active section as an h1', () => {
render(
-
+
Intro content
Bio content
Skills content
@@ -24,56 +23,49 @@ describe('SectionsAccordion', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
});
- it('renders remaining sections as collapsed buttons', () => {
+ it('renders inactive sections as links', () => {
render(
-
+
Intro content
Bio content
Skills content
,
);
- const buttons = screen.getAllByRole('button');
- expect(buttons).toHaveLength(2);
+ const links = screen.getAllByRole('link');
+ expect(links).toHaveLength(2);
});
- });
- describe('interaction', () => {
- it('activates clicked section', async () => {
- const user = userEvent.setup();
+ it('inactive section links point to correct hrefs', () => {
render(
-
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ 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(
+
Intro content
Bio content
Skills content
,
);
- await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
});
- it('deactivates previously active section after click', async () => {
- const user = userEvent.setup();
+ it('only one section is active at a time', () => {
render(
-
+
Intro content
Bio content
Skills content
,
);
- 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(
-
- Intro content
- Bio content
- Skills content
- ,
- );
- await user.click(screen.getByRole('button', { name: /03\. Skills/i }));
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
});
});
@@ -81,7 +73,7 @@ describe('SectionsAccordion', () => {
describe('content slots', () => {
it('shows active section content', () => {
render(
-
+
Intro content
Bio content
Skills content
@@ -90,17 +82,16 @@ describe('SectionsAccordion', () => {
expect(screen.getByText('Intro content')).toBeInTheDocument();
});
- it('shows correct content after switching sections', async () => {
- const user = userEvent.setup();
+ it('does not show inactive section content', () => {
render(
-
+
Intro content
Bio content
Skills content
,
);
- await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
- expect(screen.getByText('Bio content')).toBeInTheDocument();
+ expect(screen.queryByText('Bio content')).not.toBeInTheDocument();
+ expect(screen.queryByText('Skills content')).not.toBeInTheDocument();
});
});
});
diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx
index 04673b3..51fe610 100644
--- a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx
+++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx
@@ -1,7 +1,5 @@
-'use client';
-
import type { ReactNode } from 'react';
-import { Children, useState } from 'react';
+import { Children } from 'react';
import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section';
@@ -10,6 +8,11 @@ type Props = {
* Ordered section metadata — drives navigation labels and IDs
*/
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
*/
@@ -17,11 +20,11 @@ type Props = {
};
/**
- * Manages accordion open/close state across all portfolio sections.
- * Receives RSC content as opaque children slots, matched positionally to sections.
+ * Renders all portfolio sections as an accordion list.
+ * 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) {
- const [activeSlug, setActiveSlug] = useState(sections[0]?.slug ?? '');
+export function SectionsAccordion({ sections, activeSlug, children }: Props) {
const slots = Children.toArray(children);
return (
@@ -33,7 +36,7 @@ export function SectionsAccordion({ sections, children }: Props) {
number={section.number}
title={section.title}
isActive={activeSlug === section.slug}
- onClick={() => setActiveSlug(section.slug)}
+ href={`/${section.slug}`}
>
{slots[i]}