Rename the Stepper quantity input to NumberInput (#3317)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-10-18 23:44:05 +02:00
1 parent 4aef709014
commit 3f3aae9083
18 files changed
+240 -236

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": minor
---
Added the `NumberInput` component, a `.number-input` quantity input with buttons, bounds, hold-to-repeat and keyboard support.
-7
View File
@@ -1,7 +0,0 @@
---
"@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.
@@ -1,6 +1,6 @@
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* Tabler stepper.ts * Tabler number-input.ts
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
*/ */
@@ -12,7 +12,7 @@ import { initAll } from './bootstrap/util/component-functions'
import { isDisabled } from './bootstrap/util/index' import { isDisabled } from './bootstrap/util/index'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type StepperAction = 'increment' | 'decrement' type NumberInputAction = 'increment' | 'decrement'
type ComponentConfig = { type ComponentConfig = {
/** lower bound; `null` reads the input's `min` attribute, no bound when that is missing too */ /** lower bound; `null` reads the input's `min` attribute, no bound when that is missing too */
@@ -38,23 +38,25 @@ type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
* Constants * Constants
*/ */
const NAME = 'stepper' const NAME = 'numberInput'
// the attribute spelling: `data-bs-toggle="number-input"`, `data-bs-number-input-action`
const DATA_NAME = 'number-input'
const DATA_KEY = `bs.${NAME}` const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}` const EVENT_KEY = `.${DATA_KEY}`
const EVENT_CHANGE = `change${EVENT_KEY}` const EVENT_CHANGE = `change${EVENT_KEY}`
const CLASS_NAME_STEPPER = 'stepper' const CLASS_NAME_NUMBER_INPUT = 'number-input'
const CLASS_NAME_BUTTON = 'btn btn-icon' const CLASS_NAME_BUTTON = 'btn btn-icon'
const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]` const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${DATA_NAME}"], [data-tblr-toggle="${DATA_NAME}"]`
const SELECTOR_INPUT = 'input' const SELECTOR_INPUT = 'input'
const SELECTOR_ACTION = `[data-bs-${NAME}-action], [data-tblr-${NAME}-action]` const SELECTOR_ACTION = `[data-bs-${DATA_NAME}-action], [data-tblr-${DATA_NAME}-action]`
const ATTRIBUTES_ACTION = [`data-bs-${NAME}-action`, `data-tblr-${NAME}-action`] const ATTRIBUTES_ACTION = [`data-bs-${DATA_NAME}-action`, `data-tblr-${DATA_NAME}-action`]
const ATTRIBUTE_ACTION = ATTRIBUTES_ACTION[0] const ATTRIBUTE_ACTION = ATTRIBUTES_ACTION[0]
// `.form-control-sm` on a bare input becomes `.stepper-sm` on the rendered box // `.form-control-sm` on a bare input becomes `.number-input-sm` on the rendered box
const SIZES = ['sm', 'lg'] const SIZES = ['sm', 'lg']
const KEY_UP = 'ArrowUp' const KEY_UP = 'ArrowUp'
@@ -96,12 +98,12 @@ const DefaultType: Record<keyof ComponentConfig, string> = {
* all write to it, clamp to `min` / `max` and round to the precision of * all write to it, clamp to `min` / `max` and round to the precision of
* `step`, so a form sees nothing but an ordinary field. * `step`, so a form sees nothing but an ordinary field.
* *
* Initialised on a bare `<input>`, the plugin wraps it in `.stepper` and * Initialised on a bare `<input>`, the plugin wraps it in `.number-input` and
* renders the two buttons itself; initialised on a `.stepper` wrapper, it * renders the two buttons itself; initialised on a `.number-input` wrapper, it
* uses the buttons found in the markup. * uses the buttons found in the markup.
*/ */
class Stepper extends BaseComponent { class NumberInput extends BaseComponent {
declare _element: HTMLElement declare _element: HTMLElement
declare _config: ComponentConfig declare _config: ComponentConfig
_input: HTMLInputElement | null = null _input: HTMLInputElement | null = null
@@ -217,20 +219,20 @@ class Stepper extends BaseComponent {
} }
// Private // Private
// Puts a bare input into a `.stepper` box with a button on each side. The // Puts a bare input into a `.number-input` box with a button on each side. The
// buttons are empty: the stylesheet paints their icons. // buttons are empty: the stylesheet paints their icons.
_renderWrapper(): void { _renderWrapper(): void {
const input = this._input! const input = this._input!
const wrapper = document.createElement('div') const wrapper = document.createElement('div')
wrapper.className = CLASS_NAME_STEPPER wrapper.className = CLASS_NAME_NUMBER_INPUT
for (const size of SIZES) { for (const size of SIZES) {
if (input.classList.contains(`form-control-${size}`)) { if (input.classList.contains(`form-control-${size}`)) {
wrapper.classList.add(`${CLASS_NAME_STEPPER}-${size}`) wrapper.classList.add(`${CLASS_NAME_NUMBER_INPUT}-${size}`)
} }
} }
const button = (action: StepperAction, label: string): HTMLButtonElement => { const button = (action: NumberInputAction, label: string): HTMLButtonElement => {
const element = document.createElement('button') const element = document.createElement('button')
element.type = 'button' element.type = 'button'
element.className = CLASS_NAME_BUTTON element.className = CLASS_NAME_BUTTON
@@ -311,7 +313,7 @@ class Stepper extends BaseComponent {
} }
} }
_actionOf(button: HTMLElement): StepperAction | null { _actionOf(button: HTMLElement): NumberInputAction | null {
for (const name of ATTRIBUTES_ACTION) { for (const name of ATTRIBUTES_ACTION) {
const action = button.getAttribute(name) const action = button.getAttribute(name)
if (action === 'increment' || action === 'decrement') { if (action === 'increment' || action === 'decrement') {
@@ -370,7 +372,7 @@ class Stepper extends BaseComponent {
} }
} }
_runAction(action: StepperAction | null): void { _runAction(action: NumberInputAction | null): void {
if (action === 'increment') { if (action === 'increment') {
this.increment() this.increment()
} else if (action === 'decrement') { } else if (action === 'decrement') {
@@ -554,8 +556,8 @@ class Stepper extends BaseComponent {
* Data API implementation * Data API implementation
*/ */
// js-docs-start stepper-init // js-docs-start number-input-init
initAll(SELECTOR_DATA_TOGGLE, Stepper) initAll(SELECTOR_DATA_TOGGLE, NumberInput)
// js-docs-end stepper-init // js-docs-end number-input-init
export default Stepper export default NumberInput
+2 -2
View File
@@ -15,7 +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' import './src/number-input'
// 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'
@@ -30,7 +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 NumberInput } from './src/number-input'
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'
@@ -1,17 +1,17 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture' import { clearFixture, getFixture } from '../helpers/fixture'
import Stepper from '../../src/stepper' import NumberInput from '../../src/number-input'
const markup = (inputAttributes = 'value="1" min="0" max="5"', wrapperAttributes = ''): string => const markup = (inputAttributes = 'value="1" min="0" max="5"', wrapperAttributes = ''): string =>
[ [
`<div class="stepper" data-bs-toggle="stepper" ${wrapperAttributes}>`, `<div class="number-input" data-bs-toggle="number-input" ${wrapperAttributes}>`,
' <button type="button" class="btn" data-bs-stepper-action="decrement">-</button>', ' <button type="button" class="btn" data-bs-number-input-action="decrement">-</button>',
` <input type="text" class="form-control" ${inputAttributes} />`, ` <input type="text" class="form-control" ${inputAttributes} />`,
' <button type="button" class="btn" data-bs-stepper-action="increment">+</button>', ' <button type="button" class="btn" data-bs-number-input-action="increment">+</button>',
'</div>', '</div>',
].join('') ].join('')
describe('Stepper', () => { describe('NumberInput', () => {
let fixtureEl: HTMLElement let fixtureEl: HTMLElement
beforeAll(() => { beforeAll(() => {
@@ -27,10 +27,10 @@ describe('Stepper', () => {
vi.useRealTimers() vi.useRealTimers()
}) })
const stepper = (): HTMLElement => fixtureEl.querySelector('.stepper')! const numberInput = (): HTMLElement => fixtureEl.querySelector('.number-input')!
const input = (): HTMLInputElement => fixtureEl.querySelector('input')! const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
const decrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-stepper-action="decrement"]')! const decrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-number-input-action="decrement"]')!
const incrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-stepper-action="increment"]')! const incrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-number-input-action="increment"]')!
const change = (value: string): void => { const change = (value: string): void => {
input().value = value input().value = value
@@ -45,28 +45,28 @@ describe('Stepper', () => {
describe('NAME', () => { describe('NAME', () => {
it('should return the plugin name', () => { it('should return the plugin name', () => {
expect(Stepper.NAME).toBe('stepper') expect(NumberInput.NAME).toBe('numberInput')
}) })
}) })
describe('Default', () => { describe('Default', () => {
it('should expose the default config', () => { it('should expose the default config', () => {
expect(Stepper.Default.min).toBeNull() expect(NumberInput.Default.min).toBeNull()
expect(Stepper.Default.repeat).toBe(true) expect(NumberInput.Default.repeat).toBe(true)
expect(typeof Stepper.DefaultType.step).toBe('string') expect(typeof NumberInput.DefaultType.step).toBe('string')
}) })
}) })
describe('constructor', () => { describe('constructor', () => {
it('should read the bounds from the input attributes', () => { it('should read the bounds from the input attributes', () => {
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
expect(instance._min).toBe(0) expect(instance._min).toBe(0)
expect(instance._max).toBe(5) expect(instance._max).toBe(5)
expect(instance._step).toBe(1) expect(instance._step).toBe(1)
}) })
it('should let the config override the input attributes', () => { it('should let the config override the input attributes', () => {
const instance = new Stepper(stepper(), { min: 2, max: 10, step: 2 }) const instance = new NumberInput(numberInput(), { min: 2, max: 10, step: 2 })
expect(instance._min).toBe(2) expect(instance._min).toBe(2)
expect(instance._max).toBe(10) expect(instance._max).toBe(10)
expect(instance._step).toBe(2) expect(instance._step).toBe(2)
@@ -75,7 +75,7 @@ describe('Stepper', () => {
it('should read the config from data attributes', () => { 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"') fixtureEl.innerHTML = markup('value="1"', 'data-bs-min="1" data-bs-max="3" data-bs-step="0.5"')
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
expect(instance._min).toBe(1) expect(instance._min).toBe(1)
expect(instance._max).toBe(3) expect(instance._max).toBe(3)
expect(instance._step).toBe(0.5) expect(instance._step).toBe(0.5)
@@ -83,24 +83,24 @@ describe('Stepper', () => {
it('should have no bounds when neither attributes nor config set them', () => { it('should have no bounds when neither attributes nor config set them', () => {
fixtureEl.innerHTML = markup('value="1"') fixtureEl.innerHTML = markup('value="1"')
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
expect(instance._min).toBe(Number.NEGATIVE_INFINITY) expect(instance._min).toBe(Number.NEGATIVE_INFINITY)
expect(instance._max).toBe(Number.POSITIVE_INFINITY) expect(instance._max).toBe(Number.POSITIVE_INFINITY)
}) })
it('should fall back to a step of 1 for a zero or negative step', () => { it('should fall back to a step of 1 for a zero or negative step', () => {
fixtureEl.innerHTML = markup('value="1" step="0"') fixtureEl.innerHTML = markup('value="1" step="0"')
expect(new Stepper(stepper())._step).toBe(1) expect(new NumberInput(numberInput())._step).toBe(1)
}) })
it('should clamp the initial value', () => { it('should clamp the initial value', () => {
fixtureEl.innerHTML = markup('value="9" min="0" max="5"') fixtureEl.innerHTML = markup('value="9" min="0" max="5"')
new Stepper(stepper()) new NumberInput(numberInput())
expect(input().value).toBe('5') expect(input().value).toBe('5')
}) })
it('should give a text input the spinbutton role and a numeric inputmode', () => { it('should give a text input the spinbutton role and a numeric inputmode', () => {
new Stepper(stepper()) new NumberInput(numberInput())
expect(input().getAttribute('role')).toBe('spinbutton') expect(input().getAttribute('role')).toBe('spinbutton')
expect(input().getAttribute('inputmode')).toBe('numeric') expect(input().getAttribute('inputmode')).toBe('numeric')
expect(input().getAttribute('aria-valuemin')).toBe('0') expect(input().getAttribute('aria-valuemin')).toBe('0')
@@ -110,27 +110,27 @@ describe('Stepper', () => {
it('should use the decimal inputmode for a fractional step', () => { it('should use the decimal inputmode for a fractional step', () => {
fixtureEl.innerHTML = markup('value="1" step="0.25"') fixtureEl.innerHTML = markup('value="1" step="0.25"')
new Stepper(stepper()) new NumberInput(numberInput())
expect(input().getAttribute('inputmode')).toBe('decimal') expect(input().getAttribute('inputmode')).toBe('decimal')
}) })
it('should leave a number input alone', () => { it('should leave a number input alone', () => {
fixtureEl.innerHTML = markup('type="number" value="1" min="0" max="5"').replace('type="text"', '') fixtureEl.innerHTML = markup('type="number" value="1" min="0" max="5"').replace('type="text"', '')
new Stepper(stepper()) new NumberInput(numberInput())
expect(input().hasAttribute('role')).toBe(false) expect(input().hasAttribute('role')).toBe(false)
expect(input().hasAttribute('aria-valuenow')).toBe(false) expect(input().hasAttribute('aria-valuenow')).toBe(false)
}) })
it('should do nothing without an input', () => { it('should do nothing without an input', () => {
fixtureEl.innerHTML = '<div class="stepper"></div>' fixtureEl.innerHTML = '<div class="number-input"></div>'
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
expect(instance._input).toBeNull() expect(instance._input).toBeNull()
expect(instance.getValue()).toBeNull() expect(instance.getValue()).toBeNull()
}) })
it('should disable the buttons of a disabled input', () => { it('should disable the buttons of a disabled input', () => {
fixtureEl.innerHTML = markup('value="1" disabled') fixtureEl.innerHTML = markup('value="1" disabled')
new Stepper(stepper()) new NumberInput(numberInput())
expect(decrementButton().disabled).toBe(true) expect(decrementButton().disabled).toBe(true)
expect(incrementButton().disabled).toBe(true) expect(incrementButton().disabled).toBe(true)
}) })
@@ -138,7 +138,7 @@ describe('Stepper', () => {
describe('buttons', () => { describe('buttons', () => {
it('should step the value on click', () => { it('should step the value on click', () => {
new Stepper(stepper()) new NumberInput(numberInput())
incrementButton().click() incrementButton().click()
expect(input().value).toBe('2') expect(input().value).toBe('2')
decrementButton().click() decrementButton().click()
@@ -148,7 +148,7 @@ describe('Stepper', () => {
it('should disable the button at a bound', () => { it('should disable the button at a bound', () => {
fixtureEl.innerHTML = markup('value="4" min="0" max="5"') fixtureEl.innerHTML = markup('value="4" min="0" max="5"')
new Stepper(stepper()) new NumberInput(numberInput())
expect(incrementButton().disabled).toBe(false) expect(incrementButton().disabled).toBe(false)
incrementButton().click() incrementButton().click()
expect(input().value).toBe('5') expect(input().value).toBe('5')
@@ -157,7 +157,7 @@ describe('Stepper', () => {
}) })
it('should never leave the bounds', () => { it('should never leave the bounds', () => {
const instance = new Stepper(stepper(), { max: 2 }) const instance = new NumberInput(numberInput(), { max: 2 })
instance.increment() instance.increment()
instance.increment() instance.increment()
instance.increment() instance.increment()
@@ -166,7 +166,7 @@ describe('Stepper', () => {
it('should keep stepping while a button is held', () => { it('should keep stepping while a button is held', () => {
vi.useFakeTimers() vi.useFakeTimers()
new Stepper(stepper(), { max: 100 }) new NumberInput(numberInput(), { max: 100 })
incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }))
vi.advanceTimersByTime(400) vi.advanceTimersByTime(400)
@@ -185,7 +185,7 @@ describe('Stepper', () => {
it('should stop a hold at a bound', () => { it('should stop a hold at a bound', () => {
vi.useFakeTimers() vi.useFakeTimers()
const instance = new Stepper(stepper(), { max: 3 }) const instance = new NumberInput(numberInput(), { max: 3 })
incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }))
vi.advanceTimersByTime(400 + 80 * 5) vi.advanceTimersByTime(400 + 80 * 5)
@@ -195,7 +195,7 @@ describe('Stepper', () => {
it('should not repeat when the option is off', () => { it('should not repeat when the option is off', () => {
vi.useFakeTimers() vi.useFakeTimers()
new Stepper(stepper(), { repeat: false, max: 100 }) new NumberInput(numberInput(), { repeat: false, max: 100 })
incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }))
vi.advanceTimersByTime(2000) vi.advanceTimersByTime(2000)
@@ -205,7 +205,7 @@ describe('Stepper', () => {
describe('keyboard', () => { describe('keyboard', () => {
it('should step with the arrow keys and jump with Home and End', () => { it('should step with the arrow keys and jump with Home and End', () => {
new Stepper(stepper()) new NumberInput(numberInput())
expect(keydown('ArrowUp').defaultPrevented).toBe(true) expect(keydown('ArrowUp').defaultPrevented).toBe(true)
expect(input().value).toBe('2') expect(input().value).toBe('2')
keydown('ArrowDown') keydown('ArrowDown')
@@ -218,34 +218,34 @@ describe('Stepper', () => {
it('should ignore Home and End without a bound', () => { it('should ignore Home and End without a bound', () => {
fixtureEl.innerHTML = markup('value="1"') fixtureEl.innerHTML = markup('value="1"')
new Stepper(stepper()) new NumberInput(numberInput())
expect(keydown('End').defaultPrevented).toBe(false) expect(keydown('End').defaultPrevented).toBe(false)
expect(input().value).toBe('1') expect(input().value).toBe('1')
}) })
it('should leave other keys alone', () => { it('should leave other keys alone', () => {
new Stepper(stepper()) new NumberInput(numberInput())
expect(keydown('a').defaultPrevented).toBe(false) expect(keydown('a').defaultPrevented).toBe(false)
}) })
}) })
describe('typed value', () => { describe('typed value', () => {
it('should clamp a typed value on change', () => { it('should clamp a typed value on change', () => {
new Stepper(stepper()) new NumberInput(numberInput())
change('42') change('42')
expect(input().value).toBe('5') expect(input().value).toBe('5')
expect(incrementButton().disabled).toBe(true) expect(incrementButton().disabled).toBe(true)
}) })
it('should clamp on blur as well', () => { it('should clamp on blur as well', () => {
new Stepper(stepper()) new NumberInput(numberInput())
input().value = '125' input().value = '125'
input().dispatchEvent(new Event('blur')) input().dispatchEvent(new Event('blur'))
expect(input().value).toBe('5') expect(input().value).toBe('5')
}) })
it('should fire nothing when an untouched field is left', () => { it('should fire nothing when an untouched field is left', () => {
new Stepper(stepper()) new NumberInput(numberInput())
const spy = vi.fn() const spy = vi.fn()
input().addEventListener('change', spy) input().addEventListener('change', spy)
input().dispatchEvent(new Event('blur')) input().dispatchEvent(new Event('blur'))
@@ -253,7 +253,7 @@ describe('Stepper', () => {
}) })
it('should restore the previous value after a non-numeric edit', () => { it('should restore the previous value after a non-numeric edit', () => {
new Stepper(stepper()) new NumberInput(numberInput())
change('3') change('3')
change('abc') change('abc')
expect(input().value).toBe('3') expect(input().value).toBe('3')
@@ -261,7 +261,7 @@ describe('Stepper', () => {
it('should accept a decimal comma', () => { it('should accept a decimal comma', () => {
fixtureEl.innerHTML = markup('value="1" step="0.5"') fixtureEl.innerHTML = markup('value="1" step="0.5"')
new Stepper(stepper()) new NumberInput(numberInput())
change('2,5') change('2,5')
expect(input().value).toBe('2.5') expect(input().value).toBe('2.5')
}) })
@@ -275,7 +275,7 @@ describe('Stepper', () => {
} }
it('should drop a typed letter and keep a digit', () => { it('should drop a typed letter and keep a digit', () => {
new Stepper(stepper()) new NumberInput(numberInput())
expect(beforeInput('a').defaultPrevented).toBe(true) expect(beforeInput('a').defaultPrevented).toBe(true)
expect(beforeInput('7').defaultPrevented).toBe(false) expect(beforeInput('7').defaultPrevented).toBe(false)
expect(beforeInput('.').defaultPrevented).toBe(true) expect(beforeInput('.').defaultPrevented).toBe(true)
@@ -284,7 +284,7 @@ describe('Stepper', () => {
it('should allow the sign and the separator when the bounds and step need them', () => { 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"') fixtureEl.innerHTML = markup('value="0" min="-5" step="0.5"')
new Stepper(stepper()) new NumberInput(numberInput())
expect(beforeInput('-').defaultPrevented).toBe(false) expect(beforeInput('-').defaultPrevented).toBe(false)
expect(beforeInput('.').defaultPrevented).toBe(false) expect(beforeInput('.').defaultPrevented).toBe(false)
expect(beforeInput(',').defaultPrevented).toBe(false) expect(beforeInput(',').defaultPrevented).toBe(false)
@@ -292,7 +292,7 @@ describe('Stepper', () => {
}) })
it('should keep only the digits of a pasted text', () => { it('should keep only the digits of a pasted text', () => {
new Stepper(stepper()) new NumberInput(numberInput())
input().value = '' input().value = ''
input().setSelectionRange(0, 0) input().setSelectionRange(0, 0)
expect(beforeInput('4 pcs', 'insertFromPaste').defaultPrevented).toBe(true) expect(beforeInput('4 pcs', 'insertFromPaste').defaultPrevented).toBe(true)
@@ -300,7 +300,7 @@ describe('Stepper', () => {
}) })
it('should leave deletions alone', () => { it('should leave deletions alone', () => {
new Stepper(stepper()) new NumberInput(numberInput())
expect(beforeInput('', 'deleteContentBackward').defaultPrevented).toBe(false) expect(beforeInput('', 'deleteContentBackward').defaultPrevented).toBe(false)
}) })
}) })
@@ -308,7 +308,7 @@ describe('Stepper', () => {
describe('precision', () => { describe('precision', () => {
it('should round to the precision of the step', () => { it('should round to the precision of the step', () => {
fixtureEl.innerHTML = markup('value="0.1" step="0.1"') fixtureEl.innerHTML = markup('value="0.1" step="0.1"')
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
instance.increment() instance.increment()
instance.increment() instance.increment()
expect(input().value).toBe('0.3') expect(input().value).toBe('0.3')
@@ -317,7 +317,7 @@ describe('Stepper', () => {
it('should step an empty field from the lower bound', () => { it('should step an empty field from the lower bound', () => {
fixtureEl.innerHTML = markup('value="" min="2" max="9"') fixtureEl.innerHTML = markup('value="" min="2" max="9"')
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
expect(instance.getValue()).toBeNull() expect(instance.getValue()).toBeNull()
expect(input().hasAttribute('aria-valuenow')).toBe(false) expect(input().hasAttribute('aria-valuenow')).toBe(false)
instance.increment() instance.increment()
@@ -326,10 +326,10 @@ describe('Stepper', () => {
}) })
describe('events', () => { describe('events', () => {
it('should fire change.bs.stepper with the values', () => { it('should fire change.bs.numberInput with the values', () => {
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
const spy = vi.fn() const spy = vi.fn()
stepper().addEventListener('change.bs.stepper', spy) numberInput().addEventListener('change.bs.numberInput', spy)
instance.increment() instance.increment()
expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledTimes(1)
@@ -339,7 +339,7 @@ describe('Stepper', () => {
}) })
it('should fire input and change on the field', () => { it('should fire input and change on the field', () => {
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
const onInput = vi.fn() const onInput = vi.fn()
const onChange = vi.fn() const onChange = vi.fn()
input().addEventListener('input', onInput) input().addEventListener('input', onInput)
@@ -351,9 +351,9 @@ describe('Stepper', () => {
}) })
it('should not fire when the value does not change', () => { it('should not fire when the value does not change', () => {
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
const spy = vi.fn() const spy = vi.fn()
stepper().addEventListener('change.bs.stepper', spy) numberInput().addEventListener('change.bs.numberInput', spy)
instance.setValue(1) instance.setValue(1)
instance.decrement() instance.decrement()
@@ -364,7 +364,7 @@ describe('Stepper', () => {
describe('setValue', () => { describe('setValue', () => {
it('should set and clamp the value', () => { it('should set and clamp the value', () => {
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
instance.setValue(3) instance.setValue(3)
expect(input().value).toBe('3') expect(input().value).toBe('3')
instance.setValue('99') instance.setValue('99')
@@ -377,7 +377,7 @@ describe('Stepper', () => {
describe('disabled', () => { describe('disabled', () => {
it('should not step a disabled or readonly input', () => { it('should not step a disabled or readonly input', () => {
fixtureEl.innerHTML = markup('value="1" readonly') fixtureEl.innerHTML = markup('value="1" readonly')
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
instance.increment() instance.increment()
expect(input().value).toBe('1') expect(input().value).toBe('1')
expect(incrementButton().disabled).toBe(true) expect(incrementButton().disabled).toBe(true)
@@ -386,26 +386,26 @@ describe('Stepper', () => {
describe('dispose', () => { describe('dispose', () => {
it('should remove the listeners and the instance', () => { it('should remove the listeners and the instance', () => {
const instance = new Stepper(stepper()) const instance = new NumberInput(numberInput())
instance.dispose() instance.dispose()
incrementButton().click() incrementButton().click()
expect(input().value).toBe('1') expect(input().value).toBe('1')
expect(Stepper.getInstance(stepper())).toBeNull() expect(NumberInput.getInstance(numberInput())).toBeNull()
}) })
}) })
describe('bare input', () => { describe('bare input', () => {
const bare = (attributes = 'value="1" min="0" max="5"'): HTMLInputElement => { 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>` fixtureEl.innerHTML = `<form><input type="text" class="form-control" data-bs-toggle="number-input" ${attributes} /></form>`
return fixtureEl.querySelector('input')! return fixtureEl.querySelector('input')!
} }
it('should wrap the input and render the two buttons', () => { it('should wrap the input and render the two buttons', () => {
const el = bare() const el = bare()
new Stepper(el) new NumberInput(el)
const wrapper = el.parentElement! const wrapper = el.parentElement!
expect(wrapper.classList.contains('stepper')).toBe(true) expect(wrapper.classList.contains('number-input')).toBe(true)
expect(wrapper.parentElement!.tagName).toBe('FORM') expect(wrapper.parentElement!.tagName).toBe('FORM')
expect([...wrapper.children].map((child) => child.tagName)).toEqual(['BUTTON', 'INPUT', 'BUTTON']) expect([...wrapper.children].map((child) => child.tagName)).toEqual(['BUTTON', 'INPUT', 'BUTTON'])
expect(decrementButton().getAttribute('aria-label')).toBe('Decrease') expect(decrementButton().getAttribute('aria-label')).toBe('Decrease')
@@ -416,14 +416,14 @@ describe('Stepper', () => {
it('should step through the rendered buttons', () => { it('should step through the rendered buttons', () => {
const el = bare() const el = bare()
new Stepper(el) new NumberInput(el)
incrementButton().click() incrementButton().click()
expect(el.value).toBe('2') expect(el.value).toBe('2')
}) })
it('should read the config from the input and label the buttons', () => { 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"') const el = bare('value="1" data-bs-max="2" data-bs-increment-label="More" data-bs-decrement-label="Fewer"')
new Stepper(el) new NumberInput(el)
expect(incrementButton().getAttribute('aria-label')).toBe('More') expect(incrementButton().getAttribute('aria-label')).toBe('More')
expect(decrementButton().getAttribute('aria-label')).toBe('Fewer') expect(decrementButton().getAttribute('aria-label')).toBe('Fewer')
incrementButton().click() incrementButton().click()
@@ -432,41 +432,41 @@ describe('Stepper', () => {
}) })
it('should map the size of the input to the box', () => { 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" />' fixtureEl.innerHTML = '<input type="text" class="form-control form-control-sm" data-bs-toggle="number-input" value="1" />'
const el = fixtureEl.querySelector('input')! const el = fixtureEl.querySelector('input')!
new Stepper(el) new NumberInput(el)
expect(el.parentElement!.classList.contains('stepper-sm')).toBe(true) expect(el.parentElement!.classList.contains('number-input-sm')).toBe(true)
}) })
it('should fire change.bs.stepper on the input', () => { it('should fire change.bs.numberInput on the input', () => {
const el = bare() const el = bare()
const instance = new Stepper(el) const instance = new NumberInput(el)
const spy = vi.fn() const spy = vi.fn()
el.addEventListener('change.bs.stepper', spy) el.addEventListener('change.bs.numberInput', spy)
instance.increment() instance.increment()
expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledTimes(1)
}) })
it('should unwrap the input on dispose', () => { it('should unwrap the input on dispose', () => {
const el = bare() const el = bare()
new Stepper(el).dispose() new NumberInput(el).dispose()
expect(el.parentElement!.tagName).toBe('FORM') expect(el.parentElement!.tagName).toBe('FORM')
expect(fixtureEl.querySelector('.stepper')).toBeNull() expect(fixtureEl.querySelector('.number-input')).toBeNull()
expect(fixtureEl.querySelectorAll('button')).toHaveLength(0) expect(fixtureEl.querySelectorAll('button')).toHaveLength(0)
}) })
}) })
describe('data-api', () => { describe('data-api', () => {
it('should initialise on getOrCreateInstance', () => { it('should initialise on getOrCreateInstance', () => {
const instance = Stepper.getOrCreateInstance(stepper()) const instance = NumberInput.getOrCreateInstance(numberInput())
expect(instance).toBeInstanceOf(Stepper) expect(instance).toBeInstanceOf(NumberInput)
expect(Stepper.getOrCreateInstance(stepper())).toBe(instance) expect(NumberInput.getOrCreateInstance(numberInput())).toBe(instance)
}) })
it('should accept the data-tblr-* action attributes', () => { it('should accept the data-tblr-* action attributes', () => {
fixtureEl.innerHTML = markup().replaceAll('data-bs-stepper-action', 'data-tblr-stepper-action') fixtureEl.innerHTML = markup().replaceAll('data-bs-number-input-action', 'data-tblr-number-input-action')
new Stepper(stepper()) new NumberInput(numberInput())
fixtureEl.querySelector<HTMLButtonElement>('[data-tblr-stepper-action="increment"]')!.click() fixtureEl.querySelector<HTMLButtonElement>('[data-tblr-number-input-action="increment"]')!.click()
expect(input().value).toBe('2') expect(input().value).toBe('2')
}) })
}) })
+1 -1
View File
@@ -69,7 +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/form-number-input';
@forward 'ui/forms/validation'; @forward 'ui/forms/validation';
@forward 'ui/forms/strength'; @forward 'ui/forms/strength';
@forward 'ui/legend'; @forward 'ui/legend';
+11 -11
View File
@@ -1226,24 +1226,24 @@ $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 // NumberInput
// scss-docs-start stepper-variables // scss-docs-start number-input-variables
$stepper-input-width: 3.5rem !default; $number-input-input-width: 3.5rem !default;
$stepper-input-widths: ( $number-input-input-widths: (
'sm': 3rem, 'sm': 3rem,
'lg': 4rem, 'lg': 4rem,
) !default; ) !default;
$stepper-icon-sizes: ( $number-input-icon-sizes: (
'sm': $input-btn-icon-size-sm, 'sm': $input-btn-icon-size-sm,
'lg': $input-btn-icon-size-lg, 'lg': $input-btn-icon-size-lg,
) !default; ) !default;
$stepper-bg: $input-bg !default; $number-input-bg: $input-bg !default;
$stepper-border-color: var(--border-color) !default; $number-input-border-color: var(--border-color) !default;
$stepper-border-radius: $input-border-radius !default; $number-input-border-radius: $input-border-radius !default;
// Monochrome icons, painted with `currentColor` through `mask-icon()` on an empty button. // 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; $number-input-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; $number-input-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 // scss-docs-end number-input-variables
// Cards // Cards
// scss-docs-start card-variables // scss-docs-start card-variables
@@ -1,28 +1,28 @@
/* /*
Stepper (number input with buttons) Number input (a field with a decrease and an increase button)
*/ */
@use 'sass:map'; @use 'sass:map';
@use '../../config' as *; @use '../../config' as *;
// One bordered box, like a `.form-control`: the field in the middle and the // One bordered box, like a `.form-control`: the field in the middle and the
// two buttons inside it have no border of their own. // two buttons inside it have no border of their own.
.stepper { .number-input {
// scss-docs-start stepper-css-vars // scss-docs-start number-input-css-vars
--stepper-input-width: #{$stepper-input-width}; --number-input-input-width: #{$number-input-input-width};
--stepper-bg: #{$stepper-bg}; --number-input-bg: #{$number-input-bg};
--stepper-border-color: #{$stepper-border-color}; --number-input-border-color: #{$number-input-border-color};
--stepper-border-radius: #{$stepper-border-radius}; --number-input-border-radius: #{$number-input-border-radius};
// scss-docs-end stepper-css-vars // scss-docs-end number-input-css-vars
position: relative; position: relative;
display: inline-flex; display: inline-flex;
align-items: stretch; align-items: stretch;
// Never stretched by a flex column parent: the box is as wide as its parts // Never stretched by a flex column parent: the box is as wide as its parts
width: fit-content; width: fit-content;
vertical-align: middle; vertical-align: middle;
background-color: var(--stepper-bg); background-color: var(--number-input-bg);
border: $input-border-width solid var(--stepper-border-color); border: $input-border-width solid var(--number-input-border-color);
box-shadow: $input-box-shadow; box-shadow: $input-box-shadow;
@include border-radius(var(--stepper-border-radius)); @include border-radius(var(--number-input-border-radius));
@include focus-ring-transition(border-color); @include focus-ring-transition(border-color);
// The box carries the focus ring for the field inside it. // The box carries the focus ring for the field inside it.
@@ -40,7 +40,7 @@ Stepper (number input with buttons)
// stylelint-disable-next-line length-zero-no-unit // stylelint-disable-next-line length-zero-no-unit
--control-border-width: 0px; --control-border-width: 0px;
flex: 0 0 auto; flex: 0 0 auto;
width: var(--stepper-input-width); width: var(--number-input-input-width);
min-width: 0; min-width: 0;
padding-inline: 0.25rem; padding-inline: 0.25rem;
text-align: center; text-align: center;
@@ -51,7 +51,7 @@ Stepper (number input with buttons)
box-shadow: none; box-shadow: none;
// A `type="number"` field keeps its keyboard behaviour but loses the // A `type="number"` field keeps its keyboard behaviour but loses the
// native spin buttons, which the stepper's own buttons replace. // native spin buttons, which the number input's own buttons replace.
&::-webkit-inner-spin-button, &::-webkit-inner-spin-button,
&::-webkit-outer-spin-button { &::-webkit-outer-spin-button {
margin: 0; margin: 0;
@@ -95,14 +95,14 @@ Stepper (number input with buttons)
// An empty button paints the default icon through a CSS mask, so the // An empty button paints the default icon through a CSS mask, so the
// markup needs no svg; any content put inside replaces it. // markup needs no svg; any content put inside replaces it.
&[data-bs-stepper-action='decrement'], &[data-bs-number-input-action='decrement'],
&[data-tblr-stepper-action='decrement'] { &[data-tblr-number-input-action='decrement'] {
--stepper-icon: #{escape-svg($stepper-icon-decrement)}; --number-input-icon: #{escape-svg($number-input-icon-decrement)};
} }
&[data-bs-stepper-action='increment'], &[data-bs-number-input-action='increment'],
&[data-tblr-stepper-action='increment'] { &[data-tblr-number-input-action='increment'] {
--stepper-icon: #{escape-svg($stepper-icon-increment)}; --number-input-icon: #{escape-svg($number-input-icon-increment)};
} }
&:not(:has(> *))::before { &:not(:has(> *))::before {
@@ -110,15 +110,15 @@ Stepper (number input with buttons)
height: var(--btn-icon-size); height: var(--btn-icon-size);
content: ''; content: '';
background-color: currentColor; background-color: currentColor;
@include mask-icon(var(--stepper-icon), $size: 100%); @include mask-icon(var(--number-input-icon), $size: 100%);
} }
&:first-child { &:first-child {
@include border-start-radius(calc(var(--stepper-border-radius) - #{$input-border-width})); @include border-start-radius(calc(var(--number-input-border-radius) - #{$input-border-width}));
} }
&:last-child { &:last-child {
@include border-end-radius(calc(var(--stepper-border-radius) - #{$input-border-width})); @include border-end-radius(calc(var(--number-input-border-radius) - #{$input-border-width}));
} }
} }
@@ -144,9 +144,9 @@ Stepper (number input with buttons)
// Sizes move the same tokens `.form-control-sm` / `.btn-sm` and their `-lg` // Sizes move the same tokens `.form-control-sm` / `.btn-sm` and their `-lg`
// twins read, so the field and the buttons keep one height. // twins read, so the field and the buttons keep one height.
@each $size in (sm, lg) { @each $size in (sm, lg) {
.stepper-#{$size} { .number-input-#{$size} {
--stepper-input-width: #{map.get($stepper-input-widths, $size)}; --number-input-input-width: #{map.get($number-input-input-widths, $size)};
--stepper-border-radius: var(--btn-input-#{$size}-border-radius); --number-input-border-radius: var(--btn-input-#{$size}-border-radius);
> .form-control { > .form-control {
--control-padding-y: var(--btn-input-#{$size}-padding-y); --control-padding-y: var(--btn-input-#{$size}-padding-y);
@@ -158,7 +158,7 @@ Stepper (number input with buttons)
--btn-padding-y: var(--btn-input-#{$size}-padding-y); --btn-padding-y: var(--btn-input-#{$size}-padding-y);
--btn-font-size: var(--btn-input-#{$size}-font-size); --btn-font-size: var(--btn-input-#{$size}-font-size);
--btn-line-height: var(--btn-input-#{$size}-line-height); --btn-line-height: var(--btn-input-#{$size}-line-height);
--btn-icon-size: #{map.get($stepper-icon-sizes, $size)}; --btn-icon-size: #{map.get($number-input-icon-sizes, $size)};
} }
} }
} }
@@ -1,20 +1,20 @@
--- ---
title: Stepper title: Number input
seoTitle: Stepper (quantity input) seoTitle: Number input (quantity stepper)
order: 14 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. 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. description: Add a number input with min, max and step, hold-to-repeat buttons, arrow key support and change events.
related: [/ui/forms/elements, /ui/forms/validation] related: [/ui/forms/elements, /ui/forms/validation]
source: core/scss/ui/forms/_form-stepper.scss source: core/scss/ui/forms/_form-number-input.scss
css-vars: stepper css-vars: number-input
classnames: classnames:
component: component:
- class: stepper - class: number-input
desc: The box around the field and the two buttons; rendered by the plugin, or written by hand for custom buttons desc: The box around the field and the two buttons; rendered by the plugin, or written by hand for custom buttons
size: size:
- class: stepper-sm - class: number-input-sm
desc: Smaller field and buttons desc: Smaller field and buttons
- class: stepper-lg - class: number-input-lg
desc: Larger field and buttons desc: Larger field and buttons
--- ---
@@ -24,12 +24,12 @@ import Icon from '@ui/Icon.astro'
## Overview ## 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`. A number input 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. Add `data-bs-toggle="number-input"` to the input and Tabler does the rest: it wraps the field in a `.number-input` box and renders the two buttons. The bounds come from the input's own `min`, `max` and `step` attributes.
<Example column> <Example column>
<input type="text" class="form-control" value="1" min="1" max="99" aria-label="Quantity" data-bs-toggle="stepper" /> <input type="text" class="form-control" value="1" min="1" max="99" aria-label="Quantity" data-bs-toggle="number-input" />
</Example> </Example>
## Usage ## Usage
@@ -40,17 +40,17 @@ The button at a bound goes disabled, so the user sees when the value cannot go f
once the field is left. A fractional `step` sets the precision: with `step="0.25"` the value is always shown with two decimals. 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> <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" /> <input type="text" class="form-control" value="1.5" min="0" max="10" step="0.25" aria-label="Hours" data-bs-toggle="number-input" />
</Example> </Example>
Without `min` and `max` the value has no bounds. An empty field steps from `min` when that is positive, otherwise from zero. 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 ### 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. 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 number input only hides its spin buttons.
<Example column> <Example column>
<input type="number" class="form-control" value="5" min="0" max="10" aria-label="Seats" data-bs-toggle="stepper" /> <input type="number" class="form-control" value="5" min="0" max="10" aria-label="Seats" data-bs-toggle="number-input" />
</Example> </Example>
### Keyboard ### Keyboard
@@ -59,24 +59,24 @@ With the focus in the field, <kbd>↑</kbd> and <kbd>↓</kbd> step the value, <
### Sizes ### 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. Add `.form-control-sm` or `.form-control-lg` to the input; the rendered box gets `.number-input-sm` or `.number-input-lg`, so the field and the buttons keep one height.
<Example column> <Example column>
<input type="text" class="form-control form-control-sm" value="1" min="0" aria-label="Small" data-bs-toggle="stepper" /> <input type="text" class="form-control form-control-sm" value="1" min="0" aria-label="Small" data-bs-toggle="number-input" />
<div class="stepper " data-bs-toggle="stepper"> <div class="number-input" data-bs-toggle="number-input">
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Decrease"></button> <button type="button" class="btn btn-icon" data-bs-number-input-action="decrement" aria-label="Decrease"></button>
<input type="text" class="form-control" value="1" min="0" aria-label="Default" /> <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> <button type="button" class="btn btn-icon" data-bs-number-input-action="increment" aria-label="Increase"></button>
</div> </div>
<input type="text" class="form-control form-control-lg" value="1" min="0" aria-label="Large" data-bs-toggle="stepper" /> <input type="text" class="form-control form-control-lg" value="1" min="0" aria-label="Large" data-bs-toggle="number-input" />
</Example> </Example>
### Wider input ### 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. The input is `3.5rem` wide by default, enough for a quantity. Set `--tblr-number-input-input-width` on the input for longer values.
<Example column> <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" /> <input type="text" class="form-control" value="1000" min="0" step="100" aria-label="Amount" data-bs-toggle="number-input" style="--tblr-number-input-input-width: 6rem" />
</Example> </Example>
### Disabled ### Disabled
@@ -84,7 +84,7 @@ The input is `3.5rem` wide by default, enough for a quantity. Set `--tblr-steppe
Add `disabled` or `readonly` to the input; both buttons follow. Add `disabled` or `readonly` to the input; both buttons follow.
<Example column> <Example column>
<input type="text" class="form-control" value="3" disabled aria-label="Quantity" data-bs-toggle="stepper" /> <input type="text" class="form-control" value="3" disabled aria-label="Quantity" data-bs-toggle="number-input" />
</Example> </Example>
### Validation ### Validation
@@ -93,11 +93,11 @@ Add `.is-valid` or `.is-invalid` to the input, the same way you would on any `.f
<Example column> <Example column>
<div> <div>
<input type="text" class="form-control is-valid" value="2" min="1" max="5" aria-label="Quantity" data-bs-toggle="stepper" /> <input type="text" class="form-control is-valid" value="2" min="1" max="5" aria-label="Quantity" data-bs-toggle="number-input" />
<div class="valid-feedback">In stock, ships today.</div> <div class="valid-feedback">In stock, ships today.</div>
</div> </div>
<div> <div>
<input type="text" class="form-control is-invalid" value="5" min="1" max="5" aria-label="Quantity" data-bs-toggle="stepper" /> <input type="text" class="form-control is-invalid" value="5" min="1" max="5" aria-label="Quantity" data-bs-toggle="number-input" />
<div class="invalid-feedback">Only 5 pieces left.</div> <div class="invalid-feedback">Only 5 pieces left.</div>
</div> </div>
</Example> </Example>
@@ -107,10 +107,10 @@ With a `type="number"` input the browser validates `required` itself (the plugin
<Example column> <Example column>
<form class="was-validated" novalidate> <form class="was-validated" novalidate>
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="stepper-seats"> <label class="form-label" for="number-input-seats">
Seats Seats
</label> </label>
<input type="number" class="form-control" id="stepper-seats" value="" min="1" max="10" required data-bs-toggle="stepper" /> <input type="number" class="form-control" id="number-input-seats" value="" min="1" max="10" required data-bs-toggle="number-input" />
<div class="invalid-feedback">Pick the number of seats.</div> <div class="invalid-feedback">Pick the number of seats.</div>
</div> </div>
</form> </form>
@@ -118,15 +118,15 @@ With a `type="number"` input the browser validates `required` itself (the plugin
### Custom buttons ### 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. To change the icons, the labels or the classes of the buttons, write the markup yourself: put `data-bs-toggle="number-input"` on a `.number-input` wrapper and mark the buttons with `data-bs-number-input-action="decrement"` and `"increment"`. An empty button still gets the default icon from CSS; any content inside replaces it.
<Example column> <Example column>
<div class="stepper" data-bs-toggle="stepper"> <div class="number-input" data-bs-toggle="number-input">
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Fewer"> <button type="button" class="btn btn-icon" data-bs-number-input-action="decrement" aria-label="Fewer">
<Icon name="chevron-down" /> <Icon name="chevron-down" />
</button> </button>
<input type="text" class="form-control" value="1" min="1" max="99" aria-label="Quantity" /> <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"> <button type="button" class="btn btn-icon" data-bs-number-input-action="increment" aria-label="More">
<Icon name="chevron-up" /> <Icon name="chevron-up" />
</button> </button>
</div> </div>
@@ -149,21 +149,21 @@ Options are read from `data-bs-*` attributes on the element that carries `data-b
## JavaScript ## JavaScript
Tabler creates a `Stepper` for every element matching `[data-bs-toggle="stepper"]` (or `[data-tblr-toggle="stepper"]`), an input or a `.stepper` wrapper: Tabler creates a `NumberInput` for every element matching `[data-bs-toggle="number-input"]` (or `[data-tblr-toggle="number-input"]`), an input or a `.number-input` wrapper:
<CodeDocs name="stepper-init" file="core/js/src/stepper.ts" /> <CodeDocs name="number-input-init" file="core/js/src/number-input.ts" />
Create one manually, or get an existing instance: Create one manually, or get an existing instance:
```javascript ```javascript
const el = document.querySelector('#quantity') const el = document.querySelector('#quantity')
const stepper = tabler.Stepper.getOrCreateInstance(el, { min: 1, max: 10 }) const numberInput = tabler.NumberInput.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: 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.numberInput` fires on the element the plugin was created on with both values:
```javascript ```javascript
el.addEventListener('change.bs.stepper', (event) => { el.addEventListener('change.bs.numberInput', (event) => {
console.log(event.value, event.previousValue) console.log(event.value, event.previousValue)
}) })
``` ```
+2
View File
@@ -43,6 +43,8 @@ export const redirects: Record<string, Redirect> = {
'/ui/forms/form-fieldset': { status: 301, destination: '/ui/forms/fieldset' }, '/ui/forms/form-fieldset': { status: 301, destination: '/ui/forms/fieldset' },
'/ui/forms/form-floating': { status: 301, destination: '/ui/forms/floating-labels' }, '/ui/forms/form-floating': { status: 301, destination: '/ui/forms/floating-labels' },
'/ui/forms/form-helpers': { status: 301, destination: '/ui/forms/helpers' }, '/ui/forms/form-helpers': { status: 301, destination: '/ui/forms/helpers' },
// The quantity stepper was renamed before 1.7 shipped; `stepper` is the v6 wizard component.
'/ui/forms/stepper': { status: 301, destination: '/ui/forms/number-input' },
'/ui/forms/form-selectboxes': { status: 301, destination: '/ui/forms/select-group' }, '/ui/forms/form-selectboxes': { status: 301, destination: '/ui/forms/select-group' },
'/ui/forms/form-image-check': { status: 301, destination: '/ui/forms/image-check' }, '/ui/forms/form-image-check': { status: 301, destination: '/ui/forms/image-check' },
'/ui/forms/form-color-check': { status: 301, destination: '/ui/forms/color-check' }, '/ui/forms/form-color-check': { status: 301, destination: '/ui/forms/color-check' },
+2 -2
View File
@@ -16,7 +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 NumberInput from '@ui/NumberInput.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 }
@@ -82,7 +82,7 @@ const invoiceStatus = {
<DatagridItem title="Next invoice">September 18, 2026</DatagridItem> <DatagridItem title="Next invoice">September 18, 2026</DatagridItem>
<DatagridItem title="Seats"> <DatagridItem title="Seats">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<Stepper size="sm" value={10} min={8} max={100} ariaLabel="Seats in the plan" /> <NumberInput size="sm" value={10} min={8} max={100} ariaLabel="Seats in the plan" />
<span class="text-secondary">8 used</span> <span class="text-secondary">8 used</span>
</div> </div>
</DatagridItem> </DatagridItem>
+7 -7
View File
@@ -11,7 +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 NumberInput from '@ui/NumberInput.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'
@@ -161,13 +161,13 @@ const rows = [
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<Card> <Card>
<CardHeader title="Stepper" /> <CardHeader title="Number input" />
<CardBody> <CardBody>
<FormGroup label="Quantity" for="stepper-basic"><Stepper id="stepper-basic" value={1} min={1} max={99} /></FormGroup> <FormGroup label="Quantity" for="number-input-basic"><NumberInput id="number-input-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="Step of 0.25" for="number-input-decimal"><NumberInput id="number-input-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="Without bounds" for="number-input-unbounded"><NumberInput id="number-input-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="Native number input" for="number-input-number"><NumberInput id="number-input-number" type="number" value={5} min={0} max={10} /></FormGroup>
<FormGroup label="Disabled" for="stepper-disabled"><Stepper id="stepper-disabled" value={3} disabled /></FormGroup> <FormGroup label="Disabled" for="number-input-disabled"><NumberInput id="number-input-disabled" value={3} disabled /></FormGroup>
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
@@ -6,7 +6,7 @@ import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro' import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro' import DocsLink from '@ui/DocsLink.astro'
import FormGroup from '@ui/FormGroup.astro' import FormGroup from '@ui/FormGroup.astro'
import Stepper from '@ui/Stepper.astro' import NumberInput from '@ui/NumberInput.astro'
import Icon from '@ui/Icon.astro' import Icon from '@ui/Icon.astro'
const cart = [ const cart = [
@@ -16,16 +16,16 @@ const cart = [
] ]
--- ---
<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."> <DefaultLayout title="Number input" pageMenu="base.number-input" description="A number input is a 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" /> <DocsLink slot="page-header-actions" path="/ui/forms/number-input" />
<div class="row row-cards"> <div class="row row-cards">
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<Card> <Card>
<CardBody> <CardBody>
<CardTitle>Basic</CardTitle> <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> <CardSubtitle>Add <code>data-bs-toggle="number-input"</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" /> <NumberInput value={1} min={1} max={99} ariaLabel="Quantity" />
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
@@ -35,8 +35,8 @@ const cart = [
<CardTitle>At a bound</CardTitle> <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> <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"> <div class="d-flex flex-wrap gap-3">
<Stepper value={0} min={0} max={10} ariaLabel="At the minimum" /> <NumberInput value={0} min={0} max={10} ariaLabel="At the minimum" />
<Stepper value={10} min={0} max={10} ariaLabel="At the maximum" /> <NumberInput value={10} min={0} max={10} ariaLabel="At the maximum" />
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
@@ -47,8 +47,8 @@ const cart = [
<CardTitle>Step and precision</CardTitle> <CardTitle>Step and precision</CardTitle>
<CardSubtitle>A fractional <code>step</code> sets how many decimals the value keeps.</CardSubtitle> <CardSubtitle>A fractional <code>step</code> sets how many decimals the value keeps.</CardSubtitle>
<div class="d-flex flex-wrap gap-3"> <div class="d-flex flex-wrap gap-3">
<Stepper value={1.5} min={0} max={10} step={0.25} ariaLabel="Hours" /> <NumberInput 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" /> <NumberInput value={500} min={0} step={100} ariaLabel="Amount" style="--tblr-number-input-input-width: 5rem" />
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
@@ -57,11 +57,11 @@ const cart = [
<Card> <Card>
<CardBody> <CardBody>
<CardTitle>Sizes</CardTitle> <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> <CardSubtitle>Add <code>.form-control-sm</code> or <code>.form-control-lg</code> to the input; the box gets <code>.number-input-sm</code> or <code>-lg</code>.</CardSubtitle>
<div class="d-flex flex-wrap align-items-center gap-3"> <div class="d-flex flex-wrap align-items-center gap-3">
<Stepper size="sm" value={1} min={0} ariaLabel="Small" /> <NumberInput size="sm" value={1} min={0} ariaLabel="Small" />
<Stepper value={1} min={0} ariaLabel="Default" /> <NumberInput value={1} min={0} ariaLabel="Default" />
<Stepper size="lg" value={1} min={0} ariaLabel="Large" /> <NumberInput size="lg" value={1} min={0} ariaLabel="Large" />
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
@@ -71,7 +71,7 @@ const cart = [
<CardBody> <CardBody>
<CardTitle>Without bounds</CardTitle> <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> <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" /> <NumberInput value={0} ariaLabel="Offset" />
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
@@ -79,8 +79,8 @@ const cart = [
<Card> <Card>
<CardBody> <CardBody>
<CardTitle>Native number input</CardTitle> <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> <CardSubtitle>Use <code>type="number"</code> to keep the browser's own validation; the number input only hides its spin buttons.</CardSubtitle>
<Stepper type="number" value={5} min={0} max={10} ariaLabel="Seats" /> <NumberInput type="number" value={5} min={0} max={10} ariaLabel="Seats" />
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
@@ -89,7 +89,7 @@ const cart = [
<CardBody> <CardBody>
<CardTitle>No repeat</CardTitle> <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> <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" /> <NumberInput value={1} min={1} max={99} repeat={false} ariaLabel="Quantity, one step per click" />
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
@@ -97,8 +97,8 @@ const cart = [
<Card> <Card>
<CardBody> <CardBody>
<CardTitle>Wider input</CardTitle> <CardTitle>Wider input</CardTitle>
<CardSubtitle>Set <code>--tblr-stepper-input-width</code> on the input for longer values.</CardSubtitle> <CardSubtitle>Set <code>--tblr-number-input-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" /> <NumberInput value={125000} min={0} step={1000} ariaLabel="Budget" style="--tblr-number-input-input-width: 7rem" />
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
@@ -108,8 +108,8 @@ const cart = [
<CardTitle>Disabled and readonly</CardTitle> <CardTitle>Disabled and readonly</CardTitle>
<CardSubtitle>Add <code>disabled</code> or <code>readonly</code> to the input; both buttons follow.</CardSubtitle> <CardSubtitle>Add <code>disabled</code> or <code>readonly</code> to the input; both buttons follow.</CardSubtitle>
<div class="d-flex flex-wrap gap-3"> <div class="d-flex flex-wrap gap-3">
<Stepper value={3} disabled ariaLabel="Disabled" /> <NumberInput value={3} disabled ariaLabel="Disabled" />
<Stepper value={3} readonly ariaLabel="Readonly" /> <NumberInput value={3} readonly ariaLabel="Readonly" />
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
@@ -121,11 +121,11 @@ const cart = [
<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> <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 class="space-y-3">
<div> <div>
<Stepper class="is-valid" value={2} min={1} max={5} ariaLabel="Valid quantity" /> <NumberInput class="is-valid" value={2} min={1} max={5} ariaLabel="Valid quantity" />
<div class="valid-feedback">In stock, ships today.</div> <div class="valid-feedback">In stock, ships today.</div>
</div> </div>
<div> <div>
<Stepper class="is-invalid" value={5} min={1} max={5} ariaLabel="Invalid quantity" /> <NumberInput class="is-invalid" value={5} min={1} max={5} ariaLabel="Invalid quantity" />
<div class="invalid-feedback">Only 5 pieces left.</div> <div class="invalid-feedback">Only 5 pieces left.</div>
</div> </div>
</div> </div>
@@ -136,11 +136,11 @@ const cart = [
<Card> <Card>
<CardBody> <CardBody>
<CardTitle>Custom buttons</CardTitle> <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> <CardSubtitle>Write the <code>.number-input</code> markup yourself to change the icons or labels; <code>data-bs-number-input-action</code> marks the buttons.</CardSubtitle>
<div class="stepper" data-bs-toggle="stepper"> <div class="number-input" data-bs-toggle="number-input">
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Fewer"><Icon name="chevron-down" /></button> <button type="button" class="btn btn-icon" data-bs-number-input-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" /> <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> <button type="button" class="btn btn-icon" data-bs-number-input-action="increment" aria-label="More"><Icon name="chevron-up" /></button>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
@@ -149,8 +149,8 @@ const cart = [
<Card> <Card>
<CardBody> <CardBody>
<CardTitle>With a label and hint</CardTitle> <CardTitle>With a label and hint</CardTitle>
<CardSubtitle>A stepper sits in a <code>FormGroup</code> like any other control.</CardSubtitle> <CardSubtitle>A number input 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> <FormGroup label="Number of seats" for="number-input-seats" hint="Up to 8 seats per booking."><NumberInput id="number-input-seats" name="seats" value={2} min={1} max={8} /></FormGroup>
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
@@ -158,7 +158,7 @@ const cart = [
<Card> <Card>
<CardBody> <CardBody>
<CardTitle>In a cart</CardTitle> <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> <CardSubtitle>The usual place for a number input: a quantity per line, with <code>max</code> set to the stock.</CardSubtitle>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-vcenter card-table"> <table class="table table-vcenter card-table">
<thead> <thead>
@@ -179,7 +179,7 @@ const cart = [
</td> </td>
<td class="text-nowrap">${item.price}</td> <td class="text-nowrap">${item.price}</td>
<td> <td>
<Stepper value={item.quantity} min={1} max={item.max} ariaLabel={`Quantity of ${item.name}`} /> <NumberInput value={item.quantity} min={1} max={item.max} ariaLabel={`Quantity of ${item.name}`} />
</td> </td>
<td> <td>
<button type="button" class="btn btn-icon btn-ghost-secondary" aria-label={`Remove ${item.name}`}> <button type="button" class="btn btn-icon btn-ghost-secondary" aria-label={`Remove ${item.name}`}>
+2 -2
View File
@@ -2,7 +2,7 @@
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 NumberInput from '@ui/NumberInput.astro'
import FormGroup from '@ui/FormGroup.astro' import FormGroup from '@ui/FormGroup.astro'
--- ---
@@ -12,7 +12,7 @@ import FormGroup from '@ui/FormGroup.astro'
<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> <FormGroup label="Users" for="plan-users" hint="Each plan is billed per user. Pick how many people need access." class="mb-4"><NumberInput 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">
@@ -1,6 +1,6 @@
--- ---
// Stepper where it is used most: a quantity per line of a small cart. Three // NumberInput 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 // lines, each with its own number input; the cursor rests on the increase button of
// the middle one, and the last line sits at its stock limit so its increase // 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. // button is disabled - the state the plugin sets, not a faked one.
// //
@@ -9,7 +9,7 @@
// through the `--btn-bg` token, the same colour a real hover paints. // through the `--btn-bg` token, the same colour a real hover paints.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro' import Card from '@ui/Card.astro'
import Stepper from '@ui/Stepper.astro' import NumberInput from '@ui/NumberInput.astro'
const cart = [ const cart = [
{ name: 'Hex nut M8 DIN 934', code: 'Zinc plated', price: '$12.40', quantity: 2, max: 50 }, { name: 'Hex nut M8 DIN 934', code: 'Zinc plated', price: '$12.40', quantity: 2, max: 50 },
@@ -18,7 +18,7 @@ const cart = [
] ]
--- ---
<ScreenshotLayout title="Stepper" columns={2}> <ScreenshotLayout title="Number input" columns={2}>
<Card> <Card>
<table class="table table-vcenter card-table"> <table class="table table-vcenter card-table">
<tbody> <tbody>
@@ -33,14 +33,14 @@ const cart = [
<td class="w-1"> <td class="w-1">
<div class="position-relative"> <div class="position-relative">
{index === 1 ? ( {index === 1 ? (
<div class="stepper" data-bs-toggle="stepper"> <div class="number-input" data-bs-toggle="number-input">
<button type="button" class="btn btn-icon" data-bs-stepper-action="decrement" aria-label="Decrease" /> <button type="button" class="btn btn-icon" data-bs-number-input-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}`} /> <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)" /> <button type="button" class="btn btn-icon" data-bs-number-input-action="increment" aria-label="Increase" style="--tblr-btn-bg: var(--tblr-hover-bg)" />
<span class="cursor" style="top: 55%; left: 82%" /> <span class="cursor" style="top: 55%; left: 82%" />
</div> </div>
) : ( ) : (
<Stepper value={item.quantity} min={1} max={item.max} ariaLabel={`Quantity of ${item.name}`} /> <NumberInput value={item.quantity} min={1} max={item.max} ariaLabel={`Quantity of ${item.name}`} />
)} )}
</div> </div>
</td> </td>
+2 -2
View File
@@ -413,8 +413,8 @@
"url": "/ui/forms/otp-input" "url": "/ui/forms/otp-input"
}, },
{ {
"title": "Stepper", "title": "Number input",
"url": "/ui/forms/stepper" "url": "/ui/forms/number-input"
}, },
{ {
"title": "CSS variables", "title": "CSS variables",
+3 -3
View File
@@ -266,9 +266,9 @@
"url": "steps.html", "url": "steps.html",
"title": "Steps" "title": "Steps"
}, },
"stepper": { "number-input": {
"url": "stepper.html", "url": "number-input.html",
"title": "Stepper", "title": "Number input",
"badge": "New" "badge": "New"
}, },
"tables": { "tables": {
@@ -1,6 +1,6 @@
--- ---
// Stepper initializes from `data-bs-toggle="stepper"` on the input and its `data-bs-*` options (tabler.js, core/js/src/stepper.ts). // NumberInput initializes from `data-bs-toggle="number-input"` on the input and its `data-bs-*` options (tabler.js, core/js/src/number-input.ts).
// The plugin wraps the input in `.stepper` and renders the two buttons. // The plugin wraps the input in `.number-input` and renders the two buttons.
interface Props { interface Props {
/** data-bs-min: lower bound; defaults to the input's `min` attribute */ /** data-bs-min: lower bound; defaults to the input's `min` attribute */
min?: number min?: number
@@ -10,7 +10,7 @@ interface Props {
step?: number step?: number
/** data-bs-repeat: keep stepping while a button is held (default: true) */ /** data-bs-repeat: keep stepping while a button is held (default: true) */
repeat?: boolean repeat?: boolean
/** `.form-control-{size}`; the rendered box gets `.stepper-{size}` */ /** `.form-control-{size}`; the rendered box gets `.number-input-{size}` */
size?: 'sm' | 'lg' size?: 'sm' | 'lg'
value?: number | string value?: number | string
/** `text` keeps the native spin buttons away; `number` keeps the browser's own validation */ /** `text` keeps the native spin buttons away; `number` keeps the browser's own validation */
@@ -36,7 +36,7 @@ const inputClass = ['form-control', size && `form-control-${size}`, className]
<input <input
type={type} type={type}
class:list={inputClass} class:list={inputClass}
data-bs-toggle="stepper" data-bs-toggle="number-input"
data-bs-repeat={repeat === false ? 'false' : undefined} data-bs-repeat={repeat === false ? 'false' : undefined}
data-bs-decrement-label={decrementLabel} data-bs-decrement-label={decrementLabel}
data-bs-increment-label={incrementLabel} data-bs-increment-label={incrementLabel}