From e8ee70e7fc844299ff99d6f9dd2658d9cc17ff07 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 18 Jun 2026 16:18:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(date)=20:=20borne=20la=20saisie=20clavier?= =?UTF-8?q?=20pour=20emp=C3=AAcher=20les=20dates=20absurdes=20(99/99/9999)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le masque maska n'imposait que la forme (##/##/####), donc 99/99/9999 était saisissable puis rejeté a posteriori par la validation. Le métier veut que ce soit impossible à taper. buildBoundedMask(template) borne le premier chiffre de chaque champ (jour 0-3, mois 0-1, heure 0-2, minute 0-5) ; il distingue le mois des minutes (même lettre M) selon la présence d'heures. Les impossibilités fines (31/02, 29/02 non bissextile, hors min/max) restent captées par la validation, en filet. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + COMPONENTS.md | 2 +- app/components/malio/date/Date.test.ts | 15 ++++- .../date/composables/maskTemplate.test.ts | 45 +++++++++++++++ .../malio/date/composables/maskTemplate.ts | 56 +++++++++++++++++++ .../malio/date/internal/CalendarField.vue | 14 +++-- 6 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 app/components/malio/date/composables/maskTemplate.test.ts create mode 100644 app/components/malio/date/composables/maskTemplate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f999c..e87c81a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants. ### Fixed +* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par le masque** sur le premier chiffre de chaque champ (jour `0-3`, mois `0-1`, heure `0-2`, minute `0-5`) — une valeur structurellement absurde comme `99/99/9999` ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités plus fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Le masque est construit par `buildBoundedMask(template)` (CalendarField), qui distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit. * DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24) * Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer * Hauteur des boutons de pagination du datatable alignée sur le select (40px) diff --git a/COMPONENTS.md b/COMPONENTS.md index cb40887..171dcc6 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -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. -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). +Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La saisie est **bornée par le masque** : le premier chiffre de chaque champ est contraint (jour `0-3`, mois `0-1`, heure `0-2`, minute `0-5`), si bien qu'une valeur structurellement absurde comme `99/99/9999` ne peut pas être tapée. Les impossibilités plus fines (`31/02`, 29/02 non bissextile, dépassement `min`/`max`) restent captées par la validation, en filet de sécurité. 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. diff --git a/app/components/malio/date/Date.test.ts b/app/components/malio/date/Date.test.ts index 7762404..12743b8 100644 --- a/app/components/malio/date/Date.test.ts +++ b/app/components/malio/date/Date.test.ts @@ -391,10 +391,23 @@ describe('MalioDate', () => { it('utilise le message invalidMessage personnalisé', async () => { const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('99/99/9999') + // 32/13/2026 : structurellement saisissable (3 ≤ 3, 1 ≤ 1) mais date inexistante. + await input.setValue('32/13/2026') await input.trigger('blur') expect(wrapper.text()).toContain('Format incorrect') }) + + it('empêche la frappe d\'une date absurde (99/99/9999 borné par le masque)', async () => { + const wrapper = mountDate({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('99/99/9999') + await input.trigger('blur') + // Le masque borne le 1er chiffre (jour 0-3, mois 0-1) : « 9 » est rejeté, + // la saisie absurde ne s'inscrit jamais et aucune date réelle n'est émise. + expect((input.element as HTMLInputElement).value).not.toContain('99') + const emitted = wrapper.emitted('update:modelValue') ?? [] + expect(emitted.every(([value]) => value === null)).toBe(true) + }) }) describe('gabarit de saisie (editable)', () => { diff --git a/app/components/malio/date/composables/maskTemplate.test.ts b/app/components/malio/date/composables/maskTemplate.test.ts new file mode 100644 index 0000000..de049a5 --- /dev/null +++ b/app/components/malio/date/composables/maskTemplate.test.ts @@ -0,0 +1,45 @@ +import {describe, it, expect} from 'vitest' +import {buildBoundedMask} from './maskTemplate' + +describe('buildBoundedMask', () => { + it('borne la dizaine du jour à 0-3 (bloque la saisie de 9 → 99 impossible)', () => { + const {mask, tokens} = buildBoundedMask('JJ/MM/AAAA') + const dayTens = tokens[mask[0]] + expect(dayTens.pattern.test('9')).toBe(false) + expect(dayTens.pattern.test('3')).toBe(true) + expect(dayTens.pattern.test('0')).toBe(true) + }) + + it('borne la dizaine du mois à 0-1 (bloque 9x)', () => { + const {mask, tokens} = buildBoundedMask('JJ/MM/AAAA') + const monthTens = tokens[mask[3]] + expect(monthTens.pattern.test('9')).toBe(false) + expect(monthTens.pattern.test('1')).toBe(true) + expect(monthTens.pattern.test('0')).toBe(true) + }) + + it('laisse l’année libre et conserve les séparateurs', () => { + const {mask} = buildBoundedMask('JJ/MM/AAAA') + expect(mask).toBe('d#/m#/####') + }) + + it('borne l’heure à 0-2 et la minute à 0-5 (datetime), sans confondre minute et mois', () => { + const {mask, tokens} = buildBoundedMask('JJ/MM/AAAA HH:MM') + expect(mask).toBe('d#/m#/#### h#:n#') + const hourTens = tokens[mask[11]] + expect(hourTens.pattern.test('2')).toBe(true) + expect(hourTens.pattern.test('9')).toBe(false) + const minuteTens = tokens[mask[14]] + // La minute doit monter jusqu’à 5 — surtout PAS bornée comme un mois (0-1). + expect(minuteTens.pattern.test('5')).toBe(true) + expect(minuteTens.pattern.test('2')).toBe(true) + expect(minuteTens.pattern.test('9')).toBe(false) + }) + + it('ne borne que le premier chiffre de chaque champ (les unités restent 0-9)', () => { + const {mask} = buildBoundedMask('JJ/MM/AAAA') + // unités jour (index 1) et mois (index 4) = chiffre libre '#' + expect(mask[1]).toBe('#') + expect(mask[4]).toBe('#') + }) +}) diff --git a/app/components/malio/date/composables/maskTemplate.ts b/app/components/malio/date/composables/maskTemplate.ts new file mode 100644 index 0000000..34f476e --- /dev/null +++ b/app/components/malio/date/composables/maskTemplate.ts @@ -0,0 +1,56 @@ +import type {MaskTokens} from 'maska' + +// Tokens maska bornant le PREMIER chiffre de chaque champ d'une date/heure. +// Objectif : rendre impossible la frappe de valeurs absurdes (ex. 99/99/9999) +// dès la saisie. Les impossibilités plus fines (31/02, 29/02 non bissextile, +// dépassement min/max) restent du ressort de la validation, en filet de sécurité. +const BOUND_TOKENS: MaskTokens = { + d: {pattern: /[0-3]/}, // jour : dizaine 0-3 + m: {pattern: /[0-1]/}, // mois : dizaine 0-1 + h: {pattern: /[0-2]/}, // heure : dizaine 0-2 + n: {pattern: /[0-5]/}, // minute : dizaine 0-5 +} + +/** + * Construit un masque maska borné à partir d'un gabarit d'affichage + * (ex. `JJ/MM/AAAA`, `JJ/MM/AAAA HH:MM`). + * + * Chaque lettre devient un slot chiffre : le premier chiffre d'un champ est borné + * (token dédié), les suivants restent libres (`#`). Les séparateurs sont conservés. + * + * Le `M` désigne le mois avant les heures, et les minutes après — d'où le suivi de + * `seenHour` pour ne pas borner les minutes comme un mois (0-1 au lieu de 0-5). + */ +export function buildBoundedMask(template: string): {mask: string, tokens: MaskTokens} { + let mask = '' + let prev = '' + let seenHour = false + + for (const ch of template) { + if (!/[A-Za-z]/.test(ch)) { + mask += ch // séparateur (/, espace, :) + prev = ch + continue + } + + const letter = ch.toUpperCase() + if (letter === 'H') seenHour = true + + const isFirstOfField = ch !== prev + if (!isFirstOfField) { + mask += '#' // unités : chiffre libre + } else if (letter === 'J') { + mask += 'd' + } else if (letter === 'M') { + mask += seenHour ? 'n' : 'm' + } else if (letter === 'H') { + mask += 'h' + } else { + mask += '#' // année (ou tout autre champ) : libre + } + + prev = ch + } + + return {mask, tokens: BOUND_TOKENS} +} diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue index a736af0..a63361f 100644 --- a/app/components/malio/date/internal/CalendarField.vue +++ b/app/components/malio/date/internal/CalendarField.vue @@ -128,6 +128,7 @@ import CalendarHeader from './CalendarHeader.vue' import MonthPicker from './MonthPicker.vue' import {useCalendarPopover} from '../composables/useCalendarPopover' import {useCalendarView} from '../composables/useCalendarView' +import {buildBoundedMask} from '../composables/maskTemplate' import {useKbdFocusRing} from '../../shared/useKbdFocusRing' defineOptions({name: 'MalioCalendarField', inheritAttrs: false}) @@ -190,12 +191,15 @@ const generatedId = useId() const root = ref(null) const draft = ref(props.displayValue) -// Le masque maska est dérivé du gabarit (lettres → slot `#`, séparateurs conservés). +// Le masque maska est dérivé du gabarit : chaque lettre devient un slot chiffre, +// le premier chiffre de chaque champ est borné (jour 0-3, mois 0-1, heure 0-2, +// minute 0-5) pour empêcher la frappe de valeurs absurdes (ex. 99/99/9999). // eager : pose les séparateurs (/, espace, :) dès qu'un groupe est complet. -const maskaOptions = computed(() => ({ - mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined, - eager: props.editable, -})) +const maskaOptions = computed(() => { + if (!props.editable) return {eager: false} + const {mask, tokens} = buildBoundedMask(props.placeholderTemplate) + return {mask, tokens, eager: true} +}) const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled) // Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché