import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' import { clearFixture, getFixture } from '../helpers/fixture' import Stepper from '../../src/stepper' const markup = (inputAttributes = 'value="1" min="0" max="5"', wrapperAttributes = ''): string => [ `
`, ' ', ` `, ' ', '
', ].join('') describe('Stepper', () => { let fixtureEl: HTMLElement beforeAll(() => { fixtureEl = getFixture() }) beforeEach(() => { fixtureEl.innerHTML = markup() }) afterEach(() => { clearFixture() vi.useRealTimers() }) const stepper = (): HTMLElement => fixtureEl.querySelector('.stepper')! const input = (): HTMLInputElement => fixtureEl.querySelector('input')! const decrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-stepper-action="decrement"]')! const incrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-stepper-action="increment"]')! const change = (value: string): void => { input().value = value input().dispatchEvent(new Event('change', { bubbles: true })) } const keydown = (key: string): KeyboardEvent => { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) input().dispatchEvent(event) return event } describe('NAME', () => { it('should return the plugin name', () => { expect(Stepper.NAME).toBe('stepper') }) }) describe('Default', () => { it('should expose the default config', () => { expect(Stepper.Default.min).toBeNull() expect(Stepper.Default.repeat).toBe(true) expect(typeof Stepper.DefaultType.step).toBe('string') }) }) describe('constructor', () => { it('should read the bounds from the input attributes', () => { const instance = new Stepper(stepper()) expect(instance._min).toBe(0) expect(instance._max).toBe(5) expect(instance._step).toBe(1) }) it('should let the config override the input attributes', () => { const instance = new Stepper(stepper(), { min: 2, max: 10, step: 2 }) expect(instance._min).toBe(2) expect(instance._max).toBe(10) expect(instance._step).toBe(2) expect(input().value).toBe('2') }) it('should read the config from data attributes', () => { fixtureEl.innerHTML = markup('value="1"', 'data-bs-min="1" data-bs-max="3" data-bs-step="0.5"') const instance = new Stepper(stepper()) expect(instance._min).toBe(1) expect(instance._max).toBe(3) expect(instance._step).toBe(0.5) }) it('should have no bounds when neither attributes nor config set them', () => { fixtureEl.innerHTML = markup('value="1"') const instance = new Stepper(stepper()) expect(instance._min).toBe(Number.NEGATIVE_INFINITY) expect(instance._max).toBe(Number.POSITIVE_INFINITY) }) it('should fall back to a step of 1 for a zero or negative step', () => { fixtureEl.innerHTML = markup('value="1" step="0"') expect(new Stepper(stepper())._step).toBe(1) }) it('should clamp the initial value', () => { fixtureEl.innerHTML = markup('value="9" min="0" max="5"') new Stepper(stepper()) expect(input().value).toBe('5') }) it('should give a text input the spinbutton role and a numeric inputmode', () => { new Stepper(stepper()) expect(input().getAttribute('role')).toBe('spinbutton') expect(input().getAttribute('inputmode')).toBe('numeric') expect(input().getAttribute('aria-valuemin')).toBe('0') expect(input().getAttribute('aria-valuemax')).toBe('5') expect(input().getAttribute('aria-valuenow')).toBe('1') }) it('should use the decimal inputmode for a fractional step', () => { fixtureEl.innerHTML = markup('value="1" step="0.25"') new Stepper(stepper()) expect(input().getAttribute('inputmode')).toBe('decimal') }) it('should leave a number input alone', () => { fixtureEl.innerHTML = markup('type="number" value="1" min="0" max="5"').replace('type="text"', '') new Stepper(stepper()) expect(input().hasAttribute('role')).toBe(false) expect(input().hasAttribute('aria-valuenow')).toBe(false) }) it('should do nothing without an input', () => { fixtureEl.innerHTML = '
' const instance = new Stepper(stepper()) expect(instance._input).toBeNull() expect(instance.getValue()).toBeNull() }) it('should disable the buttons of a disabled input', () => { fixtureEl.innerHTML = markup('value="1" disabled') new Stepper(stepper()) expect(decrementButton().disabled).toBe(true) expect(incrementButton().disabled).toBe(true) }) }) describe('buttons', () => { it('should step the value on click', () => { new Stepper(stepper()) incrementButton().click() expect(input().value).toBe('2') decrementButton().click() decrementButton().click() expect(input().value).toBe('0') }) it('should disable the button at a bound', () => { fixtureEl.innerHTML = markup('value="4" min="0" max="5"') new Stepper(stepper()) expect(incrementButton().disabled).toBe(false) incrementButton().click() expect(input().value).toBe('5') expect(incrementButton().disabled).toBe(true) expect(decrementButton().disabled).toBe(false) }) it('should never leave the bounds', () => { const instance = new Stepper(stepper(), { max: 2 }) instance.increment() instance.increment() instance.increment() expect(input().value).toBe('2') }) it('should keep stepping while a button is held', () => { vi.useFakeTimers() new Stepper(stepper(), { max: 100 }) incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) vi.advanceTimersByTime(400) expect(input().value).toBe('2') vi.advanceTimersByTime(80 * 3) expect(input().value).toBe('5') incrementButton().dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) vi.advanceTimersByTime(1000) expect(input().value).toBe('5') // The click that ends the hold is not one more step incrementButton().click() expect(input().value).toBe('5') }) it('should stop a hold at a bound', () => { vi.useFakeTimers() const instance = new Stepper(stepper(), { max: 3 }) incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) vi.advanceTimersByTime(400 + 80 * 5) expect(input().value).toBe('3') expect(instance._repeatTimer).toBeNull() }) it('should not repeat when the option is off', () => { vi.useFakeTimers() new Stepper(stepper(), { repeat: false, max: 100 }) incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) vi.advanceTimersByTime(2000) expect(input().value).toBe('1') }) }) describe('keyboard', () => { it('should step with the arrow keys and jump with Home and End', () => { new Stepper(stepper()) expect(keydown('ArrowUp').defaultPrevented).toBe(true) expect(input().value).toBe('2') keydown('ArrowDown') expect(input().value).toBe('1') keydown('End') expect(input().value).toBe('5') keydown('Home') expect(input().value).toBe('0') }) it('should ignore Home and End without a bound', () => { fixtureEl.innerHTML = markup('value="1"') new Stepper(stepper()) expect(keydown('End').defaultPrevented).toBe(false) expect(input().value).toBe('1') }) it('should leave other keys alone', () => { new Stepper(stepper()) expect(keydown('a').defaultPrevented).toBe(false) }) }) describe('typed value', () => { it('should clamp a typed value on change', () => { new Stepper(stepper()) change('42') expect(input().value).toBe('5') expect(incrementButton().disabled).toBe(true) }) it('should clamp on blur as well', () => { new Stepper(stepper()) input().value = '125' input().dispatchEvent(new Event('blur')) expect(input().value).toBe('5') }) it('should fire nothing when an untouched field is left', () => { new Stepper(stepper()) const spy = vi.fn() input().addEventListener('change', spy) input().dispatchEvent(new Event('blur')) expect(spy).not.toHaveBeenCalled() }) it('should restore the previous value after a non-numeric edit', () => { new Stepper(stepper()) change('3') change('abc') expect(input().value).toBe('3') }) it('should accept a decimal comma', () => { fixtureEl.innerHTML = markup('value="1" step="0.5"') new Stepper(stepper()) change('2,5') expect(input().value).toBe('2.5') }) }) describe('character filter', () => { const beforeInput = (data: string, inputType = 'insertText'): InputEvent => { const event = new InputEvent('beforeinput', { data, inputType, bubbles: true, cancelable: true }) input().dispatchEvent(event) return event } it('should drop a typed letter and keep a digit', () => { new Stepper(stepper()) expect(beforeInput('a').defaultPrevented).toBe(true) expect(beforeInput('7').defaultPrevented).toBe(false) expect(beforeInput('.').defaultPrevented).toBe(true) expect(beforeInput('-').defaultPrevented).toBe(true) }) it('should allow the sign and the separator when the bounds and step need them', () => { fixtureEl.innerHTML = markup('value="0" min="-5" step="0.5"') new Stepper(stepper()) expect(beforeInput('-').defaultPrevented).toBe(false) expect(beforeInput('.').defaultPrevented).toBe(false) expect(beforeInput(',').defaultPrevented).toBe(false) expect(beforeInput('e').defaultPrevented).toBe(true) }) it('should keep only the digits of a pasted text', () => { new Stepper(stepper()) input().value = '' input().setSelectionRange(0, 0) expect(beforeInput('4 pcs', 'insertFromPaste').defaultPrevented).toBe(true) expect(input().value).toBe('4') }) it('should leave deletions alone', () => { new Stepper(stepper()) expect(beforeInput('', 'deleteContentBackward').defaultPrevented).toBe(false) }) }) describe('precision', () => { it('should round to the precision of the step', () => { fixtureEl.innerHTML = markup('value="0.1" step="0.1"') const instance = new Stepper(stepper()) instance.increment() instance.increment() expect(input().value).toBe('0.3') expect(instance.getValue()).toBe(0.3) }) it('should step an empty field from the lower bound', () => { fixtureEl.innerHTML = markup('value="" min="2" max="9"') const instance = new Stepper(stepper()) expect(instance.getValue()).toBeNull() expect(input().hasAttribute('aria-valuenow')).toBe(false) instance.increment() expect(input().value).toBe('3') }) }) describe('events', () => { it('should fire change.bs.stepper with the values', () => { const instance = new Stepper(stepper()) const spy = vi.fn() stepper().addEventListener('change.bs.stepper', spy) instance.increment() expect(spy).toHaveBeenCalledTimes(1) const event = spy.mock.calls[0][0] as Event & { value: number; previousValue: number } expect(event.value).toBe(2) expect(event.previousValue).toBe(1) }) it('should fire input and change on the field', () => { const instance = new Stepper(stepper()) const onInput = vi.fn() const onChange = vi.fn() input().addEventListener('input', onInput) input().addEventListener('change', onChange) instance.increment() expect(onInput).toHaveBeenCalledTimes(1) expect(onChange).toHaveBeenCalledTimes(1) }) it('should not fire when the value does not change', () => { const instance = new Stepper(stepper()) const spy = vi.fn() stepper().addEventListener('change.bs.stepper', spy) instance.setValue(1) instance.decrement() instance.decrement() expect(spy).toHaveBeenCalledTimes(1) }) }) describe('setValue', () => { it('should set and clamp the value', () => { const instance = new Stepper(stepper()) instance.setValue(3) expect(input().value).toBe('3') instance.setValue('99') expect(input().value).toBe('5') instance.setValue('nope') expect(input().value).toBe('5') }) }) describe('disabled', () => { it('should not step a disabled or readonly input', () => { fixtureEl.innerHTML = markup('value="1" readonly') const instance = new Stepper(stepper()) instance.increment() expect(input().value).toBe('1') expect(incrementButton().disabled).toBe(true) }) }) describe('dispose', () => { it('should remove the listeners and the instance', () => { const instance = new Stepper(stepper()) instance.dispose() incrementButton().click() expect(input().value).toBe('1') expect(Stepper.getInstance(stepper())).toBeNull() }) }) describe('bare input', () => { const bare = (attributes = 'value="1" min="0" max="5"'): HTMLInputElement => { fixtureEl.innerHTML = `
` return fixtureEl.querySelector('input')! } it('should wrap the input and render the two buttons', () => { const el = bare() new Stepper(el) const wrapper = el.parentElement! expect(wrapper.classList.contains('stepper')).toBe(true) expect(wrapper.parentElement!.tagName).toBe('FORM') expect([...wrapper.children].map((child) => child.tagName)).toEqual(['BUTTON', 'INPUT', 'BUTTON']) expect(decrementButton().getAttribute('aria-label')).toBe('Decrease') expect(incrementButton().getAttribute('aria-label')).toBe('Increase') expect(incrementButton().type).toBe('button') expect(incrementButton().innerHTML).toBe('') }) it('should step through the rendered buttons', () => { const el = bare() new Stepper(el) incrementButton().click() expect(el.value).toBe('2') }) it('should read the config from the input and label the buttons', () => { const el = bare('value="1" data-bs-max="2" data-bs-increment-label="More" data-bs-decrement-label="Fewer"') new Stepper(el) expect(incrementButton().getAttribute('aria-label')).toBe('More') expect(decrementButton().getAttribute('aria-label')).toBe('Fewer') incrementButton().click() incrementButton().click() expect(el.value).toBe('2') }) it('should map the size of the input to the box', () => { fixtureEl.innerHTML = '' const el = fixtureEl.querySelector('input')! new Stepper(el) expect(el.parentElement!.classList.contains('stepper-sm')).toBe(true) }) it('should fire change.bs.stepper on the input', () => { const el = bare() const instance = new Stepper(el) const spy = vi.fn() el.addEventListener('change.bs.stepper', spy) instance.increment() expect(spy).toHaveBeenCalledTimes(1) }) it('should unwrap the input on dispose', () => { const el = bare() new Stepper(el).dispose() expect(el.parentElement!.tagName).toBe('FORM') expect(fixtureEl.querySelector('.stepper')).toBeNull() expect(fixtureEl.querySelectorAll('button')).toHaveLength(0) }) }) describe('data-api', () => { it('should initialise on getOrCreateInstance', () => { const instance = Stepper.getOrCreateInstance(stepper()) expect(instance).toBeInstanceOf(Stepper) expect(Stepper.getOrCreateInstance(stepper())).toBe(instance) }) it('should accept the data-tblr-* action attributes', () => { fixtureEl.innerHTML = markup().replaceAll('data-bs-stepper-action', 'data-tblr-stepper-action') new Stepper(stepper()) fixtureEl.querySelector('[data-tblr-stepper-action="increment"]')!.click() expect(input().value).toBe('2') }) }) })