'use client'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, } from 'react'; import { Surface, cn, focusRing, inputBase, sizeHeight, surfaceClasses, toneMap, useEffectiveSurface, } from '../common'; import { useControllableState } from '../hooks/useControllableState'; /** Public prop bag for {@link PixelOTPInput}. */ export interface PixelOTPInputProps { length?: number; value?: string; defaultValue?: string; onChange?: (next: string) => void; onComplete?: (full: string) => void; mask?: boolean; /** Canonical structural variant. */ variant?: 'numeric' | 'alphanumeric'; /** * @deprecated Use `variant` instead. Retained as alias for one minor. */ type?: 'numeric' | 'alphanumeric'; autoFocus?: boolean; separator?: React.ReactNode; size?: 'sm' | 'md' | 'lg'; surface?: Surface; name?: string; disabled?: boolean; } const cellSize: Record, string> = { sm: 'h-8 w-8 text-xs', md: 'h-10 w-10 text-sm', lg: 'h-12 w-12 text-base', }; function sanitize(raw: string, type: 'numeric' | 'alphanumeric'): string { if (type === 'numeric') return raw.replace(/[^0-9]/g, ''); return raw.replace(/[^0-9a-zA-Z]/g, ''); } export const PixelOTPInput = forwardRef( function PixelOTPInput( { length = 6, value, defaultValue, onChange, onComplete, mask = false, variant, type, autoFocus = false, separator, size = 'md', surface: surfaceProp, name, disabled = false, }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const resolvedVariant: 'numeric' | 'alphanumeric' = variant ?? type ?? 'numeric'; const [val, setVal] = useControllableState({ value, defaultValue: defaultValue ?? '', onChange, }); const cellRefs = useRef>([]); const hiddenRef = useRef(null); // Expose the first cell as the imperative ref (lets parents focus the OTP). useImperativeHandle(ref, () => cellRefs.current[0] as HTMLInputElement, []); const completedRef = useRef(false); useEffect(() => { const isFull = val.length === length && length > 0; if (isFull && !completedRef.current) { completedRef.current = true; onComplete?.(val); } else if (!isFull) { completedRef.current = false; } }, [val, length, onComplete]); useEffect(() => { if (autoFocus) cellRefs.current[0]?.focus(); }, [autoFocus]); const chars = useMemo(() => { const arr = new Array(length).fill(''); for (let i = 0; i < Math.min(val.length, length); i++) { arr[i] = val[i]; } return arr; }, [val, length]); const setCharAt = useCallback( (index: number, ch: string): string => { const next = chars.slice(); next[index] = ch; // Compact trailing empties off the end but preserve internal gaps. const joined = next.join(''); setVal(joined); return joined; }, [chars, setVal], ); const focusCell = useCallback((index: number) => { const target = cellRefs.current[index]; if (target) { target.focus(); // Place caret at end so further typing replaces cleanly. try { target.setSelectionRange(target.value.length, target.value.length); } catch { /* some input types don't support selectionRange — safe to ignore */ } } }, []); const handleChange = useCallback( (index: number, raw: string) => { // Take just the last entered char (handles fast typing where the // previous value is still present when the new key lands). const cleaned = sanitize(raw, resolvedVariant); if (!cleaned) { // Reject — clear cell visually by writing the prior value back. setCharAt(index, ''); return; } const ch = cleaned.slice(-1); const joined = setCharAt(index, ch); if (index < length - 1) { focusCell(index + 1); } // onComplete handled by effect once `val` is committed. void joined; }, [resolvedVariant, setCharAt, length, focusCell], ); const handleKeyDown = useCallback( (index: number, e: React.KeyboardEvent) => { if (e.key === 'Backspace') { if (chars[index]) { // Clear current cell. setCharAt(index, ''); e.preventDefault(); return; } if (index > 0) { // Move back AND clear the previous cell. setCharAt(index - 1, ''); focusCell(index - 1); e.preventDefault(); } return; } if (e.key === 'ArrowLeft' && index > 0) { focusCell(index - 1); e.preventDefault(); } else if (e.key === 'ArrowRight' && index < length - 1) { focusCell(index + 1); e.preventDefault(); } else if (e.key === 'Home') { focusCell(0); e.preventDefault(); } else if (e.key === 'End') { focusCell(length - 1); e.preventDefault(); } }, [chars, setCharAt, focusCell, length], ); const handlePaste = useCallback( (index: number, e: React.ClipboardEvent) => { e.preventDefault(); const data = e.clipboardData?.getData?.('text') ?? ''; const cleaned = sanitize(data, resolvedVariant); if (!cleaned) return; const next = chars.slice(); let cursor = index; for (const ch of cleaned) { if (cursor >= length) break; next[cursor] = ch; cursor++; } const joined = next.join(''); setVal(joined); const focusIndex = Math.min(cursor, length - 1); // Defer focus until React has flushed the value into the cells. requestAnimationFrame(() => focusCell(focusIndex)); }, [chars, setVal, resolvedVariant, length, focusCell], ); const handleFocus = useCallback( (e: React.FocusEvent) => { // Select the cell content so the next keystroke replaces it. try { e.currentTarget.select(); } catch { /* noop */ } }, [], ); const inputMode = resolvedVariant === 'numeric' ? 'numeric' : 'text'; const pattern = resolvedVariant === 'numeric' ? '[0-9]*' : '[0-9a-zA-Z]*'; return (
{Array.from({ length }).map((_, i) => { const ch = chars[i] ?? ''; const isLast = i === length - 1; return ( { cellRefs.current[i] = el; }} data-pxl-otp-cell="true" data-pxl-otp-index={i} type={mask ? 'password' : 'text'} inputMode={inputMode} pattern={pattern} autoComplete={i === 0 ? 'one-time-code' : 'off'} maxLength={1} disabled={disabled} value={ch} aria-label={`Digit ${i + 1} of ${length}`} onChange={(e) => handleChange(i, e.target.value)} onKeyDown={(e) => handleKeyDown(i, e)} onPaste={(e) => handlePaste(i, e)} onFocus={handleFocus} className={cn( inputBase, s.font, s.border, s.radius, s.transition, cellSize[size], sizeHeight[size].replace(/h-\S+\s?/, ''), // keep text-size token only focusRing, toneMap.neutral.ring, 'text-center px-0 border-retro-border-strong', 'mx-0.5 first:ml-0 last:mr-0', )} /> {separator && !isLast ? ( {separator} ) : null} ); })} {name ? ( ) : null}
); }, ); PixelOTPInput.displayName = 'PixelOTPInput';