diff --git a/packages/headless/package.json b/packages/headless/package.json index a116c370d8a..898606b24d0 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -33,6 +33,10 @@ "import": "./dist/primitives/autocomplete/index.js", "types": "./dist/primitives/autocomplete/index.d.ts" }, + "./collapsible": { + "import": "./dist/primitives/collapsible/index.js", + "types": "./dist/primitives/collapsible/index.d.ts" + }, "./dialog": { "import": "./dist/primitives/dialog/index.js", "types": "./dist/primitives/dialog/index.d.ts" diff --git a/packages/headless/src/primitives/collapsible/collapsible-context.ts b/packages/headless/src/primitives/collapsible/collapsible-context.ts new file mode 100644 index 00000000000..bab45ad7cfa --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-context.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +export interface CollapsibleContextValue { + open: boolean; + setOpen: (v: boolean) => void; + disabled: boolean; + triggerId: string; + panelId: string; +} + +export const CollapsibleContext = createContext(null); + +export function useCollapsibleContext() { + const ctx = useContext(CollapsibleContext); + if (!ctx) { + throw new Error('Collapsible compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/collapsible/collapsible-panel.tsx b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx new file mode 100644 index 00000000000..727017bcb43 --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { type RefObject, useLayoutEffect, useRef, useState } from 'react'; +import { useTransition } from '../../hooks/use-transition'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useCollapsibleContext } from './collapsible-context'; + +export interface CollapsiblePanelProps extends ComponentProps<'div'> {} + +export function CollapsiblePanel(props: CollapsiblePanelProps) { + const { render, ...otherProps } = props; + const { open, triggerId, panelId } = useCollapsibleContext(); + + const panelRef = useRef(null); + const [height, setHeight] = useState(undefined); + const [width, setWidth] = useState(undefined); + + const hasBeenClosed = useRef(false); + if (!open) hasBeenClosed.current = true; + + const { mounted, transitionProps } = useTransition({ + open, + ref: panelRef as RefObject, + }); + + useLayoutEffect(() => { + if (!mounted) return; + + const panel = panelRef.current; + if (!panel) return; + + const measure = () => { + setHeight(panel.scrollHeight); + setWidth(panel.scrollWidth); + }; + + measure(); + + const ro = new ResizeObserver(measure); + ro.observe(panel, { box: 'border-box' }); + + return () => ro.disconnect(); + }, [mounted]); + + const state = { open }; + + const effectiveTransitionProps = !hasBeenClosed.current + ? { + ...transitionProps, + 'data-cl-starting-style': undefined, + style: undefined, + } + : transitionProps; + + const defaultProps: Record = { + 'data-cl-slot': 'collapsible-panel', + id: panelId, + role: 'region' as const, + 'aria-labelledby': triggerId, + ref: panelRef, + ...effectiveTransitionProps, + style: { + '--collapsible-panel-height': height != null ? `${height}px` : undefined, + '--collapsible-panel-width': width != null ? `${width}px` : undefined, + ...effectiveTransitionProps.style, + }, + }; + + return renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/collapsible/collapsible-root.tsx b/packages/headless/src/primitives/collapsible/collapsible-root.tsx new file mode 100644 index 00000000000..095665c150c --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-root.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { type ReactNode, useId, useMemo } from 'react'; +import { useControllableState } from '../../hooks/use-controllable-state'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { CollapsibleContext, type CollapsibleContextValue } from './collapsible-context'; + +export interface CollapsibleProps extends ComponentProps<'div'> { + /** Controlled open state. */ + open?: boolean; + /** Initial open state (uncontrolled). */ + defaultOpen?: boolean; + /** Called when open state changes. */ + onOpenChange?: (open: boolean) => void; + /** Disable the collapsible. @default false */ + disabled?: boolean; + children: ReactNode; +} + +export function CollapsibleRoot(props: CollapsibleProps) { + const { render, open: openProp, defaultOpen, onOpenChange, disabled = false, children, ...otherProps } = props; + + const [open, setOpen] = useControllableState(openProp, defaultOpen ?? false, onOpenChange); + + const rootId = useId(); + const triggerId = `${rootId}trigger`; + const panelId = `${rootId}panel`; + + const contextValue = useMemo( + () => ({ open, setOpen, disabled, triggerId, panelId }), + [open, setOpen, disabled, triggerId, panelId], + ); + + const state = { open, disabled }; + + const defaultProps: Record = { + 'data-cl-slot': 'collapsible-root', + children, + }; + + return ( + + {renderElement({ + defaultTagName: 'div', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + })} + + ); +} diff --git a/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx new file mode 100644 index 00000000000..75d53f0231f --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useCollapsibleContext } from './collapsible-context'; + +export interface CollapsibleTriggerProps extends ComponentProps<'button'> {} + +export function CollapsibleTrigger(props: CollapsibleTriggerProps) { + const { render, ...otherProps } = props; + const { open, setOpen, disabled, triggerId, panelId } = useCollapsibleContext(); + + const state = { open, disabled }; + + const defaultProps: Record = { + 'data-cl-slot': 'collapsible-trigger', + id: triggerId, + type: 'button' as const, + 'aria-expanded': open, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + onClick: () => { + if (!disabled) setOpen(!open); + }, + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/collapsible/collapsible.test.tsx b/packages/headless/src/primitives/collapsible/collapsible.test.tsx new file mode 100644 index 00000000000..848ed41cff4 --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible.test.tsx @@ -0,0 +1,190 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { axe } from '../../test-utils/axe'; +import { Collapsible } from './index'; + +afterEach(() => cleanup()); + +function renderCollapsible(props: Partial> = {}) { + return render( + + Toggle + Content + , + ); +} + +describe('Collapsible', () => { + describe('slot attributes', () => { + it('renders root with data-cl-slot', () => { + renderCollapsible(); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toBeInTheDocument(); + }); + + it('renders trigger with data-cl-slot', () => { + renderCollapsible(); + expect(document.querySelector('[data-cl-slot="collapsible-trigger"]')).toBeInTheDocument(); + }); + + it('renders panel with data-cl-slot when open', () => { + renderCollapsible({ defaultOpen: true }); + expect(document.querySelector('[data-cl-slot="collapsible-panel"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens panel on trigger click', async () => { + const user = userEvent.setup(); + renderCollapsible(); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(screen.getByText('Content')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-open', ''); + }); + + it('closes panel on second trigger click', async () => { + const user = userEvent.setup(); + renderCollapsible({ defaultOpen: true }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderCollapsible({ onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('starts closed by default', () => { + renderCollapsible(); + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-closed', ''); + }); + + it('starts open with defaultOpen=true', () => { + renderCollapsible({ defaultOpen: true }); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + }); + + describe('controlled value', () => { + it('respects controlled open prop', () => { + renderCollapsible({ open: true }); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('does not change when controlled', async () => { + const user = userEvent.setup(); + renderCollapsible({ open: false }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('trigger has aria-expanded=false when closed', () => { + renderCollapsible(); + expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-expanded', 'false'); + }); + + it('trigger has aria-expanded=true when open', () => { + renderCollapsible({ defaultOpen: true }); + expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-expanded', 'true'); + }); + + it('trigger has aria-controls linked to panel id', () => { + renderCollapsible({ defaultOpen: true }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(trigger).toHaveAttribute('aria-controls', panel?.getAttribute('id')); + }); + + it('panel has aria-labelledby linked to trigger id', () => { + renderCollapsible({ defaultOpen: true }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(panel).toHaveAttribute('aria-labelledby', trigger.getAttribute('id')); + }); + + it('panel has role=region', () => { + renderCollapsible({ defaultOpen: true }); + expect(screen.getByRole('region')).toBeInTheDocument(); + }); + }); + + describe('animation lifecycle', () => { + it('panel is not in DOM when closed', () => { + renderCollapsible(); + expect(document.querySelector('[data-cl-slot="collapsible-panel"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on panel when open', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(panel).toHaveAttribute('data-cl-open', ''); + }); + + it('does not apply starting-style on initially open panel', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(panel).not.toHaveAttribute('data-cl-starting-style'); + }); + + it('sets --collapsible-panel-height CSS variable on panel', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]') as HTMLElement; + expect(panel.getAttribute('style')).toContain('--collapsible-panel-height'); + }); + + it('sets --collapsible-panel-width CSS variable on panel', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]') as HTMLElement; + expect(panel.getAttribute('style')).toContain('--collapsible-panel-width'); + }); + }); + + describe('disabled', () => { + it('prevents toggle when disabled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderCollapsible({ disabled: true, onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('applies aria-disabled on trigger', () => { + renderCollapsible({ disabled: true }); + expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies data-cl-disabled on root and trigger', () => { + renderCollapsible({ disabled: true }); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-disabled', ''); + expect(document.querySelector('[data-cl-slot="collapsible-trigger"]')).toHaveAttribute('data-cl-disabled', ''); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderCollapsible(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + const { container } = renderCollapsible({ defaultOpen: true }); + expect(await axe(container)).toHaveNoViolations(); + }); + }); +}); diff --git a/packages/headless/src/primitives/collapsible/index.ts b/packages/headless/src/primitives/collapsible/index.ts new file mode 100644 index 00000000000..c1ad32a41fe --- /dev/null +++ b/packages/headless/src/primitives/collapsible/index.ts @@ -0,0 +1,3 @@ +export * as Collapsible from './parts'; + +export type { CollapsibleProps, CollapsibleTriggerProps, CollapsiblePanelProps } from './parts'; diff --git a/packages/headless/src/primitives/collapsible/parts.ts b/packages/headless/src/primitives/collapsible/parts.ts new file mode 100644 index 00000000000..d09f50c23ac --- /dev/null +++ b/packages/headless/src/primitives/collapsible/parts.ts @@ -0,0 +1,3 @@ +export { type CollapsibleProps, CollapsibleRoot as Root } from './collapsible-root'; +export { type CollapsibleTriggerProps, CollapsibleTrigger as Trigger } from './collapsible-trigger'; +export { type CollapsiblePanelProps, CollapsiblePanel as Panel } from './collapsible-panel'; diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 751450974d3..80ba5c893a5 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ 'primitives/select/index': 'src/primitives/select/index.ts', 'primitives/menu/index': 'src/primitives/menu/index.ts', 'primitives/autocomplete/index': 'src/primitives/autocomplete/index.ts', + 'primitives/collapsible/index': 'src/primitives/collapsible/index.ts', 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'utils/index': 'src/utils/index.ts', 'hooks/use-controllable-state': 'src/hooks/use-controllable-state.ts',