feat: SectionAccordion inactive state uses Link href instead of button onClick
This commit is contained in:
@@ -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(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
@@ -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(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
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(
|
||||
<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>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
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(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<SectionsAccordion sections={sections} activeSlug="skills">
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</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);
|
||||
});
|
||||
});
|
||||
@@ -81,7 +73,7 @@ describe('SectionsAccordion', () => {
|
||||
describe('content slots', () => {
|
||||
it('shows active section content', () => {
|
||||
render(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
@@ -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(
|
||||
<SectionsAccordion sections={sections}>
|
||||
<SectionsAccordion sections={sections} activeSlug="intro">
|
||||
<div>Intro content</div>
|
||||
<div>Bio content</div>
|
||||
<div>Skills content</div>
|
||||
</SectionsAccordion>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]}
|
||||
</SectionAccordion>
|
||||
|
||||
Reference in New Issue
Block a user