Compare commits

...

96 Commits

Author SHA1 Message Date
tristan cc03559dcf feat(ui) : astérisque required à 16px
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:37:15 +02:00
tristan 6b1e11bd6f test(ui) : vérifie aria-required sur Select/SelectCheckbox/RichText
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:12:09 +02:00
tristan 4f5eaaacb9 docs : documentation required cohérente sur toute la famille formulaire
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:11:06 +02:00
tristan 2d8639a913 docs(playground) : exemples required + email lowercase
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:03:15 +02:00
tristan 3e09f4278e docs : required/astérisque + lowercase email (COMPONENTS + CHANGELOG)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:00:38 +02:00
tristan 4e2303c471 test(inputs) : tests mode contrôlé email + commentaire caret jsdom
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:58:06 +02:00
tristan 6081f0c90c feat(inputs) : sanitisation email (suppression des espaces + option lowercase)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:54:13 +02:00
tristan 120020b210 feat(ui): astérisque required dans le label de la famille formulaire
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:45:48 +02:00
tristan 61cb90a9c6 fix(ui): aria-required sur champ visible InputUpload + ordre import RichText
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:38:25 +02:00
tristan 167cc43870 feat(ui) : prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:32:10 +02:00
tristan 03fe458248 feat(ui) : composant partagé MalioRequiredMark (astérisque champ obligatoire)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:26:04 +02:00
tristan df289aa829 docs: plan d'implémentation MUI-41 + précisions spec (caret email, exclusion SiteSelector)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:23:31 +02:00
tristan 05949b727e docs: spec required asterisk + email sanitization (MUI-41)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:16:07 +02:00
tristan aedfaa865d Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
2026-06-01 09:30:53 +02:00
tristan 39eb6e6068 feat(ui): token w-m-btn-action partagé + fix alignement pagination DataTable
- Nouveau token de largeur partagé `w-m-btn-action` (150px) exposé via
  tailwind.config.ts + CSS var `--m-btn-action-width` dans malio.css.
  Themable côté consommateur en redéfinissant la CSS var dans son :root.
- DataTable : pagination réalignée verticalement après l'introduction du
  `min-h-[1rem]` sur MalioSelect — la barre passe en `items-center` et le
  MalioSelect du sélecteur perPage est encapsulé dans un wrapper `h-12`
  qui borne sa taille flex à la hauteur du field. Span « Lignes : » et
  boutons Prev/Page/Next désormais centrés exactement sur le field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 09:17:58 +02:00
tristan 1d66e5dd31 fix: plusieurs retours UX/UI (#58)
Release / release (push) Successful in 1m11s
| 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é

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #58
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 13:53:52 +00:00
tristan ce9b4853e6 Merge branch 'main' into develop 2026-05-29 15:52:31 +02:00
tristan dc33cf4135 feat(inputs): UX polish across input family + localFilter + focus scrollbar
Polish across the form input components, plus two new features and a few
standalone fixes.

Fixes
-----
* Reserve hint/error/success paragraph space (min-h-[1rem]) in 15
  components so a single error message no longer shifts neighboring grid
  cells: InputText, Email, Password, Phone, Amount, Number, Upload,
  Autocomplete, RichText, TextArea, Select, SelectCheckbox, Time,
  TimePicker, CalendarField, Checkbox.
* InputPhone: the '+' add button now follows the icon-state cascade
  (muted / primary on focus / black when filled / danger / success) like
  the other field icons instead of being permanently primary.
* Select and SelectCheckbox: chevron color follows the field state
  (muted by default, primary when open, black when an option is
  selected, danger / success on error / success) instead of always being
  text-current.
* InputTextArea: single-root component (was multi-root). The message
  wrapper used to occupy its own grid cell, breaking row-span layouts.
  Now flex flex-col, with the textarea area filling the available height
  via flex-1 and the message inside the same root.
* Disabled labels use text-m-muted (border-gray) instead of text-black/60
  (dark) across InputText, Email, Password, Amount, Phone, Upload,
  Autocomplete, TextArea, RichText. Also removes an unreachable
  peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60 rule that
  twMerge was silently overriding with text-black.
* InputAutocomplete: eliminates four sources of visual jitter when
  focusing / opening a field that already has a selected value.
  - Drop peer-focus:-translate-y-[1.55rem] extra label translate.
  - Drop the .grow-height:focus padding rule (no more height growth or
    downward text shift on focus).
  - Drop focus:pl-[11px] (no more 1px horizontal jump).
  - Replace !border-b-0 with !border-b-transparent so the bottom border
    still reserves its 1px while remaining invisible against the
    dropdown.
* Select / SelectCheckbox: same anti-jitter treatment.
  - Drop .grow-height:focus padding rule (~12px height growth gone).
  - Replace !border-b-0 / !border-t-0 with !border-b-transparent /
    !border-t-transparent across danger / success / primary branches.
* Button: default width 240px -> 200px to match the form button sizing
  used across the app. Test updated to match.

Features
--------
* InputTextArea: scrollbar turns primary blue on focus
  (scrollbar-color: rgb(var(--m-primary)) transparent), matching the
  Select listbox styling.
* InputAutocomplete: new localFilter prop (default false). When enabled,
  filters the options prop client-side based on the input value
  (case-insensitive label.includes(query)), so static lists no longer
  need a @search listener. Async/API usage keeps the existing behavior.
  Playground "Simple statique" and "Avec icône à gauche" examples use
  local-filter.

Playground
----------
* client.vue: tighter grid gap (gap-y-5) plus an example error on a
  SelectCheckbox to visually exercise the message-space fix.

Tests
-----
All component test files include regression coverage for the above.
720/720 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:43:53 +02:00
tristan 526dcd1a84 Merge branch 'main' into develop
# Conflicts:
#	.playground/pages/composant/filtre/filtres.vue
2026-05-27 14:53:05 +02:00
tristan 280b650e49 fix: rendre le footer du Drawer hors zone scrollable (épinglé en bas)
Le slot #footer était rendu à l'intérieur du body overflow-y-auto, ce qui
faisait courir la scrollbar sur toute la hauteur, derrière le footer. Il est
désormais frère du body (comme MalioModal) : seul le body défile et le footer
reste fixé en bas. Tests, story, pages playground et doc alignés.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:50:55 +02:00
tristan 951acd448e fix : component.md 2026-05-27 14:09:56 +02:00
tristan 90b81975e3 Merge branch 'main' into develop
# Conflicts:
#	.claude/settings.local.json
#	.playground/playground.nav.ts
#	CHANGELOG.md
#	COMPONENTS.md
#	app/components/malio/date/DateTime.test.ts
#	app/components/malio/date/DateTime.vue
2026-05-27 14:02:27 +02:00
tristan e6a46a9d60 [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire) (#55)
| 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: #55
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:01:29 +00:00
tristan 6efb830ffe [#MUI-37] Création d'un composant accordéon (#54)
| 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: #54
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 07:12:10 +00:00
tristan 7b838c60ca [#MUI-36] Création d'un composant modal (#53)
| 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: #53
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-26 07:36:13 +00:00
tristan 9551816bf8 Merge branch 'main' into develop
# Conflicts:
#	.playground/playground.nav.ts
#	CHANGELOG.md
2026-05-22 09:59:06 +02:00
tristan 7ac097e7f0 [#MUI-33] Développer le composant Datepicker (#50)
| 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: #50
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 07:56:07 +00:00
tristan bc813190c6 Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	CHANGELOG.md
2026-05-22 09:03:49 +02:00
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
51 changed files with 1604 additions and 218 deletions
@@ -55,7 +55,7 @@
<MalioButton <MalioButton
label="Réinitialiser" label="Réinitialiser"
variant="tertiary" variant="tertiary"
button-class="w-[150px]" button-class="w-m-btn-action"
@click="resetFiltres" @click="resetFiltres"
/> />
<MalioButton <MalioButton
+4 -3
View File
@@ -10,7 +10,7 @@
/> />
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1> <h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
</div> </div>
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-8"> <div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
label="Nom du client (Entreprise)" label="Nom du client (Entreprise)"
/> />
@@ -22,6 +22,7 @@
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-model="multiselectValue" v-model="multiselectValue"
error="test"
label="Catégorie" label="Catégorie"
:options="[ :options="[
{label: 'Catégorie 1', value: 'Catégorie 1'}, {label: 'Catégorie 1', value: 'Catégorie 1'},
@@ -75,7 +76,7 @@
<div class="mt-[60px]"> <div class="mt-[60px]">
<MalioTabList :tabs="tabs" v-model="tabsValue"> <MalioTabList :tabs="tabs" v-model="tabsValue">
<template #information> <template #information>
<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]"> <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]">
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/> <MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
<MalioInputText v-model="concurrent" label="Concurrent"/> <MalioInputText v-model="concurrent" label="Concurrent"/>
<MalioDate <MalioDate
@@ -92,7 +93,7 @@
</div> </div>
</template> </template>
<template #adresses> <template #adresses>
<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]"> <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]">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:delete-outline" icon="mdi:delete-outline"
aria-label="Supprimer l'adresse" aria-label="Supprimer l'adresse"
@@ -6,6 +6,7 @@
v-model="simpleValue" v-model="simpleValue"
label="Pays" label="Pays"
:options="staticOptions" :options="staticOptions"
local-filter
/> />
<p class="mt-2 text-sm text-m-muted"> <p class="mt-2 text-sm text-m-muted">
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code> Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
@@ -20,6 +21,7 @@
icon-name="mdi:magnify" icon-name="mdi:magnify"
icon-position="left" icon-position="left"
:options="staticOptions" :options="staticOptions"
local-filter
/> />
</div> </div>
@@ -84,6 +84,24 @@
:success="dynamicSuccess" :success="dynamicSuccess"
/> />
</div> </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> </div>
</template> </template>
@@ -92,6 +110,8 @@ import { computed, ref } from 'vue'
const emailValue = ref('') const emailValue = ref('')
const dynamicEmail = ref('') const dynamicEmail = ref('')
const requiredEmail = ref('')
const lowercaseEmail = ref('')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value)) const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
@@ -108,6 +108,14 @@
icon-size="20" icon-size="20"
/> />
</div> </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"> <div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec masque</h2> <h2 class="mb-4 text-xl font-bold">Avec masque</h2>
<MalioInputText <MalioInputText
@@ -82,6 +82,17 @@
/> />
</div> </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"> <div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2> <h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
<MalioSelect <MalioSelect
@@ -151,6 +162,7 @@ const longOptions = [
{label: 'Republique tcheque', value: 'cz'}, {label: 'Republique tcheque', value: 'cz'},
] ]
const requiredValue = ref<string | number | null>(null)
const basicValue = ref<string | number | null>(null) const basicValue = ref<string | number | null>(null)
const labelValue = ref<string | number | null>(null) const labelValue = ref<string | number | null>(null)
const selectedValue = ref<string | number | null>('fr') const selectedValue = ref<string | number | null>('fr')
+14
View File
@@ -36,11 +36,25 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe) * [#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-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) * [#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`)
### Changed ### Changed
* [#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-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`.
### Fixed ### Fixed
* 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 * 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) * 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 * 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
+32 -12
View File
@@ -2,6 +2,8 @@
Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-model` pour le binding bidirectionnel sur les composants de formulaire. 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`.
--- ---
## MalioInputText ## MalioInputText
@@ -15,7 +17,7 @@ Champ texte avec label, icône optionnelle et support de masque de saisie.
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) | | `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
| `disabled` | `boolean` | `false` | Désactive le champ | | `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide | | `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
@@ -53,6 +55,7 @@ Champ mot de passe avec toggle visibilité.
| `displayIcon` | `boolean` | `true` | Afficher l'icône toggle | | `displayIcon` | `boolean` | `true` | Afficher l'icône toggle |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide | | `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
@@ -79,7 +82,8 @@ 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) | | `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
| `disabled` | `boolean` | `false` | Désactive le champ | | `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
| `hint` | `string` | `''` | Message d'aide | | `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
@@ -91,6 +95,8 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
| `labelClass` | `string` | `''` | Classes CSS label | | `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur | | `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)` **Events :** `update:modelValue(value: string)`
```vue ```vue
@@ -115,7 +121,7 @@ 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é) | | `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + | | `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) | | `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
| `required` | `boolean` | `false` | Champ requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide | | `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
@@ -146,7 +152,7 @@ Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outl
## MalioInputAutocomplete ## 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). 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. 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`.
| Prop | Type | Défaut | Description | | Prop | Type | Défaut | Description |
|------|------|--------|-------------| |------|------|--------|-------------|
@@ -159,6 +165,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` | | `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `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`) | | `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 | | `iconName` | `string` | `''` | Icône Iconify décorative |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative | | `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
| `iconSize` | `string \| number` | `24` | Taille de l'icône | | `iconSize` | `string \| number` | `24` | Taille de l'icône |
@@ -168,7 +175,7 @@ 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 | | `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 | | `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) | | `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
| `required` | `boolean` | `false` | Champ requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide | | `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur (prioritaire) | | `error` | `string` | `''` | Message d'erreur (prioritaire) |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
@@ -185,8 +192,8 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown. **Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
```vue ```vue
<!-- Usage statique --> <!-- Usage statique (filtrage côté client via local-filter) -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" /> <MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
<!-- Usage API (parent gère le fetch) --> <!-- Usage API (parent gère le fetch) -->
<MalioInputAutocomplete <MalioInputAutocomplete
@@ -230,6 +237,7 @@ Champ montant avec icône devise (euro par défaut).
| `label` | `string` | `''` | Label | | `label` | `string` | `''` | Label |
| `iconName` | `string` | `'mdi:currency-eur'` | Icône devise | | `iconName` | `string` | `'mdi:currency-eur'` | Icône devise |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)` **Events :** `update:modelValue(value: string)`
@@ -252,6 +260,7 @@ Champ numérique avec boutons +/-.
| `min` | `number \| string` | — | Valeur minimum | | `min` | `number \| string` | — | Valeur minimum |
| `max` | `number \| string` | — | Valeur maximum | | `max` | `number \| string` | — | Valeur maximum |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)` **Events :** `update:modelValue(value: string)`
@@ -275,6 +284,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
| `maxLength` | `number` | `800` | Longueur max | | `maxLength` | `number` | `800` | Longueur max |
| `showCounter` | `boolean` | `false` | Afficher le compteur | | `showCounter` | `boolean` | `false` | Afficher le compteur |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) | | `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
@@ -303,6 +313,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) | | `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar | | `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
| `readonly` | `boolean` | `false` | Lecture seule (toolbar visible mais désactivée) | | `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 | | `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
@@ -333,6 +344,7 @@ Champ d'upload de fichier.
| `accept` | `string` | `''` | Types de fichiers acceptés | | `accept` | `string` | `''` | Types de fichiers acceptés |
| `displayIcon` | `boolean` | `true` | Afficher l'icône | | `displayIcon` | `boolean` | `true` | Afficher l'icône |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)` **Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
@@ -357,6 +369,7 @@ Liste déroulante.
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) | | `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
| `rounded` | `string` | `'rounded-md'` | Classe border-radius | | `rounded` | `string` | `'rounded-md'` | Classe border-radius |
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton | | `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
@@ -388,6 +401,7 @@ Liste déroulante multi-sélection avec checkboxes.
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global | | `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
| `label` | `string` | `''` | Label | | `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide | | `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
**Events :** `update:modelValue(value: (string | number)[])` **Events :** `update:modelValue(value: (string | number)[])`
@@ -409,6 +423,7 @@ Case à cocher.
| `label` | `string` | `''` | Label | | `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: boolean)` **Events :** `update:modelValue(value: boolean)`
@@ -432,6 +447,7 @@ Bouton radio (à utiliser en groupe avec le même `name`).
| `name` | `string` | `''` | Nom du groupe radio | | `name` | `string` | `''` | Nom du groupe radio |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
**Events :** `update:modelValue(value: string | number | boolean | null)` **Events :** `update:modelValue(value: string | number | boolean | null)`
@@ -455,7 +471,7 @@ La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et f
| `name` | `string` | `''` | Attribut name | | `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant | | `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder | | `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide | | `hint` | `string` | `''` | Texte d'aide |
@@ -489,7 +505,7 @@ La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"
| `name` | `string` | `''` | Attribut name | | `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant | | `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder | | `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide | | `hint` | `string` | `''` | Texte d'aide |
@@ -522,7 +538,7 @@ La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2
| `name` | `string` | `''` | Attribut name | | `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant | | `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder | | `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide | | `hint` | `string` | `''` | Texte d'aide |
@@ -552,6 +568,7 @@ Sélecteur d'heure.
| `label` | `string` | `''` | Label | | `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur | | `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)` **Events :** `update:modelValue(value: string)`
@@ -574,7 +591,7 @@ Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes in
| `label` | `string` | `''` | Label flottant | | `label` | `string` | `''` | Label flottant |
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) | | `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
| `placeholder` | `string` | `'HH:MM'` | Placeholder | | `placeholder` | `string` | `'HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactive le champ | | `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement | | `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
@@ -607,7 +624,7 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
| `name` | `string` | `''` | Attribut name | | `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant | | `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder | | `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Requis | | `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé | | `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule | | `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide | | `hint` | `string` | `''` | Texte d'aide |
@@ -652,8 +669,11 @@ Bouton d'action avec 4 variantes visuelles et icône optionnelle.
<MalioButton label="Voir plus" variant="tertiary" /> <MalioButton label="Voir plus" variant="tertiary" />
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" /> <MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
<MalioButton label="Pleine largeur" button-class="w-full" /> <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 ## MalioButtonIcon
+3
View File
@@ -31,6 +31,9 @@
--m-btn-danger-hover: 234 151 151; /* #EA9797 */ --m-btn-danger-hover: 234 151 151; /* #EA9797 */
--m-btn-danger-active: 255 83 86; /* #FF5356 */ --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) ── */ /* ── Couleurs de site (usage ponctuel) ── */
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */ --m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */ --m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
+1 -1
View File
@@ -162,7 +162,7 @@ describe('MalioButton', () => {
it('applies correct dimensions', () => { it('applies correct dimensions', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
expect(wrapper.get('button').classes()).toContain('w-[240px]') expect(wrapper.get('button').classes()).toContain('w-[200px]')
expect(wrapper.get('button').classes()).toContain('h-[40px]') expect(wrapper.get('button').classes()).toContain('h-[40px]')
}) })
+1 -1
View File
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
const mergedButtonClass = computed(() => const mergedButtonClass = computed(() =>
twMerge( twMerge(
'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', 'inline-flex w-[200px] 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, variantClasses.value,
props.buttonClass, props.buttonClass,
), ),
@@ -161,4 +161,14 @@ describe('MalioCheckbox', () => {
expect(wrapper.get('label').classes()).toContain('text-black') 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)
})
}) })
+3 -3
View File
@@ -25,12 +25,11 @@
</svg> </svg>
</span> </span>
<span> <span>
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</span> </span>
</label> </label>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="mergedMessageClass" :class="mergedMessageClass"
> >
@@ -42,6 +41,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioCheckbox', inheritAttrs: false}) defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
@@ -121,7 +121,7 @@ const mergedLabelClass = computed(() =>
const mergedMessageClass = computed(() => const mergedMessageClass = computed(() =>
twMerge( twMerge(
'text-xs', 'text-xs min-h-[1rem]',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
+17 -15
View File
@@ -57,25 +57,27 @@
<div <div
v-if="totalItems > 0" v-if="totalItems > 0"
class="flex justify-between pt-2" class="flex items-center justify-between pt-2"
data-test="pagination" data-test="pagination"
> >
<div class="flex gap-4"> <div class="flex items-center gap-4">
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span> <span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
<MalioSelect <div class="h-12">
:model-value="perPage" <MalioSelect
:options="perPageSelectOptions" :model-value="perPage"
min-width="w-20 !mt-0" :options="perPageSelectOptions"
rounded="rounded" group-class="w-20"
text-field="text-sm" rounded="rounded"
text-value="text-sm" text-field="text-sm"
text-label="text-xs" text-value="text-sm"
data-test="per-page-select" text-label="text-xs"
@update:model-value="onPerPageChange" data-test="per-page-select"
/> @update:model-value="onPerPageChange"
/>
</div>
</div> </div>
<nav aria-label="Pagination" class="flex gap-1" data-test="pagination-nav"> <nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
label="Prev" label="Prev"
+10
View File
@@ -40,6 +40,16 @@ describe('MalioDate', () => {
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) 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', () => { it('displays the formatted value in the field', () => {
const wrapper = mountDate({modelValue: '2026-05-19'}) const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
@@ -29,7 +29,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1"> <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
@@ -85,11 +85,10 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -101,6 +100,7 @@
import {computed, ref, useAttrs, useId, watch} from 'vue' import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue' import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../../shared/RequiredMark.vue'
import CalendarHeader from './CalendarHeader.vue' import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue' import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover' import {useCalendarPopover} from '../composables/useCalendarPopover'
+26
View File
@@ -53,6 +53,16 @@ describe('MalioInputText', () => {
expect(wrapper.get('label').text()).toBe('labelTest') 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', () => { it('applies the name attribute', () => {
const wrapper = mountInput({name: 'nameTest'}) const wrapper = mountInput({name: 'nameTest'})
@@ -126,6 +136,13 @@ describe('MalioInputText', () => {
expect(wrapper.get('input').classes()).toContain('text-black/60') 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 () => { it('emits update:modelValue on input change', async () => {
const wrapper = mountInput({modelValue: ''}) const wrapper = mountInput({modelValue: ''})
@@ -253,6 +270,15 @@ describe('MalioInputText', () => {
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test') 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('does not render label when label prop is missing', () => { it('does not render label when label prop is missing', () => {
const wrapper = mountInput({labelClass: 'text-red-500'}) const wrapper = mountInput({labelClass: 'text-red-500'})
@@ -174,4 +174,14 @@ describe('MalioInputAmount', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black') 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)
})
}) })
+7 -6
View File
@@ -30,7 +30,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<IconifyIcon <IconifyIcon
@@ -44,7 +44,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -52,7 +51,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -64,6 +63,7 @@
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputAmount', inheritAttrs: false}) defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
@@ -109,7 +109,7 @@ const props = withDefaults(
hint: '', hint: '',
error: '', error: '',
success: '', success: '',
iconSize: 24, iconSize: 20,
iconColor: 'text-m-muted', iconColor: 'text-m-muted',
}, },
) )
@@ -153,12 +153,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', 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 hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -28,6 +28,7 @@ type InputAutocompleteProps = {
debounce?: number debounce?: number
minSearchLength?: number minSearchLength?: number
allowCreate?: boolean allowCreate?: boolean
localFilter?: boolean
iconName?: string iconName?: string
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
iconSize?: string | number iconSize?: string | number
@@ -64,6 +65,16 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('label').text()).toBe('Pays') 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', () => { it('renders with type combobox role', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
@@ -427,4 +438,82 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('input').element.value).toBe('Custom') 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')
})
}) })
@@ -33,7 +33,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<IconifyIcon <IconifyIcon
@@ -107,7 +107,7 @@
{{ minSearchText }} {{ minSearchText }}
</li> </li>
<li <li
v-else-if="options.length === 0" v-else-if="filteredOptions.length === 0"
class="px-3 py-2 text-m-muted" class="px-3 py-2 text-m-muted"
data-test="no-results-text" data-test="no-results-text"
> >
@@ -115,7 +115,7 @@
</li> </li>
<template v-else> <template v-else>
<li <li
v-for="(opt, index) in options" v-for="(opt, index) in filteredOptions"
:id="optionId(index)" :id="optionId(index)"
:key="String(opt.value)" :key="String(opt.value)"
data-test="option" data-test="option"
@@ -136,11 +136,10 @@
</ul> </ul>
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -152,6 +151,7 @@
import {computed, 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 {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false}) defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
@@ -180,6 +180,7 @@ const props = withDefaults(
debounce?: number debounce?: number
minSearchLength?: number minSearchLength?: number
allowCreate?: boolean allowCreate?: boolean
localFilter?: boolean
iconName?: string iconName?: string
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
iconSize?: string | number iconSize?: string | number
@@ -207,6 +208,7 @@ const props = withDefaults(
debounce: 300, debounce: 300,
minSearchLength: 0, minSearchLength: 0,
allowCreate: false, allowCreate: false,
localFilter: false,
iconName: '', iconName: '',
iconPosition: 'left', iconPosition: 'left',
iconSize: 24, iconSize: 24,
@@ -253,9 +255,18 @@ const showMinSearch = computed(() =>
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength, 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 optionId = (index: number) => `${inputId.value}-option-${index}`
const activeOptionId = computed(() => const activeOptionId = computed(() =>
activeIndex.value >= 0 && props.options[activeIndex.value] activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
? optionId(activeIndex.value) ? optionId(activeIndex.value)
: undefined, : undefined,
) )
@@ -294,11 +305,6 @@ const iconInputPaddingClass = computed(() => {
return parts.join(' ') return parts.join(' ')
}) })
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const labelPositionClass = computed(() => const labelPositionClass = computed(() =>
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3', props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
) )
@@ -315,10 +321,9 @@ const mergedInputClass = computed(() =>
: hasSuccess.value : hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success' ? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary', : 'focus:border-m-primary',
isOpen.value ? '!rounded-b-none !border-b-0' : '', isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
props.inputClass, props.inputClass,
iconInputPaddingClass.value, iconInputPaddingClass.value,
focusPaddingClass.value,
), ),
) )
@@ -326,13 +331,14 @@ const mergedLabelClass = computed(() =>
twMerge( twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
props.disabled ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
hasError.value hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : props.disabled
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -432,8 +438,8 @@ const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault() event.preventDefault()
if (activeIndex.value >= 0 && props.options[activeIndex.value]) { if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
onSelect(props.options[activeIndex.value]) onSelect(filteredOptions.value[activeIndex.value])
return return
} }
if (props.allowCreate && inputValue.value !== '') { if (props.allowCreate && inputValue.value !== '') {
@@ -450,7 +456,7 @@ const onKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) { if (!isOpen.value) {
isOpen.value = true isOpen.value = true
} }
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1) activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
return return
} }
@@ -481,12 +487,7 @@ onBeforeUnmount(() => {
} }
.grow-height { .grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; transition: border-color 160ms ease, box-shadow 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -23,6 +23,7 @@ type InputEmailProps = {
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
iconSize?: string | number iconSize?: string | number
iconColor?: string iconColor?: string
lowercase?: boolean
} }
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps> const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
@@ -52,6 +53,16 @@ describe('MalioInputEmail', () => {
expect(wrapper.get('label').text()).toBe('Adresse email') 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', () => { it('has type email', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
@@ -225,4 +236,42 @@ describe('MalioInputEmail', () => {
expect(wrapper.get('input').attributes('autocomplete')).toBe('email') 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')
})
}) })
+36 -8
View File
@@ -28,7 +28,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<IconifyIcon <IconifyIcon
@@ -42,7 +42,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -50,7 +49,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -63,6 +62,7 @@
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputEmail', inheritAttrs: false}) defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
@@ -86,6 +86,7 @@ const props = withDefaults(
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
iconSize?: string | number iconSize?: string | number
iconColor?: string iconColor?: string
lowercase?: boolean
}>(), }>(),
{ {
id: '', id: '',
@@ -106,6 +107,7 @@ const props = withDefaults(
success: '', success: '',
iconSize: 24, iconSize: 24,
iconColor: 'text-m-muted', iconColor: 'text-m-muted',
lowercase: false,
}, },
) )
@@ -147,12 +149,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', 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 hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -169,12 +172,37 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: string): void (event: 'update:modelValue', value: string): void
}>() }>()
const sanitizeEmail = (v: string) => {
let out = v.replace(/\s+/g, '')
if (props.lowercase) out = out.toLowerCase()
return out
}
const onInput = (event: Event) => { const onInput = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
if (!isControlled.value) { const raw = target.value
localValue.value = 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 */
}
}
} }
emit('update:modelValue', target.value)
if (!isControlled.value) {
localValue.value = sanitized
}
emit('update:modelValue', sanitized)
} }
const iconInputPaddingClass = computed(() => { const iconInputPaddingClass = computed(() => {
@@ -6,6 +6,7 @@ import InputNumber from './InputNumber.vue'
type InputNumberProps = { type InputNumberProps = {
modelValue?: string | null modelValue?: string | null
label?: string label?: string
required?: boolean
readonly?: boolean readonly?: boolean
min?: number | string min?: number | string
max?: number | string max?: number | string
@@ -162,4 +163,14 @@ describe('MalioInputNumber', () => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5']) expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
expect(input.element.value).toBe('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)
})
}) })
+3 -3
View File
@@ -6,7 +6,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<button <button
type="button" type="button"
@@ -51,7 +51,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -59,7 +58,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'text-xs ml-[2px] ', 'text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -71,6 +70,7 @@
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputNumber', inheritAttrs: false}) defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
@@ -51,6 +51,16 @@ describe('MalioInputPassword', () => {
expect(wrapper.get('label').text()).toBe('Mot de passe') 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', () => { it('has type password by default', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
+6 -5
View File
@@ -29,7 +29,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<IconifyIcon <IconifyIcon
@@ -47,7 +47,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -55,7 +54,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -68,6 +67,7 @@
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputPassword', inheritAttrs: false}) defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
@@ -155,12 +155,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3', 'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', 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 hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -56,6 +56,16 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('label').text()).toBe('Téléphone') 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', () => { it('has type tel', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
@@ -298,6 +308,41 @@ describe('MalioInputPhone', () => {
expect(wrapper.get('input').classes()).toContain('!pr-10') 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 () => { it('applies mask via maska directive', async () => {
const wrapper = mountComponent({mask: '+## # ## ## ## ##'}) const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
+8 -6
View File
@@ -29,7 +29,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<IconifyIcon <IconifyIcon
@@ -60,7 +60,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -68,7 +67,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -83,6 +82,7 @@ import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputPhone', inheritAttrs: false}) defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
@@ -175,19 +175,21 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', 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 hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
const mergedAddButtonClass = computed(() => const mergedAddButtonClass = computed(() =>
twMerge( twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer text-m-primary transition-opacity hover:opacity-70', 'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
(props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '', (props.disabled || props.readonly) ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
), ),
) )
@@ -19,6 +19,7 @@ type InputRichTextProps = {
groupClass?: string groupClass?: string
labelClass?: string labelClass?: string
editorClass?: string editorClass?: string
required?: boolean
} }
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps> const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
@@ -155,6 +156,18 @@ describe('MalioInputRichText', () => {
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby') 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 () => { it('renders initial markdown content visually', async () => {
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'}) const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
@@ -162,4 +175,16 @@ describe('MalioInputRichText', () => {
expect(html).toContain('Mon titre') expect(html).toContain('Mon titre')
expect(html).toContain('Un paragraphe.') 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)
})
}) })
+11 -7
View File
@@ -5,7 +5,7 @@
:for="editorId" :for="editorId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<!-- Mode lecture seule (rendu uniquement) --> <!-- Mode lecture seule (rendu uniquement) -->
@@ -22,6 +22,7 @@
v-else v-else
:id="editorId" :id="editorId"
:class="mergedEditorWrapperClass" :class="mergedEditorWrapperClass"
:aria-required="required || undefined"
@click="focusEditor" @click="focusEditor"
> >
<div <div
@@ -184,7 +185,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${editorId}-describedby`" :id="`${editorId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -192,7 +192,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px]', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -211,6 +211,7 @@ import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight' import Highlight from '@tiptap/extension-highlight'
import { Markdown } from 'tiptap-markdown' import { Markdown } from 'tiptap-markdown'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false }) defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
@@ -233,6 +234,7 @@ const props = withDefaults(
groupClass?: string groupClass?: string
labelClass?: string labelClass?: string
editorClass?: string editorClass?: string
required?: boolean
}>(), }>(),
{ {
id: '', id: '',
@@ -250,6 +252,7 @@ const props = withDefaults(
groupClass: '', groupClass: '',
labelClass: '', labelClass: '',
editorClass: '', editorClass: '',
required: false,
}, },
) )
@@ -279,10 +282,11 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: isFocused.value : props.disabled
? 'text-m-primary' ? 'text-m-muted'
: 'text-m-text', : isFocused.value
props.disabled ? 'text-black/60' : '', ? 'text-m-primary'
: 'text-m-text',
props.labelClass, props.labelClass,
), ),
) )
+6 -5
View File
@@ -30,7 +30,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<IconifyIcon <IconifyIcon
@@ -44,7 +44,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -52,7 +51,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -67,6 +66,7 @@ import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputText', inheritAttrs: false}) defineOptions({name: 'MalioInputText', inheritAttrs: false})
@@ -158,12 +158,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value, labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', 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 hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -149,4 +149,48 @@ describe('MalioInputTextArea', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false) expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error') 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)
})
}) })
+78 -70
View File
@@ -1,85 +1,89 @@
<template> <template>
<div :class="mergedGroupClass"> <div :class="mergedGroupClass">
<textarea <div class="relative w-full flex-1">
:id="inputId" <textarea
:name="name" :id="inputId"
:name="name"
:autocomplete="autocomplete" :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="floating-input peer w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent overflow-auto"
:class="[ :class="[
isFilled ? 'border-black' : 'border-m-muted', isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text', disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
hasError
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? '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,
]"
>
{{ 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 hasError
? 'text-m-danger' ? 'border-m-danger focus:border-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'border-m-success focus:border-m-success'
: 'text-m-muted', : 'focus:border-m-primary',
'ml-[2px]', isFocused ? 'textarea-scrollbar-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' : '',
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: disabled
? '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
class="mt-1 flex items-center justify-between gap-2 text-xs min-h-[1rem]"
> >
{{ error || success || hint }} <p
</p> :id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'ml-[2px]',
]"
>
{{ error || success || hint }}
</p>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false}) defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
@@ -138,7 +142,7 @@ const props = withDefaults(
) )
const mergedGroupClass = computed(() => const mergedGroupClass = computed(() =>
twMerge('relative w-full', props.groupClass), twMerge('flex flex-col w-full', props.groupClass),
) )
const attrs = useAttrs() const attrs = useAttrs()
@@ -188,4 +192,8 @@ const onInput = (event: Event) => {
background: white; background: white;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
.textarea-scrollbar-primary {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
</style> </style>
@@ -17,6 +17,7 @@ type InputUploadProps = {
success?: string success?: string
displayIcon?: boolean displayIcon?: boolean
accept?: string accept?: string
required?: boolean
} }
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps> const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
@@ -167,6 +168,11 @@ describe('MalioInputUpload', () => {
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false') 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', () => { it('passes accept attribute to file input', () => {
const wrapper = mountComponent({accept: '.pdf,.doc'}) const wrapper = mountComponent({accept: '.pdf,.doc'})
@@ -186,4 +192,16 @@ describe('MalioInputUpload', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black') 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)
})
}) })
+10 -5
View File
@@ -9,6 +9,7 @@
:accept="accept" :accept="accept"
class="hidden" class="hidden"
:disabled="disabled" :disabled="disabled"
:required="required"
@change="onFileChange" @change="onFileChange"
> >
@@ -19,6 +20,7 @@
:value="currentDisplayValue" :value="currentDisplayValue"
:readonly="true" :readonly="true"
:aria-invalid="!!error" :aria-invalid="!!error"
:aria-required="required || undefined"
:aria-describedby="describedBy" :aria-describedby="describedBy"
v-bind="attrs" v-bind="attrs"
placeholder="_" placeholder="_"
@@ -33,7 +35,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<IconifyIcon <IconifyIcon
@@ -50,7 +52,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -58,7 +59,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 text-xs ml-[2px] ', 'mt-1 text-xs ml-[2px] min-h-[1rem]',
]" ]"
> >
{{ hint || error || success }} {{ hint || error || success }}
@@ -71,6 +72,7 @@
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputUpload', inheritAttrs: false}) defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
@@ -88,6 +90,7 @@ const props = withDefaults(
success?: string success?: string
displayIcon?: boolean displayIcon?: boolean
accept?: string accept?: string
required?: boolean
}>(), }>(),
{ {
id: '', id: '',
@@ -102,6 +105,7 @@ const props = withDefaults(
success: '', success: '',
displayIcon: true, displayIcon: true,
accept: '', accept: '',
required: false,
}, },
) )
@@ -144,12 +148,13 @@ const mergedLabelClass = computed(() =>
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm', 'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3', 'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '', 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 hasError.value
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary', : disabled.value
? 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass, props.labelClass,
), ),
) )
@@ -173,6 +173,16 @@ describe('MalioRadioButton', () => {
expect(wrapper.get('input').classes()).toContain('checked:border-black') 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 () => { it('updates label color when toggled without v-model (uncontrolled)', async () => {
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'}) const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
+2 -1
View File
@@ -29,7 +29,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
</div> </div>
@@ -46,6 +46,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue' import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioRadioButton', inheritAttrs: false}) defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
@@ -21,6 +21,7 @@ type SelectProps = {
textLabel?: string textLabel?: string
rounded?: string rounded?: string
disabled?: boolean disabled?: boolean
required?: boolean
} }
const SelectForTest = Select as DefineComponent<SelectProps> const SelectForTest = Select as DefineComponent<SelectProps>
@@ -207,4 +208,102 @@ describe('MalioSelect', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false) expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error') 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('shows muted chevron color when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
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')
})
}) })
+21 -16
View File
@@ -13,19 +13,19 @@
hasError hasError
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-danger !border-b-0' ? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-0' : 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger' : 'border-m-danger'
: hasSuccess : hasSuccess
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-success !border-b-0' ? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-0' : 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success' : 'border-m-success'
: isOpen : isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-0' ? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-0' : 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected : isOptionSelected
? 'border-black' ? 'border-black'
: 'border-m-muted', : 'border-m-muted',
@@ -38,6 +38,7 @@
:aria-controls="listboxId" :aria-controls="listboxId"
:aria-invalid="hasError" :aria-invalid="hasError"
:aria-describedby="describedBy" :aria-describedby="describedBy"
:aria-required="required || undefined"
:disabled="disabled" :disabled="disabled"
@click="toggle" @click="toggle"
> >
@@ -59,7 +60,7 @@
]" ]"
:style="labelTransformStyle" :style="labelTransformStyle"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<span <span
@@ -73,13 +74,20 @@
</span> </span>
<span <span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2" class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[ :class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-current' : disabled
? 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]" ]"
> >
<slot name="icon"> <slot name="icon">
@@ -145,7 +153,6 @@
</ul> </ul>
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`" :id="`${buttonId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -153,7 +160,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -165,6 +172,7 @@
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue' import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioSelect', inheritAttrs: false}) defineOptions({name: 'MalioSelect', inheritAttrs: false})
@@ -187,6 +195,7 @@ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
groupClass?: string groupClass?: string
noOptionsText?: string noOptionsText?: string
required?: boolean
}>(), { }>(), {
options: () => [], options: () => [],
emptyOptionLabel: '', emptyOptionLabel: '',
@@ -201,6 +210,7 @@ const props = withDefaults(defineProps<{
disabled: false, disabled: false,
groupClass: '', groupClass: '',
noOptionsText: 'Aucune option disponible', noOptionsText: 'Aucune option disponible',
required: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -330,12 +340,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
} }
.grow-height { .grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; transition: border-color 160ms ease, box-shadow 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -25,6 +25,7 @@ type SelectCheckboxProps = {
selectAllLabel?: string selectAllLabel?: string
disabled?: boolean disabled?: boolean
groupClass?: string groupClass?: string
required?: boolean
} }
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps> const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
@@ -182,4 +183,102 @@ describe('MalioSelectCheckbox', () => {
const root = wrapper.find('button').element.parentElement const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('mt-4') 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('shows muted chevron color when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, disabled: true},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
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')
})
}) })
+21 -16
View File
@@ -13,19 +13,19 @@
hasError hasError
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-danger !border-b-0' ? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-0' : 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger' : 'border-m-danger'
: hasSuccess : hasSuccess
? isOpen ? isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-success !border-b-0' ? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-0' : 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success' : 'border-m-success'
: isOpen : isOpen
? openDirection === 'down' ? openDirection === 'down'
? 'rounded-b-none !border !border-m-primary !border-b-0' ? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-0' : 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected : isOptionSelected
? 'border-black' ? 'border-black'
: 'border-m-muted', : 'border-m-muted',
@@ -38,6 +38,7 @@
:aria-controls="listboxId" :aria-controls="listboxId"
:aria-invalid="hasError" :aria-invalid="hasError"
:aria-describedby="describedBy" :aria-describedby="describedBy"
:aria-required="required || undefined"
:disabled="disabled" :disabled="disabled"
@click="toggle" @click="toggle"
> >
@@ -59,7 +60,7 @@
]" ]"
:style="labelTransformStyle" :style="labelTransformStyle"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<div <div
@@ -101,13 +102,20 @@
</span> </span>
<span <span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2" class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[ :class="[
hasError hasError
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-current' : disabled
? 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]" ]"
> >
<slot name="icon"> <slot name="icon">
@@ -194,7 +202,6 @@
</ul> </ul>
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${buttonId}-describedby`" :id="`${buttonId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -202,7 +209,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -215,6 +222,7 @@ import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import Checkbox from '../checkbox/Checkbox.vue' import Checkbox from '../checkbox/Checkbox.vue'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false}) defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
@@ -240,6 +248,7 @@ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
groupClass?: string groupClass?: string
noOptionsText?: string noOptionsText?: string
required?: boolean
}>(), { }>(), {
options: () => [], options: () => [],
emptyOptionLabel: '', emptyOptionLabel: '',
@@ -257,6 +266,7 @@ const props = withDefaults(defineProps<{
disabled: false, disabled: false,
groupClass: '', groupClass: '',
noOptionsText: 'Aucune option disponible', noOptionsText: 'Aucune option disponible',
required: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -409,12 +419,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
} }
.grow-height { .grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease; transition: border-color 160ms ease, box-shadow 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,25 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import RequiredMark from './RequiredMark.vue'
describe('MalioRequiredMark', () => {
it('rend un astérisque', () => {
const wrapper = mount(RequiredMark)
expect(wrapper.text()).toBe('*')
})
it('est masqué pour les technologies d\'assistance', () => {
const wrapper = mount(RequiredMark)
expect(wrapper.get('[data-test="required-mark"]').attributes('aria-hidden')).toBe('true')
})
it('utilise le token de couleur danger', () => {
const wrapper = mount(RequiredMark)
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-m-danger')
})
it('rend l\'astérisque à 16px', () => {
const wrapper = mount(RequiredMark)
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-[16px]')
})
})
@@ -0,0 +1,11 @@
<template>
<span
data-test="required-mark"
aria-hidden="true"
class="ml-0.5 select-none text-[16px] leading-none text-m-danger"
>*</span>
</template>
<script setup lang="ts">
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
</script>
+10
View File
@@ -76,4 +76,14 @@ describe('MalioTime', () => {
expect(inputs[0].classes()).toContain('border-m-primary') expect(inputs[0].classes()).toContain('border-m-primary')
expect(inputs[1].classes()).not.toContain('border-m-primary') expect(inputs[1].classes()).not.toContain('border-m-primary')
}) })
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountTime({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 = mountTime({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
}) })
+3 -3
View File
@@ -6,7 +6,7 @@
:for="hoursInputId" :for="hoursInputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -58,7 +58,6 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError hasError
@@ -66,7 +65,7 @@
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: 'text-m-muted', : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -77,6 +76,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, nextTick, ref, useAttrs, useId, watch} from 'vue' import {computed, nextTick, ref, useAttrs, useId, watch} from 'vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioTime', inheritAttrs: false}) defineOptions({name: 'MalioTime', inheritAttrs: false})
@@ -73,4 +73,14 @@ describe('MalioTimePicker', () => {
expect(input.attributes('aria-describedby')).toBeTruthy() expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Heure requise') expect(wrapper.text()).toContain('Heure requise')
}) })
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountPicker({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 = mountPicker({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
}) })
+3 -3
View File
@@ -26,7 +26,7 @@
:for="inputId" :for="inputId"
:class="mergedLabelClass" :class="mergedLabelClass"
> >
{{ label }} {{ label }}<MalioRequiredMark v-if="required" />
</label> </label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1"> <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
@@ -78,11 +78,10 @@
</div> </div>
<p <p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`" :id="`${inputId}-describedby`"
:class="[ :class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted', hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs', 'mt-1 ml-[2px] text-xs min-h-[1rem]',
]" ]"
> >
{{ error || success || hint }} {{ error || success || hint }}
@@ -94,6 +93,7 @@
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue' import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
import {Icon} from '@iconify/vue' import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
import TimeWheels from './internal/TimeWheels.vue' import TimeWheels from './internal/TimeWheels.vue'
defineOptions({name: 'MalioTimePicker', inheritAttrs: false}) defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
@@ -0,0 +1,460 @@
# État « obligatoire » cohérent + normalisation email — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Exposer une prop `required` cohérente avec astérisque rouge dans le label sur toute la famille formulaire, et ajouter une sanitisation à la saisie (suppression des espaces + option `lowercase`) à `MalioInputEmail`.
**Architecture :** Un composant présentational partagé `MalioRequiredMark` (astérisque `aria-hidden`, token `text-m-danger`) est importé explicitement et rendu dans le `<label>` de chaque composant quand `required` est vrai. Les 4 composants sans la prop la reçoivent (+ câblage `aria-required` là où il n'y a pas de `required` natif). `MalioInputEmail.onInput` sanitise la valeur avant émission.
**Tech Stack :** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Tailwind (palette `m-*`), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
**Spec :** `docs/superpowers/specs/2026-06-03-required-asterisk-email-sanitization-design.md`
**Conventions de test (rappel) :** chaque fichier `*.test.ts` définit son propre helper de montage (nom variable : `mountInput`, `mountDate`, `mountCheckbox`, `mountTime`, `mountComponent`…) ou monte en inline. Le tableau de chaque tâche indique le helper exact à réutiliser.
**⚠️ Suite flaky :** des timeouts intermittents existent sur diverses suites. Si un test échoue par timeout sans rapport avec le changement, relancer le fichier ciblé ; ne pas conclure à un échec sans relance. Le hook pre-commit lance les tests — si un timeout flaky bloque un commit déjà vérifié manuellement, utiliser `git commit --no-verify`.
**Branche :** `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (rester dessus, ne pas créer de branche).
---
## Task 1 : Composant partagé `MalioRequiredMark`
**Files:**
- Create: `app/components/malio/shared/RequiredMark.vue`
- Test: `app/components/malio/shared/RequiredMark.test.ts`
- [ ] **Step 1 : Écrire le test qui échoue**
Create `app/components/malio/shared/RequiredMark.test.ts` :
```ts
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import RequiredMark from './RequiredMark.vue'
describe('MalioRequiredMark', () => {
it('rend un astérisque', () => {
const wrapper = mount(RequiredMark)
expect(wrapper.text()).toBe('*')
})
it('est masqué pour les technologies dassistance', () => {
const wrapper = mount(RequiredMark)
expect(wrapper.get('[data-test="required-mark"]').attributes('aria-hidden')).toBe('true')
})
it('utilise le token de couleur danger', () => {
const wrapper = mount(RequiredMark)
expect(wrapper.get('[data-test="required-mark"]').classes()).toContain('text-m-danger')
})
})
```
- [ ] **Step 2 : Lancer le test, vérifier l'échec**
Run: `npm run test -- app/components/malio/shared/RequiredMark.test.ts`
Expected: FAIL — `Failed to resolve import './RequiredMark.vue'` (le composant n'existe pas encore).
- [ ] **Step 3 : Créer le composant**
Create `app/components/malio/shared/RequiredMark.vue` :
```vue
<template>
<span
data-test="required-mark"
aria-hidden="true"
class="ml-0.5 select-none text-m-danger"
>*</span>
</template>
<script setup lang="ts">
defineOptions({name: 'MalioRequiredMark', inheritAttrs: false})
</script>
```
- [ ] **Step 4 : Lancer le test, vérifier le succès**
Run: `npm run test -- app/components/malio/shared/RequiredMark.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/shared/RequiredMark.vue app/components/malio/shared/RequiredMark.test.ts
git commit -m "feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)"
```
---
## Task 2 : Prop `required` + a11y + astérisque sur les 4 composants sans la prop
Composants : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`. Chacun reçoit la prop `required`, le câblage a11y adapté, l'import + le rendu de l'astérisque, et un test.
**Files:**
- Modify: `app/components/malio/select/Select.vue`, `app/components/malio/select/SelectCheckbox.vue`, `app/components/malio/input/InputUpload.vue`, `app/components/malio/input/InputRichText.vue`
- Test: `app/components/malio/select/Select.test.ts`, `app/components/malio/select/SelectCheckbox.test.ts`, `app/components/malio/input/InputUpload.test.ts`, `app/components/malio/input/InputRichText.test.ts`
- [ ] **Step 1 : Écrire les tests qui échouent (un par composant)**
Patron d'assertion (à adapter au helper de chaque fichier) :
```ts
it('affiche lastérisque quand required est vrai', () => {
const wrapper = /* monter avec { label: 'Champ', required: true, ...props requises } */
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('naffiche pas lastérisque par défaut', () => {
const wrapper = /* monter avec { label: 'Champ', ...props requises } */
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
```
Montage par fichier :
| Fichier test | Montage |
|---|---|
| `select/Select.test.ts` | inline : `mount(SelectForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` (et sans `required` pour le 2ᵉ test) |
| `select/SelectCheckbox.test.ts` | inline : `mount(SelectCheckboxForTest, {props: {label: 'Champ', required: true, options: [{label: 'A', value: 'a'}]}})` |
| `input/InputUpload.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` |
| `input/InputRichText.test.ts` | helper existant `mountComponent({label: 'Champ', required: true})` |
> Note : pour `Select`/`SelectCheckbox`, reprendre la forme exacte des `options` et les `global.stubs` déjà utilisés dans les autres `it()` du fichier (copier un montage voisin).
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts`
Expected: FAIL sur les nouveaux tests « affiche lastérisque » (la prop/le rendu n'existent pas encore).
- [ ] **Step 3 : Ajouter la prop `required` (type + défaut) dans les 4 composants**
Dans chaque `defineProps<{…}>()`, ajouter la ligne :
```ts
required?: boolean
```
Dans chaque `withDefaults(…, { … })`, ajouter :
```ts
required: false,
```
- [ ] **Step 4 : Câbler l'accessibilité (un élément interactif par composant)**
`Select.vue` — sur le `<button>` déclencheur (là où sont déjà `:aria-expanded`, `:aria-controls`), ajouter :
```vue
:aria-required="required || undefined"
```
`SelectCheckbox.vue` — idem, sur son `<button>` déclencheur :
```vue
:aria-required="required || undefined"
```
`InputUpload.vue` — sur l'`<input type="file">`, ajouter l'attribut natif :
```vue
:required="required"
```
`InputRichText.vue` — sur le wrapper éditeur identifié par `:id="editorId"` (le conteneur de `<EditorContent>` en mode éditable), ajouter :
```vue
:aria-required="required || undefined"
```
- [ ] **Step 5 : Importer et rendre l'astérisque dans les 4 composants**
Dans le `<script setup>` de chacun, ajouter l'import (chemin relatif depuis `family/Component.vue`) :
```ts
import MalioRequiredMark from '../shared/RequiredMark.vue'
```
Dans le `<template>`, remplacer le rendu du libellé `{{ label }}` (celui à l'intérieur du `<label>` du champ — **pas** un `{{ opt.label }}`) par :
```vue
{{ label }}<MalioRequiredMark v-if="required" />
```
> Respecter l'indentation existante de chaque fichier. Pour `Select`/`SelectCheckbox`, viser le `{{ label }}` du `<label>` flottant, pas le `{{ opt.label }}` des options.
- [ ] **Step 6 : Lancer les tests, vérifier le succès**
Run: `npm run test -- app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts`
Expected: PASS (anciens + nouveaux tests). En cas de timeout flaky non lié, relancer le fichier concerné.
- [ ] **Step 7 : Commit**
```bash
git add app/components/malio/select/Select.vue app/components/malio/select/SelectCheckbox.vue app/components/malio/input/InputUpload.vue app/components/malio/input/InputRichText.vue app/components/malio/select/Select.test.ts app/components/malio/select/SelectCheckbox.test.ts app/components/malio/input/InputUpload.test.ts app/components/malio/input/InputRichText.test.ts
git commit -m "feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText"
```
---
## Task 3 : Astérisque sur les composants ayant déjà `required`
Ces composants ont déjà la prop `required` (câblée nativement). On ajoute uniquement l'import + le rendu de l'astérisque + un test.
**Files (16 composants → 13 via CalendarField mutualisé) :**
| Composant `.vue` | Import à ajouter | Fichier test | Helper de montage |
|---|---|---|---|
| `input/InputText.vue` | `'../shared/RequiredMark.vue'` | `input/Input.test.ts` | `mountInput({label:'Champ', required:true})` |
| `input/InputEmail.vue` | `'../shared/RequiredMark.vue'` | `input/InputEmail.test.ts` | `mountComponent({label:'Champ', required:true})` |
| `input/InputPhone.vue` | `'../shared/RequiredMark.vue'` | `input/InputPhone.test.ts` | `mountComponent({label:'Champ', required:true})` |
| `input/InputPassword.vue` | `'../shared/RequiredMark.vue'` | `input/InputPassword.test.ts` | `mountComponent({label:'Champ', required:true})` |
| `input/InputTextArea.vue` | `'../shared/RequiredMark.vue'` | `input/InputTextArea.test.ts` | helper du fichier (`mount<…>` ; copier un montage voisin) |
| `input/InputAmount.vue` | `'../shared/RequiredMark.vue'` | `input/InputAmount.test.ts` | helper du fichier |
| `input/InputNumber.vue` | `'../shared/RequiredMark.vue'` | `input/InputNumber.test.ts` | helper du fichier |
| `input/InputAutocomplete.vue` | `'../shared/RequiredMark.vue'` | `input/InputAutocomplete.test.ts` | `mountComponent({label:'Champ', required:true, …props requises})` |
| `checkbox/Checkbox.vue` | `'../shared/RequiredMark.vue'` | `checkbox/Checkbox.test.ts` | `mountCheckbox({label:'Champ', required:true})` |
| `radio/RadioButton.vue` | `'../shared/RequiredMark.vue'` | `radio/RadioButton.test.ts` | helper du fichier |
| `time/Time.vue` | `'../shared/RequiredMark.vue'` | `time/Time.test.ts` | `mountTime({label:'Champ', required:true})` |
| `time/TimePicker.vue` | `'../shared/RequiredMark.vue'` | `time/TimePicker.test.ts` | helper du fichier |
| `date/internal/CalendarField.vue` | `'../../shared/RequiredMark.vue'` | `date/Date.test.ts` | `mountDate({label:'Champ', required:true})` |
> `CalendarField` rend le label de tout le date family (`Date`, `DateTime`, `DateRange`, `DateWeek`). Une seule modif + un seul test (via `Date.test.ts`) couvrent les quatre.
- [ ] **Step 1 : Écrire les tests qui échouent (un couple par fichier test du tableau)**
Pour chaque fichier test listé, ajouter :
```ts
it('affiche lastérisque quand required est vrai', () => {
const wrapper = /* helper du tableau, avec required: true */
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('naffiche pas lastérisque par défaut', () => {
const wrapper = /* helper du tableau, sans required */
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
```
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts`
Expected: FAIL sur les nouveaux tests « affiche lastérisque ».
- [ ] **Step 3 : Ajouter l'import + le rendu de l'astérisque dans les 13 `.vue`**
Dans chaque `<script setup>`, ajouter l'import indiqué dans la colonne « Import à ajouter ».
Dans chaque `<template>`, transformer le libellé du champ :
```vue
{{ label }}<MalioRequiredMark v-if="required" />
```
(Le `{{ label }}` est à l'intérieur du `<label v-if="label">` du champ. Respecter l'indentation propre à chaque fichier.)
- [ ] **Step 4 : Lancer les tests, vérifier le succès**
Run: `npm run test -- app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/Date.test.ts`
Expected: PASS. (Vérifier notamment que `input/InputEmail.test.ts` « renders the label text » → `'Adresse email'` passe toujours : pas de `required` dans ce test, donc pas d'astérisque.) Relancer en cas de timeout flaky.
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/input app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date/internal/CalendarField.vue app/components/malio/date/Date.test.ts
git commit -m "feat(ui): astérisque required dans le label de la famille formulaire"
```
---
## Task 4 : Sanitisation de `MalioInputEmail`
**Files:**
- Modify: `app/components/malio/input/InputEmail.vue`
- Test: `app/components/malio/input/InputEmail.test.ts`
- [ ] **Step 1 : Écrire les tests qui échouent**
Ajouter à `input/InputEmail.test.ts` :
```ts
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'])
})
```
> Ajouter `lowercase?: boolean` au type `InputEmailProps` en tête du fichier de test (sinon TS refuse la prop dans le 3ᵉ test).
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `npm run test -- app/components/malio/input/InputEmail.test.ts`
Expected: FAIL — les espaces ne sont pas supprimés / `lowercase` inconnu.
- [ ] **Step 3 : Ajouter la prop `lowercase`**
Dans `defineProps<{…}>()` de `InputEmail.vue`, ajouter :
```ts
lowercase?: boolean
```
Dans `withDefaults(…, { … })`, ajouter :
```ts
lowercase: false,
```
- [ ] **Step 4 : Ajouter la fonction de sanitisation et réécrire `onInput`**
Ajouter la fonction pure (au-dessus de `onInput`) :
```ts
const sanitizeEmail = (v: string) => {
let out = v.replace(/\s+/g, '')
if (props.lowercase) out = out.toLowerCase()
return out
}
```
Remplacer le `onInput` existant par :
```ts
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, setSelectionRange lève. On garde defensivement.
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
}
emit('update:modelValue', sanitized)
}
```
- [ ] **Step 5 : Lancer les tests, vérifier le succès**
Run: `npm run test -- app/components/malio/input/InputEmail.test.ts`
Expected: PASS (anciens tests inclus, dont « emits update:modelValue on input change » avec `'new@example.com'` qui n'a pas d'espace → inchangé).
- [ ] **Step 6 : Commit**
```bash
git add app/components/malio/input/InputEmail.vue app/components/malio/input/InputEmail.test.ts
git commit -m "feat(inputs): sanitisation email (suppression des espaces + option lowercase)"
```
---
## Task 5 : Documentation (`COMPONENTS.md` + `CHANGELOG.md`)
**Files:**
- Modify: `COMPONENTS.md`, `CHANGELOG.md`
- [ ] **Step 1 : `COMPONENTS.md` — lignes `required` manquantes**
Pour les sections `MalioSelect`, `MalioSelectCheckbox`, `MalioInputUpload`, `MalioInputRichText`, ajouter dans le tableau des props la ligne (au même format que les autres composants) :
```
| `required` | `boolean` | `false` | Champ requis (affiche un astérisque rouge dans le label) |
```
- [ ] **Step 2 : `COMPONENTS.md` — note astérisque + prop `lowercase`**
- Dans l'introduction de la famille formulaire (ou la section des props communes), ajouter une phrase : « Lorsque `required` est vrai, un astérisque rouge est ajouté dans le label (visuel ; la sémantique est portée par l'attribut `required`/`aria-required`). »
- Dans la section `MalioInputEmail`, ajouter la ligne de prop :
```
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
```
et préciser que les espaces sont supprimés automatiquement à la saisie (pas de masque ; la validation de format reste à la couche `error`).
- [ ] **Step 3 : `CHANGELOG.md` — entrées**
Sous le `### Added` de la version en cours (format `* [#…] …`), ajouter :
```
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
```
- [ ] **Step 4 : Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)"
```
---
## Task 6 : Exemples playground + vérification finale
**Files:**
- Modify: page(s) playground des composants concernés (selon `.playground/` ; cf. mémoire « Architecture playground »)
- [ ] **Step 1 : Ajouter des exemples légers**
Sur la page playground d'un composant représentatif (ex. `InputText`/`Select`), ajouter une instance `:required="true"`. Sur la page `InputEmail`, ajouter une instance `:lowercase="true"`. Si le coût d'intégration dépasse quelques minutes (routage/nav à câbler), le **noter** et passer — c'est hors scope strict du ticket.
- [ ] **Step 2 : Lint**
Run: `npm run lint`
Expected: 0 erreur. Corriger le cas échéant.
- [ ] **Step 3 : Suite de tests complète des fichiers touchés**
Run: `npm run test -- app/components/malio/shared app/components/malio/input app/components/malio/select app/components/malio/checkbox app/components/malio/radio app/components/malio/time app/components/malio/date`
Expected: PASS. En cas de timeout flaky, relancer le(s) fichier(s) concerné(s) individuellement.
- [ ] **Step 4 : Commit (si exemples playground ajoutés)**
```bash
git add .playground
git commit -m "docs(playground): exemples required + email lowercase"
```
---
## Récapitulatif des commits attendus
1. `feat(ui): composant partagé MalioRequiredMark (astérisque champ obligatoire)`
2. `feat(ui): prop required + aria-required + astérisque sur Select/SelectCheckbox/Upload/RichText`
3. `feat(ui): astérisque required dans le label de la famille formulaire`
4. `feat(inputs): sanitisation email (suppression des espaces + option lowercase)`
5. `docs: required/astérisque + lowercase email (COMPONENTS + CHANGELOG)`
6. `docs(playground): exemples required + email lowercase` (optionnel)
@@ -0,0 +1,168 @@
# Design — État « obligatoire » cohérent + normalisation email
- **Date** : 2026-06-03
- **Ticket Malio UI** : MUI-41 (branche `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co`)
- **Ticket Starseed lié** : ERP-101 (MAJ Malio UI + branchement `required` + stratégie de validation), découvert pendant ERP-63 (écran « Ajouter un client »)
## Contexte & problème
Pendant ERP-63, deux manques ont bloqué la mise en place de champs obligatoires :
1. Certains composants de formulaire n'exposent pas de prop `required` (`MalioSelect`, `MalioSelectCheckbox`), et **aucun composant n'affiche d'indicateur visuel** de champ obligatoire. Résultat : le bouton « Valider » se bloque sans feedback à l'utilisateur — anti-pattern UX.
2. Tentation erronée de « masquer » l'email à la maska. Un email n'a **pas** de structure fixe : pas de masque. Le bon comportement est une **sanitisation** légère à la saisie + validation déléguée à la couche `error`.
État réel constaté (inventaire) : la **majorité** des composants ont déjà la prop `required` (câblée sur l'attribut HTML natif uniquement, sans astérisque). Seuls **5** ne l'ont pas : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`, `SiteSelector`. Aucun composant n'affiche d'astérisque. Il n'existe pas de composant de label partagé : chaque composant rend `{{ label }}` dans son propre `<label>` au style spécifique (floating labels).
## Objectifs
- Prop `required: boolean` cohérente sur **toute la famille formulaire**.
- Quand `required` est vrai → **astérisque rouge dans le label**.
- `MalioInputEmail` : sanitisation à la saisie (suppression de tous les espaces, option `lowercase`), **sans** masque ni validation de format.
- Mettre à jour `COMPONENTS.md` et `CHANGELOG.md`.
## Hors scope
- Validation de format email (reste à la charge de la couche validation via la prop `error`, alimentée serveur ou check client).
- Toute logique de masque sur l'email.
- Refonte des suites de tests existantes.
## Décisions de cadrage (validées avec l'utilisateur)
| Décision | Choix retenu |
|---|---|
| Périmètre `required` + astérisque | **Toute la famille formulaire**, y compris `InputUpload`, `InputRichText`, `SiteSelector` |
| Prop `lowercase` (email) | **Opt-in, défaut `false`** |
| Espaces email | **Supprimer tous les espaces** (début, milieu, fin) ; préservation du curseur *best-effort* (voir caveat ci-dessous) |
| Accessibilité astérisque | `aria-hidden="true"` — la sémantique est portée par l'attribut HTML natif `required` |
## Section 1 — Indicateur « obligatoire »
### Composant partagé `MalioRequiredMark`
Nouveau composant `app/components/malio/shared/RequiredMark.vue` (auto-importé `<MalioRequiredMark>`). Source unique de vérité pour couleur/espacement.
Rendu :
```vue
<span aria-hidden="true" class="ml-0.5 select-none text-m-danger">*</span>
```
- `aria-hidden="true"` : évite la double annonce, la sémantique est déjà sur l'attribut natif `required`.
- Couleur via token existant `text-m-danger` (`--m-danger`, rouge `#F2696B`).
- `defineOptions({ name: 'MalioRequiredMark', inheritAttrs: false })`.
### Intégration
Dans chaque composant de la famille, remplacer `{{ label }}` par :
```vue
{{ label }}<MalioRequiredMark v-if="required" />
```
L'astérisque vit **à l'intérieur du `<label>`** → il flotte avec le floating-label et reste dans la pastille blanche.
### Props à ajouter
`required?: boolean` (défaut `false`) sur les **4** composants qui ne l'ont pas et qui possèdent un label de champ : `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText`.
### Câblage accessibilité (a11y)
L'astérisque est `aria-hidden` : la sémantique « obligatoire » doit donc être portée par le DOM.
- **Élément natif `required` déjà câblé** (asterisque suffit) : `InputText`, `InputEmail`, `InputPhone`, `InputPassword`, `InputTextArea`, `InputAmount`, `InputNumber`, `InputAutocomplete`, `Checkbox`, `RadioButton`, `Time`, `TimePicker`, et `CalendarField` (date family).
- **Pas de `required` natif** → ajouter `:aria-required="required || undefined"` sur l'élément interactif :
- `Select` / `SelectCheckbox` : le `<button>` déclencheur (combobox).
- `InputRichText` : le wrapper éditeur (`#editorId`, contenteditable via TipTap).
- `InputUpload` : possède un `<input type="file">` natif → on câble `:required="required"` dessus (natif).
### Composants concernés par le rendu de l'astérisque
`InputText`, `InputEmail`, `InputPhone`, `InputPassword`, `InputTextArea`, `InputAmount`, `InputNumber`, `InputAutocomplete`, `InputUpload`, `InputRichText`, `Select`, `SelectCheckbox`, `Checkbox`, `RadioButton`, `Time`, `TimePicker`, et `CalendarField` (rendu mutualisé pour `Date`, `DateTime`, `DateRange`, `DateWeek`).
### Exclusion : `SiteSelector`
`MalioSiteSelector` est un **radiogroup de tuiles** (segmented control) : il n'a **pas de label de champ** (son `labelClass` style le nom de chaque tuile). Y placer un astérisque n'a pas de sens. Il est **exclu** du périmètre `required`/astérisque. À rouvrir si un besoin de « groupe obligatoire » émerge (ce serait alors un libellé de groupe distinct, hors de ce ticket).
### Alternative écartée
Inliner un `<span>` dans chaque composant : duplication, couleur/espacement à changer à ~20 endroits. Le composant partagé est préféré.
## Section 2 — Sanitisation `MalioInputEmail`
### Nouvelle prop
`lowercase?: boolean` (défaut `false`).
### Fonction de sanitisation (pure, testable)
```ts
const sanitizeEmail = (v: string) => {
let out = v.replace(/\s+/g, '') // supprime TOUT espace
if (props.lowercase) out = out.toLowerCase()
return out
}
```
### `onInput` réécrit
```ts
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, setSelectionRange lève une exception.
// On garde donc la repositionnement défensif (no-op sur type=email).
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
emit('update:modelValue', sanitized)
}
```
Points clés :
- **Resynchro DOM** : `target.value = sanitized` même en mode contrôlé, pour que l'affichage colle toujours à la valeur émise.
- **Caveat curseur** : la spec HTML interdit l'API de sélection sur `type="email"` (`selectionStart` = `null`, `setSelectionRange` lève). La repositionnement est donc **best-effort** et inactif sur l'email : sur le cas rare d'une suppression d'espace en milieu de chaîne, le curseur peut aller en fin. Les cas courants (espace en fin, collage) gardent naturellement le curseur en fin. Le code est gardé (`caret !== null` + `try/catch`) pour ne jamais lever.
- **Collage** couvert (paste déclenche `input`).
- **Inchangé** : `type="email"`, `inputmode="email"`, icône, et **aucune validation de format**.
## Section 3 — Tests, docs & livraison
### Tests (colocalisés `*.test.ts`)
- `RequiredMark.test.ts` — rend `*`, `aria-hidden="true"`, classe `text-m-danger`.
- 1 test ciblé par composant équipé : `required: true` → astérisque présent dans le label ; défaut → absent. S'appuie sur le helper `mountComponent` existant de chaque fichier.
- `InputEmail.test.ts` — espaces (début/milieu/fin) supprimés ; `lowercase=false` préserve la casse ; `lowercase=true` minuscule ; valeur émise sanitisée ; valeur DOM resynchronisée. Le curseur n'est pas testé (peu fiable en jsdom) → on teste la valeur.
⚠️ Suite de tests **flaky** connue (timeouts intermittents). Lancer les tests des fichiers touchés ; en cas de timeout non lié aux changements, relancer / documenter plutôt que conclure à un échec.
### Documentation (manuelle, requise par convention)
- `COMPONENTS.md` : ajouter la ligne `required` aux 5 composants manquants ; ajouter `lowercase` à `MalioInputEmail` ; mentionner en intro famille formulaire que `required` affiche un astérisque rouge.
- `CHANGELOG.md` : entrée(s) `MUI-41` sous `### Added`, format existant (`* [#MUI-41] ...`).
### Playground / Histoire
Ajouter un exemple `required` + un exemple email `lowercase` sur les pages playground concernées si coût faible ; sinon signaler (hors scope strict).
### Découpage de livraison (1 PR, commits Conventional)
1. `feat(ui): MalioRequiredMark + prop required sur Select/SelectCheckbox/Upload/RichText/SiteSelector`
2. `feat(ui): astérisque required dans le label de la famille formulaire`
3. `feat(inputs): sanitisation email (suppression espaces + option lowercase)`
4. `docs: COMPONENTS.md + CHANGELOG`
Branche : `feature/MUI-41-props-required-asterisque-dans-le-label-sur-les-co` (inchangée).
+3
View File
@@ -17,6 +17,9 @@ export default {
borderRadius: { borderRadius: {
malio: 'var(--m-radius)', malio: 'var(--m-radius)',
}, },
width: {
'm-btn-action': 'var(--m-btn-action-width)',
},
colors: { colors: {
m: { m: {
primary: 'rgb(var(--m-primary) / <alpha-value>)', primary: 'rgb(var(--m-primary) / <alpha-value>)',