Compare commits

..

65 Commits

Author SHA1 Message Date
tristan 41a3df7481 fix(ui) : checkbox internes de SelectCheckbox sans espace message réservé
Les options du dropdown (et le « tout sélectionner ») passaient reserveMessageSpace
par défaut → chaque ligne réservait un min-h-[1rem] inutile, d'où le gros espacement.
On force :reserve-message-space=false sur ces Checkbox internes.
Retire aussi la carte de démo playground hors-sujet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:07:42 +02:00
tristan bf4d62a23f docs(playground) : exemple reserveMessageSpace sur SelectCheckbox
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:03:57 +02:00
tristan 1b5a4c9920 feat(ui) : prop reserveMessageSpace (défaut true) sur Select/SelectCheckbox/Checkbox/date/time
Ajoute reserveMessageSpace (défaut true) pour permettre de ne pas réserver
la ligne de message d'aide quand aucun message n'est présent. Comportement
inchangé par défaut. La famille date hérite via $attrs → CalendarField.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:53:22 +02:00
tristan cda0f994ca feat(ui) : prop reserveMessageSpace (défaut true) sur la famille input
Ajoute une prop booléenne reserveMessageSpace (défaut true) aux 10 composants
de la famille input. Par défaut, comportement inchangé (ligne message toujours
rendue avec min-h-[1rem]). À false, la ligne ne prend aucun espace en l'absence
de message, et s'affiche sans min-h quand un message est présent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:43:31 +02:00
tristan 5f1dc834cd fix(ui) : pt-1 sur InputTextArea pour aligner son haut avec les inputs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:31:50 +02:00
tristan de504d8ba0 docs(playground) : retire empty-option-label du SelectCheckbox readonly vide
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:28:44 +02:00
tristan 59004c9635 docs(playground) : badges (display-tag) sur SelectCheckbox readonly
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:26:10 +02:00
tristan 801925c443 fix(ui) : SelectCheckbox tolère modelValue absent (défaut []) — corrige crash SSR readonly
Le composant accédait à props.modelValue.length sans garde alors que modelValue
était requis ; un usage non bindé (ex. page playground readonly) plantait le rendu SSR.
modelValue devient optionnel avec défaut [].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:24:19 +02:00
tristan cec528eac6 docs(playground) : exemples readonly Select/SelectCheckbox (+ page DIVERS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:18:04 +02:00
tristan 358ba246d7 fix(ui) : aria-readonly suit isReadonly sur Select/SelectCheckbox (disabled prime)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:15:38 +02:00
tristan 621077f555 feat(ui) : état readonly visuel sur Select et SelectCheckbox
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:11:13 +02:00
tristan f5163f10f1 fix(playground) : retire prop icon-name fantôme sur InputTextArea (pas d'icône)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:57:36 +02:00
tristan 734c7aba2f docs(playground) : page DIVERS regroupant tous les champs readonly
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:56:08 +02:00
tristan bdca9490ee test(ui) : couvre icône/label readonly vide sur la famille champs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:49:56 +02:00
tristan 5b9b771174 docs(playground) : exemples readonly
Ajout de deux blocs readonly (vide + rempli) sur chaque page playground
concernée : InputText, InputEmail, InputAmount, InputAutocomplete,
InputPassword, InputTextArea, InputPhone, InputUpload, Date, TimePicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:39:30 +02:00
tristan 993364062d fix(ui) : garde readonly défensive isOpen + test readonly TimePicker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:35:41 +02:00
tristan 12a165c1c1 feat(ui) : état readonly visuel sur pickers date/heure
Bordure noire forcée (même vide), suppression du bleu focus/primary, label
et icône en text-black si rempli sinon text-m-muted, float piloté par isFilled
uniquement en readonly. Bouton clear et astérisque inchangés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:30:45 +02:00
tristan f8c0bf13d5 feat(ui) : InputPhone readonly suit les règles readonly (plus de look disabled)
Découple readonly de disabled : le champ affiche border-black + curseur default,
l'icône suit text-black/text-m-muted selon isFilled, et le bouton "add" conserve
son guard onAdd sans porter l'apparence désactivée (opacity-40/cursor-not-allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:26:50 +02:00
tristan 26c0a8b533 fix(ui) : cursor-default readonly TextArea + test chevron readonly Autocomplete
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:24:28 +02:00
tristan e622380916 feat(ui) : état readonly visuel sur les inputs floating-label
Applique le traitement readonly canonique (isReadonly, shouldFloatLabel,
mergedInputClass sans grow-height, bordure noire fixe, sans focus:border-m-primary,
mergedLabelClass sans peer-focus, iconStateClass sans isFocused) sur les 6 composants
InputText, InputEmail, InputAmount, InputAutocomplete, InputPassword et InputTextArea.
L'œil de InputPassword reste cliquable en readonly. Tests TDD ajoutés (3 cas par fichier).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:18:20 +02:00
tristan 289ff036d2 fix(ui) : readonly InputUpload — drop peer-focus float + idiome grow-height/cursor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:12:15 +02:00
tristan fd3e3a7922 feat(ui) : état readonly visuel sur InputUpload (+ prop readonly)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:05:48 +02:00
tristan c934019260 docs : plan état readonly cohérent
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:03:26 +02:00
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 c0c39705c7 fix: drawer footer (#57)
Release / release (push) Successful in 1m20s
| 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é

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #57
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:53:43 +00: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 acd531f69e feat: Ajout des composants modal, accordeon, datetime avec selecteur d'heure à la molette (#56)
Release / release (push) Successful in 2m38s
| 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: #56
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:11:51 +00: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 7d7b2fb720 feat: Développer le composant Datepicker (#52)
Release / release (push) Successful in 1m24s
| 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: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #52
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 08:08:50 +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 7ca5c5f4c5 fix: refonte du composant Drawer (#51)
Release / release (push) Successful in 1m25s
| 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: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 07:05:16 +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 f3a18ace1d feat: composant saisie assistée, composant téléphone et composant mail (#47)
Release / release (push) Successful in 1m12s
| 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: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #47
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-13 07:01:30 +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 d9023a0ddc fix: problèmes de taille des champs + Ajout d'un playground form (#43)
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: kevin <kevin@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #43
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-11 07:38:57 +00:00
tristan eb21827686 Merge branch 'main' into develop 2026-05-11 09:38:39 +02:00
matthieu c646df9fe3 fix: republier 1.4.8 (couleurs éditeur rich text) (#41)
Release / release (push) Successful in 1m8s
## Pourquoi

Le squash-merge de #40 a utilisé le titre \`release : ...\` comme message du merge commit. Le mot \`release\` n'est pas un type reconnu par le \`commit-analyzer\` (angular preset reconnaît \`feat\`, \`fix\`, \`perf\`, etc.). Résultat : semantic-release a tourné mais n'a rien publié → le code des couleurs est sur main mais 1.4.8 n'est jamais sorti.

## Quoi

Un commit vide \`fix(release) : ...\` qui force le bump patch et republie en 1.4.8.

## Note durable

Le titre des PR de release **doit** être un Conventional Commit (\`fix: ...\`, \`feat: ...\`). Avec squash-merge, c'est ce titre qui devient le message analysé.

Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Reviewed-on: #41
2026-05-04 18:42:23 +00:00
145 changed files with 24530 additions and 744 deletions
+8 -1
View File
@@ -12,7 +12,14 @@
"Bash(mv buttonIcon.story.vue button/)",
"Bash(mv inputText.story.vue inputAmount.story.vue inputNumber.story.vue inputPassword.story.vue inputTextArea.story.vue inputUpload.story.vue input/)",
"Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)",
"Bash(mv inputCheckbox.story.vue checkbox/)"
"Bash(mv inputCheckbox.story.vue checkbox/)",
"Bash(npx eslint *)",
"Bash(echo \"LINT EXIT: $?\")",
"Bash(git commit *)",
"mcp__chrome__navigate_page",
"mcp__chrome__take_snapshot",
"mcp__chrome__click",
"mcp__chrome__evaluate_script"
]
}
}
@@ -108,9 +108,9 @@ npm run lint # Pas d'erreurs
### 5. Créer la page playground
**Fichier :** `.playground/pages/composant/<nomComposant>.vue` (camelCase)
**Fichier :** `.playground/pages/composant/<categorie>/<nomComposant>.vue` (camelCase, dans le sous-dossier de catégorie)
La page est auto-détectée par `index.vue` via `import.meta.glob`. Inclure des variantes représentatives dans une grille :
La page devient automatiquement une route Nuxt (`/composant/<categorie>/<nomComposant>`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant/<categorie>/<nomComposant>`. Inclure des variantes représentatives dans une grille :
```html
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
@@ -216,7 +216,7 @@ Cette section est alimentée au fur et à mesure des retours utilisateur et des
|--------|----------|
| Stub IconifyIcon ne fonctionne pas dans les tests | Utiliser `findComponent(IconifyIcon)` avec l'import réel pour tester les props |
| Oubli de `inheritAttrs: false` | Toujours dans `defineOptions` — sinon les attrs se dupliquent |
| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` |
| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) |
| Padding input pas ajusté avec icône | Ajouter `!pr-10` (ou équivalent) quand une icône est présente à droite |
| Story sans initial state | Toujours initialiser les `ref` avec des valeurs pour que les variantes soient visibles dès le chargement |
| CHANGELOG oublié | Toujours ajouter la ligne dans `### Added` avant de commit |
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="flex h-screen">
<MalioSidebar :sections="navSections">
<template #logo>
<NuxtLink to="/">
<img src="/LOGO_MALIO.png" alt="Malio">
</NuxtLink>
</template>
<template #logo-collapsed>
<NuxtLink to="/">
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio">
</NuxtLink>
</template>
</MalioSidebar>
<main class="flex-1 overflow-y-auto p-6">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
import {navSections} from '../playground.nav'
</script>
@@ -0,0 +1,63 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) défaut</h2>
<MalioAccordion v-model="multiple">
<MalioAccordionItem title="Prix" value="prix">
<p>Slider de prix ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<p>Liste de checkboxes ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Marque" value="marque">
<p>Recherche + liste ici</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouverts : {{ multiple }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
<MalioAccordion v-model="single" mode="single">
<MalioAccordionItem title="Question 1" value="q1">
<p>Réponse 1</p>
</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">
<p>Réponse 2</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouvert : {{ single }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé + defaultOpen</h2>
<MalioAccordion>
<MalioAccordionItem title="Section A" value="a" :default-open="true">
<p>Ouverte au montage</p>
</MalioAccordionItem>
<MalioAccordionItem title="Section B" value="b">
<p>Fermée au montage</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
<MalioAccordion>
<MalioAccordionItem title="Active" value="ok">
<p>Contenu accessible</p>
</MalioAccordionItem>
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
<p>Inaccessible</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const multiple = ref<string[]>(['prix'])
const single = ref('q1')
</script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDate</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDate
v-model="value"
label="Date de naissance"
hint="Clique pour ouvrir le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25'"
>
Forcer le 25/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDate
v-model="erpValue"
label="Date du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDate
v-model="bounded"
label="Date bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Readonly (readonly vide)</h2>
<MalioDate
label="Date de naissance (readonly vide)"
:readonly="true"
/>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">Readonly (readonly rempli)</h2>
<MalioDate
v-model="readonlyFilledDate"
label="Date de naissance (readonly rempli)"
:readonly="true"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const readonlyFilledDate = ref<string | null>('2026-06-15')
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
@@ -0,0 +1,72 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateRange</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateRange
v-model="value"
label="Période"
hint="Clique deux fois pour définir une plage"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ value?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ value?.end ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = {start: '2026-12-20', end: '2026-12-31'}"
>
Forcer 2031/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateRange
v-model="erpValue"
label="Période"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ erpValue?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ erpValue?.end ?? 'null' }}</code></p>
</div>
<MalioDateRange
v-model="bounded"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<RangeValue | null>(null)
const erpValue = ref<RangeValue | null>(null)
const bounded = ref<RangeValue | null>(null)
</script>
@@ -0,0 +1,68 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateWeek</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateWeek
v-model="value"
label="Semaine"
hint="Clique un jour ou un n° de semaine"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-W52'"
>
Forcer 2026-W52
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateWeek
v-model="erpValue"
label="Semaine"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateWeek
v-model="bounded"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
@@ -0,0 +1,68 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateTime</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateTime
v-model="value"
label="Date et heure du rendez-vous"
hint="Choisis un jour puis une heure"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25T09:30:00'"
>
Forcer le 25/12/2026 09:30
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateTime
v-model="erpValue"
label="Date et heure du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO naïf) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateTime
v-model="bounded"
label="Créneau borné"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>('2026-05-20T14:30:00')
</script>
@@ -0,0 +1,276 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">Champs en lecture seule (readonly)</h1>
<p class="text-sm text-m-muted">
Tous les champs de formulaire dans leur état <code>readonly</code>, vides puis remplis.
Règles : bordure noire même vide, label et icône gris quand vide noir quand rempli,
pas de focus bleu ni de grossissement.
</p>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
<div class="space-y-4">
<MalioInputText
label="Référence (vide)"
:readonly="true"
/>
<MalioInputText
model-value="Commande #A-2048"
label="Référence (rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputEmail</h2>
<div class="space-y-4">
<MalioInputEmail
label="Adresse email (vide)"
:readonly="true"
/>
<MalioInputEmail
model-value="contact@malio.fr"
label="Adresse email (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
<div class="space-y-4">
<MalioInputAmount
label="Montant (vide)"
:readonly="true"
/>
<MalioInputAmount
model-value="1250.00"
label="Montant (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete</h2>
<div class="space-y-4">
<MalioInputAutocomplete
label="Pays (vide)"
:options="countryOptions"
:readonly="true"
/>
<MalioInputAutocomplete
model-value="de"
label="Pays (rempli)"
icon-name="mdi:lock-outline"
icon-position="left"
:options="countryOptions"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPassword</h2>
<div class="space-y-4">
<MalioInputPassword
label="Mot de passe (vide)"
:readonly="true"
/>
<MalioInputPassword
model-value="motdepasse123"
label="Mot de passe (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
<div class="space-y-4">
<MalioInputTextArea
label="Description (vide)"
:size="3"
:readonly="true"
/>
<MalioInputTextArea
model-value="Ce texte est en lecture seule et ne peut pas être modifié."
label="Description (rempli)"
:size="3"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPhone</h2>
<div class="space-y-4">
<MalioInputPhone
label="Téléphone (vide)"
:readonly="true"
/>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
label="Téléphone (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputUpload</h2>
<div class="space-y-4">
<MalioInputUpload
label="Fichier (vide)"
:readonly="true"
/>
<MalioInputUpload
model-value="document.pdf"
label="Fichier (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelect</h2>
<div class="space-y-4">
<MalioSelect
label="Catégorie (readonly vide)"
:options="categoryOptions"
empty-option-label="Aucune selection"
:readonly="true"
/>
<MalioSelect
:model-value="'a'"
label="Catégorie (readonly rempli)"
:options="categoryOptions"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox</h2>
<div class="space-y-4">
<MalioSelectCheckbox
label="Catégories (readonly vide)"
:options="categoryOptions"
:display-tag="true"
:readonly="true"
/>
<MalioSelectCheckbox
:model-value="['a']"
label="Catégories (readonly rempli)"
:options="categoryOptions"
empty-option-label="Aucune selection"
:display-tag="true"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDate</h2>
<div class="space-y-4">
<MalioDate
label="Date de naissance (vide)"
:readonly="true"
/>
<MalioDate
model-value="2026-06-15"
label="Date de naissance (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
<div class="space-y-4">
<MalioDateTime
label="Date et heure (vide)"
:readonly="true"
/>
<MalioDateTime
model-value="2026-12-25T09:30:00"
label="Date et heure (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
<div class="space-y-4">
<MalioDateRange
label="Période (vide)"
:readonly="true"
/>
<MalioDateRange
:model-value="rangeValue"
label="Période (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
<div class="space-y-4">
<MalioDateWeek
label="Semaine (vide)"
:readonly="true"
/>
<MalioDateWeek
model-value="2026-W52"
label="Semaine (rempli)"
:readonly="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioTimePicker</h2>
<div class="space-y-4">
<MalioTimePicker
label="Heure (vide)"
:readonly="true"
/>
<MalioTimePicker
model-value="14:30"
label="Heure (rempli)"
:readonly="true"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const countryOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
]
const categoryOptions: Option[] = [
{label: 'Catégorie A', value: 'a'},
{label: 'Catégorie B', value: 'b'},
]
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
</script>
+60 -25
View File
@@ -1,48 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
const drawerDefault = ref(false)
const drawerNoClose = ref(false)
const drawerCustomWidth = ref(false)
const drawerWithForm = ref(false)
const drawerRight = ref(false)
const drawerLeft = ref(false)
const drawerForm = ref(false)
const drawerFixedFooter = ref(false)
const drawerNoDismiss = ref(false)
</script>
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Drawer simple</h2>
<MalioButton label="Ouvrir le drawer" @click="drawerDefault = true" />
<MalioDrawer v-model="drawerDefault" title="Titre du drawer">
<p class="text-m-text">Contenu du drawer. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<h2 class="mb-6 text-xl font-bold">Drawer droite (défaut)</h2>
<MalioButton label="Ouvrir à droite" @click="drawerRight = true" />
<MalioDrawer v-model="drawerRight">
<template #header>
<h2 class="text-[24px] font-bold text-black">Détails</h2>
</template>
<p class="text-m-text">Contenu du drawer. Échap, clic backdrop et croix le ferment.</p>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Sans bouton fermer</h2>
<MalioButton label="Ouvrir le drawer" variant="secondary" @click="drawerNoClose = true" />
<MalioDrawer v-model="drawerNoClose" title="Sans croix" :show-close="false">
<p class="text-m-text">Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
<h2 class="mb-6 text-xl font-bold">Drawer gauche</h2>
<MalioButton label="Ouvrir à gauche" variant="secondary" @click="drawerLeft = true" />
<MalioDrawer v-model="drawerLeft" side="left">
<template #header>
<h2 class="text-[24px] font-bold text-black">Navigation</h2>
</template>
<p class="text-m-text">Ce drawer glisse depuis la gauche.</p>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Largeur personnalisée</h2>
<MalioButton label="Ouvrir le drawer large" variant="tertiary" @click="drawerCustomWidth = true" />
<MalioDrawer v-model="drawerCustomWidth" title="Drawer large" drawer-class="max-w-2xl">
<p class="text-m-text">Ce drawer utilise une largeur personnalisée via drawerClass.</p>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec formulaire</h2>
<MalioButton label="Ouvrir le formulaire" variant="danger" @click="drawerWithForm = true" />
<MalioDrawer v-model="drawerWithForm" title="Formulaire">
<div class="flex flex-col gap-4">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
<template #header>
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
<MalioButton label="Enregistrer" button-class="w-full" @click="drawerWithForm = false" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
</template>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Footer fixe avec contenu long</h2>
<MalioButton label="Ouvrir (contenu long)" variant="tertiary" @click="drawerFixedFooter = true" />
<MalioDrawer v-model="drawerFixedFooter">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<!-- Pas de hack : le footer est hors zone scrollable, seul le body défile -->
<div class="flex flex-col gap-4">
<p v-for="n in 12" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll et montrer que seul le body défile, le footer restant fixé en bas.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = false" />
</template>
</MalioDrawer>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
<MalioButton label="Ouvrir" variant="danger" @click="drawerNoDismiss = true" />
<MalioDrawer v-model="drawerNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
</template>
<p class="text-m-text">Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
</MalioDrawer>
</div>
</div>
@@ -0,0 +1,88 @@
<template>
<div class="flex justify-center">
<div class="w-[1348px]">
<div class="flex items-center justify-between mt-[46px]">
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
aria-label="Précédent"
variant="ghost"
/>
<h1 class="text-[32px] text-m-primary font-bold">Filtres</h1>
</div>
<MalioButton
label="Filtres"
variant="tertiary"
icon-name="mdi:tune"
icon-position="left"
button-class="w-[184px] px-2 py-2 justify-start text-black gap-4"
@click="drawerOpen = true"
/>
</div>
</div>
<MalioDrawer
v-model="drawerOpen"
side="right"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between gap-4 py-7"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">Filtres</h2>
</template>
<MalioAccordion>
<MalioAccordionItem title="Type de camion" value="camion">
<div class="flex flex-col gap-6">
<MalioCheckbox v-model="semiBenne" label="Semi Benne" />
<MalioCheckbox v-model="benne" label="Benne" />
</div>
</MalioAccordionItem>
<MalioAccordionItem title="Date à Date" value="date">
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
<span>Du</span>
<MalioDate v-model="dateDebut"/>
<span>Au</span>
<MalioDate v-model="dateFin"/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
label="Réinitialiser"
variant="tertiary"
button-class="w-m-btn-action"
@click="resetFiltres"
/>
<MalioButton
label="Voir les résultats"
variant="primary"
button-class="w-[170px]"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const drawerOpen = ref(false)
const semiBenne = ref(false)
const benne = ref(false)
const dateDebut = ref<string | null>(null)
const dateFin = ref<string | null>(null)
function resetFiltres() {
semiBenne.value = false
benne.value = false
dateDebut.value = null
dateFin.value = null
}
</script>
+250 -52
View File
@@ -1,8 +1,16 @@
<template>
<div>
<div class="flex justify-center mt-8">
<div>
<div class="w-[1348px] grid grid-cols-3 gap-x-[160px] gap-y-8">
<div class="flex justify-center">
<div class="w-[1348px]">
<div class="flex gap-3 mt-[46px]">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
aria-label="Précédent"
variant="ghost"
/>
<h1 class="text-[32px] text-m-primary font-bold">Ajouter un client</h1>
</div>
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
label="Nom du client (Entreprise)"
/>
@@ -13,20 +21,28 @@
label="Prénom du contact principal"
/>
<MalioSelectCheckbox
label="Catégorie"
v-model="multiselectValue"
error="test"
label="Catégorie"
:options="[
{label: 'Catégorie 1', value: 'Catégorie 1'},
{label: 'Catégorie 2', value: 'Catégorie 2'}
]"
/>
<MalioInputText
<MalioInputPhone
v-for="(_, index) in phones"
:key="index"
v-model="phones[index]"
label="Téléphone"
add-icon-name="mdi:plus"
:addable="phones.length === 1"
@add="addPhoneInput"
/>
<MalioInputText
<MalioInputEmail
label="Email"
/>
<MalioSelect
v-model="distributeur"
value=""
label="Distributeur / Courtier"
:options="[
@@ -36,6 +52,7 @@
]"
/>
<MalioSelect
v-model="nomCourtier"
value=""
label="Nom du courtier"
:options="[
@@ -43,83 +60,264 @@
]"
/>
<MalioSelect
v-model="nomDistributeur"
value=""
label="Distributeur / Courtier"
label="Nom du distributeur"
:options="[
{label: 'Nom 1', value: 'Nom 1'}
]"
/>
<MalioCheckbox label="Prestation de triage"/>
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
</div>
<div class="mt-16 flex justify-center">
<div class="mt-12 flex justify-center">
<MalioButton label="Valider" variant="primary"/>
</div>
<div class="mt-[60px]">
<MalioTabList :tabs="tabs" v-model="tabsValue">
<template #information>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
<MalioInputText v-model="concurrent" label="Concurrent"/>
<MalioDate
v-model="dateCreation"
label="Date création"
/>
<MalioInputText label="Nombre de salariés" />
<MalioInputAmount label="CA"/>
<MalioInputText label="Dirigeant" />
<MalioInputText label="Résultat" />
</div>
<div class="mt-12 flex justify-center">
<MalioButton label="Valider" variant="primary"/>
</div>
<div class="flex justify-center mt-16">
<div>
<div class="w-[1348px] grid grid-cols-3 gap-x-[80px] gap-y-8">
<MalioInputText
label="Nom du client (Entreprise)"
/>
<MalioInputText
label="Nom du contact principal"
/>
<MalioInputText
label="Prénom du contact principal"
</template>
<template #adresses>
<div class="relative grid grid-cols-3 gap-x-[80px] gap-y-5 mt-12 bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioButtonIcon
icon="mdi:delete-outline"
aria-label="Supprimer l'adresse"
variant="ghost"
button-class="absolute top-3 right-3"
@click="onDeleteAdresse"
/>
<MalioCheckbox label="Prospect" groupClass="self-center"/>
<MalioCheckbox label="Adresse de livraison" groupClass="self-center"/>
<MalioCheckbox label="Facturation" groupClass="self-center"/>
<MalioSelectCheckbox
label="Catégorie"
v-model="multiselectValue"
label="Catégorie"
:options="[
{label: 'Catégorie 1', value: 'Catégorie 1'},
{label: 'Catégorie 2', value: 'Catégorie 2'}
]"
/>
<MalioInputText
label="Téléphone"
/>
<MalioInputText
label="Email"
/>
<MalioSelect
value=""
label="Distributeur / Courtier"
label="Pays"
v-model="pays"
:options="[
{label: 'Dépend du distributeur', value: 'Dépend du distributeur'},
{label: 'Distributeur', value: 'Distributeur'},
{label: 'Courtier', value: 'Courtier'},
]"
/>
{label: 'France', value: 'France'},
{label: 'Espagne', value: 'Espagne'}
]"/>
<MalioInputText v-model="codePostal" label="Code postal" />
<MalioSelect
value=""
label="Nom du courtier"
:options="[
{label: 'Nom 1', value: 'Nom 1'}
]"
v-model="ville"
label="Ville"
:options="villeOptions"
:no-options-text="villeNoOptionsText"
/>
<MalioSelect
value=""
label="Distributeur / Courtier"
:options="[
{label: 'Nom 1', value: 'Nom 1'}
]"
<MalioInputAutocomplete
v-model="adresse"
label="Adresse"
:options="adresseOptions"
:loading="adresseLoading"
:min-search-length="2"
:no-results-text="adresseNoResultsText"
:min-search-text="adresseMinSearchText"
@search="onSearchAdresse"
/>
<MalioInputText label="Adresse complémentaire"/>
<div class="flex justify-between">
<MalioCheckbox
v-for="dep in departements"
:key="dep"
v-model="departementsSelected[dep]"
:label="dep"
group-class="w-auto self-center"
/>
<MalioCheckbox label="Prestation de triage"/>
</div>
<div class="mt-16 flex justify-center">
<MalioSelect label="Contact" :options="[]"/>
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
</div>
<div class="mt-12 flex justify-center gap-6">
<MalioButton label="Nouvelle Adresse" variant="secondary" icon-name="mdi:add-bold" icon-position="left"/>
<MalioButton label="Valider" variant="primary"/>
</div>
</template>
</MalioTabList>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from "vue";
import MalioSelect from "../../../../app/components/malio/select/Select.vue";
import MalioCheckbox from "../../../../app/components/malio/checkbox/Checkbox.vue";
import MalioButton from "../../../../app/components/malio/button/Button.vue";
import {ref, computed, watch} from 'vue'
import MalioDate from "../../../../app/components/malio/date/Date.vue";
type Commune = {
nom: string
code: string
codesPostaux: string[]
}
type BanFeature = {
properties: {
label: string
id: string
name: string
housenumber?: string
street?: string
postcode: string
citycode: string
city: string
}
}
const multiselectValue = ref<Array<string | number>>([])
const distributeur = ref<string>('')
const phones = ref<string[]>([''])
const nomDistributeur = ref<string>('')
const nomCourtier = ref<string>('')
function addPhoneInput() {
phones.value.push('')
}
function onDeleteAdresse() {
console.log('Supprimer cette adresse')
}
const departements = ['86', '17', '82']
const departementsSelected = ref<Record<string, boolean>>({86: false, 17: false, 82: false})
const pays = ref<string>('France')
const codePostal = ref<string>('')
const ville = ref<string | number | null>(null)
const villeOptions = ref<Array<{label: string; value: string}>>([])
const villeLoading = ref(false)
const villeNoOptionsText = computed(() => {
if (villeLoading.value) return 'Chargement…'
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir un code postal (5 chiffres)'
return 'Aucune ville pour ce code postal'
})
let villeFetchId = 0
watch(codePostal, async (cp) => {
ville.value = null
villeOptions.value = []
adresse.value = null
adresseOptions.value = []
if (!/^\d{5}$/.test(cp)) {
villeLoading.value = false
return
}
const requestId = ++villeFetchId
villeLoading.value = true
try {
const response = await fetch(`https://geo.api.gouv.fr/communes?codePostal=${cp}`)
const data = await response.json() as Commune[]
if (requestId !== villeFetchId) return
villeOptions.value = data.map(c => ({label: c.nom, value: c.code}))
} catch (err) {
if (requestId !== villeFetchId) return
villeOptions.value = []
console.error('Erreur lors du chargement des villes', err)
} finally {
if (requestId === villeFetchId) villeLoading.value = false
}
})
const adresse = ref<string | number | null>(null)
const adresseOptions = ref<Array<{label: string; value: string}>>([])
const adresseLoading = ref(false)
const adresseMinSearchText = computed(() => {
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
return 'Tapez au moins 3 caractères'
})
const adresseNoResultsText = computed(() => {
if (!/^\d{5}$/.test(codePostal.value)) return 'Saisir d\'abord un code postal'
return 'Aucune adresse trouvée'
})
let adresseFetchId = 0
const onSearchAdresse = async (query: string) => {
if (!/^\d{5}$/.test(codePostal.value) || query.length < 3) {
adresseOptions.value = []
adresseLoading.value = false
return
}
const requestId = ++adresseFetchId
adresseLoading.value = true
try {
const params = new URLSearchParams({
q: query,
postcode: codePostal.value,
type: 'housenumber',
})
const response = await fetch(`https://api-adresse.data.gouv.fr/search/?${params.toString()}`)
const data = await response.json() as {features: BanFeature[]}
if (requestId !== adresseFetchId) return
adresseOptions.value = data.features.map(f => ({
label: f.properties.name,
value: f.properties.name,
}))
} catch (err) {
if (requestId !== adresseFetchId) return
adresseOptions.value = []
console.error('Erreur lors du chargement des adresses', err)
} finally {
if (requestId === adresseFetchId) adresseLoading.value = false
}
}
const tabsValue = ref('information')
const concurrent = ref('')
const dateCreation = ref<string | null>(null)
const informationValid = computed(() => concurrent.value.trim().length > 0)
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
const tabs = computed(() => [
{
key: 'information',
label: 'Information',
icon: 'mdi:account-outline',
},
{
key: 'contacts',
label: 'Contacts',
icon: 'mdi:account-box-plus-outline',
disabled: !informationValid.value,
},
{
key: 'adresses',
label: 'Adresses',
icon: 'mdi:map-marker-outline',
disabled: !informationValid.value,
},
{
key: 'transport',
label: 'Transport',
icon: 'mdi:truck-delivery-outline',
disabled: !informationValid.value || !adressesValid.value,
},
{
key: 'comptabilité',
label: 'Comptabilité',
icon: 'mdi:bank-circle-outline',
disabled: !informationValid.value || !adressesValid.value,
},
])
</script>
@@ -36,6 +36,23 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputAmount
label="Montant (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputAmount
v-model="readonlyFilledAmount"
label="Montant (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur et succès</h2>
<div class="mt-4">
@@ -57,4 +74,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
const readonlyFilledAmount = ref('1250.00')
</script>
@@ -0,0 +1,202 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple (statique)</h2>
<MalioInputAutocomplete
v-model="simpleValue"
label="Pays"
:options="staticOptions"
local-filter
/>
<p class="mt-2 text-sm text-m-muted">
Valeur sélectionnée : <code>{{ simpleValue ?? 'null' }}</code>
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec icône à gauche</h2>
<MalioInputAutocomplete
v-model="leftIconValue"
label="Recherche"
icon-name="mdi:magnify"
icon-position="left"
:options="staticOptions"
local-filter
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Branché sur une API (simulé)</h2>
<p class="mb-3 text-sm text-m-muted">
Le parent écoute l'event <code>search</code> et alimente <code>options</code> + <code>loading</code>.
Tapez au moins 2 caractères.
</p>
<MalioInputAutocomplete
v-model="apiValue"
label="Client"
:options="apiOptions"
:loading="apiLoading"
:min-search-length="2"
icon-name="mdi:magnify"
icon-position="left"
@search="onSearchApi"
@select="onSelectApi"
/>
<p v-if="apiSelected" class="mt-2 text-sm text-m-muted">
Sélection : <code>{{ apiSelected.label }} (id={{ apiSelected.value }})</code>
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec création (allowCreate)</h2>
<MalioInputAutocomplete
v-model="createValue"
label="Catégorie"
:options="staticOptions"
allow-create
hint="Taper Entrée pour créer une nouvelle valeur"
@create="onCreate"
/>
<p v-if="createdItems.length > 0" class="mt-2 text-sm text-m-muted">
Créés : {{ createdItems.join(', ') }}
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
readonly
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputAutocomplete
label="Pays (readonly vide)"
:options="staticOptions"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputAutocomplete
v-model="readonlyFilledAutocomplete"
label="Pays (readonly rempli)"
:options="staticOptions"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputAutocomplete
v-model="hintValue"
label="Pays"
:options="staticOptions"
hint="Sélectionne un pays dans la liste"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
error="Sélection invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputAutocomplete
model-value="fr"
label="Pays"
:options="staticOptions"
success="Sélection valide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Liste vide</h2>
<MalioInputAutocomplete
v-model="emptyValue"
label="Recherche"
:options="[]"
no-results-text="Aucun élément disponible"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const staticOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
{label: 'Espagne', value: 'es'},
{label: 'Italie', value: 'it'},
]
const readonlyFilledAutocomplete = ref<string | number | null>('de')
const simpleValue = ref<string | number | null>(null)
const leftIconValue = ref<string | number | null>(null)
const createValue = ref<string | number | null>(null)
const hintValue = ref<string | number | null>(null)
const emptyValue = ref<string | number | null>(null)
const createdItems = ref<string[]>([])
const onCreate = (value: string) => {
createdItems.value.push(value)
}
const apiValue = ref<string | number | null>(null)
const apiOptions = ref<Option[]>([])
const apiLoading = ref(false)
const apiSelected = ref<Option | null>(null)
const fakeClients: Option[] = [
{label: 'Yuno Malio', value: 1},
{label: 'Yuna Corp', value: 2},
{label: 'Yum Foods', value: 3},
{label: 'Yumi Studio', value: 4},
{label: 'Acme Inc.', value: 5},
{label: 'Globex Corp', value: 6},
{label: 'Initech', value: 7},
{label: 'Soylent Corp', value: 8},
]
const onSearchApi = async (query: string) => {
apiLoading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
apiOptions.value = fakeClients.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase()),
)
apiLoading.value = false
}
const onSelectApi = (option: Option | null) => {
apiSelected.value = option
}
</script>
@@ -0,0 +1,144 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputEmail />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
<MalioInputEmail
v-model="emailValue"
label="Adresse email"
name="email"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
<MalioInputEmail
label="Adresse email"
icon-position="left"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
<MalioInputEmail
label="Adresse email"
:icon-name="''"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputEmail
model-value="contact@malio.fr"
disabled
label="Adresse email"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputEmail
model-value="readonly@malio.fr"
readonly
label="Adresse email"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputEmail
label="Adresse email (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputEmail
v-model="readonlyFilledEmail"
label="Adresse email (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputEmail
label="Adresse email"
hint="ex: prenom.nom@malio.fr"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputEmail
model-value="pas-un-email"
label="Adresse email"
error="Adresse email invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputEmail
model-value="contact@malio.fr"
label="Adresse email"
success="Adresse email valide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Validation dynamique</h2>
<MalioInputEmail
v-model="dynamicEmail"
label="Adresse email"
hint="Saisir une adresse au format prenom@domaine.tld"
:error="dynamicError"
:success="dynamicSuccess"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Email obligatoire</h2>
<MalioInputEmail
v-model="requiredEmail"
label="Email obligatoire"
:required="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Email normalisé (minuscules)</h2>
<MalioInputEmail
v-model="lowercaseEmail"
label="Email normalisé (minuscules)"
:lowercase="true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledEmail = ref('contact@malio.fr')
const emailValue = ref('')
const dynamicEmail = ref('')
const requiredEmail = ref('')
const lowercaseEmail = ref('')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const isDynamicValid = computed(() => emailRegex.test(dynamicEmail.value))
const dynamicError = computed(() => {
if (!dynamicEmail.value) return ''
return isDynamicValid.value ? '' : 'Adresse email invalide'
})
const dynamicSuccess = computed(() => {
if (!dynamicEmail.value) return ''
return isDynamicValid.value ? 'Adresse email valide' : ''
})
</script>
@@ -41,6 +41,23 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputPassword
label="Mot de passe (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputPassword
v-model="readonlyFilledPassword"
label="Mot de passe (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputPassword
@@ -83,6 +100,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledPassword = ref('motdepasse123')
const passwordValue = ref('')
const dynamicPassword = ref('')
@@ -0,0 +1,159 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputPhone />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec label</h2>
<MalioInputPhone
v-model="phoneValue"
label="Téléphone"
name="phone"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
<MalioInputPhone
v-model="phoneAddable"
label="Téléphone"
addable
@add="onAdd"
/>
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
Bouton cliqué {{ addClicks }} fois
</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Icône à droite (sans bouton +)</h2>
<MalioInputPhone
label="Téléphone"
icon-position="right"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
<MalioInputPhone
label="Téléphone"
:icon-name="''"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec masque français</h2>
<MalioInputPhone
v-model="phoneFrench"
label="Téléphone (FR)"
mask="+33 # ## ## ## ##"
hint="Saisir uniquement les chiffres"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé (avec addable)</h2>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
addable
disabled
label="Téléphone"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (avec addable)</h2>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
addable
readonly
label="Téléphone"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputPhone
label="Téléphone (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputPhone
v-model="readonlyFilledPhone"
label="Téléphone (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputPhone
label="Téléphone"
hint="Format international recommandé"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputPhone
model-value="abc"
label="Téléphone"
error="Numéro de téléphone invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
label="Téléphone"
success="Numéro valide"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Cas ERP liste de téléphones (max 2)</h2>
<p class="mb-3 text-sm text-m-muted">
Le bouton + s'affiche sur le dernier champ tant que la liste contient moins de {{ MAX_PHONES }} numéros.
</p>
<div class="flex flex-col gap-4">
<MalioInputPhone
v-for="(phone, index) in phones"
:key="index"
v-model="phones[index]"
:label="`Téléphone ${index + 1}`"
:addable="index === phones.length - 1 && phones.length < MAX_PHONES"
@add="addPhone"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const readonlyFilledPhone = ref('+33 6 12 34 56 78')
const phoneValue = ref('')
const phoneAddable = ref('')
const phoneFrench = ref('')
const addClicks = ref(0)
const onAdd = () => {
addClicks.value++
}
const MAX_PHONES = 2
const phones = ref<string[]>([''])
const addPhone = () => {
if (phones.value.length < MAX_PHONES) {
phones.value.push('')
}
}
</script>
@@ -108,6 +108,33 @@
icon-size="20"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputText
label="Référence (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputText
v-model="readonlyFilledValue"
label="Référence (readonly rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Champ obligatoire</h2>
<MalioInputText
label="Champ obligatoire"
:required="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec masque</h2>
<MalioInputText
@@ -154,6 +181,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledValue = ref('Commande #A-2048')
const nameValue = ref('')
const searchValue = ref('')
const codeValue = ref('')
@@ -61,6 +61,25 @@
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputTextArea
label="Description (readonly vide)"
:readonly="true"
:size="3"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputTextArea
v-model="readonlyFilledTextArea"
label="Description (readonly rempli)"
:readonly="true"
:size="3"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Resize avec limites</h2>
<MalioInputTextArea
@@ -94,6 +113,7 @@
import {ref} from 'vue'
import MalioInputTextArea from '../../../../app/components/malio/input/InputTextArea.vue'
const readonlyFilledTextArea = ref('Ce texte est en lecture seule et ne peut pas être modifié.')
const hintValue = ref('')
const iconValue = ref('')
const errorValue = ref('abc')
@@ -31,6 +31,23 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioInputUpload
label="Fichier (readonly vide)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioInputUpload
v-model="readonlyFilledUpload"
label="Fichier (readonly rempli)"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
<MalioInputUpload
@@ -74,6 +91,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const readonlyFilledUpload = ref('document.pdf')
const uploadValue = ref('')
const dynamicUpload = ref('')
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import MalioButton from "../../../../app/components/malio/button/Button.vue";
const modalBase = ref(false)
const modalForm = ref(false)
const modalLong = ref(false)
const modalNoDismiss = ref(false)
</script>
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Modal simple</h2>
<MalioButton label="Ouvrir" @click="modalBase = true" />
<MalioModal v-model="modalBase" headerClass="py-7 px-[25px]" footerClass="flex justify-center pt-8">
<template #header>
<h2 class="text-[24px] font-bold text-black">Marquer comme vu ?</h2>
</template>
<template #footer>
<MalioButton label="Valider"/>
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Avec footer d'actions</h2>
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="modalForm = true" />
<MalioModal v-model="modalForm" modal-class="max-w-lg">
<template #header>
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
</template>
<div class="flex flex-col gap-4 py-2">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
</div>
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="modalForm = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="modalForm = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Contenu long (body scrollable)</h2>
<MalioButton label="Ouvrir" variant="tertiary" @click="modalLong = true" />
<MalioModal v-model="modalLong">
<template #header>
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
</template>
<div class="flex flex-col gap-4">
<p v-for="n in 20" :key="n" class="text-m-text">
Paragraphe {{ n }} contenu long pour forcer le scroll interne ; le header et le footer restent fixes.
</p>
</div>
<template #footer>
<MalioButton label="Accepter" button-class="w-full" @click="modalLong = false" />
</template>
</MalioModal>
</div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
<MalioButton label="Ouvrir" variant="danger" @click="modalNoDismiss = true" />
<MalioModal v-model="modalNoDismiss" :dismissable="false" :close-on-escape="false">
<template #header>
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
</template>
<p class="text-m-text">Ni le backdrop ni Échap ne ferment cette modal. Utilisez la croix.</p>
</MalioModal>
</div>
</div>
</template>
@@ -82,6 +82,17 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sélection obligatoire</h2>
<MalioSelect
v-model="requiredValue"
:options="options"
label="Sélection obligatoire"
:required="true"
empty-option-label="Aucune selection"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Peu d'elements (2)</h2>
<MalioSelect
@@ -92,6 +103,28 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
<MalioSelect
v-model="readonlyEmptyValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
<MalioSelect
v-model="readonlyFilledValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
<MalioSelect
@@ -151,6 +184,7 @@ const longOptions = [
{label: 'Republique tcheque', value: 'cz'},
]
const requiredValue = ref<string | number | null>(null)
const basicValue = ref<string | number | null>(null)
const labelValue = ref<string | number | null>(null)
const selectedValue = ref<string | number | null>('fr')
@@ -162,4 +196,6 @@ const emptyValue = ref<string | number | null>(null)
const shortListValue = ref<string | number | null>(null)
const longListValue = ref<string | number | null>(null)
const bottomValue = ref<string | number | null>(null)
const readonlyEmptyValue = ref<string | number | null>(null)
const readonlyFilledValue = ref<string | number | null>('fr')
</script>
@@ -123,6 +123,28 @@
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (vide)</h2>
<MalioSelectCheckbox
v-model="readonlyEmptyValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule (rempli)</h2>
<MalioSelectCheckbox
v-model="readonlyFilledValue"
:options="options"
label="Pays"
empty-option-label="Aucune selection"
:readonly="true"
/>
</div>
<div class="rounded-lg border p-4 md:col-span-2">
<h2 class="mb-4 text-xl font-bold">Liste longue</h2>
<MalioSelectCheckbox
@@ -145,6 +167,7 @@
empty-option-label="Aucune selection"
/>
</div>
</div>
</template>
@@ -190,4 +213,6 @@ const selectAllValue = ref<Array<string | number>>([])
const selectAllCustomValue = ref<Array<string | number>>([])
const longListValue = ref<Array<string | number>>([])
const bottomValue = ref<Array<string | number>>([])
const readonlyEmptyValue = ref<Array<string | number>>([])
const readonlyFilledValue = ref<Array<string | number>>(['fr'])
</script>
@@ -0,0 +1,56 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTimePicker v-model="simpleValue" label="Heure" />
<p class="mt-2 text-sm text-m-muted">Valeur : {{ simpleValue || '—' }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioTimePicker v-model="initialValue" label="Heure de départ" hint="Format HH:MM" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioTimePicker v-model="disabledValue" label="Heure verrouillée" disabled />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioTimePicker v-model="errorValue" label="Heure de fermeture" error="Heure invalide" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioTimePicker v-model="successValue" label="Heure confirmée" success="Horaire enregistré" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioTimePicker v-model="noClearValue" label="Heure" :clearable="false" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly vide)</h2>
<MalioTimePicker label="Heure (readonly vide)" :readonly="true" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly (readonly rempli)</h2>
<MalioTimePicker v-model="readonlyFilledTime" label="Heure (readonly rempli)" :readonly="true" />
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const readonlyFilledTime = ref('14:30')
const simpleValue = ref('')
const initialValue = ref('08:30')
const disabledValue = ref('14:15')
const errorValue = ref('25:90')
const successValue = ref('09:00')
const noClearValue = ref('10:00')
</script>
+5 -184
View File
@@ -1,189 +1,10 @@
<template>
<div class="flex min-h-screen">
<aside class="w-72 bg-m-bg p-6 text-white">
<button
type="button"
class="text-xl text-black font-semibold"
@click="clearSelection"
>
Liste des composants
</button>
<nav class="mt-6 flex flex-col gap-1">
<div
v-for="group in groups"
:key="group.category"
>
<button
type="button"
class="flex w-full items-center justify-between rounded px-3 py-2 text-left text-black font-bold hover:bg-m-primary/10"
@click="toggleCategory(group.category)"
>
{{ group.category }}
<span
class="text-xs transition-transform duration-200"
:class="openCategories.has(group.category) ? 'rotate-90' : ''"
>
&#9654;
</span>
</button>
<div
v-if="openCategories.has(group.category)"
class="ml-3 flex flex-col gap-1 border-l border-gray-300 pl-2"
>
<button
v-for="item in group.items"
:key="item.name"
type="button"
class="rounded px-3 py-1.5 text-left text-sm text-black hover:bg-m-primary hover:text-white"
:class="selectedName === item.name ? 'bg-m-primary/50 text-white' : ''"
@click="selectItem(item.name)"
>
{{ item.label }}
</button>
</div>
</div>
</nav>
</aside>
<main class="flex-1 p-6">
<component
:is="selectedDemoComponent"
v-if="selectedDemoComponent"
/>
<p
v-else-if="selectedName"
class="text-gray-700"
>
Page de demo introuvable:
<code>.playground/pages/composant/{{ selectedDemoFileName }}.vue</code>
</p>
<div v-else>
<h1 class="text-2xl font-semibold text-gray-900">
Playground composants
<div class="mx-auto max-w-2xl py-16 text-center">
<h1 class="text-3xl font-bold text-m-text">
Playground @malio/layer-ui
</h1>
<p class="mt-2 text-gray-600">
Selectionne un composant dans la liste pour afficher sa page de demo.
<p class="mt-4 text-m-muted">
Sélectionne un composant dans la barre latérale pour afficher sa page de démonstration.
</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import {computed, reactive, ref, watch, shallowRef} from 'vue'
type LoadedModule = {
default: unknown
}
type Item = {
name: string
label: string
}
type Group = {
category: string
items: Item[]
}
const componentModules = import.meta.glob('../../app/components/malio/**/*.vue')
const demoModules = import.meta.glob('./composant/**/*.vue')
const demoByName: Record<string, () => Promise<LoadedModule>> =
Object.fromEntries(
Object.entries(demoModules).map(([file, loader]) => {
const name = file.split('/').pop()?.replace('.vue', '') ?? ''
return [name.toLowerCase(), loader as () => Promise<LoadedModule>]
}),
)
const groups = computed<Group[]>(() => {
const categoryMap = new Map<string, Item[]>()
Object.keys(componentModules).forEach((file) => {
const parts = file.split('/')
const name = parts.pop()?.replace('.vue', '') ?? ''
const category = parts.pop() ?? ''
if (!categoryMap.has(category)) {
categoryMap.set(category, [])
}
categoryMap.get(category)!.push({name, label: name})
})
const componentGroups = Array.from(categoryMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, items]) => ({
category: category.charAt(0).toUpperCase() + category.slice(1),
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}))
return [
...componentGroups,
{
category: 'Form',
items: [{name: 'client', label: 'Client'}],
},
]
})
const openCategories = reactive(new Set<string>())
const selectedName = ref('')
const hasInitializedSelection = ref(false)
watch(
groups,
(val) => {
if (!hasInitializedSelection.value && val.length > 0) {
openCategories.add(val[0].category)
if (val[0].items.length > 0) {
selectedName.value = val[0].items[0].name
}
hasInitializedSelection.value = true
}
},
{immediate: true},
)
function toggleCategory(category: string) {
if (openCategories.has(category)) {
openCategories.delete(category)
} else {
openCategories.add(category)
}
}
function selectItem(name: string) {
selectedName.value = selectedName.value === name ? '' : name
}
function clearSelection() {
selectedName.value = ''
}
const selectedDemoComponent = shallowRef<unknown>(null)
watch(selectedName, async (name) => {
if (!name) {
selectedDemoComponent.value = null
return
}
const loader = demoByName[name.toLowerCase()]
if (!loader) {
selectedDemoComponent.value = null
return
}
const mod = await loader()
selectedDemoComponent.value = mod.default
})
const selectedDemoFileName = computed(() => {
const name = selectedName.value
if (!name) return ''
return name.charAt(0).toLowerCase() + name.slice(1)
})
</script>
+79
View File
@@ -0,0 +1,79 @@
import type {SidebarSection} from '../app/components/malio/sidebar/Sidebar.vue'
export const navSections: SidebarSection[] = [
{
label: 'BOUTONS',
icon: 'mdi:gesture-tap-button',
items: [
{label: 'Button', to: '/composant/button/button'},
{label: 'Button Icon', to: '/composant/button/buttonIcon'},
],
},
{
label: 'CHAMPS',
icon: 'mdi:form-textbox',
items: [
{label: 'Texte', to: '/composant/input/inputText'},
{label: 'Nombre', to: '/composant/input/inputNumber'},
{label: 'Montant', to: '/composant/input/inputAmount'},
{label: 'Email', to: '/composant/input/inputEmail'},
{label: 'Mot de passe', to: '/composant/input/inputPassword'},
{label: 'Téléphone', to: '/composant/input/inputPhone'},
{label: 'Zone de texte', to: '/composant/input/inputTextArea'},
{label: 'Saisie assistée', to: '/composant/input/inputAutocomplete'},
{label: 'Upload', to: '/composant/input/inputUpload'},
{label: 'Éditeur riche', to: '/composant/input/inputRichText'},
],
},
{
label: 'DATES & HEURES',
icon: 'mdi:calendar-clock',
items: [
{label: 'Date', to: '/composant/date/date'},
{label: 'Plage de dates', to: '/composant/date/dateRange'},
{label: 'Semaine', to: '/composant/date/dateWeek'},
{label: 'Date & heure', to: '/composant/date/datetime'},
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'},
],
},
{
label: 'SÉLECTIONS',
icon: 'mdi:form-dropdown',
items: [
{label: 'Select', to: '/composant/select/select'},
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
{label: 'Radio', to: '/composant/radio/radioButton'},
],
},
{
label: 'NAVIGATION',
icon: 'mdi:navigation-variant',
items: [
{label: 'Sidebar', to: '/composant/sidebar/sidebar'},
{label: 'Drawer', to: '/composant/drawer/drawer'},
{label: 'Modal', to: '/composant/modal/modal'},
{label: 'Onglets', to: '/composant/tab/tabList'},
{label: 'Accordéon', to: '/composant/accordion/accordion'},
],
},
{
label: 'DONNÉES',
icon: 'mdi:table',
items: [
{label: 'DataTable', to: '/composant/datatable/datatable'},
],
},
{
label: 'DIVERS',
icon: 'mdi:dots-horizontal',
items: [
{label: 'Champs readonly', to: '/composant/divers/readonly'},
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'},
{label: 'Filtres', to: '/composant/filtre/filtres'},
],
},
]
+25
View File
@@ -27,9 +27,34 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-22] Création d'un composant datatable
* [#MUI-27] Création d'un composant sélection de site
* Création d'un composant rich text (TipTap) avec sortie markdown / HTML
* [#MUI-30] Création d'un composant email
* [#MUI-31] Création d'un composant téléphone
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
* [#MUI-34] Revoir le système de playground
* [#MUI-33] Développer le composant Datepicker
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
* [#MUI-37] Création d'un composant accordéon
* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire)
* InputAutocomplete : prop `localFilter` pour le filtrage côté client des listes statiques (case-insensitive `label.includes(query)`), sans avoir à brancher `@search`
* InputTextArea : la scrollbar passe en primary (bleu) au focus, comme la liste du Select
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
### 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`.
### 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
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
* Distribution de `tailwind.config.ts` aux projets consommateurs avec paths `content` absolus
* Espace réservé (`min-h-[1rem]`) pour le paragraphe hint/error/success de 15 composants (Input*, Select*, Time*, CalendarField, Checkbox) — l'apparition d'une erreur ne décale plus les cellules voisines dans une grille
* InputPhone : la croix `+` (add button) suit la même cascade d'état que les autres icônes du champ (muted / primary en focus / black quand rempli / danger / success) au lieu d'être figée en primary
* Select / SelectCheckbox : le chevron suit l'état du champ (muted par défaut, primary à l'ouverture, black avec une option sélectionnée, danger / success en cas d'erreur ou succès) au lieu de `text-current`
* InputTextArea : composant single-root (était multi-root) — le wrapper du message ne prend plus sa propre cellule de grille, `row-span-2` fonctionne à nouveau
* Label désactivé en `text-m-muted` (gris des bordures) au lieu de `text-black/60` sur les inputs à floating-label (InputText, Email, Password, Amount, Phone, Upload, Autocomplete, TextArea, RichText)
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
+540 -14
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.
> **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
@@ -15,7 +17,7 @@ Champ texte avec label, icône optionnelle et support de masque de saisie.
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
| `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
@@ -53,6 +55,7 @@ Champ mot de passe avec toggle visibilité.
| `displayIcon` | `boolean` | `true` | Afficher l'icône toggle |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
@@ -66,6 +69,164 @@ Champ mot de passe avec toggle visibilité.
---
## MalioInputEmail
Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outline` à droite par défaut.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `label` | `string` | `''` | Label du champ |
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
| `name` | `string` | `''` | Attribut name |
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'email'` pour suggérer l'email utilisateur) |
| `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `lowercase` | `boolean` | `false` | Normalise la saisie en minuscules à la frappe |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `iconName` | `string` | `'mdi:email-outline'` | Icône Iconify (chaîne vide pour masquer) |
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
| `iconSize` | `string \| number` | `24` | Taille icône |
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
| `inputClass` | `string` | `''` | Classes CSS input |
| `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur |
> **Sanitisation à la saisie :** tous les espaces sont supprimés automatiquement au fil de la frappe (sans masque). Avec `lowercase=true`, la valeur est également convertie en minuscules à la frappe. La validation du format (ex. présence d'un `@`) reste à la charge du parent via la prop `error` ou la couche de validation.
**Events :** `update:modelValue(value: string)`
```vue
<MalioInputEmail v-model="email" label="Adresse email" />
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
```
---
## MalioInputPhone
Champ téléphone (`type="tel"` + `inputmode="tel"`) avec icône `mdi:phone-outline` à gauche par défaut et bouton `+` optionnel à droite pour gérer une liste de numéros côté parent.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `label` | `string` | `''` | Label du champ |
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
| `name` | `string` | `''` | Attribut name |
| `autocomplete` | `string` | `'off'` | Autocomplétion (passer `'tel'` pour suggérer un numéro enregistré) |
| `disabled` | `boolean` | `false` | Désactive le champ et le bouton + |
| `readonly` | `boolean` | `false` | Lecture seule (désactive aussi le bouton +) |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `iconName` | `string` | `'mdi:phone-outline'` | Icône Iconify (chaîne vide pour masquer) |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône |
| `iconSize` | `string \| number` | `24` | Taille icône |
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
| `mask` | `string \| MaskInputOptions` | `undefined` | Masque maska (aucun par défaut, utile pour mono-pays) |
| `addable` | `boolean` | `false` | Affiche un bouton à droite qui émet l'event `add` |
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
| `addButtonLabel` | `string` | `'Ajouter un numéro'` | aria-label du bouton d'ajout |
| `inputClass` | `string` | `''` | Classes CSS input |
| `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur |
**Events :**
- `update:modelValue(value: string)`
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
```vue
<MalioInputPhone v-model="phone" label="Téléphone" />
<MalioInputPhone v-model="phone" label="Téléphone (FR)" mask="+33 # ## ## ## ##" />
<MalioInputPhone v-model="phone" label="Téléphone" addable @add="addPhoneField" />
<MalioInputPhone v-model="phone" label="Téléphone" error="Numéro invalide" />
```
---
## MalioInputAutocomplete
Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtrer une liste d'options, ou pour déclencher une recherche côté parent (API). Par défaut le parent alimente `options` et `loading` en réponse à l'event `search` — c'est lui qui gère l'appel API, l'auth, la transformation et le cache. Pour une liste **statique** courte, activer `localFilter` fait filtrer le composant lui-même (case-insensitive `label.includes(query)`) sans avoir à brancher `@search`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `label` | `string` | `''` | Label flottant |
| `modelValue` | `string \| number \| null` | `undefined` | Valeur sélectionnée (v-model) |
| `name` | `string` | `''` | Attribut name |
| `options` | `{label: string; value: string\|number}[]` | `[]` | Liste affichée dans le dropdown |
| `loading` | `boolean` | `false` | Affiche un spinner + un message de chargement |
| `debounce` | `number` | `300` | Délai (ms) avant émission de `search` |
| `minSearchLength` | `number` | `0` | Caractères mini avant d'émettre `search` |
| `allowCreate` | `boolean` | `false` | Autorise la saisie libre validée par Entrée (émet `create`) |
| `localFilter` | `boolean` | `false` | Filtre `options` côté client par sous-chaîne du label (case-insensitive). À utiliser pour les listes statiques courtes ; en mode API on laisse `false` et le parent répond à `@search`. |
| `iconName` | `string` | `''` | Icône Iconify décorative |
| `iconPosition` | `'left' \| 'right'` | `'left'` | Position de l'icône décorative |
| `iconSize` | `string \| number` | `24` | Taille de l'icône |
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur de l'icône |
| `noResultsText` | `string` | `'Aucun résultat'` | Texte affiché quand `options` est vide |
| `loadingText` | `string` | `'Chargement…'` | Texte affiché pendant le chargement |
| `minSearchText` | `string` | `'Tapez pour rechercher'` | Texte affiché tant que `minSearchLength` n'est pas atteint |
| `disabled` | `boolean` | `false` | Désactive le champ et empêche l'ouverture |
| `readonly` | `boolean` | `false` | Lecture seule (n'ouvre pas le dropdown) |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur (prioritaire) |
| `success` | `string` | `''` | Message de succès |
| `inputClass` | `string` | `''` | Classes CSS input |
| `labelClass` | `string` | `''` | Classes CSS label |
| `groupClass` | `string` | `''` | Classes CSS conteneur |
**Events :**
- `update:modelValue(value: string \| number \| null)` — valeur sélectionnée (v-model)
- `search(query: string)` — émis (après debounce + minSearchLength) avec le texte tapé ; le parent l'écoute pour lancer son fetch API
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
```vue
<!-- Usage statique (filtrage côté client via local-filter) -->
<MalioInputAutocomplete v-model="country" label="Pays" :options="countries" local-filter />
<!-- Usage API (parent gère le fetch) -->
<MalioInputAutocomplete
v-model="clientId"
label="Client"
:options="clientOptions"
:loading="isFetching"
:min-search-length="2"
@search="onSearchClients"
@select="onSelectClient"
/>
<!-- Avec création libre -->
<MalioInputAutocomplete
v-model="category"
label="Catégorie"
:options="categories"
allow-create
@create="onCreateCategory"
/>
```
```ts
async function onSearchClients(query: string) {
isFetching.value = true
const res = await $fetch('/api/clients', {params: {q: query}})
clientOptions.value = res.map(c => ({label: c.name, value: c.id}))
isFetching.value = false
}
```
---
## MalioInputAmount
Champ montant avec icône devise (euro par défaut).
@@ -76,6 +237,7 @@ Champ montant avec icône devise (euro par défaut).
| `label` | `string` | `''` | Label |
| `iconName` | `string` | `'mdi:currency-eur'` | Icône devise |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)`
@@ -98,6 +260,7 @@ Champ numérique avec boutons +/-.
| `min` | `number \| string` | — | Valeur minimum |
| `max` | `number \| string` | — | Valeur maximum |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)`
@@ -121,7 +284,9 @@ Zone de texte multiligne avec compteur et redimensionnement.
| `maxLength` | `number` | `800` | Longueur max |
| `showCounter` | `boolean` | `false` | Afficher le compteur |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
| `groupClass` | `string` | `''` | Classes CSS sur la div conteneur (utile pour `row-span-*`, `col-span-*`, etc.) |
**Events :** `update:modelValue(value: string)`
@@ -148,6 +313,7 @@ Zone de texte multiligne avec compteur et redimensionnement.
| `editable` | `boolean` | `true` | `false` → mode affichage seul (toolbar masquée) |
| `disabled` | `boolean` | `false` | Désactive l'édition et la toolbar |
| `readonly` | `boolean` | `false` | Lecture seule (toolbar visible mais désactivée) |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
@@ -178,6 +344,7 @@ Champ d'upload de fichier.
| `accept` | `string` | `''` | Types de fichiers acceptés |
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
@@ -202,11 +369,13 @@ Liste déroulante.
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `disabled` | `boolean` | `false` | Désactivé |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
| `rounded` | `string` | `'rounded-md'` | Classe border-radius |
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
**Events :** `update:modelValue(value: string | number | null)`
**Slots :** `icon` (icône dropdown custom)
@@ -232,6 +401,8 @@ Liste déroulante multi-sélection avec checkboxes.
| `selectAllLabel` | `string` | `'Tout sélectionner'` | Texte du sélecteur global |
| `label` | `string` | `''` | Label |
| `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 |
**Events :** `update:modelValue(value: (string | number)[])`
@@ -252,6 +423,7 @@ Case à cocher.
| `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: boolean)`
@@ -275,6 +447,7 @@ Bouton radio (à utiliser en groupe avec le même `name`).
| `name` | `string` | `''` | Nom du groupe radio |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
**Events :** `update:modelValue(value: string | number | boolean | null)`
@@ -285,6 +458,106 @@ Bouton radio (à utiliser en groupe avec le même `name`).
---
## MalioDate
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivés) |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioDate v-model="date" label="Date de naissance" />
<!-- date === "2026-05-20" -->
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
```
---
## MalioDateRange
Sélecteur de **plage de dates** (date de début → date de fin) dans un seul champ. Cliquer un premier jour démarre la plage, le second la termine ; un survol prévisualise la plage.
La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"`), ou `null`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `{ start: string; end: string } \| null` | `undefined` | Plage de dates ISO (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
```vue
<MalioDateRange v-model="periode" label="Période de séjour" />
<!-- periode === { start: "2026-05-20", end: "2026-05-27" } -->
```
---
## MalioDateWeek
Sélecteur de **semaine ISO** : cliquer un jour (ou un numéro de semaine) sélectionne la semaine entière.
La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2026-W21"`), ou `null`. Le champ affiche `Semaine W (JJ/MM → JJ/MM/AAAA)`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Semaine ISO `"YYYY-Www"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioDateWeek v-model="semaine" label="Semaine de livraison" />
<!-- semaine === "2026-W21" -->
```
---
## MalioTime
Sélecteur d'heure.
@@ -295,6 +568,7 @@ Sélecteur d'heure.
| `label` | `string` | `''` | Label |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `error` | `string` | `''` | Message d'erreur |
**Events :** `update:modelValue(value: string)`
@@ -306,6 +580,72 @@ Sélecteur d'heure.
---
## MalioTimePicker
Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes infinies (heures `0023`, minutes `0059`, pas de 1) avec une bande de sélection centrale ; la valeur centrée est sélectionnée. Défilement, clic sur une valeur (recentrage) ou flèches clavier (`role="spinbutton"`). Pour une saisie clavier directe au format texte, voir plutôt `MalioTime`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) |
| `placeholder` | `string` | `'HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactive le champ |
| `readonly` | `boolean` | `false` | Lecture seule |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
```vue
<MalioTimePicker v-model="heure" label="Heure" />
<MalioTimePicker v-model="heure" label="Départ" hint="Format HH:MM" />
```
---
## MalioDateTime
Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille).
> Depuis MUI-39, le réglage de l'heure utilise le sélecteur à molettes (cf. `MalioTimePicker`), qui remplace l'ancien `<input type="time">` natif intérimaire.
La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date + heure ISO naïve `"YYYY-MM-DDTHH:MM:00"` (v-model) |
| `id` | `string` | `''` | Id du champ |
| `name` | `string` | `''` | Attribut name |
| `label` | `string` | `''` | Label flottant |
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
| `disabled` | `boolean` | `false` | Désactivé |
| `readonly` | `boolean` | `false` | Lecture seule |
| `hint` | `string` | `''` | Texte d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Borne min (datetime ou date ; borne la grille sur la partie date) |
| `max` | `string` | `undefined` | Borne max (idem) |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" -->
```
---
## MalioButton
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
@@ -329,8 +669,11 @@ Bouton d'action avec 4 variantes visuelles et icône optionnelle.
<MalioButton label="Voir plus" variant="tertiary" />
<MalioButton label="Supprimer" variant="danger" icon-name="mdi:trash" icon-position="left" />
<MalioButton label="Pleine largeur" button-class="w-full" />
<MalioButton label="Modifier" button-class="w-m-btn-action" /> <!-- 150px, format bouton d'action -->
```
> **Token de largeur partagé** : `w-m-btn-action` (150px) est exposé via `tailwind.config.ts` du layer, branché sur la CSS var `--m-btn-action-width`. Pour les boutons d'action (listes, lignes de tableau, footers denses…). Themable côté consommateur en redéfinissant `--m-btn-action-width` dans son propre CSS.
---
## MalioButtonIcon
@@ -362,18 +705,90 @@ Navigation par onglets avec contenu dynamique.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
| `tabs` | `{ key: string, label: string, icon?: string }[]` | **requis** | Liste des onglets |
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
**Events :** `update:modelValue(value: string)`
Type `Tab` :
| Propriété | Type | Défaut | Description |
|-----------|------|--------|-------------|
| `key` | `string` | — | Identifiant unique (utilisé pour le slot et le v-model) |
| `label` | `string` | — | Texte de l'onglet |
| `icon` | `string` | — | Nom Iconify (optionnel) |
| `iconSize` | `string` | `24` | Taille de l'icône |
| `disabled` | `boolean` | `false` | Onglet désactivé : grisé et non cliquable. Le parent calcule cet état selon sa logique de validation |
**Events :** `update:modelValue(value: string)` — émis uniquement quand l'onglet cible n'est pas `disabled`
**Slots :** Un slot nommé par `tab.key` pour le contenu de chaque onglet
```vue
<MalioTabList v-model="activeTab" :tabs="[{ key: 'infos', label: 'Informations' }, { key: 'docs', label: 'Documents', icon: 'mdi:file' }]">
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #infos>Contenu infos</template>
<template #docs>Contenu docs</template>
</MalioTabList>
```
**Pattern de gating progressif** (déverrouille les onglets quand les précédents sont valides) :
```ts
const informationValid = computed(() => name.value && email.value)
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))
const tabs = computed(() => [
{ key: 'information', label: 'Information' },
{ key: 'contacts', label: 'Contacts', disabled: !informationValid.value },
{ key: 'adresses', label: 'Adresses', disabled: !informationValid.value },
{ key: 'transport', label: 'Transport', disabled: !informationValid.value || !adressesValid.value },
])
```
---
## MalioAccordion
Accordéon compositionnel : `<MalioAccordion>` enveloppe des `<MalioAccordionItem>`. Plusieurs panneaux ouverts (`multiple`, défaut) ou un seul (`single`). Pensé pour les filtres en drawer et les FAQ.
### MalioAccordion
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts |
| `modelValue` | `string \| string[]` | `undefined` | Clés ouvertes (v-model). `string[]` en `multiple`, `string` en `single` |
| `id` | `string` | auto | Préfixe des IDs d'accessibilité |
| `groupClass` | `string` | `''` | Classes du conteneur (twMerge) |
**Events :** `update:modelValue(value: string | string[])`
### MalioAccordionItem
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `title` | `string` | — | Texte de l'en-tête |
| `value` | `string` | auto | Clé unique de la section |
| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non contrôlé) |
| `disabled` | `boolean` | `false` | En-tête non cliquable |
| `headerClass` | `string` | `''` | Override classes en-tête (twMerge) |
| `panelClass` | `string` | `''` | Override classes panneau (twMerge) |
**Slot :** par défaut = contenu du panneau.
```vue
<!-- Filtres : plusieurs sections ouvertes -->
<MalioAccordion v-model="ouverts">
<MalioAccordionItem title="Prix" value="prix">
<MalioInputAmount v-model="prix" />
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<MalioCheckbox v-model="cats" />
</MalioAccordionItem>
</MalioAccordion>
<!-- FAQ : une seule section ouverte -->
<MalioAccordion mode="single">
<MalioAccordionItem title="Question 1" value="q1">Réponse 1</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">Réponse 2</MalioAccordionItem>
</MalioAccordion>
```
---
## MalioSidebar
@@ -403,29 +818,110 @@ Barre latérale de navigation rétractable.
## MalioDrawer
Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transparent.
Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs drawers.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `title` | `string` | `''` | Titre affiché dans le header |
| `side` | `'right' \| 'left'` | `'right'` | Côté d'apparition |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `drawerClass` | `string` | `''` | Classes CSS panneau (twMerge) |
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
| `drawerClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-2xl` (twMerge) |
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS du footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`
**Slots :** `default` (contenu du drawer)
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.), fixe en haut. S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable : seul le body défile).
- `footer` — actions (boutons). Rendu en bas du panneau, fixe, hors de la zone scrollable. N'apparaît que si le slot est fourni.
```vue
<MalioDrawer v-model="isOpen" title="Détails">
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-[24px] font-bold">Détails</h2>
</template>
<p>Contenu du drawer</p>
</MalioDrawer>
<MalioDrawer v-model="isOpen" title="Sans croix" :show-close="false">
<p>Fermeture uniquement via backdrop</p>
<!-- Côté gauche, largeur custom -->
<MalioDrawer v-model="isOpen" side="left" drawer-class="max-w-2xl">
<template #header><h2>Navigation</h2></template>
<p>Drawer large depuis la gauche</p>
</MalioDrawer>
<MalioDrawer v-model="isOpen" title="Large" drawer-class="max-w-2xl">
<p>Drawer plus large</p>
<!-- Footer d'actions (fixe en bas, hors zone scrollable) -->
<MalioDrawer v-model="isOpen">
<template #header><h2>Formulaire</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
</template>
</MalioDrawer>
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
<MalioDrawer v-model="isOpen" :dismissable="false" :close-on-escape="false">
<template #header><h2>Action requise</h2></template>
<p>Fermeture via la croix uniquement</p>
</MalioDrawer>
```
---
## MalioModal
Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs modals. Structure : header fixe, body scrollable (`max-h-[85vh]`), footer fixe.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
| `modalClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-lg` (twMerge) |
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
| `footerClass` | `string` | `''` | Classes CSS footer fixe (twMerge) |
**Events :** `update:modelValue(value: boolean)`, `close()`
**Slots :**
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
- `default` — contenu (zone scrollable).
- `footer` — actions (boutons). Rendu en bas, fixe, séparé par une bordure. N'apparaît que si le slot est fourni.
```vue
<MalioModal v-model="isOpen">
<template #header>
<h2 class="text-[24px] font-bold">Détails</h2>
</template>
<p>Contenu de la modal</p>
</MalioModal>
<!-- Largeur custom + footer d'actions -->
<MalioModal v-model="isOpen" modal-class="max-w-lg">
<template #header><h2>Nouveau contact</h2></template>
<MalioInputText label="Nom" />
<template #footer>
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="isOpen = false" />
<MalioButton label="Enregistrer" button-class="flex-1" @click="isOpen = false" />
</template>
</MalioModal>
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
<MalioModal v-model="isOpen" :dismissable="false" :close-on-escape="false">
<template #header><h2>Action requise</h2></template>
<p>Fermeture via la croix uniquement</p>
</MalioModal>
```
---
@@ -483,3 +979,33 @@ Tableau de données presentational avec pagination, filtres par slots et lignes
v-model:per-page="perPage"
/>
```
---
## MalioSiteSelector
Sélecteur de site sous forme de tuiles segmentées (`role="radiogroup"`). Chaque site occupe une tuile de largeur égale ; la tuile active s'affiche pleine opacité dans sa couleur (`site.color`), les autres sont atténuées. Pattern contrôlé (`v-model`) ou non contrôlé (premier site sélectionné par défaut).
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `sites` | `{ id: string, name: string, color: string }[]` | **requis** | Liste des sites (la `color` colore la tuile active) |
| `modelValue` | `string` | `undefined` | `id` du site sélectionné (v-model) |
| `id` | `string` | auto | Identifiant HTML du conteneur |
| `groupClass` | `string` | `''` | Classes CSS du conteneur (twMerge) |
| `tileClass` | `string` | `''` | Classes CSS de chaque tuile (twMerge) |
| `labelClass` | `string` | `''` | Classes CSS du label de tuile (twMerge) |
**Events :**
- `update:modelValue(value: string)``id` du site sélectionné (v-model)
- `change(site: Site)` — émis avec l'objet site complet sélectionné
```vue
<MalioSiteSelector
v-model="siteId"
:sites="[
{ id: 'paris', name: 'Paris', color: '#2563eb' },
{ id: 'lyon', name: 'Lyon', color: '#16a34a' },
]"
@change="onSiteChange"
/>
```
+4
View File
@@ -6,6 +6,7 @@
:root {
/* ── Globales ── */
--m-primary: 34 39 131; /* #222783 - Bleu Malio */
--m-primary-light: 239 239 253; /* #EFEFFD - Teinte claire du primary (fonds doux) */
--m-bg: 243 244 248; /* #F3F4F8 - Fond de page */
--m-surface: 243 244 248; /* #F3F4F8 - Fond hover/cartes */
--m-text: 15 23 42; /* #0F172A */
@@ -30,6 +31,9 @@
--m-btn-danger-hover: 234 151 151; /* #EA9797 */
--m-btn-danger-active: 255 83 86; /* #FF5356 */
/* ── Largeurs Boutons ── */
--m-btn-action-width: 150px; /* Boutons d'action (liste, ligne tableau, footer dense…) */
/* ── Couleurs de site (usage ponctuel) ── */
--m-site-blue: 5 108 242; /* #056CF2 - Bleu Châtellerault */
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
@@ -0,0 +1,256 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import {nextTick} from 'vue'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
const TWO_ITEMS = `
<MalioAccordionItem title="Prix" value="prix"><p>Contenu prix</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat"><p>Contenu catégorie</p></MalioAccordionItem>
`
function mountAccordion(props: Record<string, unknown> = {}, slot: string = TWO_ITEMS, attachTo?: HTMLElement) {
return mount(Accordion, {
props,
slots: {default: slot},
attachTo,
global: {components: {MalioAccordionItem: AccordionItem}},
})
}
describe('MalioAccordion — rendu & mode multiple', () => {
it('renders each item header with its title', () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers).toHaveLength(2)
expect(headers[0].text()).toContain('Prix')
expect(headers[1].text()).toContain('Catégorie')
})
it('renders the slot content of each panel', () => {
const wrapper = mountAccordion()
expect(wrapper.html()).toContain('Contenu prix')
expect(wrapper.html()).toContain('Contenu catégorie')
})
it('all panels are collapsed by default', () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('false')
const regions = wrapper.findAll('[role="region"]')
expect(regions[0].classes()).toContain('grid-rows-[0fr]')
})
it('opens a panel on header click (multiple mode is default)', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('true')
const regions = wrapper.findAll('[role="region"]')
expect(regions[0].classes()).toContain('grid-rows-[1fr]')
})
it('keeps multiple panels open simultaneously in multiple mode', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[1].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('true')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('closes an open panel when its header is clicked again', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[0].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('false')
})
it('wires aria-controls / aria-labelledby / role=region correctly', () => {
const wrapper = mountAccordion({id: 'acc'})
const headers = wrapper.findAll('button[aria-expanded]')
const regions = wrapper.findAll('[role="region"]')
expect(headers[0].attributes('id')).toBe('acc-header-prix')
expect(headers[0].attributes('aria-controls')).toBe('acc-panel-prix')
expect(regions[0].attributes('id')).toBe('acc-panel-prix')
expect(regions[0].attributes('aria-labelledby')).toBe('acc-header-prix')
})
it('emits update:modelValue with an array in multiple mode', async () => {
const wrapper = mountAccordion()
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
await nextTick()
})
})
describe('MalioAccordion — mode single & contrôlé', () => {
it('opening a panel closes the others in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[1].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('emits a string in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
})
it('emits empty string when closing the open panel in single mode', async () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('respects modelValue array in controlled multiple mode', () => {
const wrapper = mountAccordion({modelValue: ['cat']})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('respects modelValue string in controlled single mode', () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('true')
expect(headers[1].attributes('aria-expanded')).toBe('false')
})
it('does not mutate local state in controlled mode (emits only)', async () => {
const wrapper = mountAccordion({modelValue: []})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
// état piloté par le parent : sans mise à jour de la prop, reste fermé
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
})
})
describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
const WITH_DEFAULT_OPEN = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :default-open="true"><p>C</p></MalioAccordionItem>
`
const WITH_DISABLED = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :disabled="true"><p>C</p></MalioAccordionItem>
`
it('opens defaultOpen items initially in uncontrolled mode', async () => {
const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN)
await nextTick()
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('sets disabled and aria-disabled on a disabled item', () => {
const wrapper = mountAccordion({}, WITH_DISABLED)
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[1].attributes('disabled')).toBeDefined()
expect(headers[1].attributes('aria-disabled')).toBe('true')
})
it('does not toggle a disabled item on click', async () => {
const wrapper = mountAccordion({}, WITH_DISABLED)
const headers = wrapper.findAll('button[aria-expanded]')
await headers[1].trigger('click')
expect(headers[1].attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('moves focus to the next header on ArrowDown', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[0].element as HTMLElement).focus()
await headers[0].trigger('keydown', {key: 'ArrowDown'})
expect(document.activeElement).toBe(headers[1].element)
wrapper.unmount()
root.remove()
})
it('wraps focus to the first header on ArrowDown from the last', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[1].element as HTMLElement).focus()
await headers[1].trigger('keydown', {key: 'ArrowDown'})
expect(document.activeElement).toBe(headers[0].element)
wrapper.unmount()
root.remove()
})
it('moves focus to the previous header on ArrowUp', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[1].element as HTMLElement).focus()
await headers[1].trigger('keydown', {key: 'ArrowUp'})
expect(document.activeElement).toBe(headers[0].element)
wrapper.unmount()
root.remove()
})
it('skips disabled headers during keyboard navigation', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const slot = `
<MalioAccordionItem title="A" value="a"><p>A</p></MalioAccordionItem>
<MalioAccordionItem title="B" value="b" :disabled="true"><p>B</p></MalioAccordionItem>
<MalioAccordionItem title="C" value="c"><p>C</p></MalioAccordionItem>
`
const wrapper = mountAccordion({}, slot, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[0].element as HTMLElement).focus()
await headers[0].trigger('keydown', {key: 'ArrowDown'})
// saute le header désactivé (B) pour aller directement à C
expect(document.activeElement).toBe(headers[2].element)
wrapper.unmount()
root.remove()
})
})
describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
const ONE = `<MalioAccordionItem title="A" value="a"><p>contenu</p></MalioAccordionItem>`
const ONE_OPEN = `<MalioAccordionItem title="A" value="a" :default-open="true"><p>contenu</p></MalioAccordionItem>`
it('clips the panel (overflow-hidden) while collapsed', () => {
const wrapper = mountAccordion({}, ONE)
const inner = wrapper.find('[role="region"] > div')
expect(inner.classes()).toContain('overflow-hidden')
expect(inner.classes()).not.toContain('overflow-visible')
})
it('lets the panel overflow once open at mount (defaultOpen)', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('switches to overflow-visible after the open transition ends', async () => {
const wrapper = mountAccordion({}, ONE)
await wrapper.find('button[aria-expanded]').trigger('click')
await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('re-clips (overflow-hidden) as soon as it closes', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
await wrapper.find('button[aria-expanded]').trigger('click')
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
})
})
@@ -0,0 +1,109 @@
<template>
<div v-bind="$attrs" :class="rootClass">
<slot />
</div>
</template>
<script setup lang="ts">
import {computed, provide, ref, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey, type AccordionItemRegistration} from './context'
defineOptions({name: 'MalioAccordion', inheritAttrs: false})
const props = withDefaults(defineProps<{
mode?: 'single' | 'multiple'
modelValue?: string | string[]
id?: string
groupClass?: string
}>(), {
mode: 'multiple',
modelValue: undefined,
id: '',
groupClass: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const generatedId = useId()
const baseId = computed(() => props.id || `malio-accordion-${generatedId}`)
const mode = computed(() => props.mode)
const isControlled = computed(() => props.modelValue !== undefined)
const localOpen = ref<string[]>([])
const items = ref<AccordionItemRegistration[]>([])
const openKeys = computed<string[]>(() => {
if (isControlled.value) {
const v = props.modelValue
if (props.mode === 'single') return v ? [v as string] : []
if (Array.isArray(v)) return v
return v ? [v as string] : []
}
return localOpen.value
})
function isOpen(value: string) {
return openKeys.value.includes(value)
}
function toggle(value: string) {
const current = openKeys.value
let next: string[]
if (props.mode === 'single') {
next = current.includes(value) ? [] : [value]
} else {
next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]
}
if (!isControlled.value) {
localOpen.value = next
}
emit('update:modelValue', props.mode === 'single' ? (next[0] ?? '') : next)
}
function register(item: AccordionItemRegistration, defaultOpen: boolean) {
items.value.push(item)
if (defaultOpen && !isControlled.value) {
if (props.mode === 'single') {
if (localOpen.value.length === 0) localOpen.value = [item.value]
} else if (!localOpen.value.includes(item.value)) {
localOpen.value.push(item.value)
}
}
}
function unregister(value: string) {
items.value = items.value.filter(i => i.value !== value)
}
// `items` est ordonné par ordre de montage (= ordre du DOM pour des sections
// statiques/ajoutées en fin). Si un consommateur réordonne dynamiquement les
// items, cet ordre peut diverger de l'ordre visuel ; trier par position DOM
// serait alors nécessaire (hors périmètre v1).
function focusSibling(value: string, offset: 1 | -1) {
const enabled = items.value.filter(i => !i.isDisabled())
const idx = enabled.findIndex(i => i.value === value)
if (idx === -1) return
const next = enabled[(idx + offset + enabled.length) % enabled.length]
next?.getHeaderEl()?.focus()
}
const rootClass = computed(() =>
twMerge('divide-y divide-black border-y border-black', props.groupClass),
)
provide(accordionContextKey, {
mode,
baseId,
isOpen,
toggle,
register,
unregister,
focusSibling,
})
</script>
@@ -0,0 +1,48 @@
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
function mountInAccordion(slot: string, accordionProps: Record<string, unknown> = {}) {
return mount(Accordion, {
props: accordionProps,
slots: {default: slot},
global: {components: {MalioAccordionItem: AccordionItem}},
})
}
describe('MalioAccordionItem', () => {
it('throws when used outside MalioAccordion', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow(
/à l'intérieur de MalioAccordion/,
)
spy.mockRestore()
})
it('generates an auto id-based value and still toggles when value prop is omitted', async () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="Sans value"><p>X</p></MalioAccordionItem>`,
)
const header = wrapper.find('button[aria-expanded]')
expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/)
await header.trigger('click')
expect(header.attributes('aria-expanded')).toBe('true')
})
it('applies headerClass and panelClass overrides via twMerge', () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="T" value="t" header-class="bg-red-500" panel-class="text-lg"><p>X</p></MalioAccordionItem>`,
)
const header = wrapper.find('button[aria-expanded]')
expect(header.classes()).toContain('bg-red-500')
expect(wrapper.find('[role="region"]').html()).toContain('text-lg')
})
it('renders a rotating chevron icon', () => {
const wrapper = mountInAccordion(
`<MalioAccordionItem title="T" value="t"><p>X</p></MalioAccordionItem>`,
)
expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true)
})
})
@@ -0,0 +1,126 @@
<template>
<div>
<h3 class="m-0">
<button
:id="headerId"
ref="headerRef"
type="button"
:class="headerClasses"
:aria-expanded="open"
:aria-controls="panelId"
:disabled="disabled"
:aria-disabled="disabled || undefined"
@click="onToggle"
@keydown.down.prevent="ctx.focusSibling(value, 1)"
@keydown.up.prevent="ctx.focusSibling(value, -1)"
>
<span>{{ title }}</span>
<IconifyIcon
icon="mdi:chevron-down"
:width="24"
class="shrink-0 transition-transform duration-200"
:class="open ? 'rotate-180' : ''"
/>
</button>
</h3>
<div
:id="panelId"
role="region"
:aria-labelledby="headerId"
class="grid transition-[grid-template-rows] duration-200 ease-out"
:class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
@transitionend="onPanelTransitionEnd"
>
<div
:class="overflowVisible ? 'overflow-visible' : 'overflow-hidden'"
:inert="!open || undefined"
>
<div :class="panelInnerClass">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, inject, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey} from './context'
defineOptions({name: 'MalioAccordionItem', inheritAttrs: false})
const props = withDefaults(defineProps<{
title: string
value?: string
defaultOpen?: boolean
disabled?: boolean
headerClass?: string
panelClass?: string
}>(), {
value: '',
defaultOpen: false,
disabled: false,
headerClass: '',
panelClass: '',
})
const ctx = inject(accordionContextKey)
if (!ctx) {
throw new Error('MalioAccordionItem doit être utilisé à l\'intérieur de MalioAccordion')
}
const generatedId = useId()
const value = computed(() => props.value || `malio-accordion-item-${generatedId}`)
const headerRef = ref<HTMLButtonElement | null>(null)
const headerId = computed(() => `${ctx.baseId.value}-header-${value.value}`)
const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`)
const open = computed(() => ctx.isOpen(value.value))
// Le panneau garde `overflow-hidden` pendant l'animation (clipping requis par
// la transition grid-template-rows), puis passe en `overflow-visible` une fois
// complètement ouvert pour qu'un popover enfant (datepicker, select…) ne soit
// pas rogné. On re-clippe dès le début de la fermeture.
const overflowVisible = ref(false)
watch(open, (isOpen) => {
if (!isOpen) overflowVisible.value = false
})
function onPanelTransitionEnd(e: TransitionEvent) {
if (e.propertyName === 'grid-template-rows' && open.value) {
overflowVisible.value = true
}
}
function onToggle() {
if (props.disabled) return
ctx.toggle(value.value)
}
const headerClasses = computed(() =>
twMerge(
'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[20px] text-left font-[600] text-[20px] transition-colors',
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
props.headerClass,
),
)
const panelInnerClass = computed(() => twMerge('px-7 pt-[10px] pb-[20px]', props.panelClass))
onMounted(() => {
ctx.register(
{
value: value.value,
getHeaderEl: () => headerRef.value,
isDisabled: () => props.disabled,
},
props.defaultOpen,
)
// Ouvert au montage (defaultOpen / contrôlé) : pas d'animation, overflow visible direct.
if (open.value) overflowVisible.value = true
})
onBeforeUnmount(() => ctx.unregister(value.value))
</script>
+19
View File
@@ -0,0 +1,19 @@
import type {ComputedRef, InjectionKey} from 'vue'
export interface AccordionItemRegistration {
value: string
getHeaderEl: () => HTMLElement | null
isDisabled: () => boolean
}
export interface AccordionContext {
mode: ComputedRef<'single' | 'multiple'>
baseId: ComputedRef<string>
isOpen: (value: string) => boolean
toggle: (value: string) => void
register: (item: AccordionItemRegistration, defaultOpen: boolean) => void
unregister: (value: string) => void
focusSibling: (value: string, offset: 1 | -1) => void
}
export const accordionContextKey: InjectionKey<AccordionContext> = Symbol('MalioAccordion')
+1 -1
View File
@@ -162,7 +162,7 @@ describe('MalioButton', () => {
it('applies correct dimensions', () => {
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]')
})
+1 -1
View File
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
const mergedButtonClass = computed(() =>
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,
props.buttonClass,
),
+54 -2
View File
@@ -17,6 +17,7 @@ type CheckboxProps = {
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}
const CheckboxForTest = Checkbox as DefineComponent<CheckboxProps>
@@ -114,7 +115,7 @@ describe('MalioCheckbox', () => {
})
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('label').classes()).toContain('text-m-error')
expect(wrapper.get('label').classes()).toContain('text-m-danger')
expect(wrapper.get('p').text()).toBe('You must accept')
})
@@ -125,7 +126,7 @@ describe('MalioCheckbox', () => {
})
expect(wrapper.get('p').text()).toBe('Invalid')
expect(wrapper.get('p').classes()).toContain('text-m-error')
expect(wrapper.get('p').classes()).toContain('text-m-danger')
})
it('shows success styles and message when there is no error', () => {
@@ -139,4 +140,55 @@ describe('MalioCheckbox', () => {
expect(wrapper.get('p').text()).toBe('Valid')
expect(wrapper.get('p').classes()).toContain('text-m-success')
})
it('uses muted label color when unchecked', () => {
const wrapper = mountCheckbox({label: 'Accept terms', modelValue: false})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('uses black label color when checked', () => {
const wrapper = mountCheckbox({label: 'Accept terms', modelValue: true})
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('updates label color when toggled without v-model (uncontrolled)', async () => {
const wrapper = mountCheckbox({label: 'Accept terms'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
await wrapper.get('input').setValue(true)
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountCheckbox({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountCheckbox({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountCheckbox({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountCheckbox({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountCheckbox({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+29 -14
View File
@@ -25,12 +25,12 @@
</svg>
</span>
<span>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</span>
</label>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="mergedMessageClass"
>
@@ -40,8 +40,9 @@
</template>
<script setup lang="ts">
import {computed, useAttrs, useId} from 'vue'
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioCheckbox', inheritAttrs: false})
@@ -60,6 +61,7 @@ const props = withDefaults(
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -75,14 +77,17 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
},
)
const attrs = useAttrs()
const generatedId = useId()
const localChecked = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-checkbox-${generatedId}`)
const isChecked = computed(() => !!props.modelValue)
const isControlled = computed(() => props.modelValue !== undefined)
const isChecked = computed(() => (isControlled.value ? !!props.modelValue : localChecked.value))
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const disabled = computed(() => props.disabled)
@@ -108,9 +113,10 @@ const mergedInputClass = computed(() =>
const mergedLabelClass = computed(() =>
twMerge(
'cbx text-black text-lg',
'cbx text-lg',
isChecked.value ? 'text-black' : 'text-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60' : '',
hasError.value ? 'text-m-error' : '',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
props.labelClass,
),
@@ -119,8 +125,9 @@ const mergedLabelClass = computed(() =>
const mergedMessageClass = computed(() =>
twMerge(
'text-xs',
props.reserveMessageSpace ? 'min-h-[1rem]' : '',
hasError.value
? 'text-m-error'
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: 'text-m-muted',
@@ -139,6 +146,10 @@ const onChange = (event: Event) => {
return
}
if (!isControlled.value) {
localChecked.value = target.checked
}
emit('update:modelValue', target.checked)
}
</script>
@@ -161,10 +172,14 @@ const onChange = (event: Event) => {
height: 18px;
flex: 0 0 18px;
transform: scale(1);
border: 2px solid rgb(0, 0, 0);
border: 2px solid rgb(var(--m-muted) / 1);
transition: all 0.1s ease;
}
.inp-cbx:checked + .cbx span:first-child {
border-color: rgb(0, 0, 0);
}
.cbx span:first-child svg {
position: absolute;
top: 2px;
@@ -200,14 +215,14 @@ const onChange = (event: Event) => {
stroke-dashoffset: 0;
}
.inp-cbx + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
.inp-cbx + .cbx.text-m-danger span:first-child {
border-color: rgb(var(--m-danger) / 1);
}
.cbx.text-m-error span:first-child svg {
stroke: rgb(var(--m-error) / 1);
.cbx.text-m-danger span:first-child svg {
stroke: rgb(var(--m-danger) / 1);
}
.inp-cbx:checked + .cbx.text-m-error span:first-child {
border-color: rgb(var(--m-error) / 1);
.inp-cbx:checked + .cbx.text-m-danger span:first-child {
border-color: rgb(var(--m-danger) / 1);
}
.inp-cbx + .cbx.text-m-success span:first-child {
+7 -5
View File
@@ -57,15 +57,16 @@
<div
v-if="totalItems > 0"
class="flex justify-between pt-2"
class="flex items-center justify-between pt-2"
data-test="pagination"
>
<div class="flex gap-4">
<span class="whitespace-nowrap text-[16px] text-black self-center">Lignes :</span>
<div class="flex items-center gap-4">
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
<div class="h-12">
<MalioSelect
:model-value="perPage"
:options="perPageSelectOptions"
min-width="w-20 !mt-0"
group-class="w-20"
rounded="rounded"
text-field="text-sm"
text-value="text-sm"
@@ -74,8 +75,9 @@
@update:model-value="onPerPageChange"
/>
</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
variant="tertiary"
label="Prev"
+261
View File
@@ -0,0 +1,261 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import Date_ from './Date.vue'
type DateProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
reserveMessageSpace?: boolean
}
const DateForTest = Date_ as DefineComponent<DateProps>
const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body})
describe('MalioDate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('renders the label and the calendar icon', () => {
const wrapper = mountDate({label: 'Date de naissance'})
expect(wrapper.get('label').text()).toBe('Date de naissance')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountDate({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountDate({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('displays the formatted value in the field', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('19/05/2026')
})
it('does not show the popover initially', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('popover', () => {
it('opens on field click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on the current month when there is no value', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
})
it('opens on the value month when a value is set', async () => {
const wrapper = mountDate({modelValue: '2025-12-25'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('closes on outside mousedown', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('navigation jours', () => {
it('goes to the next month on the right chevron', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026')
})
it('rolls December to January and bumps the year', async () => {
const wrapper = mountDate({modelValue: '2026-12-15'})
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027')
})
})
describe('sélection', () => {
it('emits the ISO date and closes on day click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('bornes min/max', () => {
it('disables days outside the range', async () => {
const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
await outside.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('vue mois', () => {
it('switches to month view on header toggle', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
})
it('navigates the year with chevrons in month view', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027')
})
it('returns to day view on month click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-toggle"]').trigger('click')
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('effacement', () => {
it('shows the clear button when there is a value', () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
expect(wrapper.find('[data-test="clear"]').exists()).toBe(true)
})
it('hides the clear button when empty', () => {
const wrapper = mountDate()
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
})
it('emits null and does not open the popover on clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
describe('états', () => {
it('does not open when disabled', async () => {
const wrapper = mountDate({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('does not open when readonly', async () => {
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('readonly vide : bordure noire sans bleu', () => {
const wrapper = mountDate({readonly: true})
const input = wrapper.get('[data-test="date-input"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('border-m-muted')
expect(input.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label muted sans bleu', () => {
const wrapper = mountDate({readonly: true, label: 'Date'})
const label = wrapper.get('label')
expect(label.classes()).toContain('text-m-muted')
expect(label.classes()).not.toContain('text-m-primary')
})
it('readonly vide : icône calendrier en text-m-muted', () => {
const wrapper = mountDate({readonly: true, label: 'Date'})
expect(wrapper.get('[data-test="calendar-icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label et icône en noir, bordure noire', () => {
const wrapper = mountDate({readonly: true, label: 'Date', modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
const label = wrapper.get('label')
const icon = wrapper.get('[data-test="calendar-icon"]')
expect(input.classes()).toContain('border-black')
expect(input.classes()).not.toContain('focus:border-m-primary')
expect(label.classes()).toContain('text-black')
expect(icon.classes()).toContain('text-black')
})
})
describe('accessibilité', () => {
it('sets aria-invalid and describedby on error', () => {
const wrapper = mountDate({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
describe('synchronisation externe', () => {
it('updates the displayed value when modelValue changes', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.setProps({modelValue: '2026-12-25'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('25/12/2026')
})
})
describe('reserveMessageSpace', () => {
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountDate({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountDate({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
})
+93
View File
@@ -0,0 +1,93 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="modelValue ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="emit('update:modelValue', null)"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="modelValue ?? null"
:min="min"
:max="max"
@select="(iso) => { emit('update:modelValue', iso); close() }"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
watch(() => props.modelValue, (val) => {
if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
}
})
</script>
+155
View File
@@ -0,0 +1,155 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateRange from './DateRange.vue'
type RangeValue = {start: string; end: string}
type DateRangeProps = {
modelValue?: RangeValue | null
label?: string
disabled?: boolean
readonly?: boolean
error?: string
min?: string
max?: string
clearable?: boolean
}
const DateRangeForTest = DateRange as DefineComponent<DateRangeProps>
const mountRange = (props: DateRangeProps = {}) =>
mount(DateRangeForTest, {props, attachTo: document.body})
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, isos: string[]) => {
await wrapper.get('[data-test="date-input"]').trigger('click')
for (const iso of isos) {
await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click')
}
}
describe('MalioDateRange', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('renders the label and calendar icon', () => {
const wrapper = mountRange({label: 'Période'})
expect(wrapper.get('label').text()).toBe('Période')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted range when modelValue is set', () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('19/05/2026 - 25/05/2026')
})
it('shows an empty field without a value', () => {
const wrapper = mountRange()
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('')
})
it('opens on the start month when a range is set', async () => {
const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('does not emit on the first click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('emits the range and closes on the second click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('auto-inverts when the second click is before the first', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}])
})
it('allows a single-day range', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19'])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}])
})
it('restarts a new range on the third click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25'])
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}])
})
it('previews the range on hover while selecting', async () => {
const wrapper = mountRange()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
})
it('does not preview before selecting', async () => {
const wrapper = mountRange()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('none')
})
it('marks start, end and in-range roles for a committed range', async () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
})
it('cancels an in-progress selection on outside click', async () => {
const wrapper = mountRange()
await openAndClickDays(wrapper, ['2026-05-19'])
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none')
})
it('emits null on clear', async () => {
const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('disables days outside min/max', async () => {
const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
await outside.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('sets aria-invalid on error', () => {
const wrapper = mountRange({error: 'Période requise'})
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Période requise')
})
it('does not open when disabled', async () => {
const wrapper = mountRange({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
})
+140
View File
@@ -0,0 +1,140 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validRange?.start ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="rangeStart"
:range-end="rangeEnd"
:preview-date="previewDate"
:min="min"
:max="max"
@select="(iso) => onSelectDay(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: DateRangeValue | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
const pendingStart = ref<string | null>(null)
const hoverDate = ref<string | null>(null)
const isSelecting = computed(() => pendingStart.value !== null)
const validRange = computed<DateRangeValue | null>(() => {
const v = props.modelValue
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
return null
})
const rangeStart = computed(() =>
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
)
const rangeEnd = computed(() =>
isSelecting.value ? null : (validRange.value?.end ?? null),
)
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
const displayValue = computed(() => {
if (isSelecting.value || !validRange.value) return ''
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
})
const onSelectDay = (iso: string, close: () => void) => {
if (pendingStart.value === null) {
pendingStart.value = iso
hoverDate.value = null
return
}
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
pendingStart.value = null
hoverDate.value = null
close()
}
const onHover = (iso: string | null) => {
if (isSelecting.value) hoverDate.value = iso
}
const onClose = () => {
pendingStart.value = null
hoverDate.value = null
}
const onClear = () => {
emit('update:modelValue', null)
pendingStart.value = null
hoverDate.value = null
}
</script>
+123
View File
@@ -0,0 +1,123 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateTime_ from './DateTime.vue'
import MalioTimePicker from '../time/TimePicker.vue'
type DateTimeProps = {
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
const mountDateTime = (props: DateTimeProps = {}) =>
mount(DateTimeForTest, {props, attachTo: document.body})
describe('MalioDateTime', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05
})
afterEach(() => vi.useRealTimers())
describe('rendu', () => {
it('affiche le label et l\'icône calendrier', () => {
const wrapper = mountDateTime({label: 'Rendez-vous'})
expect(wrapper.get('label').text()).toBe('Rendez-vous')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('affiche la valeur formatée date + heure dans le champ', () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('20/05/2026 14:30')
})
})
describe('popover', () => {
it('ouvre la grille et le champ sélecteur d\'heure au clic', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
expect(wrapper.findComponent(MalioTimePicker).exists()).toBe(true)
expect(wrapper.find('[data-test="time-field"]').exists()).toBe(true)
})
})
describe('sélection', () => {
it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
// heure système figée à 09:05
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('applique l\'heure réglée avant le clic du jour', async () => {
const wrapper = mountDateTime()
await wrapper.get('[data-test="date-input"]').trigger('click')
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '09:15')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
})
it('met à jour l\'heure quand une date est déjà choisie', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '08:45')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
})
it('initialise le champ heure depuis la valeur', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.findComponent(MalioTimePicker).props('modelValue')).toBe('14:30')
})
})
describe('bornes min/max', () => {
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
})
})
describe('effacement', () => {
it('émet null au clic sur la croix', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
})
describe('accessibilité', () => {
it('positionne aria-invalid et describedby sur erreur', () => {
const wrapper = mountDateTime({error: 'Date requise'})
const input = wrapper.get('[data-test="date-input"]')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBeTruthy()
expect(wrapper.text()).toContain('Date requise')
})
})
})
+133
View File
@@ -0,0 +1,133 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="datePart"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
>
<template #default="{ currentMonth, currentYear }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="datePart"
:min="min?.slice(0, 10)"
:max="max?.slice(0, 10)"
@select="onSelectDay"
/>
<div class="mt-4">
<MalioTimePicker
:model-value="timeValue || null"
label="Heure"
:clearable="false"
static-popover
@update:model-value="onTimeChange"
/>
</div>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import MalioTimePicker from '../time/TimePicker.vue'
import {formatTime} from '../time/composables/timeFormat'
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA HH:MM',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
const pendingTime = ref('')
const parts = computed(() => splitDateTime(props.modelValue ?? null))
const datePart = computed(() => parts.value.date)
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
const timeValue = computed(() => parts.value.time || pendingTime.value)
function onSelectDay(iso: string) {
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
// (heure courante au moment du clic)
const now = new Date()
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
emit('update:modelValue', composeDateTime(iso, time))
}
function onTimeChange(value: string | null) {
if (!value) return
if (datePart.value) {
emit('update:modelValue', composeDateTime(datePart.value, value))
}
else {
pendingTime.value = value
}
}
function onClear() {
pendingTime.value = ''
emit('update:modelValue', null)
}
watch(() => props.modelValue, (val) => {
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
}
})
</script>
+122
View File
@@ -0,0 +1,122 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import DateWeek from './DateWeek.vue'
type DateWeekProps = {
modelValue?: string | null
label?: string
disabled?: boolean
readonly?: boolean
error?: string
min?: string
max?: string
}
const DateWeekForTest = DateWeek as DefineComponent<DateWeekProps>
const mountWeek = (props: DateWeekProps = {}) =>
mount(DateWeekForTest, {props, attachTo: document.body})
describe('MalioDateWeek', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('renders the label and calendar icon', () => {
const wrapper = mountWeek({label: 'Semaine'})
expect(wrapper.get('label').text()).toBe('Semaine')
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
})
it('displays the formatted week when modelValue is set', () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('shows an empty field without a value', () => {
const wrapper = mountWeek()
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
expect(input.value).toBe('')
})
it('opens on the month of the selected week', async () => {
const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025')
})
it('selects the week when a day is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('selects the week when the week number is clicked', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21'])
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('previews the whole week on day hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range')
})
it('previews the whole week on week-number hover', async () => {
const wrapper = mountWeek()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range')
})
it('marks the committed week number', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true')
expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start')
})
it('emits null on clear', async () => {
const wrapper = mountWeek({modelValue: '2026-W21'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
})
it('disables a week fully outside min/max', async () => {
const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'})
await wrapper.get('[data-test="date-input"]').trigger('click')
const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]')
expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true)
const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]')
expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false)
})
it('does not open when disabled', async () => {
const wrapper = mountWeek({disabled: true})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('does not open when readonly', async () => {
const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
})
it('sets aria-invalid on error', () => {
const wrapper = mountWeek({error: 'Semaine requise'})
expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Semaine requise')
})
})
+123
View File
@@ -0,0 +1,123 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validWeek?.monday ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="activeMonday"
:range-end="activeSunday"
:marked-week-start="validWeek?.monday ?? null"
interactive-week-number
:min="min"
:max="max"
@select="(iso) => onSelect(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatWeekDisplay, isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek} from './composables/dateWeek'
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
min?: string
max?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const hoverWeekStart = ref<string | null>(null)
const validWeek = computed(() => {
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
return {monday: isoWeekToMonday(props.modelValue) as string}
}
return null
})
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
const onSelect = (iso: string, close: () => void) => {
emit('update:modelValue', toIsoWeek(iso))
hoverWeekStart.value = null
close()
}
const onHover = (iso: string | null) => {
hoverWeekStart.value = iso ? mondayOf(iso) : null
}
const onClose = () => {
hoverWeekStart.value = null
}
const onClear = () => {
emit('update:modelValue', null)
hoverWeekStart.value = null
}
</script>
@@ -0,0 +1,62 @@
import {describe, expect, it} from 'vitest'
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
describe('dateFormat', () => {
describe('isValidIso', () => {
it('accepts a real ISO date', () => {
expect(isValidIso('2026-05-19')).toBe(true)
})
it('rejects a malformed string', () => {
expect(isValidIso('19/05/2026')).toBe(false)
expect(isValidIso('2026-5-9')).toBe(false)
expect(isValidIso('')).toBe(false)
})
it('rejects an impossible date', () => {
expect(isValidIso('2026-02-30')).toBe(false)
expect(isValidIso('2026-13-01')).toBe(false)
})
it('accepts Feb 29 on a leap year and rejects it otherwise', () => {
expect(isValidIso('2024-02-29')).toBe(true)
expect(isValidIso('2026-02-29')).toBe(false)
})
})
describe('formatIsoToDisplay', () => {
it('formats ISO to DD/MM/YYYY', () => {
expect(formatIsoToDisplay('2026-05-19')).toBe('19/05/2026')
})
it('returns empty string for null or invalid input', () => {
expect(formatIsoToDisplay(null)).toBe('')
expect(formatIsoToDisplay('nope')).toBe('')
})
})
describe('parseDisplayToIso', () => {
it('parses DD/MM/YYYY to ISO', () => {
expect(parseDisplayToIso('19/05/2026')).toBe('2026-05-19')
})
it('returns null for malformed or impossible input', () => {
expect(parseDisplayToIso('2026-05-19')).toBeNull()
expect(parseDisplayToIso('31/02/2026')).toBeNull()
expect(parseDisplayToIso('')).toBeNull()
})
})
describe('isDateInRange', () => {
it('returns true when no bounds are given', () => {
expect(isDateInRange('2026-05-19')).toBe(true)
})
it('respects the min bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-18', '2026-05-19')).toBe(false)
})
it('respects the max bound (inclusive)', () => {
expect(isDateInRange('2026-05-19', undefined, '2026-05-19')).toBe(true)
expect(isDateInRange('2026-05-20', undefined, '2026-05-19')).toBe(false)
})
it('respects both bounds', () => {
expect(isDateInRange('2026-05-15', '2026-05-10', '2026-05-20')).toBe(true)
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
})
})
})
@@ -0,0 +1,26 @@
export function isValidIso(iso: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
const [y, m, d] = iso.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
}
export function formatIsoToDisplay(iso: string | null): string {
if (!iso || !isValidIso(iso)) return ''
const [y, m, d] = iso.split('-')
return `${d}/${m}/${y}`
}
export function parseDisplayToIso(display: string): string | null {
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(display.trim())
if (!match) return null
const [, dd, mm, yyyy] = match
const iso = `${yyyy}-${mm}-${dd}`
return isValidIso(iso) ? iso : null
}
export function isDateInRange(iso: string, min?: string, max?: string): boolean {
if (min && iso < min) return false
if (max && iso > max) return false
return true
}
@@ -0,0 +1,57 @@
import {describe, expect, it} from 'vitest'
import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange'
describe('dateRange', () => {
describe('normalizeRange', () => {
it('keeps an already ordered pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('swaps a reversed pair', () => {
expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'})
})
it('handles an equal pair', () => {
expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'})
})
})
describe('resolveRangeBounds', () => {
it('returns null without a start', () => {
expect(resolveRangeBounds(null, null, null)).toBeNull()
})
it('returns a single-point range when only start is set', () => {
expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'})
})
it('orders start and committed end', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
it('uses preview when end is not set', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'})
})
it('inverts when preview is before start', () => {
expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'})
})
it('prioritises committed end over preview', () => {
expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
})
})
describe('dayRangeRole', () => {
const bounds = {lo: '2026-05-19', hi: '2026-05-25'}
it('returns none without bounds', () => {
expect(dayRangeRole('2026-05-20', null)).toBe('none')
})
it('returns single when lo === hi and matches', () => {
expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single')
expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none')
})
it('returns start, end and in-range', () => {
expect(dayRangeRole('2026-05-19', bounds)).toBe('start')
expect(dayRangeRole('2026-05-25', bounds)).toBe('end')
expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range')
})
it('returns none outside the bounds', () => {
expect(dayRangeRole('2026-05-10', bounds)).toBe('none')
expect(dayRangeRole('2026-05-30', bounds)).toBe('none')
})
})
})
@@ -0,0 +1,31 @@
export type DateRangeValue = {start: string; end: string}
export function normalizeRange(a: string, b: string): DateRangeValue {
return a <= b ? {start: a, end: b} : {start: b, end: a}
}
export function resolveRangeBounds(
start: string | null,
end: string | null,
preview: string | null,
): {lo: string; hi: string} | null {
if (!start) return null
const other = end ?? preview
if (!other) return {lo: start, hi: start}
return start <= other ? {lo: start, hi: other} : {lo: other, hi: start}
}
export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
export function dayRangeRole(
iso: string,
bounds: {lo: string; hi: string} | null,
): DayRangeRole {
if (!bounds) return 'none'
const {lo, hi} = bounds
if (lo === hi) return iso === lo ? 'single' : 'none'
if (iso === lo) return 'start'
if (iso === hi) return 'end'
if (iso > lo && iso < hi) return 'in-range'
return 'none'
}
@@ -0,0 +1,74 @@
import {describe, expect, it} from 'vitest'
import {
formatWeekDisplay,
isValidIsoWeek,
isoWeekToMonday,
mondayOf,
sundayOf,
toIsoWeek,
} from './dateWeek'
describe('dateWeek', () => {
describe('mondayOf / sundayOf', () => {
it('returns Monday and Sunday of a midweek date', () => {
expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi
expect(sundayOf('2026-05-20')).toBe('2026-05-24')
})
it('keeps Monday on a Monday', () => {
expect(mondayOf('2026-05-18')).toBe('2026-05-18')
})
it('returns the preceding Monday for a Sunday', () => {
expect(mondayOf('2026-05-24')).toBe('2026-05-18')
})
})
describe('toIsoWeek', () => {
it('returns the ISO week of a date', () => {
expect(toIsoWeek('2026-05-20')).toBe('2026-W21')
})
it('handles year boundaries', () => {
expect(toIsoWeek('2026-01-01')).toBe('2026-W01')
expect(toIsoWeek('2025-12-31')).toBe('2026-W01')
expect(toIsoWeek('2027-01-01')).toBe('2026-W53')
})
})
describe('isoWeekToMonday', () => {
it('returns the Monday of a week string', () => {
expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18')
})
it('round-trips with toIsoWeek', () => {
for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) {
const monday = isoWeekToMonday(w)
expect(monday).not.toBeNull()
expect(toIsoWeek(monday as string)).toBe(w)
}
})
it('returns null for invalid input', () => {
expect(isoWeekToMonday('2026-21')).toBeNull()
expect(isoWeekToMonday('2026-W00')).toBeNull()
expect(isoWeekToMonday('2026-W54')).toBeNull()
expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO
})
})
describe('isValidIsoWeek', () => {
it('accepts a real ISO week', () => {
expect(isValidIsoWeek('2026-W21')).toBe(true)
})
it('rejects malformed or impossible weeks', () => {
expect(isValidIsoWeek('2026-21')).toBe(false)
expect(isValidIsoWeek('2026-W00')).toBe(false)
expect(isValidIsoWeek('2026-W54')).toBe(false)
})
})
describe('formatWeekDisplay', () => {
it('formats a week as a human label', () => {
expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)')
})
it('returns empty string for invalid input', () => {
expect(formatWeekDisplay('2026-W54')).toBe('')
})
})
})
@@ -0,0 +1,67 @@
import {formatIsoToDisplay} from './dateFormat'
const parseUtc = (iso: string): Date => {
const [y, m, d] = iso.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d))
}
const toIso = (d: Date): string => {
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function mondayOf(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7 // dimanche = 7
d.setUTCDate(d.getUTCDate() - (dayNum - 1))
return toIso(d)
}
export function sundayOf(iso: string): string {
const d = parseUtc(mondayOf(iso))
d.setUTCDate(d.getUTCDate() + 6)
return toIso(d)
}
export function toIsoWeek(iso: string): string {
const d = parseUtc(iso)
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const isoYear = d.getUTCFullYear()
const yearStart = new Date(Date.UTC(isoYear, 0, 1))
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
return `${isoYear}-W${String(week).padStart(2, '0')}`
}
export function isoWeekToMonday(week: string): string | null {
const m = /^(\d{4})-W(\d{2})$/.exec(week)
if (!m) return null
const year = Number(m[1])
const w = Number(m[2])
if (w < 1 || w > 53) return null
// Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier
const jan4 = new Date(Date.UTC(year, 0, 4))
const jan4Day = jan4.getUTCDay() || 7
const monday = new Date(jan4)
monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7)
const iso = toIso(monday)
// Garde-fou : la semaine 53 n'existe pas pour toutes les années
if (toIsoWeek(iso) !== week) return null
return iso
}
export function isValidIsoWeek(week: string): boolean {
return isoWeekToMonday(week) !== null
}
export function formatWeekDisplay(week: string): string {
const monday = isoWeekToMonday(week)
if (!monday) return ''
const sunday = sundayOf(monday)
const w = Number(week.slice(6))
const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05"
const endFull = formatIsoToDisplay(sunday) // "24/05/2026"
return `Semaine ${w} (${startDdMm}${endFull})`
}
@@ -0,0 +1,61 @@
import {describe, expect, it} from 'vitest'
import {
composeDateTime,
formatIsoDateTimeToDisplay,
isValidIsoDateTime,
splitDateTime,
} from './datetimeFormat'
describe('datetimeFormat', () => {
describe('isValidIsoDateTime', () => {
it('accepte un datetime ISO complet valide', () => {
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
})
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
expect(isValidIsoDateTime('')).toBe(false)
})
})
describe('formatIsoDateTimeToDisplay', () => {
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
})
it('renvoie une chaîne vide pour nul ou invalide', () => {
expect(formatIsoDateTimeToDisplay(null)).toBe('')
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
})
})
describe('splitDateTime', () => {
it('découpe un datetime valide', () => {
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
})
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
expect(splitDateTime(null)).toEqual({date: null, time: ''})
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
})
})
describe('composeDateTime', () => {
it('recompose un datetime ISO avec secondes à 00', () => {
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
})
it('utilise 00:00 quand l\'heure est vide', () => {
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
})
})
})
@@ -0,0 +1,33 @@
import {isValidIso} from './dateFormat'
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
export function isValidIsoDateTime(s: string): boolean {
const m = DATETIME_RE.exec(s)
if (!m) return false
const [, date, hh, mm, ss] = m
if (!isValidIso(date)) return false
const h = Number(hh)
const min = Number(mm)
const sec = Number(ss)
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
}
export function formatIsoDateTimeToDisplay(s: string | null): string {
if (!s || !isValidIsoDateTime(s)) return ''
const [date, time] = s.split('T')
const [y, mo, d] = date.split('-')
const [hh, mm] = time.split(':')
return `${d}/${mo}/${y} ${hh}:${mm}`
}
export function splitDateTime(s: string | null): {date: string | null; time: string} {
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
const [date, time] = s.split('T')
return {date, time: time.slice(0, 5)}
}
export function composeDateTime(date: string, time: string): string {
const t = time || '00:00'
return `${date}T${t}:00`
}
@@ -0,0 +1,64 @@
import {describe, expect, it} from 'vitest'
import {defineComponent, h, ref} from 'vue'
import {mount} from '@vue/test-utils'
import {useCalendarPopover} from './useCalendarPopover'
const mountHost = () => {
const api: ReturnType<typeof useCalendarPopover> = {} as never
const Host = defineComponent({
setup() {
const root = ref<HTMLElement | null>(null)
Object.assign(api, useCalendarPopover(root))
return () => h('div', {ref: root}, 'host')
},
})
const wrapper = mount(Host, {attachTo: document.body})
return {wrapper, api}
}
describe('useCalendarPopover', () => {
it('starts closed in days view', () => {
const {api} = mountHost()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('open() opens in days view', () => {
const {api} = mountHost()
api.open()
expect(api.isOpen.value).toBe(true)
expect(api.viewMode.value).toBe('days')
})
it('toggleView() switches between days and months', () => {
const {api} = mountHost()
api.open()
api.toggleView()
expect(api.viewMode.value).toBe('months')
api.toggleView()
expect(api.viewMode.value).toBe('days')
})
it('close() resets isOpen and viewMode', () => {
const {api} = mountHost()
api.open()
api.toggleView()
api.close()
expect(api.isOpen.value).toBe(false)
expect(api.viewMode.value).toBe('days')
})
it('closes on outside mousedown', () => {
const {api} = mountHost()
api.open()
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(false)
})
it('stays open on inside mousedown', () => {
const {wrapper, api} = mountHost()
api.open()
wrapper.element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
expect(api.isOpen.value).toBe(true)
})
})
@@ -0,0 +1,28 @@
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
const isOpen = ref(false)
const viewMode = ref<'days' | 'months'>('days')
const open = () => {
isOpen.value = true
viewMode.value = 'days'
}
const close = () => {
isOpen.value = false
viewMode.value = 'days'
}
const toggleView = () => {
viewMode.value = viewMode.value === 'days' ? 'months' : 'days'
}
const onMouseDown = (event: MouseEvent) => {
if (!isOpen.value || !rootRef.value) return
if (!rootRef.value.contains(event.target as Node)) close()
}
onMounted(() => document.addEventListener('mousedown', onMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
return {isOpen, viewMode, open, close, toggleView}
}
@@ -0,0 +1,68 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ref} from 'vue'
import {useCalendarView} from './useCalendarView'
describe('useCalendarView', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('initialises to the current month and year', () => {
const {currentMonth, currentYear} = useCalendarView(ref('days'))
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
it('goToNext advances the month in days view', () => {
const {currentMonth, goToNext} = useCalendarView(ref('days'))
goToNext()
expect(currentMonth.value).toBe(5)
})
it('rolls December to January and bumps the year', () => {
const {currentMonth, currentYear, goToNext} = useCalendarView(ref('days'))
currentMonth.value = 11
goToNext()
expect(currentMonth.value).toBe(0)
expect(currentYear.value).toBe(2027)
})
it('rolls January to December backwards', () => {
const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days'))
currentMonth.value = 0
goToPrev()
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('navigates the year in months view', () => {
const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months'))
goToNext()
expect(currentYear.value).toBe(2027)
goToPrev()
expect(currentYear.value).toBe(2026)
})
it('selectMonth sets the current month', () => {
const {currentMonth, selectMonth} = useCalendarView(ref('days'))
selectMonth(0)
expect(currentMonth.value).toBe(0)
})
it('syncToIso sets month/year from a valid ISO', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
expect(currentMonth.value).toBe(11)
expect(currentYear.value).toBe(2025)
})
it('syncToIso falls back to today for null/invalid', () => {
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
syncToIso('2025-12-25')
syncToIso(null)
expect(currentMonth.value).toBe(4)
expect(currentYear.value).toBe(2026)
})
})
@@ -0,0 +1,51 @@
import {ref, type Ref} from 'vue'
import {isValidIso} from './dateFormat'
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const goToPrev = () => {
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
const goToNext = () => {
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
const selectMonth = (m: number) => {
currentMonth.value = m
}
const syncToIso = (iso: string | null) => {
if (iso && isValidIso(iso)) {
currentMonth.value = Number(iso.slice(5, 7)) - 1
currentYear.value = Number(iso.slice(0, 4))
} else {
const now = new Date()
currentMonth.value = now.getMonth()
currentYear.value = now.getFullYear()
}
}
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
}
@@ -0,0 +1,69 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {ref} from 'vue'
import {useMonthMatrix} from './useMonthMatrix'
describe('useMonthMatrix', () => {
it('always produces 6 weeks of 7 days', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai 2026
expect(weeks.value).toHaveLength(6)
weeks.value.forEach(week => expect(week.days).toHaveLength(7))
})
it('starts every week on a Monday', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
weeks.value.forEach(week => {
const first = new Date(`${week.days[0].isoDate}T00:00:00`)
expect(first.getDay()).toBe(1) // 1 = lundi
})
})
it('flags exactly the days of the current month', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai = 31 jours
const currentMonthDays = weeks.value
.flatMap(w => w.days)
.filter(d => d.isCurrentMonth)
expect(currentMonthDays).toHaveLength(31)
expect(currentMonthDays.every(d => d.isoDate.startsWith('2026-05'))).toBe(true)
})
it('handles leap year February (29 days)', () => {
const {weeks} = useMonthMatrix(ref(1), ref(2024)) // février 2024
const days = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth)
expect(days).toHaveLength(29)
})
it('assigns ISO week 1 to the week containing Jan 4th', () => {
const {weeks} = useMonthMatrix(ref(0), ref(2026)) // janvier 2026
const weekWithJan4 = weeks.value.find(w =>
w.days.some(d => d.isoDate === '2026-01-04'),
)
expect(weekWithJan4?.weekNumber).toBe(1)
})
it('reacts to month/year changes', () => {
const month = ref(4)
const year = ref(2026)
const {weeks} = useMonthMatrix(month, year)
const mayCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
month.value = 1 // février
year.value = 2024
const febCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
expect(mayCount).toBe(31)
expect(febCount).toBe(29)
})
describe('isToday', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('flags only today', () => {
const {weeks} = useMonthMatrix(ref(4), ref(2026))
const todays = weeks.value.flatMap(w => w.days).filter(d => d.isToday)
expect(todays).toHaveLength(1)
expect(todays[0].isoDate).toBe('2026-05-19')
})
})
})
@@ -0,0 +1,60 @@
import {computed, type ComputedRef, type Ref} from 'vue'
export type DayCell = {
isoDate: string
day: number
isCurrentMonth: boolean
isToday: boolean
}
export type WeekRow = {
weekNumber: number
days: DayCell[]
}
const toIso = (d: Date): string => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const isoWeek = (d: Date): number => {
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
const dayNum = target.getUTCDay() || 7 // dimanche = 7
target.setUTCDate(target.getUTCDate() + 4 - dayNum) // jeudi de la semaine
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export function useMonthMatrix(
month: Ref<number>,
year: Ref<number>,
): {weeks: ComputedRef<WeekRow[]>} {
const weeks = computed<WeekRow[]>(() => {
const todayIso = toIso(new Date())
const first = new Date(year.value, month.value, 1)
// recule jusqu'au lundi (getDay : 0 = dimanche)
const offset = (first.getDay() + 6) % 7
const start = new Date(year.value, month.value, 1 - offset)
const rows: WeekRow[] = []
const cursor = new Date(start)
for (let w = 0; w < 6; w++) {
const days: DayCell[] = []
for (let i = 0; i < 7; i++) {
const iso = toIso(cursor)
days.push({
isoDate: iso,
day: cursor.getDate(),
isCurrentMonth: cursor.getMonth() === month.value,
isToday: iso === todayIso,
})
cursor.setDate(cursor.getDate() + 1)
}
rows.push({weekNumber: isoWeek(new Date(`${days[0].isoDate}T00:00:00`)), days})
}
return rows
})
return {weeks}
}
@@ -0,0 +1,249 @@
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
data-test="date-input"
readonly
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-m-primary"
aria-label="Effacer la date"
@click.stop="emit('clear')"
>
<Icon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="goToPrev"
@next="goToNext"
@toggle-view="toggleView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</div>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../../shared/RequiredMark.vue'
import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
const props = withDefaults(
defineProps<{
displayValue: string
syncTo: string | null
id?: string
name?: string
label?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
name: '',
label: '',
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
reserveMessageSpace: true,
},
)
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => props.displayValue.length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(isOpen, (value) => {
if (!value) emit('close')
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
watch(() => props.syncTo, (value) => {
if (isOpen.value) syncToIso(value)
})
const onSelectMonth = (m: number) => {
selectMonth(m)
toggleView()
}
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
<button
type="button"
data-test="header-prev"
class="ml-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
@click="emit('prev')"
>
<Icon
icon="mdi:chevron-left"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-toggle"
class="flex gap-1 rounded text-base font-medium"
@click="emit('toggle-view')"
>
<span class="mt-[2px]">{{ label }}</span>
<Icon
icon="mdi:chevron-down"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-next"
class="mr-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
@click="emit('next')"
>
<Icon
icon="mdi:chevron-right"
:width="25"
:height="25"
/>
</button>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {Icon} from '@iconify/vue'
defineOptions({name: 'MalioDateCalendarHeader'})
const props = defineProps<{
viewMode: 'days' | 'months'
currentMonth: number
currentYear: number
}>()
const emit = defineEmits<{
(e: 'prev' | 'next' | 'toggle-view'): void
}>()
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const label = computed(() => {
const name = monthsLong[props.currentMonth]
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
})
</script>
@@ -0,0 +1,178 @@
<template>
<div
data-test="month-grid"
@mouseleave="emit('hover', null)"
>
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
S
</div>
<div
v-for="d in dayLabels"
:key="d"
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
>
{{ d }}
</div>
<template
v-for="(week, wIndex) in weeks"
:key="week.days[0].isoDate"
>
<component
:is="interactiveWeekNumber ? 'button' : 'div'"
data-test="week-number"
:data-week-start="week.days[0].isoDate"
:data-marked="markedWeekStart === week.days[0].isoDate"
:type="interactiveWeekNumber ? 'button' : undefined"
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
:class="[
weekNumberClass(week),
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
@click="onWeekNumberClick(week)"
@mouseenter="onWeekNumberHover(week)"
>
{{ week.weekNumber }}
</component>
<button
v-for="cell in week.days"
:key="cell.isoDate"
type="button"
data-test="day"
:data-iso="cell.isoDate"
:data-range-role="roleOf(cell)"
:disabled="!inRange(cell.isoDate)"
:aria-label="ariaLabel(cell)"
:aria-disabled="!inRange(cell.isoDate)"
class="relative flex h-[45px] w-full items-center justify-center"
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="onSelect(cell.isoDate)"
@mouseenter="emit('hover', cell.isoDate)"
>
<span
v-if="roleOf(cell) === 'in-range'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'start'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-l-full bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'end'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-r-full bg-m-primary-light"
/>
<span
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
:class="cellClass(cell)"
>
{{ cell.day }}
</span>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, toRef} from 'vue'
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
import {isDateInRange} from '../composables/dateFormat'
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
defineOptions({name: 'MalioDateMonthGrid'})
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
rangeStart?: string | null
rangeEnd?: string | null
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
min?: string
max?: string
}>(),
{
selectedDate: null,
rangeStart: undefined,
rangeEnd: undefined,
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
min: undefined,
max: undefined,
},
)
const emit = defineEmits<{
(e: 'select', iso: string): void
(e: 'hover', iso: string | null): void
}>()
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
const weekNumberClass = (week: WeekRow) => {
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
const parts = ['bg-m-primary-light']
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
return parts.join(' ')
}
const onWeekNumberClick = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('select', week.days[0].isoDate)
}
const onWeekNumberHover = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('hover', week.days[0].isoDate)
}
const isRangeMode = computed(() => props.rangeStart !== undefined)
const bounds = computed(() =>
isRangeMode.value
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
: null,
)
const roleOf = (cell: DayCell): DayRangeRole => {
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
return props.selectedDate === cell.isoDate ? 'single' : 'none'
}
const ariaLabel = (cell: DayCell) => {
const [, m, d] = cell.isoDate.split('-')
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
}
const cellClass = (cell: DayCell) => {
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
const role = roleOf(cell)
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
if (role === 'in-range') return 'text-black'
const parts = ['hover:bg-m-primary/10']
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('opacity-[60%]')
return parts.join(' ')
}
const onSelect = (iso: string) => {
if (!inRange(iso)) return
emit('select', iso)
}
</script>
@@ -0,0 +1,36 @@
<template>
<div
data-test="month-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="(name, index) in months"
:key="name"
type="button"
data-test="month"
:data-month="index"
class="flex h-[45px] w-full items-center justify-center"
@click="emit('select', index)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="index === selectedMonth
? 'bg-m-primary text-white'
: 'text-black hover:bg-m-primary/10'"
>
{{ name }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{selectedMonth?: number}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
</script>
+280 -62
View File
@@ -1,15 +1,22 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import Drawer from './Drawer.vue'
type DrawerProps = {
modelValue?: boolean
title?: string
showClose?: boolean
id?: string
modelValue?: boolean
side?: 'right' | 'left'
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
drawerClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}
const DrawerForTest = Drawer as DefineComponent<DrawerProps>
@@ -18,64 +25,38 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
return mount(DrawerForTest, {
props,
slots,
global: {
stubs: {
Teleport: true,
},
},
global: { stubs: { Teleport: true } },
})
}
describe('MalioDrawer', () => {
enableAutoUnmount(afterEach)
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('renders when modelValue is true', () => {
it('renders the panel when modelValue is true', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
})
it('renders the title', () => {
const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' })
expect(wrapper.find('h2').text()).toBe('Mon tiroir')
})
it('renders slot content', () => {
it('renders default slot in the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu du drawer</p>' },
{ default: '<p data-test="content">Contenu</p>' },
)
expect(wrapper.find('[data-test="content"]').text()).toBe('Contenu du drawer')
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
})
it('emits update:modelValue false on backdrop click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})
it('emits update:modelValue false on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})
it('shows close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides close button when showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:close icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:close')
it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('uses custom id when provided', () => {
@@ -85,38 +66,275 @@ describe('MalioDrawer', () => {
it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true })
const id = wrapper.find('.fixed').attributes('id')
expect(id).toMatch(/^malio-drawer-/)
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/)
})
it('has role="dialog" and aria-modal on panel', () => {
it('has role="dialog" and aria-modal on the panel', () => {
const wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
})
it('aria-labelledby links to title id', () => {
const wrapper = mountComponent({ modelValue: true, id: 'test-drawer' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-title')
expect(wrapper.find('h2').attributes('id')).toBe('test-drawer-title')
})
it('applies drawerClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.classes()).toContain('max-w-lg')
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
})
it('works in uncontrolled mode', () => {
const wrapper = mountComponent()
// Without modelValue, defaults to closed
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
it('renders the #header slot inside the header bar', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ header: '<h2 data-test="title">Titre</h2>' },
)
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
})
it('renders the header bar when showClose is true even without #header', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
})
it('does not render the header bar when no #header and showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
})
it('shows the close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides the close button when showClose is false', () => {
const wrapper = mountComponent(
{ modelValue: true, showClose: false },
{ header: '<h2>Titre</h2>' },
)
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:cancel-bold icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:cancel-bold')
})
it('close button has aria-label "Fermer"', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
})
it('emits update:modelValue false and close on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('sets aria-labelledby to the header id when #header is provided', () => {
const wrapper = mountComponent(
{ modelValue: true, id: 'test-drawer' },
{ header: '<h2>Titre</h2>' },
)
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-drawer-header')
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-drawer-header')
})
it('sets aria-label from ariaLabel when no #header is provided', () => {
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Panneau latéral' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-label')).toBe('Panneau latéral')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
})
it('applies headerClass to the header bar', () => {
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer wrapper when no #footer slot', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
})
it('applies bodyClass to the body', () => {
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
})
it('aligns to the right by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('.fixed').classes()).toContain('justify-end')
})
it('aligns to the left when side is "left"', () => {
const wrapper = mountComponent({ modelValue: true, side: 'left' })
expect(wrapper.find('.fixed').classes()).toContain('justify-start')
})
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on backdrop click when dismissable is false', async () => {
const wrapper = mountComponent({ modelValue: true, dismissable: false })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('applies overlayClass to the backdrop', () => {
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
})
it('closes on Escape key when closeOnEscape is true', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on Escape when closeOnEscape is false', async () => {
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(DrawerForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
it('moves focus to the close button on open (default showClose)', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: false, showClose: true },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element)
wrapper.unmount()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
it('does not release body scroll-lock when one stacked drawer closes while another is still open', async () => {
const wrapperA = mount(DrawerForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
const wrapperB = mount(DrawerForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
// Open drawer A → scroll locked
await wrapperA.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
// Open drawer B → still locked
await wrapperB.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
// Close drawer B → A is still open, scroll must remain locked
await wrapperB.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('hidden')
// Close drawer A → both closed, scroll-lock released
await wrapperA.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
})
+191 -37
View File
@@ -1,60 +1,77 @@
<template>
<Teleport to="body">
<Transition
name="drawer"
:name="`drawer-${side}`"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex justify-end"
class="fixed inset-0 z-50 flex"
:class="side === 'right' ? 'justify-end' : 'justify-start'"
v-bind="attrs"
>
<div
class="absolute inset-0 bg-black/40"
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop"
@click="close"
@click="onBackdropClick"
/>
<div
ref="panelRef"
:class="twMerge(
'relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl',
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
drawerClass,
)"
role="dialog"
:aria-modal="true"
:aria-labelledby="titleId"
aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1"
data-test="panel"
@keydown="onKeydown"
>
<div class="flex items-center justify-between px-5 pb-8 pt-8">
<h2
:id="titleId"
class="text-[32px] font-semibold text-m-primary"
<div
v-if="hasHeader || showClose"
:class="twMerge('flex items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
data-test="header"
>
{{ title }}
</h2>
<div
:id="headerId"
class="min-w-0 flex-1"
data-test="header-content"
>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:close"
:width="24"
:height="24"
icon="mdi:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div
class="flex-1 overflow-y-auto px-5"
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
@@ -62,7 +79,17 @@
</template>
<script setup lang="ts">
import { computed, ref, useAttrs, useId, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
@@ -72,68 +99,195 @@ const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
title?: string
side?: 'right' | 'left'
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
drawerClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
title: '',
side: 'right',
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
drawerClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
const titleId = computed(() => `${componentId.value}-title`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this drawer holds a scroll-lock count slot.
let lockedByThisInstance = false
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
if (!lockedByThisInstance) {
lockedByThisInstance = true
openDrawerCount++
if (openDrawerCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
if (lockedByThisInstance) {
lockedByThisInstance = false
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) isRendered.value = true
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
function close() {
if (!isControlled.value) {
localValue.value = false
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {
if (props.dismissable) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function close() {
if (!isControlled.value) localValue.value = false
emit('update:modelValue', false)
emit('close')
}
</script>
<script lang="ts">
// Shared across all MalioDrawer instances: only the last open drawer releases the body scroll-lock.
let openDrawerCount = 0
</script>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
.drawer-right-enter-active,
.drawer-right-leave-active,
.drawer-left-enter-active,
.drawer-left-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
.drawer-right-enter-active > div:last-child,
.drawer-right-leave-active > div:last-child,
.drawer-left-enter-active > div:last-child,
.drawer-left-leave-active > div:last-child {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
.drawer-right-enter-from,
.drawer-right-leave-to,
.drawer-left-enter-from,
.drawer-left-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
.drawer-right-enter-from > div:last-child,
.drawer-right-leave-to > div:last-child {
transform: translateX(100%);
}
.drawer-left-enter-from > div:last-child,
.drawer-left-leave-to > div:last-child {
transform: translateX(-100%);
}
</style>
+82 -1
View File
@@ -24,6 +24,7 @@ type InputProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputForTest = Input as DefineComponent<InputProps>
@@ -53,6 +54,16 @@ describe('MalioInputText', () => {
expect(wrapper.get('label').text()).toBe('labelTest')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInput({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountInput({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('applies the name attribute', () => {
const wrapper = mountInput({name: 'nameTest'})
@@ -126,6 +137,13 @@ describe('MalioInputText', () => {
expect(wrapper.get('input').classes()).toContain('text-black/60')
})
it('shows muted label color when disabled (matches border color)', () => {
const wrapper = mountInput({label: 'Email', disabled: true, modelValue: 'foo@bar.com'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('label').classes()).not.toContain('text-black/60')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountInput({modelValue: ''})
@@ -253,6 +271,34 @@ describe('MalioInputText', () => {
expect(wrapper.get('p.text-m-muted').text()).toBe('Hint message test')
})
it('reserves space for the message even when no hint/error/success is set', () => {
const wrapper = mountInput({})
const p = wrapper.find('p')
expect(p.exists()).toBe(true)
expect(p.text()).toBe('')
expect(p.classes()).toContain('min-h-[1rem]')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInput({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountInput({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInput({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
it('does not render label when label prop is missing', () => {
const wrapper = mountInput({labelClass: 'text-red-500'})
@@ -279,7 +325,7 @@ describe('MalioInputText', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
expect(wrapper.get('input').classes()).toContain('!pl-11')
expect(wrapper.get('label').classes()).toContain('left-8')
expect(wrapper.get('label').classes()).toContain('left-11')
})
it('passes icon size props to icon component', () => {
@@ -294,4 +340,39 @@ describe('MalioInputText', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountInput({iconName: 'mdi:key-outline'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountInput({iconName: 'mdi:key-outline', modelValue: 'hello'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountInput({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountInput({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountInput({label: 'Champ', readonly: true, modelValue: 'hello', iconName: 'mdi:key-outline'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
})
+71 -1
View File
@@ -24,6 +24,7 @@ type InputAmountProps = {
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}
const InputAmountForTest = InputAmount as DefineComponent<InputAmountProps>
@@ -158,6 +159,75 @@ describe('MalioInputAmount', () => {
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
expect(wrapper.get('input').classes()).toContain('!pl-11')
expect(wrapper.get('label').classes()).toContain('left-8')
expect(wrapper.get('label').classes()).toContain('left-11')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountInputAmount()
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountInputAmount({modelValue: '12,50'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInputAmount({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountInputAmount({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly vide : icône en text-m-muted', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountInputAmount({label: 'Champ', readonly: true, modelValue: '12.50'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInputAmount({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInputAmount({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+40 -18
View File
@@ -30,7 +30,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -39,18 +39,12 @@
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
:class="[iconStateClass, iconPositionClass]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -59,6 +53,7 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -70,6 +65,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
@@ -95,6 +91,7 @@ const props = withDefaults(
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -115,8 +112,9 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
iconSize: 24,
iconSize: 20,
iconColor: 'text-m-muted',
reserveMessageSpace: true,
},
)
@@ -128,10 +126,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
@@ -141,29 +144,38 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
@@ -222,7 +234,7 @@ const iconInputPaddingClass = computed(() => {
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-8'
if (props.iconName && props.iconPosition === 'left') return 'left-11'
return 'left-3'
})
@@ -235,6 +247,16 @@ const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
})
</script>
<style scoped>
@@ -0,0 +1,566 @@
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import InputAutocomplete from './InputAutocomplete.vue'
type Option = {
label: string
value: string | number
}
type InputAutocompleteProps = {
id?: string
label?: string
name?: string
modelValue?: string | number | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
options?: Option[]
loading?: boolean
debounce?: number
minSearchLength?: number
allowCreate?: boolean
localFilter?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}
const InputAutocompleteForTest = InputAutocomplete as DefineComponent<InputAutocompleteProps>
const options: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
]
const mountComponent = (props: InputAutocompleteProps = {}) =>
mount(InputAutocompleteForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputAutocomplete', () => {
it('renders the label text', () => {
const wrapper = mountComponent({label: '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', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('role')).toBe('combobox')
})
it('renders input with provided modelValue label when option matches', () => {
const wrapper = mountComponent({modelValue: 'fr', options})
expect(wrapper.get('input').element.value).toBe('France')
})
it('opens dropdown on focus', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(true)
expect(wrapper.get('input').attributes('aria-expanded')).toBe('true')
})
it('does not open dropdown on focus when disabled', async () => {
const wrapper = mountComponent({options, disabled: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('does not open dropdown on focus when readonly', async () => {
const wrapper = mountComponent({options, readonly: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('renders all options in dropdown', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
const items = wrapper.findAll('[data-test="option"]')
expect(items).toHaveLength(3)
expect(items[0].text()).toBe('France')
expect(items[1].text()).toBe('Belgique')
expect(items[2].text()).toBe('Canada')
})
it('emits update:modelValue with option value when option is selected', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
})
it('emits select with full option object', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[0].trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual([options[0]])
})
it('closes dropdown after selecting an option', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[0].trigger('click')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('fills input with selected option label after selection', async () => {
const wrapper = mountComponent({options, modelValue: null})
await wrapper.get('input').trigger('focus')
await wrapper.findAll('[data-test="option"]')[1].trigger('click')
await wrapper.setProps({modelValue: 'be'})
expect(wrapper.get('input').element.value).toBe('Belgique')
})
it('emits search after debounce when user types', async () => {
vi.useFakeTimers()
const wrapper = mountComponent({options, debounce: 300})
await wrapper.get('input').setValue('fra')
expect(wrapper.emitted('search')).toBeUndefined()
vi.advanceTimersByTime(300)
expect(wrapper.emitted('search')?.[0]).toEqual(['fra'])
vi.useRealTimers()
})
it('does not emit search until minSearchLength is reached', async () => {
vi.useFakeTimers()
const wrapper = mountComponent({minSearchLength: 3, debounce: 300})
await wrapper.get('input').setValue('fr')
vi.advanceTimersByTime(300)
expect(wrapper.emitted('search')).toBeUndefined()
await wrapper.get('input').setValue('fra')
vi.advanceTimersByTime(300)
expect(wrapper.emitted('search')?.[0]).toEqual(['fra'])
vi.useRealTimers()
})
it('shows minSearch text in dropdown when minSearchLength not reached', async () => {
const wrapper = mountComponent({minSearchLength: 3, minSearchText: 'Tapez 3 caractères'})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="min-search-text"]').text()).toBe('Tapez 3 caractères')
})
it('shows loading text in dropdown when loading', async () => {
const wrapper = mountComponent({loading: true, loadingText: 'En cours…'})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="loading-text"]').text()).toBe('En cours…')
})
it('shows loading icon when loading', async () => {
const wrapper = mountComponent({loading: true})
expect(wrapper.find('[data-test="loading-icon"]').exists()).toBe(true)
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('shows no results text when options is empty', async () => {
const wrapper = mountComponent({options: [], noResultsText: 'Rien trouvé'})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="no-results-text"]').text()).toBe('Rien trouvé')
})
it('clears selection when typing different value', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('belg')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([null])
expect(wrapper.emitted('select')?.[0]).toEqual([null])
})
it('emits create event with typed value when allowCreate and Enter pressed', async () => {
const wrapper = mountComponent({options, allowCreate: true})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('Custom')
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('create')?.[0]).toEqual(['Custom'])
expect(wrapper.emitted('update:modelValue')?.some(e => e[0] === 'Custom')).toBe(true)
})
it('does not emit create when allowCreate is false', async () => {
const wrapper = mountComponent({options, allowCreate: false})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('Custom')
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('create')).toBeUndefined()
})
it('selects option on Enter with active index', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
await wrapper.get('input').trigger('keydown', {key: 'Enter'})
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['fr'])
})
it('navigates options with ArrowDown', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
await wrapper.get('input').trigger('keydown', {key: 'ArrowDown'})
expect(wrapper.get('input').attributes('aria-activedescendant')).toContain('-option-1')
})
it('closes dropdown on Escape', async () => {
const wrapper = mountComponent({options})
await wrapper.get('input').trigger('focus')
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(true)
await wrapper.get('input').trigger('keydown', {key: 'Escape'})
expect(wrapper.find('[data-test="dropdown"]').exists()).toBe(false)
})
it('reverts input value on Escape', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
await wrapper.get('input').trigger('focus')
await wrapper.get('input').setValue('xyz')
await wrapper.get('input').trigger('keydown', {key: 'Escape'})
expect(wrapper.get('input').element.value).toBe('France')
})
it('shows error message and styles', () => {
const wrapper = mountComponent({error: 'Champ invalide'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Champ invalide')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
})
it('shows success message and styles', () => {
const wrapper = mountComponent({success: 'Champ valide'})
expect(wrapper.get('p.text-m-success').text()).toBe('Champ valide')
expect(wrapper.get('input').classes()).toContain('border-m-success')
})
it('shows hint message', () => {
const wrapper = mountComponent({hint: 'Tapez pour rechercher'})
expect(wrapper.get('p.text-m-muted').text()).toBe('Tapez pour rechercher')
})
it('renders left icon when iconName provided with left position', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'left'})
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(true)
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
})
it('renders right icon when iconName provided with right position', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'right'})
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(true)
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
})
it('does not render icon when iconName is empty', () => {
const wrapper = mountComponent({iconName: ''})
expect(wrapper.find('[data-test="icon-left"]').exists()).toBe(false)
expect(wrapper.find('[data-test="icon-right"]').exists()).toBe(false)
})
it('uses left padding when icon is left', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'left'})
expect(wrapper.get('input').classes()).toContain('!pl-11')
})
it('uses extra right padding when icon is right', () => {
const wrapper = mountComponent({iconName: 'mdi:magnify', iconPosition: 'right'})
expect(wrapper.get('input').classes()).toContain('!pr-16')
})
it('renders the chevron with default icon', () => {
const wrapper = mountComponent()
const icons = wrapper.findAllComponents(IconifyIcon)
const chevron = icons[icons.length - 1]
expect(chevron.props('icon')).toBe('mdi:chevron-down')
})
it('rotates the chevron when dropdown is open', async () => {
const wrapper = mountComponent({options})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('rotate-0')
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('rotate-180')
})
it('sets disabled attribute', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
})
it('sets readonly attribute', () => {
const wrapper = mountComponent({readonly: true})
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
})
it('links label to input via for/id', () => {
const wrapper = mountComponent({id: 'country', label: 'Pays'})
expect(wrapper.get('input').attributes('id')).toBe('country')
expect(wrapper.get('label').attributes('for')).toBe('country')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountComponent({label: 'Pays'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-autocomplete-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('aria-invalid is false when no error', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
})
it('marks the option matching modelValue as aria-selected', async () => {
const wrapper = mountComponent({options, modelValue: 'be'})
await wrapper.get('input').trigger('focus')
const items = wrapper.findAll('[data-test="option"]')
expect(items[0].attributes('aria-selected')).toBe('false')
expect(items[1].attributes('aria-selected')).toBe('true')
expect(items[2].attributes('aria-selected')).toBe('false')
})
it('updates inputValue when modelValue changes externally', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
expect(wrapper.get('input').element.value).toBe('France')
await wrapper.setProps({modelValue: 'ca'})
expect(wrapper.get('input').element.value).toBe('Canada')
})
it('clears inputValue when modelValue is cleared externally', async () => {
const wrapper = mountComponent({options, modelValue: 'fr'})
expect(wrapper.get('input').element.value).toBe('France')
await wrapper.setProps({modelValue: null})
expect(wrapper.get('input').element.value).toBe('')
})
it('uses allowCreate modelValue as inputValue when no match in options', async () => {
const wrapper = mountComponent({options, allowCreate: true, modelValue: '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')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly vide : chevron en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fr', options, iconName: 'mdi:magnify', iconPosition: 'left'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon-left"]').classes()).toContain('text-black')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ', options})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountComponent({label: 'Champ', options, reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
@@ -0,0 +1,531 @@
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:readonly="readonly"
:value="inputValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
:aria-controls="listboxId"
:aria-activedescendant="activeOptionId"
role="combobox"
v-bind="attrs"
placeholder="_"
type="text"
@input="onInput"
@focus="onFocus"
@click="onInputClick"
@keydown="onKeydown"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
v-if="iconName && iconPosition === 'left'"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon-left"
:class="[iconStateClass, 'pointer-events-none absolute left-[10px] top-1/2 -translate-y-1/2']"
/>
<div class="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<IconifyIcon
v-if="iconName && iconPosition === 'right'"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon-right"
:class="[iconStateClass]"
/>
<IconifyIcon
v-if="loading"
icon="mdi:loading"
:width="20"
:height="20"
data-test="loading-icon"
class="animate-spin text-m-primary"
/>
<IconifyIcon
v-else
icon="mdi:chevron-down"
:width="20"
:height="20"
data-test="chevron"
class="transition-transform duration-300"
:class="[
isOpen ? 'rotate-180' : 'rotate-0',
chevronColorClass,
]"
/>
</div>
<ul
v-if="isOpen"
:id="listboxId"
ref="listRef"
data-test="dropdown"
role="listbox"
:aria-labelledby="inputId"
class="absolute left-0 right-0 top-[calc(100%-4px)] z-20 max-h-60 w-full overflow-auto rounded-b-md border border-t-0 bg-white"
:class="[
hasError
? 'border-m-danger select-scrollbar-error'
: hasSuccess
? 'border-m-success select-scrollbar-success'
: 'border-m-primary select-scrollbar-primary',
]"
>
<li
v-if="loading"
class="px-3 py-2 text-m-muted"
data-test="loading-text"
>
{{ loadingText }}
</li>
<li
v-else-if="showMinSearch"
class="px-3 py-2 text-m-muted"
data-test="min-search-text"
>
{{ minSearchText }}
</li>
<li
v-else-if="filteredOptions.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-results-text"
>
{{ noResultsText }}
</li>
<template v-else>
<li
v-for="(opt, index) in filteredOptions"
:id="optionId(index)"
:key="String(opt.value)"
data-test="option"
role="option"
:aria-selected="opt.value === modelValue"
class="cursor-pointer px-3 py-2 text-black"
:class="[
index === activeIndex ? 'bg-m-muted/10' : '',
opt.value === modelValue ? 'bg-m-muted/10 font-semibold' : '',
]"
@mouseenter="activeIndex = index"
@mousedown.prevent
@click="onSelect(opt)"
>
{{ opt.label || '\u00A0' }}
</li>
</template>
</ul>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
type Option = {
label: string
value: string | number
}
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
modelValue?: string | number | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
options?: Option[]
loading?: boolean
debounce?: number
minSearchLength?: number
allowCreate?: boolean
localFilter?: boolean
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
noResultsText?: string
loadingText?: string
minSearchText?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
name: '',
modelValue: undefined,
inputClass: '',
labelClass: '',
groupClass: '',
label: '',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
options: () => [],
loading: false,
debounce: 300,
minSearchLength: 0,
allowCreate: false,
localFilter: false,
iconName: '',
iconPosition: 'left',
iconSize: 24,
iconColor: 'text-m-muted',
noResultsText: 'Aucun résultat',
loadingText: 'Chargement…',
minSearchText: 'Tapez pour rechercher',
reserveMessageSpace: true,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | null): void
(e: 'search' | 'create', value: string): void
(e: 'select', option: Option | null): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const listRef = ref<HTMLElement | null>(null)
const inputValue = ref<string>('')
const isFocused = ref(false)
const isOpen = ref(false)
const activeIndex = ref(-1)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const inputId = computed(() => props.id?.toString() || `malio-input-autocomplete-${generatedId}`)
const listboxId = computed(() => `${inputId.value}-listbox`)
const selectedOption = computed(() =>
props.options.find(o => o.value === props.modelValue) ?? null,
)
const hasSelection = computed(() =>
props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== '',
)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || inputValue.value.length > 0,
)
const showMinSearch = computed(() =>
props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
)
const filteredOptions = computed(() => {
if (!props.localFilter) return props.options
const query = inputValue.value.trim().toLowerCase()
if (query === '') return props.options
return props.options.filter(opt =>
opt.label.toLowerCase().includes(query),
)
})
const optionId = (index: number) => `${inputId.value}-option-${index}`
const activeOptionId = computed(() =>
activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
? optionId(activeIndex.value)
: undefined,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(
[() => props.modelValue, () => props.options],
() => {
if (isFocused.value) return
if (selectedOption.value) {
inputValue.value = selectedOption.value.label
} else if (props.allowCreate && typeof props.modelValue === 'string' && props.modelValue !== '') {
inputValue.value = props.modelValue
} else if (!hasSelection.value) {
inputValue.value = ''
}
},
{immediate: true},
)
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const iconInputPaddingClass = computed(() => {
const parts: string[] = []
if (props.iconName && props.iconPosition === 'left') parts.push('!pl-11')
const hasCustomRight = !!props.iconName && props.iconPosition === 'right'
if (hasCustomRight) parts.push('!pr-16')
else parts.push('!pr-10')
return parts.join(' ')
})
const labelPositionClass = computed(() =>
props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled
? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
: 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
props.inputClass,
iconInputPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: props.disabled
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
})
const chevronColorClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
const scheduleSearch = () => {
if (debounceTimer) clearTimeout(debounceTimer)
if (showMinSearch.value) return
debounceTimer = setTimeout(() => {
emit('search', inputValue.value)
}, props.debounce)
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
inputValue.value = target.value
if (!isOpen.value) isOpen.value = true
activeIndex.value = -1
if (hasSelection.value && target.value !== selectedOption.value?.label) {
emit('update:modelValue', null)
emit('select', null)
}
scheduleSearch()
}
const onFocus = () => {
if (props.disabled || props.readonly) return
isFocused.value = true
isOpen.value = true
}
const onInputClick = () => {
if (props.disabled || props.readonly) return
isOpen.value = true
}
const onSelect = (option: Option) => {
inputValue.value = option.label
activeIndex.value = -1
emit('update:modelValue', option.value)
emit('select', option)
isOpen.value = false
isFocused.value = false
}
const closeAndCommit = () => {
if (
props.allowCreate
&& inputValue.value !== ''
&& inputValue.value !== selectedOption.value?.label
) {
emit('update:modelValue', inputValue.value)
emit('create', inputValue.value)
} else if (selectedOption.value) {
inputValue.value = selectedOption.value.label
} else if (!props.allowCreate) {
inputValue.value = ''
}
isOpen.value = false
isFocused.value = false
}
const closeAndRevert = () => {
if (selectedOption.value) {
inputValue.value = selectedOption.value.label
} else {
inputValue.value = ''
}
isOpen.value = false
isFocused.value = false
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
closeAndRevert()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
onSelect(filteredOptions.value[activeIndex.value])
return
}
if (props.allowCreate && inputValue.value !== '') {
emit('update:modelValue', inputValue.value)
emit('create', inputValue.value)
isOpen.value = false
isFocused.value = false
}
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!isOpen.value) {
isOpen.value = true
}
activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, 0)
}
}
const onClickOutside = (event: MouseEvent) => {
if (!root.value) return
if (!root.value.contains(event.target as Node)) {
closeAndCommit()
}
}
onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside)
if (debounceTimer) clearTimeout(debounceTimer)
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.grow-height {
transition: none;
}
}
:deep(ul[role="listbox"]) {
scrollbar-width: auto;
}
:deep(.select-scrollbar-primary) {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
:deep(.select-scrollbar-error) {
scrollbar-color: #000000 transparent;
}
:deep(.select-scrollbar-success) {
scrollbar-color: #000000 transparent;
}
</style>
@@ -0,0 +1,318 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import InputEmail from './InputEmail.vue'
type InputEmailProps = {
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}
const InputEmailForTest = InputEmail as DefineComponent<InputEmailProps>
const mountComponent = (props: InputEmailProps = {}) =>
mount(InputEmailForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputEmail', () => {
it('renders the initial input value', () => {
const wrapper = mountComponent({modelValue: 'user@example.com'})
expect(wrapper.get('input').element.value).toBe('user@example.com')
})
it('renders the label text', () => {
const wrapper = mountComponent({label: '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', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('type')).toBe('email')
})
it('has inputmode email', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('inputmode')).toBe('email')
})
it('renders the default email icon', () => {
const wrapper = mountComponent()
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:email-outline')
})
it('allows overriding the icon', () => {
const wrapper = mountComponent({iconName: 'mdi:at'})
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:at')
})
it('does not render icon when iconName is empty', () => {
const wrapper = mountComponent({iconName: ''})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('places icon on the right by default', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('places icon on the left when iconPosition is left', () => {
const wrapper = mountComponent({iconPosition: 'left'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountComponent({modelValue: ''})
await wrapper.get('input').setValue('new@example.com')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['new@example.com'])
})
it('sets disabled styles when true', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
})
it('sets readonly when true', () => {
const wrapper = mountComponent({readonly: true})
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
})
it('shows error message and styles', () => {
const wrapper = mountComponent({error: 'Email invalide'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Email invalide')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
})
it('shows error style on icon', () => {
const wrapper = mountComponent({error: 'Error'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
})
it('shows success message and styles', () => {
const wrapper = mountComponent({success: 'Email valide'})
expect(wrapper.get('p.text-m-success').text()).toBe('Email valide')
expect(wrapper.get('input').classes()).toContain('border-m-success')
})
it('shows success style on icon', () => {
const wrapper = mountComponent({success: 'Success'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
})
it('shows default icon color when empty and unfocused', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountComponent()
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountComponent({modelValue: 'user@example.com'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('keeps primary icon color when filled and focused', async () => {
const wrapper = mountComponent({modelValue: 'user@example.com'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('keeps default icon color when disabled, even if filled', () => {
const wrapper = mountComponent({modelValue: 'user@example.com', disabled: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('error overrides focus color on icon', async () => {
const wrapper = mountComponent({error: 'Email invalide'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
})
it('shows hint message', () => {
const wrapper = mountComponent({hint: 'ex: prenom.nom@malio.fr'})
expect(wrapper.get('p.text-m-muted').text()).toBe('ex: prenom.nom@malio.fr')
})
it('links label to input via for/id', () => {
const wrapper = mountComponent({id: 'email-field', label: 'Email'})
expect(wrapper.get('input').attributes('id')).toBe('email-field')
expect(wrapper.get('label').attributes('for')).toBe('email-field')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountComponent({label: 'Email'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-email-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('aria-invalid is false when no error', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
})
it('uses autocomplete off by default', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('autocomplete')).toBe('off')
})
it('allows overriding autocomplete', () => {
const wrapper = mountComponent({autocomplete: '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')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'user@example.com'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+275
View File
@@ -0,0 +1,275 @@
<template>
<div>
<div
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
type="email"
inputmode="email"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[iconStateClass, iconPositionClass]"
/>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
lowercase?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
name: '',
autocomplete: 'off',
modelValue: undefined,
iconName: 'mdi:email-outline',
iconPosition: 'right',
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
lowercase: false,
reserveMessageSpace: true,
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-email-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
if (hasError.value) ids.push(`${inputId.value}-error`)
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
return ids.length ? ids.join(' ') : undefined
})
const emit = defineEmits<{
(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 target = event.target as HTMLInputElement
const raw = target.value
const sanitized = sanitizeEmail(raw)
if (sanitized !== raw) {
// `<input type="email">` ne supporte pas l'API de sélection :
// selectionStart vaut null et setSelectionRange lève en navigateur.
// (En jsdom selectionStart peut renvoyer un nombre, d'où le code gardé ci-dessous.)
const caret = target.selectionStart
target.value = sanitized
if (caret !== null) {
const newCaret = sanitizeEmail(raw.slice(0, caret)).length
try {
target.setSelectionRange(newCaret, newCaret)
} catch {
/* type d'input sans support de sélection — ignore */
}
}
}
if (!isControlled.value) {
localValue.value = sanitized
}
emit('update:modelValue', sanitized)
}
const iconInputPaddingClass = computed(() => {
if (!props.iconName) return ''
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
})
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
@media (prefers-reduced-motion: reduce) {
.grow-height { transition: none; }
}
</style>
@@ -6,9 +6,13 @@ import InputNumber from './InputNumber.vue'
type InputNumberProps = {
modelValue?: string | null
label?: string
required?: boolean
readonly?: boolean
min?: number | string
max?: number | string
error?: string
hint?: string
reserveMessageSpace?: boolean
}
const InputNumberForTest = InputNumber as DefineComponent<InputNumberProps>
@@ -162,4 +166,33 @@ describe('MalioInputNumber', () => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['5'])
expect(input.element.value).toBe('5')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountInputNumber({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountInputNumber({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountInputNumber({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountInputNumber({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+6 -2
View File
@@ -6,7 +6,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<button
type="button"
@@ -51,7 +51,7 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -60,6 +60,7 @@
? 'text-m-success'
: 'text-m-muted',
'text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -71,6 +72,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
@@ -91,6 +93,7 @@ const props = withDefaults(
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -108,6 +111,7 @@ const props = withDefaults(
hint: '',
error: '',
success: '',
reserveMessageSpace: true,
},
)
@@ -22,6 +22,7 @@ type InputPasswordProps = {
error?: string
success?: string
displayIcon?: boolean
reserveMessageSpace?: boolean
}
const InputPasswordForTest = InputPassword as DefineComponent<InputPasswordProps>
@@ -51,6 +52,16 @@ describe('MalioInputPassword', () => {
expect(wrapper.get('label').text()).toBe('Mot de passe')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('has type password by default', () => {
const wrapper = mountComponent()
@@ -171,4 +182,69 @@ describe('MalioInputPassword', () => {
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountComponent()
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountComponent({modelValue: 'secret'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
expect(field.classes()).not.toContain('grow-height')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('label').classes()).not.toContain('peer-focus:text-m-primary')
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly vide : icône en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir et icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'secret'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : eye toggle reste cliquable', async () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
await wrapper.get('[data-test="icon"]').trigger('click')
expect(wrapper.get('input').attributes('type')).toBe('text')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+38 -13
View File
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -39,10 +39,7 @@
:height="24"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : 'text-m-muted',
iconStateClass,
'cursor-pointer absolute right-[10px] top-1/2 -translate-y-1/2',
]"
@click="toggleVisibility"
@@ -50,7 +47,7 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -59,6 +56,7 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -71,6 +69,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
@@ -93,6 +92,7 @@ const props = withDefaults(
error?: string
success?: string
displayIcon?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -112,6 +112,7 @@ const props = withDefaults(
error: '',
success: '',
displayIcon: true,
reserveMessageSpace: true,
},
)
@@ -128,10 +129,15 @@ const toggleVisibility = () => {
const inputId = computed(() => props.id?.toString() || `malio-input-password-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -140,16 +146,20 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.displayIcon ? '!pr-10' : '',
'focus:pl-[11px]',
isReadonly.value ? '' : 'focus:pl-[11px]',
props.inputClass,
),
)
@@ -157,12 +167,17 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
@@ -189,6 +204,16 @@ const onInput = (event: Event) => {
}
const disabled = computed(() => props.disabled)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
@@ -0,0 +1,406 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import InputPhone from './InputPhone.vue'
type InputPhoneProps = {
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
mask?: string
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}
const InputPhoneForTest = InputPhone as DefineComponent<InputPhoneProps>
const mountComponent = (props: InputPhoneProps = {}) =>
mount(InputPhoneForTest, {
props,
global: {
stubs: {
IconifyIcon: {
template: '<span data-test="icon" v-bind="$attrs" />',
},
},
},
})
describe('MalioInputPhone', () => {
it('renders the initial input value', () => {
const wrapper = mountComponent({modelValue: '+33 6 12 34 56 78'})
expect(wrapper.get('input').element.value).toBe('+33 6 12 34 56 78')
})
it('renders the label text', () => {
const wrapper = mountComponent({label: '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', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('type')).toBe('tel')
})
it('has inputmode tel', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('inputmode')).toBe('tel')
})
it('renders the default phone icon', () => {
const wrapper = mountComponent()
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:phone-outline')
})
it('allows overriding the icon', () => {
const wrapper = mountComponent({iconName: 'mdi:cellphone'})
const iconComponent = wrapper.findComponent(IconifyIcon)
expect(iconComponent.props('icon')).toBe('mdi:cellphone')
})
it('does not render icon when iconName is empty', () => {
const wrapper = mountComponent({iconName: ''})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('places icon on the left by default', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('left-[10px]')
})
it('places icon on the right when iconPosition is right', () => {
const wrapper = mountComponent({iconPosition: 'right'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
})
it('emits update:modelValue on input change', async () => {
const wrapper = mountComponent({modelValue: ''})
await wrapper.get('input').setValue('+33612345678')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['+33612345678'])
})
it('sets disabled styles when true', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
})
it('sets readonly when true', () => {
const wrapper = mountComponent({readonly: true})
expect(wrapper.get('input').attributes('readonly')).toBeDefined()
})
it('shows error message and styles', () => {
const wrapper = mountComponent({error: 'Numéro invalide'})
expect(wrapper.get('p.text-m-danger').text()).toBe('Numéro invalide')
expect(wrapper.get('input').classes()).toContain('border-m-danger')
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
})
it('shows error style on icon', () => {
const wrapper = mountComponent({error: 'Error'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
})
it('shows success message and styles', () => {
const wrapper = mountComponent({success: 'Numéro valide'})
expect(wrapper.get('p.text-m-success').text()).toBe('Numéro valide')
expect(wrapper.get('input').classes()).toContain('border-m-success')
})
it('shows success style on icon', () => {
const wrapper = mountComponent({success: 'Success'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-success')
})
it('shows default icon color when empty and unfocused', () => {
const wrapper = mountComponent()
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountComponent()
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountComponent({modelValue: '+33612345678'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('keeps default icon color when disabled, even if filled', () => {
const wrapper = mountComponent({modelValue: '+33612345678', disabled: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('error overrides focus color on icon', async () => {
const wrapper = mountComponent({error: 'Numéro invalide'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-danger')
})
it('shows hint message', () => {
const wrapper = mountComponent({hint: 'Format international recommandé'})
expect(wrapper.get('p.text-m-muted').text()).toBe('Format international recommandé')
})
it('links label to input via for/id', () => {
const wrapper = mountComponent({id: 'phone-field', label: 'Téléphone'})
expect(wrapper.get('input').attributes('id')).toBe('phone-field')
expect(wrapper.get('label').attributes('for')).toBe('phone-field')
})
it('generates an id when missing and reuses it on label', () => {
const wrapper = mountComponent({label: 'Téléphone'})
const inputId = wrapper.get('input').attributes('id')
expect(inputId?.startsWith('malio-input-phone-')).toBe(true)
expect(wrapper.get('label').attributes('for')).toBe(inputId)
})
it('aria-invalid is false when no error', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('aria-invalid')).toBe('false')
})
it('uses autocomplete off by default', () => {
const wrapper = mountComponent()
expect(wrapper.get('input').attributes('autocomplete')).toBe('off')
})
it('allows overriding autocomplete', () => {
const wrapper = mountComponent({autocomplete: 'tel'})
expect(wrapper.get('input').attributes('autocomplete')).toBe('tel')
})
it('does not render add button by default', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
})
it('renders add button when addable is true', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
})
it('emits add event when add button is clicked', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit add when disabled', async () => {
const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('does not emit add when readonly', async () => {
const wrapper = mountComponent({addable: true, readonly: true})
await wrapper.get('[data-test="add-button"]').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
})
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
})
it('readonly : border-black appliqué sur l\'input', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('input').classes()).toContain('border-black')
})
it('readonly : icône en text-m-muted quand vide', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly : icône en text-black quand rempli', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly : pas d\'apparence désactivée (pas opacity-40)', () => {
const wrapper = mountComponent({label: 'Tel', addable: true, readonly: true})
// opacity-40 was only ever applied to the add button, not the input
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('opacity-40')
// and the input is not natively disabled in readonly:
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
})
it('readonly vide : label en text-m-muted', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('readonly rempli : label en text-black', () => {
const wrapper = mountComponent({label: 'Tel', readonly: true, modelValue: '+33612345678'})
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('renders the default add icon (mdi:plus)', () => {
const wrapper = mountComponent({addable: true})
const icons = wrapper.findAllComponents(IconifyIcon)
const addIcon = icons[icons.length - 1]
expect(addIcon.props('icon')).toBe('mdi:plus')
})
it('allows overriding the add icon', () => {
const wrapper = mountComponent({addable: true, addIconName: 'mdi:phone-plus'})
const icons = wrapper.findAllComponents(IconifyIcon)
const addIcon = icons[icons.length - 1]
expect(addIcon.props('icon')).toBe('mdi:phone-plus')
})
it('exposes aria-label on add button', () => {
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un autre numéro'})
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un autre numéro')
})
it('adds right padding to input when addable', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('input').classes()).toContain('!pr-10')
})
it('shows default add button color when empty and unfocused', () => {
const wrapper = mountComponent({addable: true})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-muted')
expect(wrapper.get('[data-test="add-button"]').classes()).not.toContain('text-m-primary')
})
it('shows primary add button color on focus', async () => {
const wrapper = mountComponent({addable: true})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-primary')
})
it('shows black add button color when filled and unfocused', () => {
const wrapper = mountComponent({addable: true, modelValue: '+33612345678'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-black')
})
it('error overrides focus color on add button', async () => {
const wrapper = mountComponent({addable: true, error: 'Numéro invalide'})
await wrapper.get('input').trigger('focus')
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-danger')
})
it('success applies to add button', () => {
const wrapper = mountComponent({addable: true, success: 'Numéro valide'})
expect(wrapper.get('[data-test="add-button"]').classes()).toContain('text-m-success')
})
it('applies mask via maska directive', async () => {
const wrapper = mountComponent({mask: '+## # ## ## ## ##'})
await wrapper.get('input').setValue('33612345678')
expect(wrapper.emitted('update:modelValue')).toBeDefined()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+294
View File
@@ -0,0 +1,294 @@
<template>
<div>
<div
:class="mergedGroupClass"
>
<input
:id="inputId"
v-maska="mask"
:name="name"
:autocomplete="autocomplete"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="currentValue"
:readonly="readonly"
:aria-invalid="!!error"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
type="tel"
inputmode="tel"
@input="onInput"
@focus="isFocused = true"
@blur="isFocused = false"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
v-if="iconName"
:icon="iconName"
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[iconStateClass, iconPositionClass]"
/>
<button
v-if="addable"
type="button"
:disabled="disabled"
:aria-label="addButtonLabel"
data-test="add-button"
:class="mergedAddButtonClass"
@click="onAdd"
>
<IconifyIcon
:icon="addIconName"
:width="24"
:height="24"
data-test="add-icon"
/>
</button>
</div>
<p
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
</p>
</div>
</template>
<script setup lang="ts">
import type {MaskInputOptions} from 'maska'
import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
label?: string
name?: string
autocomplete?: string
modelValue?: string | null | undefined
inputClass?: string
labelClass?: string
groupClass?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
iconName?: string
iconPosition?: 'left' | 'right'
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
addable?: boolean
addIconName?: string
addButtonLabel?: string
reserveMessageSpace?: boolean
}>(),
{
id: '',
name: '',
autocomplete: 'off',
modelValue: undefined,
iconName: 'mdi:phone-outline',
iconPosition: 'left',
label: '',
inputClass: '',
labelClass: '',
groupClass: '',
required: false,
readonly: false,
disabled: false,
hint: '',
error: '',
success: '',
iconSize: 24,
iconColor: 'text-m-muted',
mask: undefined,
addable: false,
addIconName: 'mdi:plus',
addButtonLabel: 'Ajouter un numéro',
reserveMessageSpace: true,
},
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-phone-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
props.groupClass,
),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
)
const mergedAddButtonClass = computed(() =>
twMerge(
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
iconStateClass.value,
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
),
)
const describedBy = computed(() => {
const ids: string[] = []
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
if (hasError.value) ids.push(`${inputId.value}-error`)
if (hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-success`)
return ids.length ? ids.join(' ') : undefined
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'add'): void
}>()
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (!isControlled.value) {
localValue.value = target.value
}
emit('update:modelValue', target.value)
}
const onAdd = () => {
if (props.disabled || props.readonly) return
emit('add')
}
const iconInputPaddingClass = computed(() => {
const leftIcon = props.iconName && props.iconPosition === 'left'
const rightIcon = props.iconName && props.iconPosition === 'right'
const parts: string[] = []
if (leftIcon) parts.push('!pl-11')
if (rightIcon || props.addable) parts.push('!pr-10')
return parts.join(' ')
})
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-11'
return 'left-3'
})
const focusPaddingClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
return 'focus:pl-[11px]'
})
const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease, padding-top 160ms ease, padding-bottom 160ms ease;
}
.grow-height:focus {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
@media (prefers-reduced-motion: reduce) {
.grow-height { transition: none; }
}
</style>
@@ -19,6 +19,8 @@ type InputRichTextProps = {
groupClass?: string
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
@@ -155,6 +157,18 @@ describe('MalioInputRichText', () => {
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
})
it('expose aria-required quand required est vrai', async () => {
const wrapper = await mountComponent({required: true})
expect(wrapper.find('[aria-required="true"]').exists()).toBe(true)
})
it('n\'expose pas aria-required par défaut', async () => {
const wrapper = await mountComponent()
expect(wrapper.find('[aria-required="true"]').exists()).toBe(false)
})
it('renders initial markdown content visually', async () => {
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
@@ -162,4 +176,35 @@ describe('MalioInputRichText', () => {
expect(html).toContain('Mon titre')
expect(html).toContain('Un paragraphe.')
})
it('affiche l\'astérisque quand required est vrai', async () => {
const wrapper = await mountComponent({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', async () => {
const wrapper = await mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('réserve lespace message par défaut même sans message', async () => {
const wrapper = await mountComponent({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', async () => {
const wrapper = await mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', async () => {
const wrapper = await mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+11 -3
View File
@@ -5,7 +5,7 @@
:for="editorId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<!-- Mode lecture seule (rendu uniquement) -->
@@ -22,6 +22,7 @@
v-else
:id="editorId"
:class="mergedEditorWrapperClass"
:aria-required="required || undefined"
@click="focusEditor"
>
<div
@@ -184,7 +185,7 @@
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${editorId}-describedby`"
:class="[
hasError
@@ -193,6 +194,7 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
@@ -211,6 +213,7 @@ import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import { Markdown } from 'tiptap-markdown'
import { twMerge } from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
@@ -233,6 +236,8 @@ const props = withDefaults(
groupClass?: string
labelClass?: string
editorClass?: string
required?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -250,6 +255,8 @@ const props = withDefaults(
groupClass: '',
labelClass: '',
editorClass: '',
required: false,
reserveMessageSpace: true,
},
)
@@ -279,10 +286,11 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: props.disabled
? 'text-m-muted'
: isFocused.value
? 'text-m-primary'
: 'text-m-text',
props.disabled ? 'text-black/60' : '',
props.labelClass,
),
)
+39 -17
View File
@@ -30,7 +30,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -39,18 +39,12 @@
:width="iconSize"
:height="iconSize"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : iconColor,
iconPositionClass,
]"
:class="[iconStateClass, iconPositionClass]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -59,6 +53,7 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -73,6 +68,7 @@ import {vMaska} from 'maska/vue'
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputText', inheritAttrs: false})
@@ -100,6 +96,7 @@ const props = withDefaults(
iconSize?: string | number
iconColor?: string
mask?: string | MaskInputOptions
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -123,6 +120,7 @@ const props = withDefaults(
iconSize: 24,
iconColor: 'text-m-muted',
mask: undefined,
reserveMessageSpace: true,
},
)
@@ -134,10 +132,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-text-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -146,29 +149,38 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-text',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
isReadonly.value ? 'cursor-default' : '',
props.inputClass,
iconInputPaddingClass.value,
focusPaddingClass.value,
isReadonly.value ? '' : focusPaddingClass.value,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
labelPositionClass.value,
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
@@ -202,7 +214,7 @@ const iconInputPaddingClass = computed(() => {
const disabled = computed(() => props.disabled)
const labelPositionClass = computed(() => {
if (props.iconName && props.iconPosition === 'left') return 'left-8'
if (props.iconName && props.iconPosition === 'left') return 'left-11'
return 'left-3'
})
@@ -215,6 +227,16 @@ const iconPositionClass = computed(() => {
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
})
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return props.iconColor
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return props.iconColor
})
</script>
<style scoped>
@@ -21,6 +21,7 @@ type InputTextAreaProps = {
error?: string
success?: string
rounded?: string
reserveMessageSpace?: boolean
}
const InputTextAreaForTest = InputTextArea as DefineComponent<InputTextAreaProps>
@@ -149,4 +150,87 @@ describe('MalioInputTextArea', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Textarea error')
})
it('renders as a single root element (works as a single grid item)', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const wrapper = mount(InputTextAreaForTest, {
attachTo: host,
})
// host > div[data-v-app] > component roots
const app = host.firstElementChild as HTMLElement
expect(app.children.length).toBe(1)
wrapper.unmount()
host.remove()
})
it('applies primary scrollbar class on focus', async () => {
const wrapper = mount(InputTextAreaForTest)
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
await wrapper.get('textarea').trigger('focus')
expect(wrapper.get('textarea').classes()).toContain('textarea-scrollbar-primary')
})
it('removes primary scrollbar class on blur', async () => {
const wrapper = mount(InputTextAreaForTest)
await wrapper.get('textarea').trigger('focus')
await wrapper.get('textarea').trigger('blur')
expect(wrapper.get('textarea').classes()).not.toContain('textarea-scrollbar-primary')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', required: true}})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de bleu', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
const field = wrapper.get('textarea')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label gris, pas de bleu focus', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true}})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
// En readonly, pas de couleur primary sur le label
expect(wrapper.get('label').classes()).not.toContain('text-m-primary')
})
it('readonly rempli : label noir', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', readonly: true, modelValue: 'du texte'}})
expect(wrapper.get('label').classes()).toContain('text-black')
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ'}})
const msg = wrapper.find('[data-test="message-line"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false}})
expect(wrapper.find('[data-test="message-line"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mount(InputTextAreaForTest, {props: {label: 'Champ', reserveMessageSpace: false, error: 'Erreur'}})
const msg = wrapper.find('[data-test="message-line"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+40 -13
View File
@@ -1,21 +1,21 @@
<template>
<div
class="relative w-full"
>
<div :class="mergedGroupClass">
<div class="relative w-full flex-1">
<textarea
:id="inputId"
:name="name"
:autocomplete="autocomplete"
class="floating-input peer w-full border-2 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="[
isFilled ? 'border-black' : 'border-m-muted',
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : 'cursor-text',
isReadonly ? 'border-black' : (isFilled ? 'border-black' : 'border-m-muted'),
disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : (isReadonly ? 'cursor-default' : 'cursor-text'),
hasError
? 'border-m-danger focus:border-m-danger'
: hasSuccess
? 'border-m-success focus:border-m-success'
: 'focus:border-m-primary',
: isReadonly ? '' : 'focus:border-m-primary',
isReadonly ? '' : (isFocused ? 'textarea-scrollbar-primary' : ''),
textInput,
showCounterComputed ? 'pb-6' : '',
rounded,
@@ -41,16 +41,19 @@
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',
: disabled
? 'text-m-muted'
: isReadonly
? (isFilled ? 'text-black' : 'text-m-muted')
: (isFocused ? 'text-m-primary' : shouldFloatLabel ? 'text-black' : 'text-m-muted'),
textLabel,
]"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<span
v-if="showCounterComputed"
@@ -60,8 +63,10 @@
</span>
</div>
<div
v-if="hasError || hasSuccess || hint"
v-if="reserveMessageSpace || hint || error || success"
data-test="message-line"
class="mt-1 flex items-center justify-between gap-2 text-xs"
:class="reserveMessageSpace ? 'min-h-[1rem]' : ''"
>
<p
:id="`${inputId}-describedby`"
@@ -77,10 +82,13 @@
{{ error || success || hint }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
@@ -108,6 +116,8 @@ const props = withDefaults(
error?: string
success?: string
rounded?: string
groupClass?: string
reserveMessageSpace?: boolean
}>(),
{
@@ -133,9 +143,17 @@ const props = withDefaults(
maxResizeWidth: 640,
minResizeHeight: 40,
maxResizeHeight: 320,
groupClass: '',
reserveMessageSpace: true,
},
)
const mergedGroupClass = computed(() =>
// pt-1 (4px) aligne le haut de la textarea avec les inputs floating-label,
// qui centrent un champ de 40px dans un groupe h-12 (≈ 4px de décalage en haut).
twMerge('flex flex-col w-full pt-1', props.groupClass),
)
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref('')
@@ -144,9 +162,15 @@ const isFocused = ref(false)
const inputId = computed(() => props.id?.toString() || `malio-input-textarea-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => currentValue.value.trim().length > 0)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentValue.value.length > 0,
)
const rowsCount = computed(() => Math.max(1, Number(props.size || 3)))
const currentLength = computed(() => (currentValue.value ?? '').length)
const showCounterComputed = computed(() =>
@@ -160,7 +184,6 @@ const textareaStyle = computed(() => ({
minHeight: toCssSize(props.minResizeHeight),
maxHeight: toCssSize(props.maxResizeHeight),
}))
const isFilled = computed(() => currentValue.value.trim().length > 0)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${inputId.value}-describedby` : undefined,
)
@@ -183,4 +206,8 @@ const onInput = (event: Event) => {
background: white;
padding: 0 0.25rem;
}
.textarea-scrollbar-primary {
scrollbar-color: rgb(var(--m-primary)) transparent;
}
</style>
+89 -1
View File
@@ -1,4 +1,4 @@
import {describe, expect, it} from 'vitest'
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
@@ -12,11 +12,14 @@ type InputUploadProps = {
labelClass?: string
groupClass?: string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
accept?: string
required?: boolean
reserveMessageSpace?: boolean
}
const InputUploadForTest = InputUpload as DefineComponent<InputUploadProps>
@@ -167,9 +170,94 @@ describe('MalioInputUpload', () => {
expect(wrapper.get('input[type="text"]').attributes('aria-invalid')).toBe('false')
})
it('expose aria-required sur le champ visible quand required est vrai', () => {
const wrapper = mountComponent({label: 'Champ', required: true})
expect(wrapper.get('input[type="text"]').attributes('aria-required')).toBe('true')
})
it('passes accept attribute to file input', () => {
const wrapper = mountComponent({accept: '.pdf,.doc'})
expect(wrapper.get('input[type="file"]').attributes('accept')).toBe('.pdf,.doc')
})
it('shows primary icon color on focus', async () => {
const wrapper = mountComponent()
await wrapper.get('input[type="text"]').trigger('focus')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-primary')
})
it('shows black icon color when filled and unfocused', () => {
const wrapper = mountComponent({modelValue: 'document.pdf'})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountComponent({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountComponent({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('readonly : bordure noire même vide, pas de grow/bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const field = wrapper.get('input[type="text"]')
expect(field.classes()).toContain('border-black')
expect(field.classes()).not.toContain('border-m-muted')
expect(field.classes()).not.toContain('grow-height')
expect(field.classes()).not.toContain('focus:border-m-primary')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('peer-focus:text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly vide : icône en text-m-muted', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-m-muted')
})
it('readonly rempli : label noir + icône noire', () => {
const wrapper = mountComponent({label: 'Champ', readonly: true, modelValue: 'fichier.pdf'})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="icon"]').classes()).toContain('text-black')
})
it('readonly empêche l\'ouverture du sélecteur de fichier', async () => {
const wrapper = mountComponent({label: 'Champ', readonly: true})
const fileInput = wrapper.get('input[type="file"]').element as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
await wrapper.get('input[type="text"]').trigger('click')
expect(clickSpy).not.toHaveBeenCalled()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountComponent({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountComponent({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+47 -16
View File
@@ -9,6 +9,7 @@
:accept="accept"
class="hidden"
:disabled="disabled"
:required="required"
@change="onFileChange"
>
@@ -19,6 +20,7 @@
:value="currentDisplayValue"
:readonly="true"
:aria-invalid="!!error"
:aria-required="required || undefined"
:aria-describedby="describedBy"
v-bind="attrs"
placeholder="_"
@@ -33,7 +35,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<IconifyIcon
@@ -43,17 +45,14 @@
:height="24"
data-test="icon"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success' : 'text-m-muted',
iconStateClass,
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
]"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${inputId}-describedby`"
:class="[
hasError
@@ -62,6 +61,7 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ hint || error || success }}
@@ -74,6 +74,7 @@
import {computed, ref, useAttrs, useId} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
@@ -86,11 +87,14 @@ const props = withDefaults(
labelClass?: string
groupClass?: string
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
displayIcon?: boolean
accept?: string
required?: boolean
reserveMessageSpace?: boolean
}>(),
{
id: '',
@@ -100,11 +104,14 @@ const props = withDefaults(
labelClass: '',
groupClass: '',
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
displayIcon: true,
accept: '',
required: false,
reserveMessageSpace: true,
},
)
@@ -117,10 +124,16 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
const inputId = computed(() => props.id?.toString() || `malio-input-upload-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const currentDisplayValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
const shouldFloatLabel = computed(() => isFocused.value || currentDisplayValue.value.length > 0)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success)
const isFilled = computed(() => currentDisplayValue.value.trim().length > 0)
const disabled = computed(() => props.disabled)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isReadonly.value
? isFilled.value
: isFocused.value || currentDisplayValue.value.length > 0,
)
const mergedGroupClass = computed(() =>
twMerge(
'relative flex h-12 w-full items-center',
@@ -129,16 +142,21 @@ const mergedGroupClass = computed(() =>
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent focus:border-2 text-lg rounded-md',
isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : 'cursor-pointer',
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md cursor-pointer',
isReadonly.value ? '' : 'grow-height',
isReadonly.value
? 'border-black'
: isFilled.value ? 'border-black' : 'border-m-muted',
disabled.value ? 'text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted' : '',
hasError.value
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
: hasSuccess.value
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
: 'focus:border-m-primary',
: isReadonly.value ? '' : 'focus:border-m-primary',
props.displayIcon ? '!pr-10' : '',
'focus:pl-[11px]',
isReadonly.value ? '' : 'focus:pl-[11px]',
isReadonly.value ? 'cursor-default' : '',
disabled.value ? 'cursor-not-allowed' : '',
props.inputClass,
),
)
@@ -146,12 +164,17 @@ const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
'left-3',
shouldFloatLabel.value ? '-translate-y-[1.25rem] peer-focus:-translate-y-[1.55rem] scale-90' : '',
disabled.value ? 'peer-[&:not(:placeholder-shown):not(:focus)]:text-black/60' : '',
shouldFloatLabel.value
? `-translate-y-[1.25rem] scale-90${isReadonly.value ? '' : ' peer-focus:-translate-y-[1.55rem]'}`
: '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: disabled.value
? 'text-m-muted'
: isReadonly.value
? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
props.labelClass,
),
@@ -171,7 +194,7 @@ const emit = defineEmits<{
}>()
const openFilePicker = () => {
if (props.disabled) return
if (props.disabled || props.readonly) return
fileInputRef.value?.click()
}
@@ -188,7 +211,15 @@ const onFileChange = (event: Event) => {
}
}
const disabled = computed(() => props.disabled)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (disabled.value) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isFocused.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
+320
View File
@@ -0,0 +1,320 @@
import { afterEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import Modal from './Modal.vue'
type ModalProps = {
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}
const ModalForTest = Modal as DefineComponent<ModalProps>
function mountComponent(props: ModalProps = {}, slots?: Record<string, string>) {
return mount(ModalForTest, {
props,
slots,
global: { stubs: { Teleport: true } },
})
}
describe('MalioModal', () => {
enableAutoUnmount(afterEach)
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('renders the panel when modelValue is true', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
})
it('centers the modal (items-center justify-center)', () => {
const wrapper = mountComponent({ modelValue: true })
const root = wrapper.find('.fixed')
expect(root.classes()).toContain('items-center')
expect(root.classes()).toContain('justify-center')
})
it('renders default slot in the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu</p>' },
)
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
})
it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ modelValue: true, id: 'my-modal' })
expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal')
})
it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/)
})
it('has role="dialog" and aria-modal on the panel', () => {
const wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true')
})
it('applies modalClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, modalClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
})
it('renders the #header slot inside the header bar', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ header: '<h2 data-test="title">Titre</h2>' },
)
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
})
it('renders the header bar when showClose is true even without #header', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
})
it('does not render the header bar when no #header and showClose is false', () => {
const wrapper = mountComponent({ modelValue: true, showClose: false })
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
})
it('shows the close button by default', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
})
it('hides the close button when showClose is false', () => {
const wrapper = mountComponent(
{ modelValue: true, showClose: false },
{ header: '<h2>Titre</h2>' },
)
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
})
it('close button renders mdi:cancel-bold icon', () => {
const wrapper = mountComponent({ modelValue: true })
const icon = wrapper.findComponent(IconifyIcon)
expect(icon.props('icon')).toBe('mdi:cancel-bold')
})
it('close button has aria-label "Fermer"', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
})
it('emits update:modelValue false and close on close button click', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="close-button"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('sets aria-labelledby to the header id when #header is provided', () => {
const wrapper = mountComponent(
{ modelValue: true, id: 'test-modal' },
{ header: '<h2>Titre</h2>' },
)
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-labelledby')).toBe('test-modal-header')
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header')
})
it('sets aria-label from ariaLabel when no #header is provided', () => {
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' })
const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('aria-label')).toBe('Boîte de dialogue')
expect(panel.attributes('aria-labelledby')).toBeUndefined()
})
it('applies headerClass to the header bar', () => {
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
})
it('renders the #footer slot in a footer pinned below the body', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ footer: '<button data-test="save">Enregistrer</button>' },
)
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
})
it('does not render the footer when no #footer slot', () => {
const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
})
it('applies bodyClass to the body', () => {
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
})
it('applies footerClass to the footer', () => {
const wrapper = mountComponent(
{ modelValue: true, footerClass: 'justify-end' },
{ footer: '<span>pied</span>' },
)
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
})
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on backdrop click when dismissable is false', async () => {
const wrapper = mountComponent({ modelValue: true, dismissable: false })
await wrapper.find('[data-test="backdrop"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('applies overlayClass to the backdrop', () => {
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
})
it('closes on Escape key when closeOnEscape is true', async () => {
const wrapper = mountComponent({ modelValue: true })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('does not close on Escape when closeOnEscape is false', async () => {
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(ModalForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(ModalForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
it('does not release body scroll-lock when one stacked modal closes while another is still open', async () => {
const wrapperA = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
const wrapperB = mount(ModalForTest, {
props: { modelValue: false },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapperA.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapperB.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('hidden')
await wrapperA.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
})
+279
View File
@@ -0,0 +1,279 @@
<template>
<Teleport to="body">
<Transition
name="modal"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
v-bind="attrs"
>
<div
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop"
@click="onBackdropClick"
/>
<div
ref="panelRef"
:class="twMerge(
'relative z-50 flex max-h-[85vh] w-full max-w-md flex-col rounded-malio bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]',
modalClass,
)"
role="dialog"
aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1"
data-test="panel"
@keydown="onKeydown"
>
<div
v-if="hasHeader || showClose"
:class="twMerge('flex shrink-0 items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
data-test="header"
>
<div
:id="headerId"
class="min-w-0 flex-1"
data-test="header-content"
>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
>
<slot />
</div>
<div
v-if="$slots.footer"
:class="twMerge('flex shrink-0 items-center gap-3 px-5 py-4', footerClass)"
data-test="footer"
>
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioModal', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
modalClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
modalClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-modal-${generatedId}`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this modal holds a scroll-lock count slot.
let lockedByThisInstance = false
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
if (!lockedByThisInstance) {
lockedByThisInstance = true
openModalCount++
if (openModalCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openModalCount = Math.max(0, openModalCount - 1)
if (openModalCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {
if (props.dismissable) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation()
close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function close() {
if (!isControlled.value) localValue.value = false
emit('update:modelValue', false)
emit('close')
}
</script>
<script lang="ts">
// Shared across all MalioModal instances: only the last open modal releases the body scroll-lock.
let openModalCount = 0
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95);
}
</style>
@@ -153,4 +153,43 @@ describe('MalioRadioButton', () => {
expect(wrapper.get('input').classes()).toContain('border-red-500')
expect(wrapper.get('.radio-text').classes()).toContain('font-bold')
})
it('uses muted label color and muted border when unchecked', () => {
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'b'})
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
expect(wrapper.get('input').classes()).toContain('border-m-muted')
})
it('uses black label color when checked', () => {
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
})
it('has checked:border-black on input', () => {
const wrapper = mountRadioButton({label: 'Option 1', value: 'a', modelValue: 'a'})
expect(wrapper.get('input').classes()).toContain('checked:border-black')
})
it('affiche l\'astérisque quand required est vrai', () => {
const wrapper = mountRadioButton({label: 'Champ', required: true})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(true)
})
it('n\'affiche pas l\'astérisque par défaut', () => {
const wrapper = mountRadioButton({label: 'Champ'})
expect(wrapper.find('[data-test="required-mark"]').exists()).toBe(false)
})
it('updates label color when toggled without v-model (uncontrolled)', async () => {
const wrapper = mountRadioButton({label: 'Option 1', value: 'a'})
expect(wrapper.get('.radio-text').classes()).toContain('text-m-muted')
await wrapper.get('input').trigger('change')
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
})
})
+15 -5
View File
@@ -29,7 +29,7 @@
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
</div>
@@ -44,8 +44,9 @@
</template>
<script setup lang="ts">
import {computed, useAttrs, useId} from 'vue'
import {computed, ref, useAttrs, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
@@ -86,9 +87,13 @@ const props = withDefaults(
const attrs = useAttrs()
const generatedId = useId()
const localValue = ref<string | number | boolean | null | undefined>(undefined)
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
const isChecked = computed(() => props.modelValue === props.value)
const isControlled = computed(() => props.modelValue !== undefined)
const isChecked = computed(() =>
isControlled.value ? props.modelValue === props.value : localValue.value === props.value,
)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const disabled = computed(() => props.disabled)
@@ -117,14 +122,15 @@ const mergedControlClass = computed(() =>
const mergedInputClass = computed(() =>
twMerge(
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-black',
'h-5 w-5 cursor-pointer appearance-none rounded-full border-2 border-m-muted checked:border-black',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'radio-text mt-px cursor-pointer text-black',
'radio-text mt-px cursor-pointer',
isChecked.value ? 'text-black' : 'text-m-muted',
hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '',
disabled.value ? 'cursor-not-allowed text-black/60' : '',
@@ -160,6 +166,10 @@ const onChange = (event: Event) => {
return
}
if (!isControlled.value) {
localValue.value = props.value
}
emit('update:modelValue', props.value)
}
</script>
+172
View File
@@ -21,6 +21,9 @@ type SelectProps = {
textLabel?: string
rounded?: string
disabled?: boolean
readonly?: boolean
required?: boolean
reserveMessageSpace?: boolean
}
const SelectForTest = Select as DefineComponent<SelectProps>
@@ -207,4 +210,173 @@ describe('MalioSelect', () => {
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Selection error')
})
it('shows muted chevron color when empty and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when an option is selected and closed', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('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')
})
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
})
const trigger = wrapper.get('button')
expect(trigger.classes()).toContain('border-black')
expect(trigger.classes()).not.toContain('border-m-muted')
expect(trigger.classes()).not.toContain('grow-height')
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly sélectionné : label noir + chevron noir', () => {
const wrapper = mount(SelectForTest, {
props: {label: 'Champ', readonly: true, modelValue: 'a', options: [{label: 'A', value: 'a'}]},
})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('readonly empêche louverture du dropdown', async () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options: [{label: 'A', value: 'a'}]},
})
await wrapper.get('button').trigger('click')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: null, label: 'Champ', readonly: true, options},
})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBe('true')
expect(trigger.attributes('disabled')).toBeUndefined()
})
it('disabled + readonly : pas daria-readonly (disabled prime)', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBeUndefined()
expect(trigger.attributes('disabled')).toBeDefined()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options}})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false}})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mount(SelectForTest, {props: {modelValue: null, label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+68 -14
View File
@@ -6,29 +6,34 @@
>
<button
:id="buttonId"
ref="buttonRef"
type="button"
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none"
:class="[
isReadonly ? '' : 'grow-height',
isReadonly ? '' : 'focus-visible:border-m-primary',
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-danger !border-b-0'
: 'rounded-t-none !border-2 !border-m-danger !border-t-0'
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success'
: isReadonly
? 'border-black'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded,
textField,
@@ -37,6 +42,8 @@
:aria-controls="listboxId"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:aria-required="required || undefined"
:aria-readonly="isReadonly || undefined"
:disabled="disabled"
@click="toggle"
>
@@ -49,6 +56,10 @@
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
@@ -58,7 +69,7 @@
]"
:style="labelTransformStyle"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<span
@@ -72,13 +83,24 @@
</span>
<span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-current'
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]"
>
<slot name="icon">
@@ -98,7 +120,7 @@
ref="listRef"
role="listbox"
:aria-labelledby="buttonId"
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border bg-white"
:class="[
openDirection === 'down'
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
@@ -115,8 +137,16 @@
: 'border-m-primary'
]"
>
<li
v-if="normalizedOptions.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-options-text"
>
{{ noOptionsText }}
</li>
<li
v-for="(opt, index) in normalizedOptions"
v-else
:id="optionId(index)"
:key="String(opt.value)"
role="option"
@@ -136,7 +166,7 @@
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${buttonId}-describedby`"
:class="[
hasError
@@ -145,6 +175,7 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
@@ -156,6 +187,7 @@
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioSelect', inheritAttrs: false})
@@ -176,7 +208,11 @@ const props = withDefaults(defineProps<{
textLabel?: string
rounded?: string
disabled?: boolean
readonly?: boolean
groupClass?: string
noOptionsText?: string
required?: boolean
reserveMessageSpace?: boolean
}>(), {
options: () => [],
emptyOptionLabel: '',
@@ -189,13 +225,18 @@ const props = withDefaults(defineProps<{
textLabel: 'text-sm',
rounded: 'rounded-md',
disabled: false,
readonly: false,
groupClass: '',
noOptionsText: 'Aucune option disponible',
required: false,
reserveMessageSpace: true,
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string | number | null): void
}>()
const root = ref<HTMLElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null)
const isOpen = ref(false)
const activeIndex = ref(-1)
const openDirection = ref<'down' | 'up'>('down')
@@ -216,8 +257,9 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() =>
props.options.some(o => o.value === props.modelValue)
)
const isReadonly = computed(() => props.readonly && !props.disabled)
const shouldFloatLabel = computed(() =>
isOpen.value || isOptionSelected.value
isReadonly.value ? isOptionSelected.value : (isOpen.value || isOptionSelected.value)
)
const selectedLabel = computed(() =>
props.options.find(o => o.value === props.modelValue)?.label ?? ''
@@ -245,6 +287,7 @@ function updateOpenDirection() {
}
function open() {
if (props.disabled || props.readonly) return
updateOpenDirection()
isOpen.value = true
@@ -288,7 +331,7 @@ function close() {
}
function toggle() {
if (props.disabled) return
if (props.disabled || props.readonly) return
if (isOpen.value) {
close()
return
@@ -299,6 +342,7 @@ function toggle() {
function select(value: string | number | null) {
emit('update:modelValue', value)
close()
buttonRef.value?.blur()
}
function onClickOutside(e: MouseEvent) {
@@ -316,6 +360,16 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.grow-height {
transition: none;
}
}
:deep(ul[role="listbox"]) {
scrollbar-width: auto;
}
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import {mount, renderToString} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import SelectCheckbox from './SelectCheckbox.vue'
@@ -9,7 +9,7 @@ type Option = {
}
type SelectCheckboxProps = {
modelValue: Array<string | number>
modelValue?: Array<string | number>
options?: Option[]
emptyOptionLabel?: string
label?: string
@@ -24,7 +24,10 @@ type SelectCheckboxProps = {
displaySelectAll?: boolean
selectAllLabel?: string
disabled?: boolean
readonly?: boolean
groupClass?: string
required?: boolean
reserveMessageSpace?: boolean
}
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
@@ -36,6 +39,18 @@ const options: Option[] = [
]
describe('MalioSelectCheckbox', () => {
it('rend sans planter quand modelValue nest pas fourni (non contrôlé)', () => {
expect(() =>
mount(SelectCheckboxForTest, {props: {label: 'Catégories', options}}),
).not.toThrow()
})
it('rend en SSR sans planter quand modelValue est absent (cause du crash playground)', async () => {
await expect(
renderToString(SelectCheckboxForTest, {props: {label: 'Catégories', readonly: true, options}}),
).resolves.toBeTruthy()
})
it('renders checkbox inputs for options', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
@@ -182,4 +197,173 @@ describe('MalioSelectCheckbox', () => {
const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('mt-4')
})
it('shows muted chevron color when nothing is selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted')
})
it('shows primary chevron color when open', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options},
})
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-primary')
})
it('shows black chevron color when options are selected and closed', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options},
})
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('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')
})
it('readonly : bordure noire même sans sélection, pas de grow/bleu', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
})
const trigger = wrapper.get('button')
expect(trigger.classes()).toContain('border-black')
expect(trigger.classes()).not.toContain('border-m-muted')
expect(trigger.classes()).not.toContain('grow-height')
expect(trigger.classes()).not.toContain('focus-visible:border-m-primary')
})
it('readonly vide : label gris, pas de bleu', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
})
const label = wrapper.get('label')
expect(label.classes()).not.toContain('text-m-primary')
expect(label.classes()).toContain('text-m-muted')
})
it('readonly sélectionné : label noir + chevron noir', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: ['a'], options: [{label: 'A', value: 'a'}]},
})
expect(wrapper.get('label').classes()).toContain('text-black')
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
})
it('readonly empêche louverture du dropdown', async () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options: [{label: 'A', value: 'a'}]},
})
await wrapper.get('button').trigger('click')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('readonly expose aria-readonly et reste focusable (pas disabled)', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {label: 'Champ', readonly: true, modelValue: [], options},
})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBe('true')
expect(trigger.attributes('disabled')).toBeUndefined()
})
it('disabled + readonly : pas daria-readonly (disabled prime)', () => {
const wrapper = mount(SelectCheckboxForTest, {props: {modelValue: [], label: 'Champ', disabled: true, readonly: true, options: [{label: 'A', value: 'a'}]}})
const trigger = wrapper.get('button')
expect(trigger.attributes('aria-readonly')).toBeUndefined()
expect(trigger.attributes('disabled')).toBeDefined()
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options}})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false}})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mount(SelectCheckboxForTest, {props: {label: 'Champ', options, reserveMessageSpace: false, error: 'Erreur'}})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})
+75 -18
View File
@@ -6,29 +6,34 @@
>
<button
:id="buttonId"
ref="buttonRef"
type="button"
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
class="peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none"
:class="[
isReadonly ? '' : 'grow-height',
isReadonly ? '' : 'focus-visible:border-m-primary',
hasError
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-danger !border-b-0'
: 'rounded-t-none !border-2 !border-m-danger !border-t-0'
? 'rounded-b-none !border !border-m-danger !border-b-transparent'
: 'rounded-t-none !border !border-m-danger !border-t-transparent'
: 'border-m-danger'
: hasSuccess
? isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
? 'rounded-b-none !border !border-m-success !border-b-transparent'
: 'rounded-t-none !border !border-m-success !border-t-transparent'
: 'border-m-success'
: isReadonly
? 'border-black'
: isOpen
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
? 'rounded-b-none !border !border-m-primary !border-b-transparent'
: 'rounded-t-none !border !border-m-primary !border-t-transparent'
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded,
textField,
@@ -37,6 +42,8 @@
:aria-controls="listboxId"
:aria-invalid="hasError"
:aria-describedby="describedBy"
:aria-required="required || undefined"
:aria-readonly="isReadonly || undefined"
:disabled="disabled"
@click="toggle"
>
@@ -49,6 +56,10 @@
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
@@ -58,7 +69,7 @@
]"
:style="labelTransformStyle"
>
{{ label }}
{{ label }}<MalioRequiredMark v-if="required" />
</label>
<div
@@ -100,13 +111,24 @@
</span>
<span
data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-current'
: disabled
? 'text-m-muted'
: isReadonly
? isOptionSelected
? 'text-black'
: 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted'
]"
>
<slot name="icon">
@@ -126,7 +148,7 @@
ref="listRef"
role="listbox"
:aria-labelledby="buttonId"
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border bg-white"
:class="[
openDirection === 'down'
? 'top-[calc(100%-4px)] rounded-b-md border-t-0'
@@ -144,7 +166,14 @@
]"
>
<li
v-if="displaySelectAll"
v-if="normalizedOptions.length === 0"
class="px-3 py-2 text-m-muted"
data-test="no-options-text"
>
{{ noOptionsText }}
</li>
<li
v-if="displaySelectAll && normalizedOptions.length > 0"
class="border-b border-m-muted/30 px-3 py-2"
@mousedown.prevent
>
@@ -155,6 +184,7 @@
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer font-semibold"
tabindex="-1"
:reserve-message-space="false"
@update:model-value="toggleAll"
/>
</li>
@@ -180,13 +210,14 @@
group-class="!mt-0"
label-class="option-checkbox w-full cursor-pointer"
tabindex="-1"
:reserve-message-space="false"
@update:model-value="toggleOption(opt.value)"
/>
</li>
</ul>
</div>
<p
v-if="hint || hasError || hasSuccess"
v-if="reserveMessageSpace || hint || error || success"
:id="`${buttonId}-describedby`"
:class="[
hasError
@@ -195,6 +226,7 @@
? 'text-m-success'
: 'text-m-muted',
'mt-1 ml-[2px] text-xs',
reserveMessageSpace ? 'min-h-[1rem]' : '',
]"
>
{{ error || success || hint }}
@@ -207,6 +239,7 @@ import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import Checkbox from '../checkbox/Checkbox.vue'
import MalioRequiredMark from '../shared/RequiredMark.vue'
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
@@ -215,7 +248,7 @@ type Option = {
value: string | number
}
const props = withDefaults(defineProps<{
modelValue: Array<string | number>
modelValue?: Array<string | number>
options?: Option[]
emptyOptionLabel?: string
label?: string
@@ -230,8 +263,13 @@ const props = withDefaults(defineProps<{
displaySelectAll?: boolean
selectAllLabel?: string
disabled?: boolean
readonly?: boolean
groupClass?: string
noOptionsText?: string
required?: boolean
reserveMessageSpace?: boolean
}>(), {
modelValue: () => [],
options: () => [],
emptyOptionLabel: '',
label: '',
@@ -246,13 +284,18 @@ const props = withDefaults(defineProps<{
displaySelectAll: false,
selectAllLabel: 'Tout sélectionner',
disabled: false,
readonly: false,
groupClass: '',
noOptionsText: 'Aucune option disponible',
required: false,
reserveMessageSpace: true,
})
const emit = defineEmits<{
(e: 'update:modelValue', v: Array<string | number>): void
}>()
const root = ref<HTMLElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null)
const isOpen = ref(false)
const activeIndex = ref(-1)
const openDirection = ref<'down' | 'up'>('down')
@@ -270,6 +313,7 @@ const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() =>
props.modelValue.length > 0
)
const isReadonly = computed(() => props.readonly && !props.disabled)
const selectedOptions = computed(() =>
normalizedOptions.value.filter(option => props.modelValue.includes(option.value)),
)
@@ -277,7 +321,7 @@ const displayTags = computed(() =>
props.displayTag && selectedOptions.value.length > 0,
)
const shouldFloatLabel = computed(() =>
isOpen.value || displayTags.value
isReadonly.value ? isOptionSelected.value : (isOpen.value || displayTags.value)
)
const selectionSummary = computed(() =>
`${props.modelValue.length}/${normalizedOptions.value.length}`
@@ -309,6 +353,7 @@ function updateOpenDirection() {
}
function open() {
if (props.disabled || props.readonly) return
updateOpenDirection()
isOpen.value = true
@@ -352,7 +397,7 @@ function close() {
}
function toggle() {
if (props.disabled) return
if (props.disabled || props.readonly) return
if (isOpen.value) {
close()
return
@@ -367,10 +412,11 @@ function isChecked(value: string | number) {
function toggleOption(value: string | number) {
if (isChecked(value)) {
emit('update:modelValue', props.modelValue.filter(item => item !== value))
return
}
} else {
emit('update:modelValue', [...props.modelValue, value])
}
nextTick(() => buttonRef.value?.focus())
}
function toggleAll() {
if (allSelected.value) {
@@ -378,6 +424,7 @@ function toggleAll() {
} else {
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
}
nextTick(() => buttonRef.value?.focus())
}
function onClickOutside(e: MouseEvent) {
@@ -395,6 +442,16 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
padding: 0 0.25rem;
}
.grow-height {
transition: border-color 160ms ease, box-shadow 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.grow-height {
transition: none;
}
}
:deep(ul[role="listbox"]) {
scrollbar-width: auto;
scrollbar-gutter: stable;
@@ -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>
+50
View File
@@ -8,6 +8,7 @@ type Tab = {
key: string
label: string
icon?: string
disabled?: boolean
}
type TabListProps = {
@@ -134,4 +135,53 @@ describe('MalioTabList', () => {
expect(icons[0].props('icon')).toBe('mdi:home')
expect(icons[1].props('icon')).toBe('mdi:account')
})
it('sets disabled attribute and aria-disabled on disabled tabs', () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
expect(buttons[1].attributes('disabled')).toBeDefined()
expect(buttons[1].attributes('aria-disabled')).toBe('true')
})
it('applies cursor-not-allowed on disabled tabs', () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
expect(buttons[1].classes()).toContain('cursor-not-allowed')
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70')
})
it('does not emit update:modelValue when clicking a disabled tab', async () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs, modelValue: 'a'})
const buttons = wrapper.findAll('[role="tab"]')
await buttons[1].trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('does not change active tab in uncontrolled mode when clicking disabled tab', async () => {
const disabledTabs: Tab[] = [
{key: 'a', label: 'A'},
{key: 'b', label: 'B', disabled: true},
]
const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]')
await buttons[1].trigger('click')
expect(buttons[0].attributes('aria-selected')).toBe('true')
expect(buttons[1].attributes('aria-selected')).toBe('false')
})
})
+12 -4
View File
@@ -12,19 +12,23 @@
type="button"
:aria-selected="activeTab === tab.key"
:aria-controls="`${componentId}-panel-${tab.key}`"
:aria-disabled="!!tab.disabled"
:tabindex="activeTab === tab.key ? 0 : -1"
:disabled="tab.disabled"
:class="[
'flex items-center gap-[18px] text-[24px] font-medium transition-colors cursor-pointer',
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
activeTab === tab.key
? 'border-b-2 border-m-primary text-m-primary font-bold outline-b'
: 'border-transparent text-m-primary/50 hover:text-m-primary/70',
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
: tab.disabled
? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
]"
@click="selectTab(tab.key)"
>
<IconifyIcon
v-if="tab.icon"
:icon="tab.icon"
:width="20"
:width="tab.iconSize ?? 24"
/>
{{ tab.label }}
</button>
@@ -53,6 +57,8 @@ type Tab = {
key: string
label: string
icon?: string
iconSize?: string
disabled?: boolean
}
const props = withDefaults(defineProps<{
@@ -79,6 +85,8 @@ const activeTab = computed(() =>
)
function selectTab(key: string) {
const tab = props.tabs.find(t => t.key === key)
if (tab?.disabled) return
if (!isControlled.value) {
localValue.value = key
}
+30
View File
@@ -17,6 +17,7 @@ type TimeProps = {
hint?: string
error?: string
success?: string
reserveMessageSpace?: boolean
}
const TimeForTest = Time as DefineComponent<TimeProps>
@@ -76,4 +77,33 @@ describe('MalioTime', () => {
expect(inputs[0].classes()).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)
})
it('réserve lespace message par défaut même sans message', () => {
const wrapper = mountTime({label: 'Champ'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).toContain('min-h-[1rem]')
})
it('reserveMessageSpace=false sans message : pas de ligne réservée', () => {
const wrapper = mountTime({label: 'Champ', reserveMessageSpace: false})
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
})
it('reserveMessageSpace=false avec message : ligne rendue sans min-h', () => {
const wrapper = mountTime({label: 'Champ', reserveMessageSpace: false, error: 'Erreur'})
const msg = wrapper.find('[id$="-describedby"]')
expect(msg.exists()).toBe(true)
expect(msg.classes()).not.toContain('min-h-[1rem]')
})
})

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