Files
malio-layer-ui/app/components/malio/date/internal/CalendarField.vue
T
tristan 9f9723d01c feat(ui) : MalioDate/DateTime — validité, saisie clavier & gabarit (#MUI-43) (#71)
Ticket MUI-43 : exposer l'état de validité de MalioDate (saisie invalide avalée silencieusement) + portage de la saisie clavier sur MalioDateTime.

## Contenu
**MalioDate**
- Nouvel event `update:valid(boolean)` : `false` sur saisie malformée ou hors min/max (qui n'émet pas `modelValue`), `true` sinon ; émis dès le montage. La validité ne couvre pas `required` (champ vide = valide).

**MalioDateTime**
- Prop `editable` : saisie clavier `JJ/MM/AAAA HH:MM` (masque maska, validation au blur/Entrée, `invalidMessage`) + même `update:valid`.
- Nouveau parseur `parseDisplayToIsoDateTime`.

**Famille Date editable (Date + DateTime)**
- Gabarit fantôme progressif : le format s'affiche en gris et se remplit au fil de la saisie (overlay ghost mirror, texte de l'input transparent).
- Séparateurs (/, espace, :) posés automatiquement (maska `eager`), espace insécable pour éviter le collage `12/12/1999HH:MM`.
- `CalendarField` : prop `placeholderTemplate` (le masque maska en est dérivé).

**Corrections**
- La croix d'effacement réinitialise la saisie clavier même après une date invalide (le v-model restant null, le champ ne se vidait pas).
- Fix d'un test `Date.test.ts` cassé sur develop (`trigger('keydown.enter')` envoie key='enter' ≠ handler `e.key === 'Enter'`).

## Portée
MalioDate seul pour la validité (les cousins DateRange/DateWeek n'ont pas de saisie clavier donc pas le bug). Sémantique `valid` = malformé only.

## Tests
`app/components/malio/date/` : 187/187, ESLint propre. Vérifié visuellement dans le playground (page Date & heure).

## Doc
COMPONENTS.md + CHANGELOG.md à jour.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #71
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 15:16:10 +00:00

372 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
v-maska="maskaOptions"
:name="name"
data-test="date-input"
:readonly="inputReadonly"
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="editable ? draft : displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
@focus="onFocus(); onKbdFocus()"
@input="onInput"
@blur="onBlur(); onKbdBlur()"
@keydown="onKeydown"
>
<div
v-if="showGhost"
data-test="format-ghost"
aria-hidden="true"
class="pointer-events-none absolute left-0 right-0 top-1/2 flex h-10 -translate-y-1/2 items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent pl-3 pr-10 text-lg"
><span
data-test="ghost-typed"
class="text-black"
>{{ ghostTyped }}</span><span
data-test="ghost-remaining"
class="text-m-muted"
>{{ ghostRemaining }}</span></div>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
aria-label="Effacer la date"
@click.stop="onClearClick"
>
<Icon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="goToPrev"
@next="goToNext"
@toggle-view="toggleView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</div>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import {vMaska} from 'maska/vue'
import type {MaskInputOptions} from 'maska'
import MalioRequiredMark from '../../shared/RequiredMark.vue'
import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
displayValue: string
syncTo: string | null
id?: string
name?: string
label?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
editable?: boolean
placeholderTemplate?: string
inputClass?: string
labelClass?: string
groupClass?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
name: '',
label: '',
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
editable: false,
placeholderTemplate: 'JJ/MM/AAAA',
inputClass: '',
labelClass: '',
groupClass: '',
reserveMessageSpace: true,
},
)
const emit = defineEmits<{
(e: 'clear' | 'close'): void
(e: 'commit', value: string): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const draft = ref(props.displayValue)
// Le masque maska est dérivé du gabarit (lettres → slot `#`, séparateurs conservés).
// eager : pose les séparateurs (/, espace, :) dès qu'un groupe est complet.
const maskaOptions = computed<MaskInputOptions>(() => ({
mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined,
eager: props.editable,
}))
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
// par-dessus l'input (dont le texte est rendu transparent en mode editable).
// Espaces → insécables : un espace en bord de span (flex-item) serait sinon rogné,
// collant la suite du gabarit à la date (« 12/12/1999HH:MM »).
const nbsp = (s: string) => s.replace(/ /g, ' ')
const ghostTyped = computed(() => nbsp(draft.value))
const ghostRemaining = computed(() => nbsp(props.placeholderTemplate.slice(draft.value.length)))
watch(() => props.displayValue, (value) => {
draft.value = value
})
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() =>
(props.editable ? draft.value.length : props.displayValue.length) > 0,
)
const isReadonly = computed(() => props.readonly && !props.disabled)
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(isOpen, (value) => {
if (!value) emit('close')
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (props.editable) {
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
return
}
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
const onFocus = () => {
if (props.disabled || props.readonly || !props.editable) return
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
}
const onInput = (event: Event) => {
draft.value = (event.target as HTMLInputElement).value
}
// Reset local immédiat : sur saisie invalide, modelValue est déjà null, donc le
// watch(displayValue) ne se redéclenche pas — il faut vider le draft soi-même.
const onClearClick = () => {
draft.value = ''
emit('clear')
}
const onBlur = () => {
if (!props.editable) return
emit('commit', draft.value)
}
const onEnter = () => {
if (!props.editable) return
emit('commit', draft.value)
closePopover()
}
const onKeydown = (e: KeyboardEvent) => {
if (props.disabled || props.readonly) return
if (e.key === 'Escape') {
if (isOpen.value) {
e.preventDefault()
closePopover()
}
return
}
if (props.editable) {
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
if (e.key === 'Enter') {
e.preventDefault()
onEnter()
}
return
}
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onFieldClick()
}
}
watch(() => props.syncTo, (value) => {
if (isOpen.value) syncToIso(value)
})
const onSelectMonth = (m: number) => {
selectMonth(m)
toggleView()
}
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
// En mode editable, le texte réel est masqué : c'est le gabarit fantôme qui l'affiche.
props.editable ? 'text-transparent caret-black' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>