Files
malio-layer-ui/app/components/malio/input/InputNumber.vue
T
tristan cda0f994ca feat(ui) : prop reserveMessageSpace (défaut true) sur la famille input
Ajoute une prop booléenne reserveMessageSpace (défaut true) aux 10 composants
de la famille input. Par défaut, comportement inchangé (ligne message toujours
rendue avec min-h-[1rem]). À false, la ligne ne prend aucun espace en l'absence
de message, et s'affiche sans min-h quand un message est présent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:43:31 +02:00

310 lines
8.9 KiB
Vue

<template>
<div>
<div :class="mergedGroupClass" >
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<button
type="button"
:disabled="isMinusDisabled"
@click="decrement"
>
<IconifyIcon
icon="mdi:minus"
:class="mergedButtonMinusClass"
/>
</button>
<input
:id="inputId"
:name="name"
autocomplete="off"
:class="mergedInputClass"
:style="inputWidthStyle"
:value="displayedValue"
:required="required"
:disabled="disabled"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
type="text"
inputmode="numeric"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<button
type="button"
:disabled="isPlusDisabled"
@click="increment"
>
<IconifyIcon
icon="mdi:plus"
:class="mergedButtonPlusClass"
/>
</button>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: string | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
min?: number | string
max?: number | string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
name: '',
modelValue: undefined,
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
min: undefined,
max: undefined,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
// Ajoute un separateur de milliers pour l'affichage dans le champ.
const formatDisplayValue = (value: string) => {
if (!value) return ''
const [integerPart = '', decimalPart] = value.split('.')
const formattedIntegerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
if (decimalPart !== undefined) {
return `${formattedIntegerPart}.${decimalPart}`
}
return formattedIntegerPart
}
// Valeur visible dans l'input, avec formatage des milliers.
const displayedValue = computed(() => formatDisplayValue(currentValue.value))
const inputCharacterWidth = computed(() => Math.max(displayedValue.value.length, 1))
// Transforme min/max en nombres utilisables.
const parseBound = (value: number | string | undefined) => {
if (value === undefined || value === '') return undefined
const parsedValue = Number.parseFloat(String(value).replace(',', '.'))
return Number.isNaN(parsedValue) ? undefined : parsedValue
}
const minValue = computed(() => parseBound(props.min))
const maxValue = computed(() => parseBound(props.max))
// Recupere la valeur numerique brute actuellement saisie.
const currentNumericValue = computed(() => {
if (currentValue.value === '') return undefined
const parsedValue = Number.parseFloat(currentValue.value)
return Number.isNaN(parsedValue) ? undefined : parsedValue
})
const inputWidthStyle = computed(() => ({
width: `calc(${inputCharacterWidth.value}ch + 30px)`,
maxWidth: '100%',
}))
const isMinusDisabled = computed(() =>
props.disabled || currentNumericValue.value <= minValue.value,
)
const isPlusDisabled = computed(() =>
props.disabled || currentNumericValue.value >= maxValue.value,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'cursor-pointer text-black mr-4 text-[18px]',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
props.disabled ? 'cursor-not-allowed text-black/60' : '',
props.labelClass,
),
)
const mergedButtonMinusClass = computed(() =>
twMerge(
'h-[22px] w-[40px] border border-black rounded-s-[3px]',
isMinusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: '',
),
)
const mergedButtonPlusClass = computed(() =>
twMerge(
'h-[22px] w-[40px] border border-black rounded-e-[3px]',
isPlusDisabled.value ? 'cursor-not-allowed text-black/60' : 'cursor-pointer',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: '',
),
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
if (hasError.value) ids.push(`${inputId.value}-error`)
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
return ids.length ? ids.join(' ') : undefined
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
// Met a jour l'etat local si besoin puis emet la valeur brute.
const updateValue = (value: string) => {
if (!isControlled.value) {
localValue.value = value
}
emit('update:modelValue', value)
}
// Force la valeur a rester entre les bornes min et max.
const clampValue = (value: number) => {
if (minValue.value !== undefined && value < minValue.value) return minValue.value
if (maxValue.value !== undefined && value > maxValue.value) return maxValue.value
return value
}
// Garde uniquement les chiffres et la virgule puis applique les bornes.
const normalizeValue = (value: string) => {
const sanitizedValue = value
.replace(/[^\d,.]/g, '')
.replace(/,/g, '.')
const [integerPart = '', ...decimalParts] = sanitizedValue.split('.')
const decimalPart = decimalParts.join('')
const hasDecimalSeparator = sanitizedValue.includes('.')
if (hasDecimalSeparator) {
const normalizedValue = `${integerPart || '0'}.${decimalPart}`
const parsedValue = Number.parseFloat(normalizedValue)
if (Number.isNaN(parsedValue)) return ''
const clampedValue = clampValue(parsedValue)
if (clampedValue !== parsedValue) return String(clampedValue)
return decimalPart === '' ? `${integerPart || '0'}.` : normalizedValue
}
return integerPart === '' ? '' : String(clampValue(Number.parseFloat(integerPart)))
}
// Reformate l'affichage dans le champ tout en conservant une valeur brute pour le v-model.
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const normalizedValue = normalizeValue(target.value)
target.value = formatDisplayValue(normalizedValue)
updateValue(normalizedValue)
}
// Retourne la valeur numerique courante, ou 0 si le champ est vide.
const getNumericValue = () => {
const parsedValue = Number.parseFloat(currentValue.value || '0')
return Number.isNaN(parsedValue) ? 0 : parsedValue
}
// Retire 1 a la valeur si l'action est autorisee.
const decrement = () => {
if (props.disabled || props.readonly || isMinusDisabled.value) return
updateValue(String(clampValue(getNumericValue() - 1)))
}
// Ajoute 1 a la valeur si l'action est autorisee.
const increment = () => {
if (props.disabled || props.readonly || isPlusDisabled.value) return
updateValue(String(clampValue(getNumericValue() + 1)))
}
</script>