shadimage

Input OTP

One-time password input for Tile UI.

packages/react/src/components/input-otp/input-otp.tsx

import React, { createContext, useCallback, useContext, useRef, useState } from 'react';
import { getNextOtpIndex, getPrevOtpIndex, inputOtpStyleKeys, isOtpCharAllowed, joinOtpValue, splitOtpValue } from '../lib/core';
import type { InputOtpBaseProps, InputOtpMode, InputOtpSlotBaseProps } from '../lib/core';
import styles from './input-otp.module.scss';

interface InputOtpContextValue {
	value: string;
	maxLength: number;
	activeIndex: number;
	mode: InputOtpMode;
	disabled: boolean;
	registerSlot: (index: number, element: HTMLInputElement | null) => void;
	setActiveIndex: (index: number) => void;
	focusSlot: (index: number) => void;
	handleInputChange: (index: number, inputValue: string) => void;
	handleSlotKeyDown: (index: number, event: React.KeyboardEvent<HTMLInputElement>) => void;
	handlePaste: (event: React.ClipboardEvent<HTMLInputElement>) => void;
}

const InputOtpContext = createContext<InputOtpContextValue | null>(null);

function useInputOtpContext(): InputOtpContextValue {
	const context = useContext(InputOtpContext);
	if (!context) {
		throw new Error('InputOTP sub-components must be used within <InputOTP>.');
	}
	return context;
}

export interface InputOTPProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'value' | 'defaultValue'>, InputOtpBaseProps {
	containerClassName?: string;
}

const InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(
	(
		{ className = '', containerClassName = '', value, defaultValue = '', maxLength = 4, disabled = false, mode = 'alphanumeric', onChange, onComplete, children, ...props },
		ref,
	) => {
		const [internalValue, setInternalValue] = useState(defaultValue);
		const isControlled = value !== undefined;
		const currentValue = isControlled ? value : internalValue;
		const [activeIndex, setActiveIndexState] = useState(0);
		const slotsRef = useRef<(HTMLInputElement | null)[]>([]);

		function commit(next: string) {
			const sanitized = Array.from(next)
				.filter((char) => isOtpCharAllowed(char, mode))
				.slice(0, maxLength)
				.join('');
			if (!isControlled) {
				setInternalValue(sanitized);
			}
			onChange?.(sanitized);
			if (sanitized.length === maxLength) {
				onComplete?.(sanitized);
			}
			return sanitized;
		}

		function registerSlot(index: number, element: HTMLInputElement | null) {
			slotsRef.current[index] = element;
		}

		const focusSlot = useCallback((index: number) => {
			const slot = slotsRef.current[index];
			slot?.focus();
			slot?.select();
			setActiveIndexState(index);
		}, []);

		const setActiveIndex = useCallback((index: number) => {
			setActiveIndexState(index);
		}, []);

		function handleInputChange(index: number, inputValue: string) {
			const newChar =
				Array.from(inputValue)
					.filter((char) => isOtpCharAllowed(char, mode))
					.slice(-1)[0] ?? '';
			const chars = splitOtpValue(currentValue, maxLength);
			chars[index] = newChar;
			commit(joinOtpValue(chars, maxLength));
			if (newChar) {
				const next = getNextOtpIndex(index, maxLength);
				if (next !== null) {
					focusSlot(next);
				}
			}
		}

		function handleSlotKeyDown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
			if (event.key === 'Backspace') {
				event.preventDefault();
				const chars = splitOtpValue(currentValue, maxLength);
				if (chars[index]) {
					chars[index] = '';
					commit(joinOtpValue(chars, maxLength));
				} else {
					const prev = getPrevOtpIndex(index);
					if (prev !== null) {
						chars[prev] = '';
						commit(joinOtpValue(chars, maxLength));
						focusSlot(prev);
					}
				}
			} else if (event.key === 'ArrowLeft') {
				event.preventDefault();
				const prev = getPrevOtpIndex(index);
				if (prev !== null) {
					focusSlot(prev);
				}
			} else if (event.key === 'ArrowRight') {
				event.preventDefault();
				const next = getNextOtpIndex(index, maxLength);
				if (next !== null) {
					focusSlot(next);
				}
			} else if (event.key === 'Home') {
				event.preventDefault();
				focusSlot(0);
			} else if (event.key === 'End') {
				event.preventDefault();
				focusSlot(maxLength - 1);
			}
		}

		function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
			event.preventDefault();
			const text = event.clipboardData.getData('text');
			const pasted = Array.from(text)
				.filter((char) => isOtpCharAllowed(char, mode))
				.slice(0, maxLength);
			if (pasted.length === 0) {
				return;
			}
			const chars = splitOtpValue(currentValue, maxLength);
			for (let i = 0; i < pasted.length && activeIndex + i < maxLength; i++) {
				chars[activeIndex + i] = pasted[i];
			}
			commit(joinOtpValue(chars, maxLength));
			const nextIndex = Math.min(activeIndex + pasted.length, maxLength - 1);
			focusSlot(nextIndex);
		}

		return (
			<InputOtpContext.Provider
				value={{
					value: currentValue,
					maxLength,
					activeIndex,
					mode,
					disabled,
					registerSlot,
					setActiveIndex,
					focusSlot,
					handleInputChange,
					handleSlotKeyDown,
					handlePaste,
				}}>
				<div
					ref={ref}
					data-slot="input-otp"
					data-disabled={disabled}
					className={[styles[inputOtpStyleKeys.root], containerClassName, className].filter(Boolean).join(' ')}
					{...props}>
					{children}
				</div>
			</InputOtpContext.Provider>
		);
	},
);
InputOTP.displayName = 'InputOTP';

export interface InputOTPGroupProps extends React.HTMLAttributes<HTMLDivElement> {}

const InputOTPGroup = React.forwardRef<HTMLDivElement, InputOTPGroupProps>(({ className = '', children, ...props }, ref) => {
	return (
		<div ref={ref} data-slot="input-otp-group" className={[styles[inputOtpStyleKeys.group], className].filter(Boolean).join(' ')} {...props}>
			{children}
		</div>
	);
});
InputOTPGroup.displayName = 'InputOTPGroup';

export interface InputOTPSlotProps extends React.HTMLAttributes<HTMLDivElement>, InputOtpSlotBaseProps {}

const InputOTPSlot = React.forwardRef<HTMLDivElement, InputOTPSlotProps>(({ className = '', index, children, ...props }, ref) => {
	const context = useInputOtpContext();
	const char = context.value[index] ?? '';
	const isActive = context.activeIndex === index;

	function setRef(element: HTMLInputElement | null) {
		context.registerSlot(index, element);
	}

	return (
		<div ref={ref} data-slot="input-otp-slot" data-active={isActive} className={[styles[inputOtpStyleKeys.slot], className].filter(Boolean).join(' ')} {...props}>
			<input
				ref={setRef}
				value={char}
				disabled={context.disabled}
				inputMode={context.mode === 'numeric' ? 'numeric' : 'text'}
				autoComplete="one-time-code"
				aria-label={`Character ${index + 1}`}
				onFocus={(event) => {
					context.setActiveIndex(index);
					event.target.select();
				}}
				onChange={(event) => context.handleInputChange(index, event.target.value)}
				onKeyDown={(event) => context.handleSlotKeyDown(index, event)}
				onPaste={context.handlePaste}
			/>
			{children}
		</div>
	);
});
InputOTPSlot.displayName = 'InputOTPSlot';

export interface InputOTPSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}

const InputOTPSeparator = React.forwardRef<HTMLDivElement, InputOTPSeparatorProps>(({ className = '', ...props }, ref) => {
	return (
		<div ref={ref} data-slot="input-otp-separator" role="separator" className={[styles[inputOtpStyleKeys.separator], className].filter(Boolean).join(' ')} {...props}>
			<svg
				xmlns="http://www.w3.org/2000/svg"
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="none"
				stroke="currentColor"
				strokeWidth="2"
				strokeLinecap="round"
				strokeLinejoin="round"
				aria-hidden="true">
				<path d="M5 12h14" />
			</svg>
		</div>
	);
});
InputOTPSeparator.displayName = 'InputOTPSeparator';

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
export default InputOTP;

No selection tags yet — empty means no evidence, not lack of support.