Files
malio-layer-ui/app/components/malio/input/InputNumber.vue
T
tristan dc33cf4135 feat(inputs): UX polish across input family + localFilter + focus scrollbar
Polish across the form input components, plus two new features and a few
standalone fixes.

Fixes
-----
* Reserve hint/error/success paragraph space (min-h-[1rem]) in 15
  components so a single error message no longer shifts neighboring grid
  cells: InputText, Email, Password, Phone, Amount, Number, Upload,
  Autocomplete, RichText, TextArea, Select, SelectCheckbox, Time,
  TimePicker, CalendarField, Checkbox.
* InputPhone: the '+' add button now follows the icon-state cascade
  (muted / primary on focus / black when filled / danger / success) like
  the other field icons instead of being permanently primary.
* Select and SelectCheckbox: chevron color follows the field state
  (muted by default, primary when open, black when an option is
  selected, danger / success on error / success) instead of always being
  text-current.
* InputTextArea: single-root component (was multi-root). The message
  wrapper used to occupy its own grid cell, breaking row-span layouts.
  Now flex flex-col, with the textarea area filling the available height
  via flex-1 and the message inside the same root.
* Disabled labels use text-m-muted (border-gray) instead of text-black/60
  (dark) across InputText, Email, Password, Amount, Phone, Upload,
  Autocomplete, TextArea, RichText. Also removes an unreachable
  peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60 rule that
  twMerge was silently overriding with text-black.
* InputAutocomplete: eliminates four sources of visual jitter when
  focusing / opening a field that already has a selected value.
  - Drop peer-focus:-translate-y-[1.55rem] extra label translate.
  - Drop the .grow-height:focus padding rule (no more height growth or
    downward text shift on focus).
  - Drop focus:pl-[11px] (no more 1px horizontal jump).
  - Replace !border-b-0 with !border-b-transparent so the bottom border
    still reserves its 1px while remaining invisible against the
    dropdown.
* Select / SelectCheckbox: same anti-jitter treatment.
  - Drop .grow-height:focus padding rule (~12px height growth gone).
  - Replace !border-b-0 / !border-t-0 with !border-b-transparent /
    !border-t-transparent across danger / success / primary branches.
* Button: default width 240px -> 200px to match the form button sizing
  used across the app. Test updated to match.

Features
--------
* InputTextArea: scrollbar turns primary blue on focus
  (scrollbar-color: rgb(var(--m-primary)) transparent), matching the
  Select listbox styling.
* InputAutocomplete: new localFilter prop (default false). When enabled,
  filters the options prop client-side based on the input value
  (case-insensitive label.includes(query)), so static lists no longer
  need a @search listener. Async/API usage keeps the existing behavior.
  Playground "Simple statique" and "Avec icône à gauche" examples use
  local-filter.

Playground
----------
* client.vue: tighter grid gap (gap-y-5) plus an example error on a
  SelectCheckbox to visually exercise the message-space fix.

Tests
-----
All component test files include regression coverage for the above.
720/720 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:43:53 +02:00

305 lines
8.7 KiB
Vue

<template>
<div>
<div :class="mergedGroupClass" >
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</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
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px] 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'
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
}>(),
{
id: '',
name: '',
modelValue: undefined,
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
min: undefined,
max: undefined,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
},
)
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>