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>
This commit was merged in pull request #71.
This commit is contained in:
@@ -29,6 +29,19 @@
|
||||
@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"
|
||||
@@ -44,7 +57,7 @@
|
||||
data-test="clear"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
aria-label="Effacer la date"
|
||||
@click.stop="emit('clear')"
|
||||
@click.stop="onClearClick"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:close"
|
||||
@@ -137,6 +150,7 @@ const props = withDefaults(
|
||||
success?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
placeholderTemplate?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -155,6 +169,7 @@ const props = withDefaults(
|
||||
success: '',
|
||||
clearable: true,
|
||||
editable: false,
|
||||
placeholderTemplate: 'JJ/MM/AAAA',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
@@ -172,9 +187,22 @@ const generatedId = useId()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const draft = ref(props.displayValue)
|
||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
|
||||
// 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
|
||||
})
|
||||
@@ -189,6 +217,7 @@ 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,
|
||||
)
|
||||
@@ -229,6 +258,13 @@ 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)
|
||||
@@ -294,6 +330,8 @@ const mergedInputClass = computed(() =>
|
||||
: 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,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user