diff --git a/packages/headless/package.json b/packages/headless/package.json index 3c3e24d0d99..97ecfbd4c03 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -16,6 +16,10 @@ "import": "./dist/primitives/tooltip/index.js", "types": "./dist/primitives/tooltip/index.d.ts" }, + "./popover": { + "import": "./dist/primitives/popover/index.js", + "types": "./dist/primitives/popover/index.d.ts" + }, "./dialog": { "import": "./dist/primitives/dialog/index.js", "types": "./dist/primitives/dialog/index.d.ts" diff --git a/packages/headless/src/primitives/popover/README.md b/packages/headless/src/primitives/popover/README.md new file mode 100644 index 00000000000..c320cdd1a0b --- /dev/null +++ b/packages/headless/src/primitives/popover/README.md @@ -0,0 +1,111 @@ +# Popover + +A floating panel anchored to a trigger element. Supports focus management, ARIA labeling, and enter/exit animations. + +## When to Use + +- Rich content panels, filter dropdowns, or any non-modal floating content anchored to a trigger. +- When content includes interactive elements (inputs, buttons) — unlike Tooltip which is display-only. +- Prefer Popover over Dialog when the content should be anchored to a specific element and the page should remain interactive by default. + +## Usage + +```tsx +import { Popover } from '@/primitives/popover'; + + + Settings + + + Preferences + Adjust your settings below. + {/* Interactive content here */} + Done + + +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + {/* ... */} +; +``` + +### Modal Mode + +```tsx +{/* Focus is trapped within the popover */} +``` + +## Parts + +| Part | Default Element | Description | +| --------------------- | --------------- | ---------------------------------------- | +| `Popover` | — | Root context provider | +| `Popover.Trigger` | `` | Toggles the popover on click | +| `Popover.Portal` | — | Portals children (accepts `root` prop) | +| `Popover.Positioner` | `` | Floating positioned container | +| `Popover.Popup` | `` | Visual content wrapper | +| `Popover.Arrow` | `` | Optional floating arrow | +| `Popover.Title` | `` | Heading, wired to `aria-labelledby` | +| `Popover.Description` | `` | Description, wired to `aria-describedby` | +| `Popover.Close` | `` | Closes the popover on click | + +## Props + +### `Popover` (root) + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ---------- | ---------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"bottom"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | +| `modal` | `boolean` | `false` | Traps focus within the popover | + +### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Popover.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard + +| Key | Action | +| -------- | -------------------------------------------------------------------- | +| `Escape` | Closes the popover | +| `Tab` | Cycles focus within popover (modal mode) or moves freely (non-modal) | + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"popover-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Open state | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Positioning + +Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popup auto-repositions on scroll and resize via `autoUpdate`. Cross-axis flipping is enabled only when using an aligned placement (e.g. `"bottom-start"`). + +## Important Notes + +- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent. +- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap. +- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically. + +## ARIA + +- Positioner: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description) +- Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls` diff --git a/packages/headless/src/primitives/popover/index.ts b/packages/headless/src/primitives/popover/index.ts new file mode 100644 index 00000000000..54d8a7b9576 --- /dev/null +++ b/packages/headless/src/primitives/popover/index.ts @@ -0,0 +1,12 @@ +export type { + PopoverArrowProps, + PopoverCloseProps, + PopoverDescriptionProps, + PopoverPopupProps, + PopoverPortalProps, + PopoverPositionerProps, + PopoverProps, + PopoverTitleProps, + PopoverTriggerProps, +} from './popover'; +export { Popover } from './popover'; diff --git a/packages/headless/src/primitives/popover/popover.test.tsx b/packages/headless/src/primitives/popover/popover.test.tsx new file mode 100644 index 00000000000..ed0ec5efaf8 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover.test.tsx @@ -0,0 +1,259 @@ +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 { Popover } from './popover'; + +afterEach(() => cleanup()); + +function renderPopover(props: Partial> = {}) { + return render( + + Open popover + + + Popover Title + Some description + Popover content + Close + + + , + ); +} + +describe('Popover', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderPopover(); + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-slot', 'popover-trigger'); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-title"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-description"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-close"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + }); + + it('closes on trigger click when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes via Close button', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + const closeBtn = screen.getByRole('button', { name: 'Close' }); + await user.click(closeBtn); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderPopover({ onOpenChange }); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('closes on outside click', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + + await user.click(document.body); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).not.toBeInTheDocument(); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderPopover({ open: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderPopover({ open: false }); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('positioner has aria-labelledby linked to title', () => { + renderPopover({ defaultOpen: true }); + + const title = document.querySelector('[data-cl-slot="popover-title"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(title).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + }); + + it('positioner has aria-describedby linked to description', () => { + renderPopover({ defaultOpen: true }); + + const desc = document.querySelector('[data-cl-slot="popover-description"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(desc).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); + }); + + it('trigger has role=button', () => { + renderPopover(); + expect(screen.getByRole('button', { name: 'Open popover' })).toBeInTheDocument(); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderPopover(); + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const popup = document.querySelector('[data-cl-slot="popover-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('content rendering', () => { + it('renders children content when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(screen.getByText('Popover content')).toBeInTheDocument(); + expect(screen.getByText('Popover Title')).toBeInTheDocument(); + expect(screen.getByText('Some description')).toBeInTheDocument(); + }); + }); + + describe('placement', () => { + it('accepts custom placement', () => { + renderPopover({ defaultOpen: true, placement: 'top-start' }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'top'); + }); + + it('defaults to bottom placement', () => { + renderPopover({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + }); + }); + + describe('focus management', () => { + it('moves focus into popover on open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + // FloatingFocusManager schedules focus via requestAnimationFrame + await new Promise(r => requestAnimationFrame(r)); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner?.contains(document.activeElement)).toBe(true); + }); + + it('returns focus to trigger on close via Escape', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.keyboard('{Escape}'); + + expect(document.activeElement).toBe(trigger); + }); + + it('returns focus to trigger on close via Close button', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(document.activeElement).toBe(trigger); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderPopover(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + renderPopover({ defaultOpen: true }); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); +}); diff --git a/packages/headless/src/primitives/popover/popover.tsx b/packages/headless/src/primitives/popover/popover.tsx new file mode 100644 index 00000000000..b14d723fb4e --- /dev/null +++ b/packages/headless/src/primitives/popover/popover.tsx @@ -0,0 +1,419 @@ +'use client'; + +import { + arrow, + autoUpdate, + type ExtendedRefs, + FloatingArrow, + type FloatingContext, + FloatingFocusManager, + FloatingNode, + FloatingPortal, + FloatingTree, + flip, + offset, + type Placement, + type ReferenceType, + shift, + type UseInteractionsReturn, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { + type CSSProperties, + createContext, + type ReactNode, + useContext, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useControllableState } from '../../hooks/use-controllable-state'; +import { type TransitionProps, useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface PopoverContextValue { + open: boolean; + setOpen: (open: boolean) => void; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + modal: boolean; + labelId: string | undefined; + descriptionId: string | undefined; + setLabelId: React.Dispatch>; + setDescriptionId: React.Dispatch>; + mounted: boolean; + transitionProps: TransitionProps; +} + +const PopoverContext = createContext(null); + +function usePopoverContext() { + const ctx = useContext(PopoverContext); + if (!ctx) { + throw new Error('Popover compound components must be used within '); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// Popover (root) +// --------------------------------------------------------------------------- + +export interface PopoverProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + modal?: boolean; + children: ReactNode; +} + +function PopoverInner(props: PopoverProps) { + const nodeId = useFloatingNodeId(); + const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [labelId, setLabelId] = useState(); + const [descriptionId, setDescriptionId] = useState(); + + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ + crossAxis: placementProp.includes('-'), + fallbackAxisSideDirection: 'end', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + popupRef, + arrowRef, + modal, + labelId, + descriptionId, + setLabelId, + setDescriptionId, + mounted, + transitionProps, + }), + [ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + modal, + labelId, + descriptionId, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +function PopoverRoot(props: PopoverProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} + +// --------------------------------------------------------------------------- +// Popover.Trigger +// --------------------------------------------------------------------------- + +export interface PopoverTriggerProps extends ComponentProps<'button'> {} + +function PopoverTrigger(props: PopoverTriggerProps) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = usePopoverContext(); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-trigger', + ref: refs.setReference, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Portal +// --------------------------------------------------------------------------- + +export interface PopoverPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +function PopoverPortal(props: PopoverPortalProps) { + const { mounted } = usePopoverContext(); + if (!mounted) return null; + return {props.children}; +} + +// --------------------------------------------------------------------------- +// Popover.Positioner +// --------------------------------------------------------------------------- + +export interface PopoverPositionerProps extends ComponentProps<'div'> {} + +function PopoverPositioner(props: PopoverPositionerProps) { + const { render, ...otherProps } = props; + const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, modal, labelId, descriptionId } = + usePopoverContext(); + + const side = placement.split('-')[0]; + + const defaultProps = { + 'data-cl-slot': 'popover-positioner', + 'data-cl-side': side, + ref: refs.setFloating, + style: floatingStyles, + 'aria-labelledby': labelId, + 'aria-describedby': descriptionId, + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + }; + + const element = renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + return ( + + {element!} + + ); +} + +// --------------------------------------------------------------------------- +// Popover.Popup +// --------------------------------------------------------------------------- + +export interface PopoverPopupProps extends ComponentProps<'div'> {} + +function PopoverPopup(props: PopoverPopupProps) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = usePopoverContext(); + + const defaultProps = { + 'data-cl-slot': 'popover-popup', + ref: popupRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Arrow +// --------------------------------------------------------------------------- + +export interface PopoverArrowProps extends React.ComponentPropsWithRef {} + +function PopoverArrowComponent(props: PopoverArrowProps) { + const { floatingContext, arrowRef, placement } = usePopoverContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Popover.Title +// --------------------------------------------------------------------------- + +export interface PopoverTitleProps extends ComponentProps<'h2'> {} + +function PopoverTitle(props: PopoverTitleProps) { + const { render, ...otherProps } = props; + const { setLabelId } = usePopoverContext(); + const id = useId(); + + useLayoutEffect(() => { + setLabelId(id); + return () => setLabelId(undefined); + }, [id, setLabelId]); + + const defaultProps = { + 'data-cl-slot': 'popover-title', + id, + }; + + return renderElement({ + defaultTagName: 'h2', + render, + props: mergeProps<'h2'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Description +// --------------------------------------------------------------------------- + +export interface PopoverDescriptionProps extends ComponentProps<'p'> {} + +function PopoverDescription(props: PopoverDescriptionProps) { + const { render, ...otherProps } = props; + const { setDescriptionId } = usePopoverContext(); + const id = useId(); + + useLayoutEffect(() => { + setDescriptionId(id); + return () => setDescriptionId(undefined); + }, [id, setDescriptionId]); + + const defaultProps = { + 'data-cl-slot': 'popover-description', + id, + }; + + return renderElement({ + defaultTagName: 'p', + render, + props: mergeProps<'p'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Close +// --------------------------------------------------------------------------- + +export interface PopoverCloseProps extends ComponentProps<'button'> {} + +function PopoverClose(props: PopoverCloseProps) { + const { render, ...otherProps } = props; + const { setOpen } = usePopoverContext(); + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-close', + onClick() { + setOpen(false); + }, + }; + + return renderElement({ + defaultTagName: 'button', + render, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Compound export +// --------------------------------------------------------------------------- + +export const Popover = Object.assign(PopoverRoot, { + Trigger: PopoverTrigger, + Portal: PopoverPortal, + Positioner: PopoverPositioner, + Popup: PopoverPopup, + Arrow: PopoverArrowComponent, + Title: PopoverTitle, + Description: PopoverDescription, + Close: PopoverClose, +}); diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 90cd1c95a41..53c1f77a990 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ 'primitives/accordion/index': 'src/primitives/accordion/index.ts', 'primitives/tabs/index': 'src/primitives/tabs/index.ts', 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', + 'primitives/popover/index': 'src/primitives/popover/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',
` | Description, wired to `aria-describedby` | +| `Popover.Close` | `` | Closes the popover on click | + +## Props + +### `Popover` (root) + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ---------- | ---------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"bottom"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | +| `modal` | `boolean` | `false` | Traps focus within the popover | + +### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Popover.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard + +| Key | Action | +| -------- | -------------------------------------------------------------------- | +| `Escape` | Closes the popover | +| `Tab` | Cycles focus within popover (modal mode) or moves freely (non-modal) | + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"popover-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Open state | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Positioning + +Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popup auto-repositions on scroll and resize via `autoUpdate`. Cross-axis flipping is enabled only when using an aligned placement (e.g. `"bottom-start"`). + +## Important Notes + +- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent. +- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap. +- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically. + +## ARIA + +- Positioner: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description) +- Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls` diff --git a/packages/headless/src/primitives/popover/index.ts b/packages/headless/src/primitives/popover/index.ts new file mode 100644 index 00000000000..54d8a7b9576 --- /dev/null +++ b/packages/headless/src/primitives/popover/index.ts @@ -0,0 +1,12 @@ +export type { + PopoverArrowProps, + PopoverCloseProps, + PopoverDescriptionProps, + PopoverPopupProps, + PopoverPortalProps, + PopoverPositionerProps, + PopoverProps, + PopoverTitleProps, + PopoverTriggerProps, +} from './popover'; +export { Popover } from './popover'; diff --git a/packages/headless/src/primitives/popover/popover.test.tsx b/packages/headless/src/primitives/popover/popover.test.tsx new file mode 100644 index 00000000000..ed0ec5efaf8 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover.test.tsx @@ -0,0 +1,259 @@ +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 { Popover } from './popover'; + +afterEach(() => cleanup()); + +function renderPopover(props: Partial> = {}) { + return render( + + Open popover + + + Popover Title + Some description + Popover content + Close + + + , + ); +} + +describe('Popover', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderPopover(); + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-slot', 'popover-trigger'); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-title"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-description"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-close"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + }); + + it('closes on trigger click when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes via Close button', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + const closeBtn = screen.getByRole('button', { name: 'Close' }); + await user.click(closeBtn); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderPopover({ onOpenChange }); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('closes on outside click', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + + await user.click(document.body); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).not.toBeInTheDocument(); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderPopover({ open: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderPopover({ open: false }); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('positioner has aria-labelledby linked to title', () => { + renderPopover({ defaultOpen: true }); + + const title = document.querySelector('[data-cl-slot="popover-title"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(title).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + }); + + it('positioner has aria-describedby linked to description', () => { + renderPopover({ defaultOpen: true }); + + const desc = document.querySelector('[data-cl-slot="popover-description"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(desc).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); + }); + + it('trigger has role=button', () => { + renderPopover(); + expect(screen.getByRole('button', { name: 'Open popover' })).toBeInTheDocument(); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderPopover(); + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const popup = document.querySelector('[data-cl-slot="popover-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('content rendering', () => { + it('renders children content when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(screen.getByText('Popover content')).toBeInTheDocument(); + expect(screen.getByText('Popover Title')).toBeInTheDocument(); + expect(screen.getByText('Some description')).toBeInTheDocument(); + }); + }); + + describe('placement', () => { + it('accepts custom placement', () => { + renderPopover({ defaultOpen: true, placement: 'top-start' }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'top'); + }); + + it('defaults to bottom placement', () => { + renderPopover({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + }); + }); + + describe('focus management', () => { + it('moves focus into popover on open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + // FloatingFocusManager schedules focus via requestAnimationFrame + await new Promise(r => requestAnimationFrame(r)); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner?.contains(document.activeElement)).toBe(true); + }); + + it('returns focus to trigger on close via Escape', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.keyboard('{Escape}'); + + expect(document.activeElement).toBe(trigger); + }); + + it('returns focus to trigger on close via Close button', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(document.activeElement).toBe(trigger); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderPopover(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + renderPopover({ defaultOpen: true }); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); +}); diff --git a/packages/headless/src/primitives/popover/popover.tsx b/packages/headless/src/primitives/popover/popover.tsx new file mode 100644 index 00000000000..b14d723fb4e --- /dev/null +++ b/packages/headless/src/primitives/popover/popover.tsx @@ -0,0 +1,419 @@ +'use client'; + +import { + arrow, + autoUpdate, + type ExtendedRefs, + FloatingArrow, + type FloatingContext, + FloatingFocusManager, + FloatingNode, + FloatingPortal, + FloatingTree, + flip, + offset, + type Placement, + type ReferenceType, + shift, + type UseInteractionsReturn, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { + type CSSProperties, + createContext, + type ReactNode, + useContext, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useControllableState } from '../../hooks/use-controllable-state'; +import { type TransitionProps, useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface PopoverContextValue { + open: boolean; + setOpen: (open: boolean) => void; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + modal: boolean; + labelId: string | undefined; + descriptionId: string | undefined; + setLabelId: React.Dispatch>; + setDescriptionId: React.Dispatch>; + mounted: boolean; + transitionProps: TransitionProps; +} + +const PopoverContext = createContext(null); + +function usePopoverContext() { + const ctx = useContext(PopoverContext); + if (!ctx) { + throw new Error('Popover compound components must be used within '); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// Popover (root) +// --------------------------------------------------------------------------- + +export interface PopoverProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + modal?: boolean; + children: ReactNode; +} + +function PopoverInner(props: PopoverProps) { + const nodeId = useFloatingNodeId(); + const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [labelId, setLabelId] = useState(); + const [descriptionId, setDescriptionId] = useState(); + + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ + crossAxis: placementProp.includes('-'), + fallbackAxisSideDirection: 'end', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + popupRef, + arrowRef, + modal, + labelId, + descriptionId, + setLabelId, + setDescriptionId, + mounted, + transitionProps, + }), + [ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + modal, + labelId, + descriptionId, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +function PopoverRoot(props: PopoverProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} + +// --------------------------------------------------------------------------- +// Popover.Trigger +// --------------------------------------------------------------------------- + +export interface PopoverTriggerProps extends ComponentProps<'button'> {} + +function PopoverTrigger(props: PopoverTriggerProps) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = usePopoverContext(); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-trigger', + ref: refs.setReference, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Portal +// --------------------------------------------------------------------------- + +export interface PopoverPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +function PopoverPortal(props: PopoverPortalProps) { + const { mounted } = usePopoverContext(); + if (!mounted) return null; + return {props.children}; +} + +// --------------------------------------------------------------------------- +// Popover.Positioner +// --------------------------------------------------------------------------- + +export interface PopoverPositionerProps extends ComponentProps<'div'> {} + +function PopoverPositioner(props: PopoverPositionerProps) { + const { render, ...otherProps } = props; + const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, modal, labelId, descriptionId } = + usePopoverContext(); + + const side = placement.split('-')[0]; + + const defaultProps = { + 'data-cl-slot': 'popover-positioner', + 'data-cl-side': side, + ref: refs.setFloating, + style: floatingStyles, + 'aria-labelledby': labelId, + 'aria-describedby': descriptionId, + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + }; + + const element = renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + return ( + + {element!} + + ); +} + +// --------------------------------------------------------------------------- +// Popover.Popup +// --------------------------------------------------------------------------- + +export interface PopoverPopupProps extends ComponentProps<'div'> {} + +function PopoverPopup(props: PopoverPopupProps) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = usePopoverContext(); + + const defaultProps = { + 'data-cl-slot': 'popover-popup', + ref: popupRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Arrow +// --------------------------------------------------------------------------- + +export interface PopoverArrowProps extends React.ComponentPropsWithRef {} + +function PopoverArrowComponent(props: PopoverArrowProps) { + const { floatingContext, arrowRef, placement } = usePopoverContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Popover.Title +// --------------------------------------------------------------------------- + +export interface PopoverTitleProps extends ComponentProps<'h2'> {} + +function PopoverTitle(props: PopoverTitleProps) { + const { render, ...otherProps } = props; + const { setLabelId } = usePopoverContext(); + const id = useId(); + + useLayoutEffect(() => { + setLabelId(id); + return () => setLabelId(undefined); + }, [id, setLabelId]); + + const defaultProps = { + 'data-cl-slot': 'popover-title', + id, + }; + + return renderElement({ + defaultTagName: 'h2', + render, + props: mergeProps<'h2'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Description +// --------------------------------------------------------------------------- + +export interface PopoverDescriptionProps extends ComponentProps<'p'> {} + +function PopoverDescription(props: PopoverDescriptionProps) { + const { render, ...otherProps } = props; + const { setDescriptionId } = usePopoverContext(); + const id = useId(); + + useLayoutEffect(() => { + setDescriptionId(id); + return () => setDescriptionId(undefined); + }, [id, setDescriptionId]); + + const defaultProps = { + 'data-cl-slot': 'popover-description', + id, + }; + + return renderElement({ + defaultTagName: 'p', + render, + props: mergeProps<'p'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Popover.Close +// --------------------------------------------------------------------------- + +export interface PopoverCloseProps extends ComponentProps<'button'> {} + +function PopoverClose(props: PopoverCloseProps) { + const { render, ...otherProps } = props; + const { setOpen } = usePopoverContext(); + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-close', + onClick() { + setOpen(false); + }, + }; + + return renderElement({ + defaultTagName: 'button', + render, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Compound export +// --------------------------------------------------------------------------- + +export const Popover = Object.assign(PopoverRoot, { + Trigger: PopoverTrigger, + Portal: PopoverPortal, + Positioner: PopoverPositioner, + Popup: PopoverPopup, + Arrow: PopoverArrowComponent, + Title: PopoverTitle, + Description: PopoverDescription, + Close: PopoverClose, +}); diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 90cd1c95a41..53c1f77a990 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ 'primitives/accordion/index': 'src/primitives/accordion/index.ts', 'primitives/tabs/index': 'src/primitives/tabs/index.ts', 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', + 'primitives/popover/index': 'src/primitives/popover/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',
Popover content