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 (
+
+
+ toggle(item.id)}
+ className={cn(
+ 'hover:bg-muted/60 flex w-full items-center justify-between gap-4 px-5 py-4 text-start transition-colors',
+ 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset',
+ 'disabled:pointer-events-none disabled:opacity-50'
+ )}
+ >
+
+ {item.title}
+
+
+
+
+ {/* grid-rows 0fr→1fr animates to natural height without a max-height clip */}
+
+
+ );
+ })}
+
+ );
+ }
+);
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 */}