Files
malio-layer-ui/app/components/malio/date/Date.test.ts
T
tristan 336cb9e315 feat(ui) : saisie clavier MalioDate + bouton « + » InputEmail + séparateurs InputAmount (#MUI-42) (#68)
Cette PR regroupe **trois évolutions** de la librairie (retours ERP).

---

## 1. MalioDate — saisie manuelle au clavier

Ajoute la **saisie manuelle au clavier** `JJ/MM/AAAA` sur `MalioDate` (opt-in via la prop `editable`), en plus de la sélection au calendrier.

- `CalendarField` (interne) gagne un mode `editable` : input non `readonly`, masque maska `##/##/####`, buffer local synchronisé sur la valeur, event `commit` au blur / à Entrée.
- `MalioDate` parse le texte (`parseDisplayToIso`), valide les bornes (`isDateInRange`) et gère un état d'erreur interne fusionné avec la prop `error` du consommateur.
- Le focus ouvre le popover ; la saisie invalide/hors bornes conserve le texte et affiche un message (`invalidMessage`, défaut `Date invalide`) ; la sélection au calendrier ou un changement externe de `modelValue` efface l'erreur.
- **Aucune régression** : `editable` défaut `false` ; le reste de la famille Date (DateRange/DateTime/DateWeek) est inchangé.

Nouvelles props `MalioDate` : `editable` (boolean, défaut false), `invalidMessage` (string, défaut Date invalide).

---

## 2. MalioInputEmail — bouton « + » d'ajout

Ajoute à `MalioInputEmail` le même bouton « + » que `MalioInputPhone` : un bouton optionnel qui émet un event `add` (ex. pour ajouter dynamiquement un autre champ email).

- Props `addable` (défaut `false`), `addIconName` (défaut `mdi:plus`), `addButtonLabel` (défaut `Ajouter une adresse email`) ; nouvel event `add()`.
- L'icône email étant à droite par défaut, une computed `effectiveIconPosition` la **déplace automatiquement à gauche** quand `addable` est actif, libérant la droite pour le bouton.
- Le bouton respecte `disabled`/`readonly` (pas d'émission).
- **Aucune régression** : `addable` défaut `false` ; la logique de sanitisation email (espaces, `lowercase`, caret) est intacte.

---

## 3. MalioInputAmount — séparateurs de milliers

Affiche les montants groupés à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), **en temps réel** pendant la saisie, tout en gardant une valeur émise propre.

- La valeur émise (`modelValue`) reste une **chaîne numérique propre** : point décimal, sans espaces (`'1234567.89'`). Contrat consommateur inchangé.
- Fonctions pures extraites dans `composables/amountFormat.ts` (`normalizeAmount`, `formatGroupedAmount`, helpers curseur) — testées en isolation.
- À la frappe : parse → émission du modèle propre → reformatage groupé → repositionnement du curseur (comptage des caractères significatifs hors espaces).
- `maxLength` borne désormais la **longueur du modèle** (le `maxlength` natif, qui compterait les espaces, est retiré).
- **Activé par défaut** sur tous les `MalioInputAmount` ; format FR figé.

---

Spec et plan des trois features : `docs/superpowers/specs/` et `docs/superpowers/plans/`.

## Plan de test
- [x] `npm run test -- Date.test.ts` → 40 tests OK
- [x] `npm run test -- InputEmail.test.ts` → 52 tests OK
- [x] `npm run test -- amountFormat.test.ts InputAmount.test.ts` → 50 tests OK
- [x] `npm run lint` → 0 erreur
- [ ] Vérif manuelle playground `composant/date` : saisie valide → ISO ; `32/13/2026` → texte conservé + rouge ; sélection calendrier efface l'erreur
- [ ] Vérif manuelle playground `composant/input/inputEmail` : carte « Ajout dynamique » → le « + » ajoute un champ ; icône à gauche + bouton à droite
- [ ] Vérif manuelle playground `composant/input/inputAmount` : carte « Grand montant » → `1234567` s'affiche `1 234 567` en live, `modelValue` émis `1234567` ; curseur cohérent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #68
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 15:39:38 +00:00

355 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Date_ from './Date.vue'
type DateProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string
labelClass?: string
groupClass?: string
reserveMessageSpace?: boolean
}
const DateForTest = Date_ as DefineComponent<DateProps>
const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body})
describe('MalioDate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('renders the label and the calendar icon', () => {
const wrapper = mountDate({label: 'Date de naissance'})
expect(wrapper.get('label').text()).toBe('Date de naissance')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountDate({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountDate({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('displays the formatted value in the field', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('19/05/2026')
})
it('does not show the popover initially', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('popover', () => {
it('opens on field click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').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')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
})
it('opens on the value month when a value is set', async () => {
const wrapper = mountDate({modelValue: '2025-12-25'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('closes on outside mousedown', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('navigation jours', () => {
it('goes to the next month on the right chevron', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026')
})
it('rolls December to January and bumps the year', async () => {
const wrapper = mountDate({modelValue: '2026-12-15'})
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027')
})
})
describe('sélection', () => {
it('emits the ISO date and closes on day click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('bornes min/max', () => {
it('disables days outside the range', async () => {
const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
await outside.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('vue mois', () => {
it('switches to month view on header toggle', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
})
it('navigates the year with chevrons in month view', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027')
})
it('returns to day view on month click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('effacement', () => {
it('shows the clear button when there is a value', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
expect(wrapper.find('[data-test="clear"]').exists()).toBe(true)
})
it('hides the clear button when empty', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
})
it('emits null and does not open the popover on clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('états', () => {
it('does not open when disabled', async () => {
const wrapper = mountDate({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
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')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('readonly vide : bordure noire sans bleu', () => {
const wrapper = mountDate({readonly: true})
const input = wrapper.get('[data-test="date-input"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('border-m-muted')
expect(input.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label muted sans bleu', () => {
const wrapper = mountDate({readonly: true, label: 'Date'})
const label = wrapper.get('label')
expect(label.classes()).toContain('text-m-muted')
expect(label.classes()).not.toContain('text-m-primary')
})
it('readonly vide : icône calendrier en text-m-muted', () => {
const wrapper = mountDate({readonly: true, label: 'Date'})
expect(wrapper.get('[data-test="calendar-icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label et icône en noir, bordure noire', () => {
const wrapper = mountDate({readonly: true, label: 'Date', modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
const label = wrapper.get('label')
const icon = wrapper.get('[data-test="calendar-icon"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('focus:border-m-primary')
expect(label.classes()).toContain('text-black')
expect(icon.classes()).toContain('text-black')
})
})
describe('accessibilité', () => {
it('sets aria-invalid and describedby on error', () => {
const wrapper = mountDate({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
describe('synchronisation externe', () => {
it('updates the displayed value when modelValue changes', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.setProps({modelValue: '2026-12-25'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('25/12/2026')
})
})
describe('reserveMessageSpace', () => {
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountDate({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
describe('saisie manuelle (editable)', () => {
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.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-19'})
expect(wrapper.text()).not.toContain('Date invalide')
})
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('readonly')).toBeDefined()
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
})
it('editable=true : l\'input n\'est plus readonly', () => {
const wrapper = mountDate({editable: true})
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
})
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('19/05/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
})
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.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide')
})
it('passe en erreur si la date saisie est hors min/max', async () => {
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('25/12/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.text()).toContain('Date invalide')
})
it('émet null sur saisie vidée au blur', async () => {
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
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.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.text()).not.toContain('Date invalide')
})
it('valide et ferme le popover sur Entrée', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.trigger('focus')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
await input.setValue('19/05/2026')
await input.trigger('keydown.enter')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
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')
await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect')
})
})
})