Amount picker
This is the Vue-library version of an amount selector, to be used e.g. in forms that uses 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.
Live example
Enter a custom amount between 100 and 5,000.
Usage
vue
<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>vue
<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>vue
<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>vue
<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>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
vue
<!-- 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: '',
},
idBase: {
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 lastValidCustomValue = ref(null)
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),
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 < 1) {
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 < 1 || 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(() => props.idBase || `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)
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') {
customError.value = ''
selectedSource.value = resolved.valid && resolved.source === 'preset' ? 'preset' : null
activePresetValue.value = resolved.valid && resolved.source === 'preset' ? resolved.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)
lastValidCustomValue.value = resolved.value
} else if (!isPendingEcho || !pendingEcho.preserveCustomDraft) {
customDraft.value = ''
}
} else if (resolved.valid && resolved.source === 'preset') {
selectedSource.value = 'preset'
activePresetValue.value = resolved.value
customError.value = ''
} else if (resolved.valid && resolved.source === 'custom') {
selectedSource.value = 'custom'
activePresetValue.value = null
customDraft.value = String(resolved.value)
customError.value = ''
lastValidCustomValue.value = resolved.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 selectPreset(option) {
if (props.disabled || option.disabled) {
return
}
const previousSource = selectedSource.value
const previousValue = currentValue.value
selectedSource.value = 'preset'
activePresetValue.value = option.value
customError.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
customError.value = ''
emitSelectionChangeIfNeeded(previousSource, 'custom')
if (lastValidCustomValue.value !== null) {
emitModelValue(lastValidCustomValue.value, previousValue, 'custom', {
statusMessage: resolveMessage('customAmountSelected', formatAmountValue(lastValidCustomValue.value, amountFormatter.value), lastValidCustomValue.value),
})
} else {
emitModelValue(null, previousValue, 'clear', {
statusMessage: previousValue !== null ? resolveMessage('amountCleared') : '',
})
}
focusCustomInput()
}
function validateCustomDraft() {
const validation = resolveCustomValidation(customDraft.value)
if (validation.reason === 'empty') {
customError.value = ''
return false
}
if (validation.reason !== null) {
customError.value = validation.message
emitInvalid(validation.reason, validation.attemptedValue, 'custom')
return false
}
customError.value = ''
lastValidCustomValue.value = 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) {
lastValidCustomValue.value = validation.value
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()
emit('blur', event)
}
function onCustomKeydown(event) {
if (props.disabled) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
validateCustomDraft()
return
}
if (event.key !== 'Escape') {
return
}
event.preventDefault()
const previousValue = currentValue.value
if (lastValidCustomValue.value !== null) {
customDraft.value = String(lastValidCustomValue.value)
customError.value = ''
emitModelValue(lastValidCustomValue.value, previousValue, 'custom', {
statusMessage: resolveMessage('customAmountReset', formatAmountValue(lastValidCustomValue.value, amountFormatter.value), lastValidCustomValue.value),
})
return
}
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>