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:
2026-06-11 17:11:30 +02:00
parent fee894e895
commit b77ab37cf4
6 changed files with 119 additions and 8 deletions
+49
View File
@@ -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)', () => {
it('émet valid=true au montage avec une valeur valide', () => {
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)', () => {
it('émet valid=true au montage avec une valeur valide', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
+1 -1
View File
@@ -14,7 +14,7 @@
:success="success"
:clearable="clearable"
:editable="editable"
mask="##/##/#### ##:##"
placeholder-template="JJ/MM/AAAA HH:MM"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
@@ -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,7 +150,7 @@ const props = withDefaults(
success?: string
clearable?: boolean
editable?: boolean
mask?: string
placeholderTemplate?: string
inputClass?: string
labelClass?: string
groupClass?: string
@@ -156,7 +169,7 @@ const props = withDefaults(
success: '',
clearable: true,
editable: false,
mask: '##/##/####',
placeholderTemplate: 'JJ/MM/AAAA',
inputClass: '',
labelClass: '',
groupClass: '',
@@ -174,9 +187,22 @@ const generatedId = useId()
const root = ref<HTMLElement | null>(null)
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)
// 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
})
@@ -191,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,
)
@@ -231,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)
@@ -296,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,
),
)