Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/components/Accordion/Accordion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Accordion, type AccordionItem } from './Accordion';

const meta: Meta<typeof Accordion> = {
title: 'Components/Layout & Structure/Accordion',
component: Accordion,
parameters: {
layout: 'padded',
docs: {
description: {
component:
'A vertically stacked set of expandable panels for FAQ lists, settings groups, and ' +
'progressive disclosure. Panels animate to their natural height (CSS grid rows — no ' +
'max-height clipping), headers are real buttons inside headings with full ' +
'`aria-expanded`/`aria-controls` wiring, and open state can be single or multiple, ' +
'uncontrolled or controlled.',
},
},
},
tags: ['autodocs'],
argTypes: {
items: { description: 'Panels to render.', control: false },
type: {
description:
'single keeps at most one panel open; multiple allows any number.',
control: 'select',
options: ['single', 'multiple'],
},
variant: {
description: 'separated cards or one joined bordered list.',
control: 'select',
options: ['separated', 'joined'],
},
collapsible: {
description: 'In single mode, allow closing the open panel.',
control: 'boolean',
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

const FAQ_ITEMS: AccordionItem[] = [
{
id: 'what-is-recordable',
title: 'What makes an injury OSHA recordable?',
content: (
<p>
A work-related injury or illness is recordable when it results in death,
days away from work, restricted work or job transfer, medical treatment
beyond first aid, or loss of consciousness (29 CFR 1904.7).
</p>
),
},
{
id: 'when-to-report',
title: 'How quickly must a fatality be reported?',
content: (
<p>
Employers must report a work-related fatality to OSHA within 8 hours,
and any in-patient hospitalization, amputation, or loss of an eye within
24 hours (29 CFR 1904.39).
</p>
),
},
{
id: 'who-keeps-logs',
title: 'Which employers must keep OSHA 300 logs?',
content: (
<p>
Employers with more than 10 employees keep injury and illness records
unless their industry is classified as low-hazard and specifically
exempted from routine recordkeeping.
</p>
),
},
{
id: 'disabled-example',
title: 'Coming soon: state-plan differences',
content: <p>Placeholder.</p>,
disabled: true,
},
];

export const Default: Story = {
args: {
items: FAQ_ITEMS,
type: 'single',
defaultOpenIds: ['what-is-recordable'],
},
};

export const Joined: Story = {
args: {
items: FAQ_ITEMS,
variant: 'joined',
type: 'single',
defaultOpenIds: ['when-to-report'],
},
};

export const Multiple: Story = {
args: {
items: FAQ_ITEMS.slice(0, 3),
type: 'multiple',
defaultOpenIds: ['what-is-recordable', 'who-keeps-logs'],
},
};

export const Controlled: Story = {
render: (args) => <ControlledExample {...args} />,
args: { items: FAQ_ITEMS.slice(0, 3), type: 'single' },
};

function ControlledExample(args: React.ComponentProps<typeof Accordion>) {
const [openIds, setOpenIds] = useState<string[]>([]);
return (
<div className="flex flex-col gap-3">
<Accordion {...args} openIds={openIds} onOpenChange={setOpenIds} />
<pre className="bg-muted text-muted-foreground rounded-md p-2 text-xs">
open: {JSON.stringify(openIds)}
</pre>
</div>
);
}
130 changes: 130 additions & 0 deletions src/components/Accordion/Accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithTheme } from '../../test/test-utils';
import { Accordion, type AccordionItem } from './Accordion';

const ITEMS: AccordionItem[] = [
{ id: 'a', title: 'Question A', content: 'Answer A' },
{ id: 'b', title: 'Question B', content: 'Answer B' },
{ id: 'c', title: 'Question C', content: 'Answer C', disabled: true },
];

describe('Accordion', () => {
it('renders all triggers collapsed by default', () => {
renderWithTheme(<Accordion items={ITEMS} />);
for (const name of ['Question A', 'Question B']) {
expect(screen.getByRole('button', { name })).toHaveAttribute(
'aria-expanded',
'false'
);
}
});

it('opens defaultOpenIds and wires aria-controls to the panel', () => {
renderWithTheme(<Accordion items={ITEMS} defaultOpenIds={['a']} />);
const trigger = screen.getByRole('button', { name: 'Question A' });
expect(trigger).toHaveAttribute('aria-expanded', 'true');
const panel = screen.getByRole('region', { name: 'Question A' });
expect(panel.id).toBe(trigger.getAttribute('aria-controls'));
});

it('single mode closes the previous panel', () => {
renderWithTheme(
<Accordion items={ITEMS} type="single" defaultOpenIds={['a']} />
);
fireEvent.click(screen.getByRole('button', { name: 'Question B' }));
expect(screen.getByRole('button', { name: 'Question A' })).toHaveAttribute(
'aria-expanded',
'false'
);
expect(screen.getByRole('button', { name: 'Question B' })).toHaveAttribute(
'aria-expanded',
'true'
);
});

it('single non-collapsible keeps one panel open', () => {
renderWithTheme(
<Accordion
items={ITEMS}
type="single"
collapsible={false}
defaultOpenIds={['a']}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Question A' }));
expect(screen.getByRole('button', { name: 'Question A' })).toHaveAttribute(
'aria-expanded',
'true'
);
});

it('multiple mode opens panels independently', () => {
renderWithTheme(<Accordion items={ITEMS} type="multiple" />);
fireEvent.click(screen.getByRole('button', { name: 'Question A' }));
fireEvent.click(screen.getByRole('button', { name: 'Question B' }));
expect(screen.getByRole('button', { name: 'Question A' })).toHaveAttribute(
'aria-expanded',
'true'
);
expect(screen.getByRole('button', { name: 'Question B' })).toHaveAttribute(
'aria-expanded',
'true'
);
});

it('supports controlled open state', () => {
const onOpenChange = vi.fn();
renderWithTheme(
<Accordion items={ITEMS} openIds={['b']} onOpenChange={onOpenChange} />
);
expect(screen.getByRole('button', { name: 'Question B' })).toHaveAttribute(
'aria-expanded',
'true'
);
fireEvent.click(screen.getByRole('button', { name: 'Question A' }));
expect(onOpenChange).toHaveBeenCalledWith(['a']);
// Controlled: state does not change without the parent updating props
expect(screen.getByRole('button', { name: 'Question A' })).toHaveAttribute(
'aria-expanded',
'false'
);
});

it('disables items', () => {
renderWithTheme(<Accordion items={ITEMS} />);
expect(screen.getByRole('button', { name: 'Question C' })).toBeDisabled();
});

it('hides collapsed panels from AT and keyboard via aria-hidden + inert', () => {
renderWithTheme(<Accordion items={ITEMS} defaultOpenIds={['a']} />);
const openPanel = document.getElementById(
screen
.getByRole('button', { name: 'Question A' })
.getAttribute('aria-controls')!
)!;
const closedPanel = document.getElementById(
screen
.getByRole('button', { name: 'Question B' })
.getAttribute('aria-controls')!
)!;
expect(openPanel).toHaveAttribute('aria-hidden', 'false');
expect(openPanel).not.toHaveAttribute('inert');
expect(closedPanel).toHaveAttribute('aria-hidden', 'true');
expect(closedPanel).toHaveAttribute('inert');
});

it('normalizes single mode to at most one open panel', () => {
renderWithTheme(
<Accordion type="single" items={ITEMS} defaultOpenIds={['a', 'b']} />
);
expect(screen.getByRole('button', { name: 'Question A' })).toHaveAttribute(
'aria-expanded',
'true'
);
expect(screen.getByRole('button', { name: 'Question B' })).toHaveAttribute(
'aria-expanded',
'false'
);
});
});
Loading
Loading