Quantity stepper
This Vue component relies heavily on the CSS component Plus-minus. It keeps using the existing c-plus-minus and c-button framework classes, but wraps them in a reusable Vue component with v-model, keyboard support, normalization of min/max/step, and status/error messaging.
Live example
Sold in steps of 2. Max 10 per order.
Usage
vue
<script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="0"
:max="10"
label="Ticket quantity"
item-name="tickets" />
</template>vue
<script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="0"
:max="10"
:action="`Buy`"
label="Ticket quantity"
item-name="tickets" />
</template>vue
<script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(2)
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="2"
:max="10"
:step="2"
label="Merch quantity"
item-name="shirts"
hint="Sold in steps of 2. Max 10 per order." />
</template>vue
<script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
const l10n = {
quantityUpdated: (value) => `Antal uppdaterat till ${value}`,
minimumReached: (min) => `Minsta antal är ${min}`,
maximumReached: (max) => `Högsta antal är ${max}`,
nonNumericError: () => 'Ange endast siffror.',
belowMinError: (min) => `Minsta antal är ${min}.`,
aboveMaxError: (max) => `Högsta antal är ${max}.`,
stepMismatchError: (step, value) => `Antalet måste öka i steg om ${step}. Använder ${value}.`,
resetStatus: (value) => `Ogiltigt värde. Antalet återställdes till ${value}`,
}
</script>
<template>
<quantity-stepper
v-model="quantity"
:min="0"
:max="10"
:l10n="l10n"
label="Antal"
item-name="biljetter" />
</template>vue
<script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(0)
const isLoading = ref(false)
</script>
<template>
<quantity-stepper
v-model="quantity"
:loading="isLoading"
label="Ticket quantity"
item-name="tickets" />
</template>vue
<script setup>
import { ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const quantity = ref(7)
const confirmedQuantity = ref(7)
const isSaving = ref(false)
let requestId = 0
async function updateCartQuantity(nextQuantity) {
return fetch('/api/cart/tickets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
quantity: nextQuantity,
}),
}).then((response) => response.json())
}
async function syncQuantity({ value }) {
const currentRequestId = ++requestId
isSaving.value = true
try {
const response = await updateCartQuantity(value)
if (currentRequestId !== requestId) {
return
}
if (!response.success) {
throw new Error('Quantity unavailable')
}
confirmedQuantity.value = value
quantity.value = value
} catch {
if (currentRequestId !== requestId) {
return
}
quantity.value = confirmedQuantity.value
} finally {
if (currentRequestId === requestId) {
isSaving.value = false
}
}
}
</script>
<template>
<quantity-stepper
v-model="quantity"
:commit-delay="400"
:disabled="isSaving"
:loading="isSaving"
label="Ticket quantity"
item-name="tickets"
@commit="syncQuantity" />
</template>vue
<script setup>
import { computed, ref } from 'vue'
import { QuantityStepper } from '@tickster/ui-framework/library.mjs'
const purchaseLimit = 10
const adultQuantity = ref(2)
const childQuantity = ref(1)
const remaining = computed(() => purchaseLimit - adultQuantity.value - childQuantity.value)
const childMax = computed(() => childQuantity.value + remaining.value)
const adultMax = computed(() => adultQuantity.value + remaining.value)
async function findTickets() {
await fetch('/api/tickets/find', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
adults: adultQuantity.value,
children: childQuantity.value,
}),
})
}
</script>
<template>
<quantity-stepper
v-model="adultQuantity"
:auto-commit="false"
:max="adultMax"
label="Adult tickets"
item-name="adult tickets" />
<quantity-stepper
v-model="childQuantity"
:auto-commit="false"
:max="childMax"
class="u-margin-top--large"
label="Children tickets"
item-name="children tickets" />
<button class="c-button c-button--primary u-margin-top--large" type="button" @click="findTickets">
Find tickets
</button>
</template>Localization
Pass an l10n object when the consuming app needs translated status/error text. Each key can be a formatter function.
quantityUpdated(value)minimumReached(min)maximumReached(max)nonNumericError()belowMinError(min)aboveMaxError(max)stepMismatchError(step, value)resetStatus(value)
Loading state
Pass loading when the consuming app is waiting on an async update and wants the stepper root to expose aria-busy and the framework u-loading utility.
Events and delayed sync
v-modelandchangestay immediate, so the field can update optimistically without waiting for the server.commitis intended for server sync. It emits{ value, previousValue, source }aftercommit-delaymilliseconds of inactivity, or immediately whencommit-delayis0.- Set
auto-commit="false"when the consuming flow should not auto-submit at all, for example when several steppers share one purchase limit and a separate button performs the availability request. - If the consuming app receives an API error or an out-of-stock response, reset the bound
v-modelvalue to the last confirmed server value instead of partially adjusting the quantity. - For stock that can change after render, keep the server as the source of truth. The
maxprop is still a client-side normalization rule, so it should only be used when clamping to the maximum is actually desired.
Accessibility notes
- Keep the
labelprop meaningful. It becomes the input label and the fallback wording for the increment/decrement button labels. - Use
item-namewhen you want button labels like “Increase tickets by 1” while keeping a shorter visible field label. - Validation feedback is exposed through
aria-invalid,aria-errormessage, and a polite live region for quantity/status updates.
Component source
vue
<!-- Usage: bind with v-model and pass a visible label plus optional min, max, step, and hint props. -->
<script setup>
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, ref, watch } from 'vue'
defineOptions({
name: 'QuantityStepper',
})
const defaultL10n = {
quantityUpdated: (value) => `Quantity updated to ${value}`,
minimumReached: (min) => `Minimum ${min} reached`,
maximumReached: (max) => `Maximum ${max} reached`,
nonNumericError: () => 'Enter digits only.',
belowMinError: (min) => `Minimum quantity is ${min}.`,
aboveMaxError: (max) => `Maximum quantity is ${max}.`,
stepMismatchError: (step, value) => `Quantity must increase in steps of ${step}. Using ${value}.`,
resetStatus: (value) => `Invalid value. Quantity reset to ${value}`,
}
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
label: {
type: String,
required: true,
},
action: {
type: String,
default: null,
},
id: {
type: String,
default: null,
},
name: {
type: String,
default: null,
},
itemName: {
type: String,
default: null,
},
min: {
type: Number,
default: 0,
validator: (value) => Number.isInteger(value) && value >= 0,
},
max: {
type: Number,
default: null,
validator: (value) => value === null || Number.isInteger(value),
},
step: {
type: Number,
default: 1,
validator: (value) => Number.isInteger(value) && value > 0,
},
autoCommit: {
type: Boolean,
default: true,
},
commitDelay: {
type: Number,
default: 0,
validator: (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0,
},
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
hint: {
type: String,
default: null,
},
l10n: {
type: Object,
default: null,
},
})
const emit = defineEmits({
'update:modelValue': (value) => typeof value === 'number' && !Number.isNaN(value),
change: (payload) => Boolean(payload),
commit: (payload) => Boolean(payload),
invalid: (payload) => Boolean(payload),
focus: (event) => Boolean(event),
blur: (event) => Boolean(event),
})
const instance = getCurrentInstance()
function toNumber(value, fallback) {
const numericValue = Number(value)
if (Number.isNaN(numericValue)) {
return fallback
}
return numericValue
}
function toInteger(value, fallback) {
const numericValue = toNumber(value, fallback)
return Math.floor(numericValue)
}
function resolveEffectiveMax(min, rawMax, step) {
if (rawMax === null) {
return null
}
if (rawMax < min) {
return min
}
const alignedMax = min + Math.floor((rawMax - min) / step) * step
return alignedMax
}
function clampToRange(value, min, max) {
if (max === null) {
return Math.max(value, min)
}
return Math.min(Math.max(value, min), max)
}
function alignToStep(value, min, step) {
return min + Math.floor((value - min) / step) * step
}
function normalizeQuantity(value, min, max, step) {
const safeValue = toInteger(value, min)
const clampedValue = clampToRange(safeValue, min, max)
const alignedValue = alignToStep(clampedValue, min, step)
return clampToRange(alignedValue, min, max)
}
function parseDraft(raw) {
if (raw === '') {
return {
isEmpty: true,
value: null,
reason: null,
}
}
if (!/^\d+$/.test(raw)) {
return {
isEmpty: false,
value: null,
reason: 'non-numeric',
}
}
return {
isEmpty: false,
value: Number.parseInt(raw, 10),
reason: null,
}
}
const minValue = computed(() => Math.max(0, toInteger(props.min, 0)))
const stepValue = computed(() => Math.max(1, toInteger(props.step, 1)))
const commitDelayValue = computed(() => Math.max(0, toNumber(props.commitDelay, 0)))
const rawMaxValue = computed(() => (
props.max === null || props.max === undefined
? null
: toInteger(props.max, null)
))
const effectiveMax = computed(() => resolveEffectiveMax(minValue.value, rawMaxValue.value, stepValue.value))
const hasUpperBound = computed(() => effectiveMax.value !== null)
const isNonInteractive = computed(() => props.disabled)
function getNormalizedPropValue() {
return normalizeQuantity(props.modelValue, minValue.value, effectiveMax.value, stepValue.value)
}
const committedValue = ref(getNormalizedPropValue())
const draftValue = ref(String(committedValue.value))
const isFocused = ref(false)
const errorMessage = ref(null)
const statusMessage = ref('')
const hasSyncedExternalProps = ref(false)
let pendingCommitTimeoutId = null
let pendingCommitPayload = null
const inputId = computed(() => props.id || `plus-minus-${instance?.uid ?? 'field'}`)
const hintId = computed(() => `${inputId.value}-hint`)
const errorId = computed(() => `${inputId.value}-error`)
const labelTarget = computed(() => (props.itemName || props.label).trim())
const isDraftInvalid = computed(() => isFocused.value && draftValue.value !== '' && !/^\d+$/.test(draftValue.value))
const describedBy = computed(() => {
const ids = []
if (props.hint) {
ids.push(hintId.value)
}
if (errorMessage.value) {
ids.push(errorId.value)
}
return ids.length > 0 ? ids.join(' ') : undefined
})
const canDecrement = computed(() => !isNonInteractive.value && committedValue.value > minValue.value)
const canIncrement = computed(() => !isNonInteractive.value && (effectiveMax.value === null || committedValue.value < effectiveMax.value))
const isActionState = computed(() => Boolean(props.action) && !canDecrement.value)
function announce(message) {
statusMessage.value = ''
if (!message) {
return
}
nextTick(() => {
statusMessage.value = message
})
}
function clearError() {
errorMessage.value = null
}
function resolveMessage(key, ...args) {
const entry = props.l10n?.[key] ?? defaultL10n[key]
if (typeof entry === 'function') {
return entry(...args)
}
return entry ?? ''
}
function clearPendingCommitTimeout() {
if (pendingCommitTimeoutId === null) {
return
}
clearTimeout(pendingCommitTimeoutId)
pendingCommitTimeoutId = null
}
function cancelPendingCommit() {
clearPendingCommitTimeout()
pendingCommitPayload = null
}
function flushPendingCommit() {
if (!pendingCommitPayload) {
return
}
const nextPayload = pendingCommitPayload
clearPendingCommitTimeout()
pendingCommitPayload = null
emit('commit', nextPayload)
}
function scheduleCommit(payload) {
if (!props.autoCommit) {
cancelPendingCommit()
return
}
pendingCommitPayload = pendingCommitPayload
? {
...pendingCommitPayload,
value: payload.value,
source: payload.source,
}
: { ...payload }
if (commitDelayValue.value <= 0) {
flushPendingCommit()
return
}
clearPendingCommitTimeout()
pendingCommitTimeoutId = setTimeout(() => {
flushPendingCommit()
}, commitDelayValue.value)
}
function commitValue(nextValue, source, options = {}) {
const previousValue = committedValue.value
committedValue.value = nextValue
draftValue.value = String(nextValue)
if (options.clearError !== false) {
clearError()
}
if (nextValue === previousValue) {
if (options.statusMessage) {
announce(options.statusMessage)
}
return false
}
emit('update:modelValue', nextValue)
const payload = {
value: nextValue,
previousValue,
source,
}
emit('change', payload)
scheduleCommit(payload)
if (options.statusMessage) {
announce(options.statusMessage)
}
return true
}
function emitInvalid(reason, attemptedValue) {
emit('invalid', {
reason,
attemptedValue,
})
}
function increment(source) {
if (!canIncrement.value) {
if (hasUpperBound.value) {
announce(resolveMessage('maximumReached', effectiveMax.value))
}
return
}
const nextValue = effectiveMax.value === null
? committedValue.value + stepValue.value
: Math.min(committedValue.value + stepValue.value, effectiveMax.value)
commitValue(nextValue, source, {
statusMessage: resolveMessage('quantityUpdated', nextValue),
})
}
function decrement(source) {
if (!canDecrement.value) {
announce(resolveMessage('minimumReached', minValue.value))
return
}
const nextValue = Math.max(committedValue.value - stepValue.value, minValue.value)
commitValue(nextValue, source, {
statusMessage: resolveMessage('quantityUpdated', nextValue),
})
}
function commitDraft(source) {
const attemptedRawValue = draftValue.value
const parsedDraft = parseDraft(draftValue.value)
if (parsedDraft.isEmpty) {
commitValue(minValue.value, source, {
statusMessage: resolveMessage('quantityUpdated', minValue.value),
})
return
}
if (parsedDraft.reason === 'non-numeric') {
errorMessage.value = resolveMessage('nonNumericError')
draftValue.value = String(committedValue.value)
emitInvalid('non-numeric', attemptedRawValue)
announce(resolveMessage('resetStatus', committedValue.value))
return
}
const attemptedValue = parsedDraft.value
const normalizedValue = normalizeQuantity(attemptedValue, minValue.value, effectiveMax.value, stepValue.value)
if (attemptedValue < minValue.value) {
errorMessage.value = resolveMessage('belowMinError', minValue.value)
emitInvalid('below-min', attemptedValue)
commitValue(minValue.value, source, {
clearError: false,
statusMessage: resolveMessage('resetStatus', minValue.value),
})
return
}
if (effectiveMax.value !== null && attemptedValue > effectiveMax.value) {
errorMessage.value = resolveMessage('aboveMaxError', effectiveMax.value)
emitInvalid('above-max', attemptedValue)
commitValue(effectiveMax.value, source, {
clearError: false,
statusMessage: resolveMessage('resetStatus', effectiveMax.value),
})
return
}
if (normalizedValue !== attemptedValue) {
errorMessage.value = resolveMessage('stepMismatchError', stepValue.value, normalizedValue)
emitInvalid('step-mismatch', attemptedValue)
commitValue(normalizedValue, source, {
clearError: false,
statusMessage: resolveMessage('resetStatus', normalizedValue),
})
return
}
commitValue(normalizedValue, source, {
statusMessage: resolveMessage('quantityUpdated', normalizedValue),
})
}
function onInput(event) {
draftValue.value = event.target.value
if (draftValue.value === '' || /^\d+$/.test(draftValue.value)) {
clearError()
return
}
errorMessage.value = resolveMessage('nonNumericError')
}
function onFocus(event) {
isFocused.value = true
draftValue.value = String(committedValue.value)
emit('focus', event)
}
function onBlur(event) {
commitDraft('input-commit')
isFocused.value = false
draftValue.value = String(committedValue.value)
emit('blur', event)
}
function onKeydown(event) {
if (props.disabled) {
return
}
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
increment('keyboard')
break
case 'ArrowDown':
event.preventDefault()
decrement('keyboard')
break
case 'Home':
event.preventDefault()
commitValue(minValue.value, 'keyboard', {
statusMessage: committedValue.value === minValue.value
? resolveMessage('minimumReached', minValue.value)
: resolveMessage('quantityUpdated', minValue.value),
})
break
case 'End':
if (!hasUpperBound.value) {
return
}
event.preventDefault()
commitValue(effectiveMax.value, 'keyboard', {
statusMessage: committedValue.value === effectiveMax.value
? resolveMessage('maximumReached', effectiveMax.value)
: resolveMessage('quantityUpdated', effectiveMax.value),
})
break
case 'Enter':
event.preventDefault()
commitDraft('input-commit')
break
case 'Escape':
event.preventDefault()
draftValue.value = String(committedValue.value)
clearError()
announce('')
break
}
}
watch(commitDelayValue, (nextDelay) => {
if (nextDelay <= 0) {
flushPendingCommit()
}
})
watch(
[() => props.modelValue, minValue, effectiveMax, stepValue],
() => {
const normalizedValue = getNormalizedPropValue()
const previousValue = committedValue.value
if (pendingCommitPayload && pendingCommitPayload.value !== normalizedValue) {
cancelPendingCommit()
}
committedValue.value = normalizedValue
if (!isFocused.value) {
draftValue.value = String(normalizedValue)
}
if (normalizedValue !== props.modelValue) {
emit('update:modelValue', normalizedValue)
if (hasSyncedExternalProps.value && normalizedValue !== previousValue) {
emit('change', {
value: normalizedValue,
previousValue,
source: 'prop-normalize',
})
}
}
hasSyncedExternalProps.value = true
},
{ immediate: true }
)
onBeforeUnmount(() => {
cancelPendingCommit()
})
</script>
<template>
<div
class="c-quantity-stepper"
:class="{ 'is-disabled': props.disabled }"
:aria-busy="props.loading ? 'true' : undefined"
>
<label :for="inputId" class="c-label u-font--bold">{{ props.label }}</label>
<p v-if="props.hint" :id="hintId" class="c-note u-margin-top--none u-margin-bottom--none">{{ props.hint }}</p>
<div
class="c-plus-minus c-quantity-stepper__control"
:class="{ 'is-disabled': props.disabled, 'u-loading': props.loading, 'c-plus-minus--action': isActionState }"
>
<button
type="button"
class="c-button c-quantity-stepper__button--decrement"
:disabled="!canDecrement"
:aria-label="`Decrease ${labelTarget} by ${stepValue}`"
:aria-hidden="isActionState ? 'true' : undefined"
@click="decrement('decrement')"
>
-
</button>
<input
:id="inputId"
:name="props.name"
:value="isFocused ? draftValue : String(committedValue)"
class="c-plus-minus__amount"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
spellcheck="false"
role="spinbutton"
:disabled="props.disabled || isActionState"
:aria-valuenow="committedValue"
:aria-valuemin="minValue"
:aria-valuemax="hasUpperBound ? effectiveMax : undefined"
:aria-invalid="isDraftInvalid ? 'true' : undefined"
:aria-errormessage="isDraftInvalid ? errorId : undefined"
:aria-describedby="describedBy"
:aria-hidden="isActionState ? 'true' : undefined"
:tabindex="isActionState ? -1 : undefined"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
@keydown="onKeydown"
/>
<button
type="button"
class="c-button c-quantity-stepper__button--increment"
:disabled="!canIncrement"
:aria-label="`Increase ${labelTarget} by ${stepValue}`"
@click="increment('increment')"
>
<span v-if="isActionState">{{ props.action }}</span>
<span v-else>+</span>
</button>
</div>
<p v-if="errorMessage" :id="errorId" class="c-note c-note--negative u-margin-top--none u-margin-bottom--none">{{ errorMessage }}</p>
<div class="c-plus-minus__status u-hidden--visually" role="status" aria-live="polite" aria-atomic="true">
{{ statusMessage }}
</div>
</div>
</template>