Amount picker
This is the Vue-library version of an amount selector, to be used e.g. in forms that use gift card or donation inputs. It reuses the existing form, fieldset, note, and label-button framework classes while adding v-model, preset/custom selection modes, inline validation, and accessible hint/error wiring.
The default minimum amount is 1. Set :min-amount="0" when zero should be accepted for presets, custom input, and incoming v-model values.
Live example
Enter a custom amount between 100 and 5,000.
Usage
<script setup>
import { ref } from 'vue'
import { AmountPicker } from '@tickster/ui-framework/library.mjs'
const amount = ref(500)
</script>
<template>
<amount-picker
v-model="amount"
mode="presets"
label="Choose amount"
:preset-options="[250, 500, 1000, 2000]" />
</template><script setup>
import { ref } from 'vue'
import { AmountPicker } from '@tickster/ui-framework/library.mjs'
const amount = ref(null)
</script>
<template>
<amount-picker
v-model="amount"
mode="custom"
label="Enter amount"
:min-amount="100"
:max-amount="5000"
hint="Enter a custom amount between 100 and 5,000."
custom-input-label="Enter custom amount" />
</template><script setup>
import { ref } from 'vue'
import { AmountPicker } from '@tickster/ui-framework/library.mjs'
const giftCardAmount = ref(1000)
</script>
<template>
<amount-picker
v-model="giftCardAmount"
mode="hybrid"
label="Choose amount"
name="giftCardAmount"
:preset-options="[250, 500, 1000, 2000]"
:min-amount="100"
:max-amount="5000"
hint="Choose a preset amount or enter a custom amount between 100 and 5,000."
custom-option-label="Custom amount"
custom-input-label="Enter custom amount" />
</template><script setup>
import { ref } from 'vue'
import { AmountPicker } from '@tickster/ui-framework/library.mjs'
const giftCardAmount = ref(100)
function addGiftCard() {
// Add giftCardAmount.value to the cart.
}
</script>
<template>
<form @submit.prevent="addGiftCard">
<amount-picker
v-model="giftCardAmount"
mode="hybrid"
label="Choose amount"
name="giftCardAmount"
:preset-options="[100, 250, 500]"
:min-amount="100"
:max-amount="5000"
hint="Choose a preset amount or enter a custom amount."
custom-option-label="Custom amount"
custom-input-label="Enter custom amount" />
<button type="submit">
Add
</button>
</form>
</template><script setup>
import { ref } from 'vue'
import { AmountPicker } from '@tickster/ui-framework/library.mjs'
const amount = ref(1000)
const l10n = {
amountCleared: () => 'Belopp rensat.',
aboveMaxError: (max) => `Beloppet får vara högst ${max}.`,
belowMinError: (min) => `Beloppet måste vara minst ${min}.`,
customAmountAccepted: (value) => `Eget belopp ${value} accepterat.`,
customAmountReset: (value) => `Eget belopp återställt till ${value}.`,
customAmountSelected: (value) => `Eget belopp ${value} valt.`,
emptyError: () => 'Ange ett belopp.',
nonNumericError: () => 'Ange ett helt belopp.',
presetSelected: (label) => `${label} valt.`,
}
</script>
<template>
<amount-picker
v-model="amount"
mode="hybrid"
label="Välj belopp"
currency="SEK"
:preset-options="[250, 500, 1000]"
:min-amount="100"
:max-amount="5000"
custom-option-label="Annat belopp"
custom-input-label="Ange eget belopp"
:l10n="l10n" />
</template>Form submission
Use a real <form> and handle its submit event when the amount should trigger an action, such as adding a gift card to a cart. Pressing Enter in the custom amount input submits the form when the amount is valid. Invalid custom values keep focus in the input, show the validation message, and block the submit.
Localization
Pass an l10n object when the consuming app needs translated dynamic status or validation text.
amountCleared()aboveMaxError(max)belowMinError(min)customAmountAccepted(value)customAmountReset(value)customAmountSelected(value)emptyError()nonNumericError()presetSelected(label)
Accessibility notes
labelis always visible and becomes thelegendinpresetsandhybridmode.hintstays persistent in the UI and is linked througharia-describedby.- Custom validation errors are rendered as visible text and linked with
aria-describedbyplusaria-errormessage.
Component source
<!-- Usage: bind with v-model and pass a visible label together with presetOptions and/or min/max constraints. -->
<script setup>
import { computed, nextTick, ref, useId, watch } from 'vue'
defineOptions({
name: 'AmountPicker',
})
const defaultL10n = {
amountCleared: () => 'Amount cleared.',
aboveMaxError: (max) => `Amount must be at most ${max}.`,
belowMinError: (min) => `Amount must be at least ${min}.`,
customAmountAccepted: (value) => `Custom amount ${value} accepted.`,
customAmountReset: (value) => `Custom amount reset to ${value}.`,
customAmountSelected: (value) => `Custom amount ${value} selected.`,
emptyError: () => 'Enter an amount.',
nonNumericError: () => 'Enter a whole amount.',
presetSelected: (label) => `${label} selected.`,
}
const props = defineProps({
modelValue: {
type: Number,
default: null,
},
mode: {
type: String,
default: 'hybrid',
validator: (value) => ['presets', 'custom', 'hybrid'].includes(value),
},
label: {
type: String,
required: true,
},
name: {
type: String,
default: '',
},
presetOptions: {
type: Array,
default: () => [],
},
minAmount: {
type: Number,
default: 1,
},
maxAmount: {
type: Number,
default: null,
},
hint: {
type: String,
default: '',
},
customOptionLabel: {
type: String,
default: 'Custom amount',
},
customInputLabel: {
type: String,
default: 'Enter custom amount',
},
customInputPlaceholder: {
type: String,
default: '',
},
currency: {
type: String,
default: 'SEK',
},
disabled: {
type: Boolean,
default: false,
},
l10n: {
type: Object,
default: null,
},
})
const emit = defineEmits({
'update:modelValue': (value) => value === null || (typeof value === 'number' && Number.isInteger(value)),
change: (payload) => Boolean(payload),
invalid: (payload) => Boolean(payload),
'selection-change': (payload) => Boolean(payload),
focus: (event) => Boolean(event),
blur: (event) => Boolean(event),
})
const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'
const warnedKeys = new Set()
const emittedInvalidPresetKeys = new Set()
const generatedId = useId()
const customInputRef = ref(null)
const selectedSource = ref(props.mode === 'custom' ? 'custom' : null)
const activePresetValue = ref(null)
const customDraft = ref('')
const customError = ref('')
const statusMessage = ref('')
const hasInteractedWithCustom = ref(false)
const lastInvalidPropSignature = ref('')
const pendingModelEcho = ref(null)
function valuesEqual(left, right) {
return left === right
}
function toInteger(value, fallback) {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) {
return fallback
}
return Math.trunc(numericValue)
}
function warnDevOnce(key, message, detail) {
if (!isDev || warnedKeys.has(key)) {
return
}
warnedKeys.add(key)
console.warn(`[AmountPicker] ${message}`, detail)
}
function getFormatter(currency) {
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'SEK',
maximumFractionDigits: 0,
})
} catch {
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0,
})
}
}
function formatAmountValue(value, formatter) {
return formatter.format(value)
}
function parseCustomDraft(rawValue) {
if (rawValue === '') {
return {
attemptedValue: '',
reason: 'empty',
value: null,
}
}
if (!/^\d+$/.test(rawValue)) {
return {
attemptedValue: rawValue,
reason: 'non-numeric',
value: null,
}
}
const parsedValue = Number.parseInt(rawValue, 10)
return {
attemptedValue: rawValue,
reason: null,
value: parsedValue,
}
}
function announce(message) {
statusMessage.value = ''
if (!message) {
return
}
nextTick(() => {
statusMessage.value = message
})
}
function emitSelectionChangeIfNeeded(previousSource, nextSource) {
if (previousSource === nextSource) {
return
}
emit('selection-change', {
source: nextSource,
})
}
function emitInvalid(reason, attemptedValue, source) {
emit('invalid', {
reason,
attemptedValue,
source,
})
}
function resolveMessage(key, ...args) {
const entry = props.l10n?.[key] ?? defaultL10n[key]
if (typeof entry === 'function') {
return entry(...args)
}
return entry ?? ''
}
function emitModelValue(nextValue, previousValue, source, options = {}) {
if (!valuesEqual(nextValue, props.modelValue)) {
pendingModelEcho.value = {
preserveCustomDraft: Boolean(options.preserveCustomDraft),
source,
value: nextValue,
}
emit('update:modelValue', nextValue)
} else {
pendingModelEcho.value = null
}
if (!valuesEqual(nextValue, previousValue)) {
emit('change', {
value: nextValue,
previousValue,
source,
})
}
if (options.statusMessage) {
announce(options.statusMessage)
}
}
const amountFormatter = computed(() => getFormatter(props.currency))
const minValue = computed(() => {
const normalizedValue = toInteger(props.minAmount, 1)
if (!Number.isInteger(props.minAmount) || normalizedValue < 0) {
warnDevOnce(
`min:${String(props.minAmount)}`,
'Invalid minAmount received. Falling back to 1.',
props.minAmount
)
return 1
}
return normalizedValue
})
const maxValue = computed(() => {
if (props.maxAmount === null || props.maxAmount === undefined) {
return null
}
const normalizedValue = toInteger(props.maxAmount, null)
if (!Number.isInteger(props.maxAmount)) {
warnDevOnce(
`max:${String(props.maxAmount)}`,
'Invalid maxAmount received. Ignoring upper bound.',
props.maxAmount
)
return null
}
if (normalizedValue < minValue.value) {
warnDevOnce(
`max-less-than-min:${String(props.maxAmount)}:${minValue.value}`,
'maxAmount was below minAmount. Using minAmount instead.',
props.maxAmount
)
return minValue.value
}
return normalizedValue
})
const hasMax = computed(() => maxValue.value !== null)
const formattedMin = computed(() => formatAmountValue(minValue.value, amountFormatter.value))
const formattedMax = computed(() => hasMax.value ? formatAmountValue(maxValue.value, amountFormatter.value) : '')
const presetDiagnostics = computed(() => {
const seenValues = new Set()
const presets = []
const dropped = []
for (const item of props.presetOptions) {
const isObjectOption = typeof item === 'object' && item !== null && !Array.isArray(item)
const rawValue = isObjectOption ? item.value : item
const numericValue = Number(rawValue)
let reason = ''
if (!Number.isFinite(numericValue) || !Number.isInteger(numericValue)) {
reason = 'not-an-integer'
} else if (numericValue < 0 || numericValue < minValue.value) {
reason = 'below-min'
} else if (hasMax.value && numericValue > maxValue.value) {
reason = 'above-max'
} else if (seenValues.has(numericValue)) {
reason = 'duplicate'
}
if (reason) {
const warningKey = `preset:${reason}:${JSON.stringify(item)}:${minValue.value}:${maxValue.value}`
warnDevOnce(
warningKey,
'Dropping invalid preset option.',
item
)
dropped.push({
attemptedValue: rawValue ?? null,
key: warningKey,
})
continue
}
seenValues.add(numericValue)
presets.push({
disabled: Boolean(isObjectOption && item.disabled),
label: isObjectOption && typeof item.label === 'string' && item.label.trim()
? item.label
: formatAmountValue(numericValue, amountFormatter.value),
value: numericValue,
})
}
return {
dropped,
presets,
}
})
const normalizedPresets = computed(() => presetDiagnostics.value.presets)
const hasPresets = computed(() => normalizedPresets.value.length > 0)
const allowsCustom = computed(() => props.mode !== 'presets')
const resolvedIdBase = computed(() => `amount-picker-${generatedId}`)
const hintId = computed(() => `${resolvedIdBase.value}-hint`)
const errorId = computed(() => `${resolvedIdBase.value}-error`)
const statusId = computed(() => `${resolvedIdBase.value}-status`)
const customInputId = computed(() => `${resolvedIdBase.value}-custom-input`)
const radioGroupName = computed(() => props.name ? `${props.name}__selection` : `${resolvedIdBase.value}-selection`)
const hiddenInputName = computed(() => props.name || null)
const selectedPresetValue = computed(() => selectedSource.value === 'preset' ? activePresetValue.value : null)
const validCustomValue = computed(() => {
const parsedDraft = parseCustomDraft(customDraft.value)
if (parsedDraft.reason !== null) {
return null
}
if (parsedDraft.value < minValue.value) {
return null
}
if (hasMax.value && parsedDraft.value > maxValue.value) {
return null
}
return parsedDraft.value
})
const activeDescribedByIds = computed(() => {
const ids = []
if (props.hint) {
ids.push(hintId.value)
}
if (customError.value) {
ids.push(errorId.value)
}
return ids
})
const customInputDescribedBy = computed(() => activeDescribedByIds.value.length > 0 ? activeDescribedByIds.value.join(' ') : undefined)
const optionDescribedBy = computed(() => props.hint ? hintId.value : undefined)
const showCustomInput = computed(() => props.mode === 'custom' || (props.mode === 'hybrid' && selectedSource.value === 'custom'))
const currentValue = computed(() => {
if (selectedSource.value === 'preset') {
return selectedPresetValue.value
}
if (selectedSource.value === 'custom') {
return validCustomValue.value
}
return null
})
const hiddenInputValue = computed(() => hiddenInputName.value && currentValue.value !== null ? currentValue.value : null)
const presetLookup = computed(() => {
const lookup = new Map()
for (const option of normalizedPresets.value) {
lookup.set(option.value, option)
}
return lookup
})
function getPresetId(value) {
return `${resolvedIdBase.value}-preset-${value}`
}
function getCustomOptionId() {
return `${resolvedIdBase.value}-custom-option`
}
function resolveCustomValidation(rawValue) {
const parsedDraft = parseCustomDraft(rawValue)
if (parsedDraft.reason === 'empty') {
return {
attemptedValue: '',
message: resolveMessage('emptyError'),
reason: 'empty',
value: null,
}
}
if (parsedDraft.reason === 'non-numeric') {
return {
attemptedValue: rawValue,
message: resolveMessage('nonNumericError'),
reason: 'non-numeric',
value: null,
}
}
if (parsedDraft.value < minValue.value) {
return {
attemptedValue: parsedDraft.value,
message: resolveMessage('belowMinError', formattedMin.value),
reason: 'below-min',
value: parsedDraft.value,
}
}
if (hasMax.value && parsedDraft.value > maxValue.value) {
return {
attemptedValue: parsedDraft.value,
message: resolveMessage('aboveMaxError', formattedMax.value),
reason: 'above-max',
value: parsedDraft.value,
}
}
return {
attemptedValue: parsedDraft.value,
message: '',
reason: null,
value: parsedDraft.value,
}
}
function resolveIncomingModelValue(value) {
if (value === null || value === undefined) {
return {
source: null,
valid: true,
value: null,
}
}
if (!Number.isFinite(value) || !Number.isInteger(value)) {
return {
source: null,
valid: false,
value: null,
}
}
const matchingPreset = presetLookup.value.get(value)
const isValidCustomAmount = value >= minValue.value && (!hasMax.value || value <= maxValue.value)
if (props.mode === 'presets') {
return matchingPreset
? { source: 'preset', valid: true, value }
: { source: null, valid: false, value: null }
}
if (props.mode === 'custom') {
return isValidCustomAmount
? { source: 'custom', valid: true, value }
: { source: null, valid: false, value: null }
}
if (matchingPreset) {
return {
source: 'preset',
valid: true,
value,
}
}
if (isValidCustomAmount) {
return {
source: 'custom',
valid: true,
value,
}
}
return {
source: null,
valid: false,
value: null,
}
}
function syncFromProps() {
const previousSource = selectedSource.value
const previousValue = currentValue.value
const pendingEcho = pendingModelEcho.value
const isPendingEcho = pendingEcho && valuesEqual(props.modelValue, pendingEcho.value)
const resolved = resolveIncomingModelValue(props.modelValue)
const shouldPreserveCustomEcho = Boolean(
isPendingEcho
&& pendingEcho?.source === 'custom'
&& props.mode === 'hybrid'
&& resolved.valid
&& resolved.source === 'preset'
)
if (isPendingEcho) {
pendingModelEcho.value = null
}
if (!resolved.valid && props.modelValue !== null && props.modelValue !== undefined) {
const invalidSignature = `${props.mode}:${String(props.modelValue)}:${minValue.value}:${maxValue.value}:${normalizedPresets.value.map((option) => option.value).join(',')}`
if (lastInvalidPropSignature.value !== invalidSignature) {
lastInvalidPropSignature.value = invalidSignature
warnDevOnce(
`invalid-prop:${invalidSignature}`,
'Received an invalid modelValue. Rendering an unselected state instead.',
props.modelValue
)
emitInvalid('invalid-prop', props.modelValue, 'prop')
}
} else {
lastInvalidPropSignature.value = ''
}
if (props.mode === 'presets') {
if (resolved.valid && resolved.source === 'preset') {
applyPresetSelection(resolved.value)
} else {
customError.value = ''
selectedSource.value = null
activePresetValue.value = null
}
} else if (props.mode === 'custom') {
selectedSource.value = 'custom'
activePresetValue.value = null
customError.value = ''
if (resolved.valid && resolved.source === 'custom') {
customDraft.value = String(resolved.value)
} else if (!isPendingEcho || !pendingEcho.preserveCustomDraft) {
customDraft.value = ''
}
} else if (shouldPreserveCustomEcho) {
selectedSource.value = 'custom'
activePresetValue.value = null
customDraft.value = String(resolved.value)
customError.value = ''
} else if (resolved.valid && resolved.source === 'preset') {
applyPresetSelection(resolved.value)
} else if (resolved.valid && resolved.source === 'custom') {
selectedSource.value = 'custom'
activePresetValue.value = null
customDraft.value = String(resolved.value)
customError.value = ''
} else if (props.modelValue === null || props.modelValue === undefined) {
activePresetValue.value = null
customError.value = ''
if (selectedSource.value === 'custom') {
if (!isPendingEcho || !pendingEcho.preserveCustomDraft) {
customDraft.value = ''
}
} else {
selectedSource.value = null
}
} else {
selectedSource.value = null
activePresetValue.value = null
customError.value = ''
if (!isPendingEcho || !pendingEcho.preserveCustomDraft) {
customDraft.value = ''
}
}
emitSelectionChangeIfNeeded(previousSource, selectedSource.value)
if (!valuesEqual(currentValue.value, previousValue)) {
emit('change', {
value: currentValue.value,
previousValue,
source: 'prop-sync',
})
}
}
function focusCustomInput() {
nextTick(() => {
customInputRef.value?.focus()
customInputRef.value?.select?.()
})
}
function applyPresetSelection(value) {
selectedSource.value = 'preset'
activePresetValue.value = value
customDraft.value = ''
customError.value = ''
}
function selectPreset(option) {
if (props.disabled || option.disabled) {
return
}
const previousSource = selectedSource.value
const previousValue = currentValue.value
applyPresetSelection(option.value)
emitSelectionChangeIfNeeded(previousSource, 'preset')
emitModelValue(option.value, previousValue, 'preset', {
statusMessage: resolveMessage('presetSelected', option.label, formatAmountValue(option.value, amountFormatter.value)),
})
}
function selectCustomOption() {
if (props.disabled || !allowsCustom.value) {
return
}
const previousSource = selectedSource.value
const previousValue = currentValue.value
selectedSource.value = 'custom'
activePresetValue.value = null
customDraft.value = ''
customError.value = ''
emitSelectionChangeIfNeeded(previousSource, 'custom')
emitModelValue(null, previousValue, 'clear', {
statusMessage: previousValue !== null ? resolveMessage('amountCleared') : '',
})
focusCustomInput()
}
function finalizeCustomSelection(value) {
if (props.mode !== 'hybrid') {
return false
}
const matchingPreset = presetLookup.value.get(value)
if (!matchingPreset) {
return false
}
const previousSource = selectedSource.value
applyPresetSelection(value)
emitSelectionChangeIfNeeded(previousSource, 'preset')
announce(resolveMessage('presetSelected', matchingPreset.label, formatAmountValue(value, amountFormatter.value)))
return true
}
function validateCustomDraft(options = {}) {
const { finalizeSelection = false, showEmptyError = false } = options
const validation = resolveCustomValidation(customDraft.value)
if (validation.reason === 'empty') {
customError.value = showEmptyError ? validation.message : ''
if (showEmptyError) {
emitInvalid(validation.reason, validation.attemptedValue, 'custom')
}
return false
}
if (validation.reason !== null) {
customError.value = validation.message
emitInvalid(validation.reason, validation.attemptedValue, 'custom')
return false
}
customError.value = ''
if (finalizeSelection) {
finalizeCustomSelection(validation.value)
}
return true
}
function onCustomInput(event) {
if (props.disabled) {
return
}
const previousSource = selectedSource.value
const previousValue = currentValue.value
selectedSource.value = 'custom'
activePresetValue.value = null
customDraft.value = event.target.value
customError.value = ''
hasInteractedWithCustom.value = true
emitSelectionChangeIfNeeded(previousSource, 'custom')
const validation = resolveCustomValidation(customDraft.value)
if (validation.reason === null) {
emitModelValue(validation.value, previousValue, 'custom', {
statusMessage: resolveMessage('customAmountAccepted', formatAmountValue(validation.value, amountFormatter.value), validation.value),
})
return
}
emitModelValue(null, previousValue, 'clear', {
preserveCustomDraft: customDraft.value !== '',
statusMessage: previousValue !== null ? resolveMessage('amountCleared') : '',
})
}
function onCustomFocus(event) {
emit('focus', event)
}
function onCustomBlur(event) {
validateCustomDraft({ finalizeSelection: true })
emit('blur', event)
}
function onCustomKeydown(event) {
if (props.disabled) {
return
}
if (event.key === 'Enter') {
const isValid = validateCustomDraft({
finalizeSelection: true,
showEmptyError: true,
})
if (!isValid) {
event.preventDefault()
}
return
}
if (event.key !== 'Escape') {
return
}
event.preventDefault()
const previousValue = currentValue.value
customDraft.value = ''
customError.value = ''
emitModelValue(null, previousValue, 'clear', {
statusMessage: previousValue !== null ? resolveMessage('amountCleared') : '',
})
}
watch(
() => presetDiagnostics.value.dropped,
(droppedItems) => {
for (const item of droppedItems) {
if (emittedInvalidPresetKeys.has(item.key)) {
continue
}
emittedInvalidPresetKeys.add(item.key)
emitInvalid('invalid-preset', item.attemptedValue, 'preset')
}
},
{ immediate: true }
)
watch(
[() => props.modelValue, () => props.mode, () => props.presetOptions, minValue, maxValue],
() => {
syncFromProps()
},
{ deep: true, immediate: true }
)
</script>
<template>
<div>
<fieldset
v-if="props.mode !== 'custom'"
class="c-fieldset"
:disabled="props.disabled"
>
<legend>{{ props.label }}</legend>
<p
v-if="props.hint"
:id="hintId"
class="c-note u-margin-top--none u-margin-bottom--none"
>
{{ props.hint }}
</p>
<div v-if="hasPresets || props.mode === 'hybrid'">
<label
v-for="option in normalizedPresets"
:key="option.value"
class="c-label-button c-label-button--small"
>
{{ option.label }}
<input
:id="getPresetId(option.value)"
type="radio"
:name="radioGroupName"
:checked="selectedSource === 'preset' && selectedPresetValue === option.value"
:value="option.value"
:disabled="props.disabled || option.disabled"
:aria-describedby="optionDescribedBy"
@change="selectPreset(option)"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
>
</label>
<label
v-if="props.mode === 'hybrid'"
class="c-label-button c-label-button--small"
>
{{ props.customOptionLabel }}
<input
:id="getCustomOptionId()"
type="radio"
:name="radioGroupName"
value="custom"
:checked="selectedSource === 'custom'"
:disabled="props.disabled"
:aria-describedby="optionDescribedBy"
@change="selectCustomOption"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
>
</label>
</div>
<div
v-if="showCustomInput"
class="c-form-control c-form-control--small"
:class="{ 'has-error': customError }"
>
<label
:for="customInputId"
class="c-label"
>
{{ props.customInputLabel }}
</label>
<input
:id="customInputId"
ref="customInputRef"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
spellcheck="false"
:value="customDraft"
:placeholder="props.customInputPlaceholder || undefined"
:disabled="props.disabled"
:aria-invalid="customError ? 'true' : undefined"
:aria-describedby="customInputDescribedBy"
:aria-errormessage="customError ? errorId : undefined"
:class="{ 'input-validation-error': customError }"
@input="onCustomInput"
@focus="onCustomFocus"
@blur="onCustomBlur"
@keydown="onCustomKeydown"
>
<p
v-if="customError"
:id="errorId"
class="c-note c-note--negative u-margin-top--xsmall u-margin-bottom--none"
>
{{ customError }}
</p>
</div>
</fieldset>
<div
v-else
class="c-form-control c-form-control--small"
:class="{ 'has-error': customError }"
>
<label
:for="customInputId"
class="c-label"
>
{{ props.label }}
</label>
<p
v-if="props.hint"
:id="hintId"
class="c-note u-margin-top--none u-margin-bottom--xsmall"
>
{{ props.hint }}
</p>
<input
:id="customInputId"
ref="customInputRef"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
spellcheck="false"
:value="customDraft"
:placeholder="props.customInputPlaceholder || undefined"
:disabled="props.disabled"
:aria-invalid="customError ? 'true' : undefined"
:aria-describedby="customInputDescribedBy"
:aria-errormessage="customError ? errorId : undefined"
:class="{ 'input-validation-error': customError }"
@input="onCustomInput"
@focus="onCustomFocus"
@blur="onCustomBlur"
@keydown="onCustomKeydown"
>
<p
v-if="customError"
:id="errorId"
class="c-note c-note--negative u-margin-top--xsmall u-margin-bottom--none"
>
{{ customError }}
</p>
</div>
<input
v-if="hiddenInputValue !== null"
type="hidden"
:name="hiddenInputName"
:value="hiddenInputValue"
>
<div
:id="statusId"
class="u-hidden--visually"
role="status"
aria-live="polite"
aria-atomic="true"
>
{{ statusMessage }}
</div>
</div>
</template>