Skip to content

Commit

Permalink
Number input registers number type
Browse files Browse the repository at this point in the history
  • Loading branch information
cameronpettit committed Feb 7, 2024
1 parent 2c38df7 commit 38383e5
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<ng-content #inputPrepend select="[ngdsInputPrepend]"></ng-content>

<!-- Input -->
<input inputmode="numeric" #inputElement [formControl]="control"
<input inputmode="numeric" #inputElement [(ngModel)]="displayValue"
class="form-control border-0 rounded-3"
[ngClass]="getInputClasses()"
[placeholder]="placeholder ? placeholder : ''" (focus)="onFocus()" (blur)="onLoseFocus()" [attr.id]="control?.id">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,21 @@ import { BehaviorSubject } from 'rxjs';
export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
// Hide increment/decrement arrows
@Input() showIncrements: boolean = false;
// Increment size
@Input() incrementValue: number = 1;
// Whether or not to allow incrementing with the mouse scroll wheel
@Input() mouseScrollIncrement: boolean = false;
// Number of decimal places (-1 for no limit)
@Input() decimalPlaces: number = -1;
// Whether or not to allow negative values
@Input() allowNegative: boolean = true;
// Whether or not to pad decimal places with zeroes on blur
@Input() padDecimals: boolean = false;
@Input() allowedKeys: string[] = [
// Whether or not to represent the field value as a string
@Input() valueAsString: boolean = false;

// Allowed characters
public allowedKeys: string[] = [
"0",
"1",
"2",
Expand All @@ -30,6 +39,7 @@ export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
"-"
];

// Allowed function keys
public functionKeyCodes: string[] = [
"AltLeft",
"AltRight",
Expand All @@ -55,19 +65,46 @@ export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
"Backspace"
]

public value: any;
// The regex used to enforce the field display, can change based on Input values
public regex;
// Tracks the display of the field
public _displayValue = new BehaviorSubject(null);
// Flag to ignore control valueChanges when they are enforced by the input displace
private isProgrammaticUpdate: boolean = true;

get displayValue() {
return this._displayValue.value;
}

set displayValue(value) {
this._displayValue.next(value);
if (value) {
// We cannot safely represent all numbers
if (this.checkNumberRepresentation(value)) {
let next = String(value).match(this.regex)?.[0];
if (value === '-') {
next = '-';
}
if (next) {
this._displayValue.next(next);
} else {
this._displayValue.next(null);
}
} else {
return;
}
} else {
this._displayValue.next(null);
}
this.matchControlToDisplay();
}

ngAfterViewInit(): void {
// monitor control value changes
this.subscriptions.add(
this.control.valueChanges.subscribe((value) => {
this.matchDisplayToControl();
})
)
// build regex
const signRegExp = this.allowNegative ? '[+-]?' : '';
if (this.decimalPlaces < 0) {
Expand Down Expand Up @@ -98,7 +135,7 @@ export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
return;
}
// prevent more than 1 decimal or minus sign not at the start
if (e.key === '-' && (this.getCaretPos() > 0 || !this.allowNegative)) {
if (e.key === '-' && (this.getCaretPos().startPos > 0 || !this.allowNegative)) {
e.preventDefault();
return;
}
Expand All @@ -107,28 +144,52 @@ export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
return;
}
// Decimal places & enforce regex
if (this.allowedKeys.indexOf(e.key) > -1 && this.control.value) {
const value = this.control.value?.toString();
const pos = this.getCaretPos();
const next = [value.slice(0, pos), e.key, value.slice(pos)].join('');
const match = next.match(this.regex);
if (match[0] !== match['input']) {
e.preventDefault();
return;
if (this.allowedKeys.indexOf(e.key) > -1 && this.displayValue) {
let value = this.displayValue?.toString();
const { startPos, endPos } = this.getCaretPos();
// perform regex
const next = [value.slice(0, startPos), e.key, value.slice(endPos, value?.length - 1)].join('');
if (next !== '-' && next !== '.' && next !== '-.') {
if (!this.checkRegex(next) || !this.checkNumberRepresentation(next)) {
e.preventDefault();
return;
}
}
}
}

// Check for allowable key combinations
isAllowableKeyCombo(e) {
// check copy, cut, paste
if ((e.key === 'a' && e.ctrlKey === true) || // Allow: Ctrl+A
(e.key === 'c' && e.ctrlKey === true) || // Allow: Ctrl+C
(e.key === 'v' && e.ctrlKey === true) || // Allow: Ctrl+V
(e.key === 'x' && e.ctrlKey === true) || // Allow: Ctrl+X
(e.key === 'a' && e.metaKey === true) || // Cmd+A (Mac)
(e.key === 'c' && e.metaKey === true) || // Cmd+C (Mac)
(e.key === 'v' && e.metaKey === true) || // Cmd+V (Mac)
(e.key === 'x' && e.metaKey === true) // Cmd+X (Mac)
) {
return true;
}
if (this.allowedKeys.indexOf(e.key) > -1 || this.functionKeyCodes.indexOf(e.code) > -1) {
return true;
}
return false;
}

// For paste event
@HostListener('paste', ['$event'])
onPaste(e: ClipboardEvent) {
if (this.isDisabled) {
return;
}
e.preventDefault()
e.preventDefault();
let payload = e.clipboardData.getData('text/plain').match(this.regex)[0];
this.control.setValue(payload);
}

// For drag and drop event
@HostListener('drop', ['$event'])
onDrop(e: DragEvent) {
if (this.isDisabled) {
Expand All @@ -139,6 +200,7 @@ export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
this.control.setValue(payload);
}

// For mouse wheel event
@HostListener('mousewheel', ['$event'])
scroll(e: MouseEvent) {
if (this.isDisabled) {
Expand All @@ -154,29 +216,83 @@ export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
}
}

isAllowableKeyCombo(e) {
// check copy, cut, paste
if ((e.key === 'a' && e.ctrlKey === true) || // Allow: Ctrl+A
(e.key === 'c' && e.ctrlKey === true) || // Allow: Ctrl+C
(e.key === 'v' && e.ctrlKey === true) || // Allow: Ctrl+V
(e.key === 'x' && e.ctrlKey === true) || // Allow: Ctrl+X
(e.key === 'a' && e.metaKey === true) || // Cmd+A (Mac)
(e.key === 'c' && e.metaKey === true) || // Cmd+C (Mac)
(e.key === 'v' && e.metaKey === true) || // Cmd+V (Mac)
(e.key === 'x' && e.metaKey === true) // Cmd+X (Mac)
) {
return true;
// Check whether or not a (String) value has an identical number representation
checkNumberRepresentation(value: string, mustBeNumber = false) {
const num = Number(value) ?? null;
// Check safe integers
if (num && (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER)) {
return false;
}
if (this.allowedKeys.indexOf(e.key) > -1 || this.functionKeyCodes.indexOf(e.code) > -1) {
// Allow single characters that arent digits
if (value === '-' || value === '.') {
if (mustBeNumber) {
return false;
}
return true;
}
return false;
// Check number conversion
// Apply regex
if (!this.checkRegex(value)){
return false;
}
// Remove minus sign
let comp = String(value).replace('-', '');
// Add leading zero if value begins with decimal
if (comp.startsWith('.')) {
comp = '0' + comp;
}
if (String(Math.abs(num)) !== comp && !comp?.endsWith('.') && !comp?.endsWith('0')) {
return false;
}
return true;
}

// Match the input display to the control value
matchDisplayToControl() {
if (this.isProgrammaticUpdate) {
let next = String(this.control?.value).match(this.regex)?.[0];
if (this.padDecimals && this.decimalPlaces > 0 && next !== null && next?.toString() !== '') {
next = Number(next).toFixed(this.decimalPlaces);
}
if (!next || isNaN(Number(next))) {
this.displayValue = null;
} else {
this.displayValue = String(next);
}
}
this.isProgrammaticUpdate = true;
}

// Match the control value to the display
matchControlToDisplay() {
// If value as string, don't do anything fancy
if (this.valueAsString) {
this.setControlValue(this.displayValue, true);
return;
}
// Otherwise convert to number before updating
if (this.displayValue) {
const next = Number(this.displayValue);
if (isNaN(next)) {
this.setControlValue(NaN);
}
if (this.control.value !== next) {
this.setControlValue(next);
}
} else {
this.setControlValue(NaN);
}
}

// Get start and end selection position for checking what the next value would be
getCaretPos() {
return Number(this.inputElement?.nativeElement?.selectionStart || 0);
return {
startPos: Number(this.inputElement?.nativeElement?.selectionStart || 0),
endPos: Number(this.inputElement?.nativeElement?.selectionEnd || 0)
};
}

// Increment or decrement the field value
increment(decrement = false) {
const value = this.control.value || 0;
let newValue = Number(value);
Expand All @@ -187,40 +303,61 @@ export class NgdsNumberInput extends NgdsInput implements AfterViewInit {
}
// round value (floating point handling)
const power = Math.pow(10, Math.abs(this.decimalPlaces));
newValue = Math.round(newValue*power)/power;
newValue = Math.round(newValue * power) / power;
if (!this.checkRegex(newValue.toString())) {
return;
}
this.control.markAsDirty();
this.control.markAsTouched();
this.padAndSetValue(newValue, true);
this.padAndSetDisplay(newValue, true);
}

// Check if the regex passes or not
checkRegex(value) {
if (value) {
const match = value.match(this.regex);
const match = String(value).match(this.regex);
if (!match || match?.[0] !== match?.['input']) {
return false;
}
}
return true;
}

padAndSetValue(value, alwaysSet = false) {
// Pad display with zeroes (and set control value if valueAsString)
padAndSetDisplay(value, alwaysSet = false) {
if (this.padDecimals && this.decimalPlaces > 0 && value !== null && value?.toString() !== '') {
this.control.setValue(Number(value).toFixed(this.decimalPlaces));
this.displayValue = Number(value).toFixed(this.decimalPlaces);
}
else if (alwaysSet) {
this.control.setValue(value);
this.displayValue = value;
}
if (this.valueAsString) {
this.setControlValue(this.displayValue);
}
}

// Set control value from a change in the display input
// This puts down the isProgrammaticUpdate flag so control.valueChanges() methods are ignored.
setControlValue(value, string = false) {
this.isProgrammaticUpdate = false;
if (this.valueAsString || string) {
if (!value) {
this.control.setValue(null);
} else {
this.control.setValue(String(value));
}
} else {
this.control.setValue(isNaN(Number(value)) ? null : Number(value));
}
}

// Fires the padding method on blur
onLoseFocus() {
this.onBlur();
if (!this.checkRegex(this.control.value?.toString())) {
if (!this.checkRegex(this.displayValue?.toString())) {
this.control.reset();
}
this.padAndSetValue(this.control.value);
this.padAndSetDisplay(this.displayValue);
}

}
6 changes: 5 additions & 1 deletion src/app/forms/forms.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ import { NumbersComponent } from './numbers/numbers.component';
PicklistsComponent,
TypeaheadsComponent,
TextInputComponent,
MultiselectsComponent
MultiselectsComponent,
DatepickersComponent,
RangepickersComponent,
TogglesComponent,
NumbersComponent
]
})
export class FormsModule { }
26 changes: 26 additions & 0 deletions src/app/forms/numbers/number-input-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,5 +281,31 @@ export const snippets = {
}
}
})`
},
asString: {
html: `
<ngds-number-input
[control]="form?.controls?.['asString']"
[decimalPlaces]="2"
[valueAsString]="true"
[padDecimals]="true"
[resetButton]="true">
</ngds-number-input>`,
ts: `
import { Component, OnInit } from '@angular/core';
import { UntypedFormControl, UntypedFormGroup} from '@angular/forms';
@Component({
selector: 'value-as-string'
export class ValueAsStringNumber implements OnInit {
public form;
ngOnInit(): void {
this.form = new UntypedFormGroup({
asString: new UntypedFormControl(null),
})
}
}
})`
}
}
Loading

0 comments on commit 38383e5

Please sign in to comment.