Skip to content

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

Choose amount

Choose a preset amount.

Enter a custom amount between 100 and 5,000.

Choose amount

Choose a preset amount or 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

  • label is always visible and becomes the legend in presets and hybrid mode.
  • hint stays persistent in the UI and is linked through aria-describedby.
  • Custom validation errors are rendered as visible text and linked with aria-describedby plus aria-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>