@ngxpert/input-otp
Version:
One-time password input component for Angular.
396 lines (389 loc) • 22.3 kB
JavaScript
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