Skip to content

Instantly share code, notes, and snippets.

@SagnikPradhan
Last active February 12, 2024 15:32
Show Gist options
  • Save SagnikPradhan/77068e79f3b58f7012a2ba5328704bd1 to your computer and use it in GitHub Desktop.
Save SagnikPradhan/77068e79f3b58f7012a2ba5328704bd1 to your computer and use it in GitHub Desktop.
Controlled Pin Input
import { Text } from "~/components/primitives/text/text";
import { cx } from "~/utils/css";
import * as LabelPrimitive from "@radix-ui/react-label";
import React, { useCallback, useEffect, useId, useRef } from "react";
interface ControlledPinInputProps {
readonly name: string;
readonly label?: string;
readonly description?: string;
readonly error?: string;
readonly length?: number;
readonly inputMode?: "text" | "numeric";
readonly disabled?: boolean;
readonly value: string;
readonly onChange: (value: string) => void;
readonly onComplete?: () => void;
}
export const ControlledPinInput = ({
name,
label,
description,
error,
length = 6,
inputMode = "numeric",
disabled,
value,
onChange,
onComplete,
}: ControlledPinInputProps) => {
const id = useId();
const describedBy = error ? `${id}-error` : `${id}-description`;
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([]);
const focusInput = useCallback((element: HTMLInputElement | null) => {
if (element) element.focus();
}, []);
const keyDownHandlerFactory = useCallback(
(idx: number) => (event: React.KeyboardEvent<HTMLInputElement>) => {
const codes = Array.from({ length }).map(
(_, index) => value[index] || " ",
);
// Remove the current element if it's not empty
// Otherwise, remove the previous element
// Focus the previous element
if (event.key === "Backspace") {
if (codes[idx] === " " && codes[idx - 1] !== " ") codes[idx - 1] = " ";
else codes[idx] = " ";
onChange(codes.join(""));
return focusInput(codeInputRefs.current[idx - 1]);
}
// Focus the next element
if (event.key === "Tab" || event.key === "ArrowRight")
return focusInput(codeInputRefs.current[idx + 1]);
// Focus the previous element
if (event.key === "ArrowLeft")
return focusInput(codeInputRefs.current[idx - 1]);
// Probably non alphanumeric key, ignore
if (
event.key.length !== 1 ||
event.altKey ||
event.metaKey ||
event.ctrlKey
)
return;
// Set the current element to the pressed key
// Focus the next element
codes[idx] = event.key;
onChange(codes.join(""));
focusInput(codeInputRefs.current[idx + 1]);
},
[value, length, onChange],
);
const pasteHandlerFactory = useCallback(
(idx: number): React.ClipboardEventHandler =>
(event) => {
const paste = event.clipboardData.getData("text").trim();
if (paste === "") return;
if (inputMode === "numeric" && !/^\d+$/.test(paste)) return;
if (inputMode === "text" && !/^\w+$/.test(paste)) return;
// If the paste is longer than the remaining length, ignore the rest
// Otherwise, add the paste to the current value
if (paste.length >= length) onChange(paste.slice(0, length));
else onChange(value.slice(0, idx) + paste.slice(0, length - idx));
},
[value, length, onChange],
);
useEffect(() => {
if (value.trim().length === length) onComplete?.();
}, [value, onComplete]);
return (
<div className="flex flex-col gap-1">
{label && (
<LabelPrimitive.Root asChild htmlFor={id} id="">
<Text>{label}</Text>
</LabelPrimitive.Root>
)}
{description && (
<Text
id={`${id}-description`}
variant="secondary"
className="text-gray-500 [text-wrap:balance]"
>
{description}
</Text>
)}
<div className="my-1 flex gap-2">
{Array.from({ length }).map((_, idx) => (
<input
aria-hidden
key={idx}
type="text"
maxLength={1}
inputMode={inputMode}
disabled={disabled}
value={value[idx]}
onKeyDownCapture={keyDownHandlerFactory(idx)}
onPasteCapture={pasteHandlerFactory(idx)}
ref={(ref) => (codeInputRefs.current[idx] = ref)}
className={cx([
"rounded-2 h-12 w-12 border border-gray-300 text-center",
"caret-transparent",
"focus:border-gray-600 focus-visible:outline-none",
"disabled:border-gray-200 disabled:text-gray-300",
error && "border-red-600 text-red-600 focus:border-red-600",
])}
/>
))}
</div>
<input
type="hidden"
name={name}
value={value}
autoComplete="one-time-code"
minLength={length}
maxLength={length}
aria-describedby={describedBy}
aria-invalid={!!error}
/>
{error && (
<Text id={`${id}-error`} variant="tertiary" className="text-red-600">
{error}
</Text>
)}
</div>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment