import type { ValidationState } from '../../common/static-wrappers/interfaces/validation-interface'; import { Prop, toNative } from 'vue-facing-decorator'; import TsxComponent, { Component } from '../../app/vuetsx'; import { isNullOrEmpty } from '../../common/utils/is-null-or-empty'; import TextBox, { TextBoxTextType } from './textbox'; import './css/pin.css'; export enum PinInputType { Alpha = 'alpha', Numeric = 'numeric', AlphaNumeric = 'alphanumeric', } interface PinInputArgs { showValue?: boolean; length: number; cssClass?: string; inputType?: PinInputType; value: string[]; changed: (value: string[]) => void; validationState?: ValidationState; /** * Fired once every cell carries a valid character (typed OR pasted). Use this * to auto-submit the form so the user doesn't have to also click a button * after entering the last digit / pasting a code. */ completed?: (value: string[]) => void; } @Component class PinInputComponent extends TsxComponent implements PinInputArgs { @Prop() showValue: boolean; @Prop() length: number; @Prop() cssClass!: string; @Prop() inputType: PinInputType; @Prop() value: string[]; @Prop() changed: (value: string[]) => void; @Prop() completed?: (value: string[]) => void; currentValue: string[] = null; mounted(): void { this.currentValue = Array.from({ length: this.length }).fill('') as any; } getCssClass(): string { return `pin ${this.cssClass || ''}`; } getInputType(): PinInputType { return this.inputType ?? PinInputType.Numeric; } get isFullyFilled(): boolean { return this.currentValue?.every(p => !isNullOrEmpty(p)); } isValidInput(value: string): boolean { const type = this.getInputType(); switch (type) { case PinInputType.Alpha: return /^[a-z]$/i.test(value); case PinInputType.Numeric: return /^\d$/.test(value); case PinInputType.AlphaNumeric: return /^[a-z0-9]$/i.test(value); default: return false; } } focusNext(index: number): void { if (index < this.length - 1) { const nextInput = this.getElement(index + 1); (nextInput.$el as HTMLElement)?.querySelector('input')?.focus(); } } focusPrev(index: number): void { if (index > 0) { const prevInput = this.getElement(index - 1); (prevInput.$el as HTMLElement)?.querySelector('input')?.focus(); } } updatePin(value: string, index: number): void { if (!this.isValidInput(value)) { return; } this.currentValue[index] = value.slice(-1); const isComplete = this.isFullyFilled; if (isComplete) { this.blurAllInputs(); } else { this.focusNext(index); } this.changed(this.currentValue); if (isComplete) { this.completed?.(this.currentValue); } } /** * Distribute a multi-character paste across the inputs. Filters by * `inputType` (so a numeric pin ignores non-digit chars), trims to * `length`, fills from index 0, then focuses the next-empty input — or * blurs all + fires `completed` when the paste fills every cell. */ handlePaste(event: ClipboardEvent): void { const pasted = event.clipboardData?.getData('text') ?? ''; const chars = pasted.split('').filter(c => this.isValidInput(c)).slice(0, this.length); if (chars.length === 0) { return; } event.preventDefault(); event.stopPropagation(); const next = Array.from({ length: this.length }).fill('') as string[]; for (let i = 0; i < chars.length; i++) { next[i] = chars[i]; } this.currentValue = next; const isComplete = this.isFullyFilled; if (isComplete) { this.blurAllInputs(); } else { const focusIndex = Math.min(chars.length, this.length - 1); const target = this.getElement(focusIndex); (target?.$el as HTMLElement)?.querySelector('input')?.focus(); } this.changed(this.currentValue); if (isComplete) { this.completed?.(this.currentValue); } } blurAllInputs(): void { for (let index = 0; index < this.currentValue?.length; index++) { const input = this.getElement(index); (input.$el as HTMLElement)?.querySelector('input')?.blur(); } } getElement(index: number) { if (index < 0 || index >= this.length) { return null; } return this.$refs[`pin-${index}`] as typeof TextBox.prototype; } getTextType(): TextBoxTextType { return this.showValue ? TextBoxTextType.Text : TextBoxTextType.Password; } render(h) { if (!this.currentValue) { return null; } const textType = this.getTextType(); return (
{ this.handlePaste(e); }}> {Array.from({ length: this.length }, (_, index) => ( { if (e.key == 'Backspace') { this.currentValue[index] = ''; this.focusPrev(index); this.changed(this.currentValue); return; } // Let clipboard/selection shortcuts (Ctrl/Cmd+V/C/A/X) and non-printable // keys (Tab, arrows) pass through - only block single printable chars // that aren't valid for this pin type. Blocking the Ctrl+V keydown here // cancelled the browser paste before the paste event could fire (QA_AT-103). if (e.ctrlKey || e.metaKey || e.altKey) { return; } if (e.key.length === 1 && !this.isValidInput(e.key)) { e.preventDefault(); e.stopPropagation(); } }} changed={(e) => { this.updatePin(e, index); }} /> ))}
); } } const PinInput = toNative(PinInputComponent); export default PinInput;