From b296ebac1f931d62d92a37898b4938568d528fc0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 11 Sep 2026 22:14:07 -0400 Subject: [PATCH 1/5] feat(ui): move focus to the entering Flow step after its transition settles --- .changeset/mosaic-flow-autofocus.md | 5 + .../headless/src/primitives/flow/README.md | 17 ++ .../src/primitives/flow/flow-context.ts | 3 +- .../src/primitives/flow/flow-root.tsx | 2 +- .../src/primitives/flow/flow-step-context.ts | 35 +++ .../src/primitives/flow/flow-step.tsx | 55 ++++- .../src/primitives/flow/flow.test.tsx | 214 +++++++++++++++++- .../headless/src/primitives/flow/index.ts | 1 + .../headless/src/primitives/flow/parts.ts | 1 + .../ui/src/mosaic/components/flow/index.ts | 1 + .../ui/src/mosaic/components/otp/otp.test.tsx | 14 ++ packages/ui/src/mosaic/components/otp/otp.tsx | 38 ++-- .../__tests__/reverification.view.test.tsx | 32 ++- .../panels/reverification-backup-code.tsx | 2 + .../panels/reverification-help.tsx | 2 + .../panels/reverification-method-picker.tsx | 6 +- .../panels/reverification-otp.tsx | 2 + .../panels/reverification-passkey.tsx | 2 + .../panels/reverification-password.tsx | 2 + packages/ui/src/mosaic/styles/index.ts | 2 +- 20 files changed, 411 insertions(+), 25 deletions(-) create mode 100644 .changeset/mosaic-flow-autofocus.md create mode 100644 packages/headless/src/primitives/flow/flow-step-context.ts diff --git a/.changeset/mosaic-flow-autofocus.md b/.changeset/mosaic-flow-autofocus.md new file mode 100644 index 00000000000..02fe3519450 --- /dev/null +++ b/.changeset/mosaic-flow-autofocus.md @@ -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. diff --git a/packages/headless/src/primitives/flow/README.md b/packages/headless/src/primitives/flow/README.md index d445061ba94..fe02bcdf529 100644 --- a/packages/headless/src/primitives/flow/README.md +++ b/packages/headless/src/primitives/flow/README.md @@ -48,6 +48,23 @@ Multiple ids can select the same step. Moving between those ids updates the exis `Flow.Step` also accepts standard `
` 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 ( + + ); +} +``` + +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 | diff --git a/packages/headless/src/primitives/flow/flow-context.ts b/packages/headless/src/primitives/flow/flow-context.ts index 016f0421696..e01ce527be8 100644 --- a/packages/headless/src/primitives/flow/flow-context.ts +++ b/packages/headless/src/primitives/flow/flow-context.ts @@ -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; registerActiveStep: (element: HTMLElement) => void; unregisterActiveStep: (element: HTMLElement) => void; } diff --git a/packages/headless/src/primitives/flow/flow-root.tsx b/packages/headless/src/primitives/flow/flow-root.tsx index 40cf3cace92..5c6c6e78224 100644 --- a/packages/headless/src/primitives/flow/flow-root.tsx +++ b/packages/headless/src/primitives/flow/flow-root.tsx @@ -55,7 +55,7 @@ export const FlowRoot = React.forwardRef(function }, [activeStepHeight, initial]); const contextValue = useMemo( - () => ({ value, direction, registerActiveStep, unregisterActiveStep }), + () => ({ value, direction, rootRef, registerActiveStep, unregisterActiveStep }), [value, direction, registerActiveStep, unregisterActiveStep], ); diff --git a/packages/headless/src/primitives/flow/flow-step-context.ts b/packages/headless/src/primitives/flow/flow-step-context.ts new file mode 100644 index 00000000000..33e794cd1db --- /dev/null +++ b/packages/headless/src/primitives/flow/flow-step-context.ts @@ -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(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(): RefCallback { + const context = useContext(FlowStepContext); + const elementRef = useRef(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], + ); +} diff --git a/packages/headless/src/primitives/flow/flow-step.tsx b/packages/headless/src/primitives/flow/flow-step.tsx index 608e97e21b8..51f4d651d83 100644 --- a/packages/headless/src/primitives/flow/flow-step.tsx +++ b/packages/headless/src/primitives/flow/flow-step.tsx @@ -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 | 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(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(null); const activeChildrenRef = useRef(children); const hasBeenClosed = useRef(false); + const focusTargetsRef = useRef(new Set()); + const wasOpenRef = useRef(open); if (open) { activeChildrenRef.current = children; @@ -26,6 +45,7 @@ export const FlowStep = React.forwardRef(function } const { mounted, transitionProps } = useTransition({ open, ref: stepRef }); + const runOnEntered = useAnimationsFinished(stepRef, open); useLayoutEffect(() => { const element = stepRef.current; @@ -37,6 +57,33 @@ export const FlowStep = React.forwardRef(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( + () => ({ registerFocusTarget, unregisterFocusTarget }), + [registerFocusTarget, unregisterFocusTarget], + ); + const effectiveTransitionProps = !hasBeenClosed.current ? { ...transitionProps, 'data-starting-style': undefined, style: undefined } : transitionProps; @@ -52,11 +99,13 @@ export const FlowStep = React.forwardRef(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 {element}; }); diff --git a/packages/headless/src/primitives/flow/flow.test.tsx b/packages/headless/src/primitives/flow/flow.test.tsx index 4e042aa2b60..29dadadd0d0 100644 --- a/packages/headless/src/primitives/flow/flow.test.tsx +++ b/packages/headless/src/primitives/flow/flow.test.tsx @@ -1,8 +1,17 @@ import { act, cleanup, render, screen } from '@testing-library/react'; -import { createRef } from 'react'; +import React, { createRef } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Flow } from './index'; +import { Flow, useFlowAutoFocus } from './index'; + +function AutoFocusInput(props: React.ComponentProps<'input'>) { + return ( + + ); +} interface TestFlowProps { value: string; @@ -22,12 +31,14 @@ function TestFlow({ value, direction = 1, passwordContent = 'Password' }: TestFl data-testid='password-step' > {passwordContent} + OTP + ); @@ -233,4 +244,203 @@ describe('Flow', () => { expect(root).not.toHaveAttribute('data-initial'); offsetHeight.mockRestore(); }); + describe('useFlowAutoFocus', () => { + let originalGetAnimations: HTMLElement['getAnimations'] | undefined; + + beforeEach(() => { + originalGetAnimations = HTMLElement.prototype.getAnimations; + }); + + afterEach(() => { + if (originalGetAnimations) { + HTMLElement.prototype.getAnimations = originalGetAnimations; + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations'); + } + }); + + function pendingAnimationOn(testid: string) { + let finishAnimation!: () => void; + const animationFinished = new Promise(resolve => { + finishAnimation = resolve; + }); + let finished = false; + HTMLElement.prototype.getAnimations = function (this: HTMLElement) { + if (finished || this.dataset.testid !== testid) { + return []; + } + return [{ finished: animationFinished }] as unknown as Animation[]; + }; + return async () => { + finished = true; + await act(async () => { + finishAnimation(); + await animationFinished; + }); + }; + } + + it('does not focus the initially active step', () => { + render(); + + expect(screen.getByTestId('password-input')).not.toHaveFocus(); + }); + + it('focuses the registered element once the entering step settles', async () => { + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + const finish = pendingAnimationOn('otp-step'); + const { rerender } = render(); + + rerender(); + const input = screen.getByTestId('otp-input'); + expect(input).not.toHaveFocus(); + + act(() => flushRaf()); + await act(async () => {}); + expect(input).not.toHaveFocus(); + + await finish(); + + expect(input).toHaveFocus(); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + focus.mockRestore(); + }); + + it('focuses immediately after the starting frame when the step has no animations', async () => { + HTMLElement.prototype.getAnimations = () => []; + const { rerender } = render(); + + rerender(); + expect(screen.getByTestId('otp-input')).not.toHaveFocus(); + + act(() => flushRaf()); + await act(async () => {}); + + expect(screen.getByTestId('otp-input')).toHaveFocus(); + }); + + it('leaves focus alone when it is outside the flow', async () => { + HTMLElement.prototype.getAnimations = () => []; + const { rerender } = render( + <> + + + , + ); + screen.getByTestId('outside').focus(); + + rerender( + <> + + + , + ); + act(() => flushRaf()); + await act(async () => {}); + + expect(screen.getByTestId('outside')).toHaveFocus(); + expect(screen.getByTestId('otp-input')).not.toHaveFocus(); + }); + + it('abandons a pending focus when the entering step closes before it settles', async () => { + const finish = pendingAnimationOn('otp-step'); + const { rerender } = render(); + + rerender(); + const otpInput = screen.getByTestId('otp-input'); + + rerender( + , + ); + act(() => flushRaf()); + await act(async () => {}); + await finish(); + + expect(otpInput).not.toHaveFocus(); + expect(screen.getByTestId('password-input')).toHaveFocus(); + }); + + it('focuses the first marked element in DOM order when several are marked', async () => { + HTMLElement.prototype.getAnimations = () => []; + const otpStep = ( + + + + + ); + const { rerender } = render( + + Password + {otpStep} + , + ); + + rerender( + + Password + {otpStep} + , + ); + act(() => flushRaf()); + await act(async () => {}); + + expect(screen.getByTestId('first')).toHaveFocus(); + }); + + it('focuses whichever marked element is still mounted when the step settles', async () => { + const finish = pendingAnimationOn('otp-step'); + const otpStep = (showFirst: boolean) => ( + + {showFirst ? : null} + + + ); + const { rerender } = render( + + Password + {otpStep(true)} + , + ); + + rerender( + + Password + {otpStep(true)} + , + ); + rerender( + + Password + {otpStep(false)} + , + ); + act(() => flushRaf()); + await act(async () => {}); + await finish(); + + expect(screen.queryByTestId('first')).not.toBeInTheDocument(); + expect(screen.getByTestId('second')).toHaveFocus(); + }); + + it('returns a no-op ref outside a step', () => { + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('lone-input')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/headless/src/primitives/flow/index.ts b/packages/headless/src/primitives/flow/index.ts index 53346ced68d..15479cf7891 100644 --- a/packages/headless/src/primitives/flow/index.ts +++ b/packages/headless/src/primitives/flow/index.ts @@ -1,3 +1,4 @@ export * as Flow from './parts'; +export { useFlowAutoFocus } from './flow-step-context'; 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 index f79aa92bcb2..abac8842513 100644 --- a/packages/headless/src/primitives/flow/parts.ts +++ b/packages/headless/src/primitives/flow/parts.ts @@ -1,3 +1,4 @@ export { type FlowRootProps, FlowRoot as Root } from './flow-root'; export { type FlowStepProps, FlowStep as Step } from './flow-step'; +export { useFlowAutoFocus } from './flow-step-context'; export type { FlowDirection } from './flow-context'; diff --git a/packages/ui/src/mosaic/components/flow/index.ts b/packages/ui/src/mosaic/components/flow/index.ts index 299af5dcfe4..ddac11c0ef0 100644 --- a/packages/ui/src/mosaic/components/flow/index.ts +++ b/packages/ui/src/mosaic/components/flow/index.ts @@ -1,2 +1,3 @@ +export { useFlowAutoFocus } from '@clerk/headless/flow'; export { Flow } from './flow'; export type { FlowDirection, FlowRootProps, FlowStepProps } from './flow'; diff --git a/packages/ui/src/mosaic/components/otp/otp.test.tsx b/packages/ui/src/mosaic/components/otp/otp.test.tsx index b2a8fd1464c..1530e1797a4 100644 --- a/packages/ui/src/mosaic/components/otp/otp.test.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.test.tsx @@ -200,4 +200,18 @@ describe('Mosaic Otp', () => { ); expect(document.querySelector('input[name="code"]')).toHaveValue('123'); }); + + it('forwards its ref to the first slot', () => { + const ref = React.createRef(); + + render( + , + ); + + expect(ref.current).toBe(slots()[0]); + }); }); diff --git a/packages/ui/src/mosaic/components/otp/otp.tsx b/packages/ui/src/mosaic/components/otp/otp.tsx index 02b82c4b412..cca403d5ef0 100644 --- a/packages/ui/src/mosaic/components/otp/otp.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.tsx @@ -19,13 +19,14 @@ export interface OtpProps extends Omit }) { const { slots, disabled } = Primitive.useOtp(); return slots.map(slot => ( (function MosaicOtp( + { + length = 6, + status: statusProp, + disabled: disabledProp, + required: requiredProp, + id, + 'aria-invalid': ariaInvalidProp, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + ...rest + }, + ref, +) { const fieldProps = useOptionalFieldControlProps({ id, disabled: disabledProp, @@ -81,7 +86,10 @@ export function Otp({ aria-labelledby={fieldProps?.['aria-labelledby'] ?? ariaLabelledBy} aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy} > - + ); -} +}); diff --git a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx index 17f1b01bf8a..d9439dedfc7 100644 --- a/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx +++ b/packages/ui/src/mosaic/features/reverification/__tests__/reverification.view.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; @@ -68,6 +68,36 @@ describe('ReverificationView', () => { expect(otpStep?.style.getPropertyValue('--cl-flow-transition-direction')).toBe('-1'); }); + it('moves focus to the entering step once it settles', async () => { + const { rerender } = renderView(); + + expect(screen.getByLabelText('Password')).not.toHaveFocus(); + + rerender( + + + , + ); + await act(async () => {}); + + expect(screen.getAllByRole('textbox')[0]).toHaveFocus(); + + rerender( + + + , + ); + await act(async () => {}); + + expect(screen.getByRole('button', { name: 'Continue with your password' })).toHaveFocus(); + }); + it('keeps a disabled resend control during the cooldown', () => { renderView({ step: 'otp', diff --git a/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx index 3bf52f7a921..2b0206cd32e 100644 --- a/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-backup-code.tsx @@ -4,6 +4,7 @@ import { useId } from 'react'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { Field } from '../../../components/field'; +import { useFlowAutoFocus } from '../../../components/flow'; import { Input } from '../../../components/input'; export interface ReverificationBackupCodeMessages { @@ -61,6 +62,7 @@ export function ReverificationBackupCode({ > {messages.fieldLabel} ()} name='backupCode' type='text' autoComplete='off' diff --git a/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx index 7d4a2577fb3..94c2347f47b 100644 --- a/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-help.tsx @@ -1,5 +1,6 @@ import { Button } from '../../../components/button'; import { Card } from '../../../components/card'; +import { useFlowAutoFocus } from '../../../components/flow'; export interface ReverificationHelpMessages { title: string; @@ -23,6 +24,7 @@ export function ReverificationHelp({ messages, onEmailSupport, onBack }: Reverif ) : null} ()} type='button' fullWidth isPending={isPending} diff --git a/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx b/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx index 2a569903e45..0a31af1a225 100644 --- a/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx +++ b/packages/ui/src/mosaic/features/reverification/panels/reverification-password.tsx @@ -4,6 +4,7 @@ import { useId } from 'react'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { Field } from '../../../components/field'; +import { useFlowAutoFocus } from '../../../components/flow'; import { Input } from '../../../components/input'; export interface ReverificationPasswordMessages { @@ -62,6 +63,7 @@ export function ReverificationPassword({ > {messages.fieldLabel} ()} name='password' type='password' autoComplete='current-password' diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 6182d413050..78a2448221c 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -54,7 +54,7 @@ export type { } from '../components/dialog'; export { Field } from '../components/field'; export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps } from '../components/field'; -export { Flow } from '../components/flow'; +export { Flow, useFlowAutoFocus } from '../components/flow'; export type { FlowRootProps, FlowStepProps } from '../components/flow'; export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; From b231d5ca8065980bc8ea21346fe4dd93c85962dd Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 14 Sep 2026 12:34:56 -0400 Subject: [PATCH 2/5] feat(ui): clip the Flow viewport so focus cannot scroll it --- packages/ui/src/mosaic/components/flow/flow.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/flow/flow.styles.ts b/packages/ui/src/mosaic/components/flow/flow.styles.ts index a7e202b8217..60e634d6dd0 100644 --- a/packages/ui/src/mosaic/components/flow/flow.styles.ts +++ b/packages/ui/src/mosaic/components/flow/flow.styles.ts @@ -4,7 +4,7 @@ import { durationVars, easingVars } from '../../tokens.stylex'; export const styles = stylex.create({ root: { - overflow: 'hidden', + overflow: 'clip', position: 'relative', transitionDuration: { default: durationVars['--cl-duration-slow'], From ec300909ba15e81b45200d775f62de6c8e4a2a1c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 14 Sep 2026 14:22:42 -0400 Subject: [PATCH 3/5] fix(ui): omit the root div ref from Mosaic OtpProps so the forwarded input ref type-checks --- packages/ui/src/mosaic/components/otp/otp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/otp/otp.tsx b/packages/ui/src/mosaic/components/otp/otp.tsx index cca403d5ef0..a5fdc5b1a1d 100644 --- a/packages/ui/src/mosaic/components/otp/otp.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.tsx @@ -12,7 +12,7 @@ import { styles } from './otp.styles'; /** How the entered code currently reads back to the user. */ export type OtpStatus = 'neutral' | 'success' | 'error'; -export interface OtpProps extends Omit { +export interface OtpProps extends Omit { /** The number of boxes in the code. @default 6 */ length?: number; /** Colours every slot for the verification outcome. Defaults to the enclosing `Field`'s validity. */ From f52599418699424d2e61455c713bb3d4d1eef599 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 14 Sep 2026 15:02:29 -0400 Subject: [PATCH 4/5] refactor(ui): focus the entering Flow step immediately now that the viewport is clipped --- .changeset/mosaic-flow-autofocus.md | 2 +- .../headless/src/primitives/flow/README.md | 4 +- .../src/primitives/flow/flow-step-context.ts | 2 +- .../src/primitives/flow/flow-step.tsx | 16 ++- .../src/primitives/flow/flow.test.tsx | 97 ++----------------- 5 files changed, 19 insertions(+), 102 deletions(-) diff --git a/.changeset/mosaic-flow-autofocus.md b/.changeset/mosaic-flow-autofocus.md index 02fe3519450..179793bb6f9 100644 --- a/.changeset/mosaic-flow-autofocus.md +++ b/.changeset/mosaic-flow-autofocus.md @@ -2,4 +2,4 @@ '@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. +Reverification now moves focus to the entering step's primary control, so the next input or action is ready for keyboard and screen reader users without disrupting the slide animation. diff --git a/packages/headless/src/primitives/flow/README.md b/packages/headless/src/primitives/flow/README.md index fe02bcdf529..40ee91b47a0 100644 --- a/packages/headless/src/primitives/flow/README.md +++ b/packages/headless/src/primitives/flow/README.md @@ -50,7 +50,7 @@ Multiple ids can select the same step. Moving between those ids updates the exis ## Focus -`useFlowAutoFocus()` returns a ref. Attach it to the element a step should focus once its enter transition settles: +`useFlowAutoFocus()` returns a ref. Attach it to the element a step should focus when it enters: ```tsx function PasswordView() { @@ -63,7 +63,7 @@ function PasswordView() { } ``` -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. +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` as soon as the step enters, and only when focus is currently on the body or inside `Flow.Root`, so it never steals from elsewhere on the page. Style `Flow.Root` with `overflow: clip` rather than `hidden` so focusing an element that is still sliding in cannot scroll the viewport. When several mounted elements are marked, the first in DOM order is focused. Outside a `Flow.Step` the hook returns a no-op ref. ## Transition attributes diff --git a/packages/headless/src/primitives/flow/flow-step-context.ts b/packages/headless/src/primitives/flow/flow-step-context.ts index 33e794cd1db..c4dff817fee 100644 --- a/packages/headless/src/primitives/flow/flow-step-context.ts +++ b/packages/headless/src/primitives/flow/flow-step-context.ts @@ -10,7 +10,7 @@ export interface FlowStepContextValue { export const FlowStepContext = createContext(null); /** - * Marks an element as the one to focus after the enclosing `Flow.Step` finishes entering. + * Marks an element as the one to focus when the enclosing `Flow.Step` enters. * 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. */ diff --git a/packages/headless/src/primitives/flow/flow-step.tsx b/packages/headless/src/primitives/flow/flow-step.tsx index 51f4d651d83..7fdfe240a11 100644 --- a/packages/headless/src/primitives/flow/flow-step.tsx +++ b/packages/headless/src/primitives/flow/flow-step.tsx @@ -3,7 +3,6 @@ import { inertProps } from '@clerk/shared/inert'; 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'; @@ -45,7 +44,6 @@ export const FlowStep = React.forwardRef(function } const { mounted, transitionProps } = useTransition({ open, ref: stepRef }); - const runOnEntered = useAnimationsFinished(stepRef, open); useLayoutEffect(() => { const element = stepRef.current; @@ -64,14 +62,12 @@ export const FlowStep = React.forwardRef(function 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 target = firstInDocumentOrder(focusTargetsRef.current); + const root = rootRef.current; + if (target && root && focusIsWithin(root)) { + target.focus({ preventScroll: true }); + } + }, [open, rootRef]); const registerFocusTarget = useCallback((element: HTMLElement) => { focusTargetsRef.current.add(element); diff --git a/packages/headless/src/primitives/flow/flow.test.tsx b/packages/headless/src/primitives/flow/flow.test.tsx index 29dadadd0d0..a037bcce442 100644 --- a/packages/headless/src/primitives/flow/flow.test.tsx +++ b/packages/headless/src/primitives/flow/flow.test.tsx @@ -245,82 +245,24 @@ describe('Flow', () => { offsetHeight.mockRestore(); }); describe('useFlowAutoFocus', () => { - let originalGetAnimations: HTMLElement['getAnimations'] | undefined; - - beforeEach(() => { - originalGetAnimations = HTMLElement.prototype.getAnimations; - }); - - afterEach(() => { - if (originalGetAnimations) { - HTMLElement.prototype.getAnimations = originalGetAnimations; - } else { - Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations'); - } - }); - - function pendingAnimationOn(testid: string) { - let finishAnimation!: () => void; - const animationFinished = new Promise(resolve => { - finishAnimation = resolve; - }); - let finished = false; - HTMLElement.prototype.getAnimations = function (this: HTMLElement) { - if (finished || this.dataset.testid !== testid) { - return []; - } - return [{ finished: animationFinished }] as unknown as Animation[]; - }; - return async () => { - finished = true; - await act(async () => { - finishAnimation(); - await animationFinished; - }); - }; - } - it('does not focus the initially active step', () => { render(); expect(screen.getByTestId('password-input')).not.toHaveFocus(); }); - it('focuses the registered element once the entering step settles', async () => { + it('focuses the registered element without scrolling when the step enters', () => { const focus = vi.spyOn(HTMLElement.prototype, 'focus'); - const finish = pendingAnimationOn('otp-step'); const { rerender } = render(); rerender(); - const input = screen.getByTestId('otp-input'); - expect(input).not.toHaveFocus(); - - act(() => flushRaf()); - await act(async () => {}); - expect(input).not.toHaveFocus(); - await finish(); - - expect(input).toHaveFocus(); + expect(screen.getByTestId('otp-input')).toHaveFocus(); expect(focus).toHaveBeenCalledWith({ preventScroll: true }); focus.mockRestore(); }); - it('focuses immediately after the starting frame when the step has no animations', async () => { - HTMLElement.prototype.getAnimations = () => []; - const { rerender } = render(); - - rerender(); - expect(screen.getByTestId('otp-input')).not.toHaveFocus(); - - act(() => flushRaf()); - await act(async () => {}); - - expect(screen.getByTestId('otp-input')).toHaveFocus(); - }); - - it('leaves focus alone when it is outside the flow', async () => { - HTMLElement.prototype.getAnimations = () => []; + it('leaves focus alone when it is outside the flow', () => { const { rerender } = render( <>