Skip to content

Commit a69ba79

Browse files
committed
refactor: use property binding, docs, remove directive
1 parent 401eb87 commit a69ba79

File tree

8 files changed

+277
-60
lines changed

8 files changed

+277
-60
lines changed

README.md

+239-30
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,268 @@
1-
# InputOtp
1+
# The only accessible & unstyled & full featured Input OTP component for Angular
22

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)
44

5-
## Development server
65

7-
To start a local development server, run:
6+
7+
## Usage
88

99
```bash
10-
ng serve
10+
ng add @ngxpert/input-otp
1111
```
1212

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.
1414

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+
```
1634

17-
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
35+
## Default example
1836

19-
```bash
20-
ng generate component component-name
21-
```
37+
The example below uses `tailwindcss` `tailwind-merge` `clsx`:
2238

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+
}
2485

25-
```bash
26-
ng generate --help
2786
```
2887

29-
## Building
88+
### slot.component
3089

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+
}
32130

33-
```bash
34-
ng build
35131
```
36132

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
38134

39-
## Running unit tests
135+
```ts
136+
import { Component } from '@angular/core';
40137

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 {}
42159

43-
```bash
44-
ng test
45160
```
46161

47-
## Running end-to-end tests
162+
### utils
48163

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+
}
50174

51-
```bash
52-
ng e2e
53175
```
54176

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>
56201

57-
## Additional Resources
202+
By default, this input uses `autocomplete='one-time-code'` and it works as it's a single input.
58203

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+
```

projects/ngxpert/input-otp/src/lib/components/input-otp/input-otp.component.html

+3-3
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
[attr.data-input-otp-placeholder-shown]="formControl.value?.length === 0 || undefined"
2121
[attr.data-input-otp-mss]="mirrorSelectionStart() || undefined"
2222
[attr.data-input-otp-mse]="mirrorSelectionEnd() || undefined"
23-
[attr.inputMode]="inputMode()"
24-
[attr.pattern]="pattern()"
25-
[attr.aria-placeholder]="placeholder()"
23+
[inputMode]="inputMode()"
24+
[pattern]="pattern() ?? ''"
25+
[ariaPlaceholder]="placeholder()"
2626
[style]="inputStyle()"
2727
[maxLength]="maxLength()"
2828
[formControl]="formControl"

projects/ngxpert/input-otp/src/lib/components/input-otp/input-otp.component.ts

+18-5
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
ValidationErrors,
2727
Validator,
2828
} from '@angular/forms';
29-
import { OTPSlot } from '../../types';
29+
import { InputOTPInputsOutputs, OTPSlot } from '../../types';
3030
import { DOCUMENT } from '@angular/common';
3131
import { getControlValueSignal } from '../../control-value-signal';
3232

@@ -67,7 +67,12 @@ const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px`;
6767
},
6868
})
6969
export class InputOTPComponent
70-
implements AfterViewInit, OnDestroy, ControlValueAccessor, Validator
70+
implements
71+
AfterViewInit,
72+
OnDestroy,
73+
ControlValueAccessor,
74+
Validator,
75+
InputOTPInputsOutputs
7176
{
7277
static nextId = 0;
7378
readonly idNextId = `input-otp-${InputOTPComponent.nextId++}`;
@@ -87,7 +92,6 @@ export class InputOTPComponent
8792
'increase-width',
8893
);
8994
containerClass = input<string>();
90-
pasteTransformer = input<(content: string | undefined) => string>();
9195
complete = output<string>();
9296

9397
mirrorSelectionStart = signal<number | null>(null);
@@ -171,6 +175,15 @@ export class InputOTPComponent
171175
}
172176
this.previousValue = newValue;
173177
});
178+
179+
effect(() => {
180+
const disabled = this.disabled();
181+
if (disabled) {
182+
this.formControl.disable();
183+
} else {
184+
this.formControl.enable();
185+
}
186+
});
174187
}
175188

176189
writeValue(value: string): void {
@@ -182,7 +195,7 @@ export class InputOTPComponent
182195
registerOnTouched(fn: () => void): void {
183196
this.formControl.valueChanges.subscribe(fn);
184197
}
185-
setDisabledState?(isDisabled: boolean): void {
198+
setDisabledState(isDisabled: boolean): void {
186199
if (isDisabled) {
187200
this.formControl.disable();
188201
} else {
@@ -250,7 +263,7 @@ export class InputOTPComponent
250263
: undefined,
251264
height: '100%',
252265
display: 'flex',
253-
textAlign: this.textAlign(),
266+
// textAlign: this.textAlign(),
254267
opacity: '1',
255268
color: 'transparent',
256269
pointerEvents: 'all',

projects/ngxpert/input-otp/src/lib/directives/slot/slot.directive.ts

-6
This file was deleted.

0 commit comments

Comments
 (0)