Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-flow-autofocus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Reverification now moves focus to the entering step's primary control after the step transition completes, so the next input or action is ready for keyboard and screen reader users without disrupting the slide animation.
17 changes: 17 additions & 0 deletions packages/headless/src/primitives/flow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ Multiple ids can select the same step. Moving between those ids updates the exis

`Flow.Step` also accepts standard `<div>` attributes and the package's `render` prop.

## Focus

`useFlowAutoFocus()` returns a ref. Attach it to the element a step should focus once its enter transition settles:

```tsx
function PasswordView() {
return (
<input
ref={useFlowAutoFocus()}
type='password'
/>
);
}
```

Focus moves only for a step that transitions in; the initially active step is left to whatever container opened it. Focus is applied with `preventScroll` after the step's animations finish, and only when focus is currently on the body or inside `Flow.Root`, so it never steals from elsewhere on the page. When several mounted elements are marked, the first in DOM order is focused, and an element that unmounts before the step settles is skipped. A step that closes before it settles drops its pending focus. Outside a `Flow.Step` the hook returns a no-op ref.

## Transition attributes

| Attribute | Description |
Expand Down
3 changes: 2 additions & 1 deletion packages/headless/src/primitives/flow/flow-context.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createContext, useContext } from 'react';
import { createContext, type RefObject, useContext } from 'react';

export type FlowDirection = -1 | 1;

export interface FlowContextValue {
value: string;
direction: FlowDirection;
rootRef: RefObject<HTMLDivElement | null>;
registerActiveStep: (element: HTMLElement) => void;
unregisterActiveStep: (element: HTMLElement) => void;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/headless/src/primitives/flow/flow-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const FlowRoot = React.forwardRef<HTMLDivElement, FlowRootProps>(function
}, [activeStepHeight, initial]);

const contextValue = useMemo<FlowContextValue>(
() => ({ value, direction, registerActiveStep, unregisterActiveStep }),
() => ({ value, direction, rootRef, registerActiveStep, unregisterActiveStep }),
[value, direction, registerActiveStep, unregisterActiveStep],
);

Expand Down
35 changes: 35 additions & 0 deletions packages/headless/src/primitives/flow/flow-step-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { createContext, type RefCallback, useCallback, useContext, useRef } from 'react';

export interface FlowStepContextValue {
registerFocusTarget: (element: HTMLElement) => void;
unregisterFocusTarget: (element: HTMLElement) => void;
}

export const FlowStepContext = createContext<FlowStepContextValue | null>(null);

/**
* Marks an element as the one to focus after the enclosing `Flow.Step` finishes entering.
* When several mounted elements are marked, the first in DOM order is focused. Outside a
* step the ref is a no-op, so a view can render standalone without a wrapper.
*/
export function useFlowAutoFocus<T extends HTMLElement = HTMLElement>(): RefCallback<T> {
const context = useContext(FlowStepContext);
const elementRef = useRef<T | null>(null);

return useCallback(
(element: T | null) => {
if (element) {
elementRef.current = element;
context?.registerFocusTarget(element);
return;
}
if (elementRef.current) {
context?.unregisterFocusTarget(elementRef.current);
elementRef.current = null;
}
},
[context],
);
}
55 changes: 52 additions & 3 deletions packages/headless/src/primitives/flow/flow-step.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,42 @@
'use client';

import { inertProps } from '@clerk/shared/inert';
import React, { useLayoutEffect, useRef } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';

import { useAnimationsFinished } from '../../hooks/use-animations-finished';
import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { useFlowContext } from './flow-context';
import { FlowStepContext, type FlowStepContextValue } from './flow-step-context';

export interface FlowStepProps extends ComponentProps<'div'> {
ids: readonly string[];
}

function focusIsWithin(root: HTMLElement): boolean {
const active = root.ownerDocument.activeElement;
return active === null || active === root.ownerDocument.body || root.contains(active);
}

function firstInDocumentOrder(elements: Iterable<HTMLElement>): HTMLElement | null {
let first: HTMLElement | null = null;
for (const element of elements) {
if (!first || first.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING) {
first = element;
}
}
return first;
}

export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function FlowStep(props, forwardedRef) {
const { render, ids, children, ...otherProps } = props;
const { value, direction, registerActiveStep, unregisterActiveStep } = useFlowContext();
const { value, direction, rootRef, registerActiveStep, unregisterActiveStep } = useFlowContext();
const open = ids.includes(value);
const stepRef = useRef<HTMLDivElement | null>(null);
const activeChildrenRef = useRef(children);
const hasBeenClosed = useRef(false);
const focusTargetsRef = useRef(new Set<HTMLElement>());
const wasOpenRef = useRef(open);

if (open) {
activeChildrenRef.current = children;
Expand All @@ -26,6 +45,7 @@ export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function
}

const { mounted, transitionProps } = useTransition({ open, ref: stepRef });
const runOnEntered = useAnimationsFinished(stepRef, open);

useLayoutEffect(() => {
const element = stepRef.current;
Expand All @@ -37,6 +57,33 @@ export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function
return () => unregisterActiveStep(element);
}, [open, registerActiveStep, unregisterActiveStep]);

useEffect(() => {
const entering = open && !wasOpenRef.current;
wasOpenRef.current = open;
if (!entering) {
return;
}

return runOnEntered(() => {
const target = firstInDocumentOrder(focusTargetsRef.current);
const root = rootRef.current;
if (target && root && focusIsWithin(root)) {
target.focus({ preventScroll: true });
}
});
}, [open, rootRef, runOnEntered]);

const registerFocusTarget = useCallback((element: HTMLElement) => {
focusTargetsRef.current.add(element);
}, []);
const unregisterFocusTarget = useCallback((element: HTMLElement) => {
focusTargetsRef.current.delete(element);
}, []);
const stepContext = useMemo<FlowStepContextValue>(
() => ({ registerFocusTarget, unregisterFocusTarget }),
[registerFocusTarget, unregisterFocusTarget],
);

const effectiveTransitionProps = !hasBeenClosed.current
? { ...transitionProps, 'data-starting-style': undefined, style: undefined }
: transitionProps;
Expand All @@ -52,11 +99,13 @@ export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function
children: open ? children : activeChildrenRef.current,
};

return useRender({
const element = useRender({
defaultTagName: 'div',
enabled: mounted,
render,
ref: [stepRef, forwardedRef],
props: mergeProps<'div'>(defaultProps, otherProps),
});

return <FlowStepContext.Provider value={stepContext}>{element}</FlowStepContext.Provider>;
});
Loading
Loading