diff --git a/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx b/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx index b46f7c89..290ad832 100644 --- a/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx +++ b/apps/blocks/src/blocks/account/account-phone-numbers/account-phone-numbers.test.tsx @@ -40,7 +40,7 @@ async function renderBlock(adapter: AccountPhoneNumbersAdapter, props: Partial

screen.getByText(display).closest('li') as HTMLElement; -const codeBox = (index: number) => screen.getByLabelText(`Digit ${index} of 6`); +const codeField = () => screen.getByLabelText('Verification code'); describe('AccountPhoneNumbers', () => { it('lists numbers with their status on one line', async () => { @@ -79,13 +79,13 @@ describe('AccountPhoneNumbers', () => { expect(adapter.sendCode).toHaveBeenCalledWith({ id: 'new-0', number: '+12025550143' }); expect(await screen.findByText('Enter the 6-digit code sent to +1 202 555 0143.')).toBeInTheDocument(); - await user.click(codeBox(1)); + await user.click(codeField()); await user.paste('000000'); await user.click(screen.getByRole('button', { name: 'Verify' })); expect(await screen.findByRole('alert')).toHaveTextContent('That code didn’t work.'); - expect(codeBox(1)).toHaveAttribute('aria-invalid', 'true'); + expect(codeField()).toHaveAttribute('aria-invalid', 'true'); - await user.click(codeBox(1)); + await user.click(codeField()); await user.paste('123456'); await user.click(screen.getByRole('button', { name: 'Verify' })); await waitFor(() => expect(within(rowFor('+1 202 555 0143')).getByText('Verified')).toBeInTheDocument()); diff --git a/packages/ui/registry.json b/packages/ui/registry.json index 8c527cd3..5d02a634 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -2632,13 +2632,17 @@ "name": "input-otp", "type": "registry:ui", "title": "Input OTP", - "description": "A one-time-code input with one box per digit that handles typing, Backspace, arrows, paste, and OS autofill as one value.", + "description": "A one-time-code input that draws one slot per digit over a single field, so typing, selection, paste, and OS autofill behave like text, with a gliding focus ring and digit motion.", "categories": [ "input" ], "docs": "Import: `import { InputOtp } from '@/components/ui/input-otp'`", + "dependencies": [ + "motion" + ], "registryDependencies": [ - "cn" + "cn", + "fluid-highlight" ], "files": [ { diff --git a/packages/ui/src/components/input-otp.tsx b/packages/ui/src/components/input-otp.tsx index d53af76b..bf97d9e7 100644 --- a/packages/ui/src/components/input-otp.tsx +++ b/packages/ui/src/components/input-otp.tsx @@ -1,36 +1,37 @@ 'use client'; -import { useId, useRef, useState, type ClipboardEvent, type KeyboardEvent, type Ref } from 'react'; +import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent, type FocusEvent, type Ref } from 'react'; +import { motion, useAnimate, useReducedMotion, type Transition } from 'motion/react'; +import { FluidHighlight } from '../lib/motion/fluid-highlight'; import { cn } from '../lib/utils'; -// Adapted from BoardUI Input OTP (MIT), https://www.boardui.com/r/input-otp.json. - /** - * One-time-code input: one box per digit, side by side. + * One-time-code input: one slot per digit, side by side. * - * It looks like N inputs but behaves like one value, and the two views stay in step in every direction: + * The slots are drawn, not typed into. A single transparent `` lies over the whole row and owns the value, + * the caret, and the selection; the slots mirror it. That one-field model is what makes the code behave like text: * - * type fills a box and advances - * Backspace clears the box, or steps back when the box is already empty - * arrows move between boxes without changing anything - * paste distributes across the boxes from wherever the caret is - * autofill the OS hands the whole code to the first box at once + * type fills the selected slot and moves on + * Backspace deletes like any text field; later digits close the gap + * ⌘A / Shift selects across slots, so cut, copy, and paste work on the whole code or a slice of it + * paste strips separators and inserts at the caret; a complete code replaces the value outright + * autofill the OS hands the whole code to the one field, which is all `autoComplete="one-time-code"` needs * - * That last one is why every box carries `autoComplete="one-time-code"` and why the change handler accepts more - * than a single character: iOS and Chrome both deliver the full code into whichever box has focus. + * It also means one tab stop and one labelled field for assistive tech, rather than six "digit N" boxes. * - * Digits are monospace so the boxes stay optically even; in a proportional face a `1` is visibly narrower than - * an `8`, which makes a row of fixed boxes look mis-set even when it is perfectly aligned. + * Because the real glyphs and caret are transparent, the slots can animate what the user sees: digits rise into + * place, a drawn caret blinks in the empty active slot, the focus ring glides between slots, and the row shakes + * once when the code turns invalid. All of it stands still under reduced motion. */ interface InputOtpProps { - /** Number of digit boxes. */ + /** Number of digit slots. */ length?: number; - /** Controlled value. Longer strings are truncated to `length`. */ + /** Controlled value. Non-digits are dropped and longer strings are truncated to `length`. */ value?: string; defaultValue?: string; onChange?: (value: string) => void; - /** Fires once the last box is filled. */ + /** Fires when an edit leaves every slot filled with a code that differs from the previous one. */ onComplete?: (value: string) => void; isDisabled?: boolean; isInvalid?: boolean; @@ -44,9 +45,21 @@ interface InputOtpProps { ref?: Ref; } +interface SlotSelection { + start: number; + end: number; +} + +type SelectionDirection = 'forward' | 'backward' | 'none'; + const DIGITS_ONLY = /\D/g; -// Preserve empty interior slots when editing a controlled code. -const INVALID_SLOT = /[^0-9 ]/g; +// Chrome restores autofill styling and the caret position a beat after a value change, so the mirror re-reads them. +const RESYNC_DELAYS = [0, 10, 50]; +// A hard on/off blink, like a text caret, rather than a fade. +const CARET_BLINK = { opacity: [1, 1, 0, 0] }; +const CARET_TIMING: Transition = { duration: 1, times: [0, 0.5, 0.5, 1], ease: 'linear', repeat: Infinity }; + +const toCode = (raw: string, length: number) => raw.replace(DIGITS_ONLY, '').slice(0, length); function InputOtp({ length = 6, @@ -63,135 +76,246 @@ function InputOtp({ className, ref, }: InputOtpProps) { - const groupId = useId(); - const inputsRef = useRef>([]); - const [internal, setInternal] = useState(defaultValue.replace(DIGITS_ONLY, '').slice(0, length)); + const inputRef = useRef(null); + const previousSelection = useRef<[number | null, number | null, SelectionDirection]>([null, null, 'none']); + const wasInvalid = useRef(isInvalid); + const [rowRef, animate] = useAnimate(); + const reduceMotion = useReducedMotion(); + const [internal, setInternal] = useState(() => toCode(defaultValue, length)); + const [isFocused, setIsFocused] = useState(false); + const [selection, setSelection] = useState(null); const controlled = value !== undefined; - const code = (controlled ? value : internal).replace(INVALID_SLOT, '').slice(0, length); - - const commit = (next: string) => { - const clean = next.replace(INVALID_SLOT, '').slice(0, length); - if (!controlled) setInternal(clean); - onChange?.(clean); - if (clean.length === length && !clean.includes(' ')) onComplete?.(clean); - }; + const code = toCode(controlled ? value : internal, length); - const focusBox = (index: number) => { - const target = inputsRef.current[Math.max(0, Math.min(index, length - 1))]; - target?.focus(); - target?.select(); - }; + const mirror = (start: number, end: number) => + setSelection((current) => (current?.start === start && current.end === end ? current : { start, end })); - const clearAt = (index: number) => { - const chars = code.padEnd(length, ' ').split(''); - chars[index] = ' '; - commit(chars.join('').trimEnd()); + const commit = (next: string) => { + if (next === code) return; + if (!controlled) setInternal(next); + onChange?.(next); + // Comparing against the previous code keeps a re-render or an identical paste from verifying twice, while + // correcting one digit of a full code still counts as a new attempt. + if (next.length === length) onComplete?.(next); }; - /** Writes `digits` starting at `index`, which covers typing and autofill. */ - const writeFrom = (index: number, digits: string) => { - const clean = digits.replace(DIGITS_ONLY, ''); - if (clean === '') { - clearAt(index); - return; - } - // Autofill or a complete pasted code replaces the whole value, even when a middle slot has focus. - const start = clean.length >= length ? 0 : index; - const chars = code.padEnd(length, ' ').split(''); - for (let offset = 0; offset < clean.length && start + offset < length; offset += 1) { - chars[start + offset] = clean[offset]; - } - commit(chars.join('').trimEnd()); - focusBox(start + clean.length); - }; + useEffect(() => { + const input = inputRef.current; + if (!input) return; - const onKeyDown = (event: KeyboardEvent, index: number) => { - if (event.key === 'Backspace') { - event.preventDefault(); - // Clear in place; the caret only steps back on an already-empty box, which makes holding Backspace feel right. - if (code[index]?.trim()) { - clearAt(index); + // The native caret only ever sits between characters. Over a slot, that reads as nothing being selected, so + // a collapsed caret inside a full run is widened to select the digit after it (or before it, when moving left). + const syncSelection = () => { + if (document.activeElement !== input) { + setSelection(null); return; } - clearAt(Math.max(0, index - 1)); - focusBox(index - 1); - } else if (event.key === 'ArrowLeft') { - event.preventDefault(); - focusBox(index - 1); - } else if (event.key === 'ArrowRight') { - event.preventDefault(); - focusBox(index + 1); - } + const { selectionStart, selectionEnd, selectionDirection, maxLength, value: current } = input; + const [, previousEnd] = previousSelection.current; + let start = -1; + let end = -1; + let direction: SelectionDirection | undefined; + + if (current.length !== 0 && selectionStart !== null && selectionEnd !== null) { + const isCaret = selectionStart === selectionEnd; + const isAppending = selectionStart === current.length && current.length < maxLength; + if (isCaret && !isAppending) { + const caret = selectionStart; + if (caret === 0) { + [start, end, direction] = [0, 1, 'forward']; + } else if (caret === maxLength) { + [start, end, direction] = [caret - 1, caret, 'backward']; + } else if (maxLength > 1 && current.length > 1) { + let offset = 0; + const [previousStart] = previousSelection.current; + if (previousStart !== null && previousEnd !== null) { + direction = caret < previousEnd ? 'backward' : 'forward'; + const wasAppending = previousStart === previousEnd && previousStart < maxLength; + if (direction === 'backward' && !wasAppending) offset = -1; + } + start = caret + offset; + end = caret + offset + 1; + } + } + if (start !== -1 && end !== -1 && start !== end) input.setSelectionRange(start, end, direction); + } + + const nextStart = start !== -1 ? start : (selectionStart ?? 0); + const nextEnd = end !== -1 ? end : (selectionEnd ?? 0); + mirror(nextStart, nextEnd); + previousSelection.current = [nextStart, nextEnd, direction ?? selectionDirection ?? 'none']; + }; + + document.addEventListener('selectionchange', syncSelection, { capture: true }); + syncSelection(); + if (document.activeElement === input) setIsFocused(true); + return () => document.removeEventListener('selectionchange', syncSelection, { capture: true }); + }, []); + + useEffect(() => { + const timeouts = RESYNC_DELAYS.map((delay) => + setTimeout(() => { + const input = inputRef.current; + if (!input) return; + // A non-bubbling `input` event clears Chrome's `:autofill` state without reaching React's onChange. + input.dispatchEvent(new Event('input')); + if (document.activeElement !== input || input.selectionStart === null || input.selectionEnd === null) return; + mirror(input.selectionStart, input.selectionEnd); + previousSelection.current = [input.selectionStart, input.selectionEnd, input.selectionDirection ?? 'none']; + }, delay), + ); + return () => timeouts.forEach(clearTimeout); + }, [code, isFocused]); + + useEffect(() => { + const turnedInvalid = isInvalid && !wasInvalid.current; + wasInvalid.current = isInvalid; + if (!turnedInvalid || reduceMotion || !rowRef.current) return; + animate(rowRef.current, { x: [0, -6, 6, -4, 4, -2, 0] }, { duration: 0.4, ease: 'easeInOut' }); + }, [isInvalid, reduceMotion, animate, rowRef]); + + const onInputChange = (event: ChangeEvent) => { + const next = toCode(event.target.value, length); + // Deleting doesn't fire `selectionchange`, so the mirror is nudged by hand. + if (next.length < code.length) document.dispatchEvent(new Event('selectionchange')); + commit(next); }; - const onPaste = (event: ClipboardEvent, index: number) => { + const onPaste = (event: ClipboardEvent) => { + // Always handled here: a native paste of "123 456" would be cut to `maxLength` before the spaces were dropped, + // and iOS pastes over the whole field rather than at the caret. event.preventDefault(); - writeFrom(index, event.clipboardData.getData('text')); + const input = event.currentTarget; + const pasted = event.clipboardData.getData('text/plain').replace(DIGITS_ONLY, ''); + if (pasted === '') return; + const start = input.selectionStart ?? code.length; + const end = input.selectionEnd ?? start; + // A complete code replaces the value even when the caret is mid-way; it's an autofill or a copied message. + const next = + pasted.length >= length ? pasted.slice(0, length) : (code.slice(0, start) + pasted + code.slice(end)).slice(0, length); + input.value = next; + commit(next); + const caret = Math.min(next.length, length - 1); + input.setSelectionRange(caret, next.length); + mirror(caret, next.length); }; + const onFocus = (event: FocusEvent) => { + // Focus lands on the first empty slot, or selects the last digit of a full code so typing replaces it. + const input = event.currentTarget; + const start = Math.min(input.value.length, length - 1); + input.setSelectionRange(start, input.value.length); + mirror(start, input.value.length); + setIsFocused(true); + }; + + const isSlotActive = (index: number) => + isFocused && + selection !== null && + (selection.start === selection.end ? index === selection.start : index >= selection.start && index < selection.end); + const isRange = selection !== null && selection.end - selection.start > 1; + return (

, which breaks React's next update; a code is never translatable. + translate="no" + className={cn('w-full', size === 'sm' ? 'max-w-[15.5rem]' : 'max-w-[21rem]', className)} > - {Array.from({ length }, (_, index) => { - const filled = code[index]?.trim() ?? ''; - const gapBefore = groupEvery !== undefined && index > 0 && index % groupEvery === 0; - return ( -
- { - inputsRef.current[index] = node; - }} - id={`${groupId}-${index}`} - // `text` with a numeric mode rather than `number`: a number input brings spinners, accepts `e` - // and `-`, and reports an empty value for anything it considers malformed. - type="text" - inputMode="numeric" - autoComplete="one-time-code" - // Long enough to accept a full autofilled code in one box. - maxLength={length} - disabled={isDisabled} - aria-label={`Digit ${index + 1} of ${length}`} - aria-invalid={isInvalid || undefined} +
+ {Array.from({ length }, (_, index) => { + const digit = code[index]; + const isActive = isSlotActive(index); + const gapBefore = groupEvery !== undefined && index > 0 && index % groupEvery === 0; + return ( +
writeFrom(index, event.target.value)} - onKeyDown={(event) => onKeyDown(event, index)} - onPaste={(event) => onPaste(event, index)} - onFocus={(event) => event.target.select()} + data-active={isActive || undefined} + // The single active slot is what the gliding ring tracks; a range is shown by the slots themselves. + data-highlighted={(isActive && !isRange) || undefined} className={cn( - 'w-full min-w-0 text-center font-mono tabular-nums', - size === 'sm' ? 'h-9 rounded-md text-base' : 'h-11 rounded-lg text-lg sm:h-12', + 'relative flex min-w-0 flex-1 items-center justify-center overflow-hidden font-mono tabular-nums', + size === 'sm' ? 'h-9 max-w-9 rounded-md text-base' : 'h-11 max-w-10 rounded-lg text-lg sm:h-12 sm:max-w-12', + gapBefore && (size === 'sm' ? 'ml-1.5' : 'ml-1 sm:ml-2'), 'border border-input bg-background text-foreground shadow-xs dark:bg-input/32', - 'outline-none transition-[background-color,border-color,box-shadow] duration-150 ease-out motion-reduce:transition-none', - 'hover:border-ring/50 focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/35', + 'transition-[background-color,border-color] duration-150 ease-out motion-reduce:transition-none', + !isDisabled && 'group-hover/otp:border-ring/50', + isActive && isRange && 'bg-accent', // Keep the invalid state visible without relying on tint alone. - isInvalid && 'border-destructive bg-destructive/5 text-destructive hover:border-destructive', - isDisabled && 'cursor-not-allowed bg-muted text-muted-foreground shadow-none', + isInvalid && 'border-destructive bg-destructive/5 text-destructive group-hover/otp:border-destructive', + isDisabled && 'bg-muted text-muted-foreground shadow-none', + )} + > + {digit !== undefined && ( + // Keyed on the digit so each new one replays the rise, including a correction in place. + + {digit} + + )} + {isActive && digit === undefined && ( + // The real caret is transparent, so the empty active slot draws its own. + )} - /> -
- ); - })} +
+ ); + })} + + setIsFocused(false)} + className={cn( + // Present and hit-testable over the whole row, but invisible: transparent ink rather than opacity, + // because iOS only offers paste on a press-and-hold over a visible field. + 'absolute inset-0 z-20 size-full cursor-text appearance-none border-0 bg-transparent p-0 font-mono leading-none', + 'text-transparent caret-transparent tracking-[-0.5em] tabular-nums shadow-none outline-none', + // A font as tall as the row keeps every glyph under the slots, so a tap anywhere lands in the field. + size === 'sm' ? 'text-[36px]' : 'text-[44px] sm:text-[48px]', + 'selection:bg-transparent selection:text-transparent', + 'autofill:opacity-0! autofill:shadow-none! autofill:[-webkit-text-fill-color:transparent]!', + // iOS lays the glyphs out wider and leaks the caret at the edge; squeeze them back under the slots. + 'supports-[-webkit-touch-callout:none]:-left-px supports-[-webkit-touch-callout:none]:right-px', + 'supports-[-webkit-touch-callout:none]:font-thin supports-[-webkit-touch-callout:none]:tracking-[-0.6em]', + 'disabled:cursor-not-allowed', + )} + /> +
); } diff --git a/packages/ui/test/input-otp.test.tsx b/packages/ui/test/input-otp.test.tsx index 30b4ea61..d5e99d78 100644 --- a/packages/ui/test/input-otp.test.tsx +++ b/packages/ui/test/input-otp.test.tsx @@ -33,7 +33,8 @@ function Harness({ onComplete }: { onComplete?: (value: string) => void }) { ); } -const box = (index: number) => container!.querySelector(`[aria-label="Digit ${index} of 6"]`)!; +const field = () => container!.querySelector('input')!; +const slots = () => Array.from(container!.querySelectorAll('[data-slot="input-otp-slot"]')); const value = () => container!.querySelector('[data-testid="value"]')!.textContent; async function type(input: HTMLInputElement, text: string) { @@ -50,47 +51,61 @@ async function paste(input: HTMLInputElement, text: string) { await act(async () => input.dispatchEvent(event)); } -async function press(input: HTMLInputElement, key: string) { - await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))); -} - describe('InputOtp', () => { - it('fills a box and advances focus as digits are typed', async () => { + it('mirrors the single field into one slot per digit', async () => { await render(); - await type(box(1), '4'); - expect(value()).toBe('4'); - expect(document.activeElement).toBe(box(2)); + await type(field(), '42'); + expect(value()).toBe('42'); + expect(slots().map((slot) => slot.textContent)).toEqual(['4', '2', '', '', '', '']); }); - it('ignores non-digits', async () => { + it('drops non-digits', async () => { await render(); - await type(box(1), 'a'); - expect(value()).toBe(''); + await type(field(), '4a'); + expect(value()).toBe('4'); }); - it('distributes a pasted or autofilled code and reports completion', async () => { + it('strips separators from a pasted code and reports completion', async () => { const onComplete = vi.fn(); await render(); - await paste(box(3), '123 456'); + await act(async () => field().setSelectionRange(0, 0)); + await paste(field(), '123 456'); expect(value()).toBe('123456'); expect(onComplete).toHaveBeenCalledWith('123456'); }); - it('clears in place, then steps back on an empty box', async () => { + it('lets a complete code replace the value wherever the caret is', async () => { + await render(); + await type(field(), '99'); + await act(async () => field().setSelectionRange(1, 1)); + await paste(field(), '123456'); + expect(value()).toBe('123456'); + }); + + it('completes again only when a full code changes', async () => { + const onComplete = vi.fn(); + await render(); + await type(field(), '123456'); + await paste(field(), '123456'); + expect(onComplete).toHaveBeenCalledTimes(1); + await type(field(), '123457'); + expect(onComplete).toHaveBeenLastCalledWith('123457'); + expect(onComplete).toHaveBeenCalledTimes(2); + }); + + it('highlights the slot the caret lands on when focused', async () => { await render(); - await paste(box(1), '12'); - await press(box(2), 'Backspace'); - expect(value()).toBe('1'); - await press(box(2), 'Backspace'); - expect(value()).toBe(''); - expect(document.activeElement).toBe(box(1)); + await type(field(), '12'); + await act(async () => field().focus()); + expect(slots()[2].hasAttribute('data-highlighted')).toBe(true); + expect(slots().filter((slot) => slot.hasAttribute('data-highlighted'))).toHaveLength(1); }); - it('marks every box invalid without putting aria-invalid on the group', async () => { + it('exposes one labelled field and marks it invalid', async () => { await render(); - const group = container!.querySelector('[role="group"]')!; - expect(group.getAttribute('aria-label')).toBe('Verification code'); - expect(group.hasAttribute('aria-invalid')).toBe(false); - expect(box(1).getAttribute('aria-invalid')).toBe('true'); + expect(container!.querySelectorAll('input')).toHaveLength(1); + expect(field().getAttribute('aria-label')).toBe('Verification code'); + expect(field().getAttribute('aria-invalid')).toBe('true'); + expect(slots().every((slot) => slot.getAttribute('aria-hidden') === 'true')).toBe(true); }); });