diff --git a/.changeset/quiet-flows-move.md b/.changeset/quiet-flows-move.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-flows-move.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/package.json b/packages/headless/package.json index a3145b4e55c..31369df6092 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -53,6 +53,10 @@ "import": "./dist/primitives/file-upload/index.js", "types": "./dist/primitives/file-upload/index.d.ts" }, + "./flow": { + "import": "./dist/primitives/flow/index.js", + "types": "./dist/primitives/flow/index.d.ts" + }, "./otp": { "import": "./dist/primitives/otp/index.js", "types": "./dist/primitives/otp/index.d.ts" diff --git a/packages/headless/src/primitives/flow/README.md b/packages/headless/src/primitives/flow/README.md new file mode 100644 index 00000000000..d445061ba94 --- /dev/null +++ b/packages/headless/src/primitives/flow/README.md @@ -0,0 +1,71 @@ +# Flow + +A controlled, headless primitive for rendering one step of a multi-step flow at a time while preserving outgoing steps until their exit animations finish. + +## Usage + +```tsx +import { Flow } from '@clerk/headless/flow'; + + + + + + + + +; +``` + +The controller owns the active value and direction. `Flow.Root` renders an unstyled viewport that measures the active step, while `Flow.Step` maps controller states to presence and transition attributes. + +Multiple ids can select the same step. Moving between those ids updates the existing step without starting a transition. + +## Parts + +| Part | Default Element | Description | +| ----------- | --------------- | ------------------------------------------- | +| `Flow.Root` | `
` | Provides state and measures the active step | +| `Flow.Step` | `
` | Renders while active or completing an exit | + +## Props + +### `Flow.Root` + +| Prop | Type | Default | Description | +| ----------- | --------- | ------------ | ---------------------------------------- | +| `value` | `string` | **required** | The active controller state | +| `direction` | `-1 \| 1` | `1` | Direction used by step transition styles | + +### `Flow.Step` + +| Prop | Type | Default | Description | +| ----- | ------------------- | ------------ | ----------------------------------------- | +| `ids` | `readonly string[]` | **required** | Controller states represented by the step | + +`Flow.Step` also accepts standard `
` attributes and the package's `render` prop. + +## Transition attributes + +| Attribute | Description | +| --------------------- | -------------------------------------------------------- | +| `data-open` | The step is active | +| `data-closed` | The step is exiting | +| `data-starting-style` | Present for the incoming step's initial animation frame | +| `data-ending-style` | Present while the outgoing step's animation is finishing | + +The initially active step does not receive `data-starting-style`. An exiting step is inert, hidden from the accessibility tree, and retains the content from its last active render until it unmounts. + +`Flow.Root` carries `data-initial` through the first measured frame. Styled adapters can use it to disable viewport transitions so the initial step never animates. + +## CSS variable + +| CSS variable | Element | Description | +| -------------------------------- | ----------- | --------------------------------------------- | +| `--cl-flow-step-height` | `Flow.Root` | Measured height of the active/entering step | +| `--cl-flow-transition-direction` | `Flow.Step` | Transition direction expressed as `1` or `-1` | + +The styled root can animate its height toward `--cl-flow-step-height`. Each step can multiply its offset by `--cl-flow-transition-direction` to reverse directional motion without branching in React. diff --git a/packages/headless/src/primitives/flow/flow-context.ts b/packages/headless/src/primitives/flow/flow-context.ts new file mode 100644 index 00000000000..016f0421696 --- /dev/null +++ b/packages/headless/src/primitives/flow/flow-context.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +export type FlowDirection = -1 | 1; + +export interface FlowContextValue { + value: string; + direction: FlowDirection; + registerActiveStep: (element: HTMLElement) => void; + unregisterActiveStep: (element: HTMLElement) => void; +} + +export const FlowContext = createContext(null); + +export function useFlowContext(): FlowContextValue { + const context = useContext(FlowContext); + if (!context) { + throw new Error('Flow compound components must be used within '); + } + return context; +} diff --git a/packages/headless/src/primitives/flow/flow-root.tsx b/packages/headless/src/primitives/flow/flow-root.tsx new file mode 100644 index 00000000000..8529c05760c --- /dev/null +++ b/packages/headless/src/primitives/flow/flow-root.tsx @@ -0,0 +1,78 @@ +'use client'; + +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { type ComponentProps, mergeProps, useRender } from '../../utils'; +import { FlowContext, type FlowContextValue, type FlowDirection } from './flow-context'; + +export interface FlowRootProps extends ComponentProps<'div'> { + value: string; + direction?: FlowDirection; +} + +export const FlowRoot = React.forwardRef(function FlowRoot(props, forwardedRef) { + const { render, value, direction = 1, ...otherProps } = props; + const rootRef = useRef(null); + const [activeStep, setActiveStep] = useState(null); + const [activeStepHeight, setActiveStepHeight] = useState(); + const [initial, setInitial] = useState(true); + + const registerActiveStep = useCallback((element: HTMLElement) => { + setActiveStep(element); + }, []); + + const unregisterActiveStep = useCallback((element: HTMLElement) => { + setActiveStep(current => (current === element ? null : current)); + }, []); + + useLayoutEffect(() => { + if (!activeStep) { + return; + } + + const measure = () => { + setActiveStepHeight(activeStep.getBoundingClientRect().height); + }; + + measure(); + + if (typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(measure); + observer.observe(activeStep); + return () => observer.disconnect(); + }, [activeStep]); + + useLayoutEffect(() => { + if (activeStepHeight === undefined || !initial) { + return; + } + + const frame = requestAnimationFrame(() => setInitial(false)); + return () => cancelAnimationFrame(frame); + }, [activeStepHeight, initial]); + + const contextValue = useMemo( + () => ({ value, direction, registerActiveStep, unregisterActiveStep }), + [value, direction, registerActiveStep, unregisterActiveStep], + ); + + const element = useRender({ + defaultTagName: 'div', + render, + ref: [rootRef, forwardedRef], + props: mergeProps<'div'>( + { + 'data-initial': initial ? '' : undefined, + style: { + ['--cl-flow-step-height' as string]: activeStepHeight === undefined ? undefined : `${activeStepHeight}px`, + }, + }, + otherProps, + ), + }); + + return {element}; +}); diff --git a/packages/headless/src/primitives/flow/flow-step.tsx b/packages/headless/src/primitives/flow/flow-step.tsx new file mode 100644 index 00000000000..608e97e21b8 --- /dev/null +++ b/packages/headless/src/primitives/flow/flow-step.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { inertProps } from '@clerk/shared/inert'; +import React, { useLayoutEffect, useRef } from 'react'; + +import { useTransition } from '../../hooks/use-transition'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; +import { useFlowContext } from './flow-context'; + +export interface FlowStepProps extends ComponentProps<'div'> { + ids: readonly string[]; +} + +export const FlowStep = React.forwardRef(function FlowStep(props, forwardedRef) { + const { render, ids, children, ...otherProps } = props; + const { value, direction, registerActiveStep, unregisterActiveStep } = useFlowContext(); + const open = ids.includes(value); + const stepRef = useRef(null); + const activeChildrenRef = useRef(children); + const hasBeenClosed = useRef(false); + + if (open) { + activeChildrenRef.current = children; + } else { + hasBeenClosed.current = true; + } + + const { mounted, transitionProps } = useTransition({ open, ref: stepRef }); + + useLayoutEffect(() => { + const element = stepRef.current; + if (!open || !element) { + return; + } + + registerActiveStep(element); + return () => unregisterActiveStep(element); + }, [open, registerActiveStep, unregisterActiveStep]); + + const effectiveTransitionProps = !hasBeenClosed.current + ? { ...transitionProps, 'data-starting-style': undefined, style: undefined } + : transitionProps; + + const defaultProps = { + ...effectiveTransitionProps, + ...inertProps(!open), + 'aria-hidden': !open ? true : undefined, + style: { + ...effectiveTransitionProps.style, + ['--cl-flow-transition-direction' as string]: String(direction), + }, + children: open ? children : activeChildrenRef.current, + }; + + return useRender({ + defaultTagName: 'div', + enabled: mounted, + render, + ref: [stepRef, forwardedRef], + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/flow/flow.test.tsx b/packages/headless/src/primitives/flow/flow.test.tsx new file mode 100644 index 00000000000..f638c6e1c7d --- /dev/null +++ b/packages/headless/src/primitives/flow/flow.test.tsx @@ -0,0 +1,212 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Flow } from './index'; + +interface TestFlowProps { + value: string; + direction?: -1 | 1; + passwordContent?: string; +} + +function TestFlow({ value, direction = 1, passwordContent = 'Password' }: TestFlowProps) { + return ( + + + {passwordContent} + + + OTP + + + ); +} + +describe('Flow', () => { + let rafCallbacks: Array; + let originalRaf: typeof requestAnimationFrame; + let originalCaf: typeof cancelAnimationFrame; + + beforeEach(() => { + rafCallbacks = []; + originalRaf = globalThis.requestAnimationFrame; + originalCaf = globalThis.cancelAnimationFrame; + globalThis.requestAnimationFrame = vi.fn(callback => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }); + globalThis.cancelAnimationFrame = vi.fn(id => { + rafCallbacks[id - 1] = () => {}; + }); + }); + + afterEach(() => { + cleanup(); + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCaf; + }); + + it('renders only the step matching the controlled value', () => { + render(); + + expect(screen.getByTestId('password-step')).toHaveTextContent('Password'); + expect(screen.queryByTestId('otp-step')).not.toBeInTheDocument(); + }); + + it('keeps the same step mounted when the value changes within its ids', () => { + const { rerender } = render(); + const step = screen.getByTestId('password-step'); + + rerender(); + + expect(screen.getByTestId('password-step')).toBe(step); + expect(step).toHaveAttribute('data-open'); + expect(step).not.toHaveAttribute('data-ending-style'); + }); + + it('does not animate the initially active step', () => { + render(); + + const step = screen.getByTestId('password-step'); + expect(step).toHaveAttribute('data-open'); + expect(step).not.toHaveAttribute('data-starting-style'); + }); + + it('keeps the outgoing step mounted, inert, and frozen while it exits', async () => { + let finishAnimation!: () => void; + const animationFinished = new Promise(resolve => { + finishAnimation = resolve; + }); + const { rerender } = render( + , + ); + const outgoingStep = screen.getByTestId('password-step'); + outgoingStep.getAnimations = vi.fn(() => [{ finished: animationFinished }] as unknown as Animation[]); + + rerender( + , + ); + + expect(outgoingStep).toHaveAttribute('data-closed'); + expect(outgoingStep).toHaveAttribute('data-ending-style'); + expect(outgoingStep).toHaveAttribute('inert'); + expect(outgoingStep).toHaveAttribute('aria-hidden', 'true'); + expect(outgoingStep).toHaveTextContent('Entered password'); + + const incomingStep = screen.getByTestId('otp-step'); + expect(incomingStep).toHaveAttribute('data-open'); + expect(incomingStep).toHaveAttribute('data-starting-style'); + expect(incomingStep).not.toHaveAttribute('inert'); + + outgoingStep.getAnimations = vi.fn(() => []); + await act(async () => { + finishAnimation(); + await animationFinished; + }); + + expect(screen.queryByTestId('password-step')).not.toBeInTheDocument(); + }); + + it('exposes direction as a numeric CSS variable on entering and exiting steps', () => { + let finishAnimation!: () => void; + const animationFinished = new Promise(resolve => { + finishAnimation = resolve; + }); + const { rerender } = render(); + const outgoingStep = screen.getByTestId('password-step'); + outgoingStep.getAnimations = vi.fn(() => [{ finished: animationFinished }] as unknown as Animation[]); + + rerender( + , + ); + + expect(outgoingStep.style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1'); + expect(screen.getByTestId('otp-step').style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1'); + + outgoingStep.getAnimations = vi.fn(() => []); + finishAnimation(); + }); + + it('forwards its ref and supports a custom rendered element', () => { + const ref = createRef(); + + render( + + } + > + Password + + , + ); + + expect(screen.getByTestId('custom-step').tagName).toBe('SECTION'); + expect(ref.current).toBe(screen.getByTestId('custom-step')); + }); + + it('throws when a step is rendered outside the root', () => { + expect(() => render(Password)).toThrow( + 'Flow compound components must be used within ', + ); + }); + + it('publishes the entering step height without enabling initial animation', () => { + const getBoundingClientRect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement, + ) { + const height = this.dataset.testid === 'password-step' ? 120 : 240; + return { + x: 0, + y: 0, + width: 420, + height, + top: 0, + right: 420, + bottom: height, + left: 0, + toJSON: () => ({}), + }; + }); + const { rerender } = render(); + const root = screen.getByTestId('flow-root'); + + expect(root.style.getPropertyValue('--cl-flow-step-height')).toBe('120px'); + expect(root).toHaveAttribute('data-initial'); + + act(() => { + const callbacks = [...rafCallbacks]; + rafCallbacks = []; + callbacks.forEach(callback => callback(performance.now())); + }); + + expect(root).not.toHaveAttribute('data-initial'); + + rerender(); + + expect(root.style.getPropertyValue('--cl-flow-step-height')).toBe('240px'); + expect(root).not.toHaveAttribute('data-initial'); + getBoundingClientRect.mockRestore(); + }); +}); diff --git a/packages/headless/src/primitives/flow/index.ts b/packages/headless/src/primitives/flow/index.ts new file mode 100644 index 00000000000..53346ced68d --- /dev/null +++ b/packages/headless/src/primitives/flow/index.ts @@ -0,0 +1,3 @@ +export * as Flow from './parts'; + +export type { FlowDirection, FlowRootProps, FlowStepProps } from './parts'; diff --git a/packages/headless/src/primitives/flow/parts.ts b/packages/headless/src/primitives/flow/parts.ts new file mode 100644 index 00000000000..f79aa92bcb2 --- /dev/null +++ b/packages/headless/src/primitives/flow/parts.ts @@ -0,0 +1,3 @@ +export { type FlowRootProps, FlowRoot as Root } from './flow-root'; +export { type FlowStepProps, FlowStep as Step } from './flow-step'; +export type { FlowDirection } from './flow-context'; diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 70a8fcb5adf..0722ae12526 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'primitives/drawer/index': 'src/primitives/drawer/index.ts', 'primitives/file-upload/index': 'src/primitives/file-upload/index.ts', + 'primitives/flow/index': 'src/primitives/flow/index.ts', 'primitives/otp/index': 'src/primitives/otp/index.ts', 'utils/index': 'src/utils/index.ts', 'hooks/index': 'src/hooks/index.ts', diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 76364d8c068..c7aecb8c7af 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -73,6 +73,7 @@ const docModules: Record> = { dialog: dynamic(() => import('../stories/dialog.mdx')), drawer: dynamic(() => import('../stories/drawer.mdx')), 'file-upload': dynamic(() => import('../stories/file-upload.mdx')), + flow: dynamic(() => import('../stories/flow.mdx')), menu: dynamic(() => import('../stories/menu.mdx')), otp: dynamic(() => import('../stories/otp.mdx')), popover: dynamic(() => import('../stories/popover.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 4a4eb30b090..b894198df1e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -40,6 +40,7 @@ import { meta as dialogMeta } from '../stories/dialog.stories'; import { meta as drawerMeta } from '../stories/drawer.stories'; import { Default as FieldDefault, meta as fieldMeta } from '../stories/field.component.stories'; import { meta as fileUploadMeta } from '../stories/file-upload.stories'; +import { meta as flowMeta } from '../stories/flow.stories'; import { Colors as HeadingColors, Default as HeadingDefault, @@ -320,6 +321,7 @@ const collapsibleModule: StoryModule = { meta: collapsibleMeta }; const dialogModule: StoryModule = { meta: dialogMeta }; const drawerModule: StoryModule = { meta: drawerMeta }; const fileUploadModule: StoryModule = { meta: fileUploadMeta }; +const flowModule: StoryModule = { meta: flowMeta }; const menuModule: StoryModule = { meta: menuMeta }; const otpModule: StoryModule = { meta: otpMeta }; const popoverModule: StoryModule = { meta: popoverMeta }; @@ -498,6 +500,7 @@ export const registry: StoryModule[] = [ dialogModule, drawerModule, fileUploadModule, + flowModule, menuModule, otpModule, popoverModule, diff --git a/packages/swingset/src/stories/flow.mdx b/packages/swingset/src/stories/flow.mdx new file mode 100644 index 00000000000..3e0342aebeb --- /dev/null +++ b/packages/swingset/src/stories/flow.mdx @@ -0,0 +1,110 @@ +import * as FlowStories from './flow.stories'; + +# Flow + +A controlled step viewport from `@clerk/headless`. It maps controller states to presentational steps, keeps an outgoing step mounted until its CSS animation finishes, and publishes the measurements needed to animate the viewport height. It ships no styles. + +## Example + +The controls in this demo stand in for a controller. The animation CSS belongs to the example rather than the primitive. + + + +## Usage + +```tsx +import { Flow } from '@clerk/headless/flow'; + + + + + + + + + +; +``` + +Flow is controlled and has no trigger. The controller owns the active state and supplies `direction` as `1` or `-1`. Grouping related state ids in one step keeps that view mounted when, for example, `enter-code` becomes `enter-code-pending`. + +## Parts + +| Part | Default Element | Description | +| ----------- | --------------- | ----------------------------------------------------------------- | +| `Flow.Root` | `
` | Provides state and publishes the active step's measured height | +| `Flow.Step` | `
` | Renders while active or while its exit animation is still running | + +Both parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their default element. + +## Props + +### `Flow.Root` + +| Prop | Type | Default | Description | +| ---------------------- | -------------------- | -------------- | -------------------------------------------- | +| value | string | — (required) | Active controller state | +| direction | -1 \| 1 | 1 | Direction used by the step transition styles | + +### `Flow.Step` + +| Prop | Type | Default | Description | +| ---------------- | ------------------------------ | ------------ | ------------------------------------------ | +| ids | readonly string[] | — (required) | Controller states represented by this step | + +## Animation lifecycle + +`Flow.Step` emits the same transition lifecycle attributes used by the other headless primitives: + +| Attribute | Description | +| --------------------- | ------------------------------------------------------ | +| `data-open` | The step is active | +| `data-closed` | The step is exiting | +| `data-starting-style` | Present on the incoming step's initial animation frame | +| `data-ending-style` | Present while the outgoing animation is finishing | + +The initially active step never receives `data-starting-style`. Exiting steps are inert, hidden from the accessibility tree, and retain their last active children until their exit animation completes. + +Direction is available to each step as `--cl-flow-transition-direction`, whose value is exactly `1` or `-1`: + +```css +.flow-step[data-starting-style] { + opacity: 0; + transform: translateX(calc(var(--cl-flow-transition-direction) * 1.5rem)); +} + +.flow-step[data-ending-style] { + opacity: 0; + transform: translateX(calc(var(--cl-flow-transition-direction) * -1.5rem)); +} +``` + +## Viewport height + +`Flow.Root` measures the active or entering step and publishes its height as `--cl-flow-step-height`. It carries `data-initial` through the first measured frame so the first step and initial viewport height can render without motion. + +```css +.flow-root { + height: var(--cl-flow-step-height, auto); + overflow: hidden; + position: relative; + transition: height 240ms ease; +} + +.flow-root[data-initial] { + transition: none; +} + +.flow-step[data-closed] { + inset: 0; + position: absolute; +} +``` + +The outgoing step becomes absolute so the entering step determines layout and therefore the root's target height. Keep reduced-motion handling in the styled layer by disabling these transitions under `prefers-reduced-motion`. diff --git a/packages/swingset/src/stories/flow.stories.tsx b/packages/swingset/src/stories/flow.stories.tsx new file mode 100644 index 00000000000..4d0226a6f69 --- /dev/null +++ b/packages/swingset/src/stories/flow.stories.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { Flow, type FlowDirection } from '@clerk/headless/flow'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Primitives', + title: 'Flow', + source: 'packages/headless/src/primitives/flow/index.ts', +}; + +const steps = [ + { + id: 'account', + title: 'Account details', + description: 'Confirm the account that this action applies to.', + }, + { + id: 'verification', + title: 'Verify your identity', + description: + 'Enter the verification code sent to your primary email address. The additional copy makes this step taller so the viewport height transition is visible.', + }, + { + id: 'complete', + title: 'Complete', + description: 'Your identity has been verified.', + }, +] as const; + +export function Default() { + const [activeIndex, setActiveIndex] = useState(0); + const [direction, setDirection] = useState(1); + + const moveTo = (nextIndex: number) => { + setDirection(nextIndex > activeIndex ? 1 : -1); + setActiveIndex(nextIndex); + }; + + return ( +
+
+ + + Step {activeIndex + 1} of {steps.length} + + +
+ + + {steps.map(step => ( + +

{step.title}

+

{step.description}

+
+ ))} +
+ + +
+ ); +}