import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
forwardRef,
inject,
input,
model,
numberAttribute,
OnDestroy,
output,
signal,
viewChildren,
} from '@angular/core';
import {
type ControlValueAccessor,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
import { ButtonComponent } from '../../button/button.component';
import {
FORM_ERROR_CLASS,
FORM_FIELD_ERROR_CLASS,
FORM_FOCUS_CLASS,
FORM_HINT_CLASS,
FORM_LABEL_CLASS,
} from '../form-field.classes';
let nextOtpId = 0;
/** Visual treatment for OTP cells. */
export type OtpInputVariant = 'default' | 'masked';
/**
* One-time-passcode field — discrete digit cells, single string model.
*
* **Prefix/suffix slots are omitted** — OTP is a fixed-length code entry, not
* a free-text field with affixes.
*
* Digits only (`inputmode="numeric"`). Paste fills multiple cells. When the
* value reaches {@link length}, {@link completed} emits so the host can submit.
*
* {@link variant} `masked` hides each digit (PIN-style). The model is still
* the real digit string.
*
* Built-in **resend** row with cooldown — host listens to {@link resend} to
* call the API; the control only manages UI + timer.
*
* @example
* ```html
*
* ```
*/
@Component({
selector: 'africanies-otp-input',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => OtpInputComponent),
multi: true,
},
],
host: {
class: 'block',
},
styleUrl: './otp-input.component.css',
template: `
@if (label()) {
{{ label() }}
}
@for (digit of cells(); track $index; let i = $index) {
}
@if (error(); as err) {
{{ err }}
} @else if (hint(); as h) {
{{ h }}
}
@if (showResend()) {
{{ resendPrompt() }}
@if (cooldownRemaining() > 0) {
Resend in {{ cooldownRemaining() }}s
} @else {
}
}
`,
})
/**
* One-time-passcode field — discrete digit cells, single string model.
*/
export class OtpInputComponent implements ControlValueAccessor, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly cellInputs =
viewChildren>('cellInput');
protected readonly controlId = `africanies-otp-input-${++nextOtpId}`;
protected readonly labelId = `${this.controlId}-label`;
protected readonly hintId = `${this.controlId}-hint`;
protected readonly errorId = `${this.controlId}-error`;
protected readonly labelClass = FORM_LABEL_CLASS;
protected readonly hintClass = FORM_HINT_CLASS;
protected readonly errorClass = FORM_ERROR_CLASS;
protected readonly cellClass = computed(() => {
let classes =
'size-10 shrink-0 rounded-md border border-neutral-300 bg-white text-center text-body font-medium text-ink outline-none transition-colors ' +
'dark:border-white/25 dark:bg-ink-950 dark:text-white ' +
`${FORM_FOCUS_CLASS} disabled:cursor-not-allowed`;
if (this.masked()) {
classes += ' africanies-otp-cell-masked text-body-lg';
}
if (this.error()) {
classes += ` ${FORM_FIELD_ERROR_CLASS}`;
}
return classes;
});
protected readonly masked = computed(() => this.variant() === 'masked');
protected readonly cellType = computed(() =>
this.masked() ? 'password' : 'text',
);
/** Visible group label. */
readonly label = input('');
/** Helper copy. Hidden while {@link error} is set. */
readonly hint = input(undefined);
/** Field-level validation message. */
readonly error = input(null);
/** Number of digit cells (default 6). */
readonly length = input(6, { transform: numberAttribute });
/**
* `masked` hides each digit (PIN-style). The bound value is still the
* real digits.
*/
readonly variant = input('default');
/** Concatenated digit string (`[(value)]` + CVA). */
readonly value = model('');
/** Host disable flag (in addition to CVA). */
readonly disabledInput = input(false, {
alias: 'disabled',
transform: booleanAttribute,
});
/** Show the “Didn’t get a code? / Resend” row (default true). */
readonly showResend = input(true, { transform: booleanAttribute });
/** Copy before the resend action. */
readonly resendPrompt = input("Didn't get a code?");
/** Label on the resend button when cooldown is idle. */
readonly resendLabel = input('Resend');
/**
* Cooldown seconds after mount and after each resend click (default 60).
* Set to `0` to allow resend immediately with no timer.
*/
readonly resendCooldown = input(60, { transform: numberAttribute });
/**
* Emits the full code when every cell is filled (typing or paste).
* Host owns submit / verify — this control does not call APIs.
*/
readonly completed = output();
/**
* Emits when the user requests another code. Host should call the resend API;
* the control restarts the cooldown automatically.
*/
readonly resend = output();
protected readonly cvaDisabled = signal(false);
protected readonly disabled = computed(
() => this.disabledInput() || this.cvaDisabled(),
);
/** Seconds left before Resend is enabled. */
protected readonly cooldownRemaining = signal(0);
/** Per-cell digits derived from {@link value}, padded to {@link length}. */
protected readonly cells = computed(() => {
const len = Math.max(1, Math.floor(this.length()) || 6);
const digits = this.digitsOnly(this.value()).slice(0, len);
return Array.from({ length: len }, (_, i) => digits[i] ?? '');
});
protected readonly describedBy = computed(() => {
if (this.error()) {
return this.errorId;
}
if (this.hint()) {
return this.hintId;
}
return null;
});
private onChange: (value: string) => void = () => undefined;
private onTouched: () => void = () => undefined;
private lastCompleted = '';
private cooldownTimer: ReturnType | null = null;
constructor() {
this.destroyRef.onDestroy(() => this.clearCooldownTimer());
// Assume the host already sent the first code — start cooldown on mount.
queueMicrotask(() => this.startCooldown());
}
ngOnDestroy(): void {
this.clearCooldownTimer();
}
/** @param value - Form model; non-digits stripped, truncated to length. */
writeValue(value: string | null): void {
this.applyValue(this.digitsOnly(value ?? ''), false);
}
/** @param fn - User change callback. */
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
/** @param fn - Blur callback. */
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/** @param isDisabled - Blocks editing when true. */
setDisabledState(isDisabled: boolean): void {
this.cvaDisabled.set(isDisabled);
}
protected onResendClick(): void {
if (this.disabled() || this.cooldownRemaining() > 0) {
return;
}
this.resend.emit();
this.startCooldown();
}
/**
* Restart the cooldown from outside (e.g. after a successful resend API call
* that the host triggered some other way). Usually unnecessary — click already
* starts the timer.
*/
restartCooldown(): void {
this.startCooldown();
}
protected onCellInput(index: number, event: Event): void {
const inputEl = event.target as HTMLInputElement;
const raw = this.digitsOnly(inputEl.value);
const digit = raw.slice(-1);
inputEl.value = digit;
const next = [...this.cells()];
next[index] = digit;
this.commitCells(next);
if (digit && index < next.length - 1) {
this.focusCell(index + 1);
}
}
protected onCellKeydown(index: number, event: KeyboardEvent): void {
const key = event.key;
if (key === 'Backspace') {
event.preventDefault();
const next = [...this.cells()];
if (next[index]) {
next[index] = '';
this.commitCells(next);
} else if (index > 0) {
next[index - 1] = '';
this.commitCells(next);
this.focusCell(index - 1);
}
return;
}
if (key === 'ArrowLeft' && index > 0) {
event.preventDefault();
this.focusCell(index - 1);
return;
}
if (key === 'ArrowRight' && index < this.cells().length - 1) {
event.preventDefault();
this.focusCell(index + 1);
return;
}
if (
key.length === 1 &&
!/[0-9]/.test(key) &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
event.preventDefault();
}
}
protected onPaste(index: number, event: ClipboardEvent): void {
event.preventDefault();
const pasted = this.digitsOnly(event.clipboardData?.getData('text') ?? '');
if (!pasted) {
return;
}
const next = [...this.cells()];
let cursor = index;
for (const ch of pasted) {
if (cursor >= next.length) {
break;
}
next[cursor] = ch;
cursor += 1;
}
this.commitCells(next);
const focusAt = Math.min(cursor, next.length - 1);
this.focusCell(focusAt);
}
protected onCellFocus(index: number): void {
const el = this.cellInputs()[index]?.nativeElement;
el?.select();
}
protected onBlur(): void {
this.onTouched();
}
private startCooldown(): void {
const seconds = Math.max(0, Math.floor(this.resendCooldown()) || 0);
this.clearCooldownTimer();
if (seconds <= 0) {
this.cooldownRemaining.set(0);
return;
}
this.cooldownRemaining.set(seconds);
this.cooldownTimer = setInterval(() => {
const left = this.cooldownRemaining() - 1;
if (left <= 0) {
this.cooldownRemaining.set(0);
this.clearCooldownTimer();
return;
}
this.cooldownRemaining.set(left);
}, 1000);
}
private clearCooldownTimer(): void {
if (this.cooldownTimer) {
clearInterval(this.cooldownTimer);
this.cooldownTimer = null;
}
}
private commitCells(cells: string[]): void {
this.applyValue(cells.join(''), true);
}
private applyValue(digits: string, emit: boolean): void {
const len = Math.max(1, Math.floor(this.length()) || 6);
const next = digits.slice(0, len);
this.value.set(next);
if (emit) {
this.onChange(next);
}
if (next.length === len && next !== this.lastCompleted) {
this.lastCompleted = next;
this.completed.emit(next);
}
if (next.length < len) {
this.lastCompleted = '';
}
}
private digitsOnly(value: string): string {
return value.replace(/\D/g, '');
}
private focusCell(index: number): void {
queueMicrotask(() => {
const el = this.cellInputs()[index]?.nativeElement;
el?.focus();
el?.select();
});
}
}