diff --git a/.changeset/mosaic-flow-autofocus.md b/.changeset/mosaic-flow-autofocus.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-flow-autofocus.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/flow/README.md b/packages/headless/src/primitives/flow/README.md index d445061ba94..9f42f4cc494 100644 --- a/packages/headless/src/primitives/flow/README.md +++ b/packages/headless/src/primitives/flow/README.md @@ -48,6 +48,25 @@ 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 when it enters: + +```tsx +import { useFlowAutoFocus } from '@clerk/headless/flow'; + +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` 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 | 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..c4dff817fee --- /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 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. + */ +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..ecb18570b79 100644 --- a/packages/headless/src/primitives/flow/flow-step.tsx +++ b/packages/headless/src/primitives/flow/flow-step.tsx @@ -1,23 +1,41 @@ 'use client'; import { inertProps } from '@clerk/shared/inert'; -import React, { useLayoutEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; 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; @@ -37,6 +55,31 @@ export const FlowStep = React.forwardRef(function return () => unregisterActiveStep(element); }, [open, registerActiveStep, unregisterActiveStep]); + useEffect(() => { + const entering = open && !wasOpenRef.current; + wasOpenRef.current = open; + if (!entering) { + return; + } + + const target = firstInDocumentOrder(focusTargetsRef.current); + const root = rootRef.current; + if (target && root && root.contains(target) && focusIsWithin(root)) { + target.focus({ preventScroll: true }); + } + }, [open, rootRef]); + + 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 +95,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..ac0672a2a48 100644 --- a/packages/headless/src/primitives/flow/flow.test.tsx +++ b/packages/headless/src/primitives/flow/flow.test.tsx @@ -1,8 +1,18 @@ import { act, cleanup, render, screen } from '@testing-library/react'; -import { createRef } from 'react'; +import React, { createRef } from 'react'; +import { createPortal } from 'react-dom'; 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 +32,14 @@ function TestFlow({ value, direction = 1, passwordContent = 'Password' }: TestFl data-testid='password-step' > {passwordContent} + OTP + ); @@ -233,4 +245,145 @@ describe('Flow', () => { expect(root).not.toHaveAttribute('data-initial'); offsetHeight.mockRestore(); }); + describe('useFlowAutoFocus', () => { + it('does not focus the initially active step', () => { + render(); + + expect(screen.getByTestId('password-input')).not.toHaveFocus(); + }); + + it('focuses the registered element without scrolling when the step enters', () => { + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + const { rerender } = render(); + + rerender(); + + expect(screen.getByTestId('otp-input')).toHaveFocus(); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + focus.mockRestore(); + }); + + it('leaves focus alone when it is outside the flow', () => { + const { rerender } = render( + <> + + + , + ); + screen.getByTestId('outside').focus(); + + rerender( + <> + + + , + ); + + expect(screen.getByTestId('outside')).toHaveFocus(); + expect(screen.getByTestId('otp-input')).not.toHaveFocus(); + }); + + it('focuses the returning step when navigation reverses mid-transition', () => { + const { rerender } = render(); + + rerender(); + const otpInput = screen.getByTestId('otp-input'); + expect(otpInput).toHaveFocus(); + + rerender( + , + ); + + expect(otpInput).not.toHaveFocus(); + expect(screen.getByTestId('password-input')).toHaveFocus(); + }); + + it('focuses the first marked element in DOM order when several are marked', () => { + const otpStep = ( + + + + + ); + const { rerender } = render( + + Password + {otpStep} + , + ); + + rerender( + + Password + {otpStep} + , + ); + + expect(screen.getByTestId('first')).toHaveFocus(); + }); + + it('skips a marked element that is not rendered', () => { + const otpStep = (showFirst: boolean) => ( + + {showFirst ? : null} + + + ); + const { rerender } = render( + + Password + {otpStep(false)} + , + ); + + rerender( + + Password + {otpStep(false)} + , + ); + + expect(screen.queryByTestId('first')).not.toBeInTheDocument(); + expect(screen.getByTestId('second')).toHaveFocus(); + }); + + it('ignores a marked element portaled outside the root', () => { + const otpStep = ( + {createPortal(, document.body)} + ); + const { rerender } = render( + + Password + {otpStep} + , + ); + + rerender( + + Password + {otpStep} + , + ); + + expect(screen.getByTestId('portaled')).not.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/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'], 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..a5fdc5b1a1d 100644 --- a/packages/ui/src/mosaic/components/otp/otp.tsx +++ b/packages/ui/src/mosaic/components/otp/otp.tsx @@ -12,20 +12,21 @@ 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. */ status?: OtpStatus; } -function OtpSlots({ status }: { status: OtpStatus }) { +function OtpSlots({ status, firstSlotRef }: { status: OtpStatus; firstSlotRef: React.ForwardedRef }) { 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';