UNPKG

@ngxpert/input-otp

Version:

One-time password input component for Angular.

396 lines (389 loc) 22.3 kB
import * as i0 from '@angular/core'; import { computed, untracked, input, viewChild, output, signal, inject, Injector, Renderer2, linkedSignal, effect, Component, ChangeDetectionStrategy } from '@angular/core'; import * as i1 from '@angular/forms'; import { FormControl, NG_VALUE_ACCESSOR, NG_VALIDATORS, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { DOCUMENT } from '@angular/common'; import { toSignal } from '@angular/core/rxjs-interop'; import { startWith } from 'rxjs'; /** * Returns a signal that contains the value of a control (or a form). * @param control * @param injector */ function getControlValueSignal(control, injector) { const valueChanges = computed(() => { return untracked(() => toSignal(control.valueChanges.pipe(startWith(control.value)), { injector, initialValue: undefined, })); }); return computed(() => valueChanges()()); } // TODO: Fix password manager badge tracking // const PWM_BADGE_MARGIN_RIGHT = 18; const PWM_BADGE_SPACE_WIDTH_PX = 40; const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px`; // TODO: Fix password manager badge tracking // const PASSWORD_MANAGERS_SELECTORS = [ // '[data-lastpass-icon-root]', // LastPass // 'com-1password-button', // 1Password // '[data-dashlanecreated]', // Dashlane // '[style$="2147483647 !important;"]', // Bitwarden // ].join(','); class InputOTPComponent { static nextId = 0; idNextId = `input-otp-${InputOTPComponent.nextId++}`; id = input(this.idNextId); name = input(); containerRef = viewChild('containerRef'); inputRef = viewChild('inputRef'); maxLength = input.required(); textAlign = input('left'); pattern = input(); placeholder = input(); inputMode = input('numeric'); disabled = input(false); autoComplete = input(); pushPasswordManagerStrategy = input('increase-width'); containerClass = input(); complete = output(); mirrorSelectionStart = signal(null); mirrorSelectionEnd = signal(null); isFocused = signal(false); isHovering = signal(false); hasPWMBadge = signal(false); hasPWMBadgeSpace = signal(false); done = signal(false); formControl = new FormControl(''); value = getControlValueSignal(this.formControl, inject(Injector)); slots = computed(() => { const slots = []; for (let i = 0; i < this.maxLength(); i++) { const mirrorSelectionStart = this.mirrorSelectionStart(); const mirrorSelectionEnd = this.mirrorSelectionEnd(); const isActive = this.isFocused() && mirrorSelectionStart !== null && mirrorSelectionEnd !== null && ((mirrorSelectionStart === mirrorSelectionEnd && i === mirrorSelectionStart) || (i >= mirrorSelectionStart && i < mirrorSelectionEnd)); const value = this.value(); const char = value && value[i] !== undefined ? value[i] : null; const placeholderChar = value && value[0] !== undefined ? null : (this.placeholder()?.[i] ?? null); slots.push({ char, placeholderChar, isActive, hasFakeCaret: isActive && char === null, }); } return slots; }); resizeObserver; previousValue; document = inject(DOCUMENT); renderer2 = inject(Renderer2); isIOS = false; inputMetadataRef = linkedSignal(() => ({ prev: [ this.inputRef()?.nativeElement?.selectionStart ?? null, this.inputRef()?.nativeElement?.selectionEnd ?? null, this.inputRef()?.nativeElement?.selectionDirection ?? 'none', ], })); onDocumentSelectionChange; constructor() { this.formControl.addValidators([this.validate.bind(this)]); effect(() => { const newValue = this.value(); if (this.previousValue !== null && this.previousValue !== undefined && this.previousValue.length < this.maxLength() && this.formControl.valid) { // formControl.valid is true if the value is valid, so we can safely emit the value this.valueOrFocusedChanged(); if (newValue !== null && newValue !== undefined && newValue.length === this.maxLength()) { this.complete.emit(newValue); } } this.previousValue = newValue; }); effect(() => { const disabled = this.disabled(); if (disabled) { this.formControl.disable(); } else { this.formControl.enable(); } }); } writeValue(value) { this.formControl.setValue(value); } registerOnChange(fn) { this.formControl.valueChanges.subscribe(fn); } registerOnTouched(fn) { this.formControl.valueChanges.subscribe(fn); } setDisabledState(isDisabled) { if (isDisabled) { this.formControl.disable(); } else { this.formControl.enable(); } } validate(control) { const value = control.value; if (value?.length !== this.maxLength()) { return { length: true }; } const pattern = this.pattern(); const regexp = typeof pattern === 'string' ? new RegExp(pattern) : pattern; if (value?.length > 0 && !regexp?.test(value)) { return { pattern: true }; } return null; } valueOrFocusedChanged() { // Forcefully remove :autofill state this.inputRef()?.nativeElement?.dispatchEvent(new Event('input')); // Update the selection state const s = this.inputRef()?.nativeElement?.selectionStart; const e = this.inputRef()?.nativeElement?.selectionEnd; const dir = this.inputRef()?.nativeElement?.selectionDirection; if (s !== null && s !== undefined && e !== null && e !== undefined && dir !== null && dir !== undefined) { this.mirrorSelectionStart.set(s); this.mirrorSelectionEnd.set(e); this.inputMetadataRef.set({ prev: [s, e, dir] }); } } rootStyle = computed(() => ({ position: 'relative', cursor: this.disabled() ? 'default' : 'text', userSelect: 'none', WebkitUserSelect: 'none', pointerEvents: 'none', })); inputStyle = computed(() => { const willPushPWMBadge = this.pushPasswordManagerStrategy() !== 'none' && this.hasPWMBadge() && this.hasPWMBadgeSpace(); return { position: 'absolute', inset: '0', width: willPushPWMBadge ? `calc(100% + ${PWM_BADGE_SPACE_WIDTH})` : '100%', clipPath: willPushPWMBadge ? `inset(0 ${PWM_BADGE_SPACE_WIDTH} 0 0)` : undefined, height: '100%', display: 'flex', // textAlign: this.textAlign(), opacity: '1', color: 'transparent', pointerEvents: 'all', background: 'transparent', caretColor: 'transparent', border: '0 solid transparent', outline: '0 solid transparent', boxShadow: 'none', lineHeight: '1', letterSpacing: '-0.5em', fontSize: 'var(--root-height)', fontFamily: 'monospace', fontVariantNumeric: 'tabular-nums', }; }); ngAfterViewInit() { this.setupResizeObserver(); const onDocumentSelectionChange = () => { const input = this.inputRef()?.nativeElement; if (this.document.activeElement !== input) { this.mirrorSelectionStart.set(null); this.mirrorSelectionEnd.set(null); return; } // Aliases const _s = input.selectionStart; const _e = input.selectionEnd; const _dir = input.selectionDirection; const _ml = input.maxLength; const _val = input.value; const _prev = this.inputMetadataRef().prev; // Algorithm let start = -1; let end = -1; let direction; if (_val.length !== 0 && _s !== null && _e !== null) { const isSingleCaret = _s === _e; const isInsertMode = _s === _val.length && _val.length < _ml; if (isSingleCaret && !isInsertMode) { const c = _s; if (c === 0) { start = 0; end = 1; direction = 'forward'; } else if (c === _ml) { start = c - 1; end = c; direction = 'backward'; } else if (_ml > 1 && _val.length > 1) { let offset = 0; if (_prev[0] !== null && _prev[1] !== null) { direction = c < _prev[1] ? 'backward' : 'forward'; const wasPreviouslyInserting = _prev[0] === _prev[1] && _prev[0] < _ml; if (direction === 'backward' && !wasPreviouslyInserting) { offset = -1; } } start = offset + c; end = offset + c + 1; } } if (start !== -1 && end !== -1 && start !== end) { input.setSelectionRange(start, end, direction); } } // Finally, update the state const s = start !== -1 ? start : _s; const e = end !== -1 ? end : _e; const dir = direction ?? _dir; this.mirrorSelectionStart.set(s); this.mirrorSelectionEnd.set(e); // Store the previous selection value this.inputMetadataRef.set({ prev: [s, e, dir] }); }; this.document.addEventListener('selectionchange', onDocumentSelectionChange, { capture: true, }); // Set initial mirror state onDocumentSelectionChange(); this.onDocumentSelectionChange = onDocumentSelectionChange; if (this.document.activeElement === this.inputRef()?.nativeElement) { this.changeFocus(true); } // Apply needed styles if (!this.document.getElementById('input-otp-style')) { const styleEl = this.document.createElement('style'); styleEl.id = 'input-otp-style'; this.document.head.appendChild(styleEl); if (styleEl.sheet) { const autofillStyles = 'background: transparent !important; color: transparent !important; border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; -webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;'; this.safeInsertRule(styleEl.sheet, '[data-input-otp]::selection { background: transparent !important; color: transparent !important; }'); this.safeInsertRule(styleEl.sheet, `[data-input-otp]:autofill { ${autofillStyles} }`); this.safeInsertRule(styleEl.sheet, `[data-input-otp]:-webkit-autofill { ${autofillStyles} }`); // iOS this.safeInsertRule(styleEl.sheet, `@supports (-webkit-touch-callout: none) { [data-input-otp] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }`); // PWM badges this.safeInsertRule(styleEl.sheet, `[data-input-otp] + * { pointer-events: all !important; }`); } } // Track root height const updateRootHeight = () => { if (this.containerRef()?.nativeElement) { this.renderer2.setStyle(this.containerRef()?.nativeElement, '--root-height', `${this.inputRef()?.nativeElement.clientHeight}px`); } }; updateRootHeight(); } safeInsertRule(sheet, rule) { try { sheet.insertRule(rule); } catch { console.error('input-otp could not insert CSS rule:', rule); } } ngOnDestroy() { this.resizeObserver?.disconnect(); if (this.onDocumentSelectionChange) { this.document.removeEventListener('selectionchange', this.onDocumentSelectionChange, { capture: true }); } } onMouseEnter() { this.isHovering.set(true); } onMouseLeave() { this.isHovering.set(false); } onFocus() { this.changeFocus(true); } onBlur() { this.changeFocus(false); } changeFocus(value) { this.isFocused.set(value); this.valueOrFocusedChanged(); } setupResizeObserver() { if (typeof ResizeObserver === 'undefined') return; const input = this.inputRef()?.nativeElement; if (!input) return; this.resizeObserver = new ResizeObserver(() => { const container = this.containerRef()?.nativeElement; if (container) { container.style.setProperty('--root-height', `${input.clientHeight}px`); } }); this.resizeObserver.observe(input); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.5", ngImport: i0, type: InputOTPComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.1.5", type: InputOTPComponent, isStandalone: true, selector: "input-otp", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, maxLength: { classPropertyName: "maxLength", publicName: "maxLength", isSignal: true, isRequired: true, transformFunction: null }, textAlign: { classPropertyName: "textAlign", publicName: "textAlign", isSignal: true, isRequired: false, transformFunction: null }, pattern: { classPropertyName: "pattern", publicName: "pattern", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, inputMode: { classPropertyName: "inputMode", publicName: "inputMode", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, autoComplete: { classPropertyName: "autoComplete", publicName: "autoComplete", isSignal: true, isRequired: false, transformFunction: null }, pushPasswordManagerStrategy: { classPropertyName: "pushPasswordManagerStrategy", publicName: "pushPasswordManagerStrategy", isSignal: true, isRequired: false, transformFunction: null }, containerClass: { classPropertyName: "containerClass", publicName: "containerClass", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { complete: "complete" }, host: { properties: { "id": "id" } }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: InputOTPComponent, multi: true, }, { provide: NG_VALIDATORS, useExisting: InputOTPComponent, multi: true, }, ], viewQueries: [{ propertyName: "containerRef", first: true, predicate: ["containerRef"], descendants: true, isSignal: true }, { propertyName: "inputRef", first: true, predicate: ["inputRef"], descendants: true, isSignal: true }], exportAs: ["inputOtp"], ngImport: i0, template: "<div\n #containerRef\n [class]=\"containerClass()\"\n data-input-otp-container\n [style]=\"rootStyle()\"\n>\n <ng-content></ng-content>\n <div\n [style]=\"{\n position: 'absolute',\n inset: 0,\n pointerEvents: 'none',\n }\"\n >\n <input\n #inputRef\n data-input-otp\n [id]=\"id() ?? idNextId\"\n [name]=\"name() ?? ''\"\n [attr.data-input-otp-placeholder-shown]=\"formControl.value?.length === 0 || undefined\"\n [attr.data-input-otp-mss]=\"mirrorSelectionStart() || undefined\"\n [attr.data-input-otp-mse]=\"mirrorSelectionEnd() || undefined\"\n [inputMode]=\"inputMode()\"\n [pattern]=\"pattern() ?? ''\"\n [ariaPlaceholder]=\"placeholder()\"\n [style]=\"inputStyle()\"\n [maxLength]=\"maxLength()\"\n [formControl]=\"formControl\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n [autocomplete]=\"autoComplete() || 'one-time-code'\"\n />\n </div>\n</div>\n", styles: ["[data-input-otp]::selection{background:transparent!important;color:transparent!important}[data-input-otp]:autofill{background:transparent!important;color:transparent!important;border-color:transparent!important;opacity:0!important;box-shadow:none!important;-webkit-box-shadow:none!important;-webkit-text-fill-color:transparent!important}[data-input-otp]:-webkit-autofill{background:transparent!important;color:transparent!important;border-color:transparent!important;opacity:0!important;box-shadow:none!important;-webkit-box-shadow:none!important;-webkit-text-fill-color:transparent!important}@supports (-webkit-touch-callout: none){[data-input-otp]{letter-spacing:-.6em!important;font-weight:100!important;font-stretch:ultra-condensed;font-optical-sizing:none!important;left:-1px!important;right:1px!important}}[data-input-otp]+*{pointer-events:all!important}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.PatternValidator, selector: "[pattern][formControlName],[pattern][formControl],[pattern][ngModel]", inputs: ["pattern"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.5", ngImport: i0, type: InputOTPComponent, decorators: [{ type: Component, args: [{ selector: 'input-otp', imports: [FormsModule, ReactiveFormsModule], exportAs: 'inputOtp', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: InputOTPComponent, multi: true, }, { provide: NG_VALIDATORS, useExisting: InputOTPComponent, multi: true, }, ], host: { '[id]': 'id', }, template: "<div\n #containerRef\n [class]=\"containerClass()\"\n data-input-otp-container\n [style]=\"rootStyle()\"\n>\n <ng-content></ng-content>\n <div\n [style]=\"{\n position: 'absolute',\n inset: 0,\n pointerEvents: 'none',\n }\"\n >\n <input\n #inputRef\n data-input-otp\n [id]=\"id() ?? idNextId\"\n [name]=\"name() ?? ''\"\n [attr.data-input-otp-placeholder-shown]=\"formControl.value?.length === 0 || undefined\"\n [attr.data-input-otp-mss]=\"mirrorSelectionStart() || undefined\"\n [attr.data-input-otp-mse]=\"mirrorSelectionEnd() || undefined\"\n [inputMode]=\"inputMode()\"\n [pattern]=\"pattern() ?? ''\"\n [ariaPlaceholder]=\"placeholder()\"\n [style]=\"inputStyle()\"\n [maxLength]=\"maxLength()\"\n [formControl]=\"formControl\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n [autocomplete]=\"autoComplete() || 'one-time-code'\"\n />\n </div>\n</div>\n", styles: ["[data-input-otp]::selection{background:transparent!important;color:transparent!important}[data-input-otp]:autofill{background:transparent!important;color:transparent!important;border-color:transparent!important;opacity:0!important;box-shadow:none!important;-webkit-box-shadow:none!important;-webkit-text-fill-color:transparent!important}[data-input-otp]:-webkit-autofill{background:transparent!important;color:transparent!important;border-color:transparent!important;opacity:0!important;box-shadow:none!important;-webkit-box-shadow:none!important;-webkit-text-fill-color:transparent!important}@supports (-webkit-touch-callout: none){[data-input-otp]{letter-spacing:-.6em!important;font-weight:100!important;font-stretch:ultra-condensed;font-optical-sizing:none!important;left:-1px!important;right:1px!important}}[data-input-otp]+*{pointer-events:all!important}\n"] }] }], ctorParameters: () => [] }); const REGEXP_ONLY_DIGITS = '^\\d+$'; const REGEXP_ONLY_CHARS = '^[a-zA-Z]+$'; const REGEXP_ONLY_DIGITS_AND_CHARS = '^[a-zA-Z0-9]+$'; /* * Public API Surface of input-otp */ /** * Generated bundle index. Do not edit. */ export { InputOTPComponent, REGEXP_ONLY_CHARS, REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS }; //# sourceMappingURL=ngxpert-input-otp.mjs.map