Files
tabler/core/js/tests/unit/stepper.spec.ts

474 lines
16 KiB
TypeScript

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 =>
[
`<div class="stepper" data-bs-toggle="stepper" ${wrapperAttributes}>`,
' <button type="button" class="btn" data-bs-stepper-action="decrement">-</button>',
` <input type="text" class="form-control" ${inputAttributes} />`,
' <button type="button" class="btn" data-bs-stepper-action="increment">+</button>',
'</div>',
].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 = '<div class="stepper"></div>'
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 = `<form><input type="text" class="form-control" data-bs-toggle="stepper" ${attributes} /></form>`
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 = '<input type="text" class="form-control form-control-sm" data-bs-toggle="stepper" value="1" />'
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<HTMLButtonElement>('[data-tblr-stepper-action="increment"]')!.click()
expect(input().value).toBe('2')
})
})
})