Skip to content

Commit

Permalink
feat(module:input): support one time password (OTP) (#8715)
Browse files Browse the repository at this point in the history
  • Loading branch information
ParsaArvanehPA authored Nov 25, 2024
1 parent 0202a19 commit cdbaf4d
Show file tree
Hide file tree
Showing 10 changed files with 556 additions and 2 deletions.
14 changes: 14 additions & 0 deletions components/input/demo/otp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
order: 14
title:
zh-CN: 一次性密码框
en-US: OTP
---

## zh-CN

一次性密码输入框。

## en-US

One time password input.
37 changes: 37 additions & 0 deletions components/input/demo/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Component } from '@angular/core';

import { NzFlexDirective } from 'ng-zorro-antd/flex';
import { NzInputOtpComponent } from 'ng-zorro-antd/input';
import { NzTypographyComponent } from 'ng-zorro-antd/typography';

@Component({
selector: 'nz-demo-input-otp',
template: `
<nz-flex nzVertical [nzGap]="16">
<nz-flex nzVertical>
<h5 nz-typography>With Formatter (Uppercase)</h5>
<nz-input-otp [nzFormatter]="formatter"></nz-input-otp>
</nz-flex>
<nz-flex nzVertical>
<h5 nz-typography>With Disabled</h5>
<nz-input-otp [disabled]="true"></nz-input-otp>
</nz-flex>
<nz-flex nzVertical>
<h5 nz-typography>With Length (8)</h5>
<nz-input-otp [nzLength]="8"></nz-input-otp>
</nz-flex>
<nz-flex nzVertical>
<h5 nz-typography>With custom display character</h5>
<nz-input-otp [nzMask]="'🔒'"></nz-input-otp>
</nz-flex>
</nz-flex>
`,
imports: [NzFlexDirective, NzTypographyComponent, NzInputOtpComponent],
standalone: true
})
export class NzDemoInputOtpComponent {
formatter: (value: string) => string = value => value.toUpperCase();
}
11 changes: 11 additions & 0 deletions components/input/doc/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,14 @@ All props of input supported by [w3c standards](https://www.w3schools.com/tags/t
| --------------------------- | ------------------------------------------------ | ----------------------- | --------------- |
| `[nzMaxCharacterCount]` | `textarea` maximum character count displayed | `number` | - |
| `[nzComputeCharacterCount]` | customized `characterCount` computation function | `(v: string) => number` | `v => v.length` |

### nz-input-otp:standalone

| Property | Description | Type | Default |
| --------------- | ------------------------------------------------------- | --------------------------------- | --------- |
| `[disabled]` | Whether the input is disabled | boolean | `false` |
| `[nzFormatter]` | Format display, blank fields will be filled with ` ` | `(value: string) => string` | - |
| `[nzMask]` | Custom display, the original value will not be modified | `boolean \| null` | `null` |
| `[nzLength]` | The number of input elements | `number` | 6 |
| `[nzStatus]` | Set validation status | `'error' \| 'warning'` | - |
| `[nzSize]` | The size of the input box | `'large' \| 'small' \| 'default'` | `default` |
11 changes: 11 additions & 0 deletions components/input/doc/index.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,14 @@ nz-input 可以使用所有的 W3C 标准下的所有 [使用方式](https://www
| --------------------------- | ---------------------------------- | ----------------------- | --------------- |
| `[nzMaxCharacterCount]` | `textarea` 数字提示显示的最大值 | `number` | - |
| `[nzComputeCharacterCount]` | 自定义计算 `characterCount` 的函数 | `(v: string) => number` | `v => v.length` |

### nz-input-otp:standalone

| Property | Description | Type | Default |
| --------------- | ------------------------------------------------- | --------------------------------- | --------- |
| `[disabled]` | 是否禁用 | boolean | `false` |
| `[nzFormatter]` | 格式化展示,留空字段会被 ` ` 填充 | `(value: string) => string` | - |
| `[nzMask]` | 自定义展示,和 `formatter` 的区别是不会修改原始值 | `boolean \| null` | `null` |
| `[nzLength]` | 输入元素数量 | `number` | 6 |
| `[nzStatus]` | 设置校验状态 | `'error' \| 'warning'` | - |
| `[nzSize]` | 输入框大小 | `'large' \| 'small' \| 'default'` | `default` |
233 changes: 233 additions & 0 deletions components/input/input-otp.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
*/

import { BACKSPACE } from '@angular/cdk/keycodes';
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
ElementRef,
forwardRef,
Input,
numberAttribute,
OnChanges,
QueryList,
SimpleChanges,
ViewChildren,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
FormArray,
FormBuilder,
FormControl,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { takeUntil, tap } from 'rxjs/operators';

import { NzDestroyService } from 'ng-zorro-antd/core/services';
import { NzSafeAny, NzSizeLDSType, NzStatus, OnTouchedType } from 'ng-zorro-antd/core/types';

import { NzInputDirective } from './input.directive';

@Component({
selector: 'nz-input-otp',
exportAs: 'nzInputOtp',
preserveWhitespaces: false,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (item of otpArray.controls; track $index) {
<input
nz-input
class="ant-otp-input"
type="text"
maxlength="1"
size="1"
[nzSize]="nzSize"
[formControl]="item"
[nzStatus]="nzStatus"
(input)="onInput($index, $event)"
(focus)="onFocus($event)"
(keydown)="onKeyDown($index, $event)"
(paste)="onPaste($index, $event)"
#otpInput
/>
}
`,
host: {
class: 'ant-otp'
},
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NzInputOtpComponent),
multi: true
},
NzDestroyService
],
imports: [NzInputDirective, ReactiveFormsModule],
standalone: true
})
export class NzInputOtpComponent implements ControlValueAccessor, OnChanges {
@ViewChildren('otpInput') otpInputs!: QueryList<ElementRef>;

@Input({ transform: numberAttribute }) nzLength: number = 6;
@Input() nzSize: NzSizeLDSType = 'default';
@Input({ transform: booleanAttribute }) disabled = false;
@Input() nzStatus: NzStatus = '';
@Input() nzFormatter: (value: string) => string = value => value;
@Input() nzMask: string | null = null;

protected otpArray!: FormArray<FormControl<string>>;
private internalValue: string[] = [];
private onChangeCallback?: (_: NzSafeAny) => void;
onTouched: OnTouchedType = () => {};

constructor(
private readonly formBuilder: FormBuilder,
private readonly nzDestroyService: NzDestroyService
) {
this.createFormArray();
}

ngOnChanges(changes: SimpleChanges): void {
if (changes['nzLength']?.currentValue) {
this.createFormArray();
}

if (changes['disabled']) {
this.setDisabledState(this.disabled);
}
}

onInput(index: number, event: Event): void {
const inputElement = event.target as HTMLInputElement;
const nextInput = this.otpInputs.toArray()[index + 1];

if (inputElement.value && nextInput) {
nextInput.nativeElement.focus();
} else if (!nextInput) {
this.selectInputBox(index);
}
}

onFocus(event: FocusEvent): void {
const inputElement = event.target as HTMLInputElement;
inputElement.select();
}

onKeyDown(index: number, event: KeyboardEvent): void {
const previousInput = this.otpInputs.toArray()[index - 1];

if (event.keyCode === BACKSPACE) {
event.preventDefault();

this.internalValue[index] = '';
this.otpArray.at(index).setValue('', { emitEvent: false });

if (previousInput) {
this.selectInputBox(index - 1);
}

this.emitValue();
}
}

writeValue(value: string): void {
if (!value) {
this.otpArray.reset();
return;
}

const controlValues = value.split('');
this.internalValue = controlValues;

controlValues.forEach((val, i) => {
const formattedValue = this.nzFormatter(val);
const value = this.nzMask ? this.nzMask : formattedValue;
this.otpArray.at(i).setValue(value, { emitEvent: false });
});
}

registerOnChange(fn: (value: string) => void): void {
this.onChangeCallback = fn;
}

registerOnTouched(fn: () => {}): void {
this.onTouched = fn;
}

setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.otpArray.disable();
} else {
this.otpArray.enable();
}
}

onPaste(index: number, event: ClipboardEvent): void {
const pastedText = event.clipboardData?.getData('text') || '';
if (!pastedText) return;

let currentIndex = index;
for (const char of pastedText.split('')) {
if (currentIndex < this.nzLength) {
const formattedChar = this.nzFormatter(char);
this.internalValue[currentIndex] = char;
const maskedValue = this.nzMask ? this.nzMask : formattedChar;
this.otpArray.at(currentIndex).setValue(maskedValue, { emitEvent: false });
currentIndex++;
} else {
break;
}
}

event.preventDefault(); // this line is needed, otherwise the last index that is going to be selected will also be filled (in the next line).
this.selectInputBox(currentIndex);
this.emitValue();
}

private createFormArray(): void {
this.otpArray = this.formBuilder.array<FormControl<string>>([]);
this.internalValue = new Array(this.nzLength).fill('');

for (let i = 0; i < this.nzLength; i++) {
const control = this.formBuilder.nonNullable.control('', [Validators.required]);

control.valueChanges
.pipe(
tap(value => {
const unmaskedValue = this.nzFormatter(value);
this.internalValue[i] = unmaskedValue;

control.setValue(this.nzMask ?? unmaskedValue, { emitEvent: false });

this.emitValue();
}),
takeUntil(this.nzDestroyService)
)
.subscribe();

this.otpArray.push(control);
}
}

private emitValue(): void {
const result = this.internalValue.join('');
if (this.onChangeCallback) {
this.onChangeCallback(result);
}
}

private selectInputBox(index: number): void {
const otpInputArray = this.otpInputs.toArray();
if (index >= otpInputArray.length) index = otpInputArray.length - 1;

otpInputArray[index].nativeElement.select();
}
}
Loading

0 comments on commit cdbaf4d

Please sign in to comment.