From bf095ee1ce94926d70da6bbcef70a731bdec052b Mon Sep 17 00:00:00 2001 From: "extern.fahrenschon_philipp@allianz.com" Date: Tue, 22 Aug 2023 16:27:34 +0200 Subject: [PATCH] fix(code-input): handle mobile keyboard clipboard pasting (#1012) --- .../src/code-input/code-input.component.html | 9 +- .../code-input/code-input.component.spec.ts | 126 +++++++++--------- .../src/code-input/code-input.component.ts | 87 ++++++------ 3 files changed, 105 insertions(+), 117 deletions(-) diff --git a/projects/ng-aquila/src/code-input/code-input.component.html b/projects/ng-aquila/src/code-input/code-input.component.html index 01ab31ecf..2efc59534 100644 --- a/projects/ng-aquila/src/code-input/code-input.component.html +++ b/projects/ng-aquila/src/code-input/code-input.component.html @@ -1,17 +1,16 @@ diff --git a/projects/ng-aquila/src/code-input/code-input.component.spec.ts b/projects/ng-aquila/src/code-input/code-input.component.spec.ts index 4dd261056..6380eb38d 100644 --- a/projects/ng-aquila/src/code-input/code-input.component.spec.ts +++ b/projects/ng-aquila/src/code-input/code-input.component.spec.ts @@ -1,4 +1,4 @@ -import { DOWN_ARROW, RIGHT_ARROW, UP_ARROW } from '@angular/cdk/keycodes'; +import { CONTROL, DOWN_ARROW, RIGHT_ARROW, UP_ARROW } from '@angular/cdk/keycodes'; import { ChangeDetectionStrategy, Component, Directive, Injectable, Type, ViewChild } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -20,6 +20,8 @@ abstract class CodeInputTest { negative = false; disabled = false; + tabindex = 0; + type = 'text'; onSubmit() {} } @@ -42,15 +44,7 @@ describe('NxCodeInputComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [NxCodeInputModule, FormsModule, ReactiveFormsModule], - declarations: [ - CodeInputTest1, - CodeInputTest2, - CodeInputTest3, - NumberCodeInput, - ConfigurableCodeInput, - OnPushCodeInput, - OverrideDefaultLabelsCodeInput, - ], + declarations: [CodeInputTest1, CodeInputTest2, CodeInputTest3, NumberCodeInput, ConfigurableCodeInput, OverrideDefaultLabelsCodeInput], providers: [NxCodeInputIntl], }).compileComponents(); })); @@ -58,6 +52,7 @@ describe('NxCodeInputComponent', () => { it('creates a 4 character input form', () => { createTestComponent(CodeInputTest1); expect(testInstance).toBeTruthy(); + expect(testInstance.codeInputInstance._keyCode).toEqual(['', '', '', '']); }); it('should have a codeLength of 4', () => { @@ -89,6 +84,30 @@ describe('NxCodeInputComponent', () => { expect(codeInputElement).toHaveClass('ng-invalid'); }); + it('should set selection range on keydown paste', () => { + createTestComponent(CodeInputTest1); + inputElement.value = '1'; + inputElement.focus(); + expect(inputElement.selectionStart).toBe(1); + expect(inputElement.selectionEnd).toBe(1); + dispatchKeyboardEvent(inputElement, 'keydown', CONTROL); + fixture.detectChanges(); + expect(inputElement.selectionStart).toBe(0); + expect(inputElement.selectionEnd).toBe(1); + }); + + it('should set selection range on mousedown', () => { + createTestComponent(CodeInputTest1); + inputElement.value = '1'; + inputElement.focus(); + expect(inputElement.selectionStart).toBe(1); + expect(inputElement.selectionEnd).toBe(1); + inputElement.dispatchEvent(new Event('mousedown')); + fixture.detectChanges(); + expect(inputElement.selectionStart).toBe(0); + expect(inputElement.selectionEnd).toBe(1); + }); + it('should select second input on right arrow', fakeAsync(() => { createTestComponent(CodeInputTest1); inputElement.focus(); @@ -129,45 +148,50 @@ describe('NxCodeInputComponent', () => { expect(codeInputElement).toHaveClass('has-error'); })); - it('should paste', fakeAsync(() => { + it('should paste', () => { createTestComponent(CodeInputTest1); - const data = new DataTransfer(); - data.items.add('abcd', 'text/plain'); - const clipboard = new ClipboardEvent('paste', { - clipboardData: data, - } as ClipboardEventInit); inputElement.focus(); - inputElement.dispatchEvent(clipboard); + inputElement.dispatchEvent(new InputEvent('input', { data: 'abcd' })); fixture.detectChanges(); - tick(1); ['A', 'B', 'C', 'D'].forEach((char, i) => { const input = codeInputElement.querySelector(`input:nth-child(${i + 1})`) as HTMLInputElement; expect(input.value).toBe(char); }); - })); + }); - it('should ignore non-number characters on paste on number input', fakeAsync(() => { - createTestComponent(NumberCodeInput); - const data = new DataTransfer(); - data.items.add('1a23', 'text/plain'); - const clipboard = new ClipboardEvent('paste', { - clipboardData: data, - } as ClipboardEventInit); + it('should work on browser autofill', () => { + /** + * important: this test will not fail if you add maxlength="1" in the template again. + * on android chrome e.g. what will happen then that it will create two input events. first one with the whole pasted value + * and a second one only with the first character. we can't really simulate this behaviour in a test. + */ + createTestComponent(CodeInputTest1); + inputElement.focus(); + fixture.detectChanges(); + + inputElement.dispatchEvent(new InputEvent('input', { data: '1234' })); + fixture.detectChanges(); + ['1', '2', '3', '4'].forEach((char, i) => { + const input = codeInputElement.querySelector(`input:nth-child(${i + 1})`) as HTMLInputElement; + expect(input.value).toBe(char); + }); + }); + + it('should ignore non-number characters on paste on number input', () => { + createTestComponent(NumberCodeInput); inputElement.focus(); - inputElement.dispatchEvent(clipboard); + inputElement.dispatchEvent(new InputEvent('input', { data: '1a23' })); fixture.detectChanges(); - tick(1); ['1', '2', '3', ''].forEach((char, i) => { const input = codeInputElement.querySelector(`input:nth-child(${i + 1})`) as HTMLInputElement; expect(input.value).toBe(char); }); - // @ts-expect-error fix nullability - expect(testInstance.codeInputInstance._keyCode).toEqual(['1', '2', '3', undefined]); - })); + expect(testInstance.codeInputInstance._keyCode).toEqual(['1', '2', '3', '']); + }); it('should prevent default on down arrow press in an empty input', fakeAsync(() => { createTestComponent(NumberCodeInput); @@ -249,8 +273,8 @@ describe('NxCodeInputComponent', () => { }); it('should set the passed tabindex', () => { - createTestComponent(CodeInputTest1); - testInstance.codeInputInstance.tabindex = 1; + createTestComponent(ConfigurableCodeInput); + testInstance.tabindex = 1; fixture.detectChanges(); expect(codeInputElement.getAttribute('tabindex')).toBe('-1'); @@ -261,8 +285,8 @@ describe('NxCodeInputComponent', () => { }); it('should change the input type', fakeAsync(() => { - createTestComponent(CodeInputTest3); - fixture.componentInstance.codeInputInstance.type = 'number'; + createTestComponent(ConfigurableCodeInput); + fixture.componentInstance.type = 'number'; fixture.detectChanges(); const inputEl = fixture.nativeElement.querySelector('.nx-code-input__field') as HTMLInputElement; expect(inputEl.getAttribute('type')).toBe('number'); @@ -289,18 +313,6 @@ describe('NxCodeInputComponent', () => { expect(testInstance.codeInputInstance.negative).toBeFalse(); expect(codeInputElement).not.toHaveClass('is-negative'); }); - - it('should update on negative change (programmatic change)', () => { - createTestComponent(OnPushCodeInput); - - testInstance.codeInputInstance.negative = true; - fixture.detectChanges(); - expect(codeInputElement).toHaveClass('is-negative'); - - testInstance.codeInputInstance.negative = false; - fixture.detectChanges(); - expect(codeInputElement).not.toHaveClass('is-negative'); - }); }); describe('disabled', () => { @@ -333,18 +345,6 @@ describe('NxCodeInputComponent', () => { }); }); - it('should update on disabled change (programmatic change)', () => { - createTestComponent(OnPushCodeInput); - - testInstance.codeInputInstance.disabled = true; - fixture.detectChanges(); - expect(codeInputElement).toHaveClass('is-disabled'); - - testInstance.codeInputInstance.disabled = false; - fixture.detectChanges(); - expect(codeInputElement).not.toHaveClass('is-disabled'); - }); - it('should update disabled on formGroup update', () => { createTestComponent(CodeInputTest1); expect(testInstance.codeInputInstance.disabled).toBeFalse(); @@ -436,16 +436,10 @@ class CodeInputTest3 extends CodeInputTest { class NumberCodeInput extends CodeInputTest {} @Component({ - template: ` `, + template: ` `, }) class ConfigurableCodeInput extends CodeInputTest {} -@Component({ - template: ` `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class OnPushCodeInput extends CodeInputTest {} - @Component({ template: ``, providers: [{ provide: NxCodeInputIntl, useClass: MyIntl }], diff --git a/projects/ng-aquila/src/code-input/code-input.component.ts b/projects/ng-aquila/src/code-input/code-input.component.ts index 15ed42563..90e555c38 100644 --- a/projects/ng-aquila/src/code-input/code-input.component.ts +++ b/projects/ng-aquila/src/code-input/code-input.component.ts @@ -34,7 +34,6 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { @Input('length') set codeLength(value: number) { this._codeLength = value; this.setInputLength(); - this._cdr.markForCheck(); } get codeLength() { return this._codeLength; @@ -44,7 +43,6 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { /** The type of HTML input */ @Input() set type(value: string) { this._type = value; - this._cdr.markForCheck(); } get type() { return this._type; @@ -54,7 +52,6 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { /** Sets the tabindex of the contained input elements. */ @Input() set tabindex(value: number) { this._tabindex = value; - this._cdr.markForCheck(); } get tabindex(): number { return this._tabindex; @@ -64,7 +61,6 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { /** Whether the form should auto capitalize or lowercase (optional). */ @Input() set convertTo(value: NxConversionTypes) { this._convertTo = value; - this._cdr.markForCheck(); } get convertTo() { return this._convertTo!; @@ -72,7 +68,7 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { private _convertTo?: NxConversionTypes; /** The user input in array form */ - _keyCode: string[] = new Array(DEFAULT_INPUT_LENGTH); + _keyCode: string[] = new Array(DEFAULT_INPUT_LENGTH).fill(''); private _focused = false; /** Whether the code input uses the negative set of styling. */ @@ -80,7 +76,6 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { const newValue = coerceBooleanProperty(value); if (this._negative !== newValue) { this._negative = newValue; - this._cdr.markForCheck(); } } get negative() { @@ -93,7 +88,6 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { const newValue = coerceBooleanProperty(value); if (this._disabled !== newValue) { this._disabled = newValue; - this._cdr.markForCheck(); } } get disabled() { @@ -135,10 +129,11 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { } else { this._keyCode = new Array(DEFAULT_INPUT_LENGTH); } + this._keyCode.fill(''); } /** Converts to upper or lowercase when enabled. */ - _convertLetterSize(value: any): string | undefined { + _convertLetterSize(value: any): string { if (value === 'ß') { return value; } @@ -152,7 +147,7 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { return value; } - return undefined; + return ''; } /** Reacts to keydown event. */ @@ -202,6 +197,7 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { break; default: + this.selectInput(targetElement); break; } } @@ -209,63 +205,62 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { /** Selects the value on click of an input field. */ _selectText(event: Event): void { this.selectInput(event.target as HTMLInputElement); + event.preventDefault(); } /** Automatically focuses and selects the next input on key input. */ - _selectNextInput(event: Event): void { + _handleInput(event: Event): void { const eventTarget: HTMLInputElement = event.target as HTMLInputElement; - eventTarget.value = this._convertLetterSize(eventTarget.value.slice(0, 1))!; + + // some event types have the data property populated, e.g. "inputType: insertCompositionText" + // these type of events should be fired e.g. when using the clipboard on an android device + // so we can either use the data property or the target value as fallback + const eventData = (event as InputEvent).data?.trim() || eventTarget.value.trim(); + const filteredData = this.type === 'number' ? this._filterNumbers(eventData) : eventData; const currentIndex = Number(this._getFocusedInputIndex(event)); - // save in model with uppercase if needed - this._keyCode[currentIndex] = eventTarget.value; - this.propagateChange(this._keyCode.join('')); - // don't jump to next input if the user uses UP/DOWn arrow (native behaviour) - const focusNextInput = !(this._isUpDown && this.type === 'number'); + this._setKeyCodes(currentIndex, filteredData); - if (eventTarget.value && focusNextInput) { - const nextInputField = eventTarget.nextSibling as HTMLInputElement; + // needed that we do not end up with multiple characters in one input field as + // we had to remove the maxlength="1" attribute + eventTarget.value = this._keyCode[currentIndex] ?? ''; + this.propagateChange(this._keyCode.join('')); - if (nextInputField !== null && nextInputField.tagName === TAG_NAME_INPUT) { - nextInputField.focus(); - if (nextInputField.value !== '') { - this.selectInput(nextInputField); - } - } + // don't jump to next input if the user uses UP/DOWN arrow (native behaviour) + const shouldMoveFocus = !(this._isUpDown && this.type === 'number'); + + if (filteredData && shouldMoveFocus) { + this.moveFocus(currentIndex, filteredData.length); } this._isUpDown = false; } - /** Paste event to distribute content in input fields. */ - _pasteClipboard(event: ClipboardEvent): void { - let copiedText = (event.clipboardData || (window as any).clipboardData).getData('text'); - let copiedTextIndex = 0; - const inputIndex = Number(this._getFocusedInputIndex(event)); - - copiedText = this.type === 'number' ? this._formatNumberInput(copiedText) : copiedText; - - for (let i: number = inputIndex; i < this.codeLength; i++) { - this._keyCode[i] = this._convertLetterSize(copiedText[copiedTextIndex])!; - copiedTextIndex++; + private _setKeyCodes(start: number, value: string) { + if (value.length <= 1) { + this._keyCode[start] = this._convertLetterSize(value); + } else { + for (let i = start, valueIndex = 0; i < this.codeLength; i++, valueIndex++) { + this._keyCode[i] = this._convertLetterSize(value[valueIndex]?.[0] ?? ''); + this._el.nativeElement.children[i].value = this._keyCode[i]; + } } + } - this.propagateChange(this._keyCode.join('')); - - if (inputIndex + copiedText.length < this.codeLength) { - this._el.nativeElement.children.item(inputIndex + copiedText.length).focus(); + /** Focus the next input depending on the the currently focused index and the length of the value that gets added. */ + private moveFocus(start: number, valueLength: number) { + if (start + valueLength < this.codeLength) { + this.selectInput(this._el.nativeElement.children.item(start + valueLength)); } else { this._el.nativeElement.children.item(this.codeLength - 1).focus(); } - - event.preventDefault(); } /** Returns the index of the code input, which is currently focused. */ private _getFocusedInputIndex(event: Event) { let inputIndex; for (let i = 0; i < this._el.nativeElement.children.length; i++) { - if (event.srcElement === this._el.nativeElement.children.item(i)) { + if (event.target === this._el.nativeElement.children.item(i)) { inputIndex = i; } } @@ -273,11 +268,11 @@ export class NxCodeInputComponent implements ControlValueAccessor, DoCheck { } /** Removes all characters from the input except for numbers [0-9]. */ - private _formatNumberInput(copiedText: string) { + private _filterNumbers(value: string) { let formattedInput = ''; - for (let i = 0; i < copiedText.length; i++) { - if (copiedText[i].match(/\d$/)) { - formattedInput += copiedText[i]; + for (const char of value) { + if (char.match(/\d$/)) { + formattedInput += char; } }