@ngxpert/input-otp
Version:
One-time password input component for Angular.
347 lines (273 loc) • 10.2 kB
Markdown
# The only accessible & unstyled & full featured Input OTP component for Angular
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
### OTP Input for Angular 🔐 by [@shhdharmen](https://twitter.com/shhdharmen)
## Usage
```bash
ng add @ngxpert/input-otp
```
Then import the component.
```ts
import { InputOTPComponent } from '@ngxpert/input-otp';
@Component({
selector: 'app-my-component',
template: `
<input-otp [maxLength]="6" [(ngModel)]="otpValue" #otpInput>
<div style="display: flex;">
@for (slot of otpInput.slots(); track $index) {
<div>{{ slot.char }}</div>
}
</div>
</input-otp>
`,
imports: [InputOTPComponent, FormsModule],
})
export class MyComponent {
otpValue = '';
}
```
## Features
- ✅ Works with `Template-Driven Forms` and `Reactive Forms` out of the box.
- ✅ Supports copy-paste-cut
- ✅ Supports all keybindings
## Default example
The example below uses `tailwindcss` `tailwind-merge` `clsx`. You can see it online [here](https://ngxpert.github.io/input-otp/examples), code available [here](https://github.com/ngxpert/input-otp/tree/main/src/app/pages/examples/main).
### main.component
```ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { InputOTPComponent } from '@ngxpert/input-otp';
import { SlotComponent } from './slot.component';
import { FakeDashComponent } from './fake-components';
@Component({
selector: 'app-examples-main',
template: `
<input-otp
[maxLength]="6"
containerClass="group flex items-center has-[:disabled]:opacity-30"
[(ngModel)]="otpValue"
#otp="inputOtp"
>
<div class="flex">
@for (
slot of otp.slots().slice(0, 3);
track $index;
let first = $first;
let last = $last
) {
<app-slot
[isActive]="slot.isActive"
[char]="slot.char"
[placeholderChar]="slot.placeholderChar"
[hasFakeCaret]="slot.hasFakeCaret"
[first]="first"
[last]="last"
/>
}
</div>
<app-fake-dash />
<div class="flex">
@for (
slot of otp.slots().slice(3, 6);
track $index + 3;
let last = $last;
let first = $first
) {
<app-slot
[isActive]="slot.isActive"
[char]="slot.char"
[placeholderChar]="slot.placeholderChar"
[hasFakeCaret]="slot.hasFakeCaret"
[first]="first"
[last]="last"
/>
}
</div>
</input-otp>
`,
imports: [FormsModule, InputOTPComponent, SlotComponent, FakeDashComponent],
})
export class ExamplesMainComponent {
otpValue = '';
}
```
### slot.component
```ts
import { Component, Input } from '@angular/core';
import { FakeCaretComponent } from './fake-components';
import { cn } from './utils';
@Component({
selector: 'app-slot',
template: `
<div
[class]="
cn(
'relative w-10 h-14 text-[2rem]',
'flex items-center justify-center',
'transition-all duration-300',
'border-y border-r',
'group-hover:border-accent-foreground/20 group-focus-within:border-accent-foreground/20',
'outline outline-0 outline-accent-foreground/20',
{ 'outline-4 outline-accent-foreground': isActive },
{ 'border-l rounded-l-md': first },
{ 'rounded-r-md': last }
)
"
>
@if (char) {
<div>{{ char }}</div>
} @else {
{{ ' ' }}
}
@if (hasFakeCaret) {
<app-fake-caret />
}
</div>
`,
imports: [FakeCaretComponent],
})
export class SlotComponent {
@Input() isActive = false;
@Input() char: string | null = null;
@Input() placeholderChar: string | null = null;
@Input() hasFakeCaret = false;
@Input() first = false;
@Input() last = false;
cn = cn;
}
```
### fake-components
```ts
import { Component } from '@angular/core';
@Component({
selector: 'app-fake-dash',
template: `
<div class="flex w-10 justify-center items-center">
<div class="w-3 h-1 rounded-full bg-black/75"></div>
</div>
`,
})
export class FakeDashComponent {}
@Component({
selector: 'app-fake-caret',
template: `
<div
class="absolute pointer-events-none inset-0 flex items-center justify-center animate-caret-blink"
>
<div class="w-[2px] h-8 bg-black/75"></div>
</div>
`,
})
export class FakeCaretComponent {}
```
### utils
```ts
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
### styles
```css
@import "tailwindcss";
@theme {
--animate-caret-blink: caret-blink 1.2s ease-out infinite;
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
}
```
## How it works
There's currently no native OTP/2FA/MFA input in HTML, which means people are either going with
1. a simple input design or
2. custom designs like this one.
This library works by rendering an invisible input as a sibling of the slots, contained by a `relative`ly positioned parent (the container root called _input-otp_).
<!-- ## Features
### This is the most complete OTP input for Angular. It's fully featured
Works with `Template-Driven Forms` and `Reactive Forms` out of the box.
<details>
<summary>Supports iOS + Android copy-paste-cut</summary>
TBA video
</details>
<details>
<summary>Automatic OTP code retrieval from transport (e.g SMS)</summary>
By default, this input uses `autocomplete='one-time-code'` and it works as it's a single input.
TBA video
</details>
<details>
<summary>Supports screen readers (a11y)</summary>
Take a look at Stripe's input. The screen reader does not behave like it normally should on a normal single input.
That's because Stripe's solution is to render a 1-digit input with "clone-divs" rendering a single char per div.
https://github.com/guilhermerodz/input-otp/assets/10366880/3d127aef-147c-4f28-9f6c-57a357a802d0
So we're rendering a single input with invisible/transparent colors instead.
The screen reader now gets to read it, but there is no appearance. Feel free to build whatever UI you want:
TBA video
</details>
<details>
<summary>Supports all keybindings</summary>
Should be able to support all keybindings of a common text input as it's an input.
TBA video
</details> -->
## API Reference
### `<input-otp>`
The root container. Define settings for the input via inputs. Then, use the `inputOtp.slots()` property to create the slots.
#### Inputs and outputs
```ts
export interface InputOTPInputsOutputs {
// The number of slots
maxLength: InputSignal<number>;
// Pro tip: input-otp export some patterns by default such as REGEXP_ONLY_DIGITS which you can import from the same library path
// Example: import { REGEXP_ONLY_DIGITS } from '@ngxpert/input-otp';
// Then use it as: <input-otp [pattern]="REGEXP_ONLY_DIGITS">
pattern?: InputSignal<string | RegExp | undefined>;
// While rendering the input slot, you can access both the char and the placeholder, if there's one and it's active.
// If you expect input to be of 6 characters, provide 6 characters in the placeholder.
placeholder?: InputSignal<string | undefined>;
// Virtual keyboard appearance on mobile
// Default: 'numeric'
inputMode?: InputSignal<'numeric' | 'text'>;
// The autocomplete attribute for the input
// Default: 'one-time-code'
autoComplete?: InputSignal<string | undefined>;
// The class name for the container
containerClass?: InputSignal<string | undefined>;
// Emits the complete value when the input is filled
complete: OutputEmitterRef<string>;
}
```
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/shhdharmen"><img src="https://avatars.githubusercontent.com/u/6831283?v=4?s=100" width="100px;" alt="Dharmen Shah"/><br /><sub><b>Dharmen Shah</b></sub></a><br /><a href="#a11y-shhdharmen" title="Accessibility">️️️️♿️</a> <a href="#question-shhdharmen" title="Answering Questions">💬</a> <a href="https://github.com/ngxpert/input-otp/issues?q=author%3Ashhdharmen" title="Bug reports">🐛</a> <a href="https://github.com/ngxpert/input-otp/commits?author=shhdharmen" title="Code">💻</a> <a href="#content-shhdharmen" title="Content">🖋</a> <a href="https://github.com/ngxpert/input-otp/commits?author=shhdharmen" title="Documentation">📖</a> <a href="#example-shhdharmen" title="Examples">💡</a> <a href="#maintenance-shhdharmen" title="Maintenance">🚧</a> <a href="#projectManagement-shhdharmen" title="Project Management">📆</a> <a href="https://github.com/ngxpert/input-otp/pulls?q=is%3Apr+reviewed-by%3Ashhdharmen" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/ngxpert/input-otp/commits?author=shhdharmen" title="Tests">⚠️</a></td>
</tr>
</tbody>
<tfoot>
<tr>
<td align="center" size="13px" colspan="7">
<img src="https://raw.githubusercontent.com/all-contributors/all-contributors-cli/1b8533af435da9854653492b1327a23a4dbd0a10/assets/logo-small.svg">
<a href="https://all-contributors.js.org/docs/en/bot/usage">Add your contributions</a>
</img>
</td>
</tr>
</tfoot>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!