Skip to content
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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ const handlePaste: React.ClipboardEventHandler<HTMLDivElement> = (event) => {
<td>text</td>
<td>The type of the input that will be passed to the input element being rendered. In v2 <code>isInputNum</code> used to set the input type as <code>tel</code> and prevented non numerical entries, so as to avoid the spin buttons added to the inputs with input type <code>number</code>. That behaviour is still supported if you pass <code>tel</code> to the inputType prop.</td>
</tr>
<tr>
<td>overrideInputMode</td>
<td><code>"none"</code> | <code>"text"</code> | <code>"numeric"</code> | <code>"tel"</code> | <code>"email"</code> | <code>"url"</code> | <code>"decimal"</code> | <code>"search"</code></td>
<td>false</td>
<td>derived from <code>inputType</code> (<code>"numeric"</code> for <code>"number"</code>/<code>"tel"</code>, otherwise <code>"text"</code>; pass <code>"none"</code> to omit the attribute)</td>
<td>Override the <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode">inputMode</a> attribute applied to each input. When <code>inputType</code> is <code>number</code> or <code>tel</code>, the default <code>inputMode</code> is <code>numeric</code>, which causes screenreaders to announce the field as "stepper" or "telephone". Pass <code>"text"</code> to avoid this while keeping numeric validation, or <code>"none"</code> to omit the attribute entirely (note: <code>"none"</code> intentionally omits the attribute rather than setting <code>inputMode="none"</code> — React renders <code>inputMode="none"</code> as a text input on some browsers, which is not the intended behaviour). Omit this prop to use the input-type-driven default.</td>
</tr>
<tr>
<td>shouldAutoFocus</td>
<td>boolean</td>
Expand Down Expand Up @@ -165,7 +172,20 @@ Do not override the following props on the input component that you return from
- `onPaste`
- `onInput`
- `type`
- `inputMode`
- `inputMode` (use the `overrideInputMode` prop instead)
Comment thread
dikshit-n marked this conversation as resolved.

**Example — avoid screenreader "stepper" announcement while keeping numeric input:**

```tsx
<OTPInput
value={otp}
onChange={setOtp}
numInputs={4}
inputType="number"
overrideInputMode="text"
renderInput={(props) => <input {...props} />}
Comment thread
dikshit-n marked this conversation as resolved.
/>
```
Comment thread
dikshit-n marked this conversation as resolved.

## Migrating from v2

Expand Down
67 changes: 56 additions & 11 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
import React from 'react';
import OTPInput from '../../src';
import type { AllowedInputMode } from '../../src';

interface PlaygroundConfig {
otp: string;
numInputs: number;
separator: string;
minLength: number;
maxLength: number;
placeholder: string;
inputType: 'text' | 'number' | 'password' | 'tel';
overrideInputMode: AllowedInputMode | undefined;
}

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

const handleOTPChange = (otp: string) => {
setConfig((prevConfig) => ({ ...prevConfig, otp }));
};

const handleChange = (event: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = event.target;
setConfig((prevConfig) => ({ ...prevConfig, [name]: value }));
const { name, value } = event.target as HTMLInputElement | HTMLSelectElement;
setConfig((prevConfig) => {
if (name === 'numInputs') {
return { ...prevConfig, numInputs: Number(value) };
}
return { ...prevConfig, [name as keyof PlaygroundConfig]: value };
});
};

const handleNumInputsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -98,6 +117,31 @@ function App() {
<option value="tel">tel</option>
</select>
</div>
<div className="side-bar__segment">
<label htmlFor="overrideInputMode">overrideInputMode</label>
<select
id="overrideInputMode"
name="overrideInputMode"
value={overrideInputMode ?? ''}
onChange={(e) => {
const val = e.target.value;
setConfig((prev) => ({
...prev,
overrideInputMode: val === '' ? undefined : (val as AllowedInputMode),
}));
}}
>
<option value="">auto (input-type-driven — recommended)</option>
<option value="text">text — avoids screenreader stepper announcement</option>
<option value="numeric">numeric — shows number pad on mobile</option>
<option value="none">none — omits the attribute entirely</option>
<option value="tel">tel</option>
<option value="email">email</option>
<option value="url">url</option>
<option value="decimal">decimal</option>
<option value="search">search</option>
</select>
</div>
<div className="side-bar__segment side-bar__segment--bottom">
<a href="https://github.com/devfolioco/react-otp-input">Documentation and Source</a>
</div>
Expand All @@ -115,6 +159,7 @@ function App() {
value={otp}
placeholder={placeholder}
inputType={inputType}
overrideInputMode={overrideInputMode}
renderInput={(props) => <input {...props} />}
shouldAutoFocus
/>
Expand Down
39 changes: 29 additions & 10 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import React from 'react';

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

type AllowedInputMode = 'none' | 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'decimal' | 'search';

// Widened inputMode: allows undefined so the 'none'→undefined mapping in renderInput is type-safe.
// Consumers who always pass inputMode (as the existing contract requires) are unaffected.
// inputMode is optional — the library may omit the attribute when overrideInputMode is 'none'.
type InputProps = Required<
Pick<
React.InputHTMLAttributes<HTMLInputElement>,
Expand All @@ -14,15 +19,15 @@ type InputProps = Required<
| 'aria-label'
| 'autoComplete'
| 'style'
| 'inputMode'
| 'onInput'
> & {
ref: React.RefCallback<HTMLInputElement>;
placeholder: string | undefined;
className: string | undefined;
type: AllowedInputTypes;
}
>;
>
> & {
ref: React.RefCallback<HTMLInputElement>;
placeholder: string | undefined;
className: string | undefined;
type: AllowedInputTypes;
inputMode?: React.InputHTMLAttributes<HTMLInputElement>['inputMode'];
};
Comment thread
dikshit-n marked this conversation as resolved.

interface OTPInputProps {
/** Value of the OTP input */
Expand All @@ -47,6 +52,18 @@ interface OTPInputProps {
inputStyle?: React.CSSProperties | string;
/** The type that will be passed to the input being rendered */
inputType?: AllowedInputTypes;
/**
* Override the inputMode attribute applied to each input.
* Accepts the same values as the HTML inputMode attribute
* ('none' | 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'decimal' | 'search').
*
* When inputType is 'number' or 'tel', the default inputMode is 'numeric',
* which causes screenreaders to announce the field as "stepper" or "telephone".
* Set this to 'text' to avoid that, or to 'none' to omit the attribute entirely.
*
* Omit or pass undefined to use the input-type-driven default.
*/
overrideInputMode?: AllowedInputMode;
Comment thread
dikshit-n marked this conversation as resolved.
/** Do not apply the default styles to the inputs, will be removed in future versions */
skipDefaultStyles?: boolean; // TODO: Remove in next major release
}
Expand All @@ -65,6 +82,7 @@ const OTPInput = ({
placeholder,
containerStyle,
inputStyle,
overrideInputMode,
skipDefaultStyles = false,
}: OTPInputProps) => {
const [activeInput, setActiveInput] = React.useState(0);
Expand Down Expand Up @@ -258,7 +276,8 @@ const OTPInput = ({
),
className: typeof inputStyle === 'string' ? inputStyle : undefined,
type: inputType,
inputMode: isInputNum ? 'numeric' : 'text',
inputMode:
overrideInputMode === 'none' ? undefined : overrideInputMode ?? (isInputNum ? 'numeric' : 'text'),
onInput: handleInputChange,
},
index
Expand All @@ -270,5 +289,5 @@ const OTPInput = ({
);
};

export type { OTPInputProps, InputProps, AllowedInputTypes };
export type { OTPInputProps, InputProps, AllowedInputTypes, AllowedInputMode };
export default OTPInput;
Loading