running-tools

A collection of tools for runners and their coaches
git clone https://git.ashermorgan.net/running-tools/
Log | Files | Refs | README

commit ae7c32e4ad2bc7ffa6ce729f220cc9438e1c4e25
parent e3e104dfe68a7d79f7e4270755461505584ea960
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Mon,  9 Aug 2021 14:21:13 -0700

Implement DecimalInput component

Diffstat:
Asrc/components/DecimalInput.vue | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/DecimalInput.spec.js | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 455 insertions(+), 0 deletions(-)

diff --git a/src/components/DecimalInput.vue b/src/components/DecimalInput.vue @@ -0,0 +1,195 @@ +<template> + <input + ref="input" + @blur="onblur" + @keydown="onkeydown" + @keypress="onkeypress" + v-model="stringValue"> +</template> + +<script> +export default { + name: 'DecimalInput', + + props: { + /** + * The input value + */ + value: { + type: Number, + default: 0, + }, + + /** + * The minimum value + */ + min: { + type: Number, + default: null, + }, + + /** + * The maximum value + */ + max: { + type: Number, + default: null, + }, + + /** + * The number of digits to show after the decimal point + */ + digits: { + type: Number, + default: 1, + validator: function(value) { + return value > 0; + }, + }, + }, + + data: function() { + return { + /** + * The internal value + */ + internalValue: this.value.toFixed(this.digits), + }; + }, + + computed: { + /** + * The value of the input element + */ + stringValue: { + get: function() { + return this.internalValue; + }, + set: function(newValue) { + // Parse new value + let parsedValue = this.parse(newValue); + + // Allow input to be '' or '-' or '.' + if (newValue === '' || newValue === '-' || newValue === '.') { + this.internalValue = newValue; + } + + // Enforce minimum + else if (this.min !== null && parsedValue < this.min) { + this.internalValue = this.min.toFixed(this.digits); + } + + // Enforce maximum + else if (this.max !== null && parsedValue > this.max) { + this.internalValue = this.max.toFixed(this.digits); + } + + // Allow valid numbers + else if (!isNaN(parsedValue)) { + this.internalValue = newValue; + } + + // Make sure input element is updated + if (this.$refs.input.value === newValue) { + // Setter was called by the input element + if (this.internalValue !== newValue) { + // The value was corrected, so the input element must be updated + this.$refs.input.value = this.internalValue; + } + } + }, + }, + + /** + * The value of the component + */ + decValue: { + get: function() { + let parsedValue = parseFloat(this.stringValue); + return isNaN(parsedValue) ? this.defaultValue : parsedValue; + }, + set: function(newValue) { + this.stringValue = newValue.toFixed(this.digits); + } + }, + + /** + * The default value of the component + */ + defaultValue: function() { + if (0 < this.min || 0 > this.max) { + return this.min; + } + else { + return 0; + } + } + }, + + watch: { + /** + * Update the component value when the value prop changes + * @param {Number} newValue The new prop value + */ + value: function(newValue) { + if (newValue !== this.decValue) { + this.decValue = newValue; + } + }, + + /** + * Emit the input event when the component value changes + * @param {Number} newValue The new component value + */ + decValue: function(newValue) { + this.$emit('input', newValue); + }, + }, + + methods: { + /** + * Restrict input to valid keys + * @param {Object} e The keypress event args + */ + onkeypress: function(e) { + let valid = ['.', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + if (!valid.includes(e.key)) { + /* key was not valid */ + e.preventDefault(); + } + }, + + /** + * Process up and down arrow presses + * @param {Object} e The keydown event args + */ + onkeydown: function(e) { + if (e.key === 'ArrowUp') { + this.decValue++; + e.preventDefault(); + } + else if (e.key === 'ArrowDown') { + this.decValue--; + e.preventDefault(); + } + }, + + /** + * Reformat display value + * @param {Object} e The blur event args + */ + onblur: function(e) { + this.stringValue = this.decValue.toFixed(this.digits); + }, + + /** + * Parse a decimal from a string + * @param {String} value The string + * @returns {Number} The parsed decimal + */ + parse: function(value) { + return Number(value); + } + }, +} +</script> diff --git a/tests/unit/DecimalInput.spec.js b/tests/unit/DecimalInput.spec.js @@ -0,0 +1,260 @@ +import { expect } from 'chai'; +import { mount } from '@vue/test-utils'; +import DecimalInput from '@/components/DecimalInput.vue'; + +describe('DecimalInput.vue', () => { + it('value should be 0.0 by default', () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Assert value is 0.0 + expect(wrapper.find('input').element.value).to.equal('0.0'); + }); + + it('should read value prop', () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1 } + }); + + // Assert value is 1.0 + expect(wrapper.find('input').element.value).to.equal('1.0'); + }); + + it('up arrow should increment value', async () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Press up arrow + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 1.0 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1.0'); + expect(wrapper.emitted().input).to.deep.equal([[1.0]]); + }); + + it('down arrow should increment value', async () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Press down arrow + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is -1.0 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-1.0'); + expect(wrapper.emitted().input).to.deep.equal([[-1.0]]); + }); + + it('should fire input event when value changes', async () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Set value to 1 + wrapper.find('input').element.value = '1.0'; + await wrapper.find('input').trigger('input'); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([[1.0]]); + }); + + it('should accept numerical values', async () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Try to set value to 1 + wrapper.find('input').element.value = '1'; + await wrapper.find('input').trigger('input'); + + // Assert value was accepted and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.deep.equal([[1.0]]); + }); + + it('should accept decimal values', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1 } + }); + + // Try to set value to 1.5 + wrapper.find('input').element.value = '1.5'; + await wrapper.find('input').trigger('input'); + + // Assert value was accepted and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1.5'); + expect(wrapper.emitted().input).to.deep.equal([[1.5]]); + }); + + it('should not accept non numerical values', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1 } + }); + + // Try to set value to a + wrapper.find('input').element.value = 'a'; + await wrapper.find('input').trigger('input'); + + // Assert value was not accepted and no events were emitted + expect(wrapper.find('input').element.value).to.equal('1.0'); + expect(wrapper.emitted().input).to.be.undefined; + }); + + it('should format input value on blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1 } + }); + + // Set value to '01' + wrapper.find('input').element.value = '01'; + await wrapper.find('input').trigger('input'); + + // Assert value was not updated and no events were emitted + expect(wrapper.find('input').element.value).to.equal('01'); + expect(wrapper.emitted().input).to.be.undefined; + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value was formatted but no events were emitted + expect(wrapper.find('input').element.value).to.equal('1.0'); + expect(wrapper.emitted().input).to.be.undefined; + }); + + it('should allow input to be empty until blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 5 } + }); + + // Set value to '' + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + + // Assert value is '' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal(''); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0.0'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + }); + + it('should allow input to be "-" until blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 5 } + }); + + // Set value to '-' + wrapper.find('input').element.value = '-'; + await wrapper.find('input').trigger('input'); + + // Assert value is '-' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal('-'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0.0'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + }); + + it('should allow input to be "." until blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 5 } + }); + + // Set value to '.' + wrapper.find('input').element.value = '.'; + await wrapper.find('input').trigger('input'); + + // Assert value is '.' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal('.'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0.0'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + }); + + it('default value should be the minimum if 0.0 is not valid', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 3, max: 4, min: 2 } + }); + + // Set value to '' and trigger blur event so value must be updated + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + await wrapper.find('input').trigger('blur'); + + // Assert value is 2 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('2.0'); + expect(wrapper.emitted().input).to.deep.equal([[2.0]]); + }); + + it('should not allow input to be below the minimum', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { min: 10, value: 20 } + }); + + // Try to set value to 9, which is below the minimum + wrapper.find('input').element.value = '9.0'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + + // Try to decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is still 10 and no new event were emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + }); + + it('should not allow input to be above the maximum', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { max: 10 } + }); + + // Try to set value to 11, which is above the maximum + wrapper.find('input').element.value = '11.0'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + + // Try to increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 10 and no new events were emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + }); + + it('should format value according to digits prop', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { digits: 3 } + }); + + // Assert value is correctly formatted + expect(wrapper.find('input').element.value).to.equal('0.000'); + }); +});