diff --git a/.changeset/stepper.md b/.changeset/stepper.md new file mode 100644 index 000000000..3649ce255 --- /dev/null +++ b/.changeset/stepper.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added the `Stepper` component, a `.stepper` quantity input with buttons, bounds, hold-to-repeat and keyboard support. diff --git a/core/js/src/stepper.ts b/core/js/src/stepper.ts new file mode 100644 index 000000000..4b51793ed --- /dev/null +++ b/core/js/src/stepper.ts @@ -0,0 +1,500 @@ +/** + * -------------------------------------------------------------------------- + * Tabler stepper.ts + * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './bootstrap/base-component' +import EventHandler from './bootstrap/dom/event-handler' +import SelectorEngine from './bootstrap/dom/selector-engine' +import { initAll } from './bootstrap/util/component-functions' +import { isDisabled } from './bootstrap/util/index' +import type { ElementSelector } from './bootstrap/types' + +type StepperAction = 'increment' | 'decrement' + +type ComponentConfig = { + /** lower bound; `null` reads the input's `min` attribute, no bound when that is missing too */ + min: number | null + /** upper bound; `null` reads the input's `max` attribute, no bound when that is missing too */ + max: number | null + /** amount added or removed per step; `null` reads the input's `step` attribute, or 1 */ + step: number | null + /** keep stepping while a button is held down */ + repeat: boolean + /** milliseconds a button has to be held before it starts repeating */ + repeatDelay: number + /** milliseconds between two repeated steps */ + repeatInterval: number +} + +type ComponentConfigInput = Partial & Record + +/** + * Constants + */ + +const NAME = 'stepper' +const DATA_KEY = `bs.${NAME}` +const EVENT_KEY = `.${DATA_KEY}` + +const EVENT_CHANGE = `change${EVENT_KEY}` + +const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` +const SELECTOR_INPUT = 'input' +const SELECTOR_ACTION = `[data-bs-${NAME}-action], [data-tblr-${NAME}-action]` + +const ATTRIBUTES_ACTION = [`data-bs-${NAME}-action`, `data-tblr-${NAME}-action`] + +const KEY_UP = 'ArrowUp' +const KEY_DOWN = 'ArrowDown' +const KEY_HOME = 'Home' +const KEY_END = 'End' + +// A held button stops repeating on any of these, so a drag off the button or +// a cancelled touch never leaves the timer running. +const POINTER_END_EVENTS = ['pointerup', 'pointercancel', 'pointerleave'] + +const Default: ComponentConfig = { + min: null, + max: null, + step: null, + repeat: true, + repeatDelay: 400, + repeatInterval: 80, +} + +const DefaultType: Record = { + min: '(number|null)', + max: '(number|null)', + step: '(number|null)', + repeat: 'boolean', + repeatDelay: 'number', + repeatInterval: 'number', +} + +/** + * Class definition + * + * A number field with a decrement and an increment button. The `` stays + * the single source of truth: the buttons, the arrow keys and the public API + * all write to it, clamp to `min` / `max` and round to the precision of + * `step`, so a form sees nothing but an ordinary field. + */ + +class Stepper extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig + _input: HTMLInputElement | null = null + _buttons: HTMLElement[] = [] + _min = Number.NEGATIVE_INFINITY + _max = Number.POSITIVE_INFINITY + _step = 1 + _precision = 0 + // Characters the field accepts: digits, plus a minus sign and a decimal + // separator only when the bounds and the step call for them. + _disallowed = /[^0-9]/g + _holdTimer: ReturnType | null = null + _repeatTimer: ReturnType | null = null + // Set once a held button has stepped on its own, so the `click` that ends + // the hold does not add one more step. + _held = false + // The last value the field held, restored after an edit that is not a number + _lastValue = '' + + _onClick = (event: Event): void => this._handleClick(event) + _onPointerDown = (event: Event): void => this._handlePointerDown(event) + _onPointerEnd = (): void => this._stopRepeat() + _onKeyDown = (event: Event): void => this._handleKeyDown(event as KeyboardEvent) + _onBeforeInput = (event: Event): void => this._handleBeforeInput(event as InputEvent) + _onInputChange = (): void => this._handleInputChange() + + constructor(element: ElementSelector, config?: ComponentConfigInput) { + super(element, config) + + if (!this._element) { + return + } + + const input = SelectorEngine.findOne(SELECTOR_INPUT, this._element) as HTMLInputElement | null + if (!input) { + return + } + + this._input = input + this._buttons = SelectorEngine.find(SELECTOR_ACTION, this._element) + + this._resolveBounds() + this._setUpInput() + this._addEventListeners() + this._update() + } + + // Getters + static get Default(): ComponentConfig { + return Default + } + + static get DefaultType(): Record { + return DefaultType + } + + static get NAME(): string { + return NAME + } + + // Public + getValue(): number | null { + return this._parse(this._input?.value ?? '') + } + + setValue(value: number | string): void { + const parsed = this._parse(String(value)) + if (parsed === null) { + return + } + + this._commit(parsed) + } + + increment(): void { + this._stepBy(1) + } + + decrement(): void { + this._stepBy(-1) + } + + dispose(): void { + this._stopRepeat() + + if (this._input) { + this._input.removeEventListener('keydown', this._onKeyDown) + this._input.removeEventListener('beforeinput', this._onBeforeInput) + this._input.removeEventListener('change', this._onInputChange) + this._input.removeEventListener('blur', this._onInputChange) + } + + for (const button of this._buttons) { + button.removeEventListener('click', this._onClick) + button.removeEventListener('pointerdown', this._onPointerDown) + for (const type of POINTER_END_EVENTS) { + button.removeEventListener(type, this._onPointerEnd) + } + } + + super.dispose() + } + + // Private + // Config wins over the input's own attributes; both are optional. + _resolveBounds(): void { + const input = this._input! + const attribute = (name: string): number | null => { + const value = Number.parseFloat(input.getAttribute(name) ?? '') + return Number.isFinite(value) ? value : null + } + + this._min = this._config.min ?? attribute('min') ?? Number.NEGATIVE_INFINITY + this._max = this._config.max ?? attribute('max') ?? Number.POSITIVE_INFINITY + + const step = this._config.step ?? attribute('step') + this._step = step !== null && step > 0 ? step : 1 + + // `0.25` has two decimals: every result is rounded to the same precision, + // so repeated steps never drift into `0.30000000000000004`. + const decimals = String(this._step).split('.')[1] + this._precision = decimals ? decimals.length : 0 + + const allowed = `0-9${this._min < 0 ? '-' : ''}${this._precision > 0 ? '.,' : ''}` + this._disallowed = new RegExp(`[^${allowed}]`, 'g') + } + + _setUpInput(): void { + const input = this._input! + + // A text field keeps the native spin buttons away on every platform; + // the role tells assistive technology it still behaves like a number. + if (input.type !== 'number') { + input.setAttribute('role', 'spinbutton') + if (!input.hasAttribute('inputmode')) { + input.setAttribute('inputmode', this._precision > 0 ? 'decimal' : 'numeric') + } + } + + const current = this._parse(input.value) + if (current !== null) { + input.value = this._format(this._clamp(current)) + } + + this._lastValue = input.value + } + + _addEventListeners(): void { + const input = this._input! + + input.addEventListener('keydown', this._onKeyDown) + input.addEventListener('beforeinput', this._onBeforeInput) + // `blur` as well as `change`: a value set by a script before the field is + // left fires no `change`, and the clamp must still happen. + input.addEventListener('change', this._onInputChange) + input.addEventListener('blur', this._onInputChange) + + for (const button of this._buttons) { + button.addEventListener('click', this._onClick) + button.addEventListener('pointerdown', this._onPointerDown) + for (const type of POINTER_END_EVENTS) { + button.addEventListener(type, this._onPointerEnd) + } + } + } + + _actionOf(button: HTMLElement): StepperAction | null { + for (const name of ATTRIBUTES_ACTION) { + const action = button.getAttribute(name) + if (action === 'increment' || action === 'decrement') { + return action + } + } + + return null + } + + _handleClick(event: Event): void { + const button = event.currentTarget as HTMLElement + event.preventDefault() + + if (this._held) { + this._held = false + return + } + + this._runAction(this._actionOf(button)) + } + + _handlePointerDown(event: Event): void { + const button = event.currentTarget as HTMLElement + const action = this._actionOf(button) + + this._stopRepeat() + + if (!this._config.repeat || !action || isDisabled(button) || this._isLocked()) { + return + } + + // Keep the focus on the field being edited; the step itself happens on + // `click`, which also covers keyboard activation. + if ((event as PointerEvent).pointerType === 'mouse') { + event.preventDefault() + } + + this._holdTimer = setTimeout(() => { + this._holdTimer = null + this._held = true + this._runAction(action) + this._repeatTimer = setInterval(() => this._runAction(action), this._config.repeatInterval) + }, this._config.repeatDelay) + } + + _stopRepeat(): void { + if (this._holdTimer !== null) { + clearTimeout(this._holdTimer) + this._holdTimer = null + } + + if (this._repeatTimer !== null) { + clearInterval(this._repeatTimer) + this._repeatTimer = null + } + } + + _runAction(action: StepperAction | null): void { + if (action === 'increment') { + this.increment() + } else if (action === 'decrement') { + this.decrement() + } + } + + _handleKeyDown(event: KeyboardEvent): void { + const steps: Record void> = { + [KEY_UP]: () => this.increment(), + [KEY_DOWN]: () => this.decrement(), + [KEY_HOME]: () => this._commit(this._min), + [KEY_END]: () => this._commit(this._max), + } + + const handler = steps[event.key] + if (!handler) { + return + } + + if ((event.key === KEY_HOME && !Number.isFinite(this._min)) || (event.key === KEY_END && !Number.isFinite(this._max))) { + return + } + + event.preventDefault() + handler() + } + + // Letters never get into the field: a typed one is dropped, a pasted text + // keeps only its digits (and the sign or separator when those are allowed). + _handleBeforeInput(event: InputEvent): void { + const { data, inputType } = event + if (!data || !inputType.startsWith('insert')) { + return + } + + const clean = data.replace(this._disallowed, '') + if (clean === data) { + return + } + + event.preventDefault() + + if (clean === '') { + return + } + + const input = this._input! + const start = input.selectionStart ?? input.value.length + const end = input.selectionEnd ?? start + input.setRangeText(clean, start, end, 'end') + input.dispatchEvent(new Event('input', { bubbles: true })) + } + + // A typed value is clamped once the field is left; a value that is not a + // number goes back to the previous one. An untouched field is left alone, + // so leaving it fires no events. + _handleInputChange(): void { + const input = this._input! + if (input.value === this._lastValue) { + return + } + + const parsed = this._parse(input.value) + + if (parsed === null) { + input.value = this._lastValue + this._update() + return + } + + this._commit(parsed) + } + + _stepBy(direction: 1 | -1): void { + if (this._isLocked()) { + return + } + + const current = this.getValue() ?? this._fallbackValue() + this._commit(current + direction * this._step) + } + + // The value an empty field steps from: the nearest bound, or zero. + _fallbackValue(): number { + if (Number.isFinite(this._min) && this._min > 0) { + return this._min + } + + if (Number.isFinite(this._max) && this._max < 0) { + return this._max + } + + return 0 + } + + _commit(value: number): void { + const input = this._input! + const previousValue = this.getValue() + const next = this._clamp(value) + const formatted = this._format(next) + + const changed = input.value !== formatted + input.value = formatted + this._lastValue = formatted + this._update() + + if (!changed) { + return + } + + // The field itself fires `input` and `change`, so a form, a framework + // binding or validation sees a stepped value like a typed one. + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + + EventHandler.trigger(this._element, EVENT_CHANGE, { value: next, previousValue }) + + if (next === this._min || next === this._max) { + this._stopRepeat() + } + } + + _isLocked(): boolean { + const input = this._input! + return input.disabled || input.readOnly + } + + _clamp(value: number): number { + return Math.min(this._max, Math.max(this._min, value)) + } + + _parse(value: string): number | null { + const trimmed = value.trim().replace(',', '.') + if (trimmed === '') { + return null + } + + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null + } + + _format(value: number): string { + return value.toFixed(this._precision) + } + + // Buttons go inert at a bound or while the field is locked, and the + // spinbutton role gets its `aria-value*` attributes refreshed. + _update(): void { + const input = this._input! + const value = this.getValue() + const locked = this._isLocked() + + for (const button of this._buttons) { + const action = this._actionOf(button) + const atBound = value !== null && ((action === 'increment' && value >= this._max) || (action === 'decrement' && value <= this._min)) + ;(button as HTMLButtonElement).disabled = locked || atBound + } + + if (input.getAttribute('role') !== 'spinbutton') { + return + } + + if (Number.isFinite(this._min)) { + input.setAttribute('aria-valuemin', this._format(this._min)) + } + + if (Number.isFinite(this._max)) { + input.setAttribute('aria-valuemax', this._format(this._max)) + } + + if (value === null) { + input.removeAttribute('aria-valuenow') + } else { + input.setAttribute('aria-valuenow', this._format(value)) + } + } +} + +/** + * Data API implementation + */ + +// js-docs-start stepper-init +initAll(SELECTOR_DATA_TOGGLE, Stepper) +// js-docs-end stepper-init + +export default Stepper diff --git a/core/js/tabler.ts b/core/js/tabler.ts index ef8eadf58..b9ae8013c 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -15,6 +15,7 @@ import './src/sortable' import './src/otp-input' import './src/sparkline' import './src/strength' +import './src/stepper' // Re-export everything from bootstrap.ts (single source of truth) export * from './src/bootstrap' @@ -29,6 +30,7 @@ export { default as InputMask } from './src/input-mask' export { default as OtpInput } from './src/otp-input' export { default as Sortable } from './src/sortable' export { default as Sparkline } from './src/sparkline' +export { default as Stepper } from './src/stepper' export { default as Strength } from './src/strength' export { default as SwitchIcon } from './src/switch-icon' diff --git a/core/js/tests/unit/stepper.spec.ts b/core/js/tests/unit/stepper.spec.ts new file mode 100644 index 000000000..864831422 --- /dev/null +++ b/core/js/tests/unit/stepper.spec.ts @@ -0,0 +1,411 @@ +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('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') + }) + }) +}) diff --git a/core/scss/_core.scss b/core/scss/_core.scss index 03e6aeb15..2c69bf584 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -69,6 +69,7 @@ @forward 'ui/forms/form-custom'; @forward 'ui/forms/form-check'; @forward 'ui/forms/form-otp'; +@forward 'ui/forms/form-stepper'; @forward 'ui/forms/validation'; @forward 'ui/forms/strength'; @forward 'ui/legend'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index ba57494ed..89beeaf60 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1207,6 +1207,14 @@ $otp-slot-box-shadow: var(--shadow-input) !default; $otp-separator-color: var(--secondary) !default; // scss-docs-end otp-variables +// Stepper +// scss-docs-start stepper-variables +$stepper-input-width: 3.5rem !default; +$stepper-bg: $input-bg !default; +$stepper-border-color: var(--border-color) !default; +$stepper-border-radius: $input-border-radius !default; +// scss-docs-end stepper-variables + // Cards // scss-docs-start card-variables $card-spacer-y: 1.25rem !default; diff --git a/core/scss/ui/forms/_form-stepper.scss b/core/scss/ui/forms/_form-stepper.scss new file mode 100644 index 000000000..4442daad4 --- /dev/null +++ b/core/scss/ui/forms/_form-stepper.scss @@ -0,0 +1,113 @@ +/* +Stepper (number input with buttons) + */ +@use 'sass:map'; +@use '../../config' as *; + +// One bordered box, like a `.form-control`: the field in the middle and the +// two buttons inside it have no border of their own. +.stepper { + // scss-docs-start stepper-css-vars + --stepper-input-width: #{$stepper-input-width}; + --stepper-bg: #{$stepper-bg}; + --stepper-border-color: #{$stepper-border-color}; + --stepper-border-radius: #{$stepper-border-radius}; + // scss-docs-end stepper-css-vars + position: relative; + display: inline-flex; + align-items: stretch; + vertical-align: middle; + background-color: var(--stepper-bg); + border: $input-border-width solid var(--stepper-border-color); + box-shadow: $input-box-shadow; + @include border-radius(var(--stepper-border-radius)); + @include focus-ring-transition(border-color); + + // The box carries the focus ring for the field inside it. + &:focus-within { + --focus-ring-offset: -1px; + border-color: $input-focus-border-color; + @include focus-ring(true); + } + + &:has(> .form-control:disabled) { + background-color: $input-disabled-bg; + } + + > .form-control { + // stylelint-disable-next-line length-zero-no-unit + --control-border-width: 0px; + flex: 0 0 auto; + width: var(--stepper-input-width); + min-width: 0; + padding-inline: 0.25rem; + text-align: center; + appearance: textfield; + background-color: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + + // A `type="number"` field keeps its keyboard behaviour but loses the + // native spin buttons, which the stepper's own buttons replace. + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + margin: 0; + appearance: none; + } + + &:focus { + background-color: transparent; + outline: 0; + box-shadow: none; + } + + // The field is too narrow for the validation icon, which would sit on + // top of the number; the box border alone carries the state. + // `!important` because forms/_validation.scss forces the icon padding + // with one. + @each $state, $data in $form-validation-states { + &.is-#{$state}, + .was-validated &:#{$state} { + padding-inline-end: 0.25rem !important; + background-image: none; + } + } + } + + > .btn { + // With a unit, or `calc()` in the button's `min-width` has nothing to add + // stylelint-disable-next-line length-zero-no-unit + --btn-border-width: 0px; + --btn-bg: transparent; + --btn-border-color: transparent; + --btn-hover-border-color: transparent; + --btn-active-border-color: transparent; + --btn-hover-bg: var(--hover-bg); + --btn-box-shadow: none; + --focus-ring-offset: -2px; + // A held button must not select text or open the context menu on touch. + touch-action: manipulation; + user-select: none; + border-radius: 0; + + &:first-child { + @include border-start-radius(calc(var(--stepper-border-radius) - #{$input-border-width})); + } + + &:last-child { + @include border-end-radius(calc(var(--stepper-border-radius) - #{$input-border-width})); + } + } + + @each $state, $data in $form-validation-states { + &:has(> .form-control.is-#{$state}), + .was-validated &:has(> .form-control:#{$state}) { + border-color: map.get($data, 'border-color'); + + &:focus-within { + outline-color: map.get($data, 'focus-ring-color'); + } + } + } +} diff --git a/docs/content/ui/forms/stepper.mdx b/docs/content/ui/forms/stepper.mdx new file mode 100644 index 000000000..8d81db3aa --- /dev/null +++ b/docs/content/ui/forms/stepper.mdx @@ -0,0 +1,180 @@ +--- +title: Stepper +seoTitle: Stepper (quantity input) +order: 14 +summary: A number field with a decrease and an increase button, for a quantity in a cart, a number of seats or any value changed in fixed steps. +description: Add a stepper input with min, max and step, hold-to-repeat buttons, arrow key support and change events. +related: [/ui/forms/elements, /ui/forms/validation] +source: core/scss/ui/forms/_form-stepper.scss +css-vars: stepper +classnames: + component: + - class: stepper + desc: Wrapper; holds the two buttons and the input +--- + +import Example from '@components/Example.astro' +import CodeDocs from '@components/CodeDocs.astro' + +## Overview + +A stepper is an ordinary `` between two buttons. The input stays the only field a form sees: the buttons, the arrow keys and the JavaScript API all write to it, keep the value between `min` and `max` and round it to the precision of `step`. + +Add `data-bs-toggle="stepper"` to a `.stepper` wrapper, mark the buttons with `data-bs-stepper-action="decrement"` and `"increment"`, and Tabler wires them up. The bounds come from the input's own `min`, `max` and `step` attributes. + + +
+ + + +
+
+ +## Usage + +### Bounds and step + +The button at a bound goes disabled, so the user sees when the value cannot go further. Letters never get into the field: a typed one is dropped and a pasted text keeps only its digits, with a minus sign allowed only when `min` is negative and a decimal separator only for a fractional `step`. A typed value is clamped once the field is left. A fractional `step` sets the precision: with `step="0.25"` the value is always shown with two decimals. + + +
+ + + +
+
+ +Without `min` and `max` the value has no bounds. An empty field steps from `min` when that is positive, otherwise from zero. + +### Text or number input + +A `type="text"` input gets `role="spinbutton"`, the matching `aria-value*` attributes and a numeric `inputmode`, so a phone shows the number keyboard and the native spin buttons never appear. A `type="number"` input keeps the browser's own validation and keyboard behaviour; the stepper only hides its spin buttons. + + +
+ + + +
+
+ +### Keyboard + +With the focus in the field, ↑ and ↓ step the value, Home and End jump to `min` and `max`. Holding a button down keeps stepping after a short delay; set `data-bs-repeat="false"` to turn that off. + +### Wider input + +The input is `3.5rem` wide by default, enough for a quantity. Set `--tblr-stepper-input-width` on the wrapper for longer values. + + +
+ + + +
+
+ +### Disabled + +Add `disabled` or `readonly` to the input; both buttons follow. + + +
+ + + +
+
+ +### Validation + +Add `.is-valid` or `.is-invalid` to the input, the same way you would on any `.form-control`. The field is too narrow for the validation icon, so only the border changes colour. + +## Options + +Options are read from `data-bs-*` attributes on the `.stepper` element (the `data-tblr-*` alias also works), or passed to the constructor. An option set this way wins over the input's attribute. + +| Option | Type | Default | Description | +| ---------------- | -------------- | ------- | ---------------------------------------------------------------------------------------------------- | +| `min` | number or null | `null` | Lower bound. When `null`, the input's `min` attribute is used; without that there is no lower bound. | +| `max` | number or null | `null` | Upper bound. When `null`, the input's `max` attribute is used; without that there is no upper bound. | +| `step` | number or null | `null` | Amount per step. When `null`, the input's `step` attribute is used, or `1`. | +| `repeat` | boolean | `true` | Keeps stepping while a button is held down. | +| `repeatDelay` | number | `400` | Milliseconds a button has to be held before it starts repeating. | +| `repeatInterval` | number | `80` | Milliseconds between two repeated steps. | + +## JavaScript + +Tabler creates a `Stepper` for every element matching `[data-bs-toggle="stepper"]` (or `[data-tblr-toggle="stepper"]`): + + + +Create one manually, or get an existing instance: + +```javascript +const el = document.querySelector('.stepper') +const stepper = tabler.Stepper.getOrCreateInstance(el, { min: 1, max: 10 }) +``` + +The input itself fires the usual `input` and `change` events whenever a button or the API changes it, so a form, a framework binding or validation sees a stepped value like a typed one. On top of that, `change.bs.stepper` fires on the wrapper with both values: + +```javascript +el.addEventListener('change.bs.stepper', (event) => { + console.log(event.value, event.previousValue) +}) +``` + +### Methods + +| Method | Description | +| ----------------- | ------------------------------------------------------------------------ | +| `getValue()` | Returns the current value as a number, or `null` for an empty field. | +| `setValue(value)` | Sets the value, clamped to the bounds and rounded to the step precision. | +| `increment()` | Adds one step. | +| `decrement()` | Removes one step. | +| `dispose()` | Removes the listeners and the instance. | diff --git a/preview/pages/form-elements.astro b/preview/pages/form-elements.astro index a2cfb8f44..f5884c7d1 100644 --- a/preview/pages/form-elements.astro +++ b/preview/pages/form-elements.astro @@ -11,6 +11,7 @@ import Layout from '@shared/components/cards/form/Layout.astro' import InputIcon from '@ui/form/InputIcon.astro' import InputMask from '@ui/form/InputMask.astro' import OtpInput from '@ui/OtpInput.astro' +import Stepper from '@ui/Stepper.astro' import Button from '@ui/Button.astro' import Avatar from '@ui/Avatar.astro' import Card from '@ui/Card.astro' @@ -158,6 +159,18 @@ const rows = [ +
+ + + + + + + + + + +
diff --git a/preview/pages/stepper.astro b/preview/pages/stepper.astro new file mode 100644 index 000000000..0d594faf1 --- /dev/null +++ b/preview/pages/stepper.astro @@ -0,0 +1,175 @@ +--- +import DefaultLayout from '@shared/layouts/DefaultLayout.astro' +import Card from '@ui/Card.astro' +import CardBody from '@ui/CardBody.astro' +import CardTitle from '@ui/CardTitle.astro' +import CardSubtitle from '@ui/CardSubtitle.astro' +import DocsLink from '@ui/DocsLink.astro' +import FormGroup from '@ui/FormGroup.astro' +import Stepper from '@ui/Stepper.astro' +import Icon from '@ui/Icon.astro' + +const cart = [ + { name: 'Hex nut M8 DIN 934, zinc plated', code: '8-934-8-ZN', price: '12.40', quantity: 2, max: 50 }, + { name: 'Lock nut M8 DIN 985, stainless A2', code: '8-985-A2', price: '28.90', quantity: 1, max: 20 }, + { name: 'Cap nut M8 DIN 1587, brass', code: '8-1587-MS', price: '41.00', quantity: 3, max: 3 }, +] +--- + + + + +
+
+ + + Basic + Add data-bs-toggle="stepper" to a .stepper wrapper; the input's min and max set the bounds. + + + +
+
+ + + At a bound + The button at min or max goes disabled, so the user sees the value cannot go further. +
+ + +
+
+
+
+
+ + + Step and precision + A fractional step sets how many decimals the value keeps. +
+ + +
+
+
+
+
+ + + Without bounds + Without min and max the value goes both ways, so a minus sign can be typed too. + + + +
+
+ + + Native number input + Use type="number" to keep the browser's own validation; the stepper only hides its spin buttons. + + + +
+
+ + + No repeat + Add data-bs-repeat="false" to step once per click instead of repeating while the button is held. + + + +
+
+ + + Wider input + Set --tblr-stepper-input-width on the wrapper for longer values. + + + +
+
+ + + Disabled and readonly + Add disabled or readonly to the input; both buttons follow. +
+ + +
+
+
+
+
+ + + Validation + Add .is-valid or .is-invalid to the input, like on any .form-control. +
+
+ + + +
+
+ + + +
+
+
+
+
+
+ + + With a label and hint + A stepper sits in a FormGroup like any other control. + + + +
+
+ + + In a cart + The usual place for a stepper: a quantity per line, with max set to the stock. +
+ + + + + + + + + + + { + cart.map((item) => ( + + + + + + + )) + } + +
ProductPriceQuantity
+
{item.name}
+
{item.code}
+
${item.price} + + + +
+
+
+
+
+
+
diff --git a/screenshots/pages/stepper.astro b/screenshots/pages/stepper.astro new file mode 100644 index 000000000..88679d9f7 --- /dev/null +++ b/screenshots/pages/stepper.astro @@ -0,0 +1,15 @@ +--- +// Stepper on its own: a quantity field between its two buttons, with the +// cursor resting on the increase button. The value sits below the maximum, so +// both buttons stay enabled and the picture shows the control in its normal +// state rather than at a bound. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Stepper from '@ui/Stepper.astro' +--- + + +
+ + +
+
diff --git a/shared/data/docs.json b/shared/data/docs.json index c42bfaf71..5a080d46c 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -400,6 +400,10 @@ "title": "OTP input", "url": "/ui/forms/otp-input" }, + { + "title": "Stepper", + "url": "/ui/forms/stepper" + }, { "title": "CSS variables", "url": "/ui/forms/css-variables" diff --git a/shared/data/menu.json b/shared/data/menu.json index 82ada0513..3e6448ca4 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -256,6 +256,11 @@ "url": "steps.html", "title": "Steps" }, + "stepper": { + "url": "stepper.html", + "title": "Stepper", + "badge": "New" + }, "tables": { "url": "tables.html", "title": "Tables" diff --git a/shared/ui/Stepper.astro b/shared/ui/Stepper.astro new file mode 100644 index 000000000..01c1ae37a --- /dev/null +++ b/shared/ui/Stepper.astro @@ -0,0 +1,39 @@ +--- +// Stepper initializes from `data-bs-toggle="stepper"` and its `data-bs-*` options (tabler.js, core/js/src/stepper.ts). +import Icon from '@ui/Icon.astro' + +interface Props { + /** data-bs-min: lower bound; defaults to the input's `min` attribute */ + min?: number + /** data-bs-max: upper bound; defaults to the input's `max` attribute */ + max?: number + /** data-bs-step: amount per step; defaults to the input's `step` attribute, or 1 */ + step?: number + /** data-bs-repeat: keep stepping while a button is held (default: true) */ + repeat?: boolean + value?: number | string + /** `text` keeps the native spin buttons away; `number` keeps the browser's own validation */ + type?: 'text' | 'number' + ariaLabel?: string + id?: string + name?: string + disabled?: boolean + readonly?: boolean + /** labels of the two buttons, for screen readers */ + decrementLabel?: string + incrementLabel?: string + class?: string + /** remaining attributes (data-*, …) are forwarded to the `.stepper` wrapper */ + [key: string]: unknown +} + +const { min, max, step, repeat, value, type = 'text', ariaLabel, id, name, disabled, readonly, decrementLabel = 'Decrease', incrementLabel = 'Increase', class: className, ...rest } = Astro.props + +const wrapperClass = ['stepper', className] +--- + +
+ + + +