feat: add SectionAccordion component to shared/ui

This commit is contained in:
Ilia Mashkov
2026-04-19 08:27:08 +03:00
parent d17104b294
commit faf6c574d1
3 changed files with 123 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { SectionAccordion } from './ui/SectionAccordion'
@@ -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: <p>Content here</p>,
}
describe('SectionAccordion', () => {
describe('collapsed state (isActive=false)', () => {
it('renders a section element with the given id', () => {
const { container } = render(<SectionAccordion {...defaultProps} />)
expect(container.querySelector('section#about')).toBeInTheDocument()
})
it('renders a button with number and title', () => {
render(<SectionAccordion {...defaultProps} />)
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument()
})
it('does not render children', () => {
render(<SectionAccordion {...defaultProps} />)
expect(screen.queryByText('Content here')).not.toBeInTheDocument()
})
it('calls onClick when button is clicked', async () => {
const onClick = vi.fn()
render(<SectionAccordion {...defaultProps} onClick={onClick} />)
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(<SectionAccordion {...activeProps} />)
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument()
})
it('renders children', () => {
render(<SectionAccordion {...activeProps} />)
expect(screen.getByText('Content here')).toBeInTheDocument()
})
it('does not render a button', () => {
render(<SectionAccordion {...activeProps} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('content wrapper has animate-fadeIn class', () => {
const { container } = render(<SectionAccordion {...activeProps} />)
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument()
})
})
})
@@ -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 (
<section id={id} className="scroll-mt-8">
{isActive ? (
<div className="mb-12">
<div className="mb-16">
<h1
className="font-heading font-black text-5xl leading-[1.2] mb-0"
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
>
{number}. {title}
</h1>
</div>
<div className="animate-fadeIn">
{children}
</div>
</div>
) : (
<button
onClick={onClick}
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
>
<h2
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
>
{number}. {title}
</h2>
</button>
)}
</section>
)
}