Quantity stepper
A Vue quantity control built on Plus-minus, with v-model, keyboard support, quantity limits, and validation feedback.
TIP
Use data-product-id to identify the component for analytics or testing. The buttons expose data-testid="quantity-decrease" and data-testid="quantity-increase".
Live example
Sold in steps of 2. Max 10 per order.
Usage
<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><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><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="2"
:max="10"
:step="2"
label="Merch quantity"
item-name="shirts"
hint="Sold in steps of 2. Max 10 per order." />
</template><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><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><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><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
Use loading to show a spinner and expose aria-busy during an async update. Set disabled when the whole control must be locked.
Quantity limits
- When
minis greater than0, the component still allows0as the unselected state. The first increment jumps tomin; decrementing fromminreturns to0. - Positive quantities follow
min,max, andstep. For example,min=2,max=5,step=2allows0,2, and4. - Reaching a limit does not show a warning or disable the buttons. Attempting to go beyond it shows an explanation and leaves the quantity unchanged.
Validation feedback
Invalid attempts show a localized popover anchored to the control without moving the surrounding layout or taking focus. It closes on Escape, a click/tap outside, or a valid edit. Typed values are normalized to the allowed range and step size.
Browsers without the required Popover API and CSS Anchor Positioning support show the same message through window.alert(). In that fallback, invalid typed values are reported on Enter or blur.
Customize l10n.aboveMaxError and l10n.belowMinError for application-specific explanations, such as a shared quantity already allocated to other options.
Events and delayed sync
v-modelandchangestay immediate, so the field can update optimistically without waiting for the server.- Rejected button and ArrowUp/ArrowDown attempts emit
invalidwith{ reason, attemptedValue }(above-maxorbelow-min), without changingv-modelor scheduling acommit. 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. - The plus/minus SVG icons are decorative (
aria-hidden="true", `focusable="false"). Accessible names remain on the native buttons. - Use
item-namewhen you want button labels like “Increase tickets by 1” while keeping a shorter visible field label. - The input and buttons reference visible errors through
aria-describedby. Invalid input also exposesaria-invalidandaria-errormessage; a polite live region announces quantity/status updates. aria-valuestepis exposed on the spinbutton input so assistive technology can announce the configured step size.
Component source
<!-- 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,
default: null,
},
action: {
type: String,
default: null,
},
id: {
type: String,
default: null,
},
dataProductId: {
type: String,
},
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, allowZeroState = false) {
const safeValue = toInteger(value, min)
if (allowZeroState && safeValue <= 0) {
return 0
}
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)
const supportsZeroQuantityState = computed(() => minValue.value > 0)
const minimumCommittedValue = computed(() => (supportsZeroQuantityState.value ? 0 : minValue.value))
function getNormalizedPropValue() {
return normalizeQuantity(props.modelValue, minValue.value, effectiveMax.value, stepValue.value, supportsZeroQuantityState.value)
}
const committedValue = ref(getNormalizedPropValue())
const draftValue = ref(String(committedValue.value))
const isFocused = ref(false)
const errorMessage = ref(null)
const errorPopover = ref(null)
const statusMessage = ref('')
const hasSyncedExternalProps = ref(false)
const actionLabelVisible = ref(false)
let pendingCommitTimeoutId = null
let pendingCommitPayload = null
const anchorName = `--quantity-stepper-${instance.uid}`
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 > minimumCommittedValue.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 supportsErrorPopover() {
return typeof HTMLElement.prototype.showPopover === 'function'
&& CSS.supports('(anchor-name: --quantity-stepper) and (position-anchor: --quantity-stepper) and (position-area: top span-left) and (position-try-fallbacks: flip-block)')
}
function clearError() {
document.removeEventListener('keydown', onErrorEscape)
const popover = errorPopover.value
if (popover?.matches(':popover-open')) {
popover.hidePopover()
}
errorMessage.value = null
}
function onErrorEscape(event) {
if (event.key === 'Escape' && !event.defaultPrevented) {
// Prevent WebKit from also dismissing an enclosing modal dialog.
event.preventDefault()
event.stopPropagation()
clearError()
}
}
function onErrorPopoverToggle(event) {
if (event.newState === 'closed' && !errorPopover.value?.matches(':popover-open')) {
clearError()
}
}
function showError(message) {
if (!supportsErrorPopover()) {
window.alert(message)
return
}
errorMessage.value = message
nextTick(() => {
const popover = errorPopover.value
if (!popover || !errorMessage.value) {
return
}
if (!popover.matches(':popover-open')) {
popover.showPopover()
}
document.addEventListener('keydown', onErrorEscape)
})
}
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 (isNonInteractive.value) {
return
}
if (!canIncrement.value) {
const message = resolveMessage('aboveMaxError', effectiveMax.value)
showError(message)
emitInvalid('above-max', committedValue.value + stepValue.value)
announce(message)
return
}
const nextValue = committedValue.value === 0 && supportsZeroQuantityState.value
? minValue.value
: 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 (isNonInteractive.value) {
return
}
if (!canDecrement.value) {
const message = resolveMessage('belowMinError', minimumCommittedValue.value)
showError(message)
emitInvalid('below-min', committedValue.value - stepValue.value)
announce(message)
return
}
const nextValue = supportsZeroQuantityState.value && committedValue.value <= minValue.value
? 0
: 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(minimumCommittedValue.value, source, {
statusMessage: resolveMessage('quantityUpdated', minimumCommittedValue.value),
})
return
}
if (parsedDraft.reason === 'non-numeric') {
showError(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,
supportsZeroQuantityState.value
)
if (supportsZeroQuantityState.value && attemptedValue === 0) {
commitValue(0, source, {
statusMessage: resolveMessage('quantityUpdated', 0),
})
return
}
if (attemptedValue < minValue.value) {
showError(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) {
showError(resolveMessage('aboveMaxError', effectiveMax.value))
emitInvalid('above-max', attemptedValue)
commitValue(effectiveMax.value, source, {
clearError: false,
statusMessage: resolveMessage('resetStatus', effectiveMax.value),
})
return
}
if (normalizedValue !== attemptedValue) {
showError(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
}
if (supportsErrorPopover()) {
showError(resolveMessage('nonNumericError'))
}
}
function onFocus(event) {
isFocused.value = true
draftValue.value = String(committedValue.value)
event.target.select()
emit('focus', event)
}
function onClick(event) {
event.target.select()
}
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(minimumCommittedValue.value, 'keyboard', {
statusMessage: committedValue.value === minimumCommittedValue.value
? resolveMessage('minimumReached', minimumCommittedValue.value)
: resolveMessage('quantityUpdated', minimumCommittedValue.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(isActionState, (nextIsActionState, previousIsActionState) => {
if (!nextIsActionState) {
actionLabelVisible.value = false
return
}
if (previousIsActionState === false) {
actionLabelVisible.value = false
return
}
actionLabelVisible.value = true
}, { immediate: true })
function onDecrementTransitionEnd(event) {
if (!isActionState.value || actionLabelVisible.value || event.propertyName !== 'opacity') {
return
}
actionLabelVisible.value = true
}
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(() => {
document.removeEventListener('keydown', onErrorEscape)
cancelPendingCommit()
})
</script>
<template>
<div
class="c-quantity-stepper"
:class="{ 'is-disabled': props.disabled }"
:data-product-id="props.dataProductId"
:style="{ '--quantity-stepper-anchor': anchorName }"
:aria-busy="props.loading ? 'true' : undefined"
>
<label v-if="props.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"
data-testid="quantity-decrease"
:disabled="props.disabled || isActionState"
:aria-label="`Decrease ${labelTarget} by ${stepValue}`"
:aria-describedby="errorMessage ? errorId : undefined"
:aria-hidden="isActionState ? 'true' : undefined"
@click="decrement('decrement')"
@transitionend="onDecrementTransitionEnd"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M5 12h14" />
</svg>
</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="minimumCommittedValue"
:aria-valuemax="hasUpperBound ? effectiveMax : undefined"
:aria-valuestep="stepValue"
:aria-invalid="isDraftInvalid ? 'true' : undefined"
:aria-errormessage="isDraftInvalid && errorMessage ? errorId : undefined"
:aria-describedby="describedBy"
:aria-hidden="isActionState ? 'true' : undefined"
:tabindex="isActionState ? -1 : undefined"
@focus="onFocus"
@click="onClick"
@blur="onBlur"
@input="onInput"
@keydown="onKeydown"
/>
<button
type="button"
class="c-button c-quantity-stepper__button--increment"
data-testid="quantity-increase"
:disabled="props.disabled"
:aria-label="`Increase ${labelTarget} by ${stepValue}`"
:aria-describedby="errorMessage ? errorId : undefined"
@click="increment('increment')"
>
<span v-if="actionLabelVisible">{{ props.action }}</span>
<svg v-else viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M5 12h14M12 5v14" />
</svg>
</button>
</div>
<div v-if="errorMessage" :id="errorId" ref="errorPopover" class="c-popup c-quantity-stepper__error" popover="auto" role="tooltip" data-testid="quantity-error" @toggle="onErrorPopoverToggle">
<p class="c-box c-box--warning c-box--small u-margin-top--none u-margin-bottom--none">{{ errorMessage }}</p>
</div>
<div class="c-plus-minus__status u-hidden--visually" role="status" aria-live="polite" aria-atomic="true">
{{ statusMessage }}
</div>
</div>
</template>