Skip to content

Commit cdbaf4d

Browse files
feat(module:input): support one time password (OTP) (#8715)
1 parent 0202a19 commit cdbaf4d

10 files changed

+556
-2
lines changed

components/input/demo/otp.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
order: 14
3+
title:
4+
zh-CN: 一次性密码框
5+
en-US: OTP
6+
---
7+
8+
## zh-CN
9+
10+
一次性密码输入框。
11+
12+
## en-US
13+
14+
One time password input.

components/input/demo/otp.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Component } from '@angular/core';
2+
3+
import { NzFlexDirective } from 'ng-zorro-antd/flex';
4+
import { NzInputOtpComponent } from 'ng-zorro-antd/input';
5+
import { NzTypographyComponent } from 'ng-zorro-antd/typography';
6+
7+
@Component({
8+
selector: 'nz-demo-input-otp',
9+
template: `
10+
<nz-flex nzVertical [nzGap]="16">
11+
<nz-flex nzVertical>
12+
<h5 nz-typography>With Formatter (Uppercase)</h5>
13+
<nz-input-otp [nzFormatter]="formatter"></nz-input-otp>
14+
</nz-flex>
15+
16+
<nz-flex nzVertical>
17+
<h5 nz-typography>With Disabled</h5>
18+
<nz-input-otp [disabled]="true"></nz-input-otp>
19+
</nz-flex>
20+
21+
<nz-flex nzVertical>
22+
<h5 nz-typography>With Length (8)</h5>
23+
<nz-input-otp [nzLength]="8"></nz-input-otp>
24+
</nz-flex>
25+
26+
<nz-flex nzVertical>
27+
<h5 nz-typography>With custom display character</h5>
28+
<nz-input-otp [nzMask]="'🔒'"></nz-input-otp>
29+
</nz-flex>
30+
</nz-flex>
31+
`,
32+
imports: [NzFlexDirective, NzTypographyComponent, NzInputOtpComponent],
33+
standalone: true
34+
})
35+
export class NzDemoInputOtpComponent {
36+
formatter: (value: string) => string = value => value.toUpperCase();
37+
}

components/input/doc/index.en-US.md

+11
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,14 @@ All props of input supported by [w3c standards](https://www.w3schools.com/tags/t
4949
| --------------------------- | ------------------------------------------------ | ----------------------- | --------------- |
5050
| `[nzMaxCharacterCount]` | `textarea` maximum character count displayed | `number` | - |
5151
| `[nzComputeCharacterCount]` | customized `characterCount` computation function | `(v: string) => number` | `v => v.length` |
52+
53+
### nz-input-otp:standalone
54+
55+
| Property | Description | Type | Default |
56+
| --------------- | ------------------------------------------------------- | --------------------------------- | --------- |
57+
| `[disabled]` | Whether the input is disabled | boolean | `false` |
58+
| `[nzFormatter]` | Format display, blank fields will be filled with ` ` | `(value: string) => string` | - |
59+
| `[nzMask]` | Custom display, the original value will not be modified | `boolean \| null` | `null` |
60+
| `[nzLength]` | The number of input elements | `number` | 6 |
61+
| `[nzStatus]` | Set validation status | `'error' \| 'warning'` | - |
62+
| `[nzSize]` | The size of the input box | `'large' \| 'small' \| 'default'` | `default` |

components/input/doc/index.zh-CN.md

+11
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,14 @@ nz-input 可以使用所有的 W3C 标准下的所有 [使用方式](https://www
4949
| --------------------------- | ---------------------------------- | ----------------------- | --------------- |
5050
| `[nzMaxCharacterCount]` | `textarea` 数字提示显示的最大值 | `number` | - |
5151
| `[nzComputeCharacterCount]` | 自定义计算 `characterCount` 的函数 | `(v: string) => number` | `v => v.length` |
52+
53+
### nz-input-otp:standalone
54+
55+
| Property | Description | Type | Default |
56+
| --------------- | ------------------------------------------------- | --------------------------------- | --------- |
57+
| `[disabled]` | 是否禁用 | boolean | `false` |
58+
| `[nzFormatter]` | 格式化展示,留空字段会被 ` ` 填充 | `(value: string) => string` | - |
59+
| `[nzMask]` | 自定义展示,和 `formatter` 的区别是不会修改原始值 | `boolean \| null` | `null` |
60+
| `[nzLength]` | 输入元素数量 | `number` | 6 |
61+
| `[nzStatus]` | 设置校验状态 | `'error' \| 'warning'` | - |
62+
| `[nzSize]` | 输入框大小 | `'large' \| 'small' \| 'default'` | `default` |
+233
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* Use of this source code is governed by an MIT-style license that can be
3+
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
4+
*/
5+
6+
import { BACKSPACE } from '@angular/cdk/keycodes';
7+
import {
8+
booleanAttribute,
9+
ChangeDetectionStrategy,
10+
Component,
11+
ElementRef,
12+
forwardRef,
13+
Input,
14+
numberAttribute,
15+
OnChanges,
16+
QueryList,
17+
SimpleChanges,
18+
ViewChildren,
19+
ViewEncapsulation
20+
} from '@angular/core';
21+
import {
22+
ControlValueAccessor,
23+
FormArray,
24+
FormBuilder,
25+
FormControl,
26+
NG_VALUE_ACCESSOR,
27+
ReactiveFormsModule,
28+
Validators
29+
} from '@angular/forms';
30+
import { takeUntil, tap } from 'rxjs/operators';
31+
32+
import { NzDestroyService } from 'ng-zorro-antd/core/services';
33+
import { NzSafeAny, NzSizeLDSType, NzStatus, OnTouchedType } from 'ng-zorro-antd/core/types';
34+
35+
import { NzInputDirective } from './input.directive';
36+
37+
@Component({
38+
selector: 'nz-input-otp',
39+
exportAs: 'nzInputOtp',
40+
preserveWhitespaces: false,
41+
encapsulation: ViewEncapsulation.None,
42+
changeDetection: ChangeDetectionStrategy.OnPush,
43+
template: `
44+
@for (item of otpArray.controls; track $index) {
45+
<input
46+
nz-input
47+
class="ant-otp-input"
48+
type="text"
49+
maxlength="1"
50+
size="1"
51+
[nzSize]="nzSize"
52+
[formControl]="item"
53+
[nzStatus]="nzStatus"
54+
(input)="onInput($index, $event)"
55+
(focus)="onFocus($event)"
56+
(keydown)="onKeyDown($index, $event)"
57+
(paste)="onPaste($index, $event)"
58+
#otpInput
59+
/>
60+
}
61+
`,
62+
host: {
63+
class: 'ant-otp'
64+
},
65+
providers: [
66+
{
67+
provide: NG_VALUE_ACCESSOR,
68+
useExisting: forwardRef(() => NzInputOtpComponent),
69+
multi: true
70+
},
71+
NzDestroyService
72+
],
73+
imports: [NzInputDirective, ReactiveFormsModule],
74+
standalone: true
75+
})
76+
export class NzInputOtpComponent implements ControlValueAccessor, OnChanges {
77+
@ViewChildren('otpInput') otpInputs!: QueryList<ElementRef>;
78+
79+
@Input({ transform: numberAttribute }) nzLength: number = 6;
80+
@Input() nzSize: NzSizeLDSType = 'default';
81+
@Input({ transform: booleanAttribute }) disabled = false;
82+
@Input() nzStatus: NzStatus = '';
83+
@Input() nzFormatter: (value: string) => string = value => value;
84+
@Input() nzMask: string | null = null;
85+
86+
protected otpArray!: FormArray<FormControl<string>>;
87+
private internalValue: string[] = [];
88+
private onChangeCallback?: (_: NzSafeAny) => void;
89+
onTouched: OnTouchedType = () => {};
90+
91+
constructor(
92+
private readonly formBuilder: FormBuilder,
93+
private readonly nzDestroyService: NzDestroyService
94+
) {
95+
this.createFormArray();
96+
}
97+
98+
ngOnChanges(changes: SimpleChanges): void {
99+
if (changes['nzLength']?.currentValue) {
100+
this.createFormArray();
101+
}
102+
103+
if (changes['disabled']) {
104+
this.setDisabledState(this.disabled);
105+
}
106+
}
107+
108+
onInput(index: number, event: Event): void {
109+
const inputElement = event.target as HTMLInputElement;
110+
const nextInput = this.otpInputs.toArray()[index + 1];
111+
112+
if (inputElement.value && nextInput) {
113+
nextInput.nativeElement.focus();
114+
} else if (!nextInput) {
115+
this.selectInputBox(index);
116+
}
117+
}
118+
119+
onFocus(event: FocusEvent): void {
120+
const inputElement = event.target as HTMLInputElement;
121+
inputElement.select();
122+
}
123+
124+
onKeyDown(index: number, event: KeyboardEvent): void {
125+
const previousInput = this.otpInputs.toArray()[index - 1];
126+
127+
if (event.keyCode === BACKSPACE) {
128+
event.preventDefault();
129+
130+
this.internalValue[index] = '';
131+
this.otpArray.at(index).setValue('', { emitEvent: false });
132+
133+
if (previousInput) {
134+
this.selectInputBox(index - 1);
135+
}
136+
137+
this.emitValue();
138+
}
139+
}
140+
141+
writeValue(value: string): void {
142+
if (!value) {
143+
this.otpArray.reset();
144+
return;
145+
}
146+
147+
const controlValues = value.split('');
148+
this.internalValue = controlValues;
149+
150+
controlValues.forEach((val, i) => {
151+
const formattedValue = this.nzFormatter(val);
152+
const value = this.nzMask ? this.nzMask : formattedValue;
153+
this.otpArray.at(i).setValue(value, { emitEvent: false });
154+
});
155+
}
156+
157+
registerOnChange(fn: (value: string) => void): void {
158+
this.onChangeCallback = fn;
159+
}
160+
161+
registerOnTouched(fn: () => {}): void {
162+
this.onTouched = fn;
163+
}
164+
165+
setDisabledState(isDisabled: boolean): void {
166+
if (isDisabled) {
167+
this.otpArray.disable();
168+
} else {
169+
this.otpArray.enable();
170+
}
171+
}
172+
173+
onPaste(index: number, event: ClipboardEvent): void {
174+
const pastedText = event.clipboardData?.getData('text') || '';
175+
if (!pastedText) return;
176+
177+
let currentIndex = index;
178+
for (const char of pastedText.split('')) {
179+
if (currentIndex < this.nzLength) {
180+
const formattedChar = this.nzFormatter(char);
181+
this.internalValue[currentIndex] = char;
182+
const maskedValue = this.nzMask ? this.nzMask : formattedChar;
183+
this.otpArray.at(currentIndex).setValue(maskedValue, { emitEvent: false });
184+
currentIndex++;
185+
} else {
186+
break;
187+
}
188+
}
189+
190+
event.preventDefault(); // this line is needed, otherwise the last index that is going to be selected will also be filled (in the next line).
191+
this.selectInputBox(currentIndex);
192+
this.emitValue();
193+
}
194+
195+
private createFormArray(): void {
196+
this.otpArray = this.formBuilder.array<FormControl<string>>([]);
197+
this.internalValue = new Array(this.nzLength).fill('');
198+
199+
for (let i = 0; i < this.nzLength; i++) {
200+
const control = this.formBuilder.nonNullable.control('', [Validators.required]);
201+
202+
control.valueChanges
203+
.pipe(
204+
tap(value => {
205+
const unmaskedValue = this.nzFormatter(value);
206+
this.internalValue[i] = unmaskedValue;
207+
208+
control.setValue(this.nzMask ?? unmaskedValue, { emitEvent: false });
209+
210+
this.emitValue();
211+
}),
212+
takeUntil(this.nzDestroyService)
213+
)
214+
.subscribe();
215+
216+
this.otpArray.push(control);
217+
}
218+
}
219+
220+
private emitValue(): void {
221+
const result = this.internalValue.join('');
222+
if (this.onChangeCallback) {
223+
this.onChangeCallback(result);
224+
}
225+
}
226+
227+
private selectInputBox(index: number): void {
228+
const otpInputArray = this.otpInputs.toArray();
229+
if (index >= otpInputArray.length) index = otpInputArray.length - 1;
230+
231+
otpInputArray[index].nativeElement.select();
232+
}
233+
}

0 commit comments

Comments
 (0)