From 2e055695236d4abc5c6a7ef7dfc33319f064a385 Mon Sep 17 00:00:00 2001 From: Evelyna Bellamy Date: Sat, 22 Aug 2026 20:34:36 -0700 Subject: [PATCH 1/2] feat(Accordion): expandable panel list Generalizes bluehive-marketing's FAQAccordion into a design-system primitive: single/multiple modes, controlled or uncontrolled open state, separated-card and joined-list variants, per-item disable, and heading-wrapped triggers with aria-expanded/aria-controls. Panels animate to natural height via CSS grid rows instead of a max-height clip. --- .../Accordion/Accordion.stories.tsx | 127 ++++++++++++ src/components/Accordion/Accordion.test.tsx | 98 +++++++++ src/components/Accordion/Accordion.tsx | 186 ++++++++++++++++++ src/components/Accordion/index.ts | 5 + src/index.ts | 1 + tsup.config.ts | 1 + 6 files changed, 418 insertions(+) create mode 100644 src/components/Accordion/Accordion.stories.tsx create mode 100644 src/components/Accordion/Accordion.test.tsx create mode 100644 src/components/Accordion/Accordion.tsx create mode 100644 src/components/Accordion/index.ts diff --git a/src/components/Accordion/Accordion.stories.tsx b/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 00000000..d485af57 --- /dev/null +++ b/src/components/Accordion/Accordion.stories.tsx @@ -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 = { + 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; + +const FAQ_ITEMS: AccordionItem[] = [ + { + id: 'what-is-recordable', + title: 'What makes an injury OSHA recordable?', + content: ( +

+ 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). +

+ ), + }, + { + id: 'when-to-report', + title: 'How quickly must a fatality be reported?', + content: ( +

+ 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). +

+ ), + }, + { + id: 'who-keeps-logs', + title: 'Which employers must keep OSHA 300 logs?', + content: ( +

+ 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. +

+ ), + }, + { + id: 'disabled-example', + title: 'Coming soon: state-plan differences', + content:

Placeholder.

, + 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) => , + args: { items: FAQ_ITEMS.slice(0, 3), type: 'single' }, +}; + +function ControlledExample(args: React.ComponentProps) { + const [openIds, setOpenIds] = useState([]); + return ( +
+ +
+        open: {JSON.stringify(openIds)}
+      
+
+ ); +} diff --git a/src/components/Accordion/Accordion.test.tsx b/src/components/Accordion/Accordion.test.tsx new file mode 100644 index 00000000..62626d27 --- /dev/null +++ b/src/components/Accordion/Accordion.test.tsx @@ -0,0 +1,98 @@ +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(); + 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(); + 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( + + ); + 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( + + ); + 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(); + 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( + + ); + 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(); + expect(screen.getByRole('button', { name: 'Question C' })).toBeDisabled(); + }); +}); diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 00000000..0e577c51 --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -0,0 +1,186 @@ +'use client'; + +import * as React from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../utils/cn'; + +// ============================================================================= +// Variants +// ============================================================================= + +const accordionVariants = cva('', { + variants: { + variant: { + // Each item is its own card with spacing between + separated: 'flex flex-col gap-3', + // One bordered list with dividers + joined: 'border-border divide-border divide-y rounded-lg border', + }, + }, + defaultVariants: { + variant: 'separated', + }, +}); + +const itemVariants = cva('overflow-hidden', { + variants: { + variant: { + separated: 'border-border bg-card rounded-lg border', + joined: 'bg-card first:rounded-t-lg last:rounded-b-lg', + }, + }, + defaultVariants: { + variant: 'separated', + }, +}); + +// ============================================================================= +// Types +// ============================================================================= + +export interface AccordionItem { + /** Stable id used for open state and aria wiring. */ + id: string; + /** Header label (e.g. an FAQ question). */ + title: React.ReactNode; + /** Panel content (e.g. the answer). */ + content: React.ReactNode; + disabled?: boolean; +} + +export interface AccordionProps + extends + Omit, 'onChange'>, + VariantProps { + items: AccordionItem[]; + /** `single` keeps at most one panel open; `multiple` allows any number. */ + type?: 'single' | 'multiple'; + /** Ids open initially (uncontrolled). */ + defaultOpenIds?: string[]; + /** Controlled open ids. Changes reported via `onOpenChange`. */ + openIds?: string[]; + onOpenChange?: (openIds: string[]) => void; + /** In `single` mode, allow closing the open panel (default true). */ + collapsible?: boolean; + /** Heading level wrapping each trigger for document outline (default h3). */ + headingLevel?: 'h2' | 'h3' | 'h4'; +} + +// ============================================================================= +// Accordion +// ============================================================================= + +/** + * A vertically stacked set of expandable panels — FAQ lists, settings + * groups, progressive disclosure. Panels animate open to their natural + * height (CSS grid rows, no max-height clipping), and headers are real + * buttons inside headings with full `aria-expanded`/`aria-controls` wiring. + * + * @example + * ```tsx + *

}, + * { id: 'billing', title: 'When am I billed?', content:

}, + * ]} + * /> + * ``` + */ +export const Accordion = React.forwardRef( + function Accordion( + { + items, + type = 'single', + defaultOpenIds, + openIds: controlledOpen, + onOpenChange, + collapsible = true, + headingLevel: Heading = 'h3', + variant, + className, + ...props + }, + ref + ) { + const baseId = React.useId(); + const [internalOpen, setInternalOpen] = React.useState( + () => defaultOpenIds ?? [] + ); + const open = controlledOpen ?? internalOpen; + const openSet = React.useMemo(() => new Set(open), [open]); + + const toggle = (id: string) => { + let next: string[]; + if (openSet.has(id)) { + if (type === 'single' && !collapsible) return; + next = open.filter((o) => o !== id); + } else { + next = type === 'single' ? [id] : [...open, id]; + } + if (controlledOpen === undefined) setInternalOpen(next); + onOpenChange?.(next); + }; + + return ( +
+ {items.map((item) => { + const isOpen = openSet.has(item.id); + const triggerId = `${baseId}-${item.id}-trigger`; + const panelId = `${baseId}-${item.id}-panel`; + return ( +
+ + + + {/* grid-rows 0fr→1fr animates to natural height without a max-height clip */} +
+
+
+ {item.content} +
+
+
+
+ ); + })} +
+ ); + } +); diff --git a/src/components/Accordion/index.ts b/src/components/Accordion/index.ts new file mode 100644 index 00000000..629c0de8 --- /dev/null +++ b/src/components/Accordion/index.ts @@ -0,0 +1,5 @@ +export { + Accordion, + type AccordionProps, + type AccordionItem, +} from './Accordion'; diff --git a/src/index.ts b/src/index.ts index a45ee23f..4f87afd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ // Components export * from './components/AddContactModal'; +export * from './components/Accordion'; export * from './components/AdditionalFields'; export * from './components/Address'; // AG Grid is exported via a separate entry point: @mieweb/ui/ag-grid diff --git a/tsup.config.ts b/tsup.config.ts index d4e1d637..64b5d2e7 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ 'utils/index': 'src/utils/index.ts', 'tailwind-preset': 'src/tailwind-preset.ts', // Individual component entries for tree-shaking + 'components/Accordion/index': 'src/components/Accordion/index.ts', 'components/Alert/index': 'src/components/Alert/index.ts', 'components/AlertDialog/index': 'src/components/AlertDialog/index.ts', 'components/AudioPlayer/index': 'src/components/AudioPlayer/index.ts', From 4859c3b192d8899cb09145993fa2a79b8d61227b Mon Sep 17 00:00:00 2001 From: Evelyna Bellamy Date: Sat, 22 Aug 2026 21:26:58 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(Accordion):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20inert=20collapsed=20panels,=20single-mode=20normali?= =?UTF-8?q?zation,=20safelist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapsed panels get aria-hidden + inert (CollapsiblePill/ServiceAccordion pattern) so visually-clipped content is unreachable by AT and Tab - type=single normalizes open state to at most one id even when defaultOpenIds/openIds hand it several - grid-rows-[0fr]/[1fr] + transition-[grid-template-rows] added to both Tailwind 3 safelist twins --- src/components/Accordion/Accordion.test.tsx | 32 +++++++++++++++++++++ src/components/Accordion/Accordion.tsx | 13 +++++++-- src/tailwind-preset.cjs | 4 +++ src/tailwind-preset.ts | 4 +++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/components/Accordion/Accordion.test.tsx b/src/components/Accordion/Accordion.test.tsx index 62626d27..6e889206 100644 --- a/src/components/Accordion/Accordion.test.tsx +++ b/src/components/Accordion/Accordion.test.tsx @@ -95,4 +95,36 @@ describe('Accordion', () => { renderWithTheme(); expect(screen.getByRole('button', { name: 'Question C' })).toBeDisabled(); }); + + it('hides collapsed panels from AT and keyboard via aria-hidden + inert', () => { + renderWithTheme(); + 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( + + ); + expect(screen.getByRole('button', { name: 'Question A' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(screen.getByRole('button', { name: 'Question B' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + }); }); diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx index 0e577c51..67dfd7d0 100644 --- a/src/components/Accordion/Accordion.tsx +++ b/src/components/Accordion/Accordion.tsx @@ -108,7 +108,13 @@ export const Accordion = React.forwardRef( const [internalOpen, setInternalOpen] = React.useState( () => defaultOpenIds ?? [] ); - const open = controlledOpen ?? internalOpen; + const rawOpen = controlledOpen ?? internalOpen; + // Single mode keeps at most one panel open, even if defaultOpenIds or a + // controlled openIds array hands us several. + const open = React.useMemo( + () => (type === 'single' ? rawOpen.slice(0, 1) : rawOpen), + [type, rawOpen] + ); const openSet = React.useMemo(() => new Set(open), [open]); const toggle = (id: string) => { @@ -161,11 +167,14 @@ export const Accordion = React.forwardRef( /> - {/* grid-rows 0fr→1fr animates to natural height without a max-height clip */} + {/* grid-rows 0fr→1fr animates to natural height without a max-height clip; + collapsed panels are hidden from AT and unfocusable via aria-hidden + inert */}