import {AfterViewInit, Component, ElementRef, forwardRef, Input, ViewChild} from '@angular/core'; import { AbstractControl, ControlContainer, ControlValueAccessor, FormBuilder, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, NgForm, ValidationErrors, Validator } from '@angular/forms'; import {fromEvent} from 'rxjs'; import {IntegerValueWithUnitEnum, UnitEnum} from '@nspop/gm-web-facade-api'; import deepEqual from 'deep-equal'; type NumberRangeValue = IntegerValueWithUnitEnum | number | undefined; type NumberRange = { min: NumberRangeValue, max: NumberRangeValue }; export const APP_RANGE_INPUT_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RangeInputComponent), multi: true }; export const APP_INPUT_CONTROL_VALIDATORS: any = { provide: NG_VALIDATORS, useExisting: forwardRef(() => RangeInputComponent), multi: true }; @Component({ selector: 'app-range-input', templateUrl: './range-input.component.html', styleUrls: ['./range-input.component.scss'], providers: [APP_RANGE_INPUT_CONTROL_VALUE_ACCESSOR, APP_INPUT_CONTROL_VALIDATORS], viewProviders: [ { provide: ControlContainer, useExisting: NgForm } ], }) export class RangeInputComponent implements AfterViewInit, ControlValueAccessor, Validator { @ViewChild('minInput', {static: true}) minInput: ElementRef | undefined; @ViewChild('maxInput', {static: true}) maxInput: ElementRef | undefined; private innerModel: NumberRange = {min: undefined, max: undefined}; // Configuration @Input() public label: string = ''; @Input() public name = ''; @Input() public type: 'number' | 'number-unit' = 'number'; @Input() public unit: UnitEnum | undefined; @Input() public unitDisplayLabel: string = ''; @Input() public disabled: boolean = false; @Input() public maxLength: number = 2; @Input() public separator: string = '-'; public isMinFocused = false; public isMaxFocused = false; public invalid = false; public form: FormGroup = this.fb.group({ min: [{value: null, disabled: this.disabled}], max: [{value: null, disabled: this.disabled}] }); private readonly numberKeys: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; private readonly separatorKeys: string[] = [':', '-', ';', ',', this.separator]; private readonly allowedKeys: string[] = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'] .concat(this.numberKeys) .concat(this.separatorKeys); private dirtyAble = false; private onChangeCallback: (newValue?: any) => any = () => {}; private onTouchedCallback: () => any = () => {}; constructor(private fb: FormBuilder) { } writeValue(range: NumberRange): void { this.innerModel = !!range ? range : { min: undefined, max: undefined }; this.handleNgModelBookKeeping(); } get min(): number | undefined { return this.getInnerValueAsNumber(this.innerModel.min); } set min(value: number | undefined) { const formattedValue = this.formatValue(value); if (!deepEqual(this.innerModel.min, formattedValue)) { this.innerModel.min = formattedValue; this.handleNgModelBookKeeping(); } } get max(): number | undefined { return this.getInnerValueAsNumber(this.innerModel.max); } set max(value: number | undefined) { const formattedValue = this.formatValue(value); if (!deepEqual(this.innerModel.max, formattedValue)) { this.innerModel.max = formattedValue; this.handleNgModelBookKeeping(); } } private getInnerValueAsNumber(value: NumberRangeValue): number | undefined { if (value == null) { return undefined; } switch (this.type) { case 'number': return (value as number); case 'number-unit': return (value as IntegerValueWithUnitEnum).value; } } private formatValue(value: number | undefined): NumberRangeValue { switch (this.type) { case 'number': return value; case 'number-unit': if (value) { return {value, unit: 'd'}; } else { return undefined; } } } private handleNgModelBookKeeping(): void { if (this.dirtyAble) { this.onChangeCallback(this.innerModel); } } handleMinFocus(arrowNavigation?: boolean): void { this.form.markAsTouched(); if (this.isMinFocused) { return; } this.dirtyAble = true; this.isMinFocused = true; this.minInput?.nativeElement?.focus?.(); if (!arrowNavigation) { this.minInput?.nativeElement.setSelectionRange(this.maxLength, this.maxLength); } } handleMaxFocus(arrowNavigation?: boolean): void { this.form.markAsTouched(); if (this.isMaxFocused) { return; } this.isMaxFocused = true; this.maxInput?.nativeElement?.focus?.(); if (!arrowNavigation) { this.maxInput?.nativeElement.setSelectionRange(this.maxLength, this.maxLength); } } ngAfterViewInit(): void { if (this.minInput) { fromEvent(this.minInput.nativeElement, 'keydown').subscribe((event) => { // We're using input=text instead of number because otherwise we cannot get cursor position. // Without cursor position we cannot do a seamless transition from one field to the other with arrowkeys. // Thus we manually validate allowed keys. if (!this.allowedKeys.includes(event.key)) { return event.returnValue = false; } this.focusMaxInputOnKeyInputWhileAtMaxLength(event); this.focusMaxInputOnArrowRightWhenAtInputEnd(event); this.focusMaxInputOnSeparatorKeyEvent(event); if (this.separatorKeys.includes(event.key)) { return event.returnValue = false; } }); } if (this.maxInput) { fromEvent(this.maxInput.nativeElement, 'keydown').subscribe((event) => { if (!this.allowedKeys.includes(event.key)) { return event.returnValue = false; } this.focusMinInputOnDeleteWhenInputLengthIsZero(event); this.focusMinInputOnArrowLeftWhenAtInputStart(event); if (this.separatorKeys.includes(event.key)) { event.returnValue = false; } }); } } private isMinInputAtMaxLength(): boolean { return !!this.minInput?.nativeElement?.value && this.minInput?.nativeElement.value.length >= this.maxLength; } private focusMaxInputOnKeyInputWhileAtMaxLength(event: KeyboardEvent): void { if (this.isMinInputAtMaxLength() && this.numberKeys.includes(event.key)) { this.handleMaxFocus(); } } private focusMaxInputOnArrowRightWhenAtInputEnd(event: KeyboardEvent): void { const minInputNotEmpty = this.minInput?.nativeElement.value && this.minInput?.nativeElement.value.length !== 0; const selectionAtEndOfInput = this.minInput?.nativeElement.selectionStart === this.minInput?.nativeElement.value.length; if (event.key === 'ArrowRight' && minInputNotEmpty && selectionAtEndOfInput) { event.preventDefault(); event.returnValue = false; this.handleMaxFocus(true); } } private focusMaxInputOnSeparatorKeyEvent(event: KeyboardEvent): void { if (this.separatorKeys.includes(event.key)) { event.preventDefault(); event.returnValue = false; this.handleMaxFocus(); } } private focusMinInputOnDeleteWhenInputLengthIsZero(event: KeyboardEvent): void { if (this.maxInput?.nativeElement?.value?.length === 0 && (event.key === 'Delete' || event.key === 'Backspace' || event.key === 'ArrowLeft') ) { event.preventDefault(); event.returnValue = false; this.handleMinFocus(); } } private focusMinInputOnArrowLeftWhenAtInputStart(event: KeyboardEvent): void { const selectionAtStartOfInput = this.maxInput?.nativeElement.selectionStart === 0; if (event.key === 'ArrowLeft' && selectionAtStartOfInput) { event.returnValue = false; this.handleMinFocus(true); } } registerOnChange(fn: any): void { this.onChangeCallback = fn; } registerOnTouched(fn: any): void { this.onTouchedCallback = fn; } validate(control: AbstractControl): ValidationErrors | null { const minInvalid = !!this.minInput?.nativeElement?.validationMessage.length; const maxInvalid = !!this.maxInput?.nativeElement?.validationMessage.length; const rangeInvalid = (!!this.min && !!this.max) ? +this.min > +this.max : false; this.invalid = minInvalid || maxInvalid || rangeInvalid; return this.invalid ? { invalidRange: this.name } : null; } handleFormFieldClick(): void { if (!!this.max) { return this.handleMaxFocus(); } // If max exists we focus that one first. return this.handleMinFocus(); } }