From faf6c574d118ea7cda2f45dd4008640ddad72482 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 19 Apr 2026 08:27:08 +0300 Subject: [PATCH] feat: add SectionAccordion component to shared/ui --- src/shared/ui/SectionAccordion/index.ts | 1 + .../ui/SectionAccordion.test.tsx | 57 ++++++++++++++++ .../SectionAccordion/ui/SectionAccordion.tsx | 65 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/shared/ui/SectionAccordion/index.ts create mode 100644 src/shared/ui/SectionAccordion/ui/SectionAccordion.test.tsx create mode 100644 src/shared/ui/SectionAccordion/ui/SectionAccordion.tsx diff --git a/src/shared/ui/SectionAccordion/index.ts b/src/shared/ui/SectionAccordion/index.ts new file mode 100644 index 0000000..5af53e2 --- /dev/null +++ b/src/shared/ui/SectionAccordion/index.ts @@ -0,0 +1 @@ +export { SectionAccordion } from './ui/SectionAccordion' diff --git a/src/shared/ui/SectionAccordion/ui/SectionAccordion.test.tsx b/src/shared/ui/SectionAccordion/ui/SectionAccordion.test.tsx new file mode 100644 index 0000000..e68d60b --- /dev/null +++ b/src/shared/ui/SectionAccordion/ui/SectionAccordion.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SectionAccordion } from './SectionAccordion' + +const defaultProps = { + number: '01', + title: 'About', + id: 'about', + isActive: false, + onClick: vi.fn(), + children:

Content here

, +} + +describe('SectionAccordion', () => { + describe('collapsed state (isActive=false)', () => { + it('renders a section element with the given id', () => { + const { container } = render() + expect(container.querySelector('section#about')).toBeInTheDocument() + }) + it('renders a button with number and title', () => { + render() + expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument() + }) + 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() + }) + }) + + describe('active state (isActive=true)', () => { + const activeProps = { ...defaultProps, isActive: true } + + it('renders an h1 with number and title', () => { + 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', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + it('content wrapper has animate-fadeIn class', () => { + const { container } = render() + expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument() + }) + }) +}) diff --git a/src/shared/ui/SectionAccordion/ui/SectionAccordion.tsx b/src/shared/ui/SectionAccordion/ui/SectionAccordion.tsx new file mode 100644 index 0000000..88fc0de --- /dev/null +++ b/src/shared/ui/SectionAccordion/ui/SectionAccordion.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react' + +interface SectionAccordionProps { + /** + * Display number prefix (e.g. "01") + */ + number: string + /** + * Section title + */ + title: string + /** + * HTML id for anchor navigation + */ + id: string + /** + * Whether this section is expanded + */ + isActive: boolean + /** + * Called when the collapsed header is clicked + */ + onClick: () => void + /** + * Section content, shown when active + */ + children: ReactNode +} + +/** + * Accordion-style section that collapses to a heading button when inactive. + */ +export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) { + return ( +
+ {isActive ? ( +
+
+

+ {number}. {title} +

+
+
+ {children} +
+
+ ) : ( + + )} +
+ ) +}