From ab473deacb867ffd44f23e76b0ed6f9400d5072e Mon Sep 17 00:00:00 2001 From: hirsch Date: Tue, 31 Aug 2021 08:13:13 +0200 Subject: [PATCH] feat(input): add number-input with decimal --- packages/components-vue/src/components.ts | 20 +- packages/components/package.json | 1 + packages/components/src/components.d.ts | 8 +- .../src/components/bal-input/bal-input.tsx | 208 +++--------------- .../bal-input/bal-input.utils.spec.ts | 61 +++++ .../components/bal-input/bal-input.utils.ts | 37 ++++ .../src/components/bal-input/index.html | 2 +- .../src/components/bal-input/readme.md | 4 +- 8 files changed, 144 insertions(+), 197 deletions(-) create mode 100644 packages/components/src/components/bal-input/bal-input.utils.spec.ts create mode 100644 packages/components/src/components/bal-input/bal-input.utils.ts diff --git a/packages/components-vue/src/components.ts b/packages/components-vue/src/components.ts index 0816069145..42acb770db 100644 --- a/packages/components-vue/src/components.ts +++ b/packages/components-vue/src/components.ts @@ -1005,11 +1005,6 @@ export const BalInput = /*@__PURE__*/ defineComponent({ default: undefined, required: false, }, - suffix: { - type: String, - default: undefined, - required: false, - }, autocapitalize: { type: String, default: 'off', @@ -1115,6 +1110,16 @@ export const BalInput = /*@__PURE__*/ defineComponent({ default: false, required: false, }, + decimal: { + type: Number, + default: undefined, + required: false, + }, + suffix: { + type: String, + default: undefined, + required: false, + }, hasIconRight: { type: Boolean, default: false, @@ -1130,11 +1135,6 @@ export const BalInput = /*@__PURE__*/ defineComponent({ default: '', required: false, }, - decimal: { - type: Number, - default: undefined, - required: false, - }, modelValue: { default: undefined, }, diff --git a/packages/components/package.json b/packages/components/package.json index 36ddc327b5..5d0ce88891 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -49,6 +49,7 @@ "serve": "npm run copy:fonts && stencil build --docs --dev --watch --serve --config stencil.dev.config.ts", "test": "stencil test --spec --e2e --config stencil.test.config.ts", "test:watch": "stencil test --spec --e2e --watchAll", + "test:unit": "stencil test --spec", "test:unit:watch": "stencil test --spec --watchAll", "test:e2e:watch": "stencil test --e2e --watchAll" }, diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index f3688a5d17..bbc80cb8a7 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -676,7 +676,7 @@ export namespace Components { */ "debounce": number; /** - * Number of decimal places. + * Defins the allowed decimal points for the `number-input`. */ "decimal"?: number; /** @@ -721,7 +721,7 @@ export namespace Components { */ "name": string; /** - * If `true` only valid numbers can be entered and on mobile device the number keypad is active + * If `true` on mobile device the number keypad is active */ "numberInput": boolean; /** @@ -2536,7 +2536,7 @@ declare namespace LocalJSX { */ "debounce"?: number; /** - * Number of decimal places. + * Defins the allowed decimal points for the `number-input`. */ "decimal"?: number; /** @@ -2577,7 +2577,7 @@ declare namespace LocalJSX { */ "name"?: string; /** - * If `true` only valid numbers can be entered and on mobile device the number keypad is active + * If `true` on mobile device the number keypad is active */ "numberInput"?: boolean; /** diff --git a/packages/components/src/components/bal-input/bal-input.tsx b/packages/components/src/components/bal-input/bal-input.tsx index 4b7ee4487b..54cc437c2a 100644 --- a/packages/components/src/components/bal-input/bal-input.tsx +++ b/packages/components/src/components/bal-input/bal-input.tsx @@ -3,6 +3,7 @@ import { isNil } from 'lodash' import { NUMBER_KEYS, ACTION_KEYS, isCtrlOrCommandKey } from '../../constants/keys.constant' import { debounceEvent, findItemLabel } from '../../helpers/helpers' import { AutocompleteTypes, InputTypes } from '../../types/interfaces' +import { filterInputValue, formatInputValue } from './bal-input.utils' @Component({ tag: 'bal-input', @@ -11,14 +12,11 @@ import { AutocompleteTypes, InputTypes } from '../../types/interfaces' scoped: true, }) export class Input implements ComponentInterface { - private allowedKeys = [...NUMBER_KEYS, ...ACTION_KEYS] - private allowedActionKeys = [...ACTION_KEYS] + private allowedKeys = [...NUMBER_KEYS, '.', ...ACTION_KEYS] private inputId = `bal-input-${InputIds++}` private nativeInput?: HTMLInputElement private didInit = false private hasFocus = false - private isCopyPaste = false - private keysPressed: string[] = [] @Element() el!: HTMLElement @@ -37,11 +35,6 @@ export class Input implements ComponentInterface { */ @Prop() accept?: string - /** - * Adds a suffix the the inputvalue after blur. - */ - @Prop() suffix?: string - /** * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. * Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. @@ -149,10 +142,20 @@ export class Input implements ComponentInterface { @Prop() autoComplete: boolean = false /** - * If `true` only valid numbers can be entered and on mobile device the number keypad is active + * If `true` on mobile device the number keypad is active */ @Prop() numberInput = false + /** + * Defins the allowed decimal points for the `number-input`. + */ + @Prop() decimal?: number + + /** + * Adds a suffix the the inputvalue after blur. + */ + @Prop() suffix?: string + /** * @internal * If `true` the input will get some right padding. @@ -171,18 +174,13 @@ export class Input implements ComponentInterface { */ @Prop({ mutable: true }) value?: string | number = '' - /** - * Number of decimal places. - */ - @Prop() decimal?: number = undefined - /** * Update the native input element when the value changes */ @Watch('value') protected async valueChanged(newValue: string | number | undefined, oldValue: string | number | undefined) { if (this.didInit && !this.hasFocus && newValue !== oldValue) { - this.balChange.emit(this.getValueForEmitting()) + this.balChange.emit(this.getFormattedValue()) } } @@ -235,10 +233,6 @@ export class Input implements ComponentInterface { } } - componentWillLoad() { - this.value = this.getValueForEmitting() - } - /** * Sets focus on the native `input` in `bal-input`. Use this method instead of the global * `input.focus()`. @@ -264,150 +258,28 @@ export class Input implements ComponentInterface { } private getFormattedValue(): string { - let value = this.getRawValue() - + const value = this.getRawValue() const suffix = this.suffix !== undefined && value !== undefined && value !== '' ? ' ' + this.suffix : '' - return `${value}${suffix}` + return `${formatInputValue(value, this.decimal)}${suffix}` } - private getValueForEmitting(): any { - const value = this.numberInput ? this.formatNumber(this.getRawValue()) : this.getRawValue() - - if (isNaN(value)) { - return undefined - } - - if (this.decimal && this.countDecimals(value) > this.decimal) { - return value.toFixed(this.decimal) - } - - return value - } - - private addSuffixToNumber(value: any): string { - const suffix = this.suffix !== undefined && value !== undefined && value !== '' ? ' ' + this.suffix : '' - return `${value}${suffix}` - } - - private isNumeric(value: any): boolean { - return !isNaN(value - parseFloat(value)) - } - - private isStartingWithDot(value: string): boolean { - return value.charAt(0) == '.' - } - - private isValidAfterDot(value: string): boolean { - if (value.length == 1) { - return this.isStartingWithDot(value) - } - - if (this.isStartingWithDot(value)) { - if (value.substring(1).includes('.')) { - return false - } - - if(!this.isNumeric(value.substring(1))) { - return false - } - } - return true - } - - private insertDecimal(value: any, decimalPlaces: number): any { - - if (value.length == 1 && value.charAt(0) == '.') { - return (Math.round(0 * 100) / 100).toFixed(decimalPlaces) - } - - return (Math.round(value * 100) / 100).toFixed(decimalPlaces) - } - - private numberWithCommas(value: string): string { - if (this.formatNumber(value) == 0) { - value = this.formatNumber(value) - } - - return value.toString().replace(/\B(?, element: string): boolean { - return array.indexOf(element) === -1 - } - - private checkIfCopyPaste(event: KeyboardEvent): void { - if (isCtrlOrCommandKey(event) && this.isElementExistsInArray(this.keysPressed, 'ctrl')) { - this.keysPressed.push('ctrl') - } - - if (event.key == 'v' && this.isElementExistsInArray(this.keysPressed, 'v')) { - this.keysPressed.push('v') - } - - this.isCopyPaste = this.keysPressed.length === 2 ? true : false - } - - private countDecimals(value: any): any { - if (Math.floor(value) === value) return 0; - var str = value.toString(); - - if (str.indexOf(".") !== -1 && str.indexOf("-") !== -1) { - return str.split("-")[1] || 0; - } else if (str.indexOf(".") !== -1) { - return str.split(".")[1].length || 0; - } - return str.split("-")[1] || 0; -} - private onInput = (ev: InputEvent) => { const input = ev.target as HTMLInputElement | null + if (input) { - if (!this.isCopyPaste) { - this.value = input.value || '' - } else { - if (!this.isNumeric(input.value)) { - if (this.value == '') { - input.value = '' - this.value = undefined - } else { - input.value = this.value ? this.value.toString() : '' - } - } else { - this.value = input.value || '' - } - this.keysPressed = [] - this.isCopyPaste = false - } + const value = filterInputValue(input.value, this.value, this.decimal) + input.value = this.value = value || '' } - this.balInput.emit(this.getValueForEmitting()) + + this.balInput.emit(this.value) } private onKeyDown = (event: KeyboardEvent) => { - this.checkIfCopyPaste(event) - if (this.numberInput) { - const nextValue = this.value + '' + event.key - const isKeyAllowed = this.allowedKeys.indexOf(event.key) < 0 - const isNumeric = this.isNumeric(nextValue) - const isValidAfterDot = this.isValidAfterDot(nextValue) - if (!isNumeric && isKeyAllowed && !isCtrlOrCommandKey(event) && !isValidAfterDot) { + if (!isCtrlOrCommandKey(event) && this.allowedKeys.indexOf(event.key) < 0) { event.preventDefault() event.stopPropagation() } - if (this.decimal) { - const isKeyAllowed = this.allowedActionKeys.indexOf(event.key) < 0 - if (this.countDecimals(nextValue) > this.decimal && !isCtrlOrCommandKey(event) && isKeyAllowed) { - event.preventDefault() - event.stopPropagation() - } - } } } @@ -417,42 +289,18 @@ export class Input implements ComponentInterface { const input = ev.target as HTMLInputElement | null if (input) { - input.value = this.getValueForEmitting() - } - } - - private getValidatedNumber(inputValue: string): string { - let value = '' - - if (this.numberInput && inputValue.length > 0) { - if (this.decimal) { - value = this.insertDecimal(inputValue, this.decimal) - } else { - if (inputValue.charAt(inputValue.length - 1) == '.') { - value = inputValue.slice(0, -1) - } - if (this.isStartingWithDot(inputValue)) { - value = inputValue.length > 1 ? parseFloat(inputValue).toString() : '0' - } - } - value = value === '' ? inputValue : value - value = this.numberWithCommas(value) - - return this.suffix ? this.addSuffixToNumber(value) : value - } else { - return this.suffix ? this.getFormattedValue() : value + input.value = this.getRawValue() } } private onBlur = (ev: FocusEvent) => { this.hasFocus = false this.balBlur.emit(ev) - this.balChange.emit(this.getValueForEmitting()) + this.balChange.emit(this.getRawValue()) const input = ev.target as HTMLInputElement | null - if (input) { - input.value = this.numberInput ? this.getValidatedNumber(input.value) : this.getFormattedValue() + input.value = this.getFormattedValue() } } @@ -478,12 +326,12 @@ export class Input implements ComponentInterface { label.htmlFor = this.inputId } let inputProps = {} - if (this.numberInput) { - inputProps = { pattern: '[0-9]*' } - } if (this.pattern) { inputProps = { pattern: this.pattern } } + if (this.numberInput) { + inputProps = { pattern: '[0-9]*' } + } return ( { + describe('filterInputValue', () => { + test('should filter input for a valid number', () => { + expect(filterInputValue('a', '')).toBe('') + expect(filterInputValue('1.0.', '1.0')).toBe('1.0') + expect(filterInputValue('0', '')).toBe('0') + expect(filterInputValue('3', '')).toBe('3') + expect(filterInputValue('999', '')).toBe('999') + expect(filterInputValue('.', '')).toBe('.') + expect(filterInputValue('.0', '')).toBe('.0') + expect(filterInputValue('.4', '')).toBe('.4') + expect(filterInputValue('0.', '')).toBe('0.') + expect(filterInputValue('0.4', '')).toBe('0.4') + expect(filterInputValue('1.4', '')).toBe('1.4') + }) + test('should filter input value for a valid decmial number', () => { + expect(filterInputValue('a', '', 2)).toBe('') + expect(filterInputValue('0', '', 2)).toBe('0') + expect(filterInputValue('3', '', 2)).toBe('3') + expect(filterInputValue('999', '', 2)).toBe('999') + expect(filterInputValue('.', '', 2)).toBe('.') + expect(filterInputValue('.0', '', 2)).toBe('.0') + expect(filterInputValue('.4', '', 2)).toBe('.4') + expect(filterInputValue('0.', '', 2)).toBe('0.') + expect(filterInputValue('0.4', '', 2)).toBe('0.4') + expect(filterInputValue('1.4', '', 2)).toBe('1.4') + expect(filterInputValue('1.45', '', 2)).toBe('1.45') + expect(filterInputValue('1.456', '1.45', 2)).toBe('1.45') + expect(filterInputValue('^', '', 2)).toBe('') + }) + test('should filter input value for a valid number without decimal', () => { + expect(filterInputValue('a', '', 0)).toBe('') + expect(filterInputValue('0', '', 0)).toBe('0') + expect(filterInputValue('.', '', 0)).toBe('') + expect(filterInputValue('1.', '1', 0)).toBe('1') + expect(filterInputValue('1a', '1', 0)).toBe('1') + expect(filterInputValue('999', '', 0)).toBe('999') + }) + }) + describe('formatInputValue', () => { + test('should add delemiter', () => { + expect(formatInputValue('0')).toBe('0') + expect(formatInputValue('10')).toBe('10') + expect(formatInputValue('100')).toBe('100') + expect(formatInputValue('1000')).toBe("1'000") + expect(formatInputValue('10000')).toBe("10'000") + expect(formatInputValue('100000')).toBe("100'000") + expect(formatInputValue('1000000')).toBe("1'000'000") + }) + test('should adjust the decimal points', () => { + expect(formatInputValue('0')).toBe('0') + expect(formatInputValue('0.1', 2)).toBe('0.10') + expect(formatInputValue('a')).toBe('') + expect(formatInputValue('.1')).toBe('0.1') + expect(formatInputValue('.1', 2)).toBe('0.10') + expect(formatInputValue('.1', 0)).toBe('0') + }) + }) +}) diff --git a/packages/components/src/components/bal-input/bal-input.utils.ts b/packages/components/src/components/bal-input/bal-input.utils.ts new file mode 100644 index 0000000000..a05e5d9685 --- /dev/null +++ b/packages/components/src/components/bal-input/bal-input.utils.ts @@ -0,0 +1,37 @@ +export const filterInputValue = (value: string, oldValue: string | number | undefined, decimalPoints: number | undefined = undefined): string => { + const regex = /^(((0|[1-9]\d*)?)(\.\d*)?)$/g + let regexString = regex.source + if (decimalPoints === 0) { + regexString = /^[0-9]*$/g.source + } else if (decimalPoints !== undefined && decimalPoints > 0) { + regexString = regexString.replace('d*)?)$', `d{0,${decimalPoints}})?)$`) + } + const regexp = new RegExp(regexString, 'g') + + if (regexp.test(value)) { + return value + } + return oldValue === undefined ? '' : `${oldValue}` +} + +export const formatInputValue = (value: string, decimalPoints: number | undefined = undefined): string => { + if (value.charAt(0) === '.') { + value = `0${value}` + } + + let num: number | string = parseFloat(value) + + if (isNaN(num)) { + return '' + } + + if (decimalPoints !== undefined) { + if (decimalPoints === 0) { + num = parseInt(value, 10) + } else { + num = num.toFixed(decimalPoints) + } + } + + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'") +} diff --git a/packages/components/src/components/bal-input/index.html b/packages/components/src/components/bal-input/index.html index dc727663fd..6ac86872f5 100644 --- a/packages/components/src/components/bal-input/index.html +++ b/packages/components/src/components/bal-input/index.html @@ -52,7 +52,7 @@

Number Input

var input2 = document.getElementById('input-2') var input2Preview = document.getElementById('input-2-preview') var input2Change = document.getElementById('input-2-change') - + input2.addEventListener('balInput', function (event) { input2Preview.value = event.detail }) diff --git a/packages/components/src/components/bal-input/readme.md b/packages/components/src/components/bal-input/readme.md index de2a7e2c02..b31fe7372c 100644 --- a/packages/components/src/components/bal-input/readme.md +++ b/packages/components/src/components/bal-input/readme.md @@ -16,7 +16,7 @@ | `balTabindex` | `bal-tabindex` | The tabindex of the control. | `number` | `0` | | `clickable` | `clickable` | If `true` the input gets a clickable cursor style | `boolean` | `false` | | `debounce` | `debounce` | Set the amount of time, in milliseconds, to wait to trigger the `balChange` event after each keystroke. This also impacts form bindings such as `ngModel` or `v-model`. | `number` | `0` | -| `decimal` | `decimal` | Number of decimal places. | `number \| undefined` | `undefined` | +| `decimal` | `decimal` | Defins the allowed decimal points for the `number-input`. | `number \| undefined` | `undefined` | | `disabled` | `disabled` | If `true` the input is disabled | `boolean` | `false` | | `inputmode` | `inputmode` | A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`. | `"decimal" \| "email" \| "none" \| "numeric" \| "search" \| "tel" \| "text" \| "url" \| undefined` | `undefined` | | `inverted` | `inverted` | If `true` this component can be placed on dark background | `boolean` | `false` | @@ -26,7 +26,7 @@ | `minLength` | `min-length` | Defines the min length of the value. | `number \| undefined` | `undefined` | | `multiple` | `multiple` | If `true`, the user can enter more than one value. This attribute applies when the type attribute is set to `"email"` or `"file"`, otherwise it is ignored. | `boolean \| undefined` | `undefined` | | `name` | `name` | The name of the control, which is submitted with the form data. | `string` | `this.inputId` | -| `numberInput` | `number-input` | If `true` only valid numbers can be entered and on mobile device the number keypad is active | `boolean` | `false` | +| `numberInput` | `number-input` | If `true` on mobile device the number keypad is active | `boolean` | `false` | | `pattern` | `pattern` | A regular expression that the value is checked against. The pattern must match the entire value, not just some subset. Use the title attribute to describe the pattern to help the user. This attribute applies when the value of the type attribute is `"text"`, `"search"`, `"tel"`, `"url"`, `"email"`, `"date"`, or `"password"`, otherwise it is ignored. When the type attribute is `"date"`, `pattern` will only be used in browsers that do not support the `"date"` input type natively. See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date for more information. | `string \| undefined` | `undefined` | | `placeholder` | `placeholder` | Instructional text that shows before the input has a value. | `null \| string \| undefined` | `undefined` | | `readonly` | `readonly` | If `true`, the user cannot modify the value. | `boolean` | `false` |