From 4a933da19e82a3c406159715608e1c4775161883 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 18 Jun 2026 16:35:25 +0200 Subject: [PATCH] fix(date) : borne le 2e chiffre par champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le bornage par tokens ne contraignait que le 1er chiffre (positionnel, sans mémoire du chiffre précédent) : 33 (jour) ou 19 (mois) restaient tapables. Remplacé par un preProcess maska qui valide chaque champ progressivement : un chiffre n'est accepté que s'il existe encore une complétion dans [min, max]. Borne donc le 1er ET le 2e chiffre ; les impossibilités calendaires fines (31/02, 29/02 non bissextile, hors min/max) restent captées par la validation. Tests d'intégration : 32/13 (désormais non tapable) remplacé par 31/02 comme date « champs valides mais inexistante » ; garde sur l'exemple métier 33/19. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- COMPONENTS.md | 2 +- app/components/malio/date/Date.test.ts | 43 ++++--- app/components/malio/date/DateTime.test.ts | 21 ++-- .../date/composables/maskTemplate.test.ts | 77 ++++++------ .../malio/date/composables/maskTemplate.ts | 115 ++++++++++++------ .../malio/date/internal/CalendarField.vue | 12 +- 7 files changed, 159 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e87c81a..0f78b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +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. +* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il 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 171dcc6..4104723 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 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). +Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La saisie est **bornée par champ** (1er *et* 2e chiffre) : jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`, si bien qu'une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut pas être tapée. Les impossibilités calendaires 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 12743b8..0754fe4 100644 --- a/app/components/malio/date/Date.test.ts +++ b/app/components/malio/date/Date.test.ts @@ -308,7 +308,7 @@ describe('MalioDate', () => { it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => { const wrapper = mountDate({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026') + await input.setValue('31/02/2026') await input.trigger('blur') expect(wrapper.text()).toContain('Date invalide') await wrapper.setProps({modelValue: '2026-05-19'}) @@ -338,10 +338,10 @@ describe('MalioDate', () => { it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => { const wrapper = mountDate({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026') + await input.setValue('31/02/2026') await input.trigger('blur') expect(wrapper.emitted('update:modelValue')).toBeUndefined() - expect((input.element as HTMLInputElement).value).toBe('32/13/2026') + expect((input.element as HTMLInputElement).value).toBe('31/02/2026') expect(input.attributes('aria-invalid')).toBe('true') expect(wrapper.text()).toContain('Date invalide') }) @@ -366,7 +366,7 @@ describe('MalioDate', () => { it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => { const wrapper = mountDate({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026') + await input.setValue('31/02/2026') await input.trigger('blur') expect(wrapper.text()).toContain('Date invalide') await input.trigger('focus') @@ -391,8 +391,8 @@ 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"]') - // 32/13/2026 : structurellement saisissable (3 ≤ 3, 1 ≤ 1) mais date inexistante. - await input.setValue('32/13/2026') + // 31/02/2026 : champs valides (jour ≤ 31, mois ≤ 12) mais le 31 février n'existe pas. + await input.setValue('31/02/2026') await input.trigger('blur') expect(wrapper.text()).toContain('Format incorrect') }) @@ -402,12 +402,23 @@ describe('MalioDate', () => { 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. + // Le bornage refuse « 9 » dès le 1er chiffre du jour/mois : 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) }) + + it('empêche un jour > 31 ou un mois > 12 (exemple métier 33/19, 2e chiffre borné)', async () => { + const wrapper = mountDate({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + // 33 (jour) : le 2e « 3 » est refusé → seul « 3 » subsiste. + await input.setValue('33') + expect((input.element as HTMLInputElement).value).toBe('3') + // 19 en mois : après un jour valide, le 2e chiffre du mois (« 9 ») est refusé. + await input.setValue('15/19') + expect((input.element as HTMLInputElement).value).not.toContain('19') + }) }) describe('gabarit de saisie (editable)', () => { @@ -451,9 +462,9 @@ describe('MalioDate', () => { 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.setValue('31/02/2026') await input.trigger('blur') - expect((input.element as HTMLInputElement).value).toBe('32/13/2026') + expect((input.element as HTMLInputElement).value).toBe('31/02/2026') await wrapper.get('[data-test="clear"]').trigger('click') expect((input.element as HTMLInputElement).value).toBe('') }) @@ -481,7 +492,7 @@ describe('MalioDate', () => { it('émet valid=false sur saisie malformée sans émettre modelValue', async () => { const wrapper = mountDate({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026') + await input.setValue('31/02/2026') await input.trigger('blur') expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) expect(wrapper.emitted('update:modelValue')).toBeUndefined() @@ -520,7 +531,7 @@ describe('MalioDate', () => { it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => { const wrapper = mountDate({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026') + await input.setValue('31/02/2026') await input.trigger('blur') expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) await wrapper.setProps({modelValue: '2026-05-19'}) @@ -532,9 +543,9 @@ describe('MalioDate', () => { it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => { const wrapper = mountDate({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026') + await input.setValue('31/02/2026') await input.trigger('blur') - expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026']) + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026']) expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) @@ -573,9 +584,9 @@ describe('MalioDate', () => { it('émet rawValue vide quand on sélectionne une date au calendrier', async () => { const wrapper = mountDate({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026') + await input.setValue('31/02/2026') await input.trigger('blur') - expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026']) + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026']) await input.trigger('focus') await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) diff --git a/app/components/malio/date/DateTime.test.ts b/app/components/malio/date/DateTime.test.ts index e328f10..2316229 100644 --- a/app/components/malio/date/DateTime.test.ts +++ b/app/components/malio/date/DateTime.test.ts @@ -145,10 +145,10 @@ describe('MalioDateTime', () => { it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => { const wrapper = mountDateTime({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026 14:30') + await input.setValue('31/02/2026 14:30') await input.trigger('blur') expect(wrapper.emitted('update:modelValue')).toBeUndefined() - expect((input.element as HTMLInputElement).value).toBe('32/13/2026 14:30') + expect((input.element as HTMLInputElement).value).toBe('31/02/2026 14:30') expect(input.attributes('aria-invalid')).toBe('true') expect(wrapper.text()).toContain('Date invalide') }) @@ -197,7 +197,8 @@ describe('MalioDateTime', () => { it('utilise le message invalidMessage personnalisé', async () => { const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('99/99/9999 10:00') + // 31/02 : champs valides mais date inexistante (le masque la laisse passer, la validation la rejette). + await input.setValue('31/02/2026 10:00') await input.trigger('blur') expect(wrapper.text()).toContain('Format incorrect') }) @@ -205,7 +206,7 @@ describe('MalioDateTime', () => { it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => { const wrapper = mountDateTime({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026 14:30') + await input.setValue('31/02/2026 14:30') await input.trigger('blur') expect(wrapper.text()).toContain('Date invalide') await wrapper.setProps({modelValue: '2026-05-20T14:30:00'}) @@ -259,7 +260,7 @@ describe('MalioDateTime', () => { it('émet valid=false sur saisie malformée sans émettre modelValue', async () => { const wrapper = mountDateTime({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026 14:30') + await input.setValue('31/02/2026 14:30') await input.trigger('blur') expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) expect(wrapper.emitted('update:modelValue')).toBeUndefined() @@ -289,7 +290,7 @@ describe('MalioDateTime', () => { it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => { const wrapper = mountDateTime({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026 14:30') + await input.setValue('31/02/2026 14:30') await input.trigger('blur') expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) await wrapper.setProps({modelValue: '2026-05-20T14:30:00'}) @@ -301,9 +302,9 @@ describe('MalioDateTime', () => { it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => { const wrapper = mountDateTime({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026 14:30') + await input.setValue('31/02/2026 14:30') await input.trigger('blur') - expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30']) + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30']) expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) @@ -342,9 +343,9 @@ describe('MalioDateTime', () => { it('émet rawValue vide quand on sélectionne une date au calendrier', async () => { const wrapper = mountDateTime({editable: true}) const input = wrapper.get('[data-test="date-input"]') - await input.setValue('32/13/2026 14:30') + await input.setValue('31/02/2026 14:30') await input.trigger('blur') - expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30']) + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30']) await input.trigger('focus') await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) diff --git a/app/components/malio/date/composables/maskTemplate.test.ts b/app/components/malio/date/composables/maskTemplate.test.ts index de049a5..331fac2 100644 --- a/app/components/malio/date/composables/maskTemplate.test.ts +++ b/app/components/malio/date/composables/maskTemplate.test.ts @@ -2,44 +2,43 @@ 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('#') + it('dérive le masque structurel du gabarit (séparateurs conservés)', () => { + expect(buildBoundedMask('JJ/MM/AAAA').mask).toBe('##/##/####') + expect(buildBoundedMask('JJ/MM/AAAA HH:MM').mask).toBe('##/##/#### ##:##') + }) +}) + +describe('preProcess — bornage de la saisie (1er ET 2e chiffre)', () => { + const pre = (template: string, value: string) => buildBoundedMask(template).preProcess!(value) + + it('jour : refuse > 31 et 00, accepte 01-31', () => { + expect(pre('JJ/MM/AAAA', '32')).toBe('3') // 32 impossible → 2e chiffre refusé + expect(pre('JJ/MM/AAAA', '33')).toBe('3') // exemple métier : 33 refusé + expect(pre('JJ/MM/AAAA', '31')).toBe('31') + expect(pre('JJ/MM/AAAA', '00')).toBe('0') // 00 impossible + expect(pre('JJ/MM/AAAA', '09')).toBe('09') + }) + + it('mois : refuse > 12 et 00 (après un jour valide) — cas 33/19', () => { + expect(pre('JJ/MM/AAAA', '0119')).toBe('011') // 19 (mois) refusé + expect(pre('JJ/MM/AAAA', '0113')).toBe('011') + expect(pre('JJ/MM/AAAA', '0112')).toBe('0112') + expect(pre('JJ/MM/AAAA', '0100')).toBe('010') + }) + + it('laisse l’année libre', () => { + expect(pre('JJ/MM/AAAA', '01012026')).toBe('01012026') + }) + + it('heure 00-23 et minute 00-59 (datetime), sans confondre minute et mois', () => { + const t = 'JJ/MM/AAAA HH:MM' + expect(pre(t, '010120262300')).toBe('010120262300') // 23:00 ok + expect(pre(t, '010120262460')).toBe('010120262') // heure 24 refusée + expect(pre(t, '010120261259')).toBe('010120261259') // minute 59 ok (≠ mois) + expect(pre(t, '010120261260')).toBe('0101202612') // minute 60 refusée + }) + + it('stoppe à la première saisie invalide (99/99/9999 → rien)', () => { + expect(pre('JJ/MM/AAAA', '99/99/9999')).toBe('') }) }) diff --git a/app/components/malio/date/composables/maskTemplate.ts b/app/components/malio/date/composables/maskTemplate.ts index 34f476e..1aae76f 100644 --- a/app/components/malio/date/composables/maskTemplate.ts +++ b/app/components/malio/date/composables/maskTemplate.ts @@ -1,56 +1,91 @@ -import type {MaskTokens} from 'maska' +import type {MaskInputOptions} 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 +// Un champ numérique du gabarit : sa longueur et la plage de valeurs autorisée. +interface Field { + length: number + min: number + max: number } -/** - * 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 = '' +// 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 - for (const ch of template) { + while (i < template.length) { + const ch = template[i]! if (!/[A-Za-z]/.test(ch)) { - mask += ch // séparateur (/, espace, :) - prev = 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 - 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 - } + 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 - prev = ch + i = j } - return {mask, tokens: BOUND_TOKENS} + 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 { + const mask = template.replace(/[A-Za-z]/g, '#') + const fields = parseFields(template) + return { + mask, + preProcess: (value: string) => clampDigits(value.replace(/\D/g, ''), fields), + } } diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue index a63361f..73cc8cc 100644 --- a/app/components/malio/date/internal/CalendarField.vue +++ b/app/components/malio/date/internal/CalendarField.vue @@ -191,14 +191,14 @@ const generatedId = useId() const root = ref(null) const draft = ref(props.displayValue) -// 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. +// Le masque maska est dérivé du gabarit : masque structurel pour le formatage, +// + preProcess qui borne la saisie (1er ET 2e chiffre : jour 1-31, mois 1-12, +// heure 0-23, minute 0-59) afin qu'une valeur impossible (99/99/9999, 33, mois 19…) +// ne puisse pas être tapée. eager : pose les séparateurs dès qu'un groupe est complet. const maskaOptions = computed(() => { if (!props.editable) return {eager: false} - const {mask, tokens} = buildBoundedMask(props.placeholderTemplate) - return {mask, tokens, eager: true} + const {mask, preProcess} = buildBoundedMask(props.placeholderTemplate) + return {mask, preProcess, eager: true} }) const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)