|
1 |
| -# InputOtp |
| 1 | +# The only accessible & unstyled & full featured Input OTP component for Angular |
2 | 2 |
|
3 |
| -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.1.6. |
| 3 | +### OTP Input for Angular 🔐 by [@shhdharmen](https://twitter.com/shhdharmen) |
4 | 4 |
|
5 |
| -## Development server |
6 | 5 |
|
7 |
| -To start a local development server, run: |
| 6 | + |
| 7 | +## Usage |
8 | 8 |
|
9 | 9 | ```bash
|
10 |
| -ng serve |
| 10 | +ng add @ngxpert/input-otp |
11 | 11 | ```
|
12 | 12 |
|
13 |
| -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. |
| 13 | +Then import the component. |
14 | 14 |
|
15 |
| -## Code scaffolding |
| 15 | +```ts |
| 16 | +import { InputOTPComponent } from '@ngxpert/input-otp'; |
| 17 | +@Component({ |
| 18 | + selector: 'app-my-component', |
| 19 | + template: ` |
| 20 | + <input-otp [maxLength]="6" [(ngModel)]="otpValue"> |
| 21 | + <div class="flex"> |
| 22 | + @for (slot of otp.slots(); track $index) { |
| 23 | + <div>{{ slot.char }}</div> |
| 24 | + } |
| 25 | + </div> |
| 26 | + </input-otp> |
| 27 | + `, |
| 28 | + imports: [InputOTPComponent, FormsModule], |
| 29 | +}) |
| 30 | +export class MyComponent { |
| 31 | + otpValue = ''; |
| 32 | +} |
| 33 | +``` |
16 | 34 |
|
17 |
| -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: |
| 35 | +## Default example |
18 | 36 |
|
19 |
| -```bash |
20 |
| -ng generate component component-name |
21 |
| -``` |
| 37 | +The example below uses `tailwindcss` `tailwind-merge` `clsx`: |
22 | 38 |
|
23 |
| -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: |
| 39 | +### main.component |
| 40 | + |
| 41 | +```tsx |
| 42 | +import { Component } from '@angular/core'; |
| 43 | +import { FormsModule } from '@angular/forms'; |
| 44 | +import { InputOTPComponent } from '@ngxpert/input-otp'; |
| 45 | +import { SlotComponent } from './slot.component'; |
| 46 | +import { FakeDashComponent } from './fake-components'; |
| 47 | + |
| 48 | +@Component({ |
| 49 | + selector: 'app-examples-main', |
| 50 | + template: ` |
| 51 | + <input-otp |
| 52 | + [maxLength]="6" |
| 53 | + containerClass="group flex items-center has-[:disabled]:opacity-30" |
| 54 | + [(ngModel)]="otpValue" |
| 55 | + #otp="inputOtp" |
| 56 | + > |
| 57 | + <div class="flex"> |
| 58 | + @for (slot of otp.slots().slice(0, 3); track $index) { |
| 59 | + <app-slot |
| 60 | + [isActive]="slot.isActive" |
| 61 | + [char]="slot.char" |
| 62 | + [placeholderChar]="slot.placeholderChar" |
| 63 | + [hasFakeCaret]="slot.hasFakeCaret" |
| 64 | + /> |
| 65 | + } |
| 66 | + </div> |
| 67 | + <app-fake-dash /> |
| 68 | + <div class="flex"> |
| 69 | + @for (slot of otp.slots().slice(3, 6); track $index + 3) { |
| 70 | + <app-slot |
| 71 | + [isActive]="slot.isActive" |
| 72 | + [char]="slot.char" |
| 73 | + [placeholderChar]="slot.placeholderChar" |
| 74 | + [hasFakeCaret]="slot.hasFakeCaret" |
| 75 | + /> |
| 76 | + } |
| 77 | + </div> |
| 78 | + </input-otp> |
| 79 | + `, |
| 80 | + imports: [FormsModule, InputOTPComponent, SlotComponent, FakeDashComponent], |
| 81 | +}) |
| 82 | +export class ExamplesMainComponent { |
| 83 | + otpValue = ''; |
| 84 | +} |
24 | 85 |
|
25 |
| -```bash |
26 |
| -ng generate --help |
27 | 86 | ```
|
28 | 87 |
|
29 |
| -## Building |
| 88 | +### slot.component |
30 | 89 |
|
31 |
| -To build the project run: |
| 90 | +```ts |
| 91 | +import { Component, Input } from '@angular/core'; |
| 92 | +import { FakeCaretComponent } from './fake-components'; |
| 93 | +import { cn } from './utils'; |
| 94 | + |
| 95 | +@Component({ |
| 96 | + selector: 'app-slot', |
| 97 | + template: ` |
| 98 | + <div |
| 99 | + [class]=" |
| 100 | + cn( |
| 101 | + 'relative w-10 h-14 text-[2rem]', |
| 102 | + 'flex items-center justify-center', |
| 103 | + 'transition-all duration-300', |
| 104 | + 'border-border border-y border-r first:border-l first:rounded-l-md last:rounded-r-md', |
| 105 | + 'group-hover:border-accent-foreground/20 group-focus-within:border-accent-foreground/20', |
| 106 | + 'outline outline-0 outline-accent-foreground/20', |
| 107 | + { 'outline-4 outline-accent-foreground': isActive } |
| 108 | + ) |
| 109 | + " |
| 110 | + > |
| 111 | + @if (char) { |
| 112 | + <div>{{ char }}</div> |
| 113 | + } @else { |
| 114 | + {{ ' ' }} |
| 115 | + } |
| 116 | + @if (hasFakeCaret) { |
| 117 | + <app-fake-caret /> |
| 118 | + } |
| 119 | + </div> |
| 120 | + `, |
| 121 | + imports: [FakeCaretComponent], |
| 122 | +}) |
| 123 | +export class SlotComponent { |
| 124 | + @Input() isActive = false; |
| 125 | + @Input() char: string | null = null; |
| 126 | + @Input() placeholderChar: string | null = null; |
| 127 | + @Input() hasFakeCaret = false; |
| 128 | + cn = cn; |
| 129 | +} |
32 | 130 |
|
33 |
| -```bash |
34 |
| -ng build |
35 | 131 | ```
|
36 | 132 |
|
37 |
| -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. |
| 133 | +### fake-components |
38 | 134 |
|
39 |
| -## Running unit tests |
| 135 | +```ts |
| 136 | +import { Component } from '@angular/core'; |
40 | 137 |
|
41 |
| -To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: |
| 138 | +@Component({ |
| 139 | + selector: 'app-fake-dash', |
| 140 | + template: ` |
| 141 | + <div class="flex w-10 justify-center items-center"> |
| 142 | + <div class="w-3 h-1 rounded-full bg-border"></div> |
| 143 | + </div> |
| 144 | + `, |
| 145 | +}) |
| 146 | +export class FakeDashComponent {} |
| 147 | + |
| 148 | +@Component({ |
| 149 | + selector: 'app-fake-caret', |
| 150 | + template: ` |
| 151 | + <div |
| 152 | + class="absolute pointer-events-none inset-0 flex items-center justify-center animate-caret-blink" |
| 153 | + > |
| 154 | + <div class="w-px h-8 bg-white"></div> |
| 155 | + </div> |
| 156 | + `, |
| 157 | +}) |
| 158 | +export class FakeCaretComponent {} |
42 | 159 |
|
43 |
| -```bash |
44 |
| -ng test |
45 | 160 | ```
|
46 | 161 |
|
47 |
| -## Running end-to-end tests |
| 162 | +### utils |
48 | 163 |
|
49 |
| -For end-to-end (e2e) testing, run: |
| 164 | +```ts |
| 165 | +// Small utility to merge class names. |
| 166 | +import { clsx } from 'clsx'; |
| 167 | +import { twMerge } from 'tailwind-merge'; |
| 168 | + |
| 169 | +import type { ClassValue } from 'clsx'; |
| 170 | + |
| 171 | +export function cn(...inputs: ClassValue[]) { |
| 172 | + return twMerge(clsx(inputs)); |
| 173 | +} |
50 | 174 |
|
51 |
| -```bash |
52 |
| -ng e2e |
53 | 175 | ```
|
54 | 176 |
|
55 |
| -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. |
| 177 | +## How it works |
| 178 | + |
| 179 | +There's currently no native OTP/2FA/MFA input in HTML, which means people are either going with |
| 180 | + |
| 181 | +1. a simple input design or |
| 182 | +2. custom designs like this one. |
| 183 | + |
| 184 | +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_). |
| 185 | + |
| 186 | +## Features |
| 187 | + |
| 188 | +### This is the most complete OTP input for Angular. It's fully featured |
| 189 | + |
| 190 | +Works with `Template-Driven Forms` and `Reactive Forms` out of the box. |
| 191 | + |
| 192 | +<details> |
| 193 | +<summary>Supports iOS + Android copy-paste-cut</summary> |
| 194 | + |
| 195 | +TBA video |
| 196 | + |
| 197 | +</details> |
| 198 | + |
| 199 | +<details> |
| 200 | +<summary>Automatic OTP code retrieval from transport (e.g SMS)</summary> |
56 | 201 |
|
57 |
| -## Additional Resources |
| 202 | +By default, this input uses `autocomplete='one-time-code'` and it works as it's a single input. |
58 | 203 |
|
59 |
| -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. |
| 204 | +TBA video |
| 205 | + |
| 206 | +</details> |
| 207 | + |
| 208 | +<details> |
| 209 | +<summary>Supports screen readers (a11y)</summary> |
| 210 | + |
| 211 | +Take a look at Stripe's input. The screen reader does not behave like it normally should on a normal single input. |
| 212 | +That's because Stripe's solution is to render a 1-digit input with "clone-divs" rendering a single char per div. |
| 213 | + |
| 214 | +https://github.com/guilhermerodz/input-otp/assets/10366880/3d127aef-147c-4f28-9f6c-57a357a802d0 |
| 215 | + |
| 216 | +So we're rendering a single input with invisible/transparent colors instead. |
| 217 | +The screen reader now gets to read it, but there is no appearance. Feel free to build whatever UI you want: |
| 218 | + |
| 219 | +TBA video |
| 220 | + |
| 221 | +</details> |
| 222 | + |
| 223 | +<details> |
| 224 | +<summary>Supports all keybindings</summary> |
| 225 | + |
| 226 | +Should be able to support all keybindings of a common text input as it's an input. |
| 227 | + |
| 228 | +TBA video |
| 229 | + |
| 230 | +</details> |
| 231 | + |
| 232 | +## API Reference |
| 233 | + |
| 234 | +### `<input-otp>` |
| 235 | + |
| 236 | +The root container. Define settings for the input via inputs. Then, use the `inputOtp.slots()` property to create the slots. |
| 237 | + |
| 238 | +#### Inputs and outputs |
| 239 | + |
| 240 | +```ts |
| 241 | +export interface InputOTPInputsOutputs { |
| 242 | + // The number of slots |
| 243 | + maxLength: InputSignal<number>; |
| 244 | + |
| 245 | + // Pro tip: input-otp export some patterns by default such as REGEXP_ONLY_DIGITS which you can import from the same library path |
| 246 | + // Example: import { REGEXP_ONLY_DIGITS } from '@ngxpert/input-otp'; |
| 247 | + // Then use it as: <input-otp [pattern]="REGEXP_ONLY_DIGITS"> |
| 248 | + pattern?: InputSignal<string | RegExp | undefined>; |
| 249 | + |
| 250 | + // While rendering the input slot, you can access both the char and the placeholder, if there's one and it's active. |
| 251 | + // If you expect input to be of 6 characters, provide 6 characters in the placeholder. |
| 252 | + placeholder?: InputSignal<string | undefined>; |
| 253 | + |
| 254 | + // Virtual keyboard appearance on mobile |
| 255 | + // Default: 'numeric' |
| 256 | + inputMode?: InputSignal<'numeric' | 'text'>; |
| 257 | + |
| 258 | + // The autocomplete attribute for the input |
| 259 | + // Default: 'one-time-code' |
| 260 | + autoComplete?: InputSignal<string | undefined>; |
| 261 | + |
| 262 | + // The class name for the container |
| 263 | + containerClass?: InputSignal<string | undefined>; |
| 264 | + |
| 265 | + // Emits the complete value when the input is filled |
| 266 | + complete: OutputEmitterRef<string>; |
| 267 | +} |
| 268 | +``` |
0 commit comments