diff --git a/src/components/index.ts b/src/components/index.ts index bc5bbf7d..e9b77980 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ * Import from '@/components' rather than '@/app/components' for shared pieces. */ +export * from './ui/Accordion'; export { Button, buttonVariants } from './ui/Button'; export type { ButtonProps } from './ui/Button'; export { ButtonGroup } from './ui/ButtonGroup'; diff --git a/src/components/ui/Accordion.tsx b/src/components/ui/Accordion.tsx new file mode 100644 index 00000000..2453a1cf --- /dev/null +++ b/src/components/ui/Accordion.tsx @@ -0,0 +1,374 @@ +'use client'; + +import React, { + createContext, + useCallback, + useContext, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AccordionVariant = 'default' | 'bordered' | 'ghost'; +export type AccordionSize = 'sm' | 'md' | 'lg'; + +/** Shape stored in the registry for each AccordionItem. */ +interface AccordionItemEntry { + id: string; + /** Programmatically open or close this item. */ + setOpen: (open: boolean) => void; +} + +/** Value exposed by AccordionContext to every descendant. */ +interface AccordionContextValue { + /** Currently open item ids. */ + openIds: Set; + /** Whether only one item may be open at a time. */ + exclusive: boolean; + /** Variant forwarded to items. */ + variant: AccordionVariant; + /** Size forwarded to items. */ + size: AccordionSize; + /** Called by AccordionItem on mount to register itself. */ + register: (entry: AccordionItemEntry) => void; + /** Called by AccordionItem on unmount to unregister itself. */ + unregister: (id: string) => void; + /** Toggle the open state of an item. */ + toggle: (id: string) => void; +} + +const AccordionContext = createContext(undefined); + +function useAccordionContext(): AccordionContextValue { + const ctx = useContext(AccordionContext); + if (!ctx) { + throw new Error('Accordion sub-components must be used inside .'); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// AccordionItem context (so Trigger and Content can find their shared id) +// --------------------------------------------------------------------------- + +interface AccordionItemContextValue { + itemId: string; + triggerId: string; + contentId: string; + isOpen: boolean; + toggle: () => void; +} + +const AccordionItemContext = createContext(undefined); + +function useAccordionItemContext(): AccordionItemContextValue { + const ctx = useContext(AccordionItemContext); + if (!ctx) { + throw new Error( + 'AccordionTrigger and AccordionContent must be used inside .', + ); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// Accordion (root) +// --------------------------------------------------------------------------- + +export interface AccordionProps { + children: React.ReactNode; + /** + * When `true` only one item can be open at a time. + * @default false + */ + exclusive?: boolean; + /** + * Ids of items that should be open on first render (uncontrolled). + * Pass an empty array to start fully collapsed. + */ + defaultOpenIds?: string[]; + /** + * Controlled open ids. When provided the component becomes fully controlled + * and `onOpenChange` must be used to update the value. + */ + openIds?: string[]; + /** Called whenever the set of open ids changes (controlled mode). */ + onOpenChange?: (ids: string[]) => void; + variant?: AccordionVariant; + size?: AccordionSize; + className?: string; +} + +/** + * Accordion root component. + * + * Maintains a **registration system** — each `AccordionItem` registers itself + * on mount and unregisters on unmount, giving the root full awareness of all + * items and enabling features like exclusive (single-open) mode and + * programmatic control via `openIds` / `onOpenChange`. + */ +export function Accordion({ + children, + exclusive = false, + defaultOpenIds = [], + openIds: controlledOpenIds, + onOpenChange, + variant = 'default', + size = 'md', + className, +}: AccordionProps) { + const isControlled = controlledOpenIds !== undefined; + + // Registry: id → entry (kept in a ref so mutations don't trigger re-renders) + const registryRef = useRef>(new Map()); + + // Uncontrolled open state + const [uncontrolledOpenIds, setUncontrolledOpenIds] = useState>( + () => new Set(defaultOpenIds), + ); + + const openIds: Set = isControlled + ? new Set(controlledOpenIds) + : uncontrolledOpenIds; + + const setOpenIds = useCallback( + (updater: (prev: Set) => Set) => { + if (isControlled) { + // Derive next value and call the consumer's handler + const next = updater(new Set(controlledOpenIds)); + onOpenChange?.(Array.from(next)); + } else { + setUncontrolledOpenIds((prev) => { + const next = updater(prev); + onOpenChange?.(Array.from(next)); + return next; + }); + } + }, + [isControlled, controlledOpenIds, onOpenChange], + ); + + const toggle = useCallback( + (id: string) => { + setOpenIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + if (exclusive) next.clear(); + next.add(id); + } + return next; + }); + }, + [exclusive, setOpenIds], + ); + + const register = useCallback((entry: AccordionItemEntry) => { + registryRef.current.set(entry.id, entry); + }, []); + + const unregister = useCallback((id: string) => { + registryRef.current.delete(id); + }, []); + + const contextValue = useMemo( + () => ({ openIds, exclusive, variant, size, register, unregister, toggle }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [openIds, exclusive, variant, size, register, unregister, toggle], + ); + + return ( + +
+ {children} +
+
+ ); +} +Accordion.displayName = 'Accordion'; + +// --------------------------------------------------------------------------- +// AccordionItem +// --------------------------------------------------------------------------- + +export interface AccordionItemProps { + children: React.ReactNode; + /** + * Stable identifier for this item. + * Auto-generated via `useId` when omitted. + */ + id?: string; + /** Disable interaction for this item. */ + disabled?: boolean; + className?: string; +} + +/** + * Registers itself with the parent `Accordion` on mount and unregisters on + * unmount. Provides its own context so `AccordionTrigger` and + * `AccordionContent` can share the item id without prop-drilling. + */ +export function AccordionItem({ + children, + id: externalId, + disabled = false, + className, +}: AccordionItemProps) { + const { openIds, variant, register, unregister, toggle } = useAccordionContext(); + const autoId = useId(); + const itemId = externalId ?? autoId; + + const triggerId = `${itemId}-trigger`; + const contentId = `${itemId}-content`; + const isOpen = openIds.has(itemId); + + // Register / unregister with the root + const handleToggle = useCallback(() => { + if (!disabled) toggle(itemId); + }, [disabled, itemId, toggle]); + + React.useEffect(() => { + register({ id: itemId, setOpen: (open) => (open ? toggle(itemId) : toggle(itemId)) }); + return () => unregister(itemId); + // register/unregister are stable refs — intentionally omit toggle to avoid + // re-registering on every toggle call. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [itemId, register, unregister]); + + const itemContextValue = useMemo( + () => ({ itemId, triggerId, contentId, isOpen, toggle: handleToggle }), + [itemId, triggerId, contentId, isOpen, handleToggle], + ); + + const variantClasses: Record = { + default: 'border-b border-gray-200 dark:border-gray-700', + bordered: + 'border border-gray-200 dark:border-gray-700 rounded-lg mb-2 overflow-hidden', + ghost: '', + }; + + return ( + +
+ {children} +
+
+ ); +} +AccordionItem.displayName = 'AccordionItem'; + +// --------------------------------------------------------------------------- +// AccordionTrigger +// --------------------------------------------------------------------------- + +export interface AccordionTriggerProps { + children: React.ReactNode; + /** Hide the default chevron icon. */ + hideIcon?: boolean; + /** Replace the default chevron with a custom icon. */ + icon?: React.ReactNode; + className?: string; +} + +const sizeClasses: Record = { + sm: 'py-2 text-sm', + md: 'py-3 text-base', + lg: 'py-4 text-lg', +}; + +export function AccordionTrigger({ + children, + hideIcon = false, + icon, + className, +}: AccordionTriggerProps) { + const { triggerId, contentId, isOpen, toggle } = useAccordionItemContext(); + const { size } = useAccordionContext(); + + return ( + + ); +} +AccordionTrigger.displayName = 'AccordionTrigger'; + +// --------------------------------------------------------------------------- +// AccordionContent +// --------------------------------------------------------------------------- + +export interface AccordionContentProps { + children: React.ReactNode; + className?: string; +} + +export function AccordionContent({ children, className }: AccordionContentProps) { + const { triggerId, contentId, isOpen } = useAccordionItemContext(); + const { size } = useAccordionContext(); + + const paddingClasses: Record = { + sm: 'px-4 pb-2 text-sm', + md: 'px-4 pb-4 text-sm', + lg: 'px-4 pb-5 text-base', + }; + + return ( + + ); +} +AccordionContent.displayName = 'AccordionContent'; diff --git a/src/components/ui/__tests__/Accordion.test.tsx b/src/components/ui/__tests__/Accordion.test.tsx new file mode 100644 index 00000000..77aec19a --- /dev/null +++ b/src/components/ui/__tests__/Accordion.test.tsx @@ -0,0 +1,461 @@ +import React, { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from '../Accordion'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Renders a standard two-item accordion for reuse across tests. */ +function renderBasicAccordion(props: Partial> = {}) { + return render( + + + Section 1 + Content 1 + + + Section 2 + Content 2 + + , + ); +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('Accordion – rendering', () => { + it('renders all triggers', () => { + renderBasicAccordion(); + expect(screen.getByText('Section 1')).toBeInTheDocument(); + expect(screen.getByText('Section 2')).toBeInTheDocument(); + }); + + it('hides content panels by default', () => { + renderBasicAccordion(); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + expect(screen.getByText('Content 2').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + }); + + it('opens items listed in defaultOpenIds', () => { + renderBasicAccordion({ defaultOpenIds: ['item-1'] }); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + expect(screen.getByText('Content 2').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + }); + + it('applies custom className to the root wrapper', () => { + const { container } = renderBasicAccordion({ className: 'my-custom-class' }); + expect(container.firstChild).toHaveClass('my-custom-class'); + }); + + it('applies data-state="open" to an open AccordionItem', () => { + renderBasicAccordion({ defaultOpenIds: ['item-2'] }); + const item = screen.getByText('Content 2').closest('[data-accordion-item]'); + expect(item).toHaveAttribute('data-state', 'open'); + }); +}); + +// --------------------------------------------------------------------------- +// Toggle behaviour +// --------------------------------------------------------------------------- + +describe('Accordion – toggle behaviour', () => { + it('opens a closed item when its trigger is clicked', async () => { + renderBasicAccordion(); + await userEvent.click(screen.getByText('Section 1')); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + }); + + it('closes an open item when its trigger is clicked again', async () => { + renderBasicAccordion({ defaultOpenIds: ['item-1'] }); + await userEvent.click(screen.getByText('Section 1')); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + }); + + it('allows multiple items open simultaneously by default', async () => { + renderBasicAccordion(); + await userEvent.click(screen.getByText('Section 1')); + await userEvent.click(screen.getByText('Section 2')); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + expect(screen.getByText('Content 2').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Exclusive mode +// --------------------------------------------------------------------------- + +describe('Accordion – exclusive mode', () => { + it('closes the previously open item when a new one is opened', async () => { + renderBasicAccordion({ exclusive: true, defaultOpenIds: ['item-1'] }); + await userEvent.click(screen.getByText('Section 2')); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + expect(screen.getByText('Content 2').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + }); + + it('allows closing the only open item in exclusive mode', async () => { + renderBasicAccordion({ exclusive: true, defaultOpenIds: ['item-1'] }); + await userEvent.click(screen.getByText('Section 1')); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Controlled mode +// --------------------------------------------------------------------------- + +describe('Accordion – controlled mode', () => { + it('reflects the controlled openIds prop', () => { + render( + {}}> + + Section 1 + Content 1 + + + Section 2 + Content 2 + + , + ); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + expect(screen.getByText('Content 2').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + }); + + it('calls onOpenChange with the updated ids when a trigger is clicked', async () => { + const onOpenChange = vi.fn(); + render( + + + Section 1 + Content 1 + + , + ); + await userEvent.click(screen.getByText('Section 1')); + expect(onOpenChange).toHaveBeenCalledWith(['item-1']); + }); + + it('works as a fully controlled component with state', async () => { + function ControlledAccordion() { + const [openIds, setOpenIds] = useState([]); + return ( + + + Section 1 + Content 1 + + + ); + } + render(); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + await userEvent.click(screen.getByText('Section 1')); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Registration system +// --------------------------------------------------------------------------- + +describe('Accordion – registration system', () => { + it('registers items on mount and unregisters on unmount', () => { + function DynamicAccordion() { + const [showSecond, setShowSecond] = useState(true); + return ( + <> + + + + Section 1 + Content 1 + + {showSecond && ( + + Section 2 + Content 2 + + )} + + + ); + } + + render(); + // Both items present initially + expect(screen.getByText('Section 1')).toBeInTheDocument(); + expect(screen.getByText('Section 2')).toBeInTheDocument(); + + // Remove item 2 + fireEvent.click(screen.getByText('Remove item 2')); + expect(screen.queryByText('Section 2')).not.toBeInTheDocument(); + // Item 1 still works + expect(screen.getByText('Section 1')).toBeInTheDocument(); + }); + + it('does not affect other items when one is removed while open', async () => { + function DynamicAccordion() { + const [showSecond, setShowSecond] = useState(true); + return ( + <> + + + + Section 1 + Content 1 + + {showSecond && ( + + Section 2 + Content 2 + + )} + + + ); + } + + render(); + fireEvent.click(screen.getByText('Remove item 2')); + // Item 1 should still be open + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Disabled items +// --------------------------------------------------------------------------- + +describe('Accordion – disabled items', () => { + it('does not toggle a disabled item when clicked', async () => { + render( + + + Section 1 + Content 1 + + , + ); + await userEvent.click(screen.getByText('Section 1')); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'closed', + ); + }); + + it('marks a disabled item with data-disabled attribute', () => { + render( + + + Section 1 + Content 1 + + , + ); + const item = screen.getByText('Content 1').closest('[data-accordion-item]'); + expect(item).toHaveAttribute('data-disabled'); + }); +}); + +// --------------------------------------------------------------------------- +// Accessibility +// --------------------------------------------------------------------------- + +describe('Accordion – accessibility', () => { + it('trigger has aria-expanded="false" when closed', () => { + renderBasicAccordion(); + const trigger = screen.getByRole('button', { name: 'Section 1' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('trigger has aria-expanded="true" when open', async () => { + renderBasicAccordion(); + await userEvent.click(screen.getByRole('button', { name: 'Section 1' })); + expect(screen.getByRole('button', { name: 'Section 1' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('trigger aria-controls points to the content region id', () => { + renderBasicAccordion({ defaultOpenIds: ['item-1'] }); + const trigger = screen.getByRole('button', { name: 'Section 1' }); + const contentId = trigger.getAttribute('aria-controls'); + expect(contentId).toBeTruthy(); + expect(document.getElementById(contentId!)).toBeInTheDocument(); + }); + + it('content region has role="region" and aria-labelledby pointing to trigger', () => { + renderBasicAccordion({ defaultOpenIds: ['item-1'] }); + const region = screen.getByRole('region', { name: 'Section 1' }); + expect(region).toBeInTheDocument(); + }); + + it('trigger can be activated with the keyboard (Enter key)', async () => { + renderBasicAccordion(); + const trigger = screen.getByRole('button', { name: 'Section 1' }); + trigger.focus(); + await userEvent.keyboard('{Enter}'); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + }); + + it('trigger can be activated with the keyboard (Space key)', async () => { + renderBasicAccordion(); + const trigger = screen.getByRole('button', { name: 'Section 1' }); + trigger.focus(); + await userEvent.keyboard(' '); + expect(screen.getByText('Content 1').closest('[data-state]')).toHaveAttribute( + 'data-state', + 'open', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Variants & sizes +// --------------------------------------------------------------------------- + +describe('Accordion – variants and sizes', () => { + it('renders with bordered variant', () => { + render( + + + Section 1 + Content 1 + + , + ); + const item = screen.getByText('Content 1').closest('[data-accordion-item]'); + expect(item).toHaveClass('rounded-lg'); + }); + + it('renders with ghost variant without border classes', () => { + render( + + + Section 1 + Content 1 + + , + ); + const item = screen.getByText('Content 1').closest('[data-accordion-item]'); + expect(item).not.toHaveClass('border-b'); + }); + + it('applies sm size class to trigger', () => { + render( + + + Section 1 + Content 1 + + , + ); + expect(screen.getByRole('button', { name: 'Section 1' })).toHaveClass('py-2'); + }); + + it('applies lg size class to trigger', () => { + render( + + + Section 1 + Content 1 + + , + ); + expect(screen.getByRole('button', { name: 'Section 1' })).toHaveClass('py-4'); + }); +}); + +// --------------------------------------------------------------------------- +// Error boundaries +// --------------------------------------------------------------------------- + +describe('Accordion – context errors', () => { + it('throws when AccordionItem is used outside Accordion', () => { + // Suppress the expected console.error from React + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => + render( + + Orphan + Orphan content + , + ), + ).toThrow('Accordion sub-components must be used inside .'); + spy.mockRestore(); + }); + + it('throws when AccordionTrigger is used outside AccordionItem', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => + render( + + Orphan trigger + , + ), + ).toThrow('AccordionTrigger and AccordionContent must be used inside .'); + spy.mockRestore(); + }); +});