mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 13:17:00 +04:00
Add Stepper component for quantity inputs (#3281)
This commit is contained in:
1 parent
d75c5d6a7d
commit
0e4a07b82a
17 files changed
+1746
-3
No files matched your search
@@ -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.
|
||||||
@@ -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<ComponentConfig> & Record<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<keyof ComponentConfig, string> = {
|
||||||
|
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 `<input>` 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 `<input>`, 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<typeof setTimeout> | null = null
|
||||||
|
_repeatTimer: ReturnType<typeof setInterval> | 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<keyof ComponentConfig, string> {
|
||||||
|
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<string, () => 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
|
||||||
@@ -15,6 +15,7 @@ import './src/sortable'
|
|||||||
import './src/otp-input'
|
import './src/otp-input'
|
||||||
import './src/sparkline'
|
import './src/sparkline'
|
||||||
import './src/strength'
|
import './src/strength'
|
||||||
|
import './src/stepper'
|
||||||
|
|
||||||
// Re-export everything from bootstrap.ts (single source of truth)
|
// Re-export everything from bootstrap.ts (single source of truth)
|
||||||
export * from './src/bootstrap'
|
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 OtpInput } from './src/otp-input'
|
||||||
export { default as Sortable } from './src/sortable'
|
export { default as Sortable } from './src/sortable'
|
||||||
export { default as Sparkline } from './src/sparkline'
|
export { default as Sparkline } from './src/sparkline'
|
||||||
|
export { default as Stepper } from './src/stepper'
|
||||||
export { default as Strength } from './src/strength'
|
export { default as Strength } from './src/strength'
|
||||||
export { default as SwitchIcon } from './src/switch-icon'
|
export { default as SwitchIcon } from './src/switch-icon'
|
||||||
|
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
[
|
||||||
|
`<div class="stepper" data-bs-toggle="stepper" ${wrapperAttributes}>`,
|
||||||
|
' <button type="button" class="btn" data-bs-stepper-action="decrement">-</button>',
|
||||||
|
` <input type="text" class="form-control" ${inputAttributes} />`,
|
||||||
|
' <button type="button" class="btn" data-bs-stepper-action="increment">+</button>',
|
||||||
|
'</div>',
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
describe('Stepper', () => {
|
||||||
|
let fixtureEl: HTMLElement
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fixtureEl = getFixture()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixtureEl.innerHTML = markup()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearFixture()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
const stepper = (): HTMLElement => fixtureEl.querySelector('.stepper')!
|
||||||
|
const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
|
||||||
|
const decrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-stepper-action="decrement"]')!
|
||||||
|
const incrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-stepper-action="increment"]')!
|
||||||
|
|
||||||
|
const change = (value: string): void => {
|
||||||
|
input().value = value
|
||||||
|
input().dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const keydown = (key: string): KeyboardEvent => {
|
||||||
|
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
||||||
|
input().dispatchEvent(event)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NAME', () => {
|
||||||
|
it('should return the plugin name', () => {
|
||||||
|
expect(Stepper.NAME).toBe('stepper')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Default', () => {
|
||||||
|
it('should expose the default config', () => {
|
||||||
|
expect(Stepper.Default.min).toBeNull()
|
||||||
|
expect(Stepper.Default.repeat).toBe(true)
|
||||||
|
expect(typeof Stepper.DefaultType.step).toBe('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should read the bounds from the input attributes', () => {
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
expect(instance._min).toBe(0)
|
||||||
|
expect(instance._max).toBe(5)
|
||||||
|
expect(instance._step).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should let the config override the input attributes', () => {
|
||||||
|
const instance = new Stepper(stepper(), { min: 2, max: 10, step: 2 })
|
||||||
|
expect(instance._min).toBe(2)
|
||||||
|
expect(instance._max).toBe(10)
|
||||||
|
expect(instance._step).toBe(2)
|
||||||
|
expect(input().value).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should read the config from data attributes', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1"', 'data-bs-min="1" data-bs-max="3" data-bs-step="0.5"')
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
expect(instance._min).toBe(1)
|
||||||
|
expect(instance._max).toBe(3)
|
||||||
|
expect(instance._step).toBe(0.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have no bounds when neither attributes nor config set them', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1"')
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
expect(instance._min).toBe(Number.NEGATIVE_INFINITY)
|
||||||
|
expect(instance._max).toBe(Number.POSITIVE_INFINITY)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to a step of 1 for a zero or negative step', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1" step="0"')
|
||||||
|
expect(new Stepper(stepper())._step).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clamp the initial value', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="9" min="0" max="5"')
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should give a text input the spinbutton role and a numeric inputmode', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(input().getAttribute('role')).toBe('spinbutton')
|
||||||
|
expect(input().getAttribute('inputmode')).toBe('numeric')
|
||||||
|
expect(input().getAttribute('aria-valuemin')).toBe('0')
|
||||||
|
expect(input().getAttribute('aria-valuemax')).toBe('5')
|
||||||
|
expect(input().getAttribute('aria-valuenow')).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use the decimal inputmode for a fractional step', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1" step="0.25"')
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(input().getAttribute('inputmode')).toBe('decimal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should leave a number input alone', () => {
|
||||||
|
fixtureEl.innerHTML = markup('type="number" value="1" min="0" max="5"').replace('type="text"', '')
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(input().hasAttribute('role')).toBe(false)
|
||||||
|
expect(input().hasAttribute('aria-valuenow')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing without an input', () => {
|
||||||
|
fixtureEl.innerHTML = '<div class="stepper"></div>'
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
expect(instance._input).toBeNull()
|
||||||
|
expect(instance.getValue()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable the buttons of a disabled input', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1" disabled')
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(decrementButton().disabled).toBe(true)
|
||||||
|
expect(incrementButton().disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buttons', () => {
|
||||||
|
it('should step the value on click', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
incrementButton().click()
|
||||||
|
expect(input().value).toBe('2')
|
||||||
|
decrementButton().click()
|
||||||
|
decrementButton().click()
|
||||||
|
expect(input().value).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable the button at a bound', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="4" min="0" max="5"')
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(incrementButton().disabled).toBe(false)
|
||||||
|
incrementButton().click()
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
expect(incrementButton().disabled).toBe(true)
|
||||||
|
expect(decrementButton().disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should never leave the bounds', () => {
|
||||||
|
const instance = new Stepper(stepper(), { max: 2 })
|
||||||
|
instance.increment()
|
||||||
|
instance.increment()
|
||||||
|
instance.increment()
|
||||||
|
expect(input().value).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep stepping while a button is held', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
new Stepper(stepper(), { max: 100 })
|
||||||
|
|
||||||
|
incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }))
|
||||||
|
vi.advanceTimersByTime(400)
|
||||||
|
expect(input().value).toBe('2')
|
||||||
|
vi.advanceTimersByTime(80 * 3)
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
|
||||||
|
incrementButton().dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
|
||||||
|
// The click that ends the hold is not one more step
|
||||||
|
incrementButton().click()
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop a hold at a bound', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const instance = new Stepper(stepper(), { max: 3 })
|
||||||
|
|
||||||
|
incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }))
|
||||||
|
vi.advanceTimersByTime(400 + 80 * 5)
|
||||||
|
expect(input().value).toBe('3')
|
||||||
|
expect(instance._repeatTimer).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not repeat when the option is off', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
new Stepper(stepper(), { repeat: false, max: 100 })
|
||||||
|
|
||||||
|
incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }))
|
||||||
|
vi.advanceTimersByTime(2000)
|
||||||
|
expect(input().value).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('keyboard', () => {
|
||||||
|
it('should step with the arrow keys and jump with Home and End', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(keydown('ArrowUp').defaultPrevented).toBe(true)
|
||||||
|
expect(input().value).toBe('2')
|
||||||
|
keydown('ArrowDown')
|
||||||
|
expect(input().value).toBe('1')
|
||||||
|
keydown('End')
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
keydown('Home')
|
||||||
|
expect(input().value).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore Home and End without a bound', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1"')
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(keydown('End').defaultPrevented).toBe(false)
|
||||||
|
expect(input().value).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should leave other keys alone', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(keydown('a').defaultPrevented).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('typed value', () => {
|
||||||
|
it('should clamp a typed value on change', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
change('42')
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
expect(incrementButton().disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clamp on blur as well', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
input().value = '125'
|
||||||
|
input().dispatchEvent(new Event('blur'))
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fire nothing when an untouched field is left', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
const spy = vi.fn()
|
||||||
|
input().addEventListener('change', spy)
|
||||||
|
input().dispatchEvent(new Event('blur'))
|
||||||
|
expect(spy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore the previous value after a non-numeric edit', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
change('3')
|
||||||
|
change('abc')
|
||||||
|
expect(input().value).toBe('3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept a decimal comma', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1" step="0.5"')
|
||||||
|
new Stepper(stepper())
|
||||||
|
change('2,5')
|
||||||
|
expect(input().value).toBe('2.5')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('character filter', () => {
|
||||||
|
const beforeInput = (data: string, inputType = 'insertText'): InputEvent => {
|
||||||
|
const event = new InputEvent('beforeinput', { data, inputType, bubbles: true, cancelable: true })
|
||||||
|
input().dispatchEvent(event)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should drop a typed letter and keep a digit', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(beforeInput('a').defaultPrevented).toBe(true)
|
||||||
|
expect(beforeInput('7').defaultPrevented).toBe(false)
|
||||||
|
expect(beforeInput('.').defaultPrevented).toBe(true)
|
||||||
|
expect(beforeInput('-').defaultPrevented).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow the sign and the separator when the bounds and step need them', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="0" min="-5" step="0.5"')
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(beforeInput('-').defaultPrevented).toBe(false)
|
||||||
|
expect(beforeInput('.').defaultPrevented).toBe(false)
|
||||||
|
expect(beforeInput(',').defaultPrevented).toBe(false)
|
||||||
|
expect(beforeInput('e').defaultPrevented).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep only the digits of a pasted text', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
input().value = ''
|
||||||
|
input().setSelectionRange(0, 0)
|
||||||
|
expect(beforeInput('4 pcs', 'insertFromPaste').defaultPrevented).toBe(true)
|
||||||
|
expect(input().value).toBe('4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should leave deletions alone', () => {
|
||||||
|
new Stepper(stepper())
|
||||||
|
expect(beforeInput('', 'deleteContentBackward').defaultPrevented).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('precision', () => {
|
||||||
|
it('should round to the precision of the step', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="0.1" step="0.1"')
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
instance.increment()
|
||||||
|
instance.increment()
|
||||||
|
expect(input().value).toBe('0.3')
|
||||||
|
expect(instance.getValue()).toBe(0.3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should step an empty field from the lower bound', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="" min="2" max="9"')
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
expect(instance.getValue()).toBeNull()
|
||||||
|
expect(input().hasAttribute('aria-valuenow')).toBe(false)
|
||||||
|
instance.increment()
|
||||||
|
expect(input().value).toBe('3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('events', () => {
|
||||||
|
it('should fire change.bs.stepper with the values', () => {
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
const spy = vi.fn()
|
||||||
|
stepper().addEventListener('change.bs.stepper', spy)
|
||||||
|
|
||||||
|
instance.increment()
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
const event = spy.mock.calls[0][0] as Event & { value: number; previousValue: number }
|
||||||
|
expect(event.value).toBe(2)
|
||||||
|
expect(event.previousValue).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fire input and change on the field', () => {
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
const onInput = vi.fn()
|
||||||
|
const onChange = vi.fn()
|
||||||
|
input().addEventListener('input', onInput)
|
||||||
|
input().addEventListener('change', onChange)
|
||||||
|
|
||||||
|
instance.increment()
|
||||||
|
expect(onInput).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not fire when the value does not change', () => {
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
const spy = vi.fn()
|
||||||
|
stepper().addEventListener('change.bs.stepper', spy)
|
||||||
|
|
||||||
|
instance.setValue(1)
|
||||||
|
instance.decrement()
|
||||||
|
instance.decrement()
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setValue', () => {
|
||||||
|
it('should set and clamp the value', () => {
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
instance.setValue(3)
|
||||||
|
expect(input().value).toBe('3')
|
||||||
|
instance.setValue('99')
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
instance.setValue('nope')
|
||||||
|
expect(input().value).toBe('5')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disabled', () => {
|
||||||
|
it('should not step a disabled or readonly input', () => {
|
||||||
|
fixtureEl.innerHTML = markup('value="1" readonly')
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
instance.increment()
|
||||||
|
expect(input().value).toBe('1')
|
||||||
|
expect(incrementButton().disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('should remove the listeners and the instance', () => {
|
||||||
|
const instance = new Stepper(stepper())
|
||||||
|
instance.dispose()
|
||||||
|
incrementButton().click()
|
||||||
|
expect(input().value).toBe('1')
|
||||||
|
expect(Stepper.getInstance(stepper())).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bare input', () => {
|
||||||
|
const bare = (attributes = 'value="1" min="0" max="5"'): HTMLInputElement => {
|
||||||
|
fixtureEl.innerHTML = `<form><input type="text" class="form-control" data-bs-toggle="stepper" ${attributes} /></form>`
|
||||||
|
return fixtureEl.querySelector('input')!
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should wrap the input and render the two buttons', () => {
|
||||||
|
const el = bare()
|
||||||
|
new Stepper(el)
|
||||||
|
|
||||||
|
const wrapper = el.parentElement!
|
||||||
|
expect(wrapper.classList.contains('stepper')).toBe(true)
|
||||||
|
expect(wrapper.parentElement!.tagName).toBe('FORM')
|
||||||
|
expect([...wrapper.children].map((child) => child.tagName)).toEqual(['BUTTON', 'INPUT', 'BUTTON'])
|
||||||
|
expect(decrementButton().getAttribute('aria-label')).toBe('Decrease')
|
||||||
|
expect(incrementButton().getAttribute('aria-label')).toBe('Increase')
|
||||||
|
expect(incrementButton().type).toBe('button')
|
||||||
|
expect(incrementButton().innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should step through the rendered buttons', () => {
|
||||||
|
const el = bare()
|
||||||
|
new Stepper(el)
|
||||||
|
incrementButton().click()
|
||||||
|
expect(el.value).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should read the config from the input and label the buttons', () => {
|
||||||
|
const el = bare('value="1" data-bs-max="2" data-bs-increment-label="More" data-bs-decrement-label="Fewer"')
|
||||||
|
new Stepper(el)
|
||||||
|
expect(incrementButton().getAttribute('aria-label')).toBe('More')
|
||||||
|
expect(decrementButton().getAttribute('aria-label')).toBe('Fewer')
|
||||||
|
incrementButton().click()
|
||||||
|
incrementButton().click()
|
||||||
|
expect(el.value).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should map the size of the input to the box', () => {
|
||||||
|
fixtureEl.innerHTML = '<input type="text" class="form-control form-control-sm" data-bs-toggle="stepper" value="1" />'
|
||||||
|
const el = fixtureEl.querySelector('input')!
|
||||||
|
new Stepper(el)
|
||||||
|
expect(el.parentElement!.classList.contains('stepper-sm')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fire change.bs.stepper on the input', () => {
|
||||||
|
const el = bare()
|
||||||
|
const instance = new Stepper(el)
|
||||||
|
const spy = vi.fn()
|
||||||
|
el.addEventListener('change.bs.stepper', spy)
|
||||||
|
instance.increment()
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should unwrap the input on dispose', () => {
|
||||||
|
const el = bare()
|
||||||
|
new Stepper(el).dispose()
|
||||||
|
expect(el.parentElement!.tagName).toBe('FORM')
|
||||||
|
expect(fixtureEl.querySelector('.stepper')).toBeNull()
|
||||||
|
expect(fixtureEl.querySelectorAll('button')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('data-api', () => {
|
||||||
|
it('should initialise on getOrCreateInstance', () => {
|
||||||
|
const instance = Stepper.getOrCreateInstance(stepper())
|
||||||
|
expect(instance).toBeInstanceOf(Stepper)
|
||||||
|
expect(Stepper.getOrCreateInstance(stepper())).toBe(instance)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept the data-tblr-* action attributes', () => {
|
||||||
|
fixtureEl.innerHTML = markup().replaceAll('data-bs-stepper-action', 'data-tblr-stepper-action')
|
||||||
|
new Stepper(stepper())
|
||||||
|
fixtureEl.querySelector<HTMLButtonElement>('[data-tblr-stepper-action="increment"]')!.click()
|
||||||
|
expect(input().value).toBe('2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
+2
-2
@@ -88,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler.min.css",
|
"path": "./dist/css/tabler.min.css",
|
||||||
"maxSize": "80 kB"
|
"maxSize": "81 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler.rtl.css",
|
"path": "./dist/css/tabler.rtl.css",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler.rtl.min.css",
|
"path": "./dist/css/tabler.rtl.min.css",
|
||||||
"maxSize": "80 kB"
|
"maxSize": "81 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler-flags.css",
|
"path": "./dist/css/tabler-flags.css",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
@forward 'ui/forms/form-custom';
|
@forward 'ui/forms/form-custom';
|
||||||
@forward 'ui/forms/form-check';
|
@forward 'ui/forms/form-check';
|
||||||
@forward 'ui/forms/form-otp';
|
@forward 'ui/forms/form-otp';
|
||||||
|
@forward 'ui/forms/form-stepper';
|
||||||
@forward 'ui/forms/validation';
|
@forward 'ui/forms/validation';
|
||||||
@forward 'ui/forms/strength';
|
@forward 'ui/forms/strength';
|
||||||
@forward 'ui/legend';
|
@forward 'ui/legend';
|
||||||
|
|||||||
@@ -1207,6 +1207,25 @@ $otp-slot-box-shadow: var(--shadow-input) !default;
|
|||||||
$otp-separator-color: var(--secondary) !default;
|
$otp-separator-color: var(--secondary) !default;
|
||||||
// scss-docs-end otp-variables
|
// 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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 12h14'/></svg>") !default;
|
||||||
|
$stepper-icon-increment: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 5v14M5 12h14'/></svg>") !default;
|
||||||
|
// scss-docs-end stepper-variables
|
||||||
|
|
||||||
// Cards
|
// Cards
|
||||||
// scss-docs-start card-variables
|
// scss-docs-start card-variables
|
||||||
$card-spacer-y: 1.25rem !default;
|
$card-spacer-y: 1.25rem !default;
|
||||||
|
|||||||
@@ -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)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `<input>` 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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<input type="text" class="form-control" value="1" min="1" max="99" aria-label="Quantity" data-bs-toggle="stepper" />
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<input type="text" class="form-control" value="1.5" min="0" max="10" step="0.25" aria-label="Hours" data-bs-toggle="stepper" />
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<input type="number" class="form-control" value="5" min="0" max="10" aria-label="Seats" data-bs-toggle="stepper" />
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
With the focus in the field, <kbd>↑</kbd> and <kbd>↓</kbd> step the value, <kbd>Home</kbd> and <kbd>End</kbd> 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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<input type="text" class="form-control form-control-sm" value="1" min="0" aria-label="Small" data-bs-toggle="stepper" />
|
||||||
|
<div class="stepper " data-bs-toggle="stepper">
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Decrease"></button>
|
||||||
|
<input type="text" class="form-control" value="1" min="0" aria-label="Default" />
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="increment" aria-label="Increase"></button>
|
||||||
|
</div>
|
||||||
|
<input type="text" class="form-control form-control-lg" value="1" min="0" aria-label="Large" data-bs-toggle="stepper" />
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<input type="text" class="form-control" value="1000" min="0" step="100" aria-label="Amount" data-bs-toggle="stepper" style="--tblr-stepper-input-width: 6rem" />
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
### Disabled
|
||||||
|
|
||||||
|
Add `disabled` or `readonly` to the input; both buttons follow.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<input type="text" class="form-control" value="3" disabled aria-label="Quantity" data-bs-toggle="stepper" />
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<div>
|
||||||
|
<input type="text" class="form-control is-valid" value="2" min="1" max="5" aria-label="Quantity" data-bs-toggle="stepper" />
|
||||||
|
<div class="valid-feedback">In stock, ships today.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" class="form-control is-invalid" value="5" min="1" max="5" aria-label="Quantity" data-bs-toggle="stepper" />
|
||||||
|
<div class="invalid-feedback">Only 5 pieces left.</div>
|
||||||
|
</div>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<form class="was-validated" novalidate>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="stepper-seats">
|
||||||
|
Seats
|
||||||
|
</label>
|
||||||
|
<input type="number" class="form-control" id="stepper-seats" value="" min="1" max="10" required data-bs-toggle="stepper" />
|
||||||
|
<div class="invalid-feedback">Pick the number of seats.</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
<Example column>
|
||||||
|
<div class="stepper" data-bs-toggle="stepper">
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Fewer">
|
||||||
|
<Icon name="chevron-down" />
|
||||||
|
</button>
|
||||||
|
<input type="text" class="form-control" value="1" min="1" max="99" aria-label="Quantity" />
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="increment" aria-label="More">
|
||||||
|
<Icon name="chevron-up" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
<CodeDocs name="stepper-init" file="core/js/src/stepper.ts" />
|
||||||
|
|
||||||
|
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. |
|
||||||
@@ -16,6 +16,7 @@ import ListGroupItem from '@ui/ListGroupItem.astro'
|
|||||||
import Icon from '@ui/Icon.astro'
|
import Icon from '@ui/Icon.astro'
|
||||||
import Payment from '@ui/Payment.astro'
|
import Payment from '@ui/Payment.astro'
|
||||||
import Progress from '@ui/Progress.astro'
|
import Progress from '@ui/Progress.astro'
|
||||||
|
import Stepper from '@ui/Stepper.astro'
|
||||||
import type { PaymentProvider } from '@shared/lib/tokens'
|
import type { PaymentProvider } from '@shared/lib/tokens'
|
||||||
|
|
||||||
const usage = { used: 7420, limit: 10000 }
|
const usage = { used: 7420, limit: 10000 }
|
||||||
@@ -79,7 +80,12 @@ const invoiceStatus = {
|
|||||||
<Datagrid>
|
<Datagrid>
|
||||||
<DatagridItem title="Billing period">Monthly</DatagridItem>
|
<DatagridItem title="Billing period">Monthly</DatagridItem>
|
||||||
<DatagridItem title="Next invoice">September 18, 2026</DatagridItem>
|
<DatagridItem title="Next invoice">September 18, 2026</DatagridItem>
|
||||||
<DatagridItem title="Seats">8 of 10 used</DatagridItem>
|
<DatagridItem title="Seats">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<Stepper size="sm" value={10} min={8} max={100} ariaLabel="Seats in the plan" />
|
||||||
|
<span class="text-secondary">8 used</span>
|
||||||
|
</div>
|
||||||
|
</DatagridItem>
|
||||||
</Datagrid>
|
</Datagrid>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Layout from '@shared/components/cards/form/Layout.astro'
|
|||||||
import InputIcon from '@ui/form/InputIcon.astro'
|
import InputIcon from '@ui/form/InputIcon.astro'
|
||||||
import InputMask from '@ui/form/InputMask.astro'
|
import InputMask from '@ui/form/InputMask.astro'
|
||||||
import OtpInput from '@ui/OtpInput.astro'
|
import OtpInput from '@ui/OtpInput.astro'
|
||||||
|
import Stepper from '@ui/Stepper.astro'
|
||||||
import Button from '@ui/Button.astro'
|
import Button from '@ui/Button.astro'
|
||||||
import Avatar from '@ui/Avatar.astro'
|
import Avatar from '@ui/Avatar.astro'
|
||||||
import Card from '@ui/Card.astro'
|
import Card from '@ui/Card.astro'
|
||||||
@@ -158,6 +159,18 @@ const rows = [
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Stepper" />
|
||||||
|
<CardBody>
|
||||||
|
<FormGroup label="Quantity" for="stepper-basic"><Stepper id="stepper-basic" value={1} min={1} max={99} /></FormGroup>
|
||||||
|
<FormGroup label="Step of 0.25" for="stepper-decimal"><Stepper id="stepper-decimal" value={1.5} min={0} max={10} step={0.25} /></FormGroup>
|
||||||
|
<FormGroup label="Without bounds" for="stepper-unbounded"><Stepper id="stepper-unbounded" value={0} /></FormGroup>
|
||||||
|
<FormGroup label="Native number input" for="stepper-number"><Stepper id="stepper-number" type="number" value={5} min={0} max={10} /></FormGroup>
|
||||||
|
<FormGroup label="Disabled" for="stepper-disabled"><Stepper id="stepper-disabled" value={3} disabled /></FormGroup>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<Card as="form" class="h-100">
|
<Card as="form" class="h-100">
|
||||||
<CardHeader title="My Profile" />
|
<CardHeader title="My Profile" />
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import SettingsLayout from '@shared/layouts/SettingsLayout.astro'
|
import SettingsLayout from '@shared/layouts/SettingsLayout.astro'
|
||||||
import PricingCard from '@shared/components/cards/PricingCard.astro'
|
import PricingCard from '@shared/components/cards/PricingCard.astro'
|
||||||
import PricingCardEnterprise from '@shared/components/cards/PricingCardEnterprise.astro'
|
import PricingCardEnterprise from '@shared/components/cards/PricingCardEnterprise.astro'
|
||||||
|
import Stepper from '@ui/Stepper.astro'
|
||||||
|
import FormGroup from '@ui/FormGroup.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<SettingsLayout active="settings-plan" title="Plans">
|
<SettingsLayout active="settings-plan" title="Plans">
|
||||||
@@ -10,6 +12,8 @@ import PricingCardEnterprise from '@shared/components/cards/PricingCardEnterpris
|
|||||||
|
|
||||||
<p class="text-secondary mb-4">This workspace’s Basic Plan is set to $34 per month and will renew on June 19, 2022.</p>
|
<p class="text-secondary mb-4">This workspace’s Basic Plan is set to $34 per month and will renew on June 19, 2022.</p>
|
||||||
|
|
||||||
|
<FormGroup label="Users" for="plan-users" hint="Each plan is billed per user. Pick how many people need access." class="mb-4"><Stepper id="plan-users" value={10} min={1} max={100} /></FormGroup>
|
||||||
|
|
||||||
<div class="row row-cards">
|
<div class="row row-cards">
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<PricingCard price="0" users={3} category="Free" />
|
<PricingCard price="0" users={3} category="Free" />
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<DefaultLayout title="Stepper" pageMenu="base.stepper" description="A stepper is a number field with a decrease and an increase button, for a quantity or any value changed in fixed steps.">
|
||||||
|
<DocsLink slot="page-header-actions" path="/ui/forms/stepper" />
|
||||||
|
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Basic</CardTitle>
|
||||||
|
<CardSubtitle>Add <code>data-bs-toggle="stepper"</code> to the input; the plugin renders the box and the buttons, <code>min</code> and <code>max</code> set the bounds.</CardSubtitle>
|
||||||
|
<Stepper value={1} min={1} max={99} ariaLabel="Quantity" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>At a bound</CardTitle>
|
||||||
|
<CardSubtitle>The button at <code>min</code> or <code>max</code> goes disabled, so the user sees the value cannot go further.</CardSubtitle>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
<Stepper value={0} min={0} max={10} ariaLabel="At the minimum" />
|
||||||
|
<Stepper value={10} min={0} max={10} ariaLabel="At the maximum" />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Step and precision</CardTitle>
|
||||||
|
<CardSubtitle>A fractional <code>step</code> sets how many decimals the value keeps.</CardSubtitle>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
<Stepper value={1.5} min={0} max={10} step={0.25} ariaLabel="Hours" />
|
||||||
|
<Stepper value={500} min={0} step={100} ariaLabel="Amount" style="--tblr-stepper-input-width: 5rem" />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Sizes</CardTitle>
|
||||||
|
<CardSubtitle>Add <code>.form-control-sm</code> or <code>.form-control-lg</code> to the input; the box gets <code>.stepper-sm</code> or <code>-lg</code>.</CardSubtitle>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<Stepper size="sm" value={1} min={0} ariaLabel="Small" />
|
||||||
|
<Stepper value={1} min={0} ariaLabel="Default" />
|
||||||
|
<Stepper size="lg" value={1} min={0} ariaLabel="Large" />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Without bounds</CardTitle>
|
||||||
|
<CardSubtitle>Without <code>min</code> and <code>max</code> the value goes both ways, so a minus sign can be typed too.</CardSubtitle>
|
||||||
|
<Stepper value={0} ariaLabel="Offset" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Native number input</CardTitle>
|
||||||
|
<CardSubtitle>Use <code>type="number"</code> to keep the browser's own validation; the stepper only hides its spin buttons.</CardSubtitle>
|
||||||
|
<Stepper type="number" value={5} min={0} max={10} ariaLabel="Seats" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>No repeat</CardTitle>
|
||||||
|
<CardSubtitle>Add <code>data-bs-repeat="false"</code> to step once per click instead of repeating while the button is held.</CardSubtitle>
|
||||||
|
<Stepper value={1} min={1} max={99} repeat={false} ariaLabel="Quantity, one step per click" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Wider input</CardTitle>
|
||||||
|
<CardSubtitle>Set <code>--tblr-stepper-input-width</code> on the input for longer values.</CardSubtitle>
|
||||||
|
<Stepper value={125000} min={0} step={1000} ariaLabel="Budget" style="--tblr-stepper-input-width: 7rem" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Disabled and readonly</CardTitle>
|
||||||
|
<CardSubtitle>Add <code>disabled</code> or <code>readonly</code> to the input; both buttons follow.</CardSubtitle>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
<Stepper value={3} disabled ariaLabel="Disabled" />
|
||||||
|
<Stepper value={3} readonly ariaLabel="Readonly" />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Validation</CardTitle>
|
||||||
|
<CardSubtitle>Add <code>.is-valid</code> or <code>.is-invalid</code> to the input, like on any <code>.form-control</code>; the feedback text after it still shows.</CardSubtitle>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Stepper class="is-valid" value={2} min={1} max={5} ariaLabel="Valid quantity" />
|
||||||
|
<div class="valid-feedback">In stock, ships today.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Stepper class="is-invalid" value={5} min={1} max={5} ariaLabel="Invalid quantity" />
|
||||||
|
<div class="invalid-feedback">Only 5 pieces left.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Custom buttons</CardTitle>
|
||||||
|
<CardSubtitle>Write the <code>.stepper</code> markup yourself to change the icons or labels; <code>data-bs-stepper-action</code> marks the buttons.</CardSubtitle>
|
||||||
|
<div class="stepper" data-bs-toggle="stepper">
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Fewer"><Icon name="chevron-down" /></button>
|
||||||
|
<input type="text" class="form-control" value="1" min="1" max="99" aria-label="Quantity with custom buttons" />
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="increment" aria-label="More"><Icon name="chevron-up" /></button>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>With a label and hint</CardTitle>
|
||||||
|
<CardSubtitle>A stepper sits in a <code>FormGroup</code> like any other control.</CardSubtitle>
|
||||||
|
<FormGroup label="Number of seats" for="stepper-seats" hint="Up to 8 seats per booking."><Stepper id="stepper-seats" name="seats" value={2} min={1} max={8} /></FormGroup>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 col-lg-8">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>In a cart</CardTitle>
|
||||||
|
<CardSubtitle>The usual place for a stepper: a quantity per line, with <code>max</code> set to the stock.</CardSubtitle>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
cart.map((item) => (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div>{item.name}</div>
|
||||||
|
<div class="text-secondary">{item.code}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">${item.price}</td>
|
||||||
|
<td>
|
||||||
|
<Stepper value={item.quantity} min={1} max={item.max} ariaLabel={`Quantity of ${item.name}`} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-icon btn-ghost-secondary" aria-label={`Remove ${item.name}`}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<ScreenshotLayout title="Stepper" columns={2}>
|
||||||
|
<Card>
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
cart.map((item, index) => (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-medium">{item.name}</div>
|
||||||
|
<div class="text-secondary">{item.code}</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-secondary text-nowrap">{item.price}</td>
|
||||||
|
<td class="w-1">
|
||||||
|
<div class="position-relative">
|
||||||
|
{index === 1 ? (
|
||||||
|
<div class="stepper" data-bs-toggle="stepper">
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Decrease" />
|
||||||
|
<input type="text" class="form-control" value={item.quantity} min="1" max={item.max} aria-label={`Quantity of ${item.name}`} />
|
||||||
|
<button type="button" class="btn btn-icon" data-bs-stepper-action="increment" aria-label="Increase" style="--tblr-btn-bg: var(--tblr-hover-bg)" />
|
||||||
|
<span class="cursor" style="top: 55%; left: 82%" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Stepper value={item.quantity} min={1} max={item.max} ariaLabel={`Quantity of ${item.name}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</ScreenshotLayout>
|
||||||
@@ -400,6 +400,10 @@
|
|||||||
"title": "OTP input",
|
"title": "OTP input",
|
||||||
"url": "/ui/forms/otp-input"
|
"url": "/ui/forms/otp-input"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Stepper",
|
||||||
|
"url": "/ui/forms/stepper"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "CSS variables",
|
"title": "CSS variables",
|
||||||
"url": "/ui/forms/css-variables"
|
"url": "/ui/forms/css-variables"
|
||||||
|
|||||||
@@ -256,6 +256,11 @@
|
|||||||
"url": "steps.html",
|
"url": "steps.html",
|
||||||
"title": "Steps"
|
"title": "Steps"
|
||||||
},
|
},
|
||||||
|
"stepper": {
|
||||||
|
"url": "stepper.html",
|
||||||
|
"title": "Stepper",
|
||||||
|
"badge": "New"
|
||||||
|
},
|
||||||
"tables": {
|
"tables": {
|
||||||
"url": "tables.html",
|
"url": "tables.html",
|
||||||
"title": "Tables"
|
"title": "Tables"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
---
|
||||||
|
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
class:list={inputClass}
|
||||||
|
data-bs-toggle="stepper"
|
||||||
|
data-bs-repeat={repeat === false ? 'false' : undefined}
|
||||||
|
data-bs-decrement-label={decrementLabel}
|
||||||
|
data-bs-increment-label={incrementLabel}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={disabled}
|
||||||
|
readonly={readonly}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
Reference in new issue
Block a user