"use client" import * as React from "react"; import { cn } from "../../../lib/utils"; // ============================================================================= // Types // ============================================================================= export interface SegmentedInputProps extends Omit, "value" | "defaultValue" | "onChange" | "size" | "pattern"> { /** Number of segments */ length?: number; /** Controlled value */ value?: string; /** Default uncontrolled value */ defaultValue?: string; /** Callback when value changes */ onChange?: (value: string) => void; /** Callback when all segments are filled */ onComplete?: (value: string) => void; /** Visual separator between segments */ separator?: React.ReactNode; /** Position(s) where separator appears (after these 0-based indices) */ separatorIndices?: number[]; /** Whether to auto-focus first segment on mount */ autoFocus?: boolean; /** Disabled state */ disabled?: boolean; /** Custom class for each segment */ segmentClassName?: string; /** Custom class for the container */ containerClassName?: string; /** Custom class for separator */ separatorClassName?: string; /** Pattern regex for each character */ pattern?: RegExp; /** Transform input character */ transform?: (char: string) => string; /** Fluid mode — segments stretch to fill container */ fluid?: boolean; /** Size variant */ size?: "sm" | "default" | "lg"; } // ============================================================================= // Utilities // ============================================================================= function cleanValue(value: string, pattern: RegExp, transform?: (char: string) => string): string { let cleaned = value.split("").filter((c) => pattern.test(c)).join(""); if (transform) { cleaned = cleaned.split("").map(transform).join(""); } return cleaned; } // ============================================================================= // Component // ============================================================================= const sizeVariants = { sm: "h-8 w-8 text-sm", default: "h-10 w-10 text-base", lg: "h-14 w-14 text-2xl", }; const SegmentedInput = React.forwardRef( ( { length = 6, value: controlledValue, defaultValue = "", onChange, onComplete, separator, separatorIndices, autoFocus = true, disabled = false, segmentClassName, containerClassName, separatorClassName, pattern = /[a-zA-Z0-9]/, transform, fluid = false, size = "default", name, className, ...props }, ref ) => { const isControlled = controlledValue !== undefined; const [internalValue, setInternalValue] = React.useState(defaultValue); const value = isControlled ? controlledValue : internalValue; const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]); const [activeIndex, setActiveIndex] = React.useState(-1); const cleanedValue = React.useMemo( () => cleanValue(value, pattern, transform), [value, pattern, transform] ); const updateValue = React.useCallback( (newValue: string) => { const cleaned = cleanValue(newValue, pattern, transform).slice(0, length); if (!isControlled) { setInternalValue(cleaned); } onChange?.(cleaned); if (cleaned.length === length) { onComplete?.(cleaned); } }, [isControlled, onChange, onComplete, length, pattern, transform] ); const focusSegment = React.useCallback((index: number) => { if (index >= 0 && index < length) { inputRefs.current[index]?.focus(); inputRefs.current[index]?.select(); } }, [length]); const handleSegmentChange = React.useCallback( (index: number, inputValue: string) => { const char = inputValue.slice(-1); if (!pattern.test(char)) return; const transformed = transform ? transform(char) : char; const newValue = cleanedValue.slice(0, index) + transformed + cleanedValue.slice(index + 1); updateValue(newValue); if (index < length - 1) { focusSegment(index + 1); } }, [cleanedValue, updateValue, focusSegment, length, pattern, transform] ); const handleKeyDown = React.useCallback( (index: number, event: React.KeyboardEvent) => { switch (event.key) { case "ArrowLeft": { event.preventDefault(); if (index > 0) focusSegment(index - 1); break; } case "ArrowRight": { event.preventDefault(); if (index < length - 1) focusSegment(index + 1); break; } case "Backspace": { event.preventDefault(); if (cleanedValue[index]) { const newValue = cleanedValue.slice(0, index) + cleanedValue.slice(index + 1); updateValue(newValue); } else if (index > 0) { focusSegment(index - 1); } break; } case "Delete": { event.preventDefault(); const newValue = cleanedValue.slice(0, index) + cleanedValue.slice(index + 1); updateValue(newValue); break; } case "Home": { event.preventDefault(); focusSegment(0); break; } case "End": { event.preventDefault(); focusSegment(length - 1); break; } default: { if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { if (!pattern.test(event.key)) { event.preventDefault(); return; } // Let onChange handle the input } } } }, [cleanedValue, focusSegment, length, pattern, updateValue] ); const handlePaste = React.useCallback( (event: React.ClipboardEvent) => { event.preventDefault(); const pasted = event.clipboardData.getData("text"); const cleaned = cleanValue(pasted, pattern, transform).slice(0, length); updateValue(cleaned); // Focus the next empty segment or the last one const nextEmpty = cleaned.length < length ? cleaned.length : length - 1; focusSegment(nextEmpty); }, [length, pattern, transform, updateValue, focusSegment] ); const handleFocus = React.useCallback((index: number) => { setActiveIndex(index); }, []); const handleBlur = React.useCallback(() => { setActiveIndex(-1); }, []); // Auto-focus first segment on mount React.useEffect(() => { if (autoFocus && !disabled) { const timer = setTimeout(() => focusSegment(0), 0); return () => clearTimeout(timer); } }, [autoFocus, disabled, focusSegment]); // Determine default separator positions (middle) const defaultSeparatorIndices = React.useMemo(() => { if (separatorIndices !== undefined) return separatorIndices; if (!separator) return []; const mid = Math.floor(length / 2); return mid > 0 && mid < length ? [mid - 1] : []; }, [separatorIndices, separator, length]); // Hidden input for form integration const isFormControl = React.useRef(false); const rootRef = React.useRef(null); React.useImperativeHandle(ref, () => rootRef.current!); React.useEffect(() => { if (rootRef.current) { isFormControl.current = !!rootRef.current.closest("form"); } }, []); const slots: React.ReactNode[] = []; for (let i = 0; i < length; i++) { const char = cleanedValue[i] || ""; const isActive = activeIndex === i; slots.push( { inputRefs.current[i] = el; }} type="text" inputMode="text" maxLength={1} disabled={disabled} value={char} data-active={isActive ? "" : undefined} data-filled={char ? "" : undefined} className={cn( "relative flex items-center justify-center border-y border-r border-input text-center text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", "focus-visible:outline-none focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", fluid ? `flex-1 min-w-0 aspect-square ${sizeVariants[size]}` : sizeVariants[size], segmentClassName )} onChange={(e) => handleSegmentChange(i, e.target.value)} onKeyDown={(e) => handleKeyDown(i, e)} onPaste={handlePaste} onFocus={() => handleFocus(i)} onBlur={handleBlur} {...props} /> ); // Add separator after this index if specified if (defaultSeparatorIndices.includes(i) && separator) { slots.push(
{separator}
); } } return ( <>
{slots}
{isFormControl.current && name && ( )} ); } ); SegmentedInput.displayName = "SegmentedInput"; export { SegmentedInput };