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..0b0ea6c066b --- /dev/null +++ b/packages/headless/src/primitives/otp/README.md @@ -0,0 +1,165 @@ +# 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 | +| `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). + +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 | +| ------------------ | ----------- | --------------------------------------------- | +| `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..36cc647e7fe --- /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..69d2a188741 --- /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..347dc22db03 --- /dev/null +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -0,0 +1,216 @@ +'use client'; + +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'; +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 const OtpInput = React.forwardRef(function OtpInput(props, forwardedRef) { + const { render, index, ...otherProps } = props; + const { + value, + length, + disabled, + pattern, + mask, + activeIndex, + setValue, + queueFocus, + focus, + registerInput, + onSlotFocus, + onSlotBlur, + } = useOtpContext(); + + const char = value[index] ?? ''; + + // 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. + 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, + 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) => { + // 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(); + }, + 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; + } + + 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; + + // 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 'Home': + case 'ArrowUp': { + event.preventDefault(); + focus(0); + return; + } + case 'End': + case 'ArrowDown': { + event.preventDefault(); + focus(endIndex); + return; + } + case 'Delete': { + event.preventDefault(); + const next = removeAt(value, index); + setValue(next); + queueFocus(index, next); + return; + } + case 'Backspace': { + event.preventDefault(); + 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) { + 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..27dea3b67c6 --- /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..5cbc9365e63 --- /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..ba87227a936 --- /dev/null +++ b/packages/headless/src/primitives/otp/otp.test.tsx @@ -0,0 +1,530 @@ +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(); + }); + + 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(); + }); + + 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', () => { + 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', () => { + 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('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( + + + , + ); + 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..98fa114c8f3 --- /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..30aaa368927 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -49,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'; @@ -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..07e02199c36 --- /dev/null +++ b/packages/swingset/src/stories/otp.mdx @@ -0,0 +1,166 @@ +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 (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. + + + +## 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. 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 new file mode 100644 index 00000000000..23cc5ef36b8 --- /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 ( + + + + ); +}