From e8ee70e7fc844299ff99d6f9dd2658d9cc17ff07 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 18 Jun 2026 16:18:54 +0200 Subject: [PATCH 1/9] =?UTF-8?q?fix(date)=20:=20borne=20la=20saisie=20clavi?= =?UTF-8?q?er=20pour=20emp=C3=AAcher=20les=20dates=20absurdes=20(99/99/999?= =?UTF-8?q?9)?= 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é -- 2.39.5 From 428f30aabebfa9c787a6449e2a120da8bf546b52 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 18 Jun 2026 16:25:46 +0200 Subject: [PATCH 2/9] =?UTF-8?q?test(date)=20:=20garde=20de=20non-r=C3=A9gr?= =?UTF-8?q?ession=20sur=20le=20masque=20born=C3=A9=20du=20DateTime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le DateTime partage CalendarField, donc buildBoundedMask bornait déjà sa saisie (heure 0-2, minute 0-5) — couvert par maskTemplate.test.ts. Ce test rend la garantie explicite côté composant : 99/99/9999 99:99 ne peut pas être tapé et n'émet aucun datetime. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/malio/date/DateTime.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/components/malio/date/DateTime.test.ts b/app/components/malio/date/DateTime.test.ts index 054977b..e328f10 100644 --- a/app/components/malio/date/DateTime.test.ts +++ b/app/components/malio/date/DateTime.test.ts @@ -153,6 +153,19 @@ describe('MalioDateTime', () => { expect(wrapper.text()).toContain('Date invalide') }) + it('empêche la frappe d\'un datetime absurde (99/99/9999 99:99 borné par le masque)', async () => { + const wrapper = mountDateTime({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('99/99/9999 99:99') + await input.trigger('blur') + // Le masque borne le 1er chiffre de chaque champ (jour 0-3, mois 0-1, + // heure 0-2, minute 0-5) : « 9 » est rejeté partout, rien ne s'inscrit + // et aucun datetime réel n'est émis. + expect((input.element as HTMLInputElement).value).not.toContain('99') + const emitted = wrapper.emitted('update:modelValue') ?? [] + expect(emitted.every(([value]) => value === null)).toBe(true) + }) + it('passe en erreur si le datetime saisi est hors min/max', async () => { const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'}) const input = wrapper.get('[data-test="date-input"]') -- 2.39.5 From 4a933da19e82a3c406159715608e1c4775161883 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 18 Jun 2026 16:35:25 +0200 Subject: [PATCH 3/9] 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) -- 2.39.5 From cddd174876893c10ae9850e62113b641c95f1a00 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 19 Jun 2026 08:49:08 +0200 Subject: [PATCH 4/9] fix(date) : ouvre le popover au clic sur le picto calendrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'icône calendrier (overlay absolu) interceptait le clic sans le traiter et ne le laissait pas retomber sur le @click de l'input. Ajout d'un handler onFieldClick sur l'icône (+ cursor-pointer). Couvre Date, DateTime, DateRange, DateWeek via le CalendarField partagé. La croix d'effacement garde son comportement (efface, n'ouvre pas). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + app/components/malio/date/Date.test.ts | 12 ++++++++++++ app/components/malio/date/internal/CalendarField.vue | 3 ++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f78b94..3d0f76b 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 (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir). * 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 diff --git a/app/components/malio/date/Date.test.ts b/app/components/malio/date/Date.test.ts index 0754fe4..cf9a2b0 100644 --- a/app/components/malio/date/Date.test.ts +++ b/app/components/malio/date/Date.test.ts @@ -73,6 +73,18 @@ describe('MalioDate', () => { expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) }) + it('opens on calendar icon click', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="calendar-icon"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) + }) + + it('opens on calendar icon click in editable mode', async () => { + const wrapper = mountDate({editable: true}) + await wrapper.get('[data-test="calendar-icon"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) + }) + it('opens on the current month when there is no value', async () => { const wrapper = mountDate() await wrapper.get('[data-test="date-input"]').trigger('click') diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue index 73cc8cc..4405384 100644 --- a/app/components/malio/date/internal/CalendarField.vue +++ b/app/components/malio/date/internal/CalendarField.vue @@ -70,7 +70,8 @@ icon="mdi:calendar-blank" :width="24" :height="24" - :class="iconStateClass" + :class="[iconStateClass, (disabled || readonly) ? 'cursor-not-allowed' : 'cursor-pointer']" + @click="onFieldClick" /> -- 2.39.5 From f2489a7ab0f686eef67986f282303a20c661ac46 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 19 Jun 2026 09:25:23 +0200 Subject: [PATCH 5/9] feat(sidebar) : hover texte m-primary sur les liens de navigation Les liens passent le texte en m-primary au survol (hover:text-m-primary), en plus du fond hover:bg-m-surface existant. Inclut l'alignement du test de largeur sur la valeur actuelle (w-[232px]). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + app/components/malio/sidebar/Sidebar.test.ts | 8 +++++++- app/components/malio/sidebar/Sidebar.vue | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0f76b..af76a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée). ### Changed +* Sidebar : les liens de navigation passent le **texte en `m-primary` au survol** (`hover:text-m-primary`, en plus du fond `hover:bg-m-surface` existant). * DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés. * MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`). * DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`). diff --git a/app/components/malio/sidebar/Sidebar.test.ts b/app/components/malio/sidebar/Sidebar.test.ts index ba0a3ab..006b055 100644 --- a/app/components/malio/sidebar/Sidebar.test.ts +++ b/app/components/malio/sidebar/Sidebar.test.ts @@ -62,7 +62,7 @@ describe('MalioSidebar', () => { it('renders expanded by default', () => { const wrapper = mountComponent({sections}) const aside = wrapper.find('aside') - expect(aside.classes()).toContain('w-[280px]') + expect(aside.classes()).toContain('w-[232px]') }) it('renders section labels with icons when expanded', () => { @@ -89,6 +89,12 @@ describe('MalioSidebar', () => { expect(links[2].attributes('href')).toBe('/fournisseurs') }) + it('applique un hover primary sur le texte des liens', () => { + const wrapper = mountComponent({sections}) + const link = wrapper.find('a') + expect(link.classes()).toContain('hover:text-m-primary') + }) + it('renders section icons via IconifyIcon', () => { const wrapper = mountComponent({sections}) const icons = wrapper.findAllComponents(IconifyIcon) diff --git a/app/components/malio/sidebar/Sidebar.vue b/app/components/malio/sidebar/Sidebar.vue index fd9a36c..383d5af 100644 --- a/app/components/malio/sidebar/Sidebar.vue +++ b/app/components/malio/sidebar/Sidebar.vue @@ -54,7 +54,7 @@ -- 2.39.5 From 14c719da5199353c0f578726bf5737222dec1e5c Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 19 Jun 2026 10:09:35 +0200 Subject: [PATCH 6/9] =?UTF-8?q?feat(sidebar)=20:=20=C3=A9tat=20actif=20+?= =?UTF-8?q?=20survol=20des=20liens=20(fond=20primary/10,=20texte=20primary?= =?UTF-8?q?,=20semi-bold)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Survol : highlight pleine largeur porté par le
  • (fond m-primary/10 + texte m-primary + semi-bold), espacement pt-1 pb-1. Couleur de base text-black sur le
  • et le n'impose plus sa couleur (héritage) : sinon, sur les bandes pt-1/pb-1 hors du , le fond passait bleu mais le texte restait noir. - Lien actif : texte m-primary + semi-bold, sans fond (active-class avec !important car appliqué hors twMerge). - Pas de transition sur le hover (texte + fond basculent ensemble, sans délai). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- app/components/malio/sidebar/Sidebar.test.ts | 23 +++++++++++++++++--- app/components/malio/sidebar/Sidebar.vue | 5 +++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af76a51..5876ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée). ### Changed -* Sidebar : les liens de navigation passent le **texte en `m-primary` au survol** (`hover:text-m-primary`, en plus du fond `hover:bg-m-surface` existant). +* Sidebar : états visuels des liens de navigation — **survol** : highlight pleine largeur entièrement porté par le `
  • ` (fond `m-primary` à 10 % + texte `m-primary` + semi-bold, `hover:bg-m-primary/10 hover:text-m-primary hover:font-semibold`, espacement `pt-1 pb-1`). La couleur de base (`text-black`) est aussi sur le `
  • ` et le `` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du ``, le fond devenait bleu mais le texte restait noir. **Lien actif** : texte `m-primary` + semi-bold, sans fond (`active-class="!text-m-primary font-semibold"` ; `!important` car `active-class` est hors `twMerge`). * DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés. * MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`). * DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`). diff --git a/app/components/malio/sidebar/Sidebar.test.ts b/app/components/malio/sidebar/Sidebar.test.ts index 006b055..5f5f2d7 100644 --- a/app/components/malio/sidebar/Sidebar.test.ts +++ b/app/components/malio/sidebar/Sidebar.test.ts @@ -89,10 +89,27 @@ describe('MalioSidebar', () => { expect(links[2].attributes('href')).toBe('/fournisseurs') }) - it('applique un hover primary sur le texte des liens', () => { + it('hover : fond + couleur + semi-bold tous portés par le
  • (texte non figé sur le )', () => { const wrapper = mountComponent({sections}) - const link = wrapper.find('a') - expect(link.classes()).toContain('hover:text-m-primary') + const li = wrapper.find('li') + expect(li.classes()).toContain('hover:bg-m-primary/10') + expect(li.classes()).toContain('hover:text-m-primary') + expect(li.classes()).toContain('hover:font-semibold') + expect(li.classes()).toContain('text-black') + expect(li.classes()).toContain('pt-1') + expect(li.classes()).toContain('pb-1') + // Le ne fige PAS sa couleur (sinon le texte resterait noir sur les bandes + // pt-1/pb-1 hors du alors que le fond du
  • est bleu). + expect(wrapper.find('a').classes()).not.toContain('text-black') + expect(wrapper.find('a').classes()).not.toContain('hover:text-m-primary') + }) + + it('actif : texte primary + semi-bold, sans fond, via active-class', () => { + const wrapper = mountComponent({sections}) + const activeClass = wrapper.find('a').attributes('active-class') ?? '' + expect(activeClass).toContain('text-m-primary') + expect(activeClass).toContain('font-semibold') + expect(activeClass).not.toContain('bg-') }) it('renders section icons via IconifyIcon', () => { diff --git a/app/components/malio/sidebar/Sidebar.vue b/app/components/malio/sidebar/Sidebar.vue index 383d5af..d6f8be7 100644 --- a/app/components/malio/sidebar/Sidebar.vue +++ b/app/components/malio/sidebar/Sidebar.vue @@ -49,12 +49,13 @@
  • -- 2.39.5 From 0558d8c58a860b465728788d89c91254375a8f03 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 19 Jun 2026 14:06:09 +0200 Subject: [PATCH 7/9] =?UTF-8?q?feat(tab)=20:=20onglets=20visibles=20adapt?= =?UTF-8?q?=C3=A9s=20=C3=A0=20la=20largeur=20+=20survol=20=3D=20style=20ac?= =?UTF-8?q?tif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le nombre d'onglets affichés en mode fenêtré s'adapte automatiquement à la largeur réelle (ResizeObserver + ligne de mesure cachée). Les chevrons restent fixés aux bords ; le nombre est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). Calcul isolé en fonction pure testable (tabFit.ts, basée sur les vraies largeurs). maxVisibleTabs devient un plafond optionnel. - BREAKING : suppression de la prop maxWidth (la barre prend toute la largeur). - Survol d'un onglet inactif : même style que l'actif (texte m-primary + barre). - Playground : bac à sable interactif (nb onglets, plafond, icônes, labels longs, cadre redimensionnable) pour tester tous les cas. Co-Authored-By: Claude Opus 4.8 (1M context) --- .playground/pages/composant/tab/tabList.vue | 90 ++++++++++++++++++- CHANGELOG.md | 2 + COMPONENTS.md | 5 +- app/components/malio/tab/TabList.test.ts | 22 +++-- app/components/malio/tab/TabList.vue | 98 ++++++++++++++++++--- app/components/malio/tab/tabFit.test.ts | 43 +++++++++ app/components/malio/tab/tabFit.ts | 49 +++++++++++ 7 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 app/components/malio/tab/tabFit.test.ts create mode 100644 app/components/malio/tab/tabFit.ts diff --git a/.playground/pages/composant/tab/tabList.vue b/.playground/pages/composant/tab/tabList.vue index ae19ca7..9ecd87e 100644 --- a/.playground/pages/composant/tab/tabList.vue +++ b/.playground/pages/composant/tab/tabList.vue @@ -1,5 +1,65 @@ diff --git a/.playground/playground.nav.ts b/.playground/playground.nav.ts index c10706f..8d8d165 100644 --- a/.playground/playground.nav.ts +++ b/.playground/playground.nav.ts @@ -70,6 +70,7 @@ export const navSections: SidebarSection[] = [ icon: 'mdi:dots-horizontal', items: [ {label: 'Champs readonly', to: '/composant/divers/readonly'}, + {label: 'Champs disabled', to: '/composant/divers/disabled'}, {label: 'Heure', to: '/composant/time/time'}, {label: 'Sélecteur de site', to: '/composant/site/siteSelector'}, {label: 'Formulaire client', to: '/composant/form/client'}, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6271e43..5fa6dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée). ### Changed +* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.) * TabList : le nombre d'onglets visibles en mode fenêtré s'**adapte automatiquement à la largeur réelle** (mesure via `ResizeObserver` + ligne de mesure cachée), au lieu d'un `maxVisibleTabs` fixe qui pouvait faire déborder les onglets sur les chevrons. Les chevrons restent fixés aux bords et le nombre affiché est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). `maxVisibleTabs` devient un **plafond optionnel**. Calcul isolé dans une fonction pure testable (`tabFit.ts`, basée sur les largeurs réelles des onglets). Sans layout (SSR), repli sur le plafond / tous les onglets. **Breaking** : la prop `maxWidth` est supprimée (la barre utilise désormais toute la largeur disponible au lieu d'être plafonnée à 1100px). * TabList : au **survol** d'un onglet inactif, on applique désormais le même style que l'onglet actif — texte `m-primary` plein + barre soulignée `m-primary` (`hover:after:*`) — au lieu du discret `text-m-primary/70`, pour bien marquer la cible. * Sidebar : états visuels des liens de navigation — **survol** : highlight pleine largeur entièrement porté par le `
  • ` (fond `m-primary` à 10 % + texte `m-primary` + semi-bold, `hover:bg-m-primary/10 hover:text-m-primary hover:font-semibold`, espacement `pt-1 pb-1`). La couleur de base (`text-black`) est aussi sur le `
  • ` et le `` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du ``, le fond devenait bleu mais le texte restait noir. **Lien actif** : texte `m-primary` + semi-bold, sans fond (`active-class="!text-m-primary font-semibold"` ; `!important` car `active-class` est hors `twMerge`). diff --git a/app/components/malio/date/Date.test.ts b/app/components/malio/date/Date.test.ts index cf9a2b0..0ada59b 100644 --- a/app/components/malio/date/Date.test.ts +++ b/app/components/malio/date/Date.test.ts @@ -238,6 +238,23 @@ describe('MalioDate', () => { expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) }) + it('disabled : label grisé', () => { + const wrapper = mountDate({disabled: true, label: 'Date'}) + expect(wrapper.get('label').classes()).toContain('text-m-muted') + }) + + it('disabled : pas de croix d\'effacement même avec une valeur', () => { + const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'}) + expect(wrapper.find('[data-test="clear"]').exists()).toBe(false) + }) + + it('disabled + rempli : icône calendrier grisée (pas noire)', () => { + const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'}) + const icon = wrapper.get('[data-test="calendar-icon"]') + expect(icon.classes()).toContain('text-m-muted') + expect(icon.classes()).not.toContain('text-black') + }) + it('does not open when readonly', async () => { const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'}) await wrapper.get('[data-test="date-input"]').trigger('click') diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue index 4405384..4d8af60 100644 --- a/app/components/malio/date/internal/CalendarField.vue +++ b/app/components/malio/date/internal/CalendarField.vue @@ -358,11 +358,13 @@ const mergedLabelClass = computed(() => ? 'text-m-danger' : hasSuccess.value ? 'text-m-success' - : isReadonly.value - ? isFilled.value ? 'text-black' : 'text-m-muted' - : isOpen.value - ? 'text-m-primary' - : 'peer-placeholder-shown:text-m-muted text-black', + : props.disabled + ? 'text-m-muted' + : isReadonly.value + ? isFilled.value ? 'text-black' : 'text-m-muted' + : isOpen.value + ? 'text-m-primary' + : 'peer-placeholder-shown:text-m-muted text-black', props.labelClass, ), ) @@ -370,6 +372,7 @@ const mergedLabelClass = computed(() => const iconStateClass = computed(() => { if (hasError.value) return 'text-m-danger' if (hasSuccess.value) return 'text-m-success' + if (props.disabled) return 'text-m-muted' if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted' if (isOpen.value) return 'text-m-primary' if (isFilled.value) return 'text-black' diff --git a/app/components/malio/input/InputAutocomplete.test.ts b/app/components/malio/input/InputAutocomplete.test.ts index c3e60db..ebc59fe 100644 --- a/app/components/malio/input/InputAutocomplete.test.ts +++ b/app/components/malio/input/InputAutocomplete.test.ts @@ -375,6 +375,12 @@ describe('MalioInputAutocomplete', () => { expect(wrapper.get('input').classes()).toContain('cursor-not-allowed') }) + it('hides the chevron when disabled', () => { + const wrapper = mountComponent({disabled: true}) + + expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false) + }) + it('sets readonly attribute', () => { const wrapper = mountComponent({readonly: true}) diff --git a/app/components/malio/input/InputAutocomplete.vue b/app/components/malio/input/InputAutocomplete.vue index 362a97f..1d907c2 100644 --- a/app/components/malio/input/InputAutocomplete.vue +++ b/app/components/malio/input/InputAutocomplete.vue @@ -64,7 +64,7 @@ class="animate-spin text-m-primary" /> { expect(wrapper.emitted('add')).toHaveLength(1) }) - it('does not emit add when disabled', async () => { + it('hides the add button when disabled', () => { const wrapper = mountComponent({addable: true, disabled: true}) - await wrapper.get('[data-test="add-button"]').trigger('click') - - expect(wrapper.emitted('add')).toBeUndefined() + expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false) }) it('does not emit add when readonly', async () => { @@ -355,12 +353,6 @@ describe('MalioInputEmail', () => { expect(wrapper.emitted('add')).toBeUndefined() }) - it('disables add button when disabled', () => { - const wrapper = mountComponent({addable: true, disabled: true}) - - expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined() - }) - it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => { const wrapper = mountComponent({addable: true, readonly: true}) diff --git a/app/components/malio/input/InputEmail.vue b/app/components/malio/input/InputEmail.vue index 7ed98dc..9af7aa5 100644 --- a/app/components/malio/input/InputEmail.vue +++ b/app/components/malio/input/InputEmail.vue @@ -41,9 +41,8 @@ />