Compare commits

..

68 Commits

Author SHA1 Message Date
tristan f3e298e03b [#MUI-35] Refonte du composant drawer (#49)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #49
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-21 15:17:58 +00:00
tristan e2dabb0a26 [#MUI-34] Revoir le système de playground (#48)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #48
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-21 08:30:23 +00:00
tristan ac06ed9ae6 Merge branch 'main' into develop
# Conflicts:
#	.playground/pages/composant/form/client.vue
#	app/components/malio/checkbox/Checkbox.vue
#	app/components/malio/input/InputTextArea.vue
2026-05-13 09:00:12 +02:00
tristan b2e3a83bb9 [#MUI-32] Création d'un composant saisie assistée (autocomplete) (#46)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #46
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-13 06:59:13 +00:00
tristan 9ed094ba86 [#MUI-31] Création d'un composant téléphone (#45)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #45
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-12 06:54:35 +00:00
tristan 1ffe63827d [#MUI-30] Création d'un composant email (#44)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #44
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-11 08:54:31 +00:00
tristan eb21827686 Merge branch 'main' into develop 2026-05-11 09:38:39 +02:00
tristan 6938e730b6 fix: problèmes de taille des champs + Ajout d'un playground form (#42)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #42
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-11 07:37:49 +00:00
matthieu 174f1f9a64 Merge branch 'main' into develop 2026-05-04 18:42:13 +00:00
matthieu 30efd482d8 fix(release) : republier 1.4.8 pour les couleurs de l'éditeur rich text
Le squash-merge de #40 a utilisé le titre "release : ..." comme
message de commit. "release" n'est pas un type reconnu par le
commit-analyzer (angular preset) donc semantic-release n'a rien
publié alors que le code des couleurs est bien sur main.

Ce commit force le release en utilisant le type "fix" attendu
par l'analyzer.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:41:45 +02:00
matthieu 7dec45b374 Merge branch 'main' into develop 2026-05-04 18:03:33 +00:00
matthieu ea92acff3a fix(input-rich-text) : couleurs de texte et surlignage façon Jira
Ajoute deux boutons à la toolbar avec popover en palette pour
appliquer une couleur de texte ou un surlignage sur la sélection.

- Extensions TipTap : @tiptap/extension-text-style,
  @tiptap/extension-color, @tiptap/extension-highlight (multicolor).
- Palette de 8 couleurs (texte) + 8 pastels (surlignage) + reset.
- Indicateur de couleur active sous l'icône.
- Fermeture du popover sur clic extérieur, Echap, ou clic dans
  l'éditeur.
- Inclut les améliorations rendu/markdown du commit précédent
  (default outputFormat html, normalizeEditorInput, styles deep
  pour h2/h3/p/ul/ol/blockquote).
- Tests : 4 nouveaux cas (15 au total).
- Story et COMPONENTS.md à jour.

Note : les couleurs ne sont pas sérialisables en markdown ; pour
les conserver au save/reload utiliser output-format=\"html\".

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:01:55 +02:00
matthieu a3421c02e9 Merge remote-tracking branch 'origin/main' into develop 2026-05-04 15:27:10 +02:00
matthieu 5563d89743 chore(release) : tolérer l'espace avant ':' dans le commit-analyzer
Le hook commit-msg du repo impose le format `<type>(<scope>) : <message>`
avec un espace avant le ':', mais le preset Angular du commit-analyzer
de semantic-release attend le format standard sans espace. Ce décalage
empêchait semantic-release de reconnaître les commits squashés sur main
si le titre de PR contenait un espace ou un type non standard.

On ajoute parserOpts.headerPattern à commit-analyzer ET
release-notes-generator pour matcher les deux formats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:24:24 +02:00
matthieu 640ff90187 Merge branch 'main' into develop 2026-05-04 13:15:24 +00:00
matthieu 2eb7a5247a feat(input-rich-text) : ajout d'un éditeur de texte riche basé sur TipTap v3 (#37)
## Résumé

Nouveau composant `MalioInputRichText` : éditeur WYSIWYG basé sur **TipTap v3** + **StarterKit** + **tiptap-markdown**, aligné sur le thème Malio (couleurs `m-*`, icônes `mdi:*`, états error / success / hint).

## Détails

- **Toolbar** : gras, italique, barré, H2, H3, liste à puces, liste numérotée, citation, code inline, bloc de code, lien (prompt URL), undo / redo
- **Sortie** : `markdown` (par défaut) ou `html` via la prop `outputFormat`
- **Modes** : `editable`, `disabled`, `readonly` ; mode lecture seule (`editable=false`) rend le contenu en `prose` sans toolbar
- **Accessibilité** : label `for/id`, `aria-invalid`, `aria-describedby`, `aria-pressed` sur les boutons toolbar
- **Style** : floating focus border `m-primary`, error `m-danger`, success `m-success`, toolbar `bg-m-bg`

## Dépendances ajoutées (purement additives, aucun bump existant)

- `@tiptap/vue-3` ^3.22.5
- `@tiptap/starter-kit` ^3.22.5
- `@tiptap/extension-placeholder` ^3.22.5
- `@tiptap/pm` ^3.22.5
- `tiptap-markdown` ^0.9.0

> Note : `@tiptap/extension-link` n'est pas installé séparément car StarterKit v3 l'inclut nativement (configuré via `StarterKit.configure({ link: { ... } })`).

## Test plan

- [x] `npm run test` — 315/315 (12 nouveaux tests sur InputRichText)
- [x] `npm run lint` — 0 erreur sur les fichiers ajoutés
- [x] `npm run story:build` — Histoire build OK (story `Input/RichText` listée)
- [x] `npm run dev` — playground `/composant/input/inputRichText` (vérification visuelle des 8 variantes : simple, hint, erreur, succès, readonly, disabled, lecture seule, sortie HTML)
- [x] `npm run story:dev` — story `Input/RichText` avec docs

## Fichiers

- `app/components/malio/input/InputRichText.vue` — composant
- `app/components/malio/input/InputRichText.test.ts` — tests
- `.playground/pages/composant/input/inputRichText.vue` — playground
- `app/story/input/inputRichText.story.vue` — story Histoire
- `histoire.config.ts` — alias ESM + `optimizeDeps` pour `tiptap-markdown` (sinon Histoire choisit la build UMD)
- `CHANGELOG.md`, `COMPONENTS.md` — documentation

Reviewed-on: #37
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
2026-05-04 13:12:38 +00:00
tristan 3336ff0c69 Merge branch 'main' into develop 2026-04-27 14:58:25 +02:00
tristan da3a4cb349 fix(select) : option vide rendue uniquement si emptyOptionLabel non vide 2026-04-27 14:51:49 +02:00
tristan 0ddae4dd70 Merge branch 'main' into develop 2026-04-27 12:05:31 +02:00
tristan 23210e6868 refactor(select-checkbox) : ré-aligner la structure sur MalioSelect 2026-04-27 11:44:18 +02:00
tristan 1c0fcd24e3 Merge branch 'main' into develop 2026-04-27 11:30:22 +02:00
tristan d74f3acc97 fix : suppression de la marge top des Checkbox.vue 2026-04-27 11:26:21 +02:00
tristan 014a057196 Merge branch 'main' into develop 2026-04-24 14:14:27 +02:00
tristan 73483b0573 fix : utilisation de la bonne police 2026-04-24 09:01:28 +02:00
tristan 4855923008 Merge branch 'main' into develop 2026-04-20 15:02:23 +02:00
tristan fc844078a6 fix : suppression de la marge top du champ textArea 2026-04-20 15:01:50 +02:00
tristan 02495245a5 Merge branch 'main' into develop 2026-04-20 14:54:04 +02:00
tristan 330fb2130b fix(build) : distribuer tailwind.config.ts + paths absolus + pagination datatable
- Ajoute tailwind.config.ts aux files du package pour qu'il soit inclus dans le tarball npm
- Convertit les paths content en absolus (via fileURLToPath) pour que Tailwind scanne les composants du layer depuis node_modules côté consommateur
- Aligne la hauteur des boutons de pagination du DataTable (h-10) sur le Select
- Ajuste --m-radius à 6px

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:53:20 +02:00
tristan 5acefc1d59 Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
#	COMPONENTS.md
2026-04-17 14:30:39 +02:00
tristan e77bf49146 [#MUI-27] Création d'un composant sélection de site (#29)
Composant MalioSiteSelector : bande horizontale pour choisir un site
(usine ou lieu) parmi une liste. Tuiles flex proportionnelles, couleur
du site sélectionné partagée par toutes les tuiles (opacité 1 / 0.4).
Expose update:modelValue (id) + change (objet site complet) pour
faciliter les appels API côté consommateur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #29
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-17 12:28:44 +00:00
tristan f59f866354 Merge branch 'main' into develop 2026-04-16 09:06:04 +02:00
tristan 660c3787fd [#MUI-22] Création d'un composant datatable (#27)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #27
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-16 07:00:59 +00:00
tristan e9741ff38d Merge branch 'main' into develop 2026-04-07 14:31:29 +02:00
tristan 32608c8f71 fix : suppression du doublon du composant Checkbox 2026-04-07 14:30:06 +02:00
tristan e1965db04e Merge remote-tracking branch 'origin/main' into develop 2026-04-07 10:14:51 +02:00
tristan 0ad344bab9 fix : style des inputs + hint/success/error 2026-04-07 10:02:11 +02:00
tristan 96719be78d Merge branch 'main' into develop
# Conflicts:
#	COMPONENTS.md
2026-03-26 08:57:02 +01:00
tristan b90baec571 fix : livraison + COMPONENTS.md 2026-03-26 08:54:49 +01:00
tristan 384f86a3b3 Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	CHANGELOG.md
2026-03-26 08:39:11 +01:00
tristan e8ddf4e083 [#MUI-24] Fix composant Select (#22)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-26 07:33:20 +00:00
tristan 7ee64289a8 fix : drawer animation 2026-03-25 08:38:36 +01:00
tristan f09f8a91ac [#MUI-15] Création d'un composant drawer (#21)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #21
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 10:49:27 +00:00
tristan bcadd46ce2 [#MUI-2] Faire un MCP pour la librairie de composant (#20)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #20
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 10:31:20 +00:00
tristan e76337502a [#MUI-10] Création d'un composant bouton (#19)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #19
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 10:12:28 +00:00
tristan 968b7087b5 [#MUI-23] Revoir la config couleur tailwind (#18)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #18
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-24 09:05:23 +00:00
tristan 3deba3f369 [#MUI-20] Développer le composant Menu (#17)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #17
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-23 16:36:16 +00:00
tristan cf46ab0c85 [#MUI-11] Création d'un composant navigation par onglets (#16)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-23 07:48:55 +00:00
tristan 09cc3edf6f feat : reorganisation de la structure projet 2026-03-20 14:22:40 +01:00
tristan c95a3657c0 [#MUI-14] Création d'un composant bouton icône (#15)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #15
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-20 11:00:38 +00:00
tristan 9843f4d032 feat : ajout de state dans les histoires des composants 2026-03-19 17:45:03 +01:00
tristan 9d9b9c9dc4 feat : ajout d'un sélecteur "Tout cocher" dans le composant SelectCheckbox 2026-03-19 17:30:52 +01:00
tristan 187ef52865 [#MUI-9] Ajout composant upload (#14)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #14
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-19 09:51:37 +00:00
tristan 9925f1ced4 [#MUI-8] Ajout composant mot de passe (#13)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #13
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-19 09:43:55 +00:00
tristan ded414ba1a [#MUI12] Correction composant Nombre et Taux horaire (#12)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #12
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-19 08:22:37 +00:00
kevin 11d60e687b [#366] Création d'un composant de type Select checkbox (#11)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|           #366       |            Création d'un composant de type Select checkbox     |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #11
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-17 12:28:57 +00:00
kevin d3038994c3 [#407] Création d'un composant de type time (#10)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #407         |           Création d'un composant de type time      |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #10
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-17 12:28:33 +00:00
kevin 0d350e12c6 Merge pull request '[#365] Création d'un composant de type Number' (#9) from feat/365-creation-composant-number into develop
Reviewed-on: #9
2026-03-11 15:16:18 +00:00
tristan c6acaace27 Merge remote-tracking branch 'origin/develop' into develop 2026-03-08 20:10:32 +01:00
tristan 927c7c3c70 Merge remote-tracking branch 'origin/main' into develop 2026-03-08 20:10:02 +01:00
kevin bf0aa92497 [#363] Création d'un composant de type checkbox (#5)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #363          |        Création d'un composant de type checkbox       |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #5
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-08 19:07:53 +00:00
kevin 88dd76a0e4 [#364 ] Création d'un composant de type radio (#6)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|           #364        |       Création d'un composant de type radio          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #6
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-08 18:59:50 +00:00
kevin cc04114f89 feat : ajout du composant input number 2026-03-05 09:38:56 +01:00
kevin f456ea4ddf feat : ajout du composant input number 2026-03-04 13:15:43 +01:00
kevin 77364daa67 [#362] Création d'un composant de type Montant (#4)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|          #362        |       Création d'un composant de type Montant          |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #4
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-03 10:42:39 +00:00
kevin 1ab7b2427a [#337] Création d'un composant Select (#3)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #337           |           Création d'un composant Select      |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #3
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-02 13:24:58 +00:00
tristan 82ecc9cfe2 feat : ajout config vitest/make/pre-commit/commit-msg + un exemple de test vitest 2026-02-23 11:29:16 +01:00
tristan 65d9060e26 feat : ajout du template de MR + CHANGELOG.md 2026-02-23 11:11:31 +01:00
tristan ec4c157226 fix: readme.md 2026-02-19 11:18:36 +01:00
158 changed files with 461 additions and 23408 deletions
+1 -8
View File
@@ -12,14 +12,7 @@
"Bash(mv buttonIcon.story.vue button/)",
"Bash(mv inputText.story.vue inputAmount.story.vue inputNumber.story.vue inputPassword.story.vue inputTextArea.story.vue inputUpload.story.vue input/)",
"Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)",
"Bash(mv inputCheckbox.story.vue checkbox/)",
"Bash(npx eslint *)",
"Bash(echo \"LINT EXIT: $?\")",
"Bash(git commit *)",
"mcp__chrome__navigate_page",
"mcp__chrome__take_snapshot",
"mcp__chrome__click",
"mcp__chrome__evaluate_script"
"Bash(mv inputCheckbox.story.vue checkbox/)"
]
}
}
@@ -108,9 +108,9 @@ npm run lint # Pas d'erreurs
### 5. Créer la page playground
**Fichier :** `.playground/pages/composant/<categorie>/<nomComposant>.vue` (camelCase, dans le sous-dossier de catégorie)
**Fichier :** `.playground/pages/composant/<nomComposant>.vue` (camelCase)
La page devient automatiquement une route Nuxt (`/composant/<categorie>/<nomComposant>`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant/<categorie>/<nomComposant>`. Inclure des variantes représentatives dans une grille :
La page est auto-détectée par `index.vue` via `import.meta.glob`. Inclure des variantes représentatives dans une grille :
```html
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
@@ -216,7 +216,7 @@ Cette section est alimentée au fur et à mesure des retours utilisateur et des
|--------|----------|
| Stub IconifyIcon ne fonctionne pas dans les tests | Utiliser `findComponent(IconifyIcon)` avec l'import réel pour tester les props |
| Oubli de `inheritAttrs: false` | Toujours dans `defineOptions` — sinon les attrs se dupliquent |
| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) |
| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` |
| Padding input pas ajusté avec icône | Ajouter `!pr-10` (ou équivalent) quand une icône est présente à droite |
| Story sans initial state | Toujours initialiser les `ref` avec des valeurs pour que les variantes soient visibles dès le chargement |
| CHANGELOG oublié | Toujours ajouter la ligne dans `### Added` avant de commit |
@@ -1,63 +0,0 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) défaut</h2>
<MalioAccordion v-model="multiple">
<MalioAccordionItem title="Prix" value="prix">
<p>Slider de prix ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<p>Liste de checkboxes ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Marque" value="marque">
<p>Recherche + liste ici</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouverts : {{ multiple }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
<MalioAccordion v-model="single" mode="single">
<MalioAccordionItem title="Question 1" value="q1">
<p>Réponse 1</p>
</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">
<p>Réponse 2</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouvert : {{ single }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé + defaultOpen</h2>
<MalioAccordion>
<MalioAccordionItem title="Section A" value="a" :default-open="true">
<p>Ouverte au montage</p>
</MalioAccordionItem>
<MalioAccordionItem title="Section B" value="b">
<p>Fermée au montage</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
<MalioAccordion>
<MalioAccordionItem title="Active" value="ok">
<p>Contenu accessible</p>
</MalioAccordionItem>
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
<p>Inaccessible</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const multiple = ref<string[]>(['prix'])
const single = ref('q1')
</script>
-132
View File
@@ -1,132 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDate</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDate
v-model="value"
label="Date de naissance"
hint="Clique pour ouvrir le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
</div>
<MalioDate
v-model="editableValue"
label="Date (saisie clavier)"
editable
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25'"
>
Forcer le 25/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDate
v-model="erpValue"
label="Date du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDate
v-model="bounded"
label="Date bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Readonly (readonly vide)</h2>
<MalioDate
label="Date de naissance (readonly vide)"
:readonly="true"
/>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Readonly (readonly rempli)</h2>
<MalioDate
v-model="readonlyFilledDate"
label="Date de naissance (readonly rempli)"
:readonly="true"
/>
</div>
</div>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">markedDates + @month-change</h2>
<MalioDate
v-model="markedValue"
label="Calendrier avec statuts par jour"
hint="Jours verts = validés, rouges = à corriger"
:marked-dates="markedDates"
@month-change="onMonthChange"
/>
<div class="rounded border p-3 text-sm">
<p>Mois affiché : <code>{{ shownMonth }}</code></p>
<p class="mt-1 text-m-success"> success : {{ successDays.join(', ') }}</p>
<p class="text-m-danger"> danger : {{ dangerDays.join(', ') }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const readonlyFilledDate = ref<string | null>('2026-06-15')
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
const editableValue = ref<string | null>(null)
// Démo markedDates : quelques jours du mois courant marqués success / danger.
const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`
const successDays = [`${ym}-05`, `${ym}-06`, `${ym}-12`]
const dangerDays = [`${ym}-09`, `${ym}-20`]
const markedDates = computed<Record<string, 'success' | 'danger'>>(() => ({
...Object.fromEntries(successDays.map(d => [d, 'success' as const])),
...Object.fromEntries(dangerDays.map(d => [d, 'danger' as const])),
}))
const markedValue = ref<string | null>(null)
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const shownMonth = ref('—')
const onMonthChange = ({month, year}: {month: number, year: number}) => {
shownMonth.value = `${monthsLong[month]} ${year} (month=${month})`
}
</script>
@@ -1,72 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateRange</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateRange
v-model="value"
label="Période"
hint="Clique deux fois pour définir une plage"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ value?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ value?.end ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = {start: '2026-12-20', end: '2026-12-31'}"
>
Forcer 2031/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateRange
v-model="erpValue"
label="Période"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ erpValue?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ erpValue?.end ?? 'null' }}</code></p>
</div>
<MalioDateRange
v-model="bounded"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<RangeValue | null>(null)
const erpValue = ref<RangeValue | null>(null)
const bounded = ref<RangeValue | null>(null)
</script>
@@ -1,68 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateWeek</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateWeek
v-model="value"
label="Semaine"
hint="Clique un jour ou un n° de semaine"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-W52'"
>
Forcer 2026-W52
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateWeek
v-model="erpValue"
label="Semaine"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateWeek
v-model="bounded"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
@@ -1,84 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateTime</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateTime
v-model="value"
label="Date et heure du rendez-vous"
hint="Choisis un jour puis une heure"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
</div>
<MalioDateTime
v-model="editableValue"
label="Date et heure (saisie clavier)"
editable
hint="Tape JJ/MM/AAAA HH:MM ou utilise le calendrier"
@update:valid="editableValid = $event"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur éditable (ISO naïf) : <code>{{ editableValue ?? 'null' }}</code></p>
<p>
Saisie valide :
<code :class="editableValid ? 'text-m-success' : 'text-m-danger'">{{ editableValid }}</code>
</p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25T09:30:00'"
>
Forcer le 25/12/2026 09:30
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateTime
v-model="erpValue"
label="Date et heure du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateTime
v-model="bounded"
label="Créneau borné"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>('2026-05-20T14:30:00')
const editableValue = ref<string | null>(null)
const editableValid = ref(true)
</script>
@@ -1,294 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">Champs désactivés (disabled)</h1>
<p class="text-sm text-m-muted">
Tous les champs de formulaire dans leur état <code>disabled</code>, vides puis remplis.
Règles : texte + label grisés, <code>cursor-not-allowed</code>, et <strong>aucune affordance
interactive</strong> (pas de bouton « + », pas de croix « x », pas de chevron, pas d'œil ;
tags et valeurs grisés).
</p>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
<div class="space-y-4">
<MalioInputText
label="Référence (vide)"
:disabled="true"
/>
<MalioInputText
model-value="Commande #A-2048"
label="Référence (rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputEmail (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputEmail
label="Adresse email (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputEmail
model-value="contact@malio.fr"
label="Adresse email (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPhone (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputPhone
label="Téléphone (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
label="Téléphone (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPassword (pas d'œil)</h2>
<div class="space-y-4">
<MalioInputPassword
label="Mot de passe (vide)"
:disabled="true"
/>
<MalioInputPassword
model-value="motdepasse123"
label="Mot de passe (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
<div class="space-y-4">
<MalioInputAmount
label="Montant (vide)"
:disabled="true"
/>
<MalioInputAmount
model-value="1250.00"
label="Montant (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputNumber</h2>
<div class="space-y-4">
<MalioInputNumber
label="Quantité (vide)"
:disabled="true"
/>
<MalioInputNumber
model-value="42"
label="Quantité (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
<div class="space-y-4">
<MalioInputTextArea
label="Description (vide)"
:size="3"
:disabled="true"
/>
<MalioInputTextArea
model-value="Ce texte est désactivé et ne peut pas être modifié."
label="Description (rempli)"
:size="3"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputUpload (pas de croix)</h2>
<div class="space-y-4">
<MalioInputUpload
label="Fichier (vide)"
:disabled="true"
/>
<MalioInputUpload
model-value="document.pdf"
label="Fichier (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete (pas de chevron)</h2>
<div class="space-y-4">
<MalioInputAutocomplete
label="Pays (vide)"
:options="countryOptions"
:disabled="true"
/>
<MalioInputAutocomplete
model-value="de"
label="Pays (rempli)"
:options="countryOptions"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelect (pas de chevron)</h2>
<div class="space-y-4">
<MalioSelect
label="Catégorie (vide)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
<MalioSelect
:model-value="'a'"
label="Catégorie (rempli)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox version tags (grisés)</h2>
<div class="space-y-4">
<MalioSelectCheckbox
label="Catégories (vide)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
<MalioSelectCheckbox
:model-value="['a', 'b', 'c']"
label="Catégories (rempli)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDate (pas de croix)</h2>
<div class="space-y-4">
<MalioDate
label="Date de naissance (vide)"
:disabled="true"
/>
<MalioDate
model-value="2026-06-15"
label="Date de naissance (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
<div class="space-y-4">
<MalioDateTime
label="Date et heure (vide)"
:disabled="true"
/>
<MalioDateTime
model-value="2026-12-25T09:30:00"
label="Date et heure (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
<div class="space-y-4">
<MalioDateRange
label="Période (vide)"
:disabled="true"
/>
<MalioDateRange
:model-value="rangeValue"
label="Période (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
<div class="space-y-4">
<MalioDateWeek
label="Semaine (vide)"
:disabled="true"
/>
<MalioDateWeek
model-value="2026-W52"
label="Semaine (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioTimePicker (pas de croix)</h2>
<div class="space-y-4">
<MalioTimePicker
label="Heure (vide)"
:disabled="true"
/>
<MalioTimePicker
model-value="14:30"
label="Heure (rempli)"
:disabled="true"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const countryOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
]
const categoryOptions: Option[] = [
{label: 'Catégorie A', value: 'a'},
{label: 'Catégorie B', value: 'b'},
{label: 'Catégorie C', value: 'c'},
]
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
</script>
@@ -1,276 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">Champs en lecture seule (readonly)</h1>
<p class="text-sm text-m-muted">
Tous les champs de formulaire dans leur état <code>readonly</code>, vides puis remplis.
Règles : bordure noire même vide, label et icône gris quand vide noir quand rempli,
pas de focus bleu ni de grossissement.
</p>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
<div class="space-y-4">
<MalioInputText
label="Référence (vide)"
:readonly="true"
/>
<MalioInputText
model-value="Commande #A-2048"
label="Référence (rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputEmail</h2>
<div class="space-y-4">
<MalioInputEmail
label="Adresse email (vide)"
:readonly="true"
/>
<MalioInputEmail
model-value="contact@malio.fr"
label="Adresse email (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
<div class="space-y-4">
<MalioInputAmount
label="Montant (vide)"
:readonly="true"
/>
<MalioInputAmount
model-value="1250.00"
label="Montant (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete</h2>
<div class="space-y-4">
<MalioInputAutocomplete
label="Pays (vide)"
:options="countryOptions"
:readonly="true"
/>
<MalioInputAutocomplete
model-value="de"
label="Pays (rempli)"
icon-name="mdi:lock-outline"
icon-position="left"
:options="countryOptions"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPassword</h2>
<div class="space-y-4">
<MalioInputPassword
label="Mot de passe (vide)"
:readonly="true"
/>
<MalioInputPassword
model-value="motdepasse123"
label="Mot de passe (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
<div class="space-y-4">
<MalioInputTextArea
label="Description (vide)"
:size="3"
:readonly="true"
/>
<MalioInputTextArea
model-value="Ce texte est en lecture seule et ne peut pas être modifié."
label="Description (rempli)"
:size="3"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPhone</h2>
<div class="space-y-4">
<MalioInputPhone
label="Téléphone (vide)"
:readonly="true"
/>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
label="Téléphone (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputUpload</h2>
<div class="space-y-4">
<MalioInputUpload
label="Fichier (vide)"
:readonly="true"
/>
<MalioInputUpload
model-value="document.pdf"
label="Fichier (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelect</h2>
<div class="space-y-4">
<MalioSelect
label="Catégorie (readonly vide)"
:options="categoryOptions"
empty-option-label="Aucune selection"
:readonly="true"
/>
<MalioSelect
:model-value="'a'"
label="Catégorie (readonly rempli)"
:options="categoryOptions"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox</h2>
<div class="space-y-4">
<MalioSelectCheckbox
label="Catégories (readonly vide)"
:options="categoryOptions"
:display-tag="true"
:readonly="true"
/>
<MalioSelectCheckbox
:model-value="['a']"
label="Catégories (readonly rempli)"
:options="categoryOptions"
empty-option-label="Aucune selection"
:display-tag="true"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDate</h2>
<div class="space-y-4">
<MalioDate
label="Date de naissance (vide)"
:readonly="true"
/>
<MalioDate
model-value="2026-06-15"
label="Date de naissance (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
<div class="space-y-4">
<MalioDateTime
label="Date et heure (vide)"
:readonly="true"
/>
<MalioDateTime
model-value="2026-12-25T09:30:00"
label="Date et heure (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
<div class="space-y-4">
<MalioDateRange
label="Période (vide)"
:readonly="true"
/>
<MalioDateRange
:model-value="rangeValue"
label="Période (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
<div class="space-y-4">
<MalioDateWeek
label="Semaine (vide)"
:readonly="true"
/>
<MalioDateWeek
model-value="2026-W52"
label="Semaine (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioTimePicker</h2>
<div class="space-y-4">
<MalioTimePicker
label="Heure (vide)"
:readonly="true"
/>
<MalioTimePicker
model-value="14:30"
label="Heure (rempli)"
:readonly="true"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const countryOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
]
const categoryOptions: Option[] = [
{label: 'Catégorie A', value: 'a'},
{label: 'Catégorie B', value: 'b'},
]
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
</script>
+14 -9
View File
@@ -33,7 +33,7 @@ const drawerNoDismiss = ref(false)
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<h2 class="mb-6 text-xl font-bold">Avec footer collant</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
<template #header>
@@ -45,27 +45,32 @@ const drawerNoDismiss = ref(false)
<MalioInputText label="Email" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
</div>
</template>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Footer fixe avec contenu long</h2>
<MalioButton label="Ouvrir (contenu long)" variant="tertiary" @click="drawerFixedFooter = true" />
<h2 class="mb-6 text-xl font-bold">Avec footer fixed bottom</h2>
<MalioButton label="Ouvrir (footer fixe)" variant="tertiary" @click="drawerFixedFooter = true" />
<MalioDrawer v-model="drawerFixedFooter">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<!-- Pas de hack : le footer est hors zone scrollable, seul le body défile -->
<div class="flex flex-col gap-4">
<!-- pb-24 : laisse la place au footer fixe qui sort du flux et recouvrirait le bas du contenu -->
<div class="flex flex-col gap-4 pb-24">
<p v-for="n in 12" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll et montrer que seul le body défile, le footer restant fixé en bas.
Paragraphe {{ n }} contenu long pour forcer le scroll et montrer que le footer reste fixé en bas du viewport.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
<!-- fixed : positionné par rapport au viewport ; w-full max-w-md cale la largeur sur le drawer droite par défaut -->
<div class="fixed bottom-0 right-0 w-full max-w-md border-t border-m-border bg-white px-5 py-4">
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
</div>
</template>
</MalioDrawer>
</div>
@@ -1,88 +0,0 @@
<template>
<div class="flex justify-center">
<div class="w-[1348px]">
<div class="flex items-center justify-between mt-[46px]">
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
aria-label="Précédent"
variant="ghost"
/>
<h1 class="text-[32px] text-m-primary font-bold">Filtres</h1>
</div>
<MalioButton
label="Filtres"
variant="tertiary"
icon-name="mdi:tune"
icon-position="left"
button-class="w-[184px] px-2 py-2 justify-start text-black gap-4"
@click="drawerOpen = true"
/>
</div>
</div>
<MalioDrawer
v-model="drawerOpen"
side="right"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between gap-4 py-7"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">Filtres</h2>
</template>
<MalioAccordion>
<MalioAccordionItem title="Type de camion" value="camion">
<div class="flex flex-col gap-6">
<MalioCheckbox v-model="semiBenne" label="Semi Benne" />
<MalioCheckbox v-model="benne" label="Benne" />
</div>
</MalioAccordionItem>
<MalioAccordionItem title="Date à Date" value="date">
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
<span>Du</span>
<MalioDate v-model="dateDebut"/>
<span>Au</span>
<MalioDate v-model="dateFin"/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
label="Réinitialiser"
variant="tertiary"
button-class="w-m-btn-action"
@click="resetFiltres"
/>
<MalioButton
label="Voir les résultats"
variant="primary"
button-class="w-[170px]"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const drawerOpen = ref(false)
const semiBenne = ref(false)
const benne = ref(false)
const dateDebut = ref<string | null>(null)
const dateFin = ref<string | null>(null)
function resetFiltres() {
semiBenne.value = false
benne.value = false
dateDebut.value = null
dateFin.value = null
}
</script>
+4 -10
View File
@@ -10,7 +10,7 @@
/>
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
</div>
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8">
<MalioInputText
label="Nom du client (Entreprise)"
/>
@@ -22,7 +22,6 @@
/>
<MalioSelectCheckbox
v-model="multiselectValue"
error="test"
label="Catégorie"
:options="[
{label: 'Catégorie 1', value: 'Catégorie 1'},
@@ -76,13 +75,10 @@
<div class="mt-[60px]">
<MalioTabList :tabs="tabs" v-model="tabsValue">
<template #information>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
<MalioInputText v-model="concurrent" label="Concurrent"/>
<MalioDate
v-model="dateCreation"
label="Date création"
/>
<MalioInputText label="Date création"/>
<MalioInputText label="Nombre de salariés" />
<MalioInputAmount label="CA"/>
<MalioInputText label="Dirigeant" />
@@ -93,7 +89,7 @@
</div>
</template>
<template #adresses>
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer l'adresse"
@@ -162,7 +158,6 @@
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import MalioDate from "../../../../app/components/malio/date/Date.vue";
type Commune = {
nom: string
@@ -284,7 +279,6 @@ const onSearchAdresse = async (query: string) => {
const tabsValue = ref('information')
const concurrent = ref('')
const dateCreation = ref<string | null>(null)
const informationValid = computed(() => concurrent.value.trim().length > 0)
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
@@ -14,17 +14,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
<MalioInputAmount
v-model="bigValue"
label="Budget"
/>
<div class="mt-2 rounded border p-3 text-sm">
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputAmount
@@ -47,23 +36,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputAmount
label="Montant (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputAmount
v-model="readonlyFilledAmount"
label="Montant (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
<div class="mt-4">
@@ -85,8 +57,4 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
const readonlyFilledAmount = ref('1250.00')
const bigValue = ref('1234567.89')
</script>
@@ -6,7 +6,6 @@
v-model="simpleValue"
label="Pays"
:options="staticOptions"
local-filter
/>
<p class="mt-2 text-sm text-m-muted">
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
@@ -21,7 +20,6 @@
icon-name="mdi:magnify"
icon-position="left"
:options="staticOptions"
local-filter
/>
</div>
@@ -82,25 +80,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputAutocomplete
label="Pays (readonly vide)"
:options="staticOptions"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputAutocomplete
v-model="readonlyFilledAutocomplete"
label="Pays (readonly rempli)"
:options="staticOptions"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputAutocomplete
@@ -159,7 +138,6 @@ const staticOptions: Option[] = [
{label: 'Italie', value: 'it'},
]
const readonlyFilledAutocomplete = ref<string | number | null>('de')
const simpleValue = ref<string | number | null>(null)
const leftIconValue = ref<string | number | null>(null)
const createValue = ref<string | number | null>(null)
@@ -14,20 +14,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
<div class="space-y-3">
<MalioInputEmail
v-for="(email, index) in emails"
:key="index"
v-model="emails[index]"
label="Adresse email"
addable
@add="emails.push('')"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
<MalioInputEmail
@@ -62,23 +48,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputEmail
label="Adresse email (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputEmail
v-model="readonlyFilledEmail"
label="Adresse email (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputEmail
@@ -115,36 +84,14 @@
:success="dynamicSuccess"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Email obligatoire</h2>
<MalioInputEmail
v-model="requiredEmail"
label="Email obligatoire"
:required="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Email normalisé (minuscules)</h2>
<MalioInputEmail
v-model="lowercaseEmail"
label="Email normalisé (minuscules)"
:lowercase="true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledEmail = ref('contact@malio.fr')
const emailValue = ref('')
const emails = ref<string[]>([''])
const dynamicEmail = ref('')
const requiredEmail = ref('')
const lowercaseEmail = ref('')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
@@ -41,23 +41,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputPassword
label="Mot de passe (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputPassword
v-model="readonlyFilledPassword"
label="Mot de passe (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputPassword
@@ -100,7 +83,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledPassword = ref('motdepasse123')
const passwordValue = ref('')
const dynamicPassword = ref('')
@@ -73,23 +73,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputPhone
label="Téléphone (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputPhone
v-model="readonlyFilledPhone"
label="Téléphone (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputPhone
@@ -138,7 +121,6 @@
<script setup lang="ts">
import { ref } from 'vue'
const readonlyFilledPhone = ref('+33 6 12 34 56 78')
const phoneValue = ref('')
const phoneAddable = ref('')
const phoneFrench = ref('')
@@ -108,33 +108,6 @@
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputText
label="Référence (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputText
v-model="readonlyFilledValue"
label="Référence (readonly rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Champ obligatoire</h2>
<MalioInputText
label="Champ obligatoire"
:required="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
<MalioInputText
@@ -181,7 +154,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledValue = ref('Commande #A-2048')
const nameValue = ref('')
const searchValue = ref('')
const codeValue = ref('')
@@ -61,25 +61,6 @@
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputTextArea
label="Description (readonly vide)"
:readonly="true"
:size="3"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputTextArea
v-model="readonlyFilledTextArea"
label="Description (readonly rempli)"
:readonly="true"
:size="3"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Resize avec limites</h2>
<MalioInputTextArea
@@ -113,7 +94,6 @@
import {ref} from 'vue'
import MalioInputTextArea from '../../../../app/components/malio/input/InputTextArea.vue'
const readonlyFilledTextArea = ref('Ce texte est en lecture seule et ne peut pas être modifié.')
const hintValue = ref('')
const iconValue = ref('')
const errorValue = ref('abc')
@@ -14,17 +14,6 @@
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Clearable (croix pour vider)</h2>
<MalioInputUpload
v-model="clearableUpload"
label="Téléverser un document"
clearable
@clear="onClearUpload"
/>
<p class="mt-2 text-sm text-gray-500">Valeur : {{ clearableUpload || '(aucun)' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
<MalioInputUpload
@@ -42,23 +31,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputUpload
label="Fichier (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputUpload
v-model="readonlyFilledUpload"
label="Fichier (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputUpload
@@ -102,14 +74,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledUpload = ref('document.pdf')
const uploadValue = ref('')
const dynamicUpload = ref('')
const clearableUpload = ref('rapport-2026.pdf')
const onClearUpload = () => {
clearableUpload.value = ''
}
const dynamicError = computed(() => {
if (!dynamicUpload.value) return ''
@@ -1,74 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import MalioButton from "../../../../app/components/malio/button/Button.vue";
const modalBase = ref(false)
const modalForm = ref(false)
const modalLong = ref(false)
const modalNoDismiss = ref(false)
</script>
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Modal simple</h2>
<MalioButton label="Ouvrir" @click="modalBase = true" />
<MalioModal v-model="modalBase" headerClass="py-7 px-[25px]" footerClass="flex justify-center pt-8">
<template #header>
<h2 class="text-[24px] font-bold text-black">Marquer comme vu ?</h2>
</template>
<template #footer>
<MalioButton label="Valider"/>
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="modalForm = true" />
<MalioModal v-model="modalForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="modalForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="modalForm = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Contenu long (body scrollable)</h2>
<MalioButton label="Ouvrir" variant="tertiary" @click="modalLong = true" />
<MalioModal v-model="modalLong">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<div class="flex flex-col gap-4">
<p v-for="n in 20" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll interne ; le header et le footer restent fixes.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="modalLong = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
<MalioButton label="Ouvrir" variant="danger" @click="modalNoDismiss = true" />
<MalioModal v-model="modalNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
</template>
<p class="text-m-text">Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</div>
</template>
@@ -82,17 +82,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sélection obligatoire</h2>
<MalioSelect
v-model="requiredValue"
:options="options"
label="Sélection obligatoire"
:required="true"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
<MalioSelect
@@ -103,28 +92,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
<MalioSelect
v-model="readonlyEmptyValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
<MalioSelect
v-model="readonlyFilledValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
<MalioSelect
@@ -184,7 +151,6 @@ const longOptions = [
{label: 'Republique tcheque', value: 'cz'},
]
const requiredValue = ref<string | number | null>(null)
const basicValue = ref<string | number | null>(null)
const labelValue = ref<string | number | null>(null)
const selectedValue = ref<string | number | null>('fr')
@@ -196,6 +162,4 @@ const emptyValue = ref<string | number | null>(null)
const shortListValue = ref<string | number | null>(null)
const longListValue = ref<string | number | null>(null)
const bottomValue = ref<string | number | null>(null)
const readonlyEmptyValue = ref<string | number | null>(null)
const readonlyFilledValue = ref<string | number | null>('fr')
</script>
@@ -13,7 +13,7 @@
<MalioSelectCheckbox
v-model="labelValue"
:options="options"
:display-tag="true"
displayTag="true"
empty-option-label=" "
/>
</div>
@@ -22,7 +22,7 @@
<MalioSelectCheckbox
v-model="labelValue1"
:options="options"
:display-tag="true"
displayTag="true"
label="Pays"
empty-option-label=" "
/>
@@ -123,28 +123,6 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
<MalioSelectCheckbox
v-model="readonlyEmptyValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
<MalioSelectCheckbox
v-model="readonlyFilledValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
<MalioSelectCheckbox
@@ -167,7 +145,6 @@
empty-option-label="Aucune selection"
/>
</div>
</div>
</template>
@@ -213,6 +190,4 @@ const selectAllValue = ref<Array<string | number>>([])
const selectAllCustomValue = ref<Array<string | number>>([])
const longListValue = ref<Array<string | number>>([])
const bottomValue = ref<Array<string | number>>([])
const readonlyEmptyValue = ref<Array<string | number>>([])
const readonlyFilledValue = ref<Array<string | number>>(['fr'])
</script>
+1 -137
View File
@@ -1,65 +1,5 @@
<template>
<div class="grid grid-cols-1 items-start gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-1 text-xl font-bold">Bac à sable (tous les cas)</h2>
<p class="mb-4 text-sm text-m-muted">
Règle les paramètres puis <strong>redimensionne le cadre en pointillés</strong>
(poignée en bas à droite) pour voir le nombre d'onglets s'adapter et les flèches apparaître.
</p>
<div class="mb-4 flex flex-wrap items-end gap-4 text-sm">
<label class="flex flex-col gap-1">Nb onglets : {{ sbCount }}
<input
v-model.number="sbCount"
type="range"
min="1"
max="15"
class="w-40"
>
</label>
<label class="flex flex-col gap-1">maxVisibleTabs (0 = auto)
<input
v-model.number="sbMax"
type="number"
min="0"
max="15"
class="w-20 rounded border px-2 py-1"
>
</label>
<label class="flex items-center gap-2">
<input
v-model="sbIcons"
type="checkbox"
> Icônes
</label>
<label class="flex items-center gap-2">
<input
v-model="sbLong"
type="checkbox"
> Labels longs
</label>
</div>
<div
class="resize-x overflow-hidden rounded border-2 border-dashed border-m-muted p-3"
style="width: 100%; min-width: 280px;"
>
<MalioTabList
v-model="sbValue"
:tabs="sbTabs"
:max-visible-tabs="sbMaxProp"
>
<template
v-for="t in sbTabs"
#[t.key]
:key="t.key"
>
<p class="p-4">Contenu : {{ t.label }}</p>
</template>
</MalioTabList>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTabList v-model="simpleValue" :tabs="tabs">
@@ -96,69 +36,11 @@
<template #details><p class="p-4">Détails avancés</p></template>
</MalioTabList>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-1 text-xl font-bold">Beaucoup d'onglets (fenêtré)</h2>
<p class="mb-4 text-sm text-m-muted">
7 onglets avec <code>:max-visible-tabs="5"</code> — flèches gauche/droite pour faire défiler
(1 par 1). L'onglet actif reste sélectionné même hors fenêtre.
</p>
<MalioTabList v-model="manyValue" :tabs="manyTabs" :max-visible-tabs="5">
<template #infos><p class="p-4">Contenu Informations</p></template>
<template #adresses><p class="p-4">Contenu Adresses</p></template>
<template #contacts><p class="p-4">Contenu Contacts</p></template>
<template #compta><p class="p-4">Contenu Comptabilité</p></template>
<template #documents><p class="p-4">Contenu Documents</p></template>
<template #historique><p class="p-4">Contenu Historique</p></template>
<template #parametres><p class="p-4">Contenu Paramètres</p></template>
</MalioTabList>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-1 text-xl font-bold">Peu d'onglets avec maxVisibleTabs</h2>
<p class="mb-4 text-sm text-m-muted">
3 onglets avec <code>:max-visible-tabs="5"</code> — le fenêtrage ne s'active pas
(onglets max), donc pas de flèches, affichage normal centré.
</p>
<MalioTabList v-model="fewValue" :tabs="fewTabs" :max-visible-tabs="5">
<template #general><p class="p-4">Contenu Général</p></template>
<template #adresses><p class="p-4">Contenu Adresses</p></template>
<template #contacts><p class="p-4">Contenu Contacts</p></template>
</MalioTabList>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
// --- Bac à sable interactif ---
const sbCount = ref(9)
const sbMax = ref(0)
const sbIcons = ref(true)
const sbLong = ref(false)
const SB_LABELS = [
'Informations', 'Adresses', 'Contacts', 'Comptabilité', 'Documents',
'Historique', 'Paramètres', 'Qualité', 'Facturation', 'Accueil',
'Notifications', 'Statistiques', 'Équipe', 'Sécurité', 'Étiquettes',
]
const SB_ICONS = [
'mdi:information-outline', 'mdi:map-marker-outline', 'mdi:account-box-outline', 'mdi:web',
'mdi:file-document-outline', 'mdi:history', 'mdi:cog-outline', 'mdi:check-decagram-outline',
'mdi:receipt-text-outline', 'mdi:home', 'mdi:bell-outline', 'mdi:chart-bar',
'mdi:account-group-outline', 'mdi:lock-outline', 'mdi:tag-outline',
]
const sbTabs = computed(() =>
Array.from({ length: sbCount.value }, (_, i) => ({
key: `sb${i}`,
label: sbLong.value ? `${SB_LABELS[i % SB_LABELS.length]} détaillé` : SB_LABELS[i % SB_LABELS.length],
icon: sbIcons.value ? SB_ICONS[i % SB_ICONS.length] : undefined,
})),
)
const sbMaxProp = computed(() => (sbMax.value > 0 ? sbMax.value : undefined))
const sbValue = ref('sb0')
import { ref } from 'vue'
const tabs = [
{ key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' },
@@ -178,25 +60,7 @@ const tabsTwo = [
{ key: 'details', label: 'Détails', icon: 'mdi:cog-outline' },
]
const manyTabs = [
{ key: 'infos', label: 'Informations', icon: 'mdi:information-outline' },
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
{ key: 'compta', label: 'Comptabilité', icon: 'mdi:web' },
{ key: 'documents', label: 'Documents', icon: 'mdi:file-document-outline' },
{ key: 'historique', label: 'Historique', icon: 'mdi:history' },
{ key: 'parametres', label: 'Paramètres', icon: 'mdi:cog-outline' },
]
const simpleValue = ref('qualimat')
const noIconValue = ref('tab1')
const twoTabValue = ref('general')
const manyValue = ref('infos')
const fewTabs = [
{ key: 'general', label: 'Général', icon: 'mdi:information-outline' },
{ key: 'adresses', label: 'Adresses', icon: 'mdi:map-marker-outline' },
{ key: 'contacts', label: 'Contacts', icon: 'mdi:account-box-outline' },
]
const fewValue = ref('general')
</script>
@@ -1,56 +0,0 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTimePicker v-model="simpleValue" label="Heure" />
<p class="mt-2 text-sm text-m-muted">Valeur : {{ simpleValue || '—' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioTimePicker label="Heure (readonly vide)" :readonly="true" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioTimePicker v-model="readonlyFilledTime" label="Heure (readonly rempli)" :readonly="true" />
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const readonlyFilledTime = ref('14:30')
const simpleValue = ref('')
const initialValue = ref('08:30')
const disabledValue = ref('14:15')
const errorValue = ref('25:90')
const successValue = ref('09:00')
const noClearValue = ref('10:00')
</script>
-17
View File
@@ -25,18 +25,6 @@ export const navSections: SidebarSection[] = [
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
],
},
{
label: 'DATES & HEURES',
icon: 'mdi:calendar-clock',
items: [
{label: 'Date', to: '/composant/date/date'},
{label: 'Plage de dates', to: '/composant/date/dateRange'},
{label: 'Semaine', to: '/composant/date/dateWeek'},
{label: 'Date & heure', to: '/composant/date/datetime'},
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'},
],
},
{
label: 'SÉLECTIONS',
icon: 'mdi:form-dropdown',
@@ -53,9 +41,7 @@ export const navSections: SidebarSection[] = [
items: [
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
{label: 'Drawer', to: '/composant/drawer/drawer'},
{label: 'Modal', to: '/composant/modal/modal'},
{label: 'Onglets', to: '/composant/tab/tabList'},
{label: 'Accordéon', to: '/composant/accordion/accordion'},
],
},
{
@@ -69,12 +55,9 @@ export const navSections: SidebarSection[] = [
label: 'DIVERS',
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'},
{label: 'Filtres', to: '/composant/filtre/filtres'},
],
},
]
-50
View File
@@ -31,60 +31,10 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-31] Création d'un composant téléphone
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
* [#MUI-34] Revoir le système de playground
* [#MUI-33] Développer le composant Datepicker
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
* [#MUI-37] Création d'un composant accordéon
* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire)
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
* [#MUI-42] Anneau de focus clavier standardisé (`outline` 2px `m-primary`, offset 2px) affiché **uniquement** à la navigation clavier (jamais au clic souris), sur l'ensemble des champs et contrôles : inputs (Text, Email, Password, Phone, Amount, Number + boutons ±, Upload, TextArea, Autocomplete), Select, SelectCheckbox, famille Date (Date, DateRange, DateTime, DateWeek), Button, ButtonIcon. Mécanique : composable `useKbdFocusRing` (détection de modalité clavier/souris) + utilitaires CSS `.m-focus-ring` (éléments à `:focus-visible` natif) et `.m-focus-ring-kbd` (champs texte, où `:focus-visible` se déclenche aussi à la souris)
* [#MUI-42] Anneau « combo » : quand un dropdown / calendrier est ouvert (Autocomplete, Select, SelectCheckbox, Date), l'anneau entoure le champ **et** la liste / le calendrier d'un seul tenant, adapté au sens d'ouverture (utilitaires `.m-combo-ring-top` / `.m-combo-ring-bottom`)
* [#MUI-42] Navigation clavier WAI-ARIA APG sur les listes déroulantes : Select et SelectCheckbox gagnent la navigation (flèches, Home/End, Entrée/Espace, Échap, Tab — absente jusque-là), avec scroll automatique de l'option active et `aria-activedescendant` ; InputAutocomplete complété (scroll auto, ArrowUp ouvre sur la dernière option, Home/End, Tab ferme)
* [#MUI-42] SelectCheckbox : la ligne « Tout sélectionner » est intégrée à la navigation clavier ; le clic sur toute la ligne d'option (et plus seulement le label) coche/décoche
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
* [#MUI-43] MalioDate : event `update:valid` (booléen) exposant l'état de validité de la saisie (`false` sur date malformée ou hors `min`/`max`, qui n'émet pas `modelValue`) — permet au parent de bloquer le submit ; la validité ne couvre pas `required` (champ vide = valide)
* [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). Nouveau parseur `parseDisplayToIsoDateTime`.
* [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur.
* [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas).
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
* [#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 `<li>` (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 `<li>` et le `<a>` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du `<a>`, 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`).
* Select : nouvelle prop `fieldClass` pour surcharger les classes du field (notamment la hauteur `h-[40px]` jusqu'ici codée en dur) ; utilisée par le DataTable pour passer le sélecteur de perPage à `30px`.
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
* [#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
* Sidebar : le **lien actif** reste actif sur les **sous-routes** (match par préfixe via `useRoute().path` au lieu de l'`active-class` de NuxtLink qui dépendait de l'imbrication des routes) — ex. `/supplier` reste surligné sur `/supplier/1/edit`. Nouvelle option `exact: true` par item pour forcer le match strict.
* Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` 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
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
* Espace réservé (`min-h-[1rem]`) pour le paragraphe hint/error/success de 15 composants (Input*, Select*, Time*, CalendarField, Checkbox) — l'apparition d'une erreur ne décale plus les cellules voisines dans une grille
* InputPhone : la croix `+` (add button) suit la même cascade d'état que les autres icônes du champ (muted / primary en focus / black quand rempli / danger / success) au lieu d'être figée en primary
* Select / SelectCheckbox : le chevron suit l'état du champ (muted par défaut, primary à l'ouverture, black avec une option sélectionnée, danger / success en cas d'erreur ou succès) au lieu de `text-current`
* InputTextArea : composant single-root (était multi-root) — le wrapper du message ne prend plus sa propre cellule de grille, `row-span-2` fonctionne à nouveau
* Label désactivé en `text-m-muted` (gris des bordures) au lieu de `text-black/60` sur les inputs à floating-label (InputText, Email, Password, Amount, Phone, Upload, Autocomplete, TextArea, RichText)
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
* [#MUI-42] RadioButton : ajout d'un focus visible au clavier (`outline` 2px `m-primary`, offset 2px) — l'input en `appearance-none` n'avait aucun indicateur de focus, seul l'`outline: auto 1px` du navigateur restait, quasi invisible. La navigation native (Tab entre groupes, flèches dans le groupe) reste inchangée
* [#MUI-42] Checkbox : ajout d'un focus visible au clavier sur la case (`outline` 2px `m-primary`, offset 2px) — l'input réel est masqué (`clip-path`), aucun indicateur n'apparaissait à la tabulation
* [#MUI-42] Select : le focus reste sur le bouton après sélection (un `blur()` renvoyait le focus au `body`, cassant la tabulation clavier — un Tab repartait du haut de page)
+20 -420
View File
@@ -2,10 +2,6 @@
Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-model` pour le binding bidirectionnel sur les composants de formulaire.
> **Champ obligatoire :** sur les composants de formulaire, la prop `required` ajoute un astérisque rouge dans le label. C'est un repère visuel ; la sémantique « obligatoire » est portée par l'attribut natif `required` ou `aria-required`.
> **Focus clavier :** tous les champs et contrôles affichent un anneau de focus (`outline` 2px `m-primary`, offset 2px) **uniquement** à la navigation clavier (Tab), jamais au clic souris. Sur les composants à dropdown/calendrier ouverts, l'anneau entoure le champ et la liste d'un seul tenant. Voir la note « Clavier » de chaque composant pour la navigation détaillée.
---
## MalioInputText
@@ -19,11 +15,10 @@ Champ texte avec label, icône optionnelle et support de masque de saisie.
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
| `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `required` | `boolean` | `false` | Champ requis |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `iconName` | `string` | `''` | Icône Iconify (ex: `mdi:magnify`) |
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
| `iconSize` | `string \| number` | `24` | Taille icône |
@@ -58,11 +53,9 @@ Champ mot de passe avec toggle visibilité.
| `displayIcon` | `boolean` | `true` | Afficher l'icône toggle |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
**Events :** `update:modelValue(value: string)`
@@ -86,35 +79,25 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
| `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
| `required` | `boolean` | `false` | Champ requis |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `iconName` | `string` | `'mdi:email-outline'` | Icône Iconify (chaîne vide pour masquer) |
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
| `iconSize` | `string \| number` | `24` | Taille icône |
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
| `inputClass` | `string` | `''` | Classes CSS input |
| `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur |
> **Sanitisation à la saisie :** tous les espaces sont supprimés automatiquement au fil de la frappe (sans masque). Avec `lowercase=true`, la valeur est également convertie en minuscules à la frappe. La validation du format (ex. présence d'un `@`) reste à la charge du parent via la prop `error` ou la couche de validation.
**Events :**
- `update:modelValue(value: string)`
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
**Events :** `update:modelValue(value: string)`
```vue
<MalioInputEmail v-model="email" label="Adresse email" />
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
```
---
@@ -132,11 +115,10 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `required` | `boolean` | `false` | Champ requis |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône |
| `iconSize` | `string \| number` | `24` | Taille icône |
@@ -164,7 +146,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
## MalioInputAutocomplete
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Par défaut le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache. Pour une liste **statique** courte, activer `localFilter` fait filtrer le composant lui-même (case-insensitive `label.includes(query)`) sans avoir à brancher `@search`.
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
@@ -177,7 +159,6 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
| `localFilter` | `boolean` | `false` | Filtre `options` côté client par sous-chaîne du label (case-insensitive). À utiliser pour les listes statiques courtes ; en mode API on laisse `false` et le parent répond à `@search`. |
| `iconName` | `string` | `''` | Icône Iconify décorative |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
@@ -187,11 +168,10 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint |
| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `required` | `boolean` | `false` | Champ requis |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` | `string` | `''` | Classes CSS input |
| `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur |
@@ -202,11 +182,11 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
**Clavier (WAI-ARIA APG) :** `↓` ouvre / option suivante, `↑` précédente (ou ouvre sur la dernière option si fermé), `Début`/`Fin`, scroll automatique de l'option active, `Entrée` sélection (ou création), `Échap` annule, `Tab` ferme. Anneau de focus clavier (combo champ + liste à l'ouverture).
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
```vue
<!-- Usage statique (filtrage côté client via local-filter) -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
<!-- Usage statique -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" />
<!-- Usage API (parent gère le fetch) -->
<MalioInputAutocomplete
@@ -244,25 +224,19 @@ async function onSearchClients(query: string) {
Champ montant avec icône devise (euro par défaut).
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
| `label` | `string` | `''` | Label |
| `iconName` | `string` | `'mdi:currency-eur'` | Icône devise |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
**Events :** `update:modelValue(value: string)`
```vue
<MalioInputAmount v-model="montant" label="Montant TTC" />
<MalioInputAmount v-model="prix" label="Prix" error="Montant invalide" />
<MalioInputAmount v-model="gros" label="Budget" />
<!-- saisie 1234567.89 affiché "1 234 567,89", modelValue "1234567.89" -->
```
---
@@ -278,9 +252,7 @@ Champ numérique avec boutons +/-.
| `min` | `number \| string` | — | Valeur minimum |
| `max` | `number \| string` | — | Valeur maximum |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
**Events :** `update:modelValue(value: string)`
@@ -303,9 +275,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
| `maxLength` | `number` | `800` | Longueur max |
| `showCounter` | `boolean` | `false` | Afficher le compteur |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
**Events :** `update:modelValue(value: string)`
@@ -333,11 +303,9 @@ Zone de texte multiligne avec compteur et redimensionnement.
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
| `readonly` | `boolean` | `false` | Lecture seule (toolbar visible mais désactivée) |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `outputFormat` | `'markdown' \| 'html'` | `'html'` | Format émis dans `update:modelValue` |
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
| `labelClass` | `string` | `''` | Classes CSS label (twMerge) |
@@ -364,20 +332,13 @@ Champ d'upload de fichier.
| `label` | `string` | `''` | Label |
| `accept` | `string` | `''` | Types de fichiers acceptés |
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
| `clearable` | `boolean` | `false` | Affiche une croix (`mdi:close`) focusable qui vide le champ quand un fichier est sélectionné |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`, `clear()`
**Clavier :** `Entrée` / `Espace` ouvrent le sélecteur de fichier. La croix `clearable` est focusable (anneau clavier, `Entrée`/`Espace`).
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
```vue
<MalioInputUpload v-model="fileName" label="Document" accept=".pdf,.doc" @file-selected="onFile" />
<MalioInputUpload v-model="fileName" label="Document" clearable @clear="onClear" />
```
---
@@ -395,23 +356,17 @@ Liste déroulante.
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
| `fieldClass` | `string` | `''` | Classes supplémentaires sur le field (override hauteur, ex. `h-[30px]`) |
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
**Events :** `update:modelValue(value: string | number | null)`
**Slots :** `icon` (icône dropdown custom)
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto de l'option active), `Début`/`Fin`, `Entrée`/`Espace` sélectionnent, `Échap`/`Tab` ferment. Le focus reste sur le bouton après sélection. Anneau de focus clavier (combo bouton + liste à l'ouverture, adapté au sens haut/bas).
```vue
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
@@ -426,22 +381,17 @@ Liste déroulante multi-sélection avec checkboxes.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `(string \| number)[]` | `[]` | Valeurs sélectionnées (v-model) |
| `modelValue` | `(string \| number)[]` | **requis** | Valeurs sélectionnées (v-model) |
| `options` | `{ value: string \| number, text: string }[]` | `[]` | Options |
| `displayTag` | `boolean` | `false` | Afficher les tags sélectionnés |
| `displaySelectAll` | `boolean` | `false` | Afficher "Tout sélectionner" |
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
| `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
**Events :** `update:modelValue(value: (string | number)[])`
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto), `Début`/`Fin`, `Entrée`/`Espace` cochent/décochent l'option active (la liste **reste ouverte**), `Échap`/`Tab` ferment. La ligne « Tout sélectionner » est navigable au clavier. Le clic sur toute la ligne (pas que le label) coche/décoche. Anneau de focus clavier (combo bouton + liste à l'ouverture).
```vue
<MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" />
<MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" />
@@ -459,14 +409,10 @@ Case à cocher.
| `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
**Events :** `update:modelValue(value: boolean)`
**Clavier :** `Espace` coche/décoche. Focus clavier visible sur la case (`outline` 2px `m-primary`).
```vue
<MalioCheckbox v-model="accepte" label="J'accepte les conditions" />
<MalioCheckbox v-model="newsletter" label="Newsletter" disabled />
@@ -486,12 +432,9 @@ Bouton radio (à utiliser en groupe avec le même `name`).
| `name` | `string` | `''` | Nom du groupe radio |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
**Events :** `update:modelValue(value: string | number | boolean | null)`
**Clavier :** comportement natif d'un groupe radio (options partageant le même `name`) — `Tab` / `Maj+Tab` entre/sort du groupe (1 seul arrêt par groupe), `↑↓←→` déplacent la sélection entre les options d'un même groupe. Focus clavier visible (`outline` 2px `m-primary`).
```vue
<MalioRadioButton v-model="civilite" name="civilite" value="M" label="Monsieur" />
<MalioRadioButton v-model="civilite" name="civilite" value="Mme" label="Madame" />
@@ -499,136 +442,6 @@ Bouton radio (à utiliser en groupe avec le même `name`).
---
## MalioDate
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 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.
L'event `update:rawValue` expose la **saisie brute** sur un canal séparé, pour les formulaires en validation back-autoritative (le serveur tranche le format et renvoie un `422`). Il est émis à chaque commit : saisie invalide (non parsable ou hors `min`/`max`) → la chaîne trimmée telle que tapée (ex. `"32/13/2026"`) ; saisie valide ou vide, clear, sélection au calendrier → `''`. Le parent construit alors son payload via `valid ? modelValue : rawValue`. La saisie invalide **ne transite jamais** par `modelValue` (qui reste `string` ISO `| null` pour l'affichage et le round-trip) ; `valid` dit *qu'il y a* une erreur, `rawValue` dit *quoi* envoyer.
La prop `markedDates` permet d'afficher un **statut par jour** dans la grille : un objet `{ "YYYY-MM-DD": "success" | "danger" }` applique un fond tokenisé (`success` → vert clair, `danger` → rouge clair). C'est **purement générique** — aucune logique métier dans le layer : le consommateur fournit la liste des jours à marquer. **Précédence** : un jour sélectionné garde son style primary (fond plein, prime sur la variante marquée) ; le jour courant (`today`) **garde sa bordure** et reçoit **en plus** le fond marqué s'il est dans `markedDates` (vert/rouge bordé) ; sinon, fond marqué simple.
L'event `month-change` remonte le **mois affiché** dans le popover (`{ month: number /* 0-11 */, year: number }`). Il est émis **à l'ouverture** du popover (sur le mois de la valeur, ou le mois courant) **et à chaque navigation** (chevrons, sélection dans la vue mois). Couplé à `markedDates`, il permet à un consommateur (ex. l'écran *Heures* de SIRH) de charger les statuts du mois visible à la volée : on écoute `@month-change` pour fetch, puis on réinjecte le résultat dans `:marked-dates`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivés) |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
| `markedDates` | `Record<string, 'success' \| 'danger'>` | `undefined` | Statut par jour : ISO `"YYYY-MM-DD"` → fond tokenisé. Générique (fourni par le consommateur). |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)`, `month-change(value: { month: number /* 0-11 */, year: number })`
**Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_
```vue
<MalioDate v-model="date" label="Date de naissance" />
<!-- date === "2026-05-20" -->
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
<MalioDate v-model="date" label="Date de naissance" editable />
<MalioDate v-model="date" label="Date de naissance" editable @update:valid="dateValide = $event" />
<!-- Validation back-autoritative : on envoie la saisie brute si invalide -->
<MalioDate v-model="date" editable @update:valid="valide = $event" @update:rawValue="brut = $event" />
<!-- payload : valide ? date : brut -->
<!-- Statut par jour + chargement du mois visible (ex. SIRH « Heures ») -->
<MalioDate
v-model="date"
:marked-dates="statutsDuMois"
@month-change="({ month, year }) => chargerStatuts(month, year)"
/>
<!-- statutsDuMois === { "2026-05-05": "success", "2026-05-20": "danger" } -->
```
---
## MalioDateRange
Sélecteur de **plage de dates** (date de début → date de fin) dans un seul champ. Cliquer un premier jour démarre la plage, le second la termine ; un survol prévisualise la plage.
La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"`), ou `null`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `{ start: string; end: string } \| null` | `undefined` | Plage de dates ISO (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
```vue
<MalioDateRange v-model="periode" label="Période de séjour" />
<!-- periode === { start: "2026-05-20", end: "2026-05-27" } -->
```
---
## MalioDateWeek
Sélecteur de **semaine ISO** : cliquer un jour (ou un numéro de semaine) sélectionne la semaine entière.
La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2026-W21"`), ou `null`. Le champ affiche `Semaine W (JJ/MM → JJ/MM/AAAA)`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Semaine ISO `"YYYY-Www"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioDateWeek v-model="semaine" label="Semaine de livraison" />
<!-- semaine === "2026-W21" -->
```
---
## MalioTime
Sélecteur d'heure.
@@ -639,9 +452,7 @@ Sélecteur d'heure.
| `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
**Events :** `update:modelValue(value: string)`
@@ -652,80 +463,6 @@ Sélecteur d'heure.
---
## MalioTimePicker
Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes infinies (heures `0023`, minutes `0059`, pas de 1) avec une bande de sélection centrale ; la valeur centrée est sélectionnée. Défilement, clic sur une valeur (recentrage) ou flèches clavier (`role="spinbutton"`). Pour une saisie clavier directe au format texte, voir plutôt `MalioTime`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioTimePicker v-model="heure" label="Heure" />
<MalioTimePicker v-model="heure" label="Départ" hint="Format HH:MM" />
```
---
## MalioDateTime
Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille).
> Depuis MUI-39, le réglage de l'heure utilise le sélecteur à molettes (cf. `MalioTimePicker`), qui remplace l'ancien `<input type="time">` natif intérimaire.
La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date + heure ISO naïve `"YYYY-MM-DDTHH:MM:00"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Borne min. Borne la grille sur la partie date ; en saisie `editable`, comparée au **datetime complet** (préférer une borne datetime, sinon les heures du jour `max` seraient rejetées). |
| `max` | `string` | `undefined` | Borne max (idem) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA HH:MM` (masque maska, validation au blur / sur Entrée) en plus du calendrier |
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)`
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` 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 en gris et se remplit au fil de la saisie (cf. MalioDate). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`. L'event `update:rawValue` expose la saisie brute pour la validation back-autoritative (mêmes règles que `MalioDate` : texte trimmé sur saisie invalide, `''` sinon — clear et sélection au calendrier compris).
```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" -->
<MalioDateTime v-model="rdv" label="Rendez-vous" editable @update:valid="rdvValide = $event" />
<MalioDateTime v-model="rdv" editable @update:valid="valide = $event" @update:rawValue="brut = $event" />
```
---
## MalioButton
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
@@ -749,11 +486,8 @@ Bouton d'action avec 4 variantes visuelles et icône optionnelle.
<MalioButton label="Voir plus" variant="tertiary" />
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
<MalioButton label="Pleine largeur" button-class="w-full" />
<MalioButton label="Modifier" button-class="w-m-btn-action" /> <!-- 150px, format bouton d'action -->
```
> **Token de largeur partagé** : `w-m-btn-action` (150px) est exposé via `tailwind.config.ts` du layer, branché sur la CSS var `--m-btn-action-width`. Pour les boutons d'action (listes, lignes de tableau, footers denses…). Themable côté consommateur en redéfinissant `--m-btn-action-width` dans son propre CSS.
---
## MalioButtonIcon
@@ -786,9 +520,6 @@ Navigation par onglets avec contenu dynamique.
|------|------|--------|-------------|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
| `maxVisibleTabs` | `number` | `undefined` | **Plafond** optionnel du nombre d'onglets visibles. Non défini = uniquement limité par la largeur. |
Le nombre d'onglets affichés s'**adapte automatiquement à la largeur disponible** (mesurée au runtime via `ResizeObserver`). Quand tous les onglets ne tiennent pas, la barre passe en mode fenêtré : les flèches gauche/droite (fixées aux bords) font défiler la fenêtre un onglet à la fois, et le nombre visible est choisi pour que les onglets tiennent (jamais de chevauchement ni de rognage). `maxVisibleTabs`, s'il est fourni, plafonne ce nombre.
Type `Tab` :
@@ -826,54 +557,6 @@ const tabs = computed(() => [
---
## MalioAccordion
Accordéon compositionnel : `<MalioAccordion>` enveloppe des `<MalioAccordionItem>`. Plusieurs panneaux ouverts (`multiple`, défaut) ou un seul (`single`). Pensé pour les filtres en drawer et les FAQ.
### MalioAccordion
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts |
| `modelValue` | `string \| string[]` | `undefined` | Clés ouvertes (v-model). `string[]` en `multiple`, `string` en `single` |
| `id` | `string` | auto | Préfixe des IDs d'accessibilité |
| `groupClass` | `string` | `''` | Classes du conteneur (twMerge) |
**Events :** `update:modelValue(value: string | string[])`
### MalioAccordionItem
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `title` | `string` | — | Texte de l'en-tête |
| `value` | `string` | auto | Clé unique de la section |
| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non contrôlé) |
| `disabled` | `boolean` | `false` | En-tête non cliquable |
| `headerClass` | `string` | `''` | Override classes en-tête (twMerge) |
| `panelClass` | `string` | `''` | Override classes panneau (twMerge) |
**Slot :** par défaut = contenu du panneau.
```vue
<!-- Filtres : plusieurs sections ouvertes -->
<MalioAccordion v-model="ouverts">
<MalioAccordionItem title="Prix" value="prix">
<MalioInputAmount v-model="prix" />
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<MalioCheckbox v-model="cats" />
</MalioAccordionItem>
</MalioAccordion>
<!-- FAQ : une seule section ouverte -->
<MalioAccordion mode="single">
<MalioAccordionItem title="Question 1" value="q1">Réponse 1</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">Réponse 2</MalioAccordionItem>
</MalioAccordion>
```
---
## MalioSidebar
Barre latérale de navigation rétractable.
@@ -885,10 +568,7 @@ Barre latérale de navigation rétractable.
| `sidebarClass` | `string` | `''` | Classes CSS sidebar |
| `toggleClass` | `string` | `''` | Classes CSS bouton toggle |
**Type SidebarSection :** `{ label?: string, icon?: string, items: SidebarItem[] }`
**Type SidebarItem :** `{ label: string, to: string, exact?: boolean }`
**Lien actif :** un lien est marqué actif (texte `m-primary` + semi-bold) quand la route courante **est ce lien ou une de ses sous-routes** (match par préfixe) — ex. `/supplier` reste actif sur `/supplier/1/edit`. Mettre `exact: true` sur l'item force le match strict (actif uniquement sur la route exacte). Indépendant de l'imbrication des routes côté consommateur.
**Type SidebarSection :** `{ title?: string, items: { label: string, icon?: string, to?: string, href?: string, active?: boolean }[] }`
**Events :** `update:modelValue(value: boolean)`
**Slots :** `logo` (sidebar ouverte), `logo-collapsed` (sidebar fermée)
@@ -919,14 +599,14 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS du footer fixe (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS wrapper du footer (aucune position imposée) |
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.), fixe en haut. S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable : seul le body défile).
- `footer`actions (boutons). Rendu en bas du panneau, fixe, hors de la zone scrollable. N'apparaît que si le slot est fourni.
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable).
- `footer`rendu dans la zone scrollable, sans positionnement imposé : le consommateur choisit (`sticky bottom-0`, `fixed`, ou rien).
```vue
<MalioDrawer v-model="isOpen">
@@ -942,12 +622,14 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
<p>Drawer large depuis la gauche</p>
</MalioDrawer>
<!-- Footer d'actions (fixe en bas, hors zone scrollable) -->
<!-- Footer collé en bas (le consommateur applique le positionnement) -->
<MalioDrawer v-model="isOpen">
<template #header><h2>Formulaire</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
<div class="sticky bottom-0 bg-white py-4">
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
</div>
</template>
</MalioDrawer>
@@ -960,58 +642,6 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro
---
## MalioModal
Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs modals. Structure : header fixe, body scrollable (`max-h-[85vh]`), footer fixe.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
| `modalClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-lg` (twMerge) |
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable).
- `footer` — actions (boutons). Rendu en bas, fixe, séparé par une bordure. N'apparaît que si le slot est fourni.
```vue
<MalioModal v-model="isOpen">
<template #header>
<h2 class="text-[24px] font-bold">Détails</h2>
</template>
<p>Contenu de la modal</p>
</MalioModal>
<!-- Largeur custom + footer d'actions -->
<MalioModal v-model="isOpen" modal-class="max-w-lg">
<template #header><h2>Nouveau contact</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="isOpen = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="isOpen = false" />
</template>
</MalioModal>
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
<MalioModal v-model="isOpen" :dismissable="false" :close-on-escape="false">
<template #header><h2>Action requise</h2></template>
<p>Fermeture via la croix uniquement</p>
</MalioModal>
```
---
## MalioDataTable
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
@@ -1065,33 +695,3 @@ Tableau de données presentational avec pagination, filtres par slots et lignes
v-model:per-page="perPage"
/>
```
---
## MalioSiteSelector
Sélecteur de site sous forme de tuiles segmentées (`role="radiogroup"`). Chaque site occupe une tuile de largeur égale ; la tuile active s'affiche pleine opacité dans sa couleur (`site.color`), les autres sont atténuées. Pattern contrôlé (`v-model`) ou non contrôlé (premier site sélectionné par défaut).
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `sites` | `{ id: string, name: string, color: string }[]` | **requis** | Liste des sites (la `color` colore la tuile active) |
| `modelValue` | `string` | `undefined` | `id` du site sélectionné (v-model) |
| `id` | `string` | auto | Identifiant HTML du conteneur |
| `groupClass` | `string` | `''` | Classes CSS du conteneur (twMerge) |
| `tileClass` | `string` | `''` | Classes CSS de chaque tuile (twMerge) |
| `labelClass` | `string` | `''` | Classes CSS du label de tuile (twMerge) |
**Events :**
- `update:modelValue(value: string)``id` du site sélectionné (v-model)
- `change(site: Site)` — émis avec l'objet site complet sélectionné
```vue
<MalioSiteSelector
v-model="siteId"
:sites="[
{ id: 'paris', name: 'Paris', color: '#2563eb' },
{ id: 'lyon', name: 'Lyon', color: '#16a34a' },
]"
@change="onSiteChange"
/>
```
-39
View File
@@ -2,46 +2,10 @@
@tailwind components;
@tailwind utilities;
@layer components {
/* Anneau de focus clavier standard (navigation au Tab), invisible à la souris.
Deux déclencheurs, même rendu :
- .m-focus-ring → s'appuie sur :focus-visible natif. Pour les éléments
où :focus-visible se limite déjà au clavier (boutons,
onglets, tuiles, checkbox/radio…).
- .m-focus-ring-kbd → classe ajoutée en JS (via useKbdFocusRing) uniquement
quand le focus vient du clavier. Pour les champs texte,
où :focus-visible natif se déclenche aussi à la souris.
Le `:focus` sur .m-focus-ring-kbd élève la spécificité pour passer devant le
`outline-none` des inputs. */
.m-focus-ring:focus-visible,
.m-focus-ring-kbd:focus {
outline: 2px solid rgb(var(--m-primary) / 1);
outline-offset: 2px;
}
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
reste sans contour pour un raccord sans couture. */
.m-combo-ring-top {
box-shadow:
-2px 0 0 0 rgb(var(--m-primary) / 1),
2px 0 0 0 rgb(var(--m-primary) / 1),
0 -2px 0 0 rgb(var(--m-primary) / 1);
}
.m-combo-ring-bottom {
box-shadow:
-2px 0 0 0 rgb(var(--m-primary) / 1),
2px 0 0 0 rgb(var(--m-primary) / 1),
0 2px 0 0 rgb(var(--m-primary) / 1);
}
}
@layer base {
:root {
/* ── Globales ── */
--m-primary: 34 39 131; /* #222783 - Bleu Malio */
--m-primary-light: 239 239 253; /* #EFEFFD - Teinte claire du primary (fonds doux) */
--m-bg: 243 244 248; /* #F3F4F8 - Fond de page */
--m-surface: 243 244 248; /* #F3F4F8 - Fond hover/cartes */
--m-text: 15 23 42; /* #0F172A */
@@ -66,9 +30,6 @@
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
--m-btn-danger-active: 255 83 86; /* #FF5356 */
/* ── Largeurs Boutons ── */
--m-btn-action-width: 150px; /* Boutons d'action (liste, ligne tableau, footer dense…) */
/* ── Couleurs de site (usage ponctuel) ── */
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
@@ -1,256 +0,0 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import {nextTick} from 'vue'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
const TWO_ITEMS = `
<MalioAccordionItem title="Prix" value="prix"><p>Contenu prix</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat"><p>Contenu catégorie</p></MalioAccordionItem>
`
function mountAccordion(props: Record<string, unknown> = {}, slot: string = TWO_ITEMS, attachTo?: HTMLElement) {
return mount(Accordion, {
props,
slots: {default: slot},
attachTo,
global: {components: {MalioAccordionItem: AccordionItem}},
})
}
describe('MalioAccordion — rendu & mode multiple', () => {
it('renders each item header with its title', () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers).toHaveLength(2)
expect(headers[0].text()).toContain('Prix')
expect(headers[1].text()).toContain('Catégorie')
})
it('renders the slot content of each panel', () => {
const wrapper = mountAccordion()
expect(wrapper.html()).toContain('Contenu prix')
expect(wrapper.html()).toContain('Contenu catégorie')
})
it('all panels are collapsed by default', () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('false')
const regions = wrapper.findAll('[role="region"]')
expect(regions[0].classes()).toContain('grid-rows-[0fr]')
})
it('opens a panel on header click (multiple mode is default)', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('true')
const regions = wrapper.findAll('[role="region"]')
expect(regions[0].classes()).toContain('grid-rows-[1fr]')
})
it('keeps multiple panels open simultaneously in multiple mode', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[1].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('true')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('closes an open panel when its header is clicked again', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[0].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('false')
})
it('wires aria-controls / aria-labelledby / role=region correctly', () => {
const wrapper = mountAccordion({id: 'acc'})
const headers = wrapper.findAll('button[aria-expanded]')
const regions = wrapper.findAll('[role="region"]')
expect(headers[0].attributes('id')).toBe('acc-header-prix')
expect(headers[0].attributes('aria-controls')).toBe('acc-panel-prix')
expect(regions[0].attributes('id')).toBe('acc-panel-prix')
expect(regions[0].attributes('aria-labelledby')).toBe('acc-header-prix')
})
it('emits update:modelValue with an array in multiple mode', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
await nextTick()
})
})
describe('MalioAccordion — mode single & contrôlé', () => {
it('opening a panel closes the others in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[1].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('emits a string in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
})
it('emits empty string when closing the open panel in single mode', async () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('respects modelValue array in controlled multiple mode', () => {
const wrapper = mountAccordion({modelValue: ['cat']})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('respects modelValue string in controlled single mode', () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('true')
expect(headers[1].attributes('aria-expanded')).toBe('false')
})
it('does not mutate local state in controlled mode (emits only)', async () => {
const wrapper = mountAccordion({modelValue: []})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
// état piloté par le parent : sans mise à jour de la prop, reste fermé
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
})
})
describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
const WITH_DEFAULT_OPEN = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :default-open="true"><p>C</p></MalioAccordionItem>
`
const WITH_DISABLED = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :disabled="true"><p>C</p></MalioAccordionItem>
`
it('opens defaultOpen items initially in uncontrolled mode', async () => {
const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN)
await nextTick()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('sets disabled and aria-disabled on a disabled item', () => {
const wrapper = mountAccordion({}, WITH_DISABLED)
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[1].attributes('disabled')).toBeDefined()
expect(headers[1].attributes('aria-disabled')).toBe('true')
})
it('does not toggle a disabled item on click', async () => {
const wrapper = mountAccordion({}, WITH_DISABLED)
const headers = wrapper.findAll('button[aria-expanded]')
await headers[1].trigger('click')
expect(headers[1].attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('moves focus to the next header on ArrowDown', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[0].element as HTMLElement).focus()
await headers[0].trigger('keydown', {key: 'ArrowDown'})
expect(document.activeElement).toBe(headers[1].element)
wrapper.unmount()
root.remove()
})
it('wraps focus to the first header on ArrowDown from the last', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[1].element as HTMLElement).focus()
await headers[1].trigger('keydown', {key: 'ArrowDown'})
expect(document.activeElement).toBe(headers[0].element)
wrapper.unmount()
root.remove()
})
it('moves focus to the previous header on ArrowUp', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[1].element as HTMLElement).focus()
await headers[1].trigger('keydown', {key: 'ArrowUp'})
expect(document.activeElement).toBe(headers[0].element)
wrapper.unmount()
root.remove()
})
it('skips disabled headers during keyboard navigation', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const slot = `
<MalioAccordionItem title="A" value="a"><p>A</p></MalioAccordionItem>
<MalioAccordionItem title="B" value="b" :disabled="true"><p>B</p></MalioAccordionItem>
<MalioAccordionItem title="C" value="c"><p>C</p></MalioAccordionItem>
`
const wrapper = mountAccordion({}, slot, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[0].element as HTMLElement).focus()
await headers[0].trigger('keydown', {key: 'ArrowDown'})
// saute le header désactivé (B) pour aller directement à C
expect(document.activeElement).toBe(headers[2].element)
wrapper.unmount()
root.remove()
})
})
describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
const ONE = `<MalioAccordionItem title="A" value="a"><p>contenu</p></MalioAccordionItem>`
const ONE_OPEN = `<MalioAccordionItem title="A" value="a" :default-open="true"><p>contenu</p></MalioAccordionItem>`
it('clips the panel (overflow-hidden) while collapsed', () => {
const wrapper = mountAccordion({}, ONE)
const inner = wrapper.find('[role="region"] > div')
expect(inner.classes()).toContain('overflow-hidden')
expect(inner.classes()).not.toContain('overflow-visible')
})
it('lets the panel overflow once open at mount (defaultOpen)', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('switches to overflow-visible after the open transition ends', async () => {
const wrapper = mountAccordion({}, ONE)
await wrapper.find('button[aria-expanded]').trigger('click')
await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('re-clips (overflow-hidden) as soon as it closes', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
await wrapper.find('button[aria-expanded]').trigger('click')
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
})
})
@@ -1,109 +0,0 @@
<template>
<div v-bind="$attrs" :class="rootClass">
<slot />
</div>
</template>
<script setup lang="ts">
import {computed, provide, ref, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey, type AccordionItemRegistration} from './context'
defineOptions({name: 'MalioAccordion', inheritAttrs: false})
const props = withDefaults(defineProps<{
mode?: 'single' | 'multiple'
modelValue?: string | string[]
id?: string
groupClass?: string
}>(), {
mode: 'multiple',
modelValue: undefined,
id: '',
groupClass: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const generatedId = useId()
const baseId = computed(() => props.id || `malio-accordion-${generatedId}`)
const mode = computed(() => props.mode)
const isControlled = computed(() => props.modelValue !== undefined)
const localOpen = ref<string[]>([])
const items = ref<AccordionItemRegistration[]>([])
const openKeys = computed<string[]>(() => {
if (isControlled.value) {
const v = props.modelValue
if (props.mode === 'single') return v ? [v as string] : []
if (Array.isArray(v)) return v
return v ? [v as string] : []
}
return localOpen.value
})
function isOpen(value: string) {
return openKeys.value.includes(value)
}
function toggle(value: string) {
const current = openKeys.value
let next: string[]
if (props.mode === 'single') {
next = current.includes(value) ? [] : [value]
} else {
next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]
}
if (!isControlled.value) {
localOpen.value = next
}
emit('update:modelValue', props.mode === 'single' ? (next[0] ?? '') : next)
}
function register(item: AccordionItemRegistration, defaultOpen: boolean) {
items.value.push(item)
if (defaultOpen && !isControlled.value) {
if (props.mode === 'single') {
if (localOpen.value.length === 0) localOpen.value = [item.value]
} else if (!localOpen.value.includes(item.value)) {
localOpen.value.push(item.value)
}
}
}
function unregister(value: string) {
items.value = items.value.filter(i => i.value !== value)
}
// `items` est ordonné par ordre de montage (= ordre du DOM pour des sections
// statiques/ajoutées en fin). Si un consommateur réordonne dynamiquement les
// items, cet ordre peut diverger de l'ordre visuel ; trier par position DOM
// serait alors nécessaire (hors périmètre v1).
function focusSibling(value: string, offset: 1 | -1) {
const enabled = items.value.filter(i => !i.isDisabled())
const idx = enabled.findIndex(i => i.value === value)
if (idx === -1) return
const next = enabled[(idx + offset + enabled.length) % enabled.length]
next?.getHeaderEl()?.focus()
}
const rootClass = computed(() =>
twMerge('divide-y divide-black border-y border-black', props.groupClass),
)
provide(accordionContextKey, {
mode,
baseId,
isOpen,
toggle,
register,
unregister,
focusSibling,
})
</script>
@@ -1,48 +0,0 @@
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
function mountInAccordion(slot: string, accordionProps: Record<string, unknown> = {}) {
return mount(Accordion, {
props: accordionProps,
slots: {default: slot},
global: {components: {MalioAccordionItem: AccordionItem}},
})
}
describe('MalioAccordionItem', () => {
it('throws when used outside MalioAccordion', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow(
/à l'intérieur de MalioAccordion/,
)
spy.mockRestore()
})
it('generates an auto id-based value and still toggles when value prop is omitted', async () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="Sans value"><p>X</p></MalioAccordionItem>`,
)
const header = wrapper.find('button[aria-expanded]')
expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/)
await header.trigger('click')
expect(header.attributes('aria-expanded')).toBe('true')
})
it('applies headerClass and panelClass overrides via twMerge', () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="T" value="t" header-class="bg-red-500" panel-class="text-lg"><p>X</p></MalioAccordionItem>`,
)
const header = wrapper.find('button[aria-expanded]')
expect(header.classes()).toContain('bg-red-500')
expect(wrapper.find('[role="region"]').html()).toContain('text-lg')
})
it('renders a rotating chevron icon', () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="T" value="t"><p>X</p></MalioAccordionItem>`,
)
expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true)
})
})
@@ -1,126 +0,0 @@
<template>
<div>
<h3 class="m-0">
<button
:id="headerId"
ref="headerRef"
type="button"
:class="headerClasses"
:aria-expanded="open"
:aria-controls="panelId"
:disabled="disabled"
:aria-disabled="disabled || undefined"
@click="onToggle"
@keydown.down.prevent="ctx.focusSibling(value, 1)"
@keydown.up.prevent="ctx.focusSibling(value, -1)"
>
<span>{{ title }}</span>
<IconifyIcon
icon="mdi:chevron-down"
:width="24"
class="shrink-0 transition-transform duration-200"
:class="open ? 'rotate-180' : ''"
/>
</button>
</h3>
<div
:id="panelId"
role="region"
:aria-labelledby="headerId"
class="grid transition-[grid-template-rows] duration-200 ease-out"
:class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
@transitionend="onPanelTransitionEnd"
>
<div
:class="overflowVisible ? 'overflow-visible' : 'overflow-hidden'"
:inert="!open || undefined"
>
<div :class="panelInnerClass">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, inject, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey} from './context'
defineOptions({name: 'MalioAccordionItem', inheritAttrs: false})
const props = withDefaults(defineProps<{
title: string
value?: string
defaultOpen?: boolean
disabled?: boolean
headerClass?: string
panelClass?: string
}>(), {
value: '',
defaultOpen: false,
disabled: false,
headerClass: '',
panelClass: '',
})
const ctx = inject(accordionContextKey)
if (!ctx) {
throw new Error('MalioAccordionItem doit être utilisé à l\'intérieur de MalioAccordion')
}
const generatedId = useId()
const value = computed(() => props.value || `malio-accordion-item-${generatedId}`)
const headerRef = ref<HTMLButtonElement | null>(null)
const headerId = computed(() => `${ctx.baseId.value}-header-${value.value}`)
const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`)
const open = computed(() => ctx.isOpen(value.value))
// Le panneau garde `overflow-hidden` pendant l'animation (clipping requis par
// la transition grid-template-rows), puis passe en `overflow-visible` une fois
// complètement ouvert pour qu'un popover enfant (datepicker, select…) ne soit
// pas rogné. On re-clippe dès le début de la fermeture.
const overflowVisible = ref(false)
watch(open, (isOpen) => {
if (!isOpen) overflowVisible.value = false
})
function onPanelTransitionEnd(e: TransitionEvent) {
if (e.propertyName === 'grid-template-rows' && open.value) {
overflowVisible.value = true
}
}
function onToggle() {
if (props.disabled) return
ctx.toggle(value.value)
}
const headerClasses = computed(() =>
twMerge(
'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[20px] text-left font-[600] text-[20px] transition-colors',
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
props.headerClass,
),
)
const panelInnerClass = computed(() => twMerge('px-7 pt-[10px] pb-[20px]', props.panelClass))
onMounted(() => {
ctx.register(
{
value: value.value,
getHeaderEl: () => headerRef.value,
isDisabled: () => props.disabled,
},
props.defaultOpen,
)
// Ouvert au montage (defaultOpen / contrôlé) : pas d'animation, overflow visible direct.
if (open.value) overflowVisible.value = true
})
onBeforeUnmount(() => ctx.unregister(value.value))
</script>
-19
View File
@@ -1,19 +0,0 @@
import type {ComputedRef, InjectionKey} from 'vue'
export interface AccordionItemRegistration {
value: string
getHeaderEl: () => HTMLElement | null
isDisabled: () => boolean
}
export interface AccordionContext {
mode: ComputedRef<'single' | 'multiple'>
baseId: ComputedRef<string>
isOpen: (value: string) => boolean
toggle: (value: string) => void
register: (item: AccordionItemRegistration, defaultOpen: boolean) => void
unregister: (value: string) => void
focusSibling: (value: string, offset: 1 | -1) => void
}
export const accordionContextKey: InjectionKey<AccordionContext> = Symbol('MalioAccordion')
+2 -2
View File
@@ -162,8 +162,8 @@ describe('MalioButton', () => {
it('applies correct dimensions', () => {
const wrapper = mountComponent()
expect(wrapper.get('button').classes()).toContain('w-[180px]')
expect(wrapper.get('button').classes()).toContain('h-[38px]')
expect(wrapper.get('button').classes()).toContain('w-[240px]')
expect(wrapper.get('button').classes()).toContain('h-[40px]')
})
it('applies font styles', () => {
+1 -1
View File
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
const mergedButtonClass = computed(() =>
twMerge(
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 m-focus-ring',
'inline-flex w-[240px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
variantClasses.value,
props.buttonClass,
),
+1 -1
View File
@@ -52,7 +52,7 @@ const isFilled = computed(() => props.variant === 'filled')
const mergedButtonClass = computed(() =>
twMerge(
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 m-focus-ring',
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
isFilled.value
? props.disabled
? 'bg-m-disabled text-white cursor-not-allowed'
@@ -17,7 +17,6 @@ type CheckboxProps = {
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
@@ -162,33 +161,4 @@ describe('MalioCheckbox', () => {
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountCheckbox({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 = mountCheckbox({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountCheckbox({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 = mountCheckbox({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountCheckbox({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]')
})
})
+2 -11
View File
@@ -25,12 +25,12 @@
</svg>
</span>
<span>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</span>
</label>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="mergedMessageClass"
>
@@ -42,7 +42,6 @@
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
@@ -61,7 +60,6 @@ const props = withDefaults(
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -77,7 +75,6 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
},
)
@@ -125,7 +122,6 @@ const mergedLabelClass = computed(() =>
const mergedMessageClass = computed(() =>
twMerge(
'text-xs',
props.reserveMessageSpace ? 'min-h-[1rem]' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
@@ -180,11 +176,6 @@ const onChange = (event: Event) => {
border-color: rgb(0, 0, 0);
}
.inp-cbx:focus-visible + .cbx span:first-child {
outline: 2px solid rgb(var(--m-primary) / 1);
outline-offset: 2px;
}
.cbx span:first-child svg {
position: absolute;
top: 2px;
+23 -26
View File
@@ -7,14 +7,14 @@
v-for="col in columns"
:key="col.key"
scope="col"
class="border-b border-black px-3 py-3 text-left align-middle text-[16px]"
class="border-b border-black px-3 py-3 text-left align-middle text-[20px]"
>
<slot
v-if="$slots[`header-${col.key}`]"
:name="`header-${col.key}`"
:column="col"
/>
<span v-else class="font-semibold text-black">{{ col.label }}</span>
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
</th>
</tr>
</thead>
@@ -32,7 +32,7 @@
<td
v-for="col in columns"
:key="col.key"
class="px-3 py-4 text-[14px] text-black"
class="px-3 py-4 text-[18px] text-m-primary"
:class="index < items.length - 1 ? 'border-b border-black' : ''"
>
<slot
@@ -57,33 +57,30 @@
<div
v-if="totalItems > 0"
class="flex items-center justify-between pt-3"
class="flex justify-between pt-2"
data-test="pagination"
>
<div class="flex items-center gap-4">
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
<div class="h-[30px]">
<MalioSelect
:model-value="perPage"
:options="perPageSelectOptions"
group-class="w-20 h-[30px]"
field-class="h-[30px]"
rounded="rounded"
text-field="text-sm"
text-value="text-sm"
text-label="text-xs"
data-test="per-page-select"
@update:model-value="onPerPageChange"
/>
</div>
<div class="flex gap-4">
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
<MalioSelect
:model-value="perPage"
:options="perPageSelectOptions"
min-width="w-20 !mt-0"
rounded="rounded"
text-field="text-sm"
text-value="text-sm"
text-label="text-xs"
data-test="per-page-select"
@update:model-value="onPerPageChange"
/>
</div>
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav">
<MalioButton
variant="tertiary"
label="Préc."
label="Prev"
:disabled="page <= 1"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente"
data-test="prev-button"
@click="goToPage(page - 1)"
@@ -98,7 +95,7 @@
<button
v-else
type="button"
class="inline-flex h-[30px] min-w-[2.5rem] items-center justify-center rounded px-2 text-sm transition-colors"
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
:class="p === page
? 'bg-m-btn-primary text-white font-semibold'
: 'text-m-text hover:bg-m-bg'"
@@ -112,9 +109,9 @@
<MalioButton
variant="tertiary"
label="Suiv."
label="Next"
:disabled="page >= totalPages"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante"
data-test="next-button"
@click="goToPage(page + 1)"
-624
View File
@@ -1,624 +0,0 @@
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
markedDates?: Record<string, 'success' | 'danger'>
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 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')
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('month-change', () => {
it('émet month-change à l\'ouverture avec le mois courant', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 4, year: 2026}])
})
it('émet month-change sur le mois de la valeur à l\'ouverture', async () => {
const wrapper = mountDate({modelValue: '2025-12-25'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 11, year: 2025}])
})
it('émet month-change à chaque navigation de mois', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 5, year: 2026}])
await wrapper.get('[data-test="header-prev"]').trigger('click')
await wrapper.get('[data-test="header-prev"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 3, year: 2026}])
})
it('ne ré-émet pas month-change après fermeture', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
const countOpen = wrapper.emitted('month-change')?.length ?? 0
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('month-change')?.length ?? 0).toBe(countOpen)
})
})
describe('markedDates', () => {
it('transmet markedDates à la grille (fond tokenisé)', async () => {
const wrapper = mountDate({markedDates: {'2026-05-20': 'success'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
const pill = wrapper.get('[data-iso="2026-05-20"]').get('span.rounded-full')
expect(pill.classes()).toContain('bg-m-success/15')
})
})
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('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')
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('31/02/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('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('31/02/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('31/02/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')
// Valeur DOM réelle de la touche Entrée ('Enter') ; `trigger('keydown.enter')`
// produirait `key: 'enter'`, qui ne matche pas le handler manuel `e.key === 'Enter'`.
await input.trigger('keydown', {key: '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"]')
// 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')
})
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 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)', () => {
it('affiche le gabarit complet en gris quand editable + focus + vide', async () => {
const wrapper = mountDate({editable: true})
await wrapper.get('[data-test="date-input"]').trigger('focus')
const ghost = wrapper.get('[data-test="format-ghost"]')
expect(ghost.text()).toBe('JJ/MM/AAAA')
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
})
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.trigger('focus')
await input.setValue('19')
// eager : le séparateur se pose dès que le groupe est complet (« 19 » → « 19/ »)
expect(wrapper.get('[data-test="format-ghost"]').text()).toBe('19/MM/AAAA')
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/')
expect(wrapper.get('[data-test="ghost-typed"]').classes()).toContain('text-black')
})
it('pose le séparateur automatiquement dès qu\'un groupe est complet (eager)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('1905')
expect((input.element as HTMLInputElement).value).toBe('19/05/')
})
it('n\'affiche pas de gabarit en mode non editable', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
})
it('n\'affiche pas de gabarit quand editable mais vide et non focus', () => {
const wrapper = mountDate({editable: true})
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
})
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('31/02/2026')
await input.trigger('blur')
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('')
})
})
describe('état de validité (update:valid)', () => {
it('émet valid=true au montage avec une valeur valide', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true au montage quand le champ est vide', () => {
const wrapper = mountDate()
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true sur saisie clavier valide', 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:valid')?.at(-1)).toEqual([true])
})
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('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet valid=false sur saisie 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:valid')?.at(-1)).toEqual([false])
})
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
const wrapper = mountDate({editable: true, required: true, modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('émet valid=true sur clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true quand on sélectionne une date au calendrier', 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:valid')?.at(-1)).toEqual([true])
})
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('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
await wrapper.setProps({modelValue: '2026-05-19'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
})
describe('saisie brute (update:rawValue)', () => {
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('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet le texte brut trimmé sur saisie 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:rawValue')?.at(-1)).toEqual(['25/12/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet rawValue vide et l\'ISO sur saisie clavier valide', 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:rawValue')?.at(-1)).toEqual([''])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
})
it('émet rawValue vide 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:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide sur clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
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('31/02/2026')
await input.trigger('blur')
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([''])
})
})
})
-159
View File
@@ -1,159 +0,0 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="modelValue ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="mergedError"
:success="success"
:clearable="clearable"
:editable="editable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@commit="onCommit"
@month-change="(payload) => emit('month-change', payload)"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="modelValue ?? null"
:marked-dates="markedDates"
:min="min"
:max="max"
@select="(iso) => onSelect(iso, close)"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
// Statut générique par jour, ISO yyyy-mm-dd → variante de fond. Aucune
// logique métier dans le layer : le consommateur fournit la liste.
markedDates?: Record<string, 'success' | 'danger'>
clearable?: boolean
editable?: boolean
invalidMessage?: string
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
markedDates: undefined,
clearable: true,
editable: false,
invalidMessage: 'Date invalide',
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
(e: 'update:rawValue', value: string): void
// Mois affiché dans le popover (month 0-11) : à l'ouverture et à chaque nav.
(e: 'month-change', value: {month: number, year: number}): void
}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
const internalError = ref('')
const mergedError = computed(() => props.error || internalError.value)
// La validité ne reflète que la saisie : malformée/hors plage → false. Un champ
// vide est valide (l'obligation `required` reste à la charge du parent).
const setError = (message: string) => {
internalError.value = message
emit('update:valid', message === '')
}
const onCommit = (text: string) => {
const trimmed = text.trim()
if (trimmed === '') {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', null)
return
}
const iso = parseDisplayToIso(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso)
return
}
setError(props.invalidMessage)
emit('update:rawValue', trimmed)
}
const onClear = () => {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', null)
}
const onSelect = (iso: string, close: () => void) => {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso)
close()
}
// immediate : émet aussi la validité au montage, pour que le parent connaisse
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
watch(() => props.modelValue, (val) => {
setError('')
if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
}
}, {immediate: true})
</script>
-155
View File
@@ -1,155 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateRange from './DateRange.vue'
type RangeValue = {start: string; end: string}
type DateRangeProps = {
modelValue?: RangeValue | null
label?: string
disabled?: boolean
readonly?: boolean
error?: string
min?: string
max?: string
clearable?: boolean
}
const DateRangeForTest = DateRange as DefineComponent<DateRangeProps>
const mountRange = (props: DateRangeProps = {}) =>
mount(DateRangeForTest, {props, attachTo: document.body})
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, isos: string[]) => {
await wrapper.get('[data-test="date-input"]').trigger('click')
for (const iso of isos) {
await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click')
}
}
describe('MalioDateRange', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('renders the label and calendar icon', () => {
const wrapper = mountRange({label: 'Période'})
expect(wrapper.get('label').text()).toBe('Période')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted range when modelValue is set', () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('19/05/2026 - 25/05/2026')
})
it('shows an empty field without a value', () => {
const wrapper = mountRange()
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('')
})
it('opens on the start month when a range is set', async () => {
const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('does not emit on the first click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('emits the range and closes on the second click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('auto-inverts when the second click is before the first', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
})
it('allows a single-day range', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}])
})
it('restarts a new range on the third click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}])
})
it('previews the range on hover while selecting', async () => {
const wrapper = mountRange()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
})
it('does not preview before selecting', async () => {
const wrapper = mountRange()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('none')
})
it('marks start, end and in-range roles for a committed range', async () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
})
it('cancels an in-progress selection on outside click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19'])
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none')
})
it('emits null on clear', async () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('disables days outside min/max', async () => {
const wrapper = mountRange({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()
})
it('sets aria-invalid on error', () => {
const wrapper = mountRange({error: 'Période requise'})
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Période requise')
})
it('does not open when disabled', async () => {
const wrapper = mountRange({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
-140
View File
@@ -1,140 +0,0 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validRange?.start ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="rangeStart"
:range-end="rangeEnd"
:preview-date="previewDate"
:min="min"
:max="max"
@select="(iso) => onSelectDay(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: DateRangeValue | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
const pendingStart = ref<string | null>(null)
const hoverDate = ref<string | null>(null)
const isSelecting = computed(() => pendingStart.value !== null)
const validRange = computed<DateRangeValue | null>(() => {
const v = props.modelValue
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
return null
})
const rangeStart = computed(() =>
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
)
const rangeEnd = computed(() =>
isSelecting.value ? null : (validRange.value?.end ?? null),
)
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
const displayValue = computed(() => {
if (isSelecting.value || !validRange.value) return ''
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
})
const onSelectDay = (iso: string, close: () => void) => {
if (pendingStart.value === null) {
pendingStart.value = iso
hoverDate.value = null
return
}
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
pendingStart.value = null
hoverDate.value = null
close()
}
const onHover = (iso: string | null) => {
if (isSelecting.value) hoverDate.value = iso
}
const onClose = () => {
pendingStart.value = null
hoverDate.value = null
}
const onClear = () => {
emit('update:modelValue', null)
pendingStart.value = null
hoverDate.value = null
}
</script>
-354
View File
@@ -1,354 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateTime_ from './DateTime.vue'
import MalioTimePicker from '../time/TimePicker.vue'
type DateTimeProps = {
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
}
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
const mountDateTime = (props: DateTimeProps = {}) =>
mount(DateTimeForTest, {props, attachTo: document.body})
describe('MalioDateTime', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('affiche le label et l\'icône calendrier', () => {
const wrapper = mountDateTime({label: 'Rendez-vous'})
expect(wrapper.get('label').text()).toBe('Rendez-vous')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche la valeur formatée date + heure dans le champ', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('20/05/2026 14:30')
})
})
describe('popover', () => {
it('ouvre la grille et le champ sélecteur d\'heure au clic', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
expect(wrapper.findComponent(MalioTimePicker).exists()).toBe(true)
expect(wrapper.find('[data-test="time-field"]').exists()).toBe(true)
})
})
describe('sélection', () => {
it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
// heure système figée à 09:05
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('applique l\'heure réglée avant le clic du jour', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '09:15')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
})
it('met à jour l\'heure quand une date est déjà choisie', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '08:45')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
})
it('initialise le champ heure depuis la valeur', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.findComponent(MalioTimePicker).props('modelValue')).toBe('14:30')
})
})
describe('bornes min/max', () => {
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
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)
})
})
describe('effacement', () => {
it('émet null au clic sur la croix', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
})
describe('accessibilité', () => {
it('positionne aria-invalid et describedby sur erreur', () => {
const wrapper = mountDateTime({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('saisie manuelle (editable)', () => {
it('par défaut (editable=false) l\'input reste readonly', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeDefined()
})
it('editable=true : l\'input n\'est plus readonly', () => {
const wrapper = mountDateTime({editable: true})
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
})
it('émet le datetime ISO sur saisie clavier valide au blur', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('20/05/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
})
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('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
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')
})
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"]')
await input.setValue('25/12/2026 10:00')
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 = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'})
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('valide et ferme le popover sur Entrée', async () => {
const wrapper = mountDateTime({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('20/05/2026 14:30')
await input.trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]')
// 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')
})
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('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
expect(wrapper.text()).not.toContain('Date invalide')
})
})
describe('gabarit de saisie (editable)', () => {
it('affiche le gabarit date+heure complet en gris quand editable + focus + vide', async () => {
const wrapper = mountDateTime({editable: true})
await wrapper.get('[data-test="date-input"]').trigger('focus')
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('JJ/MM/AAAA HH:MM')
expect(wrapper.get('[data-test="ghost-remaining"]').classes()).toContain('text-m-muted')
})
it('remplit le gabarit au fur et à mesure de la saisie', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.trigger('focus')
await input.setValue('190520')
expect(wrapper.get('[data-test="format-ghost"]').text().replace(/\xa0/g, ' ')).toBe('19/05/20AA HH:MM')
expect(wrapper.get('[data-test="ghost-typed"]').text()).toBe('19/05/20')
})
it('n\'affiche pas de gabarit en mode non editable', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="format-ghost"]').exists()).toBe(false)
})
})
describe('état de validité (update:valid)', () => {
it('émet valid=true au montage avec une valeur valide', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true au montage quand le champ est vide', () => {
const wrapper = mountDateTime()
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true sur saisie clavier valide', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('20/05/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
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('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet valid=true sur saisie vidée même si le champ est requis', async () => {
const wrapper = mountDateTime({editable: true, required: true, modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true sur clear', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
it('émet valid=true quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDateTime()
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:valid')?.at(-1)).toEqual([true])
})
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('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'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
})
})
describe('saisie brute (update:rawValue)', () => {
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('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet le texte brut trimmé sur saisie 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"]')
await input.setValue('25/12/2026 10:00')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026 10:00'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('20/05/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
})
it('émet rawValue vide sur saisie vidée au blur', async () => {
const wrapper = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide sur clear', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
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('31/02/2026 14:30')
await input.trigger('blur')
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([''])
})
})
})
-186
View File
@@ -1,186 +0,0 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="datePart"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="mergedError"
:success="success"
:clearable="clearable"
:editable="editable"
placeholder-template="JJ/MM/AAAA HH:MM"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@commit="onCommit"
>
<template #default="{ currentMonth, currentYear }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="datePart"
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
@select="onSelectDay"
/>
<div class="mt-4">
<MalioTimePicker
:model-value="timeValue || null"
label="Heure"
:clearable="false"
static-popover
@update:model-value="onTimeChange"
/>
</div>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import MalioTimePicker from '../time/TimePicker.vue'
import {formatTime} from '../time/composables/timeFormat'
import {isDateInRange} from './composables/dateFormat'
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, parseDisplayToIsoDateTime, splitDateTime} from './composables/datetimeFormat'
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA HH:MM',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
editable: false,
invalidMessage: 'Date invalide',
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
(e: 'update:rawValue', value: string): void
}>()
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
const pendingTime = ref('')
const parts = computed(() => splitDateTime(props.modelValue ?? null))
const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
const timeValue = computed(() => parts.value.time || pendingTime.value)
const internalError = ref('')
const mergedError = computed(() => props.error || internalError.value)
// La validité ne reflète que la saisie clavier : malformée/hors plage → false. Un
// champ vide est valide (l'obligation `required` reste à la charge du parent).
const setError = (message: string) => {
internalError.value = message
emit('update:valid', message === '')
}
function onSelectDay(iso: string) {
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
// (heure courante au moment du clic)
const now = new Date()
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
setError('')
emit('update:rawValue', '')
emit('update:modelValue', composeDateTime(iso, time))
}
function onTimeChange(value: string | null) {
if (!value) return
if (datePart.value) {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
function onCommit(text: string) {
const trimmed = text.trim()
if (trimmed === '') {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', null)
return
}
const iso = parseDisplayToIsoDateTime(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) {
setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso)
return
}
setError(props.invalidMessage)
emit('update:rawValue', trimmed)
}
function onClear() {
setError('')
pendingTime.value = ''
emit('update:rawValue', '')
emit('update:modelValue', null)
}
// immediate : émet aussi la validité au montage, pour que le parent connaisse
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
watch(() => props.modelValue, (val) => {
setError('')
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
}
}, {immediate: true})
</script>
-122
View File
@@ -1,122 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateWeek from './DateWeek.vue'
type DateWeekProps = {
modelValue?: string | null
label?: string
disabled?: boolean
readonly?: boolean
error?: string
min?: string
max?: string
}
const DateWeekForTest = DateWeek as DefineComponent<DateWeekProps>
const mountWeek = (props: DateWeekProps = {}) =>
mount(DateWeekForTest, {props, attachTo: document.body})
describe('MalioDateWeek', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('renders the label and calendar icon', () => {
const wrapper = mountWeek({label: 'Semaine'})
expect(wrapper.get('label').text()).toBe('Semaine')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted week when modelValue is set', () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('shows an empty field without a value', () => {
const wrapper = mountWeek()
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('')
})
it('opens on the month of the selected week', async () => {
const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('selects the week when a day is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('selects the week when the week number is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('previews the whole week on day hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
})
it('previews the whole week on week-number hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
})
it('marks the committed week number', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
})
it('emits null on clear', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('disables a week fully outside min/max', async () => {
const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]')
expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true)
const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]')
expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false)
})
it('does not open when disabled', async () => {
const wrapper = mountWeek({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 = mountWeek({readonly: true, modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('sets aria-invalid on error', () => {
const wrapper = mountWeek({error: 'Semaine requise'})
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Semaine requise')
})
})
-123
View File
@@ -1,123 +0,0 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validWeek?.monday ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="activeMonday"
:range-end="activeSunday"
:marked-week-start="validWeek?.monday ?? null"
interactive-week-number
:min="min"
:max="max"
@select="(iso) => onSelect(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatWeekDisplay, isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek} from './composables/dateWeek'
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const hoverWeekStart = ref<string | null>(null)
const validWeek = computed(() => {
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
return {monday: isoWeekToMonday(props.modelValue) as string}
}
return null
})
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
const onSelect = (iso: string, close: () => void) => {
emit('update:modelValue', toIsoWeek(iso))
hoverWeekStart.value = null
close()
}
const onHover = (iso: string | null) => {
hoverWeekStart.value = iso ? mondayOf(iso) : null
}
const onClose = () => {
hoverWeekStart.value = null
}
const onClear = () => {
emit('update:modelValue', null)
hoverWeekStart.value = null
}
</script>
@@ -1,62 +0,0 @@
import {describe, expect, it} from 'vitest'
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
describe('dateFormat', () => {
describe('isValidIso', () => {
it('accepts a real ISO date', () => {
expect(isValidIso('2026-05-19')).toBe(true)
})
it('rejects a malformed string', () => {
expect(isValidIso('19/05/2026')).toBe(false)
expect(isValidIso('2026-5-9')).toBe(false)
expect(isValidIso('')).toBe(false)
})
it('rejects an impossible date', () => {
expect(isValidIso('2026-02-30')).toBe(false)
expect(isValidIso('2026-13-01')).toBe(false)
})
it('accepts Feb 29 on a leap year and rejects it otherwise', () => {
expect(isValidIso('2024-02-29')).toBe(true)
expect(isValidIso('2026-02-29')).toBe(false)
})
})
describe('formatIsoToDisplay', () => {
it('formats ISO to DD/MM/YYYY', () => {
expect(formatIsoToDisplay('2026-05-19')).toBe('19/05/2026')
})
it('returns empty string for null or invalid input', () => {
expect(formatIsoToDisplay(null)).toBe('')
expect(formatIsoToDisplay('nope')).toBe('')
})
})
describe('parseDisplayToIso', () => {
it('parses DD/MM/YYYY to ISO', () => {
expect(parseDisplayToIso('19/05/2026')).toBe('2026-05-19')
})
it('returns null for malformed or impossible input', () => {
expect(parseDisplayToIso('2026-05-19')).toBeNull()
expect(parseDisplayToIso('31/02/2026')).toBeNull()
expect(parseDisplayToIso('')).toBeNull()
})
})
describe('isDateInRange', () => {
it('returns true when no bounds are given', () => {
expect(isDateInRange('2026-05-19')).toBe(true)
})
it('respects the min bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-18', '2026-05-19')).toBe(false)
})
it('respects the max bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', undefined, '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-20', undefined, '2026-05-19')).toBe(false)
})
it('respects both bounds', () => {
expect(isDateInRange('2026-05-15', '2026-05-10', '2026-05-20')).toBe(true)
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
})
})
})
@@ -1,26 +0,0 @@
export function isValidIso(iso: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
const [y, m, d] = iso.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
}
export function formatIsoToDisplay(iso: string | null): string {
if (!iso || !isValidIso(iso)) return ''
const [y, m, d] = iso.split('-')
return `${d}/${m}/${y}`
}
export function parseDisplayToIso(display: string): string | null {
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(display.trim())
if (!match) return null
const [, dd, mm, yyyy] = match
const iso = `${yyyy}-${mm}-${dd}`
return isValidIso(iso) ? iso : null
}
export function isDateInRange(iso: string, min?: string, max?: string): boolean {
if (min && iso < min) return false
if (max && iso > max) return false
return true
}
@@ -1,57 +0,0 @@
import {describe, expect, it} from 'vitest'
import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange'
describe('dateRange', () => {
describe('normalizeRange', () => {
it('keeps an already ordered pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('swaps a reversed pair', () => {
expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('handles an equal pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'})
})
})
describe('resolveRangeBounds', () => {
it('returns null without a start', () => {
expect(resolveRangeBounds(null, null, null)).toBeNull()
})
it('returns a single-point range when only start is set', () => {
expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'})
})
it('orders start and committed end', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
it('uses preview when end is not set', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'})
})
it('inverts when preview is before start', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'})
})
it('prioritises committed end over preview', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
})
describe('dayRangeRole', () => {
const bounds = {lo: '2026-05-19', hi: '2026-05-25'}
it('returns none without bounds', () => {
expect(dayRangeRole('2026-05-20', null)).toBe('none')
})
it('returns single when lo === hi and matches', () => {
expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single')
expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none')
})
it('returns start, end and in-range', () => {
expect(dayRangeRole('2026-05-19', bounds)).toBe('start')
expect(dayRangeRole('2026-05-25', bounds)).toBe('end')
expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range')
})
it('returns none outside the bounds', () => {
expect(dayRangeRole('2026-05-10', bounds)).toBe('none')
expect(dayRangeRole('2026-05-30', bounds)).toBe('none')
})
})
})
@@ -1,31 +0,0 @@
export type DateRangeValue = {start: string; end: string}
export function normalizeRange(a: string, b: string): DateRangeValue {
return a <= b ? {start: a, end: b} : {start: b, end: a}
}
export function resolveRangeBounds(
start: string | null,
end: string | null,
preview: string | null,
): {lo: string; hi: string} | null {
if (!start) return null
const other = end ?? preview
if (!other) return {lo: start, hi: start}
return start <= other ? {lo: start, hi: other} : {lo: other, hi: start}
}
export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
export function dayRangeRole(
iso: string,
bounds: {lo: string; hi: string} | null,
): DayRangeRole {
if (!bounds) return 'none'
const {lo, hi} = bounds
if (lo === hi) return iso === lo ? 'single' : 'none'
if (iso === lo) return 'start'
if (iso === hi) return 'end'
if (iso > lo && iso < hi) return 'in-range'
return 'none'
}
@@ -1,74 +0,0 @@
import {describe, expect, it} from 'vitest'
import {
formatWeekDisplay,
isValidIsoWeek,
isoWeekToMonday,
mondayOf,
sundayOf,
toIsoWeek,
} from './dateWeek'
describe('dateWeek', () => {
describe('mondayOf / sundayOf', () => {
it('returns Monday and Sunday of a midweek date', () => {
expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi
expect(sundayOf('2026-05-20')).toBe('2026-05-24')
})
it('keeps Monday on a Monday', () => {
expect(mondayOf('2026-05-18')).toBe('2026-05-18')
})
it('returns the preceding Monday for a Sunday', () => {
expect(mondayOf('2026-05-24')).toBe('2026-05-18')
})
})
describe('toIsoWeek', () => {
it('returns the ISO week of a date', () => {
expect(toIsoWeek('2026-05-20')).toBe('2026-W21')
})
it('handles year boundaries', () => {
expect(toIsoWeek('2026-01-01')).toBe('2026-W01')
expect(toIsoWeek('2025-12-31')).toBe('2026-W01')
expect(toIsoWeek('2027-01-01')).toBe('2026-W53')
})
})
describe('isoWeekToMonday', () => {
it('returns the Monday of a week string', () => {
expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18')
})
it('round-trips with toIsoWeek', () => {
for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) {
const monday = isoWeekToMonday(w)
expect(monday).not.toBeNull()
expect(toIsoWeek(monday as string)).toBe(w)
}
})
it('returns null for invalid input', () => {
expect(isoWeekToMonday('2026-21')).toBeNull()
expect(isoWeekToMonday('2026-W00')).toBeNull()
expect(isoWeekToMonday('2026-W54')).toBeNull()
expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO
})
})
describe('isValidIsoWeek', () => {
it('accepts a real ISO week', () => {
expect(isValidIsoWeek('2026-W21')).toBe(true)
})
it('rejects malformed or impossible weeks', () => {
expect(isValidIsoWeek('2026-21')).toBe(false)
expect(isValidIsoWeek('2026-W00')).toBe(false)
expect(isValidIsoWeek('2026-W54')).toBe(false)
})
})
describe('formatWeekDisplay', () => {
it('formats a week as a human label', () => {
expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('returns empty string for invalid input', () => {
expect(formatWeekDisplay('2026-W54')).toBe('')
})
})
})
@@ -1,67 +0,0 @@
import {formatIsoToDisplay} from './dateFormat'
const parseUtc = (iso: string): Date => {
const [y, m, d] = iso.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d))
}
const toIso = (d: Date): string => {
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function mondayOf(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7 // dimanche = 7
d.setUTCDate(d.getUTCDate() - (dayNum - 1))
return toIso(d)
}
export function sundayOf(iso: string): string {
const d = parseUtc(mondayOf(iso))
d.setUTCDate(d.getUTCDate() + 6)
return toIso(d)
}
export function toIsoWeek(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const isoYear = d.getUTCFullYear()
const yearStart = new Date(Date.UTC(isoYear, 0, 1))
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
return `${isoYear}-W${String(week).padStart(2, '0')}`
}
export function isoWeekToMonday(week: string): string | null {
const m = /^(\d{4})-W(\d{2})$/.exec(week)
if (!m) return null
const year = Number(m[1])
const w = Number(m[2])
if (w < 1 || w > 53) return null
// Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier
const jan4 = new Date(Date.UTC(year, 0, 4))
const jan4Day = jan4.getUTCDay() || 7
const monday = new Date(jan4)
monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7)
const iso = toIso(monday)
// Garde-fou : la semaine 53 n'existe pas pour toutes les années
if (toIsoWeek(iso) !== week) return null
return iso
}
export function isValidIsoWeek(week: string): boolean {
return isoWeekToMonday(week) !== null
}
export function formatWeekDisplay(week: string): string {
const monday = isoWeekToMonday(week)
if (!monday) return ''
const sunday = sundayOf(monday)
const w = Number(week.slice(6))
const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05"
const endFull = formatIsoToDisplay(sunday) // "24/05/2026"
return `Semaine ${w} (${startDdMm}${endFull})`
}
@@ -1,90 +0,0 @@
import {describe, expect, it} from 'vitest'
import {
composeDateTime,
formatIsoDateTimeToDisplay,
isValidIsoDateTime,
parseDisplayToIsoDateTime,
splitDateTime,
} from './datetimeFormat'
describe('datetimeFormat', () => {
describe('isValidIsoDateTime', () => {
it('accepte un datetime ISO complet valide', () => {
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
})
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
expect(isValidIsoDateTime('')).toBe(false)
})
})
describe('formatIsoDateTimeToDisplay', () => {
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
})
it('renvoie une chaîne vide pour nul ou invalide', () => {
expect(formatIsoDateTimeToDisplay(null)).toBe('')
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
})
})
describe('splitDateTime', () => {
it('découpe un datetime valide', () => {
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
})
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
expect(splitDateTime(null)).toEqual({date: null, time: ''})
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
})
})
describe('parseDisplayToIsoDateTime', () => {
it('parse un JJ/MM/AAAA HH:MM valide en datetime ISO', () => {
expect(parseDisplayToIsoDateTime('20/05/2026 14:30')).toBe('2026-05-20T14:30:00')
expect(parseDisplayToIsoDateTime('01/01/2026 00:00')).toBe('2026-01-01T00:00:00')
expect(parseDisplayToIsoDateTime('31/12/2026 23:59')).toBe('2026-12-31T23:59:00')
})
it('tolère les espaces autour', () => {
expect(parseDisplayToIsoDateTime(' 20/05/2026 14:30 ')).toBe('2026-05-20T14:30:00')
})
it('rejette une date malformée', () => {
expect(parseDisplayToIsoDateTime('32/01/2026 10:00')).toBeNull()
expect(parseDisplayToIsoDateTime('10/13/2026 10:00')).toBeNull()
})
it('rejette une heure hors bornes', () => {
expect(parseDisplayToIsoDateTime('20/05/2026 24:00')).toBeNull()
expect(parseDisplayToIsoDateTime('20/05/2026 12:60')).toBeNull()
})
it('rejette un format incomplet ou sans heure', () => {
expect(parseDisplayToIsoDateTime('20/05/2026')).toBeNull()
expect(parseDisplayToIsoDateTime('20/05/2026 14')).toBeNull()
expect(parseDisplayToIsoDateTime('')).toBeNull()
})
})
describe('composeDateTime', () => {
it('recompose un datetime ISO avec secondes à 00', () => {
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
})
it('utilise 00:00 quand l\'heure est vide', () => {
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
})
})
})
@@ -1,43 +0,0 @@
import {isValidIso, parseDisplayToIso} from './dateFormat'
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
export function isValidIsoDateTime(s: string): boolean {
const m = DATETIME_RE.exec(s)
if (!m) return false
const [, date, hh, mm, ss] = m
if (!isValidIso(date)) return false
const h = Number(hh)
const min = Number(mm)
const sec = Number(ss)
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
}
export function formatIsoDateTimeToDisplay(s: string | null): string {
if (!s || !isValidIsoDateTime(s)) return ''
const [date, time] = s.split('T')
const [y, mo, d] = date.split('-')
const [hh, mm] = time.split(':')
return `${d}/${mo}/${y} ${hh}:${mm}`
}
export function splitDateTime(s: string | null): {date: string | null; time: string} {
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
const [date, time] = s.split('T')
return {date, time: time.slice(0, 5)}
}
export function parseDisplayToIsoDateTime(display: string): string | null {
const match = /^(\d{2}\/\d{2}\/\d{4}) (\d{2}):(\d{2})$/.exec(display.trim())
if (!match) return null
const [, datePart, hh, mm] = match
const iso = parseDisplayToIso(datePart)
if (!iso) return null
if (Number(hh) > 23 || Number(mm) > 59) return null
return `${iso}T${hh}:${mm}:00`
}
export function composeDateTime(date: string, time: string): string {
const t = time || '00:00'
return `${date}T${t}:00`
}
@@ -1,44 +0,0 @@
import {describe, it, expect} from 'vitest'
import {buildBoundedMask} from './maskTemplate'
describe('buildBoundedMask', () => {
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 lanné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('')
})
})
@@ -1,91 +0,0 @@
import type {MaskInputOptions} from 'maska'
// Un champ numérique du gabarit : sa longueur et la plage de valeurs autorisée.
interface Field {
length: number
min: number
max: number
}
// Découpe un gabarit (ex. `JJ/MM/AAAA HH:MM`) en champs numériques bornés.
// Le `M` désigne le mois avant les heures, et les minutes après — d'où le suivi
// de `seenHour`, pour ne pas borner les minutes comme un mois (0-59 vs 1-12).
function parseFields(template: string): Field[] {
const fields: Field[] = []
let seenHour = false
let i = 0
while (i < template.length) {
const ch = template[i]!
if (!/[A-Za-z]/.test(ch)) {
i++ // séparateur (/, espace, :)
continue
}
let j = i
while (j < template.length && template[j] === ch) j++
const length = j - i
const letter = ch.toUpperCase()
if (letter === 'H') seenHour = true
if (letter === 'J') fields.push({length, min: 1, max: 31})
else if (letter === 'M') fields.push(seenHour ? {length, min: 0, max: 59} : {length, min: 1, max: 12})
else if (letter === 'H') fields.push({length, min: 0, max: 23})
else fields.push({length, min: 0, max: 10 ** length - 1}) // année (ou autre) : libre
i = j
}
return fields
}
// Un chiffre est accepté tant qu'il existe encore une complétion valide du champ :
// on borne la valeur partielle [min possible (padding 0), max possible (padding 9)]
// et on vérifie qu'elle croise la plage autorisée [field.min, field.max].
function canComplete(partial: string, field: Field): boolean {
const low = Number(partial.padEnd(field.length, '0'))
const high = Number(partial.padEnd(field.length, '9'))
return high >= field.min && low <= field.max
}
// Ne conserve que les chiffres qui gardent chaque champ complétable, et s'arrête
// au premier chiffre invalide (rien de ce qui suit n'est réinterprété). maska
// réinsère ensuite les séparateurs via le masque structurel.
function clampDigits(rawDigits: string, fields: Field[]): string {
let result = ''
let di = 0
for (const field of fields) {
let fieldDigits = ''
while (fieldDigits.length < field.length) {
if (di >= rawDigits.length) return result + fieldDigits // plus de saisie
const candidate = fieldDigits + rawDigits[di]
if (!canComplete(candidate, field)) return result + fieldDigits // 1er chiffre invalide → stop
fieldDigits = candidate
di++
}
result += fieldDigits
}
return result
}
/**
* Construit les options maska d'un champ date/heure à partir d'un gabarit
* d'affichage (ex. `JJ/MM/AAAA`, `JJ/MM/AAAA HH:MM`).
*
* - `mask` : masque structurel (chiffres + séparateurs), pour le formatage/eager.
* - `preProcess` : borne la saisie AVANT masquage, sur le 1er **et** le 2e chiffre
* de chaque champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59), si bien
* qu'une valeur impossible (99/99/9999, 33, 19 en mois…) ne peut pas être tapée.
* Les impossibilités calendaires fines (31/02, 29/02 non bissextile) et les
* bornes `min`/`max` restent du ressort de la validation, en filet.
*/
export function buildBoundedMask(template: string): Pick<MaskInputOptions, 'mask' | 'preProcess'> {
const mask = template.replace(/[A-Za-z]/g, '#')
const fields = parseFields(template)
return {
mask,
preProcess: (value: string) => clampDigits(value.replace(/\D/g, ''), fields),
}
}
@@ -1,64 +0,0 @@
import {describe, expect, it} from 'vitest'
import {defineComponent, h, ref} from 'vue'
import {mount} from '@vue/test-utils'
import {useCalendarPopover} from './useCalendarPopover'
const mountHost = () => {
const api: ReturnType<typeof useCalendarPopover> = {} as never
const Host = defineComponent({
setup() {
const root = ref<HTMLElement | null>(null)
Object.assign(api, useCalendarPopover(root))
return () => h('div', {ref: root}, 'host')
},
})
const wrapper = mount(Host, {attachTo: document.body})
return {wrapper, api}
}
describe('useCalendarPopover', () => {
it('starts closed in days view', () => {
const {api} = mountHost()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('open() opens in days view', () => {
const {api} = mountHost()
api.open()
expect(api.isOpen.value).toBe(true)
expect(api.viewMode.value).toBe('days')
})
it('toggleView() switches between days and months', () => {
const {api} = mountHost()
api.open()
api.toggleView()
expect(api.viewMode.value).toBe('months')
api.toggleView()
expect(api.viewMode.value).toBe('days')
})
it('close() resets isOpen and viewMode', () => {
const {api} = mountHost()
api.open()
api.toggleView()
api.close()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('closes on outside mousedown', () => {
const {api} = mountHost()
api.open()
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(false)
})
it('stays open on inside mousedown', () => {
const {wrapper, api} = mountHost()
api.open()
wrapper.element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(true)
})
})
@@ -1,28 +0,0 @@
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
const isOpen = ref(false)
const viewMode = ref<'days' | 'months'>('days')
const open = () => {
isOpen.value = true
viewMode.value = 'days'
}
const close = () => {
isOpen.value = false
viewMode.value = 'days'
}
const toggleView = () => {
viewMode.value = viewMode.value === 'days' ? 'months' : 'days'
}
const onMouseDown = (event: MouseEvent) => {
if (!isOpen.value || !rootRef.value) return
if (!rootRef.value.contains(event.target as Node)) close()
}
onMounted(() => document.addEventListener('mousedown', onMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
return {isOpen, viewMode, open, close, toggleView}
}
@@ -1,68 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ref} from 'vue'
import {useCalendarView} from './useCalendarView'
describe('useCalendarView', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('initialises to the current month and year', () => {
const {currentMonth, currentYear} = useCalendarView(ref('days'))
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
it('goToNext advances the month in days view', () => {
const {currentMonth, goToNext} = useCalendarView(ref('days'))
goToNext()
expect(currentMonth.value).toBe(5)
})
it('rolls December to January and bumps the year', () => {
const {currentMonth, currentYear, goToNext} = useCalendarView(ref('days'))
currentMonth.value = 11
goToNext()
expect(currentMonth.value).toBe(0)
expect(currentYear.value).toBe(2027)
})
it('rolls January to December backwards', () => {
const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days'))
currentMonth.value = 0
goToPrev()
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('navigates the year in months view', () => {
const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months'))
goToNext()
expect(currentYear.value).toBe(2027)
goToPrev()
expect(currentYear.value).toBe(2026)
})
it('selectMonth sets the current month', () => {
const {currentMonth, selectMonth} = useCalendarView(ref('days'))
selectMonth(0)
expect(currentMonth.value).toBe(0)
})
it('syncToIso sets month/year from a valid ISO', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('syncToIso falls back to today for null/invalid', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
syncToIso(null)
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
})
@@ -1,51 +0,0 @@
import {ref, type Ref} from 'vue'
import {isValidIso} from './dateFormat'
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const goToPrev = () => {
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
const goToNext = () => {
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
const selectMonth = (m: number) => {
currentMonth.value = m
}
const syncToIso = (iso: string | null) => {
if (iso && isValidIso(iso)) {
currentMonth.value = Number(iso.slice(5, 7)) - 1
currentYear.value = Number(iso.slice(0, 4))
} else {
const now = new Date()
currentMonth.value = now.getMonth()
currentYear.value = now.getFullYear()
}
}
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
}
@@ -1,69 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ref} from 'vue'
import {useMonthMatrix} from './useMonthMatrix'
describe('useMonthMatrix', () => {
it('always produces 6 weeks of 7 days', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai 2026
expect(weeks.value).toHaveLength(6)
weeks.value.forEach(week => expect(week.days).toHaveLength(7))
})
it('starts every week on a Monday', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
weeks.value.forEach(week => {
const first = new Date(`${week.days[0].isoDate}T00:00:00`)
expect(first.getDay()).toBe(1) // 1 = lundi
})
})
it('flags exactly the days of the current month', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai = 31 jours
const currentMonthDays = weeks.value
.flatMap(w => w.days)
.filter(d => d.isCurrentMonth)
expect(currentMonthDays).toHaveLength(31)
expect(currentMonthDays.every(d => d.isoDate.startsWith('2026-05'))).toBe(true)
})
it('handles leap year February (29 days)', () => {
const {weeks} = useMonthMatrix(ref(1), ref(2024)) // février 2024
const days = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth)
expect(days).toHaveLength(29)
})
it('assigns ISO week 1 to the week containing Jan 4th', () => {
const {weeks} = useMonthMatrix(ref(0), ref(2026)) // janvier 2026
const weekWithJan4 = weeks.value.find(w =>
w.days.some(d => d.isoDate === '2026-01-04'),
)
expect(weekWithJan4?.weekNumber).toBe(1)
})
it('reacts to month/year changes', () => {
const month = ref(4)
const year = ref(2026)
const {weeks} = useMonthMatrix(month, year)
const mayCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
month.value = 1 // février
year.value = 2024
const febCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
expect(mayCount).toBe(31)
expect(febCount).toBe(29)
})
describe('isToday', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('flags only today', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
const todays = weeks.value.flatMap(w => w.days).filter(d => d.isToday)
expect(todays).toHaveLength(1)
expect(todays[0].isoDate).toBe('2026-05-19')
})
})
})
@@ -1,60 +0,0 @@
import {computed, type ComputedRef, type Ref} from 'vue'
export type DayCell = {
isoDate: string
day: number
isCurrentMonth: boolean
isToday: boolean
}
export type WeekRow = {
weekNumber: number
days: DayCell[]
}
const toIso = (d: Date): string => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const isoWeek = (d: Date): number => {
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
const dayNum = target.getUTCDay() || 7 // dimanche = 7
target.setUTCDate(target.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export function useMonthMatrix(
month: Ref<number>,
year: Ref<number>,
): {weeks: ComputedRef<WeekRow[]>} {
const weeks = computed<WeekRow[]>(() => {
const todayIso = toIso(new Date())
const first = new Date(year.value, month.value, 1)
// recule jusqu'au lundi (getDay : 0 = dimanche)
const offset = (first.getDay() + 6) % 7
const start = new Date(year.value, month.value, 1 - offset)
const rows: WeekRow[] = []
const cursor = new Date(start)
for (let w = 0; w < 6; w++) {
const days: DayCell[] = []
for (let i = 0; i < 7; i++) {
const iso = toIso(cursor)
days.push({
isoDate: iso,
day: cursor.getDate(),
isCurrentMonth: cursor.getMonth() === month.value,
isToday: iso === todayIso,
})
cursor.setDate(cursor.getDate() + 1)
}
rows.push({weekNumber: isoWeek(new Date(`${days[0].isoDate}T00:00:00`)), days})
}
return rows
})
return {weeks}
}
@@ -1,388 +0,0 @@
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
v-maska="maskaOptions"
:name="name"
data-test="date-input"
:readonly="inputReadonly"
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="editable ? draft : displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
@focus="onFocus(); onKbdFocus()"
@input="onInput"
@blur="onBlur(); onKbdBlur()"
@keydown="onKeydown"
>
<div
v-if="showGhost"
data-test="format-ghost"
aria-hidden="true"
class="pointer-events-none absolute left-0 right-0 top-1/2 flex h-10 -translate-y-1/2 items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent pl-3 pr-10 text-lg"
><span
data-test="ghost-typed"
class="text-black"
>{{ ghostTyped }}</span><span
data-test="ghost-remaining"
class="text-m-muted"
>{{ ghostRemaining }}</span></div>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
aria-label="Effacer la date"
@click.stop="onClearClick"
>
<Icon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="[iconStateClass, (disabled || readonly) ? 'cursor-not-allowed' : 'cursor-pointer']"
@click="onFieldClick"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="goToPrev"
@next="goToNext"
@toggle-view="toggleView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</div>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import {vMaska} from 'maska/vue'
import type {MaskInputOptions} from 'maska'
import MalioRequiredMark from '../../shared/RequiredMark.vue'
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})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
displayValue: string
syncTo: string | null
id?: string
name?: string
label?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
editable?: boolean
placeholderTemplate?: string
inputClass?: string
labelClass?: string
groupClass?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
name: '',
label: '',
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
editable: false,
placeholderTemplate: 'JJ/MM/AAAA',
inputClass: '',
labelClass: '',
groupClass: '',
reserveMessageSpace: true,
},
)
const emit = defineEmits<{
(e: 'clear' | 'close'): void
(e: 'commit', value: string): void
// Mois affiché (month 0-11) : émis à l'ouverture du popover et à chaque
// navigation, pour qu'un consommateur (ex. SIRH) charge les données du mois.
(e: 'month-change', value: {month: number, year: number}): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const draft = ref(props.displayValue)
// 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<MaskInputOptions>(() => {
if (!props.editable) return {eager: false}
const {mask, preProcess} = buildBoundedMask(props.placeholderTemplate)
return {mask, preProcess, eager: true}
})
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
// par-dessus l'input (dont le texte est rendu transparent en mode editable).
// Espaces insécables : un espace en bord de span (flex-item) serait sinon rogné,
// collant la suite du gabarit à la date (« 12/12/1999HH:MM »).
const nbsp = (s: string) => s.replace(/ /g, ' ')
const ghostTyped = computed(() => nbsp(draft.value))
const ghostRemaining = computed(() => nbsp(props.placeholderTemplate.slice(draft.value.length)))
watch(() => props.displayValue, (value) => {
draft.value = value
})
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() =>
(props.editable ? draft.value.length : props.displayValue.length) > 0,
)
const isReadonly = computed(() => props.readonly && !props.disabled)
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(isOpen, (value) => {
if (!value) emit('close')
})
// Émet le mois affiché tant que le popover est ouvert : une fois à l'ouverture
// (isOpen true, après syncToIso), puis à chaque changement de mois/année.
watch([isOpen, currentMonth, currentYear], () => {
if (isOpen.value) emit('month-change', {month: currentMonth.value, year: currentYear.value})
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (props.editable) {
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
return
}
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
const onFocus = () => {
if (props.disabled || props.readonly || !props.editable) return
if (!isOpen.value) {
syncToIso(props.syncTo)
open()
}
}
const onInput = (event: Event) => {
draft.value = (event.target as HTMLInputElement).value
}
// Reset local immédiat : sur saisie invalide, modelValue est déjà null, donc le
// watch(displayValue) ne se redéclenche pas il faut vider le draft soi-même.
const onClearClick = () => {
draft.value = ''
emit('clear')
}
const onBlur = () => {
if (!props.editable) return
emit('commit', draft.value)
}
const onEnter = () => {
if (!props.editable) return
emit('commit', draft.value)
closePopover()
}
const onKeydown = (e: KeyboardEvent) => {
if (props.disabled || props.readonly) return
if (e.key === 'Escape') {
if (isOpen.value) {
e.preventDefault()
closePopover()
}
return
}
if (props.editable) {
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
if (e.key === 'Enter') {
e.preventDefault()
onEnter()
}
return
}
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onFieldClick()
}
}
watch(() => props.syncTo, (value) => {
if (isOpen.value) syncToIso(value)
})
const onSelectMonth = (m: number) => {
selectMonth(m)
toggleView()
}
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
// En mode editable, le texte réel est masqué : c'est le gabarit fantôme qui l'affiche.
props.editable ? 'text-transparent caret-black' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 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,
),
)
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'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>
@@ -1,70 +0,0 @@
<template>
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
<button
type="button"
data-test="header-prev"
class="ml-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
@click="emit('prev')"
>
<Icon
icon="mdi:chevron-left"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-toggle"
class="flex gap-1 rounded text-base font-medium"
@click="emit('toggle-view')"
>
<span class="mt-[2px]">{{ label }}</span>
<Icon
icon="mdi:chevron-down"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-next"
class="mr-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
@click="emit('next')"
>
<Icon
icon="mdi:chevron-right"
:width="25"
:height="25"
/>
</button>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {Icon} from '@iconify/vue'
defineOptions({name: 'MalioDateCalendarHeader'})
const props = defineProps<{
viewMode: 'days' | 'months'
currentMonth: number
currentYear: number
}>()
const emit = defineEmits<{
(e: 'prev' | 'next' | 'toggle-view'): void
}>()
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const label = computed(() => {
const name = monthsLong[props.currentMonth]
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
})
</script>
@@ -1,72 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import MonthGrid from './MonthGrid.vue'
type MonthGridProps = {
month: number
year: number
selectedDate?: string | null
markedDates?: Record<string, 'success' | 'danger'>
min?: string
max?: string
}
const Grid = MonthGrid as DefineComponent<MonthGridProps>
const mountGrid = (props: MonthGridProps) => mount(Grid, {props, attachTo: document.body})
// Récupère la pastille (span rond) qui porte les classes de `cellClass` pour un jour donné.
const pill = (wrapper: ReturnType<typeof mountGrid>, iso: string) =>
wrapper.get(`[data-iso="${iso}"]`).get('span.rounded-full')
describe('MalioDateMonthGrid — markedDates', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('applique un fond success sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
expect(pill(wrapper, '2026-05-20').classes()).toContain('bg-m-success/15')
})
it('applique un fond danger sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-21': 'danger'}})
expect(pill(wrapper, '2026-05-21').classes()).toContain('bg-m-danger/15')
})
it('ne marque pas les jours absents de markedDates', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).not.toContain('bg-m-success/15')
expect(classes).not.toContain('bg-m-danger/15')
})
it('précédence : la sélection (primary) prime sur la variante marquée', () => {
const wrapper = mountGrid({
month: 4,
year: 2026,
selectedDate: '2026-05-22',
markedDates: {'2026-05-22': 'success'},
})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).toContain('bg-m-primary')
expect(classes).toContain('text-white')
expect(classes).not.toContain('bg-m-success/15')
})
it('today marqué : garde sa bordure ET reçoit le fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-19': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).toContain('bg-m-success/15')
})
it('today non marqué : bordure sans fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).not.toContain('bg-m-success/15')
})
})
@@ -1,192 +0,0 @@
<template>
<div
data-test="month-grid"
@mouseleave="emit('hover', null)"
>
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
S
</div>
<div
v-for="d in dayLabels"
:key="d"
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
>
{{ d }}
</div>
<template
v-for="(week, wIndex) in weeks"
:key="week.days[0].isoDate"
>
<component
:is="interactiveWeekNumber ? 'button' : 'div'"
data-test="week-number"
:data-week-start="week.days[0].isoDate"
:data-marked="markedWeekStart === week.days[0].isoDate"
:type="interactiveWeekNumber ? 'button' : undefined"
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
:class="[
weekNumberClass(week),
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
@click="onWeekNumberClick(week)"
@mouseenter="onWeekNumberHover(week)"
>
{{ week.weekNumber }}
</component>
<button
v-for="cell in week.days"
:key="cell.isoDate"
type="button"
data-test="day"
:data-iso="cell.isoDate"
:data-range-role="roleOf(cell)"
:disabled="!inRange(cell.isoDate)"
:aria-label="ariaLabel(cell)"
:aria-disabled="!inRange(cell.isoDate)"
class="relative flex h-[45px] w-full items-center justify-center"
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="onSelect(cell.isoDate)"
@mouseenter="emit('hover', cell.isoDate)"
>
<span
v-if="roleOf(cell) === 'in-range'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'start'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-l-full bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'end'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-r-full bg-m-primary-light"
/>
<span
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
:class="cellClass(cell)"
>
{{ cell.day }}
</span>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, toRef} from 'vue'
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
import {isDateInRange} from '../composables/dateFormat'
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
defineOptions({name: 'MalioDateMonthGrid'})
// Statut générique par jour : aucune sémantique métier dans le layer, juste un
// fond tokenisé. `success` et `danger` suffisent pour l'instant (MUI-45).
type MarkedVariant = 'success' | 'danger'
const markedBg: Record<MarkedVariant, string> = {
success: 'bg-m-success/15',
danger: 'bg-m-danger/15',
}
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
rangeStart?: string | null
rangeEnd?: string | null
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
markedDates?: Record<string, MarkedVariant>
min?: string
max?: string
}>(),
{
selectedDate: null,
rangeStart: undefined,
rangeEnd: undefined,
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
markedDates: undefined,
min: undefined,
max: undefined,
},
)
const emit = defineEmits<{
(e: 'select', iso: string): void
(e: 'hover', iso: string | null): void
}>()
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
const weekNumberClass = (week: WeekRow) => {
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
const parts = ['bg-m-primary-light']
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
return parts.join(' ')
}
const onWeekNumberClick = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('select', week.days[0].isoDate)
}
const onWeekNumberHover = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('hover', week.days[0].isoDate)
}
const isRangeMode = computed(() => props.rangeStart !== undefined)
const bounds = computed(() =>
isRangeMode.value
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
: null,
)
const roleOf = (cell: DayCell): DayRangeRole => {
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
return props.selectedDate === cell.isoDate ? 'single' : 'none'
}
const ariaLabel = (cell: DayCell) => {
const [, m, d] = cell.isoDate.split('-')
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
}
const cellClass = (cell: DayCell) => {
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
const role = roleOf(cell)
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
if (role === 'in-range') return 'text-black'
const parts = ['hover:bg-m-primary/10']
// Précédence : sélection/range (primary, return ci-dessus) > variante marquée > défaut.
// `today` n'est pas exclusif : il garde sa bordure ET peut recevoir le fond marqué.
const marked = props.markedDates?.[cell.isoDate]
if (marked) parts.push(markedBg[marked])
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('opacity-[60%]')
return parts.join(' ')
}
const onSelect = (iso: string) => {
if (!inRange(iso)) return
emit('select', iso)
}
</script>
@@ -1,36 +0,0 @@
<template>
<div
data-test="month-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="(name, index) in months"
:key="name"
type="button"
data-test="month"
:data-month="index"
class="flex h-[45px] w-full items-center justify-center"
@click="emit('select', index)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="index === selectedMonth
? 'bg-m-primary text-white'
: 'text-black hover:bg-m-primary/10'"
>
{{ name }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{selectedMonth?: number}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
</script>
+7 -6
View File
@@ -152,13 +152,12 @@ describe('MalioDrawer', () => {
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
it('renders the #footer slot inside the body (scrollable zone)', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer wrapper when no #footer slot', () => {
@@ -171,12 +170,14 @@ describe('MalioDrawer', () => {
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
it('applies footerClass to the footer wrapper', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ modelValue: true, footerClass: 'sticky bottom-0' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
const footer = wrapper.find('[data-test="footer"]')
expect(footer.classes()).toContain('sticky')
expect(footer.classes()).toContain('bottom-0')
})
it('aligns to the right by default', () => {
+7 -7
View File
@@ -64,13 +64,13 @@
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
<div
v-if="$slots.footer"
:class="footerClass"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</div>
-67
View File
@@ -24,7 +24,6 @@ type InputProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputForTest = Input as DefineComponent<InputProps>
@@ -54,16 +53,6 @@ describe('MalioInputText', () => {
expect(wrapper.get('label').text()).toBe('labelTest')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInput({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 = mountInput({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('applies the name attribute', () => {
const wrapper = mountInput({name: 'nameTest'})
@@ -137,13 +126,6 @@ describe('MalioInputText', () => {
expect(wrapper.get('input').classes()).toContain('text-black/60')
})
it('shows muted label color when disabled (matches border color)', () => {
const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('label').classes()).not.toContain('text-black/60')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountInput({modelValue: ''})
@@ -271,34 +253,6 @@ describe('MalioInputText', () => {
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
})
it('reserves space for the message even when no hint/error/success is set', () => {
const wrapper = mountInput({})
const p = wrapper.find('p')
expect(p.exists()).toBe(true)
expect(p.text()).toBe('')
expect(p.classes()).toContain('min-h-[1rem]')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInput({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 = mountInput({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInput({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]')
})
it('does not render label when label prop is missing', () => {
const wrapper = mountInput({labelClass: 'text-red-500'})
@@ -354,25 +308,4 @@ describe('MalioInputText', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountInput({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountInput({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountInput({label: 'Champ', readonly: true, modelValue: 'hello', iconName: 'mdi:key-outline'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
})
+4 -108
View File
@@ -24,7 +24,6 @@ type InputAmountProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
@@ -97,7 +96,7 @@ describe('MalioInputAmount', () => {
await wrapper.get('input').setValue('12.5')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
expect(wrapper.get('input').element.value).toBe('12,5')
expect(wrapper.get('input').element.value).toBe('12.5')
})
it('accepts commas but normalizes them to dots', async () => {
@@ -106,7 +105,7 @@ describe('MalioInputAmount', () => {
await wrapper.get('input').setValue('0012,345abc')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
expect(wrapper.get('input').element.value).toBe('12,34')
expect(wrapper.get('input').element.value).toBe('12.34')
})
it('normalizes a leading decimal separator', async () => {
@@ -115,7 +114,7 @@ describe('MalioInputAmount', () => {
await wrapper.get('input').setValue(',5')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
expect(wrapper.get('input').element.value).toBe('0,5')
expect(wrapper.get('input').element.value).toBe('0.5')
})
it('keeps the normalized decimal value on blur', async () => {
@@ -126,7 +125,7 @@ describe('MalioInputAmount', () => {
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
expect(input.element.value).toBe('12,5')
expect(input.element.value).toBe('12.5')
})
it('keeps integer values unchanged on blur', async () => {
@@ -175,107 +174,4 @@ describe('MalioInputAmount', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInputAmount({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 = mountInputAmount({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly vide : icône en text-m-muted', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true, modelValue: '12.50'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInputAmount({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 = mountInputAmount({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInputAmount({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]')
})
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
expect(wrapper.get('input').element.value).toBe('1 234 567')
})
it('groupe un grand montant avec décimales', async () => {
const wrapper = mountInputAmount({modelValue: ''})
await wrapper.get('input').setValue('1234567,89')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('formate la valeur initiale (modelValue) en groupé', () => {
const wrapper = mountInputAmount({modelValue: '1234567.89'})
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
})
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('12345')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.get('input').element.value).toBe('')
})
it('maxLength autorise une valeur à la limite', async () => {
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
await wrapper.get('input').setValue('1234')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
expect(wrapper.get('input').element.value).toBe('1 234')
})
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
const wrapper = mountInputAmount({maxLength: 4})
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
})
})
+42 -63
View File
@@ -9,9 +9,10 @@
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:maxlength="maxLength"
:minlength="minLength"
:disabled="disabled"
:value="formattedValue"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
@@ -20,7 +21,7 @@
inputmode="decimal"
placeholder="_"
@input="onInput"
@focus="isFocused = true; onKbdFocus()"
@focus="isFocused = true"
@blur="onBlur"
>
@@ -29,7 +30,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<IconifyIcon
@@ -43,7 +44,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -51,8 +52,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
@@ -64,14 +64,9 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -94,7 +89,6 @@ const props = withDefaults(
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -115,9 +109,8 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
iconSize: 20,
iconSize: 24,
iconColor: 'text-m-muted',
reserveMessageSpace: true,
},
)
@@ -129,16 +122,10 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
@@ -148,40 +135,30 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
: 'focus:border-m-primary',
props.inputClass,
iconInputPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -195,37 +172,40 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
const normalizeAmount = (value: string) => {
const sanitizedValue = value
.replace(/\s+/g, '')
.replace(/,/g, '.')
.replace(/[^\d.]/g, '')
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
const decimalPart = decimalParts.join('').slice(0, 2)
if (sanitizedValue.includes('.')) {
return `${integerPart || '0'}.${decimalPart}`
}
return integerPart
}
// Keep the DOM input value, local state, and v-model emission in sync.
const updateValue = (target: HTMLInputElement, value: string) => {
target.value = value
if (!isControlled.value) {
localValue.value = value
}
emit('update:modelValue', value)
}
// Normalize while typing so the field never keeps invalid amount characters.
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const rawText = target.value
const caret = target.selectionStart ?? rawText.length
const model = normalizeAmount(rawText)
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
if (props.maxLength != null && model.length > Number(props.maxLength)) {
target.value = formattedValue.value
const restored = Math.min(Math.max(0, caret - 1), formattedValue.value.length)
target.setSelectionRange(restored, restored)
return
}
const display = formatGroupedAmount(model)
const sig = countSignificant(rawText, caret)
target.value = display
const newCaret = caretFromSignificant(display, sig)
target.setSelectionRange(newCaret, newCaret)
if (!isControlled.value) {
localValue.value = model
}
emit('update:modelValue', model)
updateValue(target, normalizeAmount(target.value))
}
// Keep the blur handler only for focus-driven UI state.
const onBlur = () => {
isFocused.value = false
onKbdBlur()
}
const iconInputPaddingClass = computed(() => {
@@ -254,7 +234,6 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -28,7 +28,6 @@ type InputAutocompleteProps = {
debounce?: number
minSearchLength?: number
allowCreate?: boolean
localFilter?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
@@ -36,7 +35,6 @@ type InputAutocompleteProps = {
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
@@ -66,16 +64,6 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('label').text()).toBe('Pays')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('renders with type combobox role', () => {
const wrapper = mountComponent()
@@ -375,12 +363,6 @@ 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})
@@ -445,128 +427,4 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('input').element.value).toBe('Custom')
})
it('does not filter options when localFilter is false (default)', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('fr')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
})
it('filters options client-side when localFilter is true', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('fr')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(1)
expect(items[0].text()).toBe('France')
})
it('localFilter is case-insensitive and matches substrings', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('GIQ')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(1)
expect(items[0].text()).toBe('Belgique')
})
it('localFilter shows all options when input is empty', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(3)
})
it('localFilter shows the no-results state when nothing matches', async () => {
const wrapper = mountComponent({options, localFilter: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('zzzzz')
expect(wrapper.findAll('[data-test="option"]')).toHaveLength(0)
expect(wrapper.find('[data-test="no-results-text"]').exists()).toBe(true)
})
it('keeps the floating label at the same position whether focused or not (no jump)', async () => {
const wrapper = mountComponent({options, label: 'Pays', modelValue: 'fr'})
// when a value is selected and the field is not focused, the label is already floated
const labelClasses = wrapper.get('label').classes()
expect(labelClasses).toContain('-translate-y-[1.25rem]')
// and there is no extra peer-focus translate that would make it jump on click
expect(labelClasses).not.toContain('peer-focus:-translate-y-[1.55rem]')
})
it('does not shift inner text horizontally on focus (no focus:pl change)', () => {
const wrapper = mountComponent({options})
const inputClasses = wrapper.get('input').classes()
expect(inputClasses).not.toContain('focus:pl-[11px]')
})
it('keeps the bottom border allocation when open (transparent, not zero)', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
const inputClasses = wrapper.get('input').classes()
// border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// border-b-transparent keeps the 1px allocation but hides the line
expect(inputClasses).not.toContain('!border-b-0')
expect(inputClasses).toContain('!border-b-transparent')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly vide : chevron en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fr', options, iconName: 'mdi:magnify', iconPosition: 'left'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon-left"]').classes()).toContain('text-black')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ', options})
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 = mountComponent({label: 'Champ', options, reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
@@ -24,7 +24,6 @@
type="text"
@input="onInput"
@focus="onFocus"
@blur="onKbdBlur"
@click="onInputClick"
@keydown="onKeydown"
>
@@ -34,7 +33,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<IconifyIcon
@@ -64,7 +63,7 @@
class="animate-spin text-m-primary"
/>
<IconifyIcon
v-else-if="!disabled"
v-else
icon="mdi:chevron-down"
:width="20"
:height="20"
@@ -91,7 +90,6 @@
: hasSuccess
? 'border-m-success select-scrollbar-success'
: 'border-m-primary select-scrollbar-primary',
keyboardFocused ? 'm-combo-ring-bottom' : '',
]"
>
<li
@@ -109,7 +107,7 @@
{{ minSearchText }}
</li>
<li
v-else-if="filteredOptions.length === 0"
v-else-if="options.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-results-text"
>
@@ -117,7 +115,7 @@
</li>
<template v-else>
<li
v-for="(opt, index) in filteredOptions"
v-for="(opt, index) in options"
:id="optionId(index)"
:key="String(opt.value)"
data-test="option"
@@ -138,12 +136,11 @@
</ul>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -152,16 +149,12 @@
</template>
<script setup lang="ts">
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
type Option = {
label: string
value: string | number
@@ -187,7 +180,6 @@ const props = withDefaults(
debounce?: number
minSearchLength?: number
allowCreate?: boolean
localFilter?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
@@ -195,7 +187,6 @@ const props = withDefaults(
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -216,7 +207,6 @@ const props = withDefaults(
debounce: 300,
minSearchLength: 0,
allowCreate: false,
localFilter: false,
iconName: '',
iconPosition: 'left',
iconSize: 24,
@@ -224,7 +214,6 @@ const props = withDefaults(
noResultsText: 'Aucun résultat',
loadingText: 'Chargement…',
minSearchText: 'Tapez pour rechercher',
reserveMessageSpace: true,
},
)
@@ -258,29 +247,15 @@ const hasSelection = computed(() =>
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || inputValue.value.length > 0,
)
const shouldFloatLabel = computed(() => isFocused.value || inputValue.value.length > 0)
const showMinSearch = computed(() =>
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
)
const filteredOptions = computed(() => {
if (!props.localFilter) return props.options
const query = inputValue.value.trim().toLowerCase()
if (query === '') return props.options
return props.options.filter(opt =>
opt.label.toLowerCase().includes(query),
)
})
const optionId = (index: number) => `${inputId.value}-option-${index}`
const activeOptionId = computed(() =>
activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
activeIndex.value >= 0 && props.options[activeIndex.value]
? optionId(activeIndex.value)
: undefined,
)
@@ -319,18 +294,19 @@ const iconInputPaddingClass = computed(() => {
return parts.join(' ')
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const labelPositionClass = computed(() =>
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled
? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
: 'cursor-text',
@@ -338,11 +314,11 @@ const mergedInputClass = computed(() =>
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
: 'focus:border-m-primary',
isOpen.value ? '!rounded-b-none !border-b-0' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
),
)
@@ -350,16 +326,13 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: props.disabled
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -368,7 +341,6 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -377,7 +349,6 @@ const iconStateClass = computed(() => {
const chevronColorClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
@@ -406,7 +377,6 @@ const onInput = (event: Event) => {
}
const onFocus = () => {
onKbdFocus()
if (props.disabled || props.readonly) return
isFocused.value = true
isOpen.value = true
@@ -453,20 +423,7 @@ const closeAndRevert = () => {
isFocused.value = false
}
// Garde l'option active visible dans la liste défilante quand on navigue au clavier
watch(activeIndex, async (index) => {
if (index < 0 || !isOpen.value) return
await nextTick()
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
})
const onKeydown = (event: KeyboardEvent) => {
// Tab : laisse le focus partir mais ferme la liste (et valide la saisie courante)
if (event.key === 'Tab') {
if (isOpen.value) closeAndCommit()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeAndRevert()
@@ -475,8 +432,8 @@ const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
onSelect(filteredOptions.value[activeIndex.value])
if (activeIndex.value >= 0 && props.options[activeIndex.value]) {
onSelect(props.options[activeIndex.value])
return
}
if (props.allowCreate && inputValue.value !== '') {
@@ -493,31 +450,13 @@ const onKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
isOpen.value = true
}
activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
// Liste fermée : ouvre et place sur la dernière option (APG)
if (!isOpen.value) {
isOpen.value = true
activeIndex.value = filteredOptions.value.length - 1
return
}
activeIndex.value = Math.max(activeIndex.value - 1, 0)
return
}
// Home / End : première / dernière option quand la liste est ouverte
if (isOpen.value && event.key === 'Home') {
event.preventDefault()
activeIndex.value = 0
return
}
if (isOpen.value && event.key === 'End') {
event.preventDefault()
activeIndex.value = filteredOptions.value.length - 1
}
}
@@ -542,7 +481,12 @@ onBeforeUnmount(() => {
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease;
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
@media (prefers-reduced-motion: reduce) {
@@ -23,11 +23,6 @@ type InputEmailProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
lowercase?: boolean
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
@@ -57,16 +52,6 @@ describe('MalioInputEmail', () => {
expect(wrapper.get('label').text()).toBe('Adresse email')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type email', () => {
const wrapper = mountComponent()
@@ -240,148 +225,4 @@ describe('MalioInputEmail', () => {
expect(wrapper.get('input').attributes('autocomplete')).toBe('email')
})
it('supprime tous les espaces saisis', async () => {
const wrapper = mountComponent()
await wrapper.get('input').setValue(' a b @ c.com ')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['ab@c.com'])
expect(wrapper.get('input').element.value).toBe('ab@c.com')
})
it('conserve la casse par défaut', async () => {
const wrapper = mountComponent()
await wrapper.get('input').setValue('User@Example.COM')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['User@Example.COM'])
})
it('met en minuscules quand lowercase est vrai', async () => {
const wrapper = mountComponent({lowercase: true})
await wrapper.get('input').setValue('User@Example.COM')
const emits = wrapper.emitted('update:modelValue')!
expect(emits[emits.length - 1]).toEqual(['user@example.com'])
})
it('émet la valeur sanitisée en mode contrôlé', async () => {
const wrapper = mountComponent({modelValue: ''})
await wrapper.get('input').setValue(' a b @ c.com ')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['ab@c.com'])
})
it('resynchronise le DOM en mode contrôlé même quand la valeur sanitisée égale déjà modelValue', async () => {
// L'utilisateur ajoute un espace en fin alors que la valeur nettoyée vaut déjà modelValue.
// Le parent ne « changera » pas modelValue → Vue ne re-patche pas le DOM ; l'écriture
// manuelle target.value = sanitized est donc indispensable pour retirer l'espace affiché.
const wrapper = mountComponent({modelValue: 'ab@c.com'})
const input = wrapper.get('input')
await input.setValue('ab@c.com ')
expect(input.element.value).toBe('ab@c.com')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'user@example.com'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({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]')
})
it('does not render add button by default', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('renders add button when addable is true', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
})
it('emits add event when add button is clicked', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('hides the add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('does not emit add when readonly', async () => {
const wrapper = mountComponent({addable: true, readonly: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('moves the email icon to the left automatically when addable', () => {
const wrapper = mountComponent({addable: true})
const icon = wrapper.get('[data-test="icon"]')
expect(icon.classes()).toContain('left-[10px]')
expect(icon.classes()).not.toContain('right-[10px]')
})
it('keeps the email icon on the right when addable is false', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('uses the default add button aria-label', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
})
it('allows overriding the add button aria-label', () => {
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
})
})
+20 -114
View File
@@ -19,8 +19,8 @@
type="email"
inputmode="email"
@input="onInput"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
@@ -28,7 +28,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<IconifyIcon
@@ -40,25 +40,9 @@
:class="[iconStateClass, iconPositionClass]"
/>
<button
v-if="addable && !disabled"
type="button"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@click="onAdd"
>
<IconifyIcon
:icon="addIconName"
:width="24"
:height="24"
data-test="add-icon"
/>
</button>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -66,8 +50,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
@@ -80,13 +63,9 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -107,11 +86,6 @@ const props = withDefaults(
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
addable?: boolean
addIconName?: string
addButtonLabel?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -132,11 +106,6 @@ const props = withDefaults(
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter une adresse email',
lowercase: false,
reserveMessageSpace: true,
},
)
@@ -148,15 +117,10 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-email-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -165,52 +129,34 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
: 'focus:border-m-primary',
props.inputClass,
iconInputPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
@@ -221,74 +167,35 @@ const describedBy = computed(() => {
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'add'): void
}>()
const sanitizeEmail = (v: string) => {
let out = v.replace(/\s+/g, '')
if (props.lowercase) out = out.toLowerCase()
return out
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const raw = target.value
const sanitized = sanitizeEmail(raw)
if (sanitized !== raw) {
// `<input type="email">` ne supporte pas l'API de sélection :
// selectionStart vaut null et setSelectionRange lève en navigateur.
// (En jsdom selectionStart peut renvoyer un nombre, d'où le code gardé ci-dessous.)
const caret = target.selectionStart
target.value = sanitized
if (caret !== null) {
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
try {
target.setSelectionRange(newCaret, newCaret)
} catch {
/* type d'input sans support de sélection — ignore */
}
}
}
if (!isControlled.value) {
localValue.value = sanitized
localValue.value = target.value
}
emit('update:modelValue', sanitized)
emit('update:modelValue', target.value)
}
const onAdd = () => {
if (props.disabled || props.readonly) return
emit('add')
}
const effectiveIconPosition = computed(() =>
props.addable && props.iconName ? 'left' : props.iconPosition,
)
const iconInputPaddingClass = computed(() => {
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
const parts: string[] = []
if (leftIcon) parts.push('!pl-11')
if (rightIcon || props.addable) parts.push('!pr-10')
return parts.join(' ')
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
})
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
if (props.iconName && props.iconPosition === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
@@ -296,7 +203,6 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -6,13 +6,9 @@ import InputNumber from './InputNumber.vue'
type InputNumberProps = {
modelValue?: string | null
label?: string
required?: boolean
readonly?: boolean
min?: number | string
max?: number | string
error?: string
hint?: string
reserveMessageSpace?: boolean
}
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
@@ -166,33 +162,4 @@ describe('MalioInputNumber', () => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
expect(input.element.value).toBe('5')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInputNumber({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 = mountInputNumber({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInputNumber({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 = mountInputNumber({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInputNumber({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]')
})
})
+6 -16
View File
@@ -6,11 +6,10 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<button
type="button"
class="m-focus-ring rounded-malio"
:disabled="isMinusDisabled"
@click="decrement"
>
@@ -36,12 +35,11 @@
inputmode="numeric"
placeholder="_"
@input="onInput"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
@focus="isFocused = true"
@blur="isFocused = false"
>
<button
type="button"
class="m-focus-ring rounded-malio"
:disabled="isPlusDisabled"
@click="increment"
>
@@ -53,7 +51,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -61,8 +59,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
'text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
@@ -74,13 +71,9 @@
import {computed, ref, useAttrs, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -98,7 +91,6 @@ const props = withDefaults(
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -116,7 +108,6 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
},
)
@@ -189,7 +180,6 @@ const mergedGroupClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
@@ -205,7 +195,7 @@ const mergedLabelClass = computed(() =>
'cursor-pointer text-black mr-4 text-[18px]',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
props.disabled ? 'cursor-not-allowed text-m-muted' : '',
props.disabled ? 'cursor-not-allowed text-black/60' : '',
props.labelClass,
),
)
@@ -22,7 +22,6 @@ type InputPasswordProps = {
error?: string
success?: string
displayIcon?: boolean
reserveMessageSpace?: boolean
}
const InputPasswordForTest = InputPassword as DefineComponent<InputPasswordProps>
@@ -52,16 +51,6 @@ describe('MalioInputPassword', () => {
expect(wrapper.get('label').text()).toBe('Mot de passe')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type password by default', () => {
const wrapper = mountComponent()
@@ -91,12 +80,6 @@ describe('MalioInputPassword', () => {
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('hides the eye icon when disabled', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('renders icon by default', () => {
const wrapper = mountComponent()
@@ -202,55 +185,4 @@ describe('MalioInputPassword', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly vide : icône en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'secret'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : eye toggle reste cliquable', async () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
await wrapper.get('[data-test="icon"]').trigger('click')
expect(wrapper.get('input').attributes('type')).toBe('text')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({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]')
})
})
+14 -37
View File
@@ -20,8 +20,8 @@
placeholder="_"
:type="isPasswordVisible ? 'text' : 'password'"
@input="onInput"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
@@ -29,11 +29,11 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<IconifyIcon
v-if="displayIcon && !disabled"
v-if="displayIcon"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24"
:height="24"
@@ -47,7 +47,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -55,8 +55,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
@@ -69,13 +68,9 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -95,7 +90,6 @@ const props = withDefaults(
error?: string
success?: string
displayIcon?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -115,7 +109,6 @@ const props = withDefaults(
error: '',
success: '',
displayIcon: true,
reserveMessageSpace: true,
},
)
@@ -132,15 +125,10 @@ const toggleVisibility = () => {
const inputId = computed(() => props.id?.toString() || `malio-input-password-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -149,21 +137,16 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
: 'focus:border-m-primary',
props.displayIcon ? '!pr-10' : '',
isReadonly.value ? '' : 'focus:pl-[11px]',
'focus:pl-[11px]',
props.inputClass,
),
)
@@ -171,18 +154,13 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -213,7 +191,6 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
+12 -102
View File
@@ -27,7 +27,6 @@ type InputPhoneProps = {
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
@@ -57,16 +56,6 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('label').text()).toBe('Téléphone')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type tel', () => {
const wrapper = mountComponent()
@@ -253,10 +242,12 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('hides the add button when disabled', () => {
it('does not emit add when disabled', async () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('does not emit add when readonly', async () => {
@@ -267,43 +258,16 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toBeUndefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('disables add button when readonly', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('readonly : border-black appliqué sur l\'input', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('input').classes()).toContain('border-black')
})
it('readonly : icône en text-m-muted quand vide', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly : icône en text-black quand rempli', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : pas d\'apparence désactivée (pas opacity-40)', () => {
const wrapper = mountComponent({label: 'Tel', addable: true, readonly: true})
// opacity-40 was only ever applied to the add button, not the input
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('opacity-40')
// and the input is not natively disabled in readonly:
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
})
it('readonly vide : label en text-m-muted', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label en text-black', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('renders the default add icon (mdi:plus)', () => {
@@ -334,41 +298,6 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('input').classes()).toContain('!pr-10')
})
it('shows default add button color when empty and unfocused', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('text-m-primary')
})
it('shows primary add button color on focus', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-primary')
})
it('shows black add button color when filled and unfocused', () => {
const wrapper = mountComponent({addable: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-black')
})
it('error overrides focus color on add button', async () => {
const wrapper = mountComponent({addable: true, error: 'Numéro invalide'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-danger')
})
it('success applies to add button', () => {
const wrapper = mountComponent({addable: true, success: 'Numéro valide'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-success')
})
it('applies mask via maska directive', async () => {
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
@@ -376,23 +305,4 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('update:modelValue')).toBeDefined()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({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]')
})
})
+17 -40
View File
@@ -20,8 +20,8 @@
type="tel"
inputmode="tel"
@input="onInput"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<IconifyIcon
@@ -42,8 +42,9 @@
/>
<button
v-if="addable && !disabled"
v-if="addable"
type="button"
:disabled="disabled || readonly"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@@ -59,7 +60,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -67,8 +68,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
@@ -83,13 +83,9 @@ import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -114,7 +110,6 @@ const props = withDefaults(
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -139,7 +134,6 @@ const props = withDefaults(
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter un numéro',
reserveMessageSpace: true,
},
)
@@ -151,15 +145,10 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -168,49 +157,38 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
: 'focus:border-m-primary',
props.inputClass,
iconInputPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70',
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
@@ -270,7 +248,6 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -19,8 +19,6 @@ type InputRichTextProps = {
groupClass?: string
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
@@ -157,18 +155,6 @@ describe('MalioInputRichText', () => {
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
})
it('expose aria-required quand required est vrai', async () => {
const wrapper = await mountComponent({required: true})
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
})
it('n\'expose pas aria-required par défaut', async () => {
const wrapper = await mountComponent()
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
})
it('renders initial markdown content visually', async () => {
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
@@ -176,35 +162,4 @@ describe('MalioInputRichText', () => {
expect(html).toContain('Mon titre')
expect(html).toContain('Un paragraphe.')
})
it('affiche l\'astérisque quand required est vrai', async () => {
const wrapper = await mountComponent({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', async () => {
const wrapper = await mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', async () => {
const wrapper = await mountComponent({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', async () => {
const wrapper = await mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', async () => {
const wrapper = await mountComponent({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]')
})
})
+7 -16
View File
@@ -5,7 +5,7 @@
:for="editorId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<!-- Mode lecture seule (rendu uniquement) -->
@@ -22,7 +22,6 @@
v-else
:id="editorId"
:class="mergedEditorWrapperClass"
:aria-required="required || undefined"
@click="focusEditor"
>
<div
@@ -185,7 +184,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${editorId}-describedby`"
:class="[
hasError
@@ -194,7 +193,6 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
@@ -213,7 +211,6 @@ import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import { Markdown } from 'tiptap-markdown'
import { twMerge } from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
@@ -236,8 +233,6 @@ const props = withDefaults(
groupClass?: string
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -255,8 +250,6 @@ const props = withDefaults(
groupClass: '',
labelClass: '',
editorClass: '',
required: false,
reserveMessageSpace: true,
},
)
@@ -286,11 +279,10 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: props.disabled
? 'text-m-muted'
: isFocused.value
? 'text-m-primary'
: 'text-m-text',
: isFocused.value
? 'text-m-primary'
: 'text-m-text',
props.disabled ? 'text-black/60' : '',
props.labelClass,
),
)
@@ -316,7 +308,6 @@ const mergedReadonlyClass = computed(() =>
'prose-headings:font-semibold prose-a:text-m-primary',
'prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-m-text prose-pre:text-white',
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
props.editorClass,
),
)
@@ -495,7 +486,7 @@ onMounted(() => {
],
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none w-full p-3 focus:outline-none prose-headings:font-semibold prose-a:text-m-primary prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-m-text prose-pre:text-white [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit',
class: 'prose prose-sm max-w-none w-full p-3 focus:outline-none prose-headings:font-semibold prose-a:text-m-primary prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-m-text prose-pre:text-white',
},
},
onUpdate: () => {
+13 -36
View File
@@ -21,8 +21,8 @@
placeholder="_"
type="text"
@input="onInput"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
@@ -30,7 +30,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<IconifyIcon
@@ -44,7 +44,7 @@
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -52,8 +52,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
@@ -68,13 +67,9 @@ import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputText', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -99,7 +94,6 @@ const props = withDefaults(
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -123,7 +117,6 @@ const props = withDefaults(
iconSize: 24,
iconColor: 'text-m-muted',
mask: undefined,
reserveMessageSpace: true,
},
)
@@ -135,15 +128,10 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -152,40 +140,30 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
: 'focus:border-m-primary',
props.inputClass,
iconInputPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -236,7 +214,6 @@ const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
@@ -21,7 +21,6 @@ type InputTextAreaProps = {
error?: string
success?: string
rounded?: string
reserveMessageSpace?: boolean
}
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
@@ -150,87 +149,4 @@ describe('MalioInputTextArea', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
})
it('renders as a single root element (works as a single grid item)', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const wrapper = mount(InputTextAreaForTest, {
attachTo: host,
})
// host > div[data-v-app] > component roots
const app = host.firstElementChild as HTMLElement
expect(app.children.length).toBe(1)
wrapper.unmount()
host.remove()
})
it('applies primary scrollbar class on focus', async () => {
const wrapper = mount(InputTextAreaForTest)
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
await wrapper.get('textarea').trigger('focus')
expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
})
it('removes primary scrollbar class on blur', async () => {
const wrapper = mount(InputTextAreaForTest)
await wrapper.get('textarea').trigger('focus')
await wrapper.get('textarea').trigger('blur')
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mount(InputTextAreaForTest, {props: {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 = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de bleu', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
const field = wrapper.get('textarea')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label gris, pas de bleu focus', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
// En readonly, pas de couleur primary sur le label
expect(wrapper.get('label').classes()).not.toContain('text-m-primary')
})
it('readonly rempli : label noir', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true, modelValue: 'du texte'}})
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
const msg = wrapper.find('[data-test="message-line"]')
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 = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false}})
expect(wrapper.find('[data-test="message-line"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false, error: 'Erreur'}})
const msg = wrapper.find('[data-test="message-line"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+73 -99
View File
@@ -1,101 +1,88 @@
<template>
<div :class="mergedGroupClass">
<div class="relative w-full flex-1">
<textarea
:id="inputId"
:name="name"
<textarea
:id="inputId"
:name="name"
:autocomplete="autocomplete"
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
:class="[
isReadonly ? 'border-black' : (isFilled ? 'border-black' : 'border-m-muted'),
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : (isReadonly ? 'cursor-default' : 'cursor-text'),
hasError
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? 'border-m-success focus:border-m-success'
: isReadonly ? '' : 'focus:border-m-primary',
isReadonly ? '' : (isFocused ? 'textarea-scrollbar-primary' : ''),
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
keyboardFocused ? 'm-focus-ring-kbd' : '',
]"
:required="required"
:maxlength="maxLength"
:rows="rowsCount"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:style="textareaStyle"
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
/>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
:autocomplete="autocomplete"
class="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
:class="[
isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
hasError
? 'text-m-danger'
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? 'text-m-success'
: disabled
? 'text-m-muted'
: isReadonly
? (isFilled ? 'text-black' : 'text-m-muted')
: (isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted'),
textLabel,
]"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<span
v-if="showCounterComputed"
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
>
{{ currentLength }}/{{ maxLength }}
</span>
</div>
<div
v-if="reserveMessageSpace || hint || error || success"
data-test="message-line"
class="mt-1 flex items-center justify-between gap-2 text-xs"
:class="reserveMessageSpace ? 'min-h-[1rem]' : ''"
? 'border-m-success focus:border-m-success'
: 'focus:border-m-primary',
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
]"
:required="required"
:maxlength="maxLength"
:rows="rowsCount"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:style="textareaStyle"
v-bind="attrs"
placeholder="_"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
/>
<label
v-if="label"
:for="inputId"
class="floating-label absolute left-3 top-2 mt-1 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? '-translate-y-[1.30rem] scale-90' : '',
disabled ? 'text-black/60' : '',
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted',
textLabel,
]"
>
<p
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'ml-[2px]',
]"
>
{{ error || success || hint }}
</p>
</div>
{{ label }}
</label>
<span
v-if="showCounterComputed"
class="pointer-events-none absolute bottom-2 left-3 text-xs text-m-muted"
>
{{ currentLength }}/{{ maxLength }}
</span>
</div>
<div
v-if="hasError || hasSuccess || hint"
class="mt-1 flex items-center justify-between gap-2 text-xs"
>
<p
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'ml-[2px]',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -121,7 +108,6 @@ const props = withDefaults(
success?: string
rounded?: string
groupClass?: string
reserveMessageSpace?: boolean
}>(),
{
@@ -148,14 +134,11 @@ const props = withDefaults(
minResizeHeight: 40,
maxResizeHeight: 320,
groupClass: '',
reserveMessageSpace: true,
},
)
const mergedGroupClass = computed(() =>
// pt-1 (4px) aligne le haut de la textarea avec les inputs floating-label,
// qui centrent un champ de 40px dans un groupe h-12 ( 4px de décalage en haut).
twMerge('flex flex-col w-full pt-1', props.groupClass),
twMerge('relative w-full', props.groupClass),
)
const attrs = useAttrs()
@@ -166,15 +149,9 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const rowsCount = computed(() => Math.max(1, Number(props.size || 3)))
const currentLength = computed(() => (currentValue.value ?? '').length)
const showCounterComputed = computed(() =>
@@ -188,6 +165,7 @@ const textareaStyle = computed(() => ({
minHeight: toCssSize(props.minResizeHeight),
maxHeight: toCssSize(props.maxResizeHeight),
}))
const isFilled = computed(() => currentValue.value.trim().length > 0)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
)
@@ -210,8 +188,4 @@ const onInput = (event: Event) => {
background: white;
padding: 0 0.25rem;
}
.textarea-scrollbar-primary {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
</style>
+1 -75
View File
@@ -1,4 +1,4 @@
import {describe, expect, it, vi} from 'vitest'
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
@@ -12,14 +12,11 @@ type InputUploadProps = {
labelClass?: string
groupClass?: string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
accept?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
@@ -170,11 +167,6 @@ describe('MalioInputUpload', () => {
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false')
})
it('expose aria-required sur le champ visible quand required est vrai', () => {
const wrapper = mountComponent({label: 'Champ', required: true})
expect(wrapper.get('input[type="text"]').attributes('aria-required')).toBe('true')
})
it('passes accept attribute to file input', () => {
const wrapper = mountComponent({accept: '.pdf,.doc'})
@@ -194,70 +186,4 @@ describe('MalioInputUpload', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input[type="text"]')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('grow-height')
expect(field.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('peer-focus:text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly vide : icône en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir + icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fichier.pdf'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly empêche l\'ouverture du sélecteur de fichier', async () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const fileInput = wrapper.get('input[type="file"]').element as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
await wrapper.get('input[type="text"]').trigger('click')
expect(clickSpy).not.toHaveBeenCalled()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({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 = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({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]')
})
})
+29 -93
View File
@@ -9,7 +9,6 @@
:accept="accept"
class="hidden"
:disabled="disabled"
:required="required"
@change="onFileChange"
>
@@ -20,16 +19,13 @@
:value="currentDisplayValue"
:readonly="true"
:aria-invalid="!!error"
:aria-required="required || undefined"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
type="text"
@click="openFilePicker"
@keydown.enter.prevent="openFilePicker"
@keydown.space.prevent="openFilePicker"
@focus="isFocused = true; onKbdFocus()"
@blur="isFocused = false; onKbdBlur()"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
@@ -37,40 +33,24 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<div
v-if="displayIcon || showClear"
class="absolute right-[10px] top-1/2 flex -translate-y-1/2 items-center gap-1"
>
<button
v-if="showClear"
type="button"
data-test="clear"
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
aria-label="Retirer le fichier"
@click.stop="onClear"
>
<IconifyIcon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<IconifyIcon
v-if="displayIcon"
icon="mdi:cloud-arrow-up-outline"
:width="24"
:height="24"
data-test="icon"
:class="[iconStateClass, 'pointer-events-none']"
/>
</div>
<IconifyIcon
v-if="displayIcon"
icon="mdi:cloud-arrow-up-outline"
:width="24"
:height="24"
data-test="icon"
:class="[
iconStateClass,
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
]"
/>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -78,8 +58,7 @@
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
'mt-1 text-xs ml-[2px] ',
]"
>
{{ hint || error || success }}
@@ -92,13 +71,9 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
const props = withDefaults(
defineProps<{
id?: string
@@ -108,15 +83,11 @@ const props = withDefaults(
labelClass?: string
groupClass?: string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
accept?: string
required?: boolean
clearable?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -126,15 +97,11 @@ const props = withDefaults(
labelClass: '',
groupClass: '',
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
displayIcon: true,
accept: '',
required: false,
clearable: false,
reserveMessageSpace: true,
},
)
@@ -147,16 +114,10 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const inputId = computed(() => props.id?.toString() || `malio-input-upload-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentDisplayValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentDisplayValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
const disabled = computed(() => props.disabled)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentDisplayValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -165,24 +126,16 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md cursor-pointer',
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : '',
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
showClear.value
? (props.displayIcon ? '!pr-16' : '!pr-10')
: (props.displayIcon ? '!pr-10' : ''),
isReadonly.value ? '' : 'focus:pl-[11px]',
isReadonly.value ? 'cursor-default' : '',
disabled.value ? 'cursor-not-allowed' : '',
: 'focus:border-m-primary',
props.displayIcon ? '!pr-10' : '',
'focus:pl-[11px]',
props.inputClass,
),
)
@@ -190,18 +143,13 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
@@ -217,23 +165,10 @@ const describedBy = computed(() => {
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'file-selected', file: File): void
(event: 'clear'): void
}>()
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !isReadonly.value,
)
const onClear = () => {
if (props.disabled || isReadonly.value) return
if (!isControlled.value) localValue.value = ''
if (fileInputRef.value) fileInputRef.value.value = ''
emit('update:modelValue', '')
emit('clear')
}
const openFilePicker = () => {
if (props.disabled || props.readonly) return
if (props.disabled) return
fileInputRef.value?.click()
}
@@ -250,11 +185,12 @@ const onFileChange = (event: Event) => {
}
}
const disabled = computed(() => props.disabled)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
@@ -1,74 +0,0 @@
import {describe, expect, it} from 'vitest'
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
describe('normalizeAmount', () => {
it('garde le point décimal', () => {
expect(normalizeAmount('12.5')).toBe('12.5')
})
it('convertit la virgule en point et nettoie', () => {
expect(normalizeAmount('0012,345abc')).toBe('12.34')
})
it('normalise une décimale en tête', () => {
expect(normalizeAmount(',5')).toBe('0.5')
})
it('retire les espaces', () => {
expect(normalizeAmount('1 234 567')).toBe('1234567')
})
it('limite à 2 décimales', () => {
expect(normalizeAmount('1234.567')).toBe('1234.56')
})
it('garde une décimale en cours de saisie', () => {
expect(normalizeAmount('12.')).toBe('12.')
})
it('renvoie une chaîne vide pour une saisie non numérique', () => {
expect(normalizeAmount('abc')).toBe('')
})
it('garde un zéro seul', () => {
expect(normalizeAmount('0')).toBe('0')
})
})
describe('formatGroupedAmount', () => {
it('groupe la partie entière par 3 avec des espaces', () => {
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
})
it('utilise la virgule comme séparateur décimal', () => {
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
})
it('affiche une virgule pour une décimale en cours', () => {
expect(formatGroupedAmount('12.')).toBe('12,')
})
it('gère les valeurs sous 1000 sans séparateur', () => {
expect(formatGroupedAmount('12')).toBe('12')
})
it('groupe avec une décimale en tête', () => {
expect(formatGroupedAmount('0.5')).toBe('0,5')
})
it('renvoie une chaîne vide pour une chaîne vide', () => {
expect(formatGroupedAmount('')).toBe('')
})
it('garde un zéro seul', () => {
expect(formatGroupedAmount('0')).toBe('0')
})
})
describe('countSignificant', () => {
it('compte les caractères hors espaces à gauche du curseur', () => {
expect(countSignificant('1 234', 5)).toBe(4)
})
it('ignore un espace juste avant le curseur', () => {
expect(countSignificant('1 234', 2)).toBe(1)
})
})
describe('caretFromSignificant', () => {
it('place le curseur après le n-ième caractère significatif', () => {
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
})
it('place le curseur en fin si on dépasse', () => {
expect(caretFromSignificant('1 234', 10)).toBe(5)
})
it('place le curseur au début pour 0 caractère significatif', () => {
expect(caretFromSignificant('1 234', 0)).toBe(0)
})
})
@@ -1,40 +0,0 @@
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
export const normalizeAmount = (value: string): string => {
const sanitizedValue = value
.replace(/\s+/g, '')
.replace(/,/g, '.')
.replace(/[^\d.]/g, '')
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
const decimalPart = decimalParts.join('').slice(0, 2)
if (sanitizedValue.includes('.')) {
return `${integerPart || '0'}.${decimalPart}`
}
return integerPart
}
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
export const formatGroupedAmount = (model: string): string => {
if (model === '') return ''
const hasDot = model.includes('.')
const [integerPart = '', decimalPart = ''] = model.split('.')
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
}
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
export const countSignificant = (str: string, upTo: number): number =>
str.slice(0, upTo).replace(/ /g, '').length
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
export const caretFromSignificant = (display: string, sig: number): number => {
if (sig <= 0) return 0
let seen = 0
for (let i = 0; i < display.length; i++) {
if (display[i] !== ' ') seen++
if (seen >= sig) return i + 1
}
return display.length
}
-320
View File
@@ -1,320 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import Modal from './Modal.vue'
type ModalProps = {
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}
const ModalForTest = Modal as DefineComponent<ModalProps>
function mountComponent(props: ModalProps = {}, slots?: Record<string, string>) {
return mount(ModalForTest, {
props,
slots,
global: { stubs: { Teleport: true } },
})
}
describe('MalioModal', () => {
enableAutoUnmount(afterEach)
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('renders the panel when modelValue is true', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
})
it('centers the modal (items-center justify-center)', () => {
const wrapper = mountComponent({ modelValue: true })
const root = wrapper.find('.fixed')
expect(root.classes()).toContain('items-center')
expect(root.classes()).toContain('justify-center')
})
it('renders default slot in the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu</p>' },
)
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
})
it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ modelValue: true, id: 'my-modal' })
expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal')
})
it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/)
})
it('has role="dialog" and aria-modal on the panel', () => {
const wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
})
it('applies modalClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, modalClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
})
it('renders the #header slot inside the header bar', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ header: '<h2 data-test="title">Titre</h2>' },
)
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
})
it('renders the header bar when showClose is true even without #header', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
})
it('does not render the header bar when no #header and showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
})
it('shows the close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides the close button when showClose is false', () => {
const wrapper = mountComponent(
{ modelValue: true, showClose: false },
{ header: '<h2>Titre</h2>' },
)
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:cancel-bold icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:cancel-bold')
})
it('close button has aria-label "Fermer"', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
})
it('emits update:modelValue false and close on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('sets aria-labelledby to the header id when #header is provided', () => {
const wrapper = mountComponent(
{ modelValue: true, id: 'test-modal' },
{ header: '<h2>Titre</h2>' },
)
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-modal-header')
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header')
})
it('sets aria-label from ariaLabel when no #header is provided', () => {
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-label')).toBe('Boîte de dialogue')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
})
it('applies headerClass to the header bar', () => {
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer when no #footer slot', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
})
it('applies bodyClass to the body', () => {
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
})
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on backdrop click when dismissable is false', async () => {
const wrapper = mountComponent({ modelValue: true, dismissable: false })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('applies overlayClass to the backdrop', () => {
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
})
it('closes on Escape key when closeOnEscape is true', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on Escape when closeOnEscape is false', async () => {
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(ModalForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
it('does not release body scroll-lock when one stacked modal closes while another is still open', async () => {
const wrapperA = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
const wrapperB = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapperA.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('hidden')
await wrapperA.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
})
-279
View File
@@ -1,279 +0,0 @@
<template>
<Teleport to="body">
<Transition
name="modal"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
v-bind="attrs"
>
<div
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop"
@click="onBackdropClick"
/>
<div
ref="panelRef"
:class="twMerge(
'relative z-50 flex max-h-[85vh] w-full max-w-md flex-col rounded-malio bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]',
modalClass,
)"
role="dialog"
aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1"
data-test="panel"
@keydown="onKeydown"
>
<div
v-if="hasHeader || showClose"
:class="twMerge('flex shrink-0 items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
data-test="header"
>
<div
:id="headerId"
class="min-w-0 flex-1"
data-test="header-content"
>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioModal', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
modalClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-modal-${generatedId}`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this modal holds a scroll-lock count slot.
let lockedByThisInstance = false
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
if (!lockedByThisInstance) {
lockedByThisInstance = true
openModalCount++
if (openModalCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {
if (props.dismissable) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function close() {
if (!isControlled.value) localValue.value = false
emit('update:modelValue', false)
emit('close')
}
</script>
<script lang="ts">
// Shared across all MalioModal instances: only the last open modal releases the body scroll-lock.
let openModalCount = 0
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95);
}
</style>
@@ -173,16 +173,6 @@ describe('MalioRadioButton', () => {
expect(wrapper.get('input').classes()).toContain('checked:border-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountRadioButton({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 = mountRadioButton({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('updates label color when toggled without v-model (uncontrolled)', async () => {
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
+1 -7
View File
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
</div>
@@ -46,7 +46,6 @@
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
@@ -179,11 +178,6 @@ const onChange = (event: Event) => {
opacity: 1;
}
.radio-control input[type='radio']:focus-visible {
outline: 2px solid rgb(var(--m-primary) / 1);
outline-offset: 2px;
}
.radio-control.is-error input[type='radio'] {
border-color: rgb(var(--m-danger) / 1);
}
-181
View File
@@ -21,9 +21,6 @@ type SelectProps = {
textLabel?: string
rounded?: string
disabled?: boolean
readonly?: boolean
required?: boolean
reserveMessageSpace?: boolean
}
const SelectForTest = Select as DefineComponent<SelectProps>
@@ -210,182 +207,4 @@ describe('MalioSelect', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
})
it('shows muted chevron color when empty and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when an option is selected and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('hides the chevron when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options, disabled: true},
})
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the selected value when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', label: 'Pays', options, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('button span.block').classes()).toContain('text-black/60')
})
it('shows danger chevron color on error even when open', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options, error: 'Selection error'},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
})
it('shows success chevron color on success', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options, success: 'OK'},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, 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 = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ'},
})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('expose aria-required quand required est vrai', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options, required: true},
})
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
})
it('n\'expose pas aria-required par défaut', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
})
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
const buttonClasses = wrapper.get('button').classes()
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// !border-b-transparent keeps the 1px allocation but hides the line
expect(buttonClasses).not.toContain('!border-b-0')
expect(buttonClasses).toContain('!border-b-transparent')
})
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
})
const trigger = wrapper.get('button')
expect(trigger.classes()).toContain('border-black')
expect(trigger.classes()).not.toContain('border-m-muted')
expect(trigger.classes()).not.toContain('grow-height')
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly sélectionné : label noir + chevron noir', () => {
const wrapper = mount(SelectForTest, {
props: {label: 'Champ', readonly: true, modelValue: 'a', options: [{label: 'A', value: 'a'}]},
})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('readonly empêche louverture du dropdown', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
})
await wrapper.get('button').trigger('click')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options},
})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBe('true')
expect(trigger.attributes('disabled')).toBeUndefined()
})
it('disabled + readonly : pas daria-readonly (disabled prime)', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBeUndefined()
expect(trigger.attributes('disabled')).toBeDefined()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options}})
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 = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false}})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+34 -141
View File
@@ -8,53 +8,38 @@
:id="buttonId"
ref="buttonRef"
type="button"
class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none"
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-m-primary"
:class="[
isReadonly ? '' : 'grow-height',
isReadonly ? '' : 'focus-visible:border-m-primary',
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
? 'rounded-b-none !border !border-m-danger !border-b-0'
: 'rounded-t-none !border !border-m-danger !border-t-0'
: 'border-m-danger'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-transparent'
? 'rounded-b-none !border !border-m-success !border-b-0'
: 'rounded-t-none !border !border-m-success !border-t-0'
: 'border-m-success'
: isReadonly
? 'border-black'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-0'
: 'rounded-t-none !border !border-m-primary !border-t-0'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded,
textField,
keyboardFocused
? (isOpen
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
: 'm-focus-ring-kbd')
: '',
]"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:aria-required="required || undefined"
:aria-readonly="isReadonly || undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
@focus="onKbdFocus"
@blur="onKbdBlur"
>
<label
v-if="label"
@@ -65,54 +50,36 @@
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel,
]"
:style="labelTransformStyle"
>
{{ label }}<MalioRequiredMark v-if="required" />
{{ label }}
</label>
<span
class="block truncate"
:class="[
textValue,
isOptionSelected ? (disabled ? 'text-black/60' : 'text-black') : 'select-none text-transparent'
isOptionSelected ? 'text-black' : 'select-none text-transparent'
]"
>
{{ selectedLabel || '\u00A0' }}
</span>
<span
v-if="!disabled"
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
: 'text-current'
]"
>
<slot name="icon">
@@ -146,10 +113,7 @@
? 'border-m-danger'
: hasSuccess
? 'border-m-success'
: 'border-m-primary',
keyboardFocused
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
: '',
: 'border-m-primary'
]"
>
<li
@@ -181,7 +145,7 @@
</ul>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`"
:class="[
hasError
@@ -190,7 +154,6 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
@@ -199,16 +162,12 @@
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
defineOptions({name: 'MalioSelect', inheritAttrs: false})
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
type Option = {
label: string;
value: string | number | null
@@ -224,14 +183,10 @@ const props = withDefaults(defineProps<{
textField?: string
textValue?: string
textLabel?: string
fieldClass?: string
rounded?: string
disabled?: boolean
readonly?: boolean
groupClass?: string
noOptionsText?: string
required?: boolean
reserveMessageSpace?: boolean
}>(), {
options: () => [],
emptyOptionLabel: '',
@@ -242,14 +197,10 @@ const props = withDefaults(defineProps<{
textField: 'text-lg',
textValue: 'text-lg',
textLabel: 'text-sm',
fieldClass: '',
rounded: 'rounded-md',
disabled: false,
readonly: false,
groupClass: '',
noOptionsText: 'Aucune option disponible',
required: false,
reserveMessageSpace: true,
})
const emit = defineEmits<{
@@ -277,9 +228,8 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() =>
props.options.some(o => o.value === props.modelValue)
)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value ? isOptionSelected.value : (isOpen.value || isOptionSelected.value)
isOpen.value || isOptionSelected.value
)
const selectedLabel = computed(() =>
props.options.find(o => o.value === props.modelValue)?.label ?? ''
@@ -307,7 +257,6 @@ function updateOpenDirection() {
}
function open() {
if (props.disabled || props.readonly) return
updateOpenDirection()
isOpen.value = true
@@ -351,7 +300,7 @@ function close() {
}
function toggle() {
if (props.disabled || props.readonly) return
if (props.disabled) return
if (isOpen.value) {
close()
return
@@ -362,68 +311,7 @@ function toggle() {
function select(value: string | number | null) {
emit('update:modelValue', value)
close()
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
// les options ; au clavier le focus n'a jamais quitté le bouton.
buttonRef.value?.focus()
}
// Garde l'option active visible quand on navigue au clavier
watch(activeIndex, async (index) => {
if (index < 0 || !isOpen.value) return
await nextTick()
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
})
function onKeydown(e: KeyboardEvent) {
if (props.disabled || props.readonly) return
// Tab : laisse le focus partir mais ferme la liste
if (e.key === 'Tab') {
if (isOpen.value) close()
return
}
// Liste fermée : ouverture au clavier
if (!isOpen.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
open()
}
return
}
// Liste ouverte
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const opt = normalizedOptions.value[activeIndex.value]
if (opt) select(opt.value)
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, 0)
return
}
if (e.key === 'Home') {
e.preventDefault()
activeIndex.value = 0
return
}
if (e.key === 'End') {
e.preventDefault()
activeIndex.value = normalizedOptions.value.length - 1
}
buttonRef.value?.blur()
}
function onClickOutside(e: MouseEvent) {
@@ -442,7 +330,12 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease;
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
@media (prefers-reduced-motion: reduce) {
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'
import {mount, renderToString} from '@vue/test-utils'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import SelectCheckbox from './SelectCheckbox.vue'
@@ -9,7 +9,7 @@ type Option = {
}
type SelectCheckboxProps = {
modelValue?: Array<string | number>
modelValue: Array<string | number>
options?: Option[]
emptyOptionLabel?: string
label?: string
@@ -24,10 +24,7 @@ type SelectCheckboxProps = {
displaySelectAll?: boolean
selectAllLabel?: string
disabled?: boolean
readonly?: boolean
groupClass?: string
required?: boolean
reserveMessageSpace?: boolean
}
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
@@ -39,18 +36,6 @@ const options: Option[] = [
]
describe('MalioSelectCheckbox', () => {
it('rend sans planter quand modelValue nest pas fourni (non contrôlé)', () => {
expect(() =>
mount(SelectCheckboxForTest, {props: {label: 'Catégories', options}}),
).not.toThrow()
})
it('rend en SSR sans planter quand modelValue est absent (cause du crash playground)', async () => {
await expect(
renderToString(SelectCheckboxForTest, {props: {label: 'Catégories', readonly: true, options}}),
).resolves.toBeTruthy()
})
it('renders checkbox inputs for options', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
@@ -68,9 +53,8 @@ describe('MalioSelectCheckbox', () => {
})
await wrapper.get('button').trigger('click')
// Le toggle se fait au clic sur la ligne d'option (la checkbox est en pointer-events-none).
const optionRows = wrapper.findAll('li[role="option"]')
await optionRows[1].trigger('click')
const checkboxInputs = wrapper.findAll('input[type="checkbox"]')
await checkboxInputs[1].setValue(true)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be']])
})
@@ -150,9 +134,8 @@ describe('MalioSelectCheckbox', () => {
await wrapper.get('button').trigger('click')
// La ligne « tout sélectionner » est la première option de la liste.
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
await selectAllRow.trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(true)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['fr', 'be', 'ca']])
})
@@ -164,9 +147,8 @@ describe('MalioSelectCheckbox', () => {
await wrapper.get('button').trigger('click')
// La ligne « tout sélectionner » est la première option de la liste.
const selectAllRow = wrapper.findAll('li[role="option"]')[0]
await selectAllRow.trigger('click')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(false)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[]])
})
@@ -200,184 +182,4 @@ describe('MalioSelectCheckbox', () => {
const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('mt-4')
})
it('shows muted chevron color when nothing is selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when options are selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('hides the chevron when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, disabled: true},
})
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the tags when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], label: 'Pays', options, displayTag: true, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
const tag = wrapper.findAll('span.inline-flex')[0]
expect(tag.classes()).toContain('text-black/60')
expect(tag.classes()).not.toContain('text-black')
})
it('shows danger chevron color on error even when open', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, error: 'Selection error'},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-danger')
})
it('shows success chevron color on success', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, success: 'OK'},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-success')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], 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 = mount(SelectCheckboxForTest, {
props: {modelValue: [], label: 'Champ'},
})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('expose aria-required quand required est vrai', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options, required: true},
})
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
})
it('n\'expose pas aria-required par défaut', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
})
it('keeps the bottom border allocation when open downward (transparent, not zero)', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
const buttonClasses = wrapper.get('button').classes()
// !border-b-0 would shrink the bottom border to 0px and grow content area by 1px;
// !border-b-transparent keeps the 1px allocation but hides the line
expect(buttonClasses).not.toContain('!border-b-0')
expect(buttonClasses).toContain('!border-b-transparent')
})
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
})
const trigger = wrapper.get('button')
expect(trigger.classes()).toContain('border-black')
expect(trigger.classes()).not.toContain('border-m-muted')
expect(trigger.classes()).not.toContain('grow-height')
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly sélectionné : label noir + chevron noir', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: ['a'], options: [{label: 'A', value: 'a'}]},
})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('readonly empêche louverture du dropdown', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
})
await wrapper.get('button').trigger('click')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options},
})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBe('true')
expect(trigger.attributes('disabled')).toBeUndefined()
})
it('disabled + readonly : pas daria-readonly (disabled prime)', () => {
const wrapper = mount(SelectCheckboxForTest, {props: {modelValue: [], label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBeUndefined()
expect(trigger.attributes('disabled')).toBeDefined()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options}})
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 = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false}})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})

Some files were not shown because too many files have changed in this diff Show More