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..4a0b1287a --- /dev/null +++ b/core/js/src/stepper.ts @@ -0,0 +1,561 @@ +/** + * -------------------------------------------------------------------------- + * 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 + /** accessible names of the buttons the plugin renders around a bare input */ + decrementLabel: string + incrementLabel: string +} + +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 CLASS_NAME_STEPPER = 'stepper' +const CLASS_NAME_BUTTON = 'btn btn-icon' + +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 ATTRIBUTE_ACTION = ATTRIBUTES_ACTION[0] + +// `.form-control-sm` on a bare input becomes `.stepper-sm` on the rendered box +const SIZES = ['sm', 'lg'] + +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, + decrementLabel: 'Decrease', + incrementLabel: 'Increase', +} + +const DefaultType: Record = { + min: '(number|null)', + max: '(number|null)', + step: '(number|null)', + repeat: 'boolean', + repeatDelay: 'number', + repeatInterval: 'number', + decrementLabel: 'string', + incrementLabel: 'string', +} + +/** + * 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. + * + * Initialised on a bare ``, the plugin wraps it in `.stepper` and + * renders the two buttons itself; initialised on a `.stepper` wrapper, it + * uses the buttons found in the markup. + */ + +class Stepper extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig + _input: HTMLInputElement | null = null + _buttons: HTMLElement[] = [] + // The box rendered around a bare input, removed again on dispose + _wrapper: HTMLElement | null = null + _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 = this._element instanceof HTMLInputElement ? this._element : (SelectorEngine.findOne(SELECTOR_INPUT, this._element) as HTMLInputElement | null) + if (!input) { + return + } + + this._input = input + + if (this._element === input) { + this._renderWrapper() + } else { + 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) + } + } + + if (this._wrapper && this._input) { + this._wrapper.replaceWith(this._input) + } + + super.dispose() + } + + // Private + // Puts a bare input into a `.stepper` box with a button on each side. The + // buttons are empty: the stylesheet paints their icons. + _renderWrapper(): void { + const input = this._input! + const wrapper = document.createElement('div') + wrapper.className = CLASS_NAME_STEPPER + + for (const size of SIZES) { + if (input.classList.contains(`form-control-${size}`)) { + wrapper.classList.add(`${CLASS_NAME_STEPPER}-${size}`) + } + } + + const button = (action: StepperAction, label: string): HTMLButtonElement => { + const element = document.createElement('button') + element.type = 'button' + element.className = CLASS_NAME_BUTTON + element.setAttribute(ATTRIBUTE_ACTION, action) + element.setAttribute('aria-label', label) + return element + } + + const decrement = button('decrement', this._config.decrementLabel) + const increment = button('increment', this._config.incrementLabel) + + input.replaceWith(wrapper) + wrapper.append(decrement, input, increment) + + this._wrapper = wrapper + this._buttons = [decrement, increment] + } + + // 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..595116d03 --- /dev/null +++ b/core/js/tests/unit/stepper.spec.ts @@ -0,0 +1,473 @@ +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') + }) + }) +}) diff --git a/core/package.json b/core/package.json index 9e8094cc0..2981375fc 100644 --- a/core/package.json +++ b/core/package.json @@ -88,7 +88,7 @@ }, { "path": "./dist/css/tabler.min.css", - "maxSize": "80 kB" + "maxSize": "81 kB" }, { "path": "./dist/css/tabler.rtl.css", @@ -96,7 +96,7 @@ }, { "path": "./dist/css/tabler.rtl.min.css", - "maxSize": "80 kB" + "maxSize": "81 kB" }, { "path": "./dist/css/tabler-flags.css", 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 d8c1bc92b..2e51d3fe0 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1207,6 +1207,25 @@ $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-input-widths: ( + 'sm': 3rem, + 'lg': 4rem, +) !default; +$stepper-icon-sizes: ( + 'sm': $input-btn-icon-size-sm, + 'lg': $input-btn-icon-size-lg, +) !default; +$stepper-bg: $input-bg !default; +$stepper-border-color: var(--border-color) !default; +$stepper-border-radius: $input-border-radius !default; +// Monochrome icons, painted with `currentColor` through `mask-icon()` on an empty button. +$stepper-icon-decrement: url("data:image/svg+xml,") !default; +$stepper-icon-increment: url("data:image/svg+xml,") !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..a4c28b17e --- /dev/null +++ b/core/scss/ui/forms/_form-stepper.scss @@ -0,0 +1,164 @@ +/* +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; + // Never stretched by a flex column parent: the box is as wide as its parts + width: fit-content; + 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; + + // An empty button paints the default icon through a CSS mask, so the + // markup needs no svg; any content put inside replaces it. + &[data-bs-stepper-action='decrement'], + &[data-tblr-stepper-action='decrement'] { + --stepper-icon: #{escape-svg($stepper-icon-decrement)}; + } + + &[data-bs-stepper-action='increment'], + &[data-tblr-stepper-action='increment'] { + --stepper-icon: #{escape-svg($stepper-icon-increment)}; + } + + &:not(:has(> *))::before { + width: var(--btn-icon-size); + height: var(--btn-icon-size); + content: ''; + background-color: currentColor; + @include mask-icon(var(--stepper-icon), $size: 100%); + } + + &: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'); + } + + // The box sits between the field and its feedback text, so the + // `.is-invalid ~ .invalid-feedback` rule has to be repeated from here. + ~ .#{$state}-feedback, + ~ .#{$state}-tooltip { + display: block; + } + } + } +} + +// Sizes move the same tokens `.form-control-sm` / `.btn-sm` and their `-lg` +// twins read, so the field and the buttons keep one height. +@each $size in (sm, lg) { + .stepper-#{$size} { + --stepper-input-width: #{map.get($stepper-input-widths, $size)}; + --stepper-border-radius: var(--btn-input-#{$size}-border-radius); + + > .form-control { + --control-padding-y: var(--btn-input-#{$size}-padding-y); + --control-font-size: var(--btn-input-#{$size}-font-size); + --control-line-height: var(--btn-input-#{$size}-line-height); + } + + > .btn { + --btn-padding-y: var(--btn-input-#{$size}-padding-y); + --btn-font-size: var(--btn-input-#{$size}-font-size); + --btn-line-height: var(--btn-input-#{$size}-line-height); + --btn-icon-size: #{map.get($stepper-icon-sizes, $size)}; + } + } +} diff --git a/docs/content/ui/forms/stepper.mdx b/docs/content/ui/forms/stepper.mdx new file mode 100644 index 000000000..ac74cedb5 --- /dev/null +++ b/docs/content/ui/forms/stepper.mdx @@ -0,0 +1,179 @@ +--- +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: The box around the field and the two buttons; rendered by the plugin, or written by hand for custom buttons + size: + - class: stepper-sm + desc: Smaller field and buttons + - class: stepper-lg + desc: Larger field and buttons +--- + +import Example from '@components/Example.astro' +import CodeDocs from '@components/CodeDocs.astro' +import Icon from '@ui/Icon.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 the input and Tabler does the rest: it wraps the field in a `.stepper` box and renders the two buttons. 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. + +### Sizes + +Add `.form-control-sm` or `.form-control-lg` to the input; the rendered box gets `.stepper-sm` or `.stepper-lg`, so the field and the buttons keep one height. + + + +
+ + + +
+ +
+ +### Wider input + +The input is `3.5rem` wide by default, enough for a quantity. Set `--tblr-stepper-input-width` on the input 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. A `.valid-feedback` or `.invalid-feedback` placed right after the input still shows, even though the plugin puts its box between them. + + +
+ +
In stock, ships today.
+
+
+ +
Only 5 pieces left.
+
+
+ +With a `type="number"` input the browser validates `required` itself (the plugin keeps the value inside `min` and `max` anyway), so a `.was-validated` form colours the box without any class on the input. + + +
+
+ + +
Pick the number of seats.
+
+
+
+ +### Custom buttons + +To change the icons, the labels or the classes of the buttons, write the markup yourself: put `data-bs-toggle="stepper"` on a `.stepper` wrapper and mark the buttons with `data-bs-stepper-action="decrement"` and `"increment"`. An empty button still gets the default icon from CSS; any content inside replaces it. + + +
+ + + +
+
+ +## Options + +Options are read from `data-bs-*` attributes on the element that carries `data-bs-toggle` (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. | +| `decrementLabel` | string | `\'Decrease\'` | `aria-label` of the rendered decrease button. | +| `incrementLabel` | string | `\'Increase\'` | `aria-label` of the rendered increase button. | + +## JavaScript + +Tabler creates a `Stepper` for every element matching `[data-bs-toggle="stepper"]` (or `[data-tblr-toggle="stepper"]`), an input or a `.stepper` wrapper: + + + +Create one manually, or get an existing instance: + +```javascript +const el = document.querySelector('#quantity') +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 element the plugin was created on 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/billing.astro b/preview/pages/billing.astro index a291aedf2..cef5671e4 100644 --- a/preview/pages/billing.astro +++ b/preview/pages/billing.astro @@ -16,6 +16,7 @@ import ListGroupItem from '@ui/ListGroupItem.astro' import Icon from '@ui/Icon.astro' import Payment from '@ui/Payment.astro' import Progress from '@ui/Progress.astro' +import Stepper from '@ui/Stepper.astro' import type { PaymentProvider } from '@shared/lib/tokens' const usage = { used: 7420, limit: 10000 } @@ -79,7 +80,12 @@ const invoiceStatus = { Monthly September 18, 2026 - 8 of 10 used + +
+ + 8 used +
+
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/settings-plan.astro b/preview/pages/settings-plan.astro index 512bba352..ca5302e14 100644 --- a/preview/pages/settings-plan.astro +++ b/preview/pages/settings-plan.astro @@ -2,6 +2,8 @@ import SettingsLayout from '@shared/layouts/SettingsLayout.astro' import PricingCard from '@shared/components/cards/PricingCard.astro' import PricingCardEnterprise from '@shared/components/cards/PricingCardEnterprise.astro' +import Stepper from '@ui/Stepper.astro' +import FormGroup from '@ui/FormGroup.astro' --- @@ -10,6 +12,8 @@ import PricingCardEnterprise from '@shared/components/cards/PricingCardEnterpris

This workspace’s Basic Plan is set to $34 per month and will renew on June 19, 2022.

+ +
diff --git a/preview/pages/stepper.astro b/preview/pages/stepper.astro new file mode 100644 index 000000000..696aec3a9 --- /dev/null +++ b/preview/pages/stepper.astro @@ -0,0 +1,199 @@ +--- +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 the input; the plugin renders the box and the buttons, 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. +
+ + +
+
+
+
+
+ + + Sizes + Add .form-control-sm or .form-control-lg to the input; the box gets .stepper-sm or -lg. +
+ + + +
+
+
+
+
+ + + 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 input 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; the feedback text after it still shows. +
+
+ +
In stock, ships today.
+
+
+ +
Only 5 pieces left.
+
+
+
+
+
+
+ + + Custom buttons + Write the .stepper markup yourself to change the icons or labels; data-bs-stepper-action marks the buttons. +
+ + + +
+
+
+
+
+ + + 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..00cc2e111 --- /dev/null +++ b/screenshots/pages/stepper.astro @@ -0,0 +1,53 @@ +--- +// Stepper where it is used most: a quantity per line of a small cart. Three +// lines, each with its own stepper; the cursor rests on the increase button of +// the middle one, and the last line sits at its stock limit so its increase +// button is disabled - the state the plugin sets, not a faked one. +// +// The capture never hovers, so the middle line is written by hand (the +// custom-buttons markup) and its increase button gets the hover background +// through the `--btn-bg` token, the same colour a real hover paints. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Card from '@ui/Card.astro' +import Stepper from '@ui/Stepper.astro' + +const cart = [ + { name: 'Hex nut M8 DIN 934', code: 'Zinc plated', price: '$12.40', quantity: 2, max: 50 }, + { name: 'Lock nut M8 DIN 985', code: 'Stainless A2', price: '$28.90', quantity: 1, max: 20 }, + { name: 'Cap nut M8 DIN 1587', code: 'Brass', price: '$41.00', quantity: 3, max: 3 }, +] +--- + + + + + + { + cart.map((item, index) => ( + + + + + + )) + } + +
+
{item.name}
+
{item.code}
+
{item.price} +
+ {index === 1 ? ( +
+
+ ) : ( + + )} +
+
+
+
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..fd2522bf8 --- /dev/null +++ b/shared/ui/Stepper.astro @@ -0,0 +1,53 @@ +--- +// Stepper initializes from `data-bs-toggle="stepper"` on the input and its `data-bs-*` options (tabler.js, core/js/src/stepper.ts). +// The plugin wraps the input in `.stepper` and renders the two buttons. +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 + /** `.form-control-{size}`; the rendered box gets `.stepper-{size}` */ + size?: 'sm' | 'lg' + 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 + /** data-bs-decrement-label / data-bs-increment-label: accessible names of the rendered buttons */ + decrementLabel?: string + incrementLabel?: string + class?: string + /** remaining attributes (data-*, style, …) are forwarded to the input */ + [key: string]: unknown +} + +const { min, max, step, repeat, size, value, type = 'text', ariaLabel, id, name, disabled, readonly, decrementLabel, incrementLabel, class: className, ...rest } = Astro.props + +const inputClass = ['form-control', size && `form-control-${size}`, className] +--- + +