Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🎡 Accessibility role="group" #432

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
node_modules
/lib
/demo
pnpm-lock.yaml
package-lock.json
yarn.lock

# Editor config
.vscode
Expand Down
3 changes: 2 additions & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
"arrowParens": "always",
"printWidth": 120,
"semi": true,
"singleQuote": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"embeddedLanguageFormatting": "auto",
"useTabs": false
}
2 changes: 2 additions & 0 deletions example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ dist-ssr
*.sln
*.sw?
.vercel
pnpm-lock.yaml
package-lock.json
10 changes: 5 additions & 5 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import React from 'react';
import React, { useState } from 'react';
import { TConfig } from './utils'
import OTPInput from '../../src';

function App() {
const [{ otp, numInputs, separator, minLength, maxLength, placeholder, inputType }, setConfig] = React.useState({
const [{ otp, numInputs, separator, minLength, maxLength, placeholder, inputType }, setConfig] = useState<TConfig>({
otp: '',
numInputs: 4,
separator: '-',
minLength: 0,
maxLength: 40,
placeholder: '',
inputType: 'text' as const,
inputType: 'text',
});

const handleOTPChange = (otp: string) => {
Expand All @@ -22,11 +23,10 @@ function App() {
};

const handleNumInputsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
let numInputs = event.target.valueAsNumber;
let numInputs = Number(event.target.value);

if (numInputs < minLength || numInputs > maxLength) {
numInputs = 4;

console.error(`Please enter a value between ${minLength} and ${maxLength}`);
}

Expand Down
16 changes: 16 additions & 0 deletions example/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
enum InputType {
"text",
"number",
"password",
"tel"
}

export type TConfig = {
otp: string;
numInputs: number;
separator: string;
minLength:number;
maxLength:number;
placeholder:string;
inputType: "text" | "number" | "password" | "tel"
}
787 changes: 0 additions & 787 deletions example/yarn.lock

This file was deleted.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-otp-input",
"version": "3.1.1",
"version": "3.1.2",
"description": "A fully customizable, one-time password input component for the web built with React",
"main": "lib/index.js",
"module": "lib/index.esm.js",
Expand Down
138 changes: 70 additions & 68 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import React from 'react';
import React, { useEffect, useRef, useState } from "react";

type AllowedInputTypes = 'password' | 'text' | 'number' | 'tel';
type AllowedInputTypes = "password" | "text" | "number" | "tel";

type InputProps = Required<
Pick<
React.InputHTMLAttributes<HTMLInputElement>,
| 'value'
| 'onChange'
| 'onFocus'
| 'onBlur'
| 'onKeyDown'
| 'onPaste'
| 'aria-label'
| 'autoComplete'
| 'style'
| 'inputMode'
| 'onInput'
| "value"
| "onChange"
| "onFocus"
| "onBlur"
| "onKeyDown"
| "onPaste"
| "aria-label"
| "autoComplete"
| "style"
| "inputMode"
| "onInput"
> & {
ref: React.RefCallback<HTMLInputElement>;
placeholder: string | undefined;
Expand Down Expand Up @@ -51,94 +51,94 @@ interface OTPInputProps {
skipDefaultStyles?: boolean; // TODO: Remove in next major release
}

const isStyleObject = (obj: unknown) => typeof obj === 'object' && obj !== null;
const isStyleObject = (obj: unknown) => typeof obj === "object" && obj !== null;

const OTPInput = ({
value = '',
value = "",
numInputs = 4,
onChange,
onPaste,
renderInput,
shouldAutoFocus = false,
inputType = 'text',
inputType = "text",
renderSeparator,
placeholder,
containerStyle,
inputStyle,
skipDefaultStyles = false,
}: OTPInputProps) => {
const [activeInput, setActiveInput] = React.useState(0);
const inputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
const [activeInput, setActiveInput] = useState<number>(0);
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);

const getOTPValue = () => (value ? value.toString().split('') : []);
/** this will return our expected OTP value as Array */
const getOTPValue = (): string[] => (value ? value.toString()?.split("") : []);
const isInputNum = inputType === "number" || inputType === "tel";

const isInputNum = inputType === 'number' || inputType === 'tel';

React.useEffect(() => {
useEffect(() => {
inputRefs.current = inputRefs.current.slice(0, numInputs);
}, [numInputs]);

React.useEffect(() => {
useEffect(() => {
if (shouldAutoFocus) {
inputRefs.current[0]?.focus();
}
}, [shouldAutoFocus]);

const getPlaceholderValue = () => {
if (typeof placeholder === 'string') {
/** this method will return us either placeholder or nothing (undefined) */
const getPlaceholderValue = (): string | undefined => {
if (typeof placeholder === "string") {
if (placeholder.length === numInputs) {
return placeholder;
}

if (placeholder.length > 0) {
console.error('Length of the placeholder should be equal to the number of inputs.');
console.error("Length of the placeholder should be equal to the number of inputs.");
}
}
return undefined;
};

const isInputValueValid = (value: string) => {
const isTypeValid = isInputNum ? !isNaN(Number(value)) : typeof value === 'string';
return isTypeValid && value.trim().length === 1;
const isInputValueValid = (value: string): boolean => {
const isTypeValid = isInputNum ? !isNaN(Number(value)) : typeof value === "string";
return isTypeValid && value?.trim().length === 1;
};

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;

if (isInputValueValid(value)) {
changeCodeAtFocus(value);
if (isInputValueValid(event.target.value)) {
changeCodeAtFocus(event.target.value);
focusInput(activeInput + 1);
}
};

const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { nativeEvent } = event;
console.log("native event", nativeEvent);
const value = event.target.value;

if (!isInputValueValid(value)) {
// Pasting from the native autofill suggestion on a mobile device can pass
// the pasted string as one long input to one of the cells. This ensures
// that we handle the full input and not just the first character.
if (value.length === numInputs) {
const hasInvalidInput = value.split('').some((cellInput) => !isInputValueValid(cellInput));
const hasInvalidInput = value?.split("")?.some((cellInput) => !isInputValueValid(cellInput));
if (!hasInvalidInput) {
handleOTPChange(value.split(''));
handleOTPChange(value.split(""));
focusInput(numInputs - 1);
}
}

// @ts-expect-error - This was added previously to handle and edge case
// for dealing with keyCode "229 Unidentified" on Android. Check if this is
// still needed.
if (nativeEvent.data === null && nativeEvent.inputType === 'deleteContentBackward') {
if (nativeEvent.data === null && nativeEvent.inputType === "deleteContentBackward") {
event.preventDefault();
changeCodeAtFocus('');
changeCodeAtFocus("");
focusInput(activeInput - 1);
}

// Clear the input if it's not valid value because firefox allows
// pasting non-numeric characters in a number type input
event.target.value = '';
event.target.value = "";
}
};

Expand All @@ -151,19 +151,22 @@ const OTPInput = ({
setActiveInput(activeInput - 1);
};

const keyboardKeyHandle = (event: React.KeyboardEvent<HTMLInputElement>, isDelete: boolean = false) => {
if (!isDelete) focusInput(activeInput - 1);
event.preventDefault();
changeCodeAtFocus("");
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const otp = getOTPValue();
if ([event.code, event.key].includes('Backspace')) {
if ([event.code, event.key].includes("Backspace")) {
keyboardKeyHandle(event, false);
} else if (event.code === "Delete") {
keyboardKeyHandle(event, true);
} else if (event.code === "ArrowLeft") {
event.preventDefault();
changeCodeAtFocus('');
focusInput(activeInput - 1);
} else if (event.code === 'Delete') {
event.preventDefault();
changeCodeAtFocus('');
} else if (event.code === 'ArrowLeft') {
event.preventDefault();
focusInput(activeInput - 1);
} else if (event.code === 'ArrowRight') {
} else if (event.code === "ArrowRight") {
event.preventDefault();
focusInput(activeInput + 1);
}
Expand All @@ -173,10 +176,10 @@ const OTPInput = ({
event.preventDefault();
focusInput(activeInput + 1);
} else if (
event.code === 'Spacebar' ||
event.code === 'Space' ||
event.code === 'ArrowUp' ||
event.code === 'ArrowDown'
event.code === "Spacebar" ||
event.code === "Space" ||
event.code === "ArrowUp" ||
event.code === "ArrowDown"
) {
event.preventDefault();
}
Expand All @@ -187,7 +190,8 @@ const OTPInput = ({

if (inputRefs.current[activeInput]) {
inputRefs.current[activeInput]?.focus();
inputRefs.current[activeInput]?.select();
/** removed select option based on issue (#427) */
// inputRefs.current[activeInput]?.select();
setActiveInput(activeInput);
}
};
Expand All @@ -199,7 +203,7 @@ const OTPInput = ({
};

const handleOTPChange = (otp: Array<string>) => {
const otpValue = otp.join('');
const otpValue = otp.join("");
onChange(otpValue);
};

Expand All @@ -211,19 +215,17 @@ const OTPInput = ({

// Get pastedData in an array of max size (num of inputs - current position)
const pastedData = event.clipboardData
.getData('text/plain')
.getData("text/plain")
.slice(0, numInputs - activeInput)
.split('');
.split("");

// Prevent pasting if the clipboard data contains non-numeric values for number inputs
if (isInputNum && pastedData.some((value) => isNaN(Number(value)))) {
return;
}
if (isInputNum && pastedData.some((value) => isNaN(Number(value)))) return;

// Paste data from focused input onwards
for (let pos = 0; pos < numInputs; ++pos) {
if (pos >= activeInput && pastedData.length > 0) {
otp[pos] = pastedData.shift() ?? '';
otp[pos] = pastedData.shift() ?? "";
nextActiveInput++;
}
}
Expand All @@ -234,36 +236,36 @@ const OTPInput = ({

return (
<div
style={Object.assign({ display: 'flex', alignItems: 'center' }, isStyleObject(containerStyle) && containerStyle)}
className={typeof containerStyle === 'string' ? containerStyle : undefined}
style={Object.assign({ display: "flex", alignItems: "center" }, isStyleObject(containerStyle) && containerStyle)}
className={typeof containerStyle === "string" ? containerStyle : undefined}
onPaste={onPaste}
>
{Array.from({ length: numInputs }, (_, index) => index).map((index) => (
<React.Fragment key={index}>
{renderInput(
{
value: getOTPValue()[index] ?? '',
value: getOTPValue()[index] ?? "",
placeholder: getPlaceholderValue()?.[index] ?? undefined,
ref: (element) => (inputRefs.current[index] = element),
onChange: handleChange,
onFocus: (event) => handleFocus(event)(index),
onBlur: handleBlur,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
autoComplete: 'off',
'aria-label': `Please enter OTP character ${index + 1}`,
autoComplete: "off",
"aria-label": `Please enter OTP character ${index + 1}`,
style: Object.assign(
!skipDefaultStyles ? ({ width: '1em', textAlign: 'center' } as const) : {},
!skipDefaultStyles ? ({ width: "1em", textAlign: "center" } as const) : {},
isStyleObject(inputStyle) ? inputStyle : {}
),
className: typeof inputStyle === 'string' ? inputStyle : undefined,
className: typeof inputStyle === "string" ? inputStyle : undefined,
type: inputType,
inputMode: isInputNum ? 'numeric' : 'text',
inputMode: isInputNum ? "numeric" : "text",
onInput: handleInputChange,
},
index
)}
{index < numInputs - 1 && (typeof renderSeparator === 'function' ? renderSeparator(index) : renderSeparator)}
{index < numInputs - 1 && (typeof renderSeparator === "function" ? renderSeparator(index) : renderSeparator)}
</React.Fragment>
))}
</div>
Expand Down
Loading