feat(ui) : gabarit fantome de saisie + saisie clavier DateTime + fix clear (#MUI-43)
- gabarit fantome progressif sur la famille Date editable (Date, DateTime) : le format s'affiche en gris et se remplit au fil de la saisie (overlay ghost) - separateurs (/, espace, :) poses automatiquement (maska eager) - espace insecable pour eviter le collage « 12/12/1999HH:MM » - CalendarField : prop placeholderTemplate (masque maska derive), remplace mask - fix : la croix reinitialise la saisie clavier meme apres une date invalide - tests + COMPONENTS.md + CHANGELOG.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -51,7 +51,9 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
|
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
|
||||||
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
|
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
|
||||||
* [#MUI-43] MalioDate : event `update:valid` (booléen) exposant l'état de validité de la saisie (`false` sur date malformée ou hors `min`/`max`, qui n'émet pas `modelValue`) — permet au parent de bloquer le submit ; la validité ne couvre pas `required` (champ vide = valide)
|
* [#MUI-43] MalioDate : event `update:valid` (booléen) exposant l'état de validité de la saisie (`false` sur date malformée ou hors `min`/`max`, qui n'émet pas `modelValue`) — permet au parent de bloquer le submit ; la validité ne couvre pas `required` (champ vide = valide)
|
||||||
* [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). CalendarField : masque maska configurable via prop `mask` (défaut `##/##/####` inchangé). Nouveau parseur `parseDisplayToIsoDateTime`.
|
* [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). Nouveau parseur `parseDisplayToIsoDateTime`.
|
||||||
|
* [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur.
|
||||||
|
* [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
|
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
|
||||||
|
|||||||
+2
-2
@@ -505,7 +505,7 @@ Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
|||||||
|
|
||||||
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
||||||
|
|
||||||
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`).
|
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format `JJ/MM/AAAA` en gris et se remplit au fur et à mesure de la saisie (caractères tapés en noir, reste du gabarit en gris).
|
||||||
|
|
||||||
L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent.
|
L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent.
|
||||||
|
|
||||||
@@ -698,7 +698,7 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
|
|||||||
|
|
||||||
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
|
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
|
||||||
|
|
||||||
Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`.
|
Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format en gris et se remplit au fil de la saisie (cf. MalioDate). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`.
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
|
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
|
||||||
|
|||||||
@@ -354,6 +354,55 @@ describe('MalioDate', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('gabarit de saisie (editable)', () => {
|
||||||
|
it('affiche le gabarit complet en gris quand editable + focus + vide', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||||
|
const ghost = wrapper.get('[data-test="format-ghost"]')
|
||||||
|
expect(ghost.text()).toBe('JJ/MM/AAAA')
|
||||||
|
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
await input.setValue('19')
|
||||||
|
// eager : le séparateur se pose dès que le groupe est complet (« 19 » → « 19/ »)
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text()).toBe('19/MM/AAAA')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').classes()).toContain('text-black')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pose le séparateur automatiquement dès qu\'un groupe est complet (eager)', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('1905')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('19/05/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||||
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit quand editable mais vide et non focus', () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vide le champ au clic sur la croix même après une saisie invalide (modelValue déjà null)', async () => {
|
||||||
|
const wrapper = mountDate({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.setValue('32/13/2026')
|
||||||
|
await input.trigger('blur')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||||
|
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('état de validité (update:valid)', () => {
|
describe('état de validité (update:valid)', () => {
|
||||||
it('émet valid=true au montage avec une valeur valide', () => {
|
it('émet valid=true au montage avec une valeur valide', () => {
|
||||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||||
|
|||||||
@@ -200,6 +200,30 @@ describe('MalioDateTime', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('gabarit de saisie (editable)', () => {
|
||||||
|
it('affiche le gabarit date+heure complet en gris quand editable + focus + vide', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('focus')
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('JJ/MM/AAAA HH:MM')
|
||||||
|
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
|
||||||
|
const wrapper = mountDateTime({editable: true})
|
||||||
|
const input = wrapper.get('[data-test="date-input"]')
|
||||||
|
await input.trigger('focus')
|
||||||
|
await input.setValue('190520')
|
||||||
|
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('19/05/20AA HH:MM')
|
||||||
|
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/05/20')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche pas de gabarit en mode non editable', async () => {
|
||||||
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
|
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('état de validité (update:valid)', () => {
|
describe('état de validité (update:valid)', () => {
|
||||||
it('émet valid=true au montage avec une valeur valide', () => {
|
it('émet valid=true au montage avec une valeur valide', () => {
|
||||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
:success="success"
|
:success="success"
|
||||||
:clearable="clearable"
|
:clearable="clearable"
|
||||||
:editable="editable"
|
:editable="editable"
|
||||||
mask="##/##/#### ##:##"
|
placeholder-template="JJ/MM/AAAA HH:MM"
|
||||||
:input-class="inputClass"
|
:input-class="inputClass"
|
||||||
:label-class="labelClass"
|
:label-class="labelClass"
|
||||||
:group-class="groupClass"
|
:group-class="groupClass"
|
||||||
|
|||||||
@@ -29,6 +29,19 @@
|
|||||||
@keydown="onKeydown"
|
@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
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="inputId"
|
:for="inputId"
|
||||||
@@ -44,7 +57,7 @@
|
|||||||
data-test="clear"
|
data-test="clear"
|
||||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||||
aria-label="Effacer la date"
|
aria-label="Effacer la date"
|
||||||
@click.stop="emit('clear')"
|
@click.stop="onClearClick"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:close"
|
icon="mdi:close"
|
||||||
@@ -137,7 +150,7 @@ const props = withDefaults(
|
|||||||
success?: string
|
success?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
mask?: string
|
placeholderTemplate?: string
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
@@ -156,7 +169,7 @@ const props = withDefaults(
|
|||||||
success: '',
|
success: '',
|
||||||
clearable: true,
|
clearable: true,
|
||||||
editable: false,
|
editable: false,
|
||||||
mask: '##/##/####',
|
placeholderTemplate: 'JJ/MM/AAAA',
|
||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
@@ -174,9 +187,22 @@ const generatedId = useId()
|
|||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const draft = ref(props.displayValue)
|
const draft = ref(props.displayValue)
|
||||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? props.mask : 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)
|
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) => {
|
watch(() => props.displayValue, (value) => {
|
||||||
draft.value = value
|
draft.value = value
|
||||||
})
|
})
|
||||||
@@ -191,6 +217,7 @@ const isFilled = computed(() =>
|
|||||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||||
)
|
)
|
||||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||||
|
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
|
||||||
const showClear = computed(() =>
|
const showClear = computed(() =>
|
||||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||||
)
|
)
|
||||||
@@ -231,6 +258,13 @@ const onInput = (event: Event) => {
|
|||||||
draft.value = (event.target as HTMLInputElement).value
|
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 = () => {
|
const onBlur = () => {
|
||||||
if (!props.editable) return
|
if (!props.editable) return
|
||||||
emit('commit', draft.value)
|
emit('commit', draft.value)
|
||||||
@@ -296,6 +330,8 @@ const mergedInputClass = computed(() =>
|
|||||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||||
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
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,
|
props.inputClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user