From 373d49f0c7538bcda1faba4c773582b57a366e2a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 1/5] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- .../headless/src/primitives/dialog/dialog.tsx | 377 ++++++++++++++++++ pnpm-lock.yaml | 13 + 2 files changed, 390 insertions(+) create mode 100644 packages/headless/src/primitives/dialog/dialog.tsx diff --git a/packages/headless/src/primitives/dialog/dialog.tsx b/packages/headless/src/primitives/dialog/dialog.tsx new file mode 100644 index 00000000000..2a54aed0a57 --- /dev/null +++ b/packages/headless/src/primitives/dialog/dialog.tsx @@ -0,0 +1,377 @@ +'use client'; + +import { + type ExtendedRefs, + type FloatingContext, + FloatingFocusManager, + FloatingNode, + FloatingOverlay, + FloatingPortal, + FloatingTree, + type ReferenceType, + type UseInteractionsReturn, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useMergeRefs, + useRole, +} from '@floating-ui/react'; +import { 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 { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface DialogContextValue { + open: boolean; + setOpen: (open: boolean) => void; + floatingContext: FloatingContext; + refs: ExtendedRefs; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + modal: boolean; + labelId: string | undefined; + descriptionId: string | undefined; + setLabelId: React.Dispatch>; + setDescriptionId: React.Dispatch>; + mounted: boolean; + transitionProps: TransitionProps; +} + +const DialogContext = createContext(null); +const DialogScopedContext = createContext(false); + +function useDialogContext() { + const ctx = useContext(DialogContext); + if (!ctx) { + throw new Error('Dialog compound components must be used within '); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// Dialog (root) +// --------------------------------------------------------------------------- + +export interface DialogProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + /** When true, the dialog traps focus and blocks interaction with the rest of the page. Default: true */ + modal?: boolean; + children: ReactNode; +} + +function DialogInner(props: DialogProps) { + const nodeId = useFloatingNodeId(); + const { modal = true, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [labelId, setLabelId] = useState(); + const [descriptionId, setDescriptionId] = useState(); + + const popupRef = useRef(null); + + const { refs, context: floatingContext } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext, { + outsidePressEvent: 'mousedown', + }); + const role = useRole(floatingContext); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + floatingContext, + refs, + getReferenceProps, + getFloatingProps, + popupRef, + modal, + labelId, + descriptionId, + setLabelId, + setDescriptionId, + mounted, + transitionProps, + }), + [ + open, + setOpen, + floatingContext, + refs, + getReferenceProps, + getFloatingProps, + modal, + labelId, + descriptionId, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +function DialogRoot(props: DialogProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} + +// --------------------------------------------------------------------------- +// Dialog.Trigger +// --------------------------------------------------------------------------- + +export interface DialogTriggerProps extends ComponentProps<'button'> {} + +function DialogTrigger(props: DialogTriggerProps) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useDialogContext(); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'dialog-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), + }); +} + +// --------------------------------------------------------------------------- +// Dialog.Portal +// --------------------------------------------------------------------------- + +export interface DialogPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +function DialogPortal(props: DialogPortalProps) { + const { mounted } = useDialogContext(); + const isScoped = props.root != null; + + if (!mounted) return null; + + return ( + + {props.children} + + ); +} + +// --------------------------------------------------------------------------- +// Dialog.Backdrop +// --------------------------------------------------------------------------- + +export interface DialogBackdropProps extends ComponentProps<'div'> { + /** When true, locks body scroll while the dialog is open. Default: true */ + lockScroll?: boolean; +} + +function DialogBackdrop(props: DialogBackdropProps) { + const { render, lockScroll = true, ...otherProps } = props; + const { open, mounted, transitionProps } = useDialogContext(); + const scoped = useContext(DialogScopedContext); + + const state = { open }; + + const defaultProps = { + 'data-cl-slot': 'dialog-backdrop', + ...transitionProps, + } as React.ComponentPropsWithRef<'div'>; + + const backdropElement = 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), + }); + + // When scoped to a container (Dialog.Portal has a root), skip FloatingOverlay + // which uses position:fixed. CSS on the backdrop element handles positioning. + if (scoped) { + return backdropElement; + } + + return {backdropElement}; +} + +// --------------------------------------------------------------------------- +// Dialog.Popup +// --------------------------------------------------------------------------- + +export interface DialogPopupProps extends ComponentProps<'div'> {} + +function DialogPopup(props: DialogPopupProps) { + const { render, ...otherProps } = props; + const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, transitionProps } = + useDialogContext(); + + const combinedRef = useMergeRefs([popupRef, refs.setFloating]); + + const defaultProps = { + 'data-cl-slot': 'dialog-popup', + ref: combinedRef, + 'aria-labelledby': labelId, + 'aria-describedby': descriptionId, + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + ...transitionProps, + }; + + return ( + + {renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + })} + + ); +} + +// --------------------------------------------------------------------------- +// Dialog.Title +// --------------------------------------------------------------------------- + +export interface DialogTitleProps extends ComponentProps<'h2'> {} + +function DialogTitle(props: DialogTitleProps) { + const { render, ...otherProps } = props; + const { setLabelId } = useDialogContext(); + const id = useId(); + + useLayoutEffect(() => { + setLabelId(id); + return () => setLabelId(undefined); + }, [id, setLabelId]); + + const defaultProps = { + 'data-cl-slot': 'dialog-title', + id, + }; + + return renderElement({ + defaultTagName: 'h2', + render, + props: mergeProps<'h2'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Dialog.Description +// --------------------------------------------------------------------------- + +export interface DialogDescriptionProps extends ComponentProps<'p'> {} + +function DialogDescription(props: DialogDescriptionProps) { + const { render, ...otherProps } = props; + const { setDescriptionId } = useDialogContext(); + const id = useId(); + + useLayoutEffect(() => { + setDescriptionId(id); + return () => setDescriptionId(undefined); + }, [id, setDescriptionId]); + + const defaultProps = { + 'data-cl-slot': 'dialog-description', + id, + }; + + return renderElement({ + defaultTagName: 'p', + render, + props: mergeProps<'p'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Dialog.Close +// --------------------------------------------------------------------------- + +export interface DialogCloseProps extends ComponentProps<'button'> {} + +function DialogClose(props: DialogCloseProps) { + const { render, ...otherProps } = props; + const { setOpen } = useDialogContext(); + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'dialog-close', + onClick() { + setOpen(false); + }, + }; + + return renderElement({ + defaultTagName: 'button', + render, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} + +// --------------------------------------------------------------------------- +// Compound export +// --------------------------------------------------------------------------- + +export const Dialog = Object.assign(DialogRoot, { + Trigger: DialogTrigger, + Portal: DialogPortal, + Backdrop: DialogBackdrop, + Popup: DialogPopup, + Title: DialogTitle, + Description: DialogDescription, + Close: DialogClose, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a233b1f918..a68a1d99370 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21462,6 +21462,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From b54202b021fd163e59ce5126d77f8f70f5089eb9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 11:38:33 -0400 Subject: [PATCH 2/5] feat(headless): add Accordion primitive --- packages/headless/package.json | 4 + .../src/primitives/accordion/README.md | 128 ++++++ .../primitives/accordion/accordion.test.tsx | 419 ++++++++++++++++++ .../src/primitives/accordion/accordion.tsx | 352 +++++++++++++++ .../src/primitives/accordion/index.ts | 8 + packages/headless/vite.config.ts | 1 + 6 files changed, 912 insertions(+) create mode 100644 packages/headless/src/primitives/accordion/README.md create mode 100644 packages/headless/src/primitives/accordion/accordion.test.tsx create mode 100644 packages/headless/src/primitives/accordion/accordion.tsx create mode 100644 packages/headless/src/primitives/accordion/index.ts diff --git a/packages/headless/package.json b/packages/headless/package.json index 43c4d5d75ae..e543ab42cbb 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -5,6 +5,10 @@ "sideEffects": false, "type": "module", "exports": { + "./accordion": { + "import": "./dist/primitives/accordion/index.js", + "types": "./dist/primitives/accordion/index.d.ts" + }, "./dialog": { "import": "./dist/primitives/dialog/index.js", "types": "./dist/primitives/dialog/index.d.ts" diff --git a/packages/headless/src/primitives/accordion/README.md b/packages/headless/src/primitives/accordion/README.md new file mode 100644 index 00000000000..5254df32cc6 --- /dev/null +++ b/packages/headless/src/primitives/accordion/README.md @@ -0,0 +1,128 @@ +# Accordion + +A vertically stacked set of collapsible sections. Supports single or multiple open panels, keyboard navigation, and CSS-driven expand/collapse animations. + +## When to Use + +- FAQ sections, settings panels, or any UI where content should be shown/hidden in discrete sections. +- When you need accessible expand/collapse with proper ARIA attributes and keyboard support. +- Prefer Accordion over manual show/hide toggles — it handles focus management, ARIA, and animation lifecycle automatically. + +## Usage + +```tsx +import { Accordion } from '@/primitives/accordion'; + + + + + Section 1 + + Content for section 1 + + + + Section 2 + + Content for section 2 + +; +``` + +### Controlled + +```tsx +const [value, setValue] = useState(['item-1']); + + + {/* ... */} +; +``` + +## Parts + +| Part | Default Element | Description | +| ------------------- | --------------- | ---------------------------------- | +| `Accordion` | `
` | Root wrapper, provides context | +| `Accordion.Item` | `
` | Wraps a single collapsible section | +| `Accordion.Header` | `

` | Heading wrapper for the trigger | +| `Accordion.Trigger` | `