From 9fa2156ee8725e61dbf810a12c4df3758c89726d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 7 May 2026 12:46:31 +0300 Subject: [PATCH] feat: SectionAccordion inactive state uses Link href instead of button onClick --- .../SectionAccordion.stories.tsx | 4 +- .../SectionAccordion.test.tsx | 30 +++++---- .../ui/SectionAccordion/SectionAccordion.tsx | 18 ++--- .../SectionsAccordion.test.tsx | 65 ++++++++----------- .../SectionsAccordion/SectionsAccordion.tsx | 19 +++--- 5 files changed, 69 insertions(+), 67 deletions(-) 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]}