fix(date) : borne la saisie clavier pour empêcher les dates absurdes (99/99/9999)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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('#')
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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<HTMLElement | null>(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<MaskInputOptions>(() => ({
|
||||
mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined,
|
||||
eager: props.editable,
|
||||
}))
|
||||
const maskaOptions = computed<MaskInputOptions>(() => {
|
||||
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é
|
||||
|
||||
Reference in New Issue
Block a user