be3d88ed45
## Problème Sur la famille Date editable, le masque maska n'imposait que la *forme* (`##/##/####`). Une valeur structurellement absurde comme `99/99/9999` était donc **saisissable**, puis rejetée *a posteriori* par la validation. Le métier veut que ce soit **impossible à taper**. ## Solution (masque borné + validation en filet) - `composables/maskTemplate.ts` — `buildBoundedMask(template)` : borne le **premier chiffre de chaque champ** (jour `0-3`, mois `0-1`, heure `0-2`, minute `0-5`). Distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit, pour ne pas brider la saisie des minutes du DateTime. - `internal/CalendarField.vue` — branche le builder dans `maskaOptions` (remplace le `replace(/[A-Za-z]/g, '#')`). Les impossibilités plus fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la **validation** (`invalidMessage` + `update:valid=false`). ## Tests - `maskTemplate.test.ts` (5) — bornes par champ, structure du masque, non-confusion mois/minutes. - `Date.test.ts` — test `invalidMessage` adapté (`32/13/2026`, typable→invalide) + garde de non-régression : `99/99/9999` ne s'inscrit jamais et n'émet aucune date. - Suite complète : **1004/1004 verte** (DateTime 36 incluse → saisie d'heure intacte). Doc : `COMPONENTS.md` (MalioDate) + `CHANGELOG.md` (Fixed) à jour. Reviewed-on: #79 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
import type {MaskInputOptions} from 'maska'
|
|
|
|
// Un champ numérique du gabarit : sa longueur et la plage de valeurs autorisée.
|
|
interface Field {
|
|
length: number
|
|
min: number
|
|
max: number
|
|
}
|
|
|
|
// Découpe un gabarit (ex. `JJ/MM/AAAA HH:MM`) en champs numériques borné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-59 vs 1-12).
|
|
function parseFields(template: string): Field[] {
|
|
const fields: Field[] = []
|
|
let seenHour = false
|
|
let i = 0
|
|
|
|
while (i < template.length) {
|
|
const ch = template[i]!
|
|
if (!/[A-Za-z]/.test(ch)) {
|
|
i++ // séparateur (/, espace, :)
|
|
continue
|
|
}
|
|
|
|
let j = i
|
|
while (j < template.length && template[j] === ch) j++
|
|
const length = j - i
|
|
const letter = ch.toUpperCase()
|
|
if (letter === 'H') seenHour = true
|
|
|
|
if (letter === 'J') fields.push({length, min: 1, max: 31})
|
|
else if (letter === 'M') fields.push(seenHour ? {length, min: 0, max: 59} : {length, min: 1, max: 12})
|
|
else if (letter === 'H') fields.push({length, min: 0, max: 23})
|
|
else fields.push({length, min: 0, max: 10 ** length - 1}) // année (ou autre) : libre
|
|
|
|
i = j
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// Un chiffre est accepté tant qu'il existe encore une complétion valide du champ :
|
|
// on borne la valeur partielle [min possible (padding 0), max possible (padding 9)]
|
|
// et on vérifie qu'elle croise la plage autorisée [field.min, field.max].
|
|
function canComplete(partial: string, field: Field): boolean {
|
|
const low = Number(partial.padEnd(field.length, '0'))
|
|
const high = Number(partial.padEnd(field.length, '9'))
|
|
return high >= field.min && low <= field.max
|
|
}
|
|
|
|
// Ne conserve que les chiffres qui gardent chaque champ complétable, et s'arrête
|
|
// au premier chiffre invalide (rien de ce qui suit n'est réinterprété). maska
|
|
// réinsère ensuite les séparateurs via le masque structurel.
|
|
function clampDigits(rawDigits: string, fields: Field[]): string {
|
|
let result = ''
|
|
let di = 0
|
|
|
|
for (const field of fields) {
|
|
let fieldDigits = ''
|
|
while (fieldDigits.length < field.length) {
|
|
if (di >= rawDigits.length) return result + fieldDigits // plus de saisie
|
|
const candidate = fieldDigits + rawDigits[di]
|
|
if (!canComplete(candidate, field)) return result + fieldDigits // 1er chiffre invalide → stop
|
|
fieldDigits = candidate
|
|
di++
|
|
}
|
|
result += fieldDigits
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Construit les options maska d'un champ date/heure à partir d'un gabarit
|
|
* d'affichage (ex. `JJ/MM/AAAA`, `JJ/MM/AAAA HH:MM`).
|
|
*
|
|
* - `mask` : masque structurel (chiffres + séparateurs), pour le formatage/eager.
|
|
* - `preProcess` : borne la saisie AVANT masquage, sur le 1er **et** le 2e chiffre
|
|
* de chaque champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59), si bien
|
|
* qu'une valeur impossible (99/99/9999, 33, 19 en mois…) ne peut pas être tapée.
|
|
* Les impossibilités calendaires fines (31/02, 29/02 non bissextile) et les
|
|
* bornes `min`/`max` restent du ressort de la validation, en filet.
|
|
*/
|
|
export function buildBoundedMask(template: string): Pick<MaskInputOptions, 'mask' | 'preProcess'> {
|
|
const mask = template.replace(/[A-Za-z]/g, '#')
|
|
const fields = parseFields(template)
|
|
return {
|
|
mask,
|
|
preProcess: (value: string) => clampDigits(value.replace(/\D/g, ''), fields),
|
|
}
|
|
}
|