/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Directive, HostListener, Input, ElementRef, inject } from '@angular/core'; import { NgControl } from '@angular/forms'; /** The kinds of field this knows how to hold to a shape. */ export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar'; interface Rule { /** Characters this kind will accept at all. */ allowed: RegExp; /** How many of them the field can hold, so it cannot be overfilled. */ maxLength: number; /** Whether what is typed is forced to upper case as it is entered. */ upper?: boolean; } const RULES: Record = { digits: { allowed: /[0-9]/, maxLength: 20 }, phone: { allowed: /[0-9+()\-\s]/, maxLength: 20 }, // A PAN is five letters, four digits, one letter - and is always written in capitals. pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }, // Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits. aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true } }; /** * Stops a field being filled with something it could never hold. * * A mobile number typed as letters, or a PAN of fifteen characters, is not a mistake worth * discovering on save - the customer is at the counter now, and the clerk is looking at the field * they just typed. Refusing the keystroke says so at the moment it happens, with nothing to read * and nothing to undo. * * Pasting is handled too, and is where this earns its place: a number copied from a spreadsheet * arrives with spaces, dashes or a +91 in front, and pasting is the one way into a field that * bypasses every keystroke rule. What can be salvaged is kept, the rest dropped. * * This only refuses characters the field cannot contain. Whether what is left is a valid number is * a separate question, answered by the form's own validators and again by the server - a screen * restriction is a convenience, not a guarantee. */ @Directive({ selector: '[mifosxRestrictInput]', standalone: true }) export class RestrictInputDirective { private el = inject(ElementRef); private control = inject(NgControl, { optional: true }); @Input('mifosxRestrictInput') kind: RestrictKind = 'digits'; private get rule(): Rule { return RULES[this.kind] ?? RULES.digits; } /** * Keys that move around or edit rather than enter a character, plus the usual copy/paste and * select-all chords. Blocking these would leave the clerk unable to correct their own typing. */ private isEditingKey(event: KeyboardEvent): boolean { const navigation = [ 'Backspace', 'Delete', 'Tab', 'Escape', 'Enter', 'Home', 'End', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown' ]; return navigation.includes(event.key) || event.ctrlKey || event.metaKey; } @HostListener('keydown', ['$event']) onKeydown(event: KeyboardEvent): void { if (this.isEditingKey(event) || event.key.length > 1) { return; } const input = this.el.nativeElement as HTMLInputElement; const hasSelection = input.selectionStart !== input.selectionEnd; if (!this.rule.allowed.test(event.key)) { event.preventDefault(); return; } // Replacing a selection does not make the field any longer, so only a plain insert can overfill. if (!hasSelection && input.value.length >= this.rule.maxLength) { event.preventDefault(); } } @HostListener('paste', ['$event']) onPaste(event: ClipboardEvent): void { event.preventDefault(); const pasted = event.clipboardData?.getData('text') ?? ''; const kept = Array.from(pasted) .filter((ch) => this.rule.allowed.test(ch)) .join('') .slice(0, this.rule.maxLength); this.write(this.rule.upper ? kept.toUpperCase() : kept); } /** Upper case is applied as the field is left, so the caret does not jump while typing. */ @HostListener('blur') onBlur(): void { if (!this.rule.upper) { return; } const current = String(this.el.nativeElement.value ?? ''); if (current !== current.toUpperCase()) { this.write(current.toUpperCase()); } } /** * Writes through the form control where there is one, so the value the form holds and the value * on screen stay the same thing - setting only the element would leave the form with the old one. */ private write(value: string): void { this.el.nativeElement.value = value; if (this.control?.control) { this.control.control.setValue(value); } else { this.el.nativeElement.dispatchEvent(new Event('input')); } } }