diff --git a/.playground/pages/composant/inputNumber.vue b/.playground/pages/composant/inputNumber.vue new file mode 100644 index 0000000..7313ebf --- /dev/null +++ b/.playground/pages/composant/inputNumber.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/components/malio/InputNumber.test.ts b/app/components/malio/InputNumber.test.ts new file mode 100644 index 0000000..009e065 --- /dev/null +++ b/app/components/malio/InputNumber.test.ts @@ -0,0 +1,165 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import InputNumber from './InputNumber.vue' + +type InputNumberProps = { + modelValue?: string | null + label?: string + readonly?: boolean + min?: number | string + max?: number | string +} + +const InputNumberForTest = InputNumber as DefineComponent + +const mountInputNumber = (props: InputNumberProps = {}) => + mount(InputNumberForTest, { + props, + global: { + stubs: { + IconifyIcon: { + template: '', + }, + }, + }, + }) + +describe('MalioInputNumber', () => { + it('renders the input with a fixed 20px height', () => { + const wrapper = mountInputNumber() + const input = wrapper.get('input') + + expect(input.classes()).toContain('h-[20px]') + }) + + it('renders the increment and decrement buttons with a fixed 20px height', () => { + const wrapper = mountInputNumber() + const buttons = wrapper.findAll('button') + + expect(buttons).toHaveLength(2) + }) + + it('still emits update:modelValue on input', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('99') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['99']) + }) + + it('filters letters from the input value', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('a1b2c3') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['123']) + expect(input.element.value).toBe('123') + }) + + it('formats large numbers with spaces in the input display', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('1000000') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['1000000']) + expect(input.element.value).toBe('1 000 000') + }) + + it('accepts decimal values with commas', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('12,5') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5']) + expect(input.element.value).toBe('12.5') + }) + + it('keeps a trailing decimal separator while typing', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue('12,') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.']) + expect(input.element.value).toBe('12.') + }) + + it('accepts a decimal starting with a comma', async () => { + const wrapper = mountInputNumber({modelValue: ''}) + const input = wrapper.get('input') + + await input.setValue(',5') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5']) + expect(input.element.value).toBe('0.5') + }) + + it('increments the current value when clicking plus', async () => { + const wrapper = mountInputNumber({modelValue: '2'}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['3']) + }) + + it('increments decimal values with a step of 1', async () => { + const wrapper = mountInputNumber({modelValue: '1.5'}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['2.5']) + }) + + it('decrements the current value when clicking minus', async () => { + const wrapper = mountInputNumber({modelValue: '2'}) + + await wrapper.findAll('button')[0].trigger('click') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['1']) + }) + + it('does not change the value from buttons when readonly', async () => { + const wrapper = mountInputNumber({modelValue: '2', readonly: true}) + + await wrapper.findAll('button')[1].trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('disables minus and prevents decrement at min', async () => { + const wrapper = mountInputNumber({modelValue: '2', min: 2}) + const minusButton = wrapper.findAll('button')[0] + + expect(minusButton.attributes('disabled')).toBeDefined() + + await minusButton.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('disables plus and prevents increment at max', async () => { + const wrapper = mountInputNumber({modelValue: '2', max: 2}) + const plusButton = wrapper.findAll('button')[1] + + expect(plusButton.attributes('disabled')).toBeDefined() + + await plusButton.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('clamps manual input to max', async () => { + const wrapper = mountInputNumber({modelValue: '', max: 5}) + const input = wrapper.get('input') + + await input.setValue('12') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5']) + expect(input.element.value).toBe('5') + }) +}) diff --git a/app/components/malio/InputNumber.vue b/app/components/malio/InputNumber.vue new file mode 100644 index 0000000..c995da4 --- /dev/null +++ b/app/components/malio/InputNumber.vue @@ -0,0 +1,300 @@ + + + diff --git a/app/story/inputNumber.story.vue b/app/story/inputNumber.story.vue new file mode 100644 index 0000000..8b5dbaf --- /dev/null +++ b/app/story/inputNumber.story.vue @@ -0,0 +1,83 @@ + + +