-
Notifications
You must be signed in to change notification settings - Fork 468
feat(headless): add Flow primitive #9603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
|
|
||
| <Flow.Root | ||
| value={machine.status} | ||
| direction={machine.direction} | ||
| > | ||
| <Flow.Step ids={['enter-password', 'enter-password-pending', 'enter-password-error']}> | ||
| <PasswordView {...passwordViewProps} /> | ||
| </Flow.Step> | ||
| <Flow.Step ids={['enter-code', 'enter-code-pending', 'enter-code-error']}> | ||
| <OtpView {...otpViewProps} /> | ||
| </Flow.Step> | ||
| </Flow.Root>; | ||
| ``` | ||
|
|
||
| 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` | `<div>` | Provides state and measures the active step | | ||
| | `Flow.Step` | `<div>` | 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 `<div>` 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<FlowContextValue | null>(null); | ||
|
|
||
| export function useFlowContext(): FlowContextValue { | ||
| const context = useContext(FlowContext); | ||
| if (!context) { | ||
| throw new Error('Flow compound components must be used within <Flow.Root>'); | ||
| } | ||
| return context; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<HTMLDivElement, FlowRootProps>(function FlowRoot(props, forwardedRef) { | ||||||||||||||||
| const { render, value, direction = 1, ...otherProps } = props; | ||||||||||||||||
| const rootRef = useRef<HTMLDivElement | null>(null); | ||||||||||||||||
| const [activeStep, setActiveStep] = useState<HTMLElement | null>(null); | ||||||||||||||||
| const [activeStepHeight, setActiveStepHeight] = useState<number>(); | ||||||||||||||||
| 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; | ||||||||||||||||
|
Comment on lines
+28
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Clear the height when no step is active. Line 29 returns without resetting Proposed fix useLayoutEffect(() => {
if (!activeStep) {
+ setActiveStepHeight(undefined);
return;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| 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<FlowContextValue>( | ||||||||||||||||
| () => ({ 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 <FlowContext.Provider value={contextValue}>{element}</FlowContext.Provider>; | ||||||||||||||||
| }); | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLDivElement, FlowStepProps>(function FlowStep(props, forwardedRef) { | ||
| const { render, ids, children, ...otherProps } = props; | ||
| const { value, direction, registerActiveStep, unregisterActiveStep } = useFlowContext(); | ||
| const open = ids.includes(value); | ||
| const stepRef = useRef<HTMLDivElement | null>(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), | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Add the Flow release entry.
This changeset does not declare
@clerk/headless, a bump type, or a release note. The new publicFlowexport will not be included in a package release.Proposed fix
As per coding guidelines, “Use Changesets for version management and changelogs.”
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines