From 9bb12586eee789378c4d2a42b02f334bfbb2e2e4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 2 Jul 2026 15:19:24 -0400 Subject: [PATCH 1/6] feat(headless): add headless OTP input primitive --- .changeset/headless-otp.md | 2 + packages/headless/README.md | 1 + packages/headless/package.json | 4 + .../headless/src/primitives/otp/README.md | 155 ++++++++ packages/headless/src/primitives/otp/index.ts | 3 + .../src/primitives/otp/otp-context.ts | 72 ++++ .../headless/src/primitives/otp/otp-input.tsx | 182 +++++++++ .../headless/src/primitives/otp/otp-root.tsx | 216 ++++++++++ .../headless/src/primitives/otp/otp-utils.ts | 41 ++ .../headless/src/primitives/otp/otp.test.tsx | 370 ++++++++++++++++++ packages/headless/src/primitives/otp/parts.ts | 4 + packages/headless/vite.config.ts | 1 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 3 + packages/swingset/src/stories/otp.mdx | 164 ++++++++ packages/swingset/src/stories/otp.stories.tsx | 38 ++ 16 files changed, 1257 insertions(+) create mode 100644 .changeset/headless-otp.md create mode 100644 packages/headless/src/primitives/otp/README.md create mode 100644 packages/headless/src/primitives/otp/index.ts create mode 100644 packages/headless/src/primitives/otp/otp-context.ts create mode 100644 packages/headless/src/primitives/otp/otp-input.tsx create mode 100644 packages/headless/src/primitives/otp/otp-root.tsx create mode 100644 packages/headless/src/primitives/otp/otp-utils.ts create mode 100644 packages/headless/src/primitives/otp/otp.test.tsx create mode 100644 packages/headless/src/primitives/otp/parts.ts create mode 100644 packages/swingset/src/stories/otp.mdx create mode 100644 packages/swingset/src/stories/otp.stories.tsx diff --git a/.changeset/headless-otp.md b/.changeset/headless-otp.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/headless-otp.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/README.md b/packages/headless/README.md index 09b25b6bf7d..ef20f91d068 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -13,6 +13,7 @@ This package is **internal** (`private: true`) and consumed by `@clerk/ui`. It e | Dialog | `@clerk/headless/dialog` | Modal dialog with focus trapping and scroll lock | | FileUpload | `@clerk/headless/file-upload` | File picker + drag-and-drop upload with image previews | | Menu | `@clerk/headless/menu` | Dropdown and nested context menus with safe hover zones | +| OTP | `@clerk/headless/otp` | One-time-password / PIN input split into per-character slots | | Popover | `@clerk/headless/popover` | Non-modal floating content triggered by click | | Select | `@clerk/headless/select` | Dropdown select with typeahead and keyboard navigation | | Tabs | `@clerk/headless/tabs` | Tab navigation with animated indicator | diff --git a/packages/headless/package.json b/packages/headless/package.json index 7a4bb60fffb..f35ad961a10 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -45,6 +45,10 @@ "import": "./dist/primitives/file-upload/index.js", "types": "./dist/primitives/file-upload/index.d.ts" }, + "./otp": { + "import": "./dist/primitives/otp/index.js", + "types": "./dist/primitives/otp/index.d.ts" + }, "./hooks": { "import": "./dist/hooks/index.js", "types": "./dist/hooks/index.d.ts" diff --git a/packages/headless/src/primitives/otp/README.md b/packages/headless/src/primitives/otp/README.md new file mode 100644 index 00000000000..ff5377c2b37 --- /dev/null +++ b/packages/headless/src/primitives/otp/README.md @@ -0,0 +1,155 @@ +# OTP + +A headless one-time-password (OTP / PIN) input. The value is a single string; each character is +rendered by its own `` slot, so you style each box yourself. Typing advances focus, +`Backspace` walks back, arrows/Home/End move between slots, and pasting a full code distributes it +across the slots. Supports controlled/uncontrolled value, a character `pattern`, masking, a +`disabled` state, and optional `
` submission via `name`. + +## When to Use + +- SMS / email verification code entry. +- Authenticator (TOTP) code entry. +- Any fixed-length PIN or code split into per-character boxes. + +Each slot is a real, individually styleable `` — the primitive emits zero styles and injects +no global CSS. Everything is driven by `data-cl-*` attributes. + +## Usage + +```tsx +import { OTP } from '@clerk/headless/otp'; + +function VerifyCode() { + return ( + submit(code)} + > + + + ); +} + +// Render one Input per slot from the live slot list. +function Slots() { + const { slots } = OTP.useOTP(); + return slots.map(slot => ( + + )); +} +``` + +`useOTP()` returns `{ value, length, disabled, complete, slots, activeIndex, clear, focus }`. Each +entry in `slots` is `{ index, char, isActive, isFilled }`, so you can render decorations (a caret, a +separator) around the boxes. It must be called inside ``. + +### Controlled + +```tsx +const [code, setCode] = useState(''); + + + +; +``` + +### Inside a form + +Pass `name` to submit the combined value through a hidden input: + +```tsx + + + + + + +``` + +### Masked / alphanumeric + +```tsx + + + +``` + +## Parts + +| Part | Default Element | Description | +| ----------- | --------------- | ----------------------------------------------------------- | +| `OTP.Root` | `
` | Owns the value + focus, provides context, submits the value | +| `OTP.Input` | `` | A single character slot (render one per `slots` entry) | + +`OTP.useOTP()` is a hook (not a component) for reading the value and driving custom UI. It must be +called inside `OTP.Root`. + +## Props + +### `OTP.Root` + +| Prop | Type | Default | Description | +| --------------- | ---------------------------------------- | ----------- | --------------------------------------------------- | +| `length` | `number` | — | Number of slots (required) | +| `value` | `string` | — | Controlled value | +| `defaultValue` | `string` | `''` | Initial value (uncontrolled) | +| `onValueChange` | `(value: string) => void` | — | Called with the full value on every change | +| `onComplete` | `(value: string) => void` | — | Called once every slot is filled | +| `pattern` | `'numeric' \| 'alpha' \| 'alphanumeric'` | `'numeric'` | Allowed characters; others are stripped | +| `mask` | `boolean` | `false` | Render slots as password inputs | +| `name` | `string` | — | Submit the value via a hidden input under this name | +| `disabled` | `boolean` | `false` | Disable every slot | + +### `OTP.Input` + +| Prop | Type | Default | Description | +| ------- | -------- | ------- | ----------------------------------------------------- | +| `index` | `number` | — | The slot's `0`-based position (from `useOTP().slots`) | + +Both parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their +default element. + +## Keyboard + +| Key | Behavior | +| ------------------------ | ----------------------------------------------------------------- | +| character | Fills the slot and moves focus to the next slot | +| `Backspace` | Clears the slot, or the previous slot when already empty | +| `Delete` | Clears the current slot | +| `ArrowLeft`/`ArrowRight` | Moves focus to the previous / next slot | +| `Home`/`End` | Moves focus to the first / last-entered slot | +| paste | Distributes the pasted code across the slots from the focus point | + +## Data Attributes + +| Attribute | Applies To | Description | +| ------------------ | ----------- | --------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (`"otp-root"`, `"otp-input"`) | +| `data-cl-empty` | Root | Present when no character has been entered | +| `data-cl-complete` | Root | Present when every slot is filled | +| `data-cl-disabled` | Root, Input | Present when disabled | +| `data-cl-active` | Input | Present when the slot holds focus | +| `data-cl-filled` | Input | Present when the slot holds a character | + +## ARIA + +- `Root` is a `role="group"`; give it an `aria-label` (or `aria-labelledby`) describing the code. +- Each `Input` gets a default `aria-label` of `"Character N of M"`, overridable per input. +- Slots use a roving tab index: `Tab` enters the group at the next empty slot and leaves in one step. +- When `name` is set, the hidden form input is `aria-hidden` and removed from the tab order. diff --git a/packages/headless/src/primitives/otp/index.ts b/packages/headless/src/primitives/otp/index.ts new file mode 100644 index 00000000000..d3cd3bc19fd --- /dev/null +++ b/packages/headless/src/primitives/otp/index.ts @@ -0,0 +1,3 @@ +export * as OTP from './parts'; + +export type { OTPInputProps, OTPPattern, OTPProps, OTPSlot } from './parts'; diff --git a/packages/headless/src/primitives/otp/otp-context.ts b/packages/headless/src/primitives/otp/otp-context.ts new file mode 100644 index 00000000000..58ab69c5e08 --- /dev/null +++ b/packages/headless/src/primitives/otp/otp-context.ts @@ -0,0 +1,72 @@ +import { createContext, useContext } from 'react'; + +import type { OTPPattern } from './otp-utils'; + +/** A single OTP slot, as read through {@link useOTP}. */ +export interface OTPSlot { + /** The slot's position, `0`-based. */ + index: number; + /** The character in this slot, or `''` when empty. */ + char: string; + /** Whether this slot currently holds the focus. */ + isActive: boolean; + /** Whether this slot holds a character. */ + isFilled: boolean; +} + +export interface OTPContextValue { + /** The current OTP value. */ + value: string; + /** The number of slots. */ + length: number; + /** Whether the whole field is disabled. */ + disabled: boolean; + /** Whether every slot is filled. */ + complete: boolean; + /** The allowed character set. */ + pattern: OTPPattern; + /** Render each slot as a masked (password) input. */ + mask: boolean; + /** One descriptor per slot, in order. */ + slots: OTPSlot[]; + /** The index of the currently focused slot, or `null` when unfocused. */ + activeIndex: number | null; + /** Clear the value and focus the first slot. */ + clear: () => void; + /** Focus the slot at `index` (clamped to range). */ + focus: (index: number) => void; + // --- internal wiring used by --- + /** Register/unregister a slot input's element by index. */ + registerInput: (index: number, element: HTMLInputElement | null) => void; + /** Propose a new full value; it is sanitized and clamped before commit. */ + setValue: (next: string) => void; + /** Queue a focus move to run once `value` reflects `afterValue`. */ + queueFocus: (index: number, afterValue: string) => void; + /** Report that slot `index` gained focus. */ + onSlotFocus: (index: number) => void; + /** Report that a slot lost focus (blurred outside the field). */ + onSlotBlur: () => void; +} + +export const OTPContext = createContext(null); + +export function useOTPContext(): OTPContextValue { + const ctx = useContext(OTPContext); + if (!ctx) { + throw new Error('OTP compound components must be used within '); + } + return ctx; +} + +/** + * Reads the current OTP state and actions. Use it to render one + * `` per slot, show a completion state, or drive a custom clear + * button. Must be called inside ``. + */ +export function useOTP(): Pick< + OTPContextValue, + 'value' | 'length' | 'disabled' | 'complete' | 'slots' | 'activeIndex' | 'clear' | 'focus' +> { + const { value, length, disabled, complete, slots, activeIndex, clear, focus } = useOTPContext(); + return { value, length, disabled, complete, slots, activeIndex, clear, focus }; +} diff --git a/packages/headless/src/primitives/otp/otp-input.tsx b/packages/headless/src/primitives/otp/otp-input.tsx new file mode 100644 index 00000000000..ede4159f598 --- /dev/null +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { type Ref, useCallback } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useOTPContext } from './otp-context'; +import { inputModeForPattern, removeAt, replaceAt, sanitize } from './otp-utils'; + +export interface OTPInputProps extends ComponentProps<'input'> { + /** This slot's position, `0`-based (typically from `useOTP().slots`). */ + index: number; +} + +export function OTPInput(props: OTPInputProps) { + const { render, index, ref: forwardedRef, ...otherProps } = props; + const { + value, + length, + disabled, + pattern, + mask, + activeIndex, + setValue, + queueFocus, + focus, + registerInput, + onSlotFocus, + onSlotBlur, + } = useOTPContext(); + + const char = value[index] ?? ''; + + // Compose our slot registration with any ref the consumer forwards. + const setRef = useCallback<(element: HTMLInputElement | null) => void>( + element => { + registerInput(index, element); + if (typeof forwardedRef === 'function') { + forwardedRef(element); + } else if (forwardedRef) { + (forwardedRef as { current: HTMLInputElement | null }).current = element; + } + }, + [registerInput, index, forwardedRef], + ); + + // Roving tab order: the focused slot is the tab stop, or the next empty slot + // when the field is unfocused, so Tab enters and leaves the group once. + const tabStop = activeIndex ?? Math.min(value.length, length - 1); + + const state = { active: activeIndex === index, filled: char !== '', disabled }; + + const defaultProps: Record = { + 'data-cl-slot': 'otp-input', + ref: setRef as Ref, + value: char, + type: mask ? 'password' : 'text', + inputMode: inputModeForPattern(pattern), + // Only the first slot advertises autofill so browsers drop the whole SMS + // code into it; its onChange then spills the characters across the slots. + autoComplete: index === 0 ? 'one-time-code' : 'off', + autoCorrect: 'off', + spellCheck: false, + // The first slot accepts the full code (autofill/paste); the rest are + // single characters handled by onChange/onPaste. + maxLength: index === 0 ? length : 1, + tabIndex: tabStop === index ? 0 : -1, + disabled, + 'aria-label': `Character ${index + 1} of ${length}`, + onMouseDown: (event: React.MouseEvent) => { + if (disabled) { + return; + } + // Take focus ourselves so a click always selects the slot's character + // (typing replaces it) instead of dropping a caret mid-value. + event.preventDefault(); + focus(index); + }, + onFocus: (event: React.FocusEvent) => { + onSlotFocus(index); + event.currentTarget.select(); + }, + onBlur: () => { + onSlotBlur(); + }, + onChange: (event: React.ChangeEvent) => { + if (disabled) { + return; + } + const raw = event.currentTarget.value; + + // Empty means the character was removed (mobile delete, cut, clear). + if (raw === '') { + setValue(removeAt(value, index)); + return; + } + + const inserted = sanitize(raw, pattern, length); + if (inserted === '') { + // Only disallowed characters were typed; restore the stored value. + event.currentTarget.value = char; + return; + } + + const next = sanitize(replaceAt(value, index, inserted), pattern, length); + setValue(next); + queueFocus(Math.min(index + inserted.length, length - 1), next); + }, + onKeyDown: (event: React.KeyboardEvent) => { + if (disabled) { + return; + } + + switch (event.key) { + case 'Backspace': { + event.preventDefault(); + const targetIndex = Math.max(0, index - 1); + // Delete this slot's character if it has one, otherwise the previous + // slot's — either way focus lands on the previous slot. + const deleteIndex = char === '' ? targetIndex : index; + const next = removeAt(value, deleteIndex); + setValue(next); + queueFocus(targetIndex, next); + break; + } + case 'Delete': { + event.preventDefault(); + const next = removeAt(value, index); + setValue(next); + queueFocus(index, next); + break; + } + case 'ArrowLeft': { + event.preventDefault(); + focus(index - 1); + break; + } + case 'ArrowRight': { + event.preventDefault(); + focus(index + 1); + break; + } + case 'Home': { + event.preventDefault(); + focus(0); + break; + } + case 'End': { + event.preventDefault(); + focus(Math.min(value.length, length - 1)); + break; + } + default: + break; + } + }, + onPaste: (event: React.ClipboardEvent) => { + if (disabled) { + return; + } + event.preventDefault(); + const inserted = sanitize(event.clipboardData?.getData('text') ?? '', pattern, length); + if (inserted === '') { + return; + } + const next = sanitize(replaceAt(value, index, inserted), pattern, length); + setValue(next); + queueFocus(Math.min(index + inserted.length, length - 1), next); + }, + }; + + return renderElement({ + defaultTagName: 'input', + render, + state, + stateAttributesMapping: { + active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), + filled: (v: boolean) => (v ? { 'data-cl-filled': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'input'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/otp/otp-root.tsx b/packages/headless/src/primitives/otp/otp-root.tsx new file mode 100644 index 00000000000..c681664f7d7 --- /dev/null +++ b/packages/headless/src/primitives/otp/otp-root.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { OTPContext, type OTPContextValue, type OTPSlot } from './otp-context'; +import { inputModeForPattern, type OTPPattern, sanitize } from './otp-utils'; + +export interface OTPProps extends Omit, 'value' | 'defaultValue' | 'onChange'> { + /** The number of slots (characters) in the code. */ + length: number; + /** Controlled value. */ + value?: string; + /** Initial value (uncontrolled). @default '' */ + defaultValue?: string; + /** Called with the full value whenever it changes. */ + onValueChange?: (value: string) => void; + /** Called with the value once every slot is filled. */ + onComplete?: (value: string) => void; + /** Allowed character set; other characters are stripped. @default 'numeric' */ + pattern?: OTPPattern; + /** Render slots as masked (password) inputs. @default false */ + mask?: boolean; + /** + * Submits the value as a hidden input under this `name`, so the field works + * inside a plain `
`. Omit for controlled-only use. + */ + name?: string; + /** Disable every slot and the picker. @default false */ + disabled?: boolean; + children: ReactNode; +} + +// The form value rides on a hidden input; the visible slots carry no name so +// only the combined value is submitted. +const visuallyHiddenInputStyle: CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +}; + +export function OTPRoot(props: OTPProps) { + const { + render, + length, + value: valueProp, + defaultValue = '', + onValueChange, + onComplete, + pattern = 'numeric', + mask = false, + name, + disabled = false, + children, + ...otherProps + } = props; + + const [rawValue, setRawValue] = useControllableState(valueProp, defaultValue, onValueChange); + // Never let out-of-range or disallowed characters reach the slots, even from a + // controlled/default value the consumer passes in. + const value = sanitize(rawValue, pattern, length); + + const inputRefs = useRef>([]); + const pendingFocusRef = useRef<{ index: number; afterValue: string } | null>(null); + const previousValueRef = useRef(value); + const [activeIndex, setActiveIndex] = useState(null); + + const registerInput = useCallback((index: number, element: HTMLInputElement | null) => { + inputRefs.current[index] = element; + }, []); + + const focus = useCallback( + (index: number) => { + const clamped = Math.min(Math.max(index, 0), Math.max(length - 1, 0)); + const target = inputRefs.current[clamped]; + target?.focus(); + target?.select(); + }, + [length], + ); + + const setValue = useCallback( + (next: string) => { + setRawValue(sanitize(next, pattern, length)); + }, + [setRawValue, pattern, length], + ); + + const queueFocus = useCallback((index: number, afterValue: string) => { + pendingFocusRef.current = { index, afterValue }; + }, []); + + const clear = useCallback(() => { + setValue(''); + queueFocus(0, ''); + }, [setValue, queueFocus]); + + const onSlotFocus = useCallback((index: number) => setActiveIndex(index), []); + const onSlotBlur = useCallback(() => setActiveIndex(null), []); + + // Apply a queued focus move once the value it was queued against is live, so + // focus lands after React has re-rendered the slots with the new characters. + useEffect(() => { + const pending = pendingFocusRef.current; + if (pending && pending.afterValue === value) { + pendingFocusRef.current = null; + focus(pending.index); + } + }, [value, focus]); + + // Fire `onComplete` on the transition into a full value (typing the last + // character or pasting a full code), not on every keystroke while full. + useEffect(() => { + if (previousValueRef.current.length < length && value.length === length) { + onComplete?.(value); + } + previousValueRef.current = value; + }, [value, length, onComplete]); + + const complete = value.length === length; + + const slots = useMemo( + () => + Array.from({ length }, (_, index) => { + const char = value[index] ?? ''; + return { index, char, isActive: activeIndex === index, isFilled: char !== '' }; + }), + [length, value, activeIndex], + ); + + const contextValue = useMemo( + () => ({ + value, + length, + disabled, + complete, + pattern, + mask, + slots, + activeIndex, + clear, + focus, + registerInput, + setValue, + queueFocus, + onSlotFocus, + onSlotBlur, + }), + [ + value, + length, + disabled, + complete, + pattern, + mask, + slots, + activeIndex, + clear, + focus, + registerInput, + setValue, + queueFocus, + onSlotFocus, + onSlotBlur, + ], + ); + + const state = { disabled, complete, empty: value.length === 0 }; + + const defaultProps: Record = { + 'data-cl-slot': 'otp-root', + role: 'group', + children: ( + <> + {children} + {name ? ( + + ) : null} + + ), + }; + + return ( + + {renderElement({ + defaultTagName: 'div', + render, + state, + stateAttributesMapping: { + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + complete: (v: boolean) => (v ? { 'data-cl-complete': '' } : null), + empty: (v: boolean) => (v ? { 'data-cl-empty': '' } : null), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + })} + + ); +} diff --git a/packages/headless/src/primitives/otp/otp-utils.ts b/packages/headless/src/primitives/otp/otp-utils.ts new file mode 100644 index 00000000000..b7835b794a5 --- /dev/null +++ b/packages/headless/src/primitives/otp/otp-utils.ts @@ -0,0 +1,41 @@ +/** The character set an OTP value is allowed to contain. */ +export type OTPPattern = 'numeric' | 'alpha' | 'alphanumeric'; + +// Each pattern maps to a regex that matches the characters to *strip*. +const DISALLOWED: Record = { + numeric: /[^0-9]/g, + alpha: /[^a-zA-Z]/g, + alphanumeric: /[^a-zA-Z0-9]/g, +}; + +/** The `inputMode` best suited to a pattern's on-screen keyboard. */ +export function inputModeForPattern(pattern: OTPPattern): 'numeric' | 'text' { + return pattern === 'numeric' ? 'numeric' : 'text'; +} + +/** + * Strips whitespace and any characters disallowed by `pattern`, then clamps the + * result to `length` characters. Used for every value the field accepts — + * typing, pasting, and controlled/default values — so state can never hold an + * out-of-range or overflowing string. + */ +export function sanitize(value: string, pattern: OTPPattern, length: number): string { + return value.replace(/\s/g, '').replace(DISALLOWED[pattern], '').slice(0, length); +} + +/** + * Overwrites the characters starting at `index` with `insert`, so typing or + * pasting into a slot replaces that slot onward rather than shifting later + * characters. The result is re-sanitized by the caller. + */ +export function replaceAt(current: string, index: number, insert: string): string { + return current.slice(0, index) + insert + current.slice(index + insert.length); +} + +/** Removes the character at `index`, closing the gap. */ +export function removeAt(current: string, index: number): string { + if (index < 0 || index >= current.length) { + return current; + } + return current.slice(0, index) + current.slice(index + 1); +} diff --git a/packages/headless/src/primitives/otp/otp.test.tsx b/packages/headless/src/primitives/otp/otp.test.tsx new file mode 100644 index 00000000000..53af047f941 --- /dev/null +++ b/packages/headless/src/primitives/otp/otp.test.tsx @@ -0,0 +1,370 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { OTP } from './index'; + +afterEach(() => cleanup()); + +/** A default composition driving the slots off the live slot list. */ +function Harness(props: Partial> & { length?: number } = {}) { + const { length = 4, children, ...rootProps } = props; + return ( + + + + ); +} + +function Slots() { + const { slots } = OTP.useOTP(); + return ( + <> + {slots.map(slot => ( + + ))} + + ); +} + +function inputs() { + return Array.from(document.querySelectorAll('[data-cl-slot="otp-input"]')); +} + +describe('OTP', () => { + describe('slot attributes', () => { + it('renders the root and one input per slot', () => { + render(); + expect(document.querySelector('[data-cl-slot="otp-root"]')).toBeInTheDocument(); + expect(inputs()).toHaveLength(4); + }); + + it('marks the root empty until a character is entered, then complete when full', async () => { + const user = userEvent.setup(); + render(); + const root = document.querySelector('[data-cl-slot="otp-root"]'); + expect(root).toHaveAttribute('data-cl-empty', ''); + + await user.type(inputs()[0], '1'); + expect(root).not.toHaveAttribute('data-cl-empty'); + expect(root).not.toHaveAttribute('data-cl-complete'); + + await user.type(inputs()[1], '2'); + expect(root).toHaveAttribute('data-cl-complete', ''); + }); + + it('marks a filled input and the active input', async () => { + const user = userEvent.setup(); + render( + , + ); + expect(inputs()[0]).toHaveAttribute('data-cl-filled', ''); + expect(inputs()[1]).not.toHaveAttribute('data-cl-filled'); + + await user.click(inputs()[1]); + expect(inputs()[1]).toHaveAttribute('data-cl-active', ''); + }); + }); + + describe('typing', () => { + it('fills slots left to right and advances focus', async () => { + const user = userEvent.setup(); + render(); + + await user.click(inputs()[0]); + await user.keyboard('12'); + + expect(inputs()[0]).toHaveValue('1'); + expect(inputs()[1]).toHaveValue('2'); + expect(inputs()[2]).toHaveFocus(); + }); + + it('reports each change through onValueChange', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + await user.click(inputs()[0]); + await user.keyboard('12'); + + expect(onValueChange).toHaveBeenLastCalledWith('12'); + }); + + it('rejects characters outside the pattern', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + await user.click(inputs()[0]); + await user.keyboard('a'); + + expect(inputs()[0]).toHaveValue(''); + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard navigation', () => { + it('deletes the current character on Backspace and moves back when empty', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[2]); + await user.keyboard('{Backspace}'); // slot 2 empty -> removes slot 1, focus slot 1 + expect(inputs()[1]).toHaveValue(''); + expect(inputs()[0]).toHaveValue('1'); + expect(inputs()[1]).toHaveFocus(); + + await user.keyboard('{Backspace}'); // slot 1 empty -> removes slot 0, focus slot 0 + expect(inputs()[0]).toHaveValue(''); + expect(inputs()[0]).toHaveFocus(); + }); + + it('moves focus with arrow keys', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[0]); + await user.keyboard('{ArrowRight}'); + expect(inputs()[1]).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + expect(inputs()[0]).toHaveFocus(); + + await user.keyboard('{End}'); + expect(inputs()[3]).toHaveFocus(); + + await user.keyboard('{Home}'); + expect(inputs()[0]).toHaveFocus(); + }); + }); + + describe('paste', () => { + it('distributes a pasted code across the slots', async () => { + const user = userEvent.setup(); + const onComplete = vi.fn(); + render( + , + ); + + await user.click(inputs()[0]); + await user.paste('1234'); + + expect(inputs().map(i => i.value)).toEqual(['1', '2', '3', '4']); + expect(onComplete).toHaveBeenCalledWith('1234'); + }); + + it('strips disallowed characters from a paste', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[0]); + await user.paste('1-2-3-4'); + + expect(inputs().map(i => i.value)).toEqual(['1', '2', '3', '4']); + }); + }); + + describe('onComplete', () => { + it('fires once when the final character is typed', async () => { + const user = userEvent.setup(); + const onComplete = vi.fn(); + render( + , + ); + + await user.click(inputs()[0]); + await user.keyboard('1'); + expect(onComplete).not.toHaveBeenCalled(); + + await user.keyboard('2'); + expect(onComplete).toHaveBeenCalledExactlyOnceWith('12'); + }); + }); + + describe('controlled', () => { + it('reflects an externally controlled value', async () => { + const user = userEvent.setup(); + function Controlled() { + const [value, setValue] = useState(''); + return ( + + + + ); + } + render(); + + await user.click(inputs()[0]); + await user.keyboard('99'); + expect(inputs()[0]).toHaveValue('9'); + expect(inputs()[1]).toHaveValue('9'); + }); + }); + + describe('disabled', () => { + it('disables every input and ignores typing', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + for (const input of inputs()) { + expect(input).toBeDisabled(); + } + expect(document.querySelector('[data-cl-slot="otp-root"]')).toHaveAttribute('data-cl-disabled', ''); + + await user.type(inputs()[0], '1'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe('form integration', () => { + it('submits the value through a hidden named input', () => { + render( + , + ); + const hidden = document.querySelector('[data-cl-slot="otp-hidden-input"]'); + expect(hidden).toHaveAttribute('name', 'code'); + expect(hidden).toHaveValue('1234'); + }); + + it('omits the hidden input when no name is given', () => { + render(); + expect(document.querySelector('[data-cl-slot="otp-hidden-input"]')).not.toBeInTheDocument(); + }); + }); + + describe('useOTP', () => { + it('exposes slots and a clear action', async () => { + const user = userEvent.setup(); + function WithClear() { + const { clear, complete } = OTP.useOTP(); + return ( + <> + + + {complete ? done : null} + + ); + } + render( + + + , + ); + expect(screen.getByText('done')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(inputs().map(i => i.value)).toEqual(['', '']); + expect(screen.queryByText('done')).not.toBeInTheDocument(); + }); + + it('throws when used outside ', () => { + function Orphan() { + OTP.useOTP(); + return null; + } + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow(/within /); + spy.mockRestore(); + }); + }); + + describe('render prop', () => { + it('lets a slot render a custom element', () => { + render( + + ( + + )} + /> + , + ); + const custom = screen.getByTestId('custom'); + expect(custom).toHaveAttribute('data-cl-slot', 'otp-input'); + }); + }); + + describe('accessibility', () => { + it('has no axe violations', async () => { + const { container } = render( + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + }); +}); diff --git a/packages/headless/src/primitives/otp/parts.ts b/packages/headless/src/primitives/otp/parts.ts new file mode 100644 index 00000000000..db06278d0ac --- /dev/null +++ b/packages/headless/src/primitives/otp/parts.ts @@ -0,0 +1,4 @@ +export { type OTPProps, OTPRoot as Root } from './otp-root'; +export { type OTPInputProps, OTPInput as Input } from './otp-input'; +export { type OTPSlot, useOTP } from './otp-context'; +export type { OTPPattern } from './otp-utils'; diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index ad9fded53ea..8fe32d6a5be 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ 'primitives/collapsible/index': 'src/primitives/collapsible/index.ts', 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'primitives/file-upload/index': 'src/primitives/file-upload/index.ts', + 'primitives/otp/index': 'src/primitives/otp/index.ts', 'utils/index': 'src/utils/index.ts', 'hooks/index': 'src/hooks/index.ts', 'hooks/use-controllable-state': 'src/hooks/use-controllable-state.ts', diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 1c33abeab29..6f945849ce5 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -41,6 +41,7 @@ const docModules: Record> = { dialog: dynamic(() => import('../stories/dialog.mdx')), 'file-upload': dynamic(() => import('../stories/file-upload.mdx')), menu: dynamic(() => import('../stories/menu.mdx')), + otp: dynamic(() => import('../stories/otp.mdx')), popover: dynamic(() => import('../stories/popover.mdx')), select: dynamic(() => import('../stories/select.mdx')), tabs: dynamic(() => import('../stories/tabs.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index c262d0b69ac..6d88d169d34 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -41,6 +41,7 @@ import { meta as leaveOrganizationMeta, } from '../stories/leave-organization.stories'; import { meta as menuMeta } from '../stories/menu.stories'; +import { meta as otpMeta } from '../stories/otp.stories'; import { Default as OrganizationProfileDefault, meta as organizationProfileMeta, @@ -109,6 +110,7 @@ const collapsibleModule: StoryModule = { meta: collapsibleMeta }; const dialogModule: StoryModule = { meta: dialogMeta }; const fileUploadModule: StoryModule = { meta: fileUploadMeta }; const menuModule: StoryModule = { meta: menuMeta }; +const otpModule: StoryModule = { meta: otpMeta }; const popoverModule: StoryModule = { meta: popoverMeta }; const selectModule: StoryModule = { meta: selectMeta }; const tabsModule: StoryModule = { meta: tabsMeta }; @@ -142,6 +144,7 @@ export const registry: StoryModule[] = [ dialogModule, fileUploadModule, menuModule, + otpModule, popoverModule, selectModule, tabsModule, diff --git a/packages/swingset/src/stories/otp.mdx b/packages/swingset/src/stories/otp.mdx new file mode 100644 index 00000000000..9c584742ac5 --- /dev/null +++ b/packages/swingset/src/stories/otp.mdx @@ -0,0 +1,164 @@ +import * as OTPStories from './otp.stories'; + +# OTP + +A one-time-password / PIN input from `@clerk/headless`. It is a **headless** primitive: it owns the +value (a single string), splits it across per-character `Input` slots, and handles focus movement, +keyboard editing, and paste distribution, but ships **no styles** — you bring your own CSS by +targeting the `data-cl-*` attributes each part emits. + +## Example + +The demo below is intentionally unstyled — it renders the raw primitive so you can see its behavior. +Type digits and focus advances slot to slot; `Backspace` walks back; arrow keys move between slots; +and pasting a full code distributes it across the slots. + + + +## Usage + +The value lives in `OTP.Root`. Read the slots with the `useOTP` hook and render one `OTP.Input` per +slot, passing each its `index`: + +```tsx +import { OTP } from '@clerk/headless/otp'; + +function VerifyCode() { + return ( + submit(code)} + > + + + ); +} + +function Slots() { + const { slots } = OTP.useOTP(); + return slots.map(slot => ( + + )); +} +``` + +### Controlled + +```tsx +const [code, setCode] = useState(''); + + + +; +``` + +### Inside a form + +Pass `name` to submit the combined value through a hidden input: + +```tsx + + + + + + +``` + +### Masked / alphanumeric + +```tsx + + + +``` + +## Parts + +| Part | Default Element | Description | +| ----------- | --------------- | -------------------------------------------------------------- | +| `OTP.Root` | `
` | Owns the value + focus, provides context, submits the value | +| `OTP.Input` | `` | A single character slot; render one per `useOTP().slots` entry | + +Both parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their +default element. `OTP.Input` (and the `useOTP` hook) throw if used outside `OTP.Root`. + +`OTP.useOTP()` is a hook (not a component) that returns +`{ value, length, disabled, complete, slots, activeIndex, clear, focus }` for reading the value and +driving custom UI. Each `slots` entry is `{ index, char, isActive, isFilled }`. It must be called +inside `OTP.Root`. + +## Props + +### `OTP.Root` + +| Prop | Type | Default | Description | +| --------------- | ---------------------------------------- | ------------ | --------------------------------------------------- | +| `length` | `number` | — (required) | Number of slots (characters) in the code | +| `value` | `string` | — | Controlled value | +| `defaultValue` | `string` | `''` | Initial value (uncontrolled) | +| `onValueChange` | `(value: string) => void` | — | Called with the full value on every change | +| `onComplete` | `(value: string) => void` | — | Called once every slot is filled | +| `pattern` | `'numeric' \| 'alpha' \| 'alphanumeric'` | `'numeric'` | Allowed characters; others are stripped | +| `mask` | `boolean` | `false` | Render slots as password inputs | +| `name` | `string` | — | Submit the value via a hidden input under this name | +| `disabled` | `boolean` | `false` | Disable every slot | + +### `OTP.Input` + +| Prop | Type | Default | Description | +| ------- | -------- | ------------ | ----------------------------------------------------- | +| `index` | `number` | — (required) | The slot's `0`-based position (from `useOTP().slots`) | + +## Styling + +Each part emits `data-cl-*` attributes you can target with any CSS solution: + +| Attribute | Applies To | Description | +| ------------------ | ----------- | --------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (`"otp-root"`, `"otp-input"`) | +| `data-cl-empty` | Root | Present when no character has been entered | +| `data-cl-complete` | Root | Present when every slot is filled | +| `data-cl-disabled` | Root, Input | Present when disabled | +| `data-cl-active` | Input | Present when the slot holds focus | +| `data-cl-filled` | Input | Present when the slot holds a character | + +```css +[data-cl-slot='otp-root'] { + display: flex; + gap: 8px; +} +[data-cl-slot='otp-input'] { + width: 40px; + height: 48px; + text-align: center; + border: 1px solid var(--color-border); + border-radius: 8px; +} +[data-cl-slot='otp-input'][data-cl-active] { + border-color: var(--color-accent); +} +``` + +`OTP.Root` is a `role="group"`; give it an `aria-label` (or `aria-labelledby`) describing the code. +Each `OTP.Input` gets a default `aria-label` of `"Character N of M"` (overridable), and the slots use +a roving tab index so `Tab` enters the group once and leaves in one step. When `name` is set, the +hidden form input is `aria-hidden` and out of the tab order. diff --git a/packages/swingset/src/stories/otp.stories.tsx b/packages/swingset/src/stories/otp.stories.tsx new file mode 100644 index 00000000000..ee80d9f3e68 --- /dev/null +++ b/packages/swingset/src/stories/otp.stories.tsx @@ -0,0 +1,38 @@ +import { OTP } from '@clerk/headless/otp'; + +import type { StoryMeta } from '@/lib/types'; + +// Headless primitives ship no styles. This single demo renders the primitive raw — +// unstyled — so it faithfully reflects what `@clerk/headless` provides: per-character +// slots, focus advancing as you type, paste distribution, and the `data-cl-*` attributes +// each part emits, with zero appearance. It is embedded once into the overview via +// `` in the MDX (the one thing prose can't convey: that typing, deleting, and +// pasting actually move focus across the slots). There is no interactive knob canvas for +// headless primitives. + +export const meta: StoryMeta = { + group: 'Primitives', + title: 'OTP', + source: 'packages/headless/src/primitives/otp/index.ts', +}; + +function Slots() { + const { slots } = OTP.useOTP(); + return slots.map(slot => ( + + )); +} + +export function Default() { + return ( + + + + ); +} From 7100b1026c9084f955eab3f241bb9c2ea683114e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 2 Jul 2026 15:24:49 -0400 Subject: [PATCH 2/6] fix(headless): match base-ui OTP focus and keyboard navigation --- .../headless/src/primitives/otp/README.md | 22 ++-- .../headless/src/primitives/otp/otp-input.tsx | 82 +++++++++----- .../headless/src/primitives/otp/otp.test.tsx | 107 ++++++++++++++++++ packages/swingset/src/stories/otp.mdx | 5 +- 4 files changed, 180 insertions(+), 36 deletions(-) diff --git a/packages/headless/src/primitives/otp/README.md b/packages/headless/src/primitives/otp/README.md index ff5377c2b37..52eb744feb0 100644 --- a/packages/headless/src/primitives/otp/README.md +++ b/packages/headless/src/primitives/otp/README.md @@ -127,14 +127,20 @@ default element. ## Keyboard -| Key | Behavior | -| ------------------------ | ----------------------------------------------------------------- | -| character | Fills the slot and moves focus to the next slot | -| `Backspace` | Clears the slot, or the previous slot when already empty | -| `Delete` | Clears the current slot | -| `ArrowLeft`/`ArrowRight` | Moves focus to the previous / next slot | -| `Home`/`End` | Moves focus to the first / last-entered slot | -| paste | Distributes the pasted code across the slots from the focus point | +| Key | Behavior | +| -------------------------- | ----------------------------------------------------------------- | +| character | Fills the slot and moves focus to the next slot | +| `Backspace` | Clears the slot, or the previous slot when already empty | +| `Ctrl`/`Cmd` + `Backspace` | Clears the whole value and focuses the first slot | +| `Delete` | Clears the current slot | +| `ArrowLeft`/`ArrowRight` | Moves focus to the previous / next slot | +| `Ctrl`/`Cmd` + arrow | Jumps focus to the first / last-entered slot | +| `Home`/`ArrowUp` | Moves focus to the first slot | +| `End`/`ArrowDown` | Moves focus to the last-entered slot | +| paste | Distributes the pasted code across the slots from the focus point | + +Focus can't skip past the first empty slot: clicking, arrowing, or tabbing onto an empty slot beyond +it snaps focus back to the first empty slot (so an empty field always focuses the first slot). ## Data Attributes diff --git a/packages/headless/src/primitives/otp/otp-input.tsx b/packages/headless/src/primitives/otp/otp-input.tsx index ede4159f598..35243c67a60 100644 --- a/packages/headless/src/primitives/otp/otp-input.tsx +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -76,6 +76,12 @@ export function OTPInput(props: OTPInputProps) { focus(index); }, onFocus: (event: React.FocusEvent) => { + // Focus can't skip past the first empty slot: any attempt to land on an + // empty slot beyond it (via click, arrow key, or Tab) snaps to that slot. + if (index > value.length) { + focus(Math.min(value.length, length - 1)); + return; + } onSlotFocus(index); event.currentTarget.select(); }, @@ -110,48 +116,72 @@ export function OTPInput(props: OTPInputProps) { return; } + const lastIndex = Math.max(length - 1, 0); + // The last slot that can hold focus: the last-entered slot, or the first + // empty one when the value isn't full. + const endIndex = Math.min(value.length, lastIndex); + // Ctrl/Cmd (without Alt) turns navigation and delete into boundary actions. + const boundaryModifier = (event.ctrlKey || event.metaKey) && !event.altKey; + switch (event.key) { - case 'Backspace': { + case 'ArrowLeft': { event.preventDefault(); - const targetIndex = Math.max(0, index - 1); - // Delete this slot's character if it has one, otherwise the previous - // slot's — either way focus lands on the previous slot. - const deleteIndex = char === '' ? targetIndex : index; - const next = removeAt(value, deleteIndex); - setValue(next); - queueFocus(targetIndex, next); - break; + focus(boundaryModifier ? 0 : index - 1); + return; } - case 'Delete': { + case 'ArrowRight': { event.preventDefault(); - const next = removeAt(value, index); - setValue(next); - queueFocus(index, next); - break; + focus(boundaryModifier ? endIndex : index + 1); + return; } - case 'ArrowLeft': { + case 'Home': + case 'ArrowUp': { event.preventDefault(); - focus(index - 1); - break; + focus(0); + return; } - case 'ArrowRight': { + case 'End': + case 'ArrowDown': { event.preventDefault(); - focus(index + 1); - break; + focus(endIndex); + return; } - case 'Home': { + case 'Delete': { event.preventDefault(); - focus(0); - break; + const next = removeAt(value, index); + setValue(next); + queueFocus(index, next); + return; } - case 'End': { + case 'Backspace': { event.preventDefault(); - focus(Math.min(value.length, length - 1)); - break; + if (boundaryModifier) { + setValue(''); + queueFocus(0, ''); + return; + } + const targetIndex = Math.max(0, index - 1); + // Delete this slot's character if it has one, otherwise the previous + // slot's — either way focus lands on the previous slot. + const deleteIndex = char === '' ? targetIndex : index; + const next = removeAt(value, deleteIndex); + setValue(next); + queueFocus(targetIndex, next); + return; } default: break; } + + // Re-typing the character already in the (fully selected) slot leaves the + // value unchanged, so onChange won't advance focus — do it here instead. + const fullSelection = + event.currentTarget.selectionStart === 0 && + event.currentTarget.selectionEnd === event.currentTarget.value.length; + if (event.key.length === 1 && !boundaryModifier && fullSelection && char === event.key) { + event.preventDefault(); + focus(index + 1); + } }, onPaste: (event: React.ClipboardEvent) => { if (disabled) { diff --git a/packages/headless/src/primitives/otp/otp.test.tsx b/packages/headless/src/primitives/otp/otp.test.tsx index 53af047f941..3f5b7fb0618 100644 --- a/packages/headless/src/primitives/otp/otp.test.tsx +++ b/packages/headless/src/primitives/otp/otp.test.tsx @@ -168,6 +168,113 @@ describe('OTP', () => { await user.keyboard('{Home}'); expect(inputs()[0]).toHaveFocus(); }); + + it('does not let focus skip past the first empty slot', async () => { + const user = userEvent.setup(); + render( + , + ); + + // Clicking an empty slot beyond the first empty one snaps to slot 2. + await user.click(inputs()[3]); + expect(inputs()[2]).toHaveFocus(); + + // Arrowing right from the first empty slot stays put. + await user.keyboard('{ArrowRight}'); + expect(inputs()[2]).toHaveFocus(); + }); + + it('treats ArrowUp/ArrowDown as first / last-entered slot', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[0]); + await user.keyboard('{ArrowDown}'); // last-entered = first empty slot (index 2) + expect(inputs()[2]).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); // first slot + expect(inputs()[0]).toHaveFocus(); + }); + + it('jumps to the boundaries with Ctrl/Cmd + arrow keys', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[0]); + await user.keyboard('{Control>}{ArrowRight}{/Control}'); // last-entered (index 3) + expect(inputs()[3]).toHaveFocus(); + + await user.keyboard('{Control>}{ArrowLeft}{/Control}'); // first + expect(inputs()[0]).toHaveFocus(); + }); + + it('clears the whole value with Ctrl/Cmd + Backspace', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[2]); + await user.keyboard('{Control>}{Backspace}{/Control}'); + + expect(inputs().map(i => i.value)).toEqual(['', '', '', '']); + expect(inputs()[0]).toHaveFocus(); + }); + }); + + describe('clicking', () => { + it('focuses the first input when the field is empty', async () => { + const user = userEvent.setup(); + render(); + + await user.click(inputs()[2]); + expect(inputs()[0]).toHaveFocus(); + }); + + it('focuses the clicked slot when the field is filled', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[2]); + expect(inputs()[2]).toHaveFocus(); + }); + }); + + describe('re-typing the same character', () => { + it('advances focus even though the value is unchanged', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(inputs()[0]); // selects the "1" + await user.keyboard('1'); // same character + expect(inputs()[1]).toHaveFocus(); + }); }); describe('paste', () => { diff --git a/packages/swingset/src/stories/otp.mdx b/packages/swingset/src/stories/otp.mdx index 9c584742ac5..8d26ebf0637 100644 --- a/packages/swingset/src/stories/otp.mdx +++ b/packages/swingset/src/stories/otp.mdx @@ -10,8 +10,9 @@ targeting the `data-cl-*` attributes each part emits. ## Example The demo below is intentionally unstyled — it renders the raw primitive so you can see its behavior. -Type digits and focus advances slot to slot; `Backspace` walks back; arrow keys move between slots; -and pasting a full code distributes it across the slots. +Type digits and focus advances slot to slot; `Backspace` walks back; arrow keys (plus `Home`/`End` +and `Ctrl`/`Cmd` variants) move between slots; and pasting a full code distributes it across the +slots. Focus can't skip past the first empty slot, so an empty field always focuses the first slot. Date: Thu, 2 Jul 2026 15:31:38 -0400 Subject: [PATCH 3/6] feat(headless): support RTL arrow-key navigation in OTP --- .../headless/src/primitives/otp/README.md | 4 ++ .../headless/src/primitives/otp/otp-input.tsx | 28 +++++++++----- .../headless/src/primitives/otp/otp.test.tsx | 38 +++++++++++++++++++ packages/swingset/src/stories/otp.mdx | 3 +- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/headless/src/primitives/otp/README.md b/packages/headless/src/primitives/otp/README.md index 52eb744feb0..7099e957cbf 100644 --- a/packages/headless/src/primitives/otp/README.md +++ b/packages/headless/src/primitives/otp/README.md @@ -142,6 +142,10 @@ default element. Focus can't skip past the first empty slot: clicking, arrowing, or tabbing onto an empty slot beyond it snaps focus back to the first empty slot (so an empty field always focuses the first slot). +Arrow keys follow reading order. Under `dir="rtl"` (resolved from the nearest ancestor with an +explicit `dir`), `ArrowLeft` moves to the next slot and `ArrowRight` to the previous one; `Home`/`End` +and the `Ctrl`/`Cmd` boundary jumps stay logical (first / last-entered). + ## Data Attributes | Attribute | Applies To | Description | diff --git a/packages/headless/src/primitives/otp/otp-input.tsx b/packages/headless/src/primitives/otp/otp-input.tsx index 35243c67a60..978e5700478 100644 --- a/packages/headless/src/primitives/otp/otp-input.tsx +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -123,17 +123,25 @@ export function OTPInput(props: OTPInputProps) { // Ctrl/Cmd (without Alt) turns navigation and delete into boundary actions. const boundaryModifier = (event.ctrlKey || event.metaKey) && !event.altKey; + // Resolve direction from the nearest ancestor with an explicit `dir` so + // arrow keys follow reading order: in RTL, Left moves to the next slot and + // Right to the previous one. + const isRtl = (event.currentTarget.closest('[dir]')?.getAttribute('dir') ?? '').toLowerCase() === 'rtl'; + const previousKey = isRtl ? 'ArrowRight' : 'ArrowLeft'; + const nextKey = isRtl ? 'ArrowLeft' : 'ArrowRight'; + + if (event.key === previousKey) { + event.preventDefault(); + focus(boundaryModifier ? 0 : index - 1); + return; + } + if (event.key === nextKey) { + event.preventDefault(); + focus(boundaryModifier ? endIndex : index + 1); + return; + } + switch (event.key) { - case 'ArrowLeft': { - event.preventDefault(); - focus(boundaryModifier ? 0 : index - 1); - return; - } - case 'ArrowRight': { - event.preventDefault(); - focus(boundaryModifier ? endIndex : index + 1); - return; - } case 'Home': case 'ArrowUp': { event.preventDefault(); diff --git a/packages/headless/src/primitives/otp/otp.test.tsx b/packages/headless/src/primitives/otp/otp.test.tsx index 3f5b7fb0618..94dad5ff50c 100644 --- a/packages/headless/src/primitives/otp/otp.test.tsx +++ b/packages/headless/src/primitives/otp/otp.test.tsx @@ -236,6 +236,44 @@ describe('OTP', () => { expect(inputs().map(i => i.value)).toEqual(['', '', '', '']); expect(inputs()[0]).toHaveFocus(); }); + + it('swaps ArrowLeft/ArrowRight under dir="rtl"', async () => { + const user = userEvent.setup(); + render( +
+ +
, + ); + + await user.click(inputs()[1]); + await user.keyboard('{ArrowLeft}'); // rtl: next slot + expect(inputs()[2]).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); // rtl: previous slot + expect(inputs()[1]).toHaveFocus(); + }); + + it('swaps Ctrl/Cmd + arrows under dir="rtl"', async () => { + const user = userEvent.setup(); + render( +
+ +
, + ); + + await user.click(inputs()[1]); + await user.keyboard('{Control>}{ArrowLeft}{/Control}'); // rtl: last-entered (index 3) + expect(inputs()[3]).toHaveFocus(); + + await user.keyboard('{Control>}{ArrowRight}{/Control}'); // rtl: first + expect(inputs()[0]).toHaveFocus(); + }); }); describe('clicking', () => { diff --git a/packages/swingset/src/stories/otp.mdx b/packages/swingset/src/stories/otp.mdx index 8d26ebf0637..01e6a5cbb30 100644 --- a/packages/swingset/src/stories/otp.mdx +++ b/packages/swingset/src/stories/otp.mdx @@ -162,4 +162,5 @@ Each part emits `data-cl-*` attributes you can target with any CSS solution: `OTP.Root` is a `role="group"`; give it an `aria-label` (or `aria-labelledby`) describing the code. Each `OTP.Input` gets a default `aria-label` of `"Character N of M"` (overridable), and the slots use a roving tab index so `Tab` enters the group once and leaves in one step. When `name` is set, the -hidden form input is `aria-hidden` and out of the tab order. +hidden form input is `aria-hidden` and out of the tab order. Arrow keys follow reading order — under +`dir="rtl"`, `ArrowLeft`/`ArrowRight` are swapped. From 5d1bfe281418a41671b823a215c88b534256b9d3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 2 Jul 2026 15:37:03 -0400 Subject: [PATCH 4/6] fix(repo): sort swingset registry imports to unblock build --- packages/swingset/src/lib/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 6d88d169d34..30aaa368927 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -41,7 +41,6 @@ import { meta as leaveOrganizationMeta, } from '../stories/leave-organization.stories'; import { meta as menuMeta } from '../stories/menu.stories'; -import { meta as otpMeta } from '../stories/otp.stories'; import { Default as OrganizationProfileDefault, meta as organizationProfileMeta, @@ -50,6 +49,7 @@ import { Default as OrganizationProfileGeneralDefault, meta as organizationProfileGeneralMeta, } from '../stories/organization-profile-general.stories'; +import { meta as otpMeta } from '../stories/otp.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; import { meta as selectMeta } from '../stories/select.stories'; import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; From 3baf2955fcc3455c6f9919b6aa3d810f7ade0e7f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 2 Jul 2026 15:44:35 -0400 Subject: [PATCH 5/6] refactor(headless): rename OTP namespace to Otp for PascalCase consistency --- .../headless/src/primitives/otp/README.md | 42 ++++++++-------- packages/headless/src/primitives/otp/index.ts | 4 +- .../src/primitives/otp/otp-context.ts | 36 +++++++------- .../headless/src/primitives/otp/otp-input.tsx | 10 ++-- .../headless/src/primitives/otp/otp-root.tsx | 18 +++---- .../headless/src/primitives/otp/otp-utils.ts | 10 ++-- .../headless/src/primitives/otp/otp.test.tsx | 42 ++++++++-------- packages/headless/src/primitives/otp/parts.ts | 8 ++-- packages/swingset/src/stories/otp.mdx | 48 +++++++++---------- packages/swingset/src/stories/otp.stories.tsx | 10 ++-- 10 files changed, 114 insertions(+), 114 deletions(-) diff --git a/packages/headless/src/primitives/otp/README.md b/packages/headless/src/primitives/otp/README.md index 7099e957cbf..0b0ea6c066b 100644 --- a/packages/headless/src/primitives/otp/README.md +++ b/packages/headless/src/primitives/otp/README.md @@ -1,7 +1,7 @@ # OTP A headless one-time-password (OTP / PIN) input. The value is a single string; each character is -rendered by its own `` slot, so you style each box yourself. Typing advances focus, +rendered by its own `` slot, so you style each box yourself. Typing advances focus, `Backspace` walks back, arrows/Home/End move between slots, and pasting a full code distributes it across the slots. Supports controlled/uncontrolled value, a character `pattern`, masking, a `disabled` state, and optional `
` submission via `name`. @@ -18,25 +18,25 @@ no global CSS. Everything is driven by `data-cl-*` attributes. ## Usage ```tsx -import { OTP } from '@clerk/headless/otp'; +import { Otp } from '@clerk/headless/otp'; function VerifyCode() { return ( - submit(code)} > - + ); } // Render one Input per slot from the live slot list. function Slots() { - const { slots } = OTP.useOTP(); + const { slots } = Otp.useOtp(); return slots.map(slot => ( - @@ -44,22 +44,22 @@ function Slots() { } ``` -`useOTP()` returns `{ value, length, disabled, complete, slots, activeIndex, clear, focus }`. Each +`useOtp()` returns `{ value, length, disabled, complete, slots, activeIndex, clear, focus }`. Each entry in `slots` is `{ index, char, isActive, isFilled }`, so you can render decorations (a caret, a -separator) around the boxes. It must be called inside ``. +separator) around the boxes. It must be called inside ``. ### Controlled ```tsx const [code, setCode] = useState(''); - -; +; ``` ### Inside a form @@ -68,12 +68,12 @@ Pass `name` to submit the combined value through a hidden input: ```tsx - - + ``` @@ -81,28 +81,28 @@ Pass `name` to submit the combined value through a hidden input: ### Masked / alphanumeric ```tsx - - + ``` ## Parts | Part | Default Element | Description | | ----------- | --------------- | ----------------------------------------------------------- | -| `OTP.Root` | `
` | Owns the value + focus, provides context, submits the value | -| `OTP.Input` | `` | A single character slot (render one per `slots` entry) | +| `Otp.Root` | `
` | Owns the value + focus, provides context, submits the value | +| `Otp.Input` | `` | A single character slot (render one per `slots` entry) | -`OTP.useOTP()` is a hook (not a component) for reading the value and driving custom UI. It must be -called inside `OTP.Root`. +`Otp.useOtp()` is a hook (not a component) for reading the value and driving custom UI. It must be +called inside `Otp.Root`. ## Props -### `OTP.Root` +### `Otp.Root` | Prop | Type | Default | Description | | --------------- | ---------------------------------------- | ----------- | --------------------------------------------------- | @@ -116,11 +116,11 @@ called inside `OTP.Root`. | `name` | `string` | — | Submit the value via a hidden input under this name | | `disabled` | `boolean` | `false` | Disable every slot | -### `OTP.Input` +### `Otp.Input` | Prop | Type | Default | Description | | ------- | -------- | ------- | ----------------------------------------------------- | -| `index` | `number` | — | The slot's `0`-based position (from `useOTP().slots`) | +| `index` | `number` | — | The slot's `0`-based position (from `useOtp().slots`) | Both parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their default element. diff --git a/packages/headless/src/primitives/otp/index.ts b/packages/headless/src/primitives/otp/index.ts index d3cd3bc19fd..36cc647e7fe 100644 --- a/packages/headless/src/primitives/otp/index.ts +++ b/packages/headless/src/primitives/otp/index.ts @@ -1,3 +1,3 @@ -export * as OTP from './parts'; +export * as Otp from './parts'; -export type { OTPInputProps, OTPPattern, OTPProps, OTPSlot } from './parts'; +export type { OtpInputProps, OtpPattern, OtpProps, OtpSlot } from './parts'; diff --git a/packages/headless/src/primitives/otp/otp-context.ts b/packages/headless/src/primitives/otp/otp-context.ts index 58ab69c5e08..69d2a188741 100644 --- a/packages/headless/src/primitives/otp/otp-context.ts +++ b/packages/headless/src/primitives/otp/otp-context.ts @@ -1,9 +1,9 @@ import { createContext, useContext } from 'react'; -import type { OTPPattern } from './otp-utils'; +import type { OtpPattern } from './otp-utils'; -/** A single OTP slot, as read through {@link useOTP}. */ -export interface OTPSlot { +/** A single Otp slot, as read through {@link useOtp}. */ +export interface OtpSlot { /** The slot's position, `0`-based. */ index: number; /** The character in this slot, or `''` when empty. */ @@ -14,8 +14,8 @@ export interface OTPSlot { isFilled: boolean; } -export interface OTPContextValue { - /** The current OTP value. */ +export interface OtpContextValue { + /** The current Otp value. */ value: string; /** The number of slots. */ length: number; @@ -24,18 +24,18 @@ export interface OTPContextValue { /** Whether every slot is filled. */ complete: boolean; /** The allowed character set. */ - pattern: OTPPattern; + pattern: OtpPattern; /** Render each slot as a masked (password) input. */ mask: boolean; /** One descriptor per slot, in order. */ - slots: OTPSlot[]; + slots: OtpSlot[]; /** The index of the currently focused slot, or `null` when unfocused. */ activeIndex: number | null; /** Clear the value and focus the first slot. */ clear: () => void; /** Focus the slot at `index` (clamped to range). */ focus: (index: number) => void; - // --- internal wiring used by --- + // --- internal wiring used by --- /** Register/unregister a slot input's element by index. */ registerInput: (index: number, element: HTMLInputElement | null) => void; /** Propose a new full value; it is sanitized and clamped before commit. */ @@ -48,25 +48,25 @@ export interface OTPContextValue { onSlotBlur: () => void; } -export const OTPContext = createContext(null); +export const OtpContext = createContext(null); -export function useOTPContext(): OTPContextValue { - const ctx = useContext(OTPContext); +export function useOtpContext(): OtpContextValue { + const ctx = useContext(OtpContext); if (!ctx) { - throw new Error('OTP compound components must be used within '); + throw new Error('Otp compound components must be used within '); } return ctx; } /** - * Reads the current OTP state and actions. Use it to render one - * `` per slot, show a completion state, or drive a custom clear - * button. Must be called inside ``. + * Reads the current Otp state and actions. Use it to render one + * `` per slot, show a completion state, or drive a custom clear + * button. Must be called inside ``. */ -export function useOTP(): Pick< - OTPContextValue, +export function useOtp(): Pick< + OtpContextValue, 'value' | 'length' | 'disabled' | 'complete' | 'slots' | 'activeIndex' | 'clear' | 'focus' > { - const { value, length, disabled, complete, slots, activeIndex, clear, focus } = useOTPContext(); + const { value, length, disabled, complete, slots, activeIndex, clear, focus } = useOtpContext(); return { value, length, disabled, complete, slots, activeIndex, clear, focus }; } diff --git a/packages/headless/src/primitives/otp/otp-input.tsx b/packages/headless/src/primitives/otp/otp-input.tsx index 978e5700478..11ea0364503 100644 --- a/packages/headless/src/primitives/otp/otp-input.tsx +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -3,15 +3,15 @@ import { type Ref, useCallback } from 'react'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; -import { useOTPContext } from './otp-context'; +import { useOtpContext } from './otp-context'; import { inputModeForPattern, removeAt, replaceAt, sanitize } from './otp-utils'; -export interface OTPInputProps extends ComponentProps<'input'> { - /** This slot's position, `0`-based (typically from `useOTP().slots`). */ +export interface OtpInputProps extends ComponentProps<'input'> { + /** This slot's position, `0`-based (typically from `useOtp().slots`). */ index: number; } -export function OTPInput(props: OTPInputProps) { +export function OtpInput(props: OtpInputProps) { const { render, index, ref: forwardedRef, ...otherProps } = props; const { value, @@ -26,7 +26,7 @@ export function OTPInput(props: OTPInputProps) { registerInput, onSlotFocus, onSlotBlur, - } = useOTPContext(); + } = useOtpContext(); const char = value[index] ?? ''; diff --git a/packages/headless/src/primitives/otp/otp-root.tsx b/packages/headless/src/primitives/otp/otp-root.tsx index c681664f7d7..27dea3b67c6 100644 --- a/packages/headless/src/primitives/otp/otp-root.tsx +++ b/packages/headless/src/primitives/otp/otp-root.tsx @@ -4,10 +4,10 @@ import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, us import { useControllableState } from '../../hooks/use-controllable-state'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; -import { OTPContext, type OTPContextValue, type OTPSlot } from './otp-context'; -import { inputModeForPattern, type OTPPattern, sanitize } from './otp-utils'; +import { OtpContext, type OtpContextValue, type OtpSlot } from './otp-context'; +import { inputModeForPattern, type OtpPattern, sanitize } from './otp-utils'; -export interface OTPProps extends Omit, 'value' | 'defaultValue' | 'onChange'> { +export interface OtpProps extends Omit, 'value' | 'defaultValue' | 'onChange'> { /** The number of slots (characters) in the code. */ length: number; /** Controlled value. */ @@ -19,7 +19,7 @@ export interface OTPProps extends Omit, 'value' | 'default /** Called with the value once every slot is filled. */ onComplete?: (value: string) => void; /** Allowed character set; other characters are stripped. @default 'numeric' */ - pattern?: OTPPattern; + pattern?: OtpPattern; /** Render slots as masked (password) inputs. @default false */ mask?: boolean; /** @@ -46,7 +46,7 @@ const visuallyHiddenInputStyle: CSSProperties = { border: 0, }; -export function OTPRoot(props: OTPProps) { +export function OtpRoot(props: OtpProps) { const { render, length, @@ -126,7 +126,7 @@ export function OTPRoot(props: OTPProps) { const complete = value.length === length; - const slots = useMemo( + const slots = useMemo( () => Array.from({ length }, (_, index) => { const char = value[index] ?? ''; @@ -135,7 +135,7 @@ export function OTPRoot(props: OTPProps) { [length, value, activeIndex], ); - const contextValue = useMemo( + const contextValue = useMemo( () => ({ value, length, @@ -199,7 +199,7 @@ export function OTPRoot(props: OTPProps) { }; return ( - + {renderElement({ defaultTagName: 'div', render, @@ -211,6 +211,6 @@ export function OTPRoot(props: OTPProps) { }, props: mergeProps<'div'>(defaultProps, otherProps), })} - + ); } diff --git a/packages/headless/src/primitives/otp/otp-utils.ts b/packages/headless/src/primitives/otp/otp-utils.ts index b7835b794a5..5cbc9365e63 100644 --- a/packages/headless/src/primitives/otp/otp-utils.ts +++ b/packages/headless/src/primitives/otp/otp-utils.ts @@ -1,15 +1,15 @@ -/** The character set an OTP value is allowed to contain. */ -export type OTPPattern = 'numeric' | 'alpha' | 'alphanumeric'; +/** The character set an Otp value is allowed to contain. */ +export type OtpPattern = 'numeric' | 'alpha' | 'alphanumeric'; // Each pattern maps to a regex that matches the characters to *strip*. -const DISALLOWED: Record = { +const DISALLOWED: Record = { numeric: /[^0-9]/g, alpha: /[^a-zA-Z]/g, alphanumeric: /[^a-zA-Z0-9]/g, }; /** The `inputMode` best suited to a pattern's on-screen keyboard. */ -export function inputModeForPattern(pattern: OTPPattern): 'numeric' | 'text' { +export function inputModeForPattern(pattern: OtpPattern): 'numeric' | 'text' { return pattern === 'numeric' ? 'numeric' : 'text'; } @@ -19,7 +19,7 @@ export function inputModeForPattern(pattern: OTPPattern): 'numeric' | 'text' { * typing, pasting, and controlled/default values — so state can never hold an * out-of-range or overflowing string. */ -export function sanitize(value: string, pattern: OTPPattern, length: number): string { +export function sanitize(value: string, pattern: OtpPattern, length: number): string { return value.replace(/\s/g, '').replace(DISALLOWED[pattern], '').slice(0, length); } diff --git a/packages/headless/src/primitives/otp/otp.test.tsx b/packages/headless/src/primitives/otp/otp.test.tsx index 94dad5ff50c..b8cc41030a7 100644 --- a/packages/headless/src/primitives/otp/otp.test.tsx +++ b/packages/headless/src/primitives/otp/otp.test.tsx @@ -4,29 +4,29 @@ import { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; -import { OTP } from './index'; +import { Otp } from './index'; afterEach(() => cleanup()); /** A default composition driving the slots off the live slot list. */ -function Harness(props: Partial> & { length?: number } = {}) { +function Harness(props: Partial> & { length?: number } = {}) { const { length = 4, children, ...rootProps } = props; return ( - - + ); } function Slots() { - const { slots } = OTP.useOTP(); + const { slots } = Otp.useOtp(); return ( <> {slots.map(slot => ( - @@ -39,7 +39,7 @@ function inputs() { return Array.from(document.querySelectorAll('[data-cl-slot="otp-input"]')); } -describe('OTP', () => { +describe('Otp', () => { describe('slot attributes', () => { it('renders the root and one input per slot', () => { render(); @@ -375,13 +375,13 @@ describe('OTP', () => { function Controlled() { const [value, setValue] = useState(''); return ( - - + ); } render(); @@ -435,11 +435,11 @@ describe('OTP', () => { }); }); - describe('useOTP', () => { + describe('useOtp', () => { it('exposes slots and a clear action', async () => { const user = userEvent.setup(); function WithClear() { - const { clear, complete } = OTP.useOTP(); + const { clear, complete } = Otp.useOtp(); return ( <> @@ -454,12 +454,12 @@ describe('OTP', () => { ); } render( - - , + , ); expect(screen.getByText('done')).toBeInTheDocument(); @@ -468,13 +468,13 @@ describe('OTP', () => { expect(screen.queryByText('done')).not.toBeInTheDocument(); }); - it('throws when used outside ', () => { + it('throws when used outside ', () => { function Orphan() { - OTP.useOTP(); + Otp.useOtp(); return null; } const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow(/within /); + expect(() => render()).toThrow(/within /); spy.mockRestore(); }); }); @@ -482,8 +482,8 @@ describe('OTP', () => { describe('render prop', () => { it('lets a slot render a custom element', () => { render( - - + ( { /> )} /> - , + , ); const custom = screen.getByTestId('custom'); expect(custom).toHaveAttribute('data-cl-slot', 'otp-input'); @@ -502,12 +502,12 @@ describe('OTP', () => { describe('accessibility', () => { it('has no axe violations', async () => { const { container } = render( - - , + , ); expect(await axe(container)).toHaveNoViolations(); }); diff --git a/packages/headless/src/primitives/otp/parts.ts b/packages/headless/src/primitives/otp/parts.ts index db06278d0ac..98fa114c8f3 100644 --- a/packages/headless/src/primitives/otp/parts.ts +++ b/packages/headless/src/primitives/otp/parts.ts @@ -1,4 +1,4 @@ -export { type OTPProps, OTPRoot as Root } from './otp-root'; -export { type OTPInputProps, OTPInput as Input } from './otp-input'; -export { type OTPSlot, useOTP } from './otp-context'; -export type { OTPPattern } from './otp-utils'; +export { type OtpProps, OtpRoot as Root } from './otp-root'; +export { type OtpInputProps, OtpInput as Input } from './otp-input'; +export { type OtpSlot, useOtp } from './otp-context'; +export type { OtpPattern } from './otp-utils'; diff --git a/packages/swingset/src/stories/otp.mdx b/packages/swingset/src/stories/otp.mdx index 01e6a5cbb30..07e02199c36 100644 --- a/packages/swingset/src/stories/otp.mdx +++ b/packages/swingset/src/stories/otp.mdx @@ -1,4 +1,4 @@ -import * as OTPStories from './otp.stories'; +import * as OtpStories from './otp.stories'; # OTP @@ -16,33 +16,33 @@ slots. Focus can't skip past the first empty slot, so an empty field always focu ## Usage -The value lives in `OTP.Root`. Read the slots with the `useOTP` hook and render one `OTP.Input` per +The value lives in `Otp.Root`. Read the slots with the `useOtp` hook and render one `Otp.Input` per slot, passing each its `index`: ```tsx -import { OTP } from '@clerk/headless/otp'; +import { Otp } from '@clerk/headless/otp'; function VerifyCode() { return ( - submit(code)} > - + ); } function Slots() { - const { slots } = OTP.useOTP(); + const { slots } = Otp.useOtp(); return slots.map(slot => ( - @@ -55,13 +55,13 @@ function Slots() { ```tsx const [code, setCode] = useState(''); - -; +; ``` ### Inside a form @@ -70,12 +70,12 @@ Pass `name` to submit the combined value through a hidden input: ```tsx
- - +
``` @@ -83,33 +83,33 @@ Pass `name` to submit the combined value through a hidden input: ### Masked / alphanumeric ```tsx - - +
``` ## Parts | Part | Default Element | Description | | ----------- | --------------- | -------------------------------------------------------------- | -| `OTP.Root` | `
` | Owns the value + focus, provides context, submits the value | -| `OTP.Input` | `` | A single character slot; render one per `useOTP().slots` entry | +| `Otp.Root` | `
` | Owns the value + focus, provides context, submits the value | +| `Otp.Input` | `` | A single character slot; render one per `useOtp().slots` entry | Both parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their -default element. `OTP.Input` (and the `useOTP` hook) throw if used outside `OTP.Root`. +default element. `Otp.Input` (and the `useOtp` hook) throw if used outside `Otp.Root`. -`OTP.useOTP()` is a hook (not a component) that returns +`Otp.useOtp()` is a hook (not a component) that returns `{ value, length, disabled, complete, slots, activeIndex, clear, focus }` for reading the value and driving custom UI. Each `slots` entry is `{ index, char, isActive, isFilled }`. It must be called -inside `OTP.Root`. +inside `Otp.Root`. ## Props -### `OTP.Root` +### `Otp.Root` | Prop | Type | Default | Description | | --------------- | ---------------------------------------- | ------------ | --------------------------------------------------- | @@ -123,11 +123,11 @@ inside `OTP.Root`. | `name` | `string` | — | Submit the value via a hidden input under this name | | `disabled` | `boolean` | `false` | Disable every slot | -### `OTP.Input` +### `Otp.Input` | Prop | Type | Default | Description | | ------- | -------- | ------------ | ----------------------------------------------------- | -| `index` | `number` | — (required) | The slot's `0`-based position (from `useOTP().slots`) | +| `index` | `number` | — (required) | The slot's `0`-based position (from `useOtp().slots`) | ## Styling @@ -159,8 +159,8 @@ Each part emits `data-cl-*` attributes you can target with any CSS solution: } ``` -`OTP.Root` is a `role="group"`; give it an `aria-label` (or `aria-labelledby`) describing the code. -Each `OTP.Input` gets a default `aria-label` of `"Character N of M"` (overridable), and the slots use +`Otp.Root` is a `role="group"`; give it an `aria-label` (or `aria-labelledby`) describing the code. +Each `Otp.Input` gets a default `aria-label` of `"Character N of M"` (overridable), and the slots use a roving tab index so `Tab` enters the group once and leaves in one step. When `name` is set, the hidden form input is `aria-hidden` and out of the tab order. Arrow keys follow reading order — under `dir="rtl"`, `ArrowLeft`/`ArrowRight` are swapped. diff --git a/packages/swingset/src/stories/otp.stories.tsx b/packages/swingset/src/stories/otp.stories.tsx index ee80d9f3e68..23cc5ef36b8 100644 --- a/packages/swingset/src/stories/otp.stories.tsx +++ b/packages/swingset/src/stories/otp.stories.tsx @@ -1,4 +1,4 @@ -import { OTP } from '@clerk/headless/otp'; +import { Otp } from '@clerk/headless/otp'; import type { StoryMeta } from '@/lib/types'; @@ -17,9 +17,9 @@ export const meta: StoryMeta = { }; function Slots() { - const { slots } = OTP.useOTP(); + const { slots } = Otp.useOtp(); return slots.map(slot => ( - @@ -28,11 +28,11 @@ function Slots() { export function Default() { return ( - - + ); } From d340dceb063a943c4ae5a25bf1d7b357c4b6f1f8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 2 Jul 2026 16:09:35 -0400 Subject: [PATCH 6/6] fix(headless): forward consumer ref on Otp.Input for React 18 --- .../headless/src/primitives/otp/otp-input.tsx | 28 ++++++++----------- .../headless/src/primitives/otp/otp.test.tsx | 15 ++++++++++ 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/headless/src/primitives/otp/otp-input.tsx b/packages/headless/src/primitives/otp/otp-input.tsx index 11ea0364503..347dc22db03 100644 --- a/packages/headless/src/primitives/otp/otp-input.tsx +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -1,6 +1,7 @@ 'use client'; -import { type Ref, useCallback } from 'react'; +import { useMergeRefs } from '@floating-ui/react'; +import React, { useCallback } from 'react'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; import { useOtpContext } from './otp-context'; @@ -11,8 +12,8 @@ export interface OtpInputProps extends ComponentProps<'input'> { index: number; } -export function OtpInput(props: OtpInputProps) { - const { render, index, ref: forwardedRef, ...otherProps } = props; +export const OtpInput = React.forwardRef(function OtpInput(props, forwardedRef) { + const { render, index, ...otherProps } = props; const { value, length, @@ -30,18 +31,13 @@ export function OtpInput(props: OtpInputProps) { const char = value[index] ?? ''; - // Compose our slot registration with any ref the consumer forwards. - const setRef = useCallback<(element: HTMLInputElement | null) => void>( - element => { - registerInput(index, element); - if (typeof forwardedRef === 'function') { - forwardedRef(element); - } else if (forwardedRef) { - (forwardedRef as { current: HTMLInputElement | null }).current = element; - } - }, - [registerInput, index, forwardedRef], + // Register this slot for programmatic focus, keyed by index. + const registerRef = useCallback<(element: HTMLInputElement | null) => void>( + element => registerInput(index, element), + [registerInput, index], ); + // Compose our slot registration with any ref the consumer forwards. + const setRef = useMergeRefs([registerRef, forwardedRef]); // Roving tab order: the focused slot is the tab stop, or the next empty slot // when the field is unfocused, so Tab enters and leaves the group once. @@ -51,7 +47,7 @@ export function OtpInput(props: OtpInputProps) { const defaultProps: Record = { 'data-cl-slot': 'otp-input', - ref: setRef as Ref, + ref: setRef, value: char, type: mask ? 'password' : 'text', inputMode: inputModeForPattern(pattern), @@ -217,4 +213,4 @@ export function OtpInput(props: OtpInputProps) { }, props: mergeProps<'input'>(defaultProps, otherProps), }); -} +}); diff --git a/packages/headless/src/primitives/otp/otp.test.tsx b/packages/headless/src/primitives/otp/otp.test.tsx index b8cc41030a7..ba87227a936 100644 --- a/packages/headless/src/primitives/otp/otp.test.tsx +++ b/packages/headless/src/primitives/otp/otp.test.tsx @@ -499,6 +499,21 @@ describe('Otp', () => { }); }); + describe('ref forwarding', () => { + it('populates a consumer ref with the underlying input element', () => { + const ref = { current: null as HTMLInputElement | null }; + render( + + + , + ); + expect(ref.current).toBe(inputs()[0]); + }); + }); + describe('accessibility', () => { it('has no axe violations', async () => { const { container } = render(