67 Commits

Author SHA1 Message Date
7dec45b374 Merge branch 'main' into develop 2026-05-04 18:03:33 +00:00
ea92acff3a fix(input-rich-text) : couleurs de texte et surlignage façon Jira
Ajoute deux boutons à la toolbar avec popover en palette pour
appliquer une couleur de texte ou un surlignage sur la sélection.

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

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

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:01:55 +02:00
f30619a497 fix: ajout du composant rich text editor (TipTap v3) (#39)
All checks were successful
Release / release (push) Successful in 1m8s
## Résumé

Release pour publier le composant `MalioInputRichText` (mergé via #37 dans `develop`).

Inclut également un correctif de configuration `semantic-release` pour tolérer le format de commits du repo (`<type>(<scope>) : <message>` avec espace avant `:`).

## Changements

- **#37** — `feat(input-rich-text)` : composant éditeur de texte riche TipTap v3 (déjà sur `develop`)
- **chore(release)** : `parserOpts.headerPattern` ajouté à `commit-analyzer` et `release-notes-generator` dans `.releaserc.json` pour matcher le format Malio avec espace

## Pourquoi un titre `fix:` et pas `feat:`

Choix utilisateur — bump souhaité **patch** (`v1.4.6` → `v1.4.7`) plutôt que minor.

## Test plan

- [ ] Workflow Gitea Actions se déclenche après merge
- [ ] semantic-release détecte le commit `fix:` et bump en `v1.4.7`
- [ ] Tag `v1.4.7` créé
- [ ] Package publié sur Gitea Packages npm registry
- [ ] Test côté projet consommateur : `npm update @malio/layer-ui` → import `<MalioInputRichText>`

## Note durable

Pour les futures PR de release : titre de la PR = un commit Conventional Commits (ex: `fix: …`, `feat: …`, `chore: …`). Avec ce parserOpts en place, l'espace avant `:` est désormais toléré par semantic-release.

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #39
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
2026-05-04 13:27:57 +00:00
a3421c02e9 Merge remote-tracking branch 'origin/main' into develop 2026-05-04 15:27:10 +02:00
5563d89743 chore(release) : tolérer l'espace avant ':' dans le commit-analyzer
Le hook commit-msg du repo impose le format `<type>(<scope>) : <message>`
avec un espace avant le ':', mais le preset Angular du commit-analyzer
de semantic-release attend le format standard sans espace. Ce décalage
empêchait semantic-release de reconnaître les commits squashés sur main
si le titre de PR contenait un espace ou un type non standard.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:24:24 +02:00
d7bf038fdd release : rich text editor (TipTap) (#38)
All checks were successful
Release / release (push) Successful in 1m6s
## Résumé

Release de `develop` vers `main` pour déclencher `semantic-release` (publication sur Gitea Packages).

Inclut :
- **#37** — `feat(input-rich-text) : ajout d'un éditeur de texte riche basé sur TipTap v3`

Le commit `feat:` déclenchera un bump **minor** (rétrocompatible).

## Test plan

- [x] Tests verts sur `develop` (315/315)
- [x] Lint OK (0 erreur sur les fichiers ajoutés)
- [x] Histoire build OK
- [ ] Vérifier le run du workflow `release.yml` après merge
- [ ] Vérifier la nouvelle version publiée sur Gitea Packages

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #38
2026-05-04 13:15:41 +00:00
640ff90187 Merge branch 'main' into develop 2026-05-04 13:15:24 +00:00
2eb7a5247a feat(input-rich-text) : ajout d'un éditeur de texte riche basé sur TipTap v3 (#37)
## Résumé

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

## Détails

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

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

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

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

## Test plan

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

## Fichiers

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

Reviewed-on: #37
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
2026-05-04 13:12:38 +00:00
2059556ffe fix: option vide rendue uniquement si emptyOptionLabel non vide (#36)
All checks were successful
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: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #36
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-27 12:59:18 +00:00
3336ff0c69 Merge branch 'main' into develop 2026-04-27 14:58:25 +02:00
da3a4cb349 fix(select) : option vide rendue uniquement si emptyOptionLabel non vide 2026-04-27 14:51:49 +02:00
a95cf8cdfb fix: select checkbox (#35)
All checks were successful
Release / release (push) Successful in 1m10s
| 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: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #35
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-27 10:09:24 +00:00
0ddae4dd70 Merge branch 'main' into develop 2026-04-27 12:05:31 +02:00
23210e6868 refactor(select-checkbox) : ré-aligner la structure sur MalioSelect 2026-04-27 11:44:18 +02:00
ba2ecb5768 fix: suppression de la marge top sur la Checkbox (#34)
All checks were successful
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: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #34
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-27 09:31:14 +00:00
1c0fcd24e3 Merge branch 'main' into develop 2026-04-27 11:30:22 +02:00
d74f3acc97 fix : suppression de la marge top des Checkbox.vue 2026-04-27 11:26:21 +02:00
87940481d6 fix: utilisation de la bonne police (#33)
All checks were successful
Release / release (push) Successful in 1m6s
| 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: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #33
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-24 12:15:03 +00:00
014a057196 Merge branch 'main' into develop 2026-04-24 14:14:27 +02:00
73483b0573 fix : utilisation de la bonne police 2026-04-24 09:01:28 +02:00
66fbbf8abe fix: suppression de la margin top du textArea component (#32)
All checks were successful
Release / release (push) Successful in 1m3s
| 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: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #32
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 13:02:59 +00:00
4855923008 Merge branch 'main' into develop 2026-04-20 15:02:23 +02:00
fc844078a6 fix : suppression de la marge top du champ textArea 2026-04-20 15:01:50 +02:00
8de950c402 fix: distribution de tailwind.config.ts aux projets consommateurs avec paths content absolus (#31)
All checks were successful
Release / release (push) Successful in 1m8s
| 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: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #31
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 12:54:42 +00:00
02495245a5 Merge branch 'main' into develop 2026-04-20 14:54:04 +02:00
330fb2130b fix(build) : distribuer tailwind.config.ts + paths absolus + pagination datatable
- Ajoute tailwind.config.ts aux files du package pour qu'il soit inclus dans le tarball npm
- Convertit les paths content en absolus (via fileURLToPath) pour que Tailwind scanne les composants du layer depuis node_modules côté consommateur
- Aligne la hauteur des boutons de pagination du DataTable (h-10) sur le Select
- Ajuste --m-radius à 6px

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:53:20 +02:00
1a14629404 feat: ajout du composant site selector (#30)
All checks were successful
Release / release (push) Successful in 1m7s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #30
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-17 12:32:09 +00:00
5acefc1d59 Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
#	COMPONENTS.md
2026-04-17 14:30:39 +02:00
e77bf49146 [#MUI-27] Création d'un composant sélection de site (#29)
Composant MalioSiteSelector : bande horizontale pour choisir un site
(usine ou lieu) parmi une liste. Tuiles flex proportionnelles, couleur
du site sélectionné partagée par toutes les tuiles (opacité 1 / 0.4).
Expose update:modelValue (id) + change (objet site complet) pour
faciliter les appels API côté consommateur.

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #3
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-02 13:24:58 +00:00
82ecc9cfe2 feat : ajout config vitest/make/pre-commit/commit-msg + un exemple de test vitest 2026-02-23 11:29:16 +01:00
65d9060e26 feat : ajout du template de MR + CHANGELOG.md 2026-02-23 11:11:31 +01:00
ec4c157226 fix: readme.md 2026-02-19 11:18:36 +01:00
23 changed files with 2420 additions and 52 deletions

View File

@@ -0,0 +1,91 @@
<template>
<div class="grid grid-cols-1 items-start gap-6 p-4 lg:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputRichText
v-model="simpleValue"
label="Note"
placeholder="Écrire ici…"
/>
<pre class="mt-3 overflow-auto rounded bg-m-bg p-2 text-xs">{{ simpleValue }}</pre>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec contenu initial + hint</h2>
<MalioInputRichText
v-model="hintValue"
label="Description"
hint="Tu peux mettre en forme avec la barre d'outils"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputRichText
v-model="errorValue"
label="Compte-rendu"
error="Le compte-rendu doit faire au moins 20 caractères"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputRichText
v-model="successValue"
label="Compte-rendu"
success="Compte-rendu validé"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputRichText
v-model="readonlyValue"
label="Note (lecture seule)"
readonly
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Disabled</h2>
<MalioInputRichText
v-model="disabledValue"
label="Note (désactivée)"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Affichage seul (editable=false)</h2>
<MalioInputRichText
:model-value="readonlyValue"
:editable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Sortie HTML</h2>
<MalioInputRichText
v-model="htmlValue"
label="Article"
output-format="html"
min-height="200px"
placeholder="Tape ici, la sortie sera en HTML…"
/>
<pre class="mt-3 overflow-auto rounded bg-m-bg p-2 text-xs">{{ htmlValue }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioInputRichText from '../../../../app/components/malio/input/InputRichText.vue'
const simpleValue = ref('')
const hintValue = ref('## Titre\n\nUn paragraphe avec du **gras**, de l\'*italique* et un [lien](https://malio.fr).')
const errorValue = ref('Trop court')
const successValue = ref('Tout est bon de mon côté.')
const readonlyValue = ref('## Compte-rendu\n\n- Point 1\n- Point 2\n\n> Citation importante')
const disabledValue = ref('Contenu indisponible.')
const htmlValue = ref('<p>Contenu <strong>riche</strong>.</p>')
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="grid grid-cols-1 items-start gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple (3 sites) + event change</h2>
<MalioSiteSelector v-model="simpleValue" :sites="sites" @change="onSiteChange" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ simpleValue }}</code></p>
<p class="mt-1 text-sm text-gray-600">Dernier event <code>change</code> : <code>{{ lastChange }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Deux sites</h2>
<MalioSiteSelector v-model="twoValue" :sites="sitesTwo" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ twoValue }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Cinq sites (largeur proportionnelle)</h2>
<MalioSiteSelector v-model="fiveValue" :sites="sitesFive" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ fiveValue }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé (sans v-model)</h2>
<MalioSiteSelector :sites="sites" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Largeur contrainte</h2>
<div class="w-[480px]">
<MalioSiteSelector v-model="constrainedValue" :sites="sites" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const sites = [
{ id: 'chatellerault', name: 'Châtellerault', color: '#0055ff' },
{ id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a' },
{ id: 'pommevic', name: 'Pommevic', color: '#dc2626' },
]
const sitesTwo = [
{ id: 'nord', name: 'Usine Nord', color: '#7c3aed' },
{ id: 'sud', name: 'Usine Sud', color: '#ea580c' },
]
const sitesFive = [
{ id: 's1', name: 'Site 1', color: '#0ea5e9' },
{ id: 's2', name: 'Site 2', color: '#14b8a6' },
{ id: 's3', name: 'Site 3', color: '#f59e0b' },
{ id: 's4', name: 'Site 4', color: '#ec4899' },
{ id: 's5', name: 'Site 5', color: '#6366f1' },
]
const simpleValue = ref('chatellerault')
const twoValue = ref('nord')
const fiveValue = ref('s3')
const constrainedValue = ref('saint-jean')
const lastChange = ref<string>('—')
function onSiteChange(site: { id: string; name: string; color: string }) {
lastChange.value = JSON.stringify(site)
}
</script>

View File

@@ -2,8 +2,26 @@
"branches": ["main", "master"],
"repositoryUrl": "https://gitea.malio.fr/MALIO-DEV/malio-layer-ui.git",
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/commit-analyzer",
{
"preset": "angular",
"parserOpts": {
"headerPattern": "^(\\w+)(?:\\(([\\w$.\\-* ]+)\\))?\\s*:\\s+(.+)$",
"headerCorrespondence": ["type", "scope", "subject"]
}
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "angular",
"parserOpts": {
"headerPattern": "^(\\w+)(?:\\(([\\w$.\\-* ]+)\\))?\\s*:\\s+(.+)$",
"headerCorrespondence": ["type", "scope", "subject"]
}
}
],
"@semantic-release/npm"
]
}

View File

@@ -25,7 +25,11 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-2] Faire un MCP pour la librairie de composant
* [#MUI-15] Création d'un composant drawer
* [#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
### Changed
### Fixed
* 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

View File

@@ -132,6 +132,41 @@ Zone de texte multiligne avec compteur et redimensionnement.
---
## MalioInputRichText
Éditeur de texte riche basé sur **TipTap v3** + **StarterKit** + **tiptap-markdown** + **TextStyle/Color/Highlight**. Toolbar avec gras, italique, barré, titres H2/H3, listes, citation, code, code-block, lien, **couleur du texte**, **surlignage**, undo/redo. Sortie en HTML (par défaut) ou markdown.
> Couleurs et surlignages ne sont **pas persistés en markdown**. Pour les conserver au save/reload, utiliser `output-format="html"`.
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML |
| `label` | `string` | `''` | Label affiché au-dessus de l'éditeur |
| `modelValue` | `string \| null` | `undefined` | Contenu (v-model) |
| `placeholder` | `string` | `''` | Texte affiché quand vide |
| `minHeight` | `string` | `'160px'` | Hauteur min de la zone d'édition |
| `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) |
| `hint` | `string` | `''` | Message d'aide |
| `error` | `string` | `''` | Message d'erreur |
| `success` | `string` | `''` | Message de succès |
| `outputFormat` | `'markdown' \| 'html'` | `'html'` | Format émis dans `update:modelValue` |
| `groupClass` | `string` | `''` | Classes CSS conteneur (twMerge) |
| `labelClass` | `string` | `''` | Classes CSS label (twMerge) |
| `editorClass` | `string` | `''` | Classes CSS wrapper éditeur (twMerge) |
**Events :** `update:modelValue(value: string)`
```vue
<MalioInputRichText v-model="note" label="Note" placeholder="Écrire ici…" />
<MalioInputRichText v-model="cr" label="Compte-rendu" error="Trop court" />
<MalioInputRichText v-model="article" label="Article" min-height="240px" />
<MalioInputRichText :model-value="content" :editable="false" />
```
---
## MalioInputUpload
Champ d'upload de fichier.

View File

@@ -35,6 +35,6 @@
--m-site-yellow: 243 203 0; /* #F3CB00 - Jaune Saint-Jean */
--m-site-green: 116 191 4; /* #74BF04 - Vert Pommevic */
--m-radius: 8px;
--m-radius: 6px;
}
}

View File

@@ -94,7 +94,7 @@ const describedBy = computed(() => {
const mergedGroupClass = computed(() =>
twMerge(
'checkbox-wrapper-4 mt-4 w-full',
'checkbox-wrapper-4 w-full',
props.groupClass,
),
)

View File

@@ -80,7 +80,7 @@
variant="tertiary"
label="Prev"
:disabled="page <= 1"
button-class="h-8 w-auto min-w-0 px-3 text-sm"
button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente"
data-test="prev-button"
@click="goToPage(page - 1)"
@@ -95,7 +95,7 @@
<button
v-else
type="button"
class="h-8 min-w-[2rem] rounded px-2 text-sm transition-colors"
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
:class="p === page
? 'bg-m-btn-primary text-white font-semibold'
: 'text-m-text hover:bg-m-bg'"
@@ -111,7 +111,7 @@
variant="tertiary"
label="Next"
:disabled="page >= totalPages"
button-class="h-8 w-auto min-w-0 px-3 text-sm"
button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante"
data-test="next-button"
@click="goToPage(page + 1)"

View File

@@ -0,0 +1,165 @@
import {afterEach, describe, expect, it} from 'vitest'
import {flushPromises, mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import InputRichText from './InputRichText.vue'
type InputRichTextProps = {
id?: string
label?: string
modelValue?: string | null
placeholder?: string
minHeight?: string
editable?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
outputFormat?: 'markdown' | 'html'
groupClass?: string
labelClass?: string
editorClass?: string
}
const InputRichTextForTest = InputRichText as DefineComponent<InputRichTextProps>
const mountComponent = async (props: InputRichTextProps = {}) => {
const wrapper = mount(InputRichTextForTest, {
props,
attachTo: document.body,
})
await flushPromises()
return wrapper
}
afterEach(() => {
document.body.replaceChildren()
})
describe('MalioInputRichText', () => {
it('renders the label and reuses a provided id', async () => {
const wrapper = await mountComponent({id: 'custom-rt-id', label: 'Description'})
const label = wrapper.get('label')
expect(label.text()).toBe('Description')
expect(label.attributes('for')).toBe('custom-rt-id')
expect(wrapper.get('#custom-rt-id').exists()).toBe(true)
})
it('generates an id when missing', async () => {
const wrapper = await mountComponent({label: 'Description'})
const labelFor = wrapper.get('label').attributes('for')
expect(labelFor?.startsWith('malio-input-rich-text-')).toBe(true)
})
it('renders the toolbar buttons in editable mode', async () => {
const wrapper = await mountComponent({modelValue: ''})
const buttons = wrapper.findAll('button[type="button"]')
expect(buttons.length).toBeGreaterThanOrEqual(13)
expect(wrapper.find('button[title="Gras"]').exists()).toBe(true)
expect(wrapper.find('button[title="Italique"]').exists()).toBe(true)
expect(wrapper.find('button[title="Lien"]').exists()).toBe(true)
expect(wrapper.find('button[title="Couleur du texte"]').exists()).toBe(true)
expect(wrapper.find('button[title="Surlignage"]').exists()).toBe(true)
expect(wrapper.find('button[title="Annuler"]').exists()).toBe(true)
expect(wrapper.find('button[title="Rétablir"]').exists()).toBe(true)
})
it('opens and closes the text color palette', async () => {
const wrapper = await mountComponent({modelValue: ''})
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(true)
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
})
it('opens the highlight palette and closes the color palette', async () => {
const wrapper = await mountComponent({modelValue: ''})
await wrapper.get('button[title="Couleur du texte"]').trigger('click')
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(true)
await wrapper.get('button[title="Surlignage"]').trigger('click')
expect(wrapper.find('[aria-label="Palette de surlignage"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Palette couleur du texte"]').exists()).toBe(false)
})
it('disables color and highlight buttons when readonly', async () => {
const wrapper = await mountComponent({readonly: true, modelValue: ''})
expect(wrapper.get('button[title="Couleur du texte"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[title="Surlignage"]').attributes('disabled')).toBeDefined()
})
it('does not render the toolbar in readonly display mode (editable=false)', async () => {
const wrapper = await mountComponent({editable: false, modelValue: '**hi**'})
expect(wrapper.find('button[title="Gras"]').exists()).toBe(false)
})
it('disables toolbar buttons when disabled', async () => {
const wrapper = await mountComponent({disabled: true, modelValue: ''})
const boldBtn = wrapper.get('button[title="Gras"]')
expect(boldBtn.attributes('disabled')).toBeDefined()
})
it('disables toolbar buttons when readonly', async () => {
const wrapper = await mountComponent({readonly: true, modelValue: ''})
const boldBtn = wrapper.get('button[title="Gras"]')
expect(boldBtn.attributes('disabled')).toBeDefined()
})
it('shows hint message in muted color', async () => {
const wrapper = await mountComponent({hint: 'Helpful hint'})
expect(wrapper.get('p.text-m-muted').text()).toBe('Helpful hint')
})
it('shows error state on wrapper, label and message', async () => {
const wrapper = await mountComponent({label: 'Description', error: 'Editor error'})
expect(wrapper.get('label').classes()).toContain('text-m-danger')
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
})
it('shows success state on wrapper, label and message', async () => {
const wrapper = await mountComponent({label: 'Description', success: 'Editor success'})
expect(wrapper.get('label').classes()).toContain('text-m-success')
expect(wrapper.get('p.text-m-success').text()).toBe('Editor success')
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-success')
})
it('prioritizes error over success', async () => {
const wrapper = await mountComponent({error: 'Editor error', success: 'Editor success'})
expect(wrapper.get('.rich-text-wrapper').classes()).toContain('border-m-danger')
expect(wrapper.find('p.text-m-success').exists()).toBe(false)
expect(wrapper.get('p.text-m-danger').text()).toBe('Editor error')
})
it('sets aria-invalid and aria-describedby on the editor content when error', async () => {
const wrapper = await mountComponent({id: 'rt-aria', error: 'Boom'})
const editorContent = wrapper.find('[aria-invalid="true"]')
expect(editorContent.exists()).toBe(true)
expect(editorContent.attributes('aria-describedby')).toBe('rt-aria-describedby')
})
it('renders initial markdown content visually', async () => {
const wrapper = await mountComponent({modelValue: '## Mon titre\n\nUn paragraphe.'})
const html = wrapper.html()
expect(html).toContain('Mon titre')
expect(html).toContain('Un paragraphe.')
})
})

View File

@@ -0,0 +1,574 @@
<template>
<div :class="mergedGroupClass">
<label
v-if="label"
:for="editorId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<!-- Mode lecture seule (rendu uniquement) -->
<div
v-if="!editable"
:id="editorId"
:class="mergedReadonlyClass"
>
<EditorContent :editor="editor" />
</div>
<!-- Mode éditable -->
<div
v-else
:id="editorId"
:class="mergedEditorWrapperClass"
@click="focusEditor"
>
<div
class="flex flex-wrap items-center gap-0.5 border-b border-m-border bg-m-bg p-1"
@click.stop
>
<button
v-for="btn in toolbarButtons"
:key="btn.key"
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
:class="btn.isActive() ? 'bg-m-primary/15 text-m-primary' : ''"
:title="btn.title"
:disabled="disabled || readonly"
:aria-label="btn.title"
:aria-pressed="btn.isActive()"
@mousedown.prevent
@click="btn.action()"
>
<IconifyIcon :icon="btn.icon" :width="18" :height="18" />
</button>
<span class="mx-1 h-5 w-px bg-m-border" aria-hidden="true" />
<div class="relative">
<button
type="button"
class="flex h-8 w-8 flex-col items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
:class="colorPickerOpen ? 'bg-m-primary/15 text-m-primary' : ''"
title="Couleur du texte"
aria-label="Couleur du texte"
:aria-expanded="colorPickerOpen"
:disabled="disabled || readonly"
@mousedown.prevent
@click="toggleColorPicker"
>
<IconifyIcon icon="mdi:format-color-text" :width="18" :height="18" />
<span
class="-mt-0.5 block h-1 w-4 rounded-sm"
:style="{ backgroundColor: currentTextColor ?? 'transparent' }"
aria-hidden="true"
/>
</button>
<div
v-if="colorPickerOpen"
class="absolute left-0 top-full z-10 mt-1 flex w-44 flex-col gap-2 rounded-md border border-m-border bg-white p-2 shadow-lg"
role="dialog"
aria-label="Palette couleur du texte"
>
<div class="grid grid-cols-4 gap-1">
<button
v-for="swatch in textColorSwatches"
:key="swatch.value"
type="button"
class="h-7 w-7 rounded border border-m-border transition-transform hover:scale-110"
:class="currentTextColor === swatch.value ? 'ring-2 ring-m-primary ring-offset-1' : ''"
:style="{ backgroundColor: swatch.value }"
:title="swatch.label"
:aria-label="swatch.label"
@mousedown.prevent
@click="applyTextColor(swatch.value)"
/>
</div>
<button
type="button"
class="flex items-center justify-center gap-1 rounded border border-m-border px-2 py-1 text-xs text-m-text transition-colors hover:bg-m-bg"
@mousedown.prevent
@click="applyTextColor(null)"
>
<IconifyIcon icon="mdi:format-color-marker-cancel" :width="14" :height="14" />
Aucune couleur
</button>
</div>
</div>
<div class="relative">
<button
type="button"
class="flex h-8 w-8 flex-col items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
:class="highlightPickerOpen ? 'bg-m-primary/15 text-m-primary' : ''"
title="Surlignage"
aria-label="Surlignage"
:aria-expanded="highlightPickerOpen"
:disabled="disabled || readonly"
@mousedown.prevent
@click="toggleHighlightPicker"
>
<IconifyIcon icon="mdi:marker" :width="18" :height="18" />
<span
class="-mt-0.5 block h-1 w-4 rounded-sm"
:style="{ backgroundColor: currentHighlightColor ?? 'transparent' }"
aria-hidden="true"
/>
</button>
<div
v-if="highlightPickerOpen"
class="absolute left-0 top-full z-10 mt-1 flex w-44 flex-col gap-2 rounded-md border border-m-border bg-white p-2 shadow-lg"
role="dialog"
aria-label="Palette de surlignage"
>
<div class="grid grid-cols-4 gap-1">
<button
v-for="swatch in highlightSwatches"
:key="swatch.value"
type="button"
class="h-7 w-7 rounded border border-m-border transition-transform hover:scale-110"
:class="currentHighlightColor === swatch.value ? 'ring-2 ring-m-primary ring-offset-1' : ''"
:style="{ backgroundColor: swatch.value }"
:title="swatch.label"
:aria-label="swatch.label"
@mousedown.prevent
@click="applyHighlight(swatch.value)"
/>
</div>
<button
type="button"
class="flex items-center justify-center gap-1 rounded border border-m-border px-2 py-1 text-xs text-m-text transition-colors hover:bg-m-bg"
@mousedown.prevent
@click="applyHighlight(null)"
>
<IconifyIcon icon="mdi:format-color-marker-cancel" :width="14" :height="14" />
Aucun surlignage
</button>
</div>
</div>
<span class="mx-1 h-5 w-px bg-m-border" aria-hidden="true" />
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Annuler"
aria-label="Annuler"
:disabled="disabled || readonly || !editor?.can().undo()"
@mousedown.prevent
@click="editor?.chain().focus().undo().run()"
>
<IconifyIcon icon="mdi:undo" :width="18" :height="18" />
</button>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-m-text transition-colors hover:bg-m-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Rétablir"
aria-label="Rétablir"
:disabled="disabled || readonly || !editor?.can().redo()"
@mousedown.prevent
@click="editor?.chain().focus().redo().run()"
>
<IconifyIcon icon="mdi:redo" :width="18" :height="18" />
</button>
</div>
<EditorContent
:editor="editor"
class="malio-rich-text flex flex-1 cursor-text"
:style="{ minHeight }"
:aria-invalid="hasError || undefined"
:aria-describedby="describedBy"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${editorId}-describedby`"
:class="[
hasError
? 'text-m-danger'
: hasSuccess
? 'text-m-success'
: 'text-m-muted',
'mt-1 text-xs ml-[2px]',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useId, watch } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { TextStyle } from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import { Markdown } from 'tiptap-markdown'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioInputRichText', inheritAttrs: false })
type OutputFormat = 'markdown' | 'html'
const props = withDefaults(
defineProps<{
id?: string
label?: string
modelValue?: string | null | undefined
placeholder?: string
minHeight?: string
editable?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
outputFormat?: OutputFormat
groupClass?: string
labelClass?: string
editorClass?: string
}>(),
{
id: '',
label: '',
modelValue: undefined,
placeholder: '',
minHeight: '160px',
editable: true,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
outputFormat: 'html',
groupClass: '',
labelClass: '',
editorClass: '',
},
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const generatedId = useId()
const editor = shallowRef<Editor>()
const isFocused = shallowRef(false)
const editorId = computed(() => props.id?.toString() || `malio-input-rich-text-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isInteractionLocked = computed(() => props.disabled || props.readonly)
const describedBy = computed(() =>
hasError.value || hasSuccess.value || props.hint ? `${editorId.value}-describedby` : undefined,
)
const mergedGroupClass = computed(() => twMerge('w-full', props.groupClass))
const mergedLabelClass = computed(() =>
twMerge(
'mb-1 block text-sm font-medium',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isFocused.value
? 'text-m-primary'
: 'text-m-text',
props.disabled ? 'text-black/60' : '',
props.labelClass,
),
)
const mergedEditorWrapperClass = computed(() =>
twMerge(
'rich-text-wrapper flex flex-col overflow-hidden rounded-md border bg-white transition-colors',
hasError.value
? 'border-m-danger focus-within:border-m-danger'
: hasSuccess.value
? 'border-m-success focus-within:border-m-success'
: isFocused.value
? 'border-m-primary'
: 'border-m-muted hover:border-m-text/60',
props.disabled ? 'cursor-not-allowed bg-m-bg/50 opacity-70' : '',
props.editorClass,
),
)
const mergedReadonlyClass = computed(() =>
twMerge(
'malio-rich-text prose prose-sm max-w-none rounded-md border border-m-border bg-white p-3',
'prose-headings:font-semibold prose-a:text-m-primary',
'prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-m-text prose-pre:text-white',
props.editorClass,
),
)
const focusEditor = () => {
if (isInteractionLocked.value) return
closePickers()
editor.value?.commands.focus()
}
const htmlPattern = /<\/?[a-z][\s\S]*>/i
const normalizeEditorInput = (value: string | null | undefined): string => {
const content = (value ?? '').replace(/\r\n?/g, '\n')
if (htmlPattern.test(content)) return content
return content.split('\n').join('\n\n').replace(/\n{3,}/g, '\n\n')
}
const promptForLink = () => {
if (!editor.value) return
const previous = editor.value.getAttributes('link').href as string | undefined
const url = window.prompt('URL du lien (vide pour retirer)', previous ?? '')
if (url === null) return
if (url === '') {
editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
type ColorSwatch = { label: string; value: string }
const textColorSwatches: ColorSwatch[] = [
{ label: 'Rouge', value: '#bf2600' },
{ label: 'Orange', value: '#ff8b00' },
{ label: 'Jaune', value: '#ffc400' },
{ label: 'Vert', value: '#00875a' },
{ label: 'Turquoise', value: '#00a3bf' },
{ label: 'Bleu', value: '#0747a6' },
{ label: 'Violet', value: '#5243aa' },
{ label: 'Gris', value: '#42526e' },
]
const highlightSwatches: ColorSwatch[] = [
{ label: 'Rouge', value: '#fdd0c8' },
{ label: 'Orange', value: '#ffe2c2' },
{ label: 'Jaune', value: '#fff0b3' },
{ label: 'Vert', value: '#c6edd0' },
{ label: 'Turquoise', value: '#c1ecf0' },
{ label: 'Bleu', value: '#cce0ff' },
{ label: 'Violet', value: '#dfd8fa' },
{ label: 'Gris', value: '#dfe1e6' },
]
const colorPickerOpen = ref(false)
const highlightPickerOpen = ref(false)
const closePickers = () => {
colorPickerOpen.value = false
highlightPickerOpen.value = false
}
const toggleColorPicker = () => {
highlightPickerOpen.value = false
colorPickerOpen.value = !colorPickerOpen.value
}
const toggleHighlightPicker = () => {
colorPickerOpen.value = false
highlightPickerOpen.value = !highlightPickerOpen.value
}
const applyTextColor = (value: string | null) => {
if (!editor.value) return
if (value === null) {
editor.value.chain().focus().unsetColor().run()
} else {
editor.value.chain().focus().setColor(value).run()
}
colorPickerOpen.value = false
}
const applyHighlight = (value: string | null) => {
if (!editor.value) return
if (value === null) {
editor.value.chain().focus().unsetHighlight().run()
} else {
editor.value.chain().focus().setHighlight({ color: value }).run()
}
highlightPickerOpen.value = false
}
const currentTextColor = computed(() => {
const attrs = editor.value?.getAttributes('textStyle') as { color?: string } | undefined
return attrs?.color ?? null
})
const currentHighlightColor = computed(() => {
const attrs = editor.value?.getAttributes('highlight') as { color?: string } | undefined
return attrs?.color ?? null
})
const toolbarButtons = computed(() => {
const e = editor.value
return [
{ key: 'bold', icon: 'mdi:format-bold', title: 'Gras', isActive: () => !!e?.isActive('bold'), action: () => e?.chain().focus().toggleBold().run() },
{ key: 'italic', icon: 'mdi:format-italic', title: 'Italique', isActive: () => !!e?.isActive('italic'), action: () => e?.chain().focus().toggleItalic().run() },
{ key: 'strike', icon: 'mdi:format-strikethrough', title: 'Barré', isActive: () => !!e?.isActive('strike'), action: () => e?.chain().focus().toggleStrike().run() },
{ key: 'h2', icon: 'mdi:format-header-2', title: 'Titre H2', isActive: () => !!e?.isActive('heading', { level: 2 }), action: () => e?.chain().focus().toggleHeading({ level: 2 }).run() },
{ key: 'h3', icon: 'mdi:format-header-3', title: 'Titre H3', isActive: () => !!e?.isActive('heading', { level: 3 }), action: () => e?.chain().focus().toggleHeading({ level: 3 }).run() },
{ key: 'bulletList', icon: 'mdi:format-list-bulleted', title: 'Liste à puces', isActive: () => !!e?.isActive('bulletList'), action: () => e?.chain().focus().toggleBulletList().run() },
{ key: 'orderedList', icon: 'mdi:format-list-numbered', title: 'Liste numérotée', isActive: () => !!e?.isActive('orderedList'), action: () => e?.chain().focus().toggleOrderedList().run() },
{ key: 'blockquote', icon: 'mdi:format-quote-close', title: 'Citation', isActive: () => !!e?.isActive('blockquote'), action: () => e?.chain().focus().toggleBlockquote().run() },
{ key: 'code', icon: 'mdi:code-tags', title: 'Code inline', isActive: () => !!e?.isActive('code'), action: () => e?.chain().focus().toggleCode().run() },
{ key: 'codeBlock', icon: 'mdi:code-braces-box', title: 'Bloc de code', isActive: () => !!e?.isActive('codeBlock'), action: () => e?.chain().focus().toggleCodeBlock().run() },
{ key: 'link', icon: 'mdi:link-variant', title: 'Lien', isActive: () => !!e?.isActive('link'), action: promptForLink },
]
})
const getCurrentValue = (): string => {
if (!editor.value) return ''
if (props.outputFormat === 'html') return editor.value.getHTML()
const storage = (editor.value.storage as unknown as Record<string, { getMarkdown?: () => string } | undefined>).markdown
return storage?.getMarkdown ? storage.getMarkdown() : editor.value.getHTML()
}
const handleDocumentMousedown = (event: MouseEvent) => {
if (!colorPickerOpen.value && !highlightPickerOpen.value) return
const target = event.target as Node | null
if (!target) return
const popovers = document.querySelectorAll(`#${editorId.value} [role="dialog"]`)
const triggers = document.querySelectorAll(`#${editorId.value} [aria-expanded]`)
for (const node of [...popovers, ...triggers]) {
if (node.contains(target)) return
}
closePickers()
}
const handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && (colorPickerOpen.value || highlightPickerOpen.value)) {
closePickers()
}
}
onMounted(() => {
document.addEventListener('mousedown', handleDocumentMousedown)
document.addEventListener('keydown', handleDocumentKeydown)
editor.value = new Editor({
content: normalizeEditorInput(props.modelValue),
editable: props.editable && !props.disabled && !props.readonly,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
link: {
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
},
}),
TextStyle,
Color.configure({ types: ['textStyle'] }),
Highlight.configure({ multicolor: true }),
Placeholder.configure({
placeholder: props.placeholder,
}),
Markdown.configure({
html: true,
tightLists: true,
bulletListMarker: '-',
linkify: true,
breaks: false,
transformPastedText: true,
transformCopiedText: true,
}),
],
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none w-full p-3 focus:outline-none prose-headings:font-semibold prose-a:text-m-primary prose-code:rounded prose-code:bg-m-bg prose-code:px-1.5 prose-code:py-0.5 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-m-text prose-pre:text-white',
},
},
onUpdate: () => {
emit('update:modelValue', getCurrentValue())
},
onFocus: () => {
isFocused.value = true
},
onBlur: () => {
isFocused.value = false
},
})
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleDocumentMousedown)
document.removeEventListener('keydown', handleDocumentKeydown)
editor.value?.destroy()
})
watch(() => props.modelValue, (incoming) => {
if (!editor.value) return
if ((incoming ?? '') === getCurrentValue()) return
editor.value.commands.setContent(normalizeEditorInput(incoming), { emitUpdate: false })
})
watch(() => [props.editable, props.disabled, props.readonly], () => {
editor.value?.setEditable(props.editable && !props.disabled && !props.readonly)
})
</script>
<style scoped>
.malio-rich-text :deep(.ProseMirror) {
outline: none;
flex: 1 1 auto;
min-width: 0;
}
.malio-rich-text :deep(.ProseMirror > *:first-child) {
margin-top: 0;
}
.malio-rich-text :deep(.ProseMirror > *:last-child) {
margin-bottom: 0;
}
.malio-rich-text :deep(.ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: rgb(var(--m-muted));
pointer-events: none;
height: 0;
}
.malio-rich-text :deep(h2) {
margin: 0.75rem 0 0.5rem;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.malio-rich-text :deep(h3) {
margin: 0.65rem 0 0.4rem;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 700;
color: rgb(var(--m-text));
}
.malio-rich-text :deep(p) {
margin: 0.45rem 0;
}
.malio-rich-text :deep(ul),
.malio-rich-text :deep(ol) {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.malio-rich-text :deep(ul) {
list-style: disc;
}
.malio-rich-text :deep(ol) {
list-style: decimal;
}
.malio-rich-text :deep(blockquote) {
margin: 0.75rem 0;
border-left: 3px solid rgb(var(--m-border));
padding-left: 0.75rem;
color: rgb(var(--m-muted));
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="relative mt-4 w-full"
class="relative w-full"
>
<textarea
:id="inputId"

View File

@@ -88,11 +88,46 @@ describe('MalioSelect', () => {
})
await wrapper.get('button').trigger('click')
await wrapper.findAll('li')[2].trigger('click')
await wrapper.findAll('li')[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['be'])
})
it('does not render empty option when emptyOptionLabel is empty', async () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options: [
{label: 'AM', value: 'am'},
{label: 'PM', value: 'pm'},
],
},
})
await wrapper.get('button').trigger('click')
const items = wrapper.findAll('li[role="option"]')
expect(items).toHaveLength(2)
expect(items[0].text()).toBe('AM')
expect(items[1].text()).toBe('PM')
})
it('renders empty option when emptyOptionLabel is provided', async () => {
const wrapper = mount(SelectForTest, {
props: {
modelValue: null,
options: [{label: 'AM', value: 'am'}],
emptyOptionLabel: 'Choisir...',
},
})
await wrapper.get('button').trigger('click')
const items = wrapper.findAll('li[role="option"]')
expect(items).toHaveLength(2)
expect(items[0].text()).toBe('Choisir...')
})
it('renders the empty option with muted text style', async () => {
const wrapper = mount(SelectForTest, {
props: {

View File

@@ -208,10 +208,10 @@ const buttonId = `custom-select-btn-${uid}`
const listboxId = `custom-select-listbox-${uid}`
const listRef = ref<HTMLElement | null>(null)
const listHeight = ref(0)
const normalizedOptions = computed<Option[]>(() => [
{label: props.emptyOptionLabel, value: null},
...props.options,
])
const normalizedOptions = computed<Option[]>(() => {
if (!props.emptyOptionLabel) return props.options
return [{label: props.emptyOptionLabel, value: null}, ...props.options]
})
const mergedGroupClass = computed(() =>
twMerge('relative w-full', props.minWidth, props.maxWidth, props.groupClass),
)

View File

@@ -26,6 +26,7 @@ type SelectCheckboxProps = {
displaySelectAll?: boolean
selectAllLabel?: string
disabled?: boolean
groupClass?: string
}
const SelectCheckboxForTest = SelectCheckbox as DefineComponent<SelectCheckboxProps>
@@ -175,4 +176,21 @@ describe('MalioSelectCheckbox', () => {
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false)
})
it('applies minWidth via twMerge so it overrides w-full (parity with MalioSelect)', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options: [], minWidth: 'w-80'},
})
const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('w-80')
expect(root?.className).not.toContain('w-full')
})
it('applies groupClass via twMerge', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: [], options: [], groupClass: 'mt-4'},
})
const root = wrapper.find('button').element.parentElement
expect(root?.className).toContain('mt-4')
})
})

View File

@@ -2,8 +2,7 @@
<div>
<div
ref="root"
class="relative w-full"
:class="[minWidth, maxWidth]"
:class="mergedGroupClass"
>
<button
:id="buttonId"
@@ -26,7 +25,7 @@
? openDirection === 'down'
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
: isOptionSelected
: isOptionSelected
? 'border-black'
: 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
@@ -45,7 +44,7 @@
v-if="label"
class="floating-label pointer-events-none absolute left-3 inline-block origin-left transition-transform duration-150 font-medium"
:class="[
shouldFloatLabel ? 'top-2 z-30' : 'top-1/2 -translate-y-1/2',
isOpen ? 'top-2 z-30' : 'top-2',
hasError
? 'text-m-danger'
: hasSuccess
@@ -206,6 +205,7 @@
<script setup lang="ts">
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'
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
@@ -232,6 +232,7 @@ const props = withDefaults(defineProps<{
displaySelectAll?: boolean
selectAllLabel?: string
disabled?: boolean
groupClass?: string
}>(), {
options: () => [],
emptyOptionLabel: '',
@@ -249,6 +250,7 @@ const props = withDefaults(defineProps<{
displaySelectAll: false,
selectAllLabel: 'Tout sélectionner',
disabled: false,
groupClass: '',
})
const emit = defineEmits<{
@@ -264,6 +266,9 @@ const listboxId = `custom-select-listbox-${uid}`
const listRef = ref<HTMLElement | null>(null)
const listHeight = ref(0)
const normalizedOptions = computed<Option[]>(() => props.options)
const mergedGroupClass = computed(() =>
twMerge('relative w-full', props.minWidth, props.maxWidth, props.groupClass),
)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isOptionSelected = computed(() =>
@@ -281,6 +286,10 @@ const shouldFloatLabel = computed(() =>
const selectionSummary = computed(() =>
`${props.modelValue.length}/${normalizedOptions.value.length}`
)
const allSelected = computed(() =>
normalizedOptions.value.length > 0
&& normalizedOptions.value.every(opt => props.modelValue.includes(opt.value)),
)
const describedBy = computed(() =>
(hasError.value || hasSuccess.value || !!props.hint) ? `${buttonId}-describedby` : undefined,
)
@@ -320,18 +329,22 @@ function open() {
}
const labelTransformStyle = computed(() => {
// label non flottant
if (!shouldFloatLabel.value) {
return undefined
return {}
}
// fermé ou ouverture vers le bas : comportement classique
if (!isOpen.value || openDirection.value === 'down') {
return {
transform: 'translateY(-1.15rem) scale(0.9)',
}
}
// ouverture vers le haut : on remonte en fonction de la hauteur de la liste
const extraOffset = 8 // marge visuelle au-dessus de la liste en px
const total = 4 + listHeight.value + extraOffset
const total = 4 +listHeight.value + extraOffset
// 18 ≈ 1.15rem pour garder la même base que votre flottant actuel
return {
transform: `translateY(-${total}px) scale(0.9)`,
@@ -351,19 +364,6 @@ function toggle() {
open()
}
const allSelected = computed(() =>
normalizedOptions.value.length > 0
&& normalizedOptions.value.every(opt => props.modelValue.includes(opt.value)),
)
function toggleAll() {
if (allSelected.value) {
emit('update:modelValue', [])
} else {
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
}
}
function isChecked(value: string | number) {
return props.modelValue.includes(value)
}
@@ -373,10 +373,17 @@ function toggleOption(value: string | number) {
emit('update:modelValue', props.modelValue.filter(item => item !== value))
return
}
emit('update:modelValue', [...props.modelValue, value])
}
function toggleAll() {
if (allSelected.value) {
emit('update:modelValue', [])
} else {
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
}
}
function onClickOutside(e: MouseEvent) {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()

View File

@@ -0,0 +1,154 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import SiteSelector from './SiteSelector.vue'
type Site = {
id: string
name: string
color: string
}
type SiteSelectorProps = {
sites: Site[]
modelValue?: string
id?: string
groupClass?: string
tileClass?: string
labelClass?: string
}
const SiteSelectorForTest = SiteSelector as DefineComponent<SiteSelectorProps>
const sites: Site[] = [
{id: 'chatellerault', name: 'Châtellerault', color: '#2563eb'},
{id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a'},
{id: 'pommevic', name: 'Pommevic', color: '#dc2626'},
]
function mountComponent(props: SiteSelectorProps) {
return mount(SiteSelectorForTest, {props})
}
describe('MalioSiteSelector', () => {
it('renders one tile per site with the site name', () => {
const wrapper = mountComponent({sites})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles).toHaveLength(3)
expect(tiles[0]!.text()).toBe('Châtellerault')
expect(tiles[1]!.text()).toBe('Saint-Jean')
expect(tiles[2]!.text()).toBe('Pommevic')
})
it('has role="radiogroup" on the wrapper', () => {
const wrapper = mountComponent({sites})
expect(wrapper.find('[role="radiogroup"]').exists()).toBe(true)
})
it('selects the first site by default in uncontrolled mode', () => {
const wrapper = mountComponent({sites})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('aria-checked')).toBe('true')
expect(tiles[1]!.attributes('aria-checked')).toBe('false')
expect(tiles[2]!.attributes('aria-checked')).toBe('false')
})
it('paints all tiles with the selected site color', () => {
const wrapper = mountComponent({sites, modelValue: 'saint-jean'})
const tiles = wrapper.findAll('[role="radio"]')
for (const tile of tiles) {
expect(tile.attributes('style')).toContain('background-color: rgb(22, 163, 74)')
}
})
it('applies opacity 1 on the selected tile and 0.4 on the others', () => {
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('style')).toContain('opacity: 1')
expect(tiles[1]!.attributes('style')).toContain('opacity: 0.4')
expect(tiles[2]!.attributes('style')).toContain('opacity: 0.4')
})
it('updates the shared color when the selection changes', async () => {
const wrapper = mountComponent({sites})
let tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('style')).toContain('background-color: rgb(37, 99, 235)')
await tiles[2]!.trigger('click')
tiles = wrapper.findAll('[role="radio"]')
for (const tile of tiles) {
expect(tile.attributes('style')).toContain('background-color: rgb(220, 38, 38)')
}
})
it('emits update:modelValue with the clicked site id', async () => {
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
const tiles = wrapper.findAll('[role="radio"]')
await tiles[1]!.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['saint-jean'])
})
it('emits change with the full selected site object', async () => {
const wrapper = mountComponent({sites, modelValue: 'chatellerault'})
const tiles = wrapper.findAll('[role="radio"]')
await tiles[2]!.trigger('click')
expect(wrapper.emitted('change')?.[0]).toEqual([
{id: 'pommevic', name: 'Pommevic', color: '#dc2626'},
])
})
it('respects modelValue in controlled mode', () => {
const wrapper = mountComponent({sites, modelValue: 'pommevic'})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('aria-checked')).toBe('false')
expect(tiles[1]!.attributes('aria-checked')).toBe('false')
expect(tiles[2]!.attributes('aria-checked')).toBe('true')
})
it('switches selection on click in uncontrolled mode', async () => {
const wrapper = mountComponent({sites})
const tiles = wrapper.findAll('[role="radio"]')
await tiles[1]!.trigger('click')
expect(tiles[0]!.attributes('aria-checked')).toBe('false')
expect(tiles[1]!.attributes('aria-checked')).toBe('true')
expect(tiles[2]!.attributes('aria-checked')).toBe('false')
})
it('sets roving tabindex (active = 0, others = -1)', () => {
const wrapper = mountComponent({sites, modelValue: 'saint-jean'})
const tiles = wrapper.findAll('[role="radio"]')
expect(tiles[0]!.attributes('tabindex')).toBe('-1')
expect(tiles[1]!.attributes('tabindex')).toBe('0')
expect(tiles[2]!.attributes('tabindex')).toBe('-1')
})
it('merges groupClass, tileClass and labelClass via twMerge', () => {
const wrapper = mountComponent({
sites,
groupClass: 'rounded-none bg-black',
tileClass: 'py-10',
labelClass: 'text-xs',
})
const group = wrapper.find('[role="radiogroup"]')
expect(group.classes()).toContain('rounded-none')
expect(group.classes()).toContain('bg-black')
const tile = wrapper.find('[role="radio"]')
expect(tile.classes()).toContain('py-10')
expect(tile.classes()).not.toContain('py-4')
const label = tile.find('span')
expect(label.classes()).toContain('text-xs')
})
it('uses a custom id when provided', () => {
const wrapper = mountComponent({sites, id: 'my-selector'})
expect(wrapper.find('[role="radiogroup"]').attributes('id')).toBe('my-selector')
})
})

View File

@@ -0,0 +1,104 @@
<template>
<div
v-bind="$attrs"
:id="componentId"
role="radiogroup"
:class="mergedGroupClass"
>
<button
v-for="site in sites"
:key="site.id"
type="button"
role="radio"
:aria-checked="activeId === site.id"
:tabindex="activeId === site.id ? 0 : -1"
:style="{
backgroundColor: activeColor,
opacity: activeId === site.id ? 1 : 0.4,
}"
:class="mergedTileClass"
@click="select(site.id)"
>
<span :class="mergedLabelClass">{{ site.name }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
defineOptions({name: 'MalioSiteSelector', inheritAttrs: false})
type Site = {
id: string
name: string
color: string
}
const props = withDefaults(defineProps<{
sites: Site[]
modelValue?: string
id?: string
groupClass?: string
tileClass?: string
labelClass?: string
}>(), {
modelValue: undefined,
id: '',
groupClass: '',
tileClass: '',
labelClass: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'change', site: Site): void
}>()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-site-selector-${generatedId}`)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(props.sites.length > 0 ? props.sites[0]!.id : '')
const activeId = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
const activeColor = computed(() =>
props.sites.find((s) => s.id === activeId.value)?.color ?? '',
)
const mergedGroupClass = computed(() =>
twMerge(
'flex w-full',
props.groupClass,
),
)
const mergedTileClass = computed(() =>
twMerge(
'flex-1 cursor-pointer px-6 py-4 text-center transition-opacity focus:outline-none',
props.tileClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'text-white font-bold uppercase tracking-wide',
props.labelClass,
),
)
function select(id: string) {
const site = props.sites.find((s) => s.id === id)
if (!site) return
if (!isControlled.value) {
localValue.value = id
}
emit('update:modelValue', id)
emit('change', site)
}
</script>

View File

@@ -0,0 +1,221 @@
<template>
<Story title="Input/RichText">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioInputRichText
v-model="simpleValue"
label="Note"
placeholder="Écrire ici…"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec contenu initial + hint</h2>
<MalioInputRichText
v-model="hintValue"
label="Description"
hint="Mise en forme via la barre d'outils"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioInputRichText
v-model="errorValue"
label="Compte-rendu"
error="Le compte-rendu doit faire au moins 20 caractères"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioInputRichText
v-model="successValue"
label="Compte-rendu"
success="Compte-rendu validé"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioInputRichText
v-model="disabledValue"
label="Note"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Readonly</h2>
<MalioInputRichText
v-model="readonlyValue"
label="Note"
readonly
/>
</div>
<div class="rounded-lg border p-4 lg:col-span-2">
<h2 class="mb-4 text-xl font-bold">Affichage seul (editable=false)</h2>
<MalioInputRichText
:model-value="readonlyValue"
:editable="false"
/>
</div>
<div class="rounded-lg border p-4 lg:col-span-2">
<h2 class="mb-4 text-xl font-bold">Sortie HTML</h2>
<MalioInputRichText
v-model="htmlValue"
label="Article"
output-format="html"
min-height="200px"
/>
</div>
<div class="rounded-lg border p-4 lg:col-span-2">
<h2 class="mb-4 text-xl font-bold">Couleurs &amp; surlignage</h2>
<MalioInputRichText
v-model="colorValue"
label="Note colorée"
output-format="html"
min-height="180px"
hint="Tester les boutons couleur du texte et surlignage (palettes Jira-like)"
/>
</div>
</div>
</Story>
</template>
<docs lang="md">
# MalioInputRichText
Éditeur de texte riche basé sur **TipTap v3** + **StarterKit** + **tiptap-markdown**.
Sortie en **HTML** (par défaut) ou en **markdown**. Aligné sur le thème Malio
(couleurs `m-*`, icônes `mdi:*`, états error / success / hint).
------------------------------------------------------------------------
## Props détaillées
### id
- Type: `string`
- Description: Identifiant HTML.
- Comportement: Généré automatiquement si non fourni (`malio-input-rich-text-…`).
### label
- Type: `string`
- Description: Label affiché au-dessus de l'éditeur.
- Comportement: Change de couleur selon l'état (focus `m-primary`, error `m-danger`, success `m-success`).
### modelValue
- Type: `string | null | undefined`
- Description: Contenu de l'éditeur (markdown ou HTML selon `outputFormat`).
- Comportement: `v-model` ; sync bidirectionnelle.
### placeholder
- Type: `string`
- Défaut: `''`
- Description: Texte affiché quand l'éditeur est vide.
### minHeight
- Type: `string`
- Défaut: `160px`
- Description: Hauteur minimale de la zone d'édition (CSS valid value).
### editable
- Type: `boolean`
- Défaut: `true`
- Description: `false` → mode affichage seul, **toolbar masquée**, contenu rendu en `prose`.
### disabled
- Type: `boolean`
- Défaut: `false`
- Description: Désactive l'édition et la toolbar (opacité réduite).
### readonly
- Type: `boolean`
- Défaut: `false`
- Description: Lecture seule (toolbar visible mais désactivée, pas de saisie).
### hint / error / success
- Type: `string`
- Description: Messages contextuels affichés sous l'éditeur.
- Priorité: `error` > `success` > `hint`.
### outputFormat
- Type: `'markdown' | 'html'`
- Défaut: `'html'`
- Description: Format émis dans `update:modelValue`.
- `html` : utilise `editor.getHTML()`.
- `markdown` : utilise `tiptap-markdown` (`getMarkdown()`).
### groupClass / labelClass / editorClass
- Type: `string`
- Description: Classes Tailwind additionnelles fusionnées via `twMerge` pour override.
------------------------------------------------------------------------
## Toolbar
Boutons (icônes `mdi:*`) :
- Gras, Italique, Barré
- Titre H2, Titre H3
- Liste à puces, Liste numérotée
- Citation
- Code inline, Bloc de code
- Lien (prompt URL ; vide pour retirer)
- Couleur du texte (palette de 8 swatches + reset)
- Surlignage (palette de 8 swatches + reset)
- Annuler / Rétablir
Les palettes couleur/surlignage s'ouvrent en popover sous leur bouton.
Fermeture : clic sur un swatch, clic en dehors, ou touche **Échap**.
> Les couleurs et surlignages ne sont **pas persistés en markdown** (spec Markdown ne couvre pas la couleur). Pour préserver les couleurs au save/reload, utiliser `output-format="html"`.
------------------------------------------------------------------------
## Accessibilité
- Le label est lié à la zone d'édition via `for` / `id`.
- `aria-invalid="true"` sur la zone d'édition en cas d'erreur.
- `aria-describedby` référence le message d'erreur / succès / hint.
- Boutons toolbar : `aria-pressed` reflète l'état actif, `aria-label` pour l'usage screen-reader.
------------------------------------------------------------------------
## Events
### update:modelValue
- Émis à chaque modification du contenu.
- Payload : `string` (markdown ou HTML selon `outputFormat`).
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioInputRichText from '../../components/malio/input/InputRichText.vue'
const simpleValue = ref('')
const hintValue = ref('## Titre\n\nUn paragraphe avec du **gras**, de l\'*italique* et un [lien](https://malio.fr).')
const errorValue = ref('Trop court')
const successValue = ref('Tout est bon de mon côté.')
const disabledValue = ref('Contenu indisponible.')
const readonlyValue = ref('## Compte-rendu\n\n- Point 1\n- Point 2\n\n> Citation importante')
const htmlValue = ref('<p>Contenu <strong>riche</strong>.</p>')
const colorValue = ref('<p>Sélectionner du texte puis utiliser les boutons <span style="color: #bf2600">couleur</span> ou <mark data-color="#fff0b3" style="background-color: #fff0b3">surlignage</mark>.</p>')
</script>

View File

@@ -0,0 +1,116 @@
<template>
<Story title="Site/Selector">
<div class="grid grid-cols-1 gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Trois sites</h2>
<MalioSiteSelector v-model="threeValue" :sites="sites" />
<p class="mt-3 text-sm text-gray-600">Site sélectionné : <code>{{ threeValue }}</code></p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Deux sites</h2>
<MalioSiteSelector v-model="twoValue" :sites="sitesTwo" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Cinq sites</h2>
<MalioSiteSelector v-model="fiveValue" :sites="sitesFive" />
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé</h2>
<MalioSiteSelector :sites="sites" />
</div>
</div>
</Story>
</template>
<docs lang="md">
# MalioSiteSelector
Sélecteur horizontal pour choisir **un site** (usine ou lieu) parmi une liste. Les tuiles occupent une largeur proportionnelle du conteneur. La couleur du site sélectionné est appliquée à toutes les tuiles ; la tuile active est opaque (opacité 1), les autres sont atténuées (opacité 0.4).
---
## Props détaillées
### sites
- Type : `Array<{ id: string; name: string; color: string }>`
- Requis : oui
- Description : Liste des sites à afficher. `color` est un hex (ex : `'#0055ff'`). La couleur du site actuellement sélectionné est appliquée à toutes les tuiles.
### modelValue
- Type : `string`
- Description : `id` du site sélectionné (v-model). Sans `v-model`, le premier site est sélectionné par défaut (mode non contrôlé).
### id
- Type : `string`
- Description : Identifiant HTML du conteneur. Auto-généré si absent.
### groupClass / tileClass / labelClass
- Type : `string`
- Description : Classes Tailwind additionnelles fusionnées via `twMerge` sur, respectivement, le conteneur `<div role="radiogroup">`, chaque tuile et le libellé.
---
## Comportement
- **Toujours un site sélectionné.** Re-cliquer sur la tuile active ne la désélectionne pas.
- **Couleur partagée.** Le `background-color` de toutes les tuiles suit la couleur du site sélectionné. Changer de site met à jour instantanément la couleur de la bande.
- **Pas de gestion d'overflow** : les tuiles se répartissent proportionnellement sur toute la largeur disponible.
---
## Accessibilité
- `role="radiogroup"` sur le conteneur.
- `role="radio"` avec `aria-checked` sur chaque tuile.
- Roving `tabindex` : la tuile active est focusable (`tabindex="0"`), les autres sont exclues du tab order (`tabindex="-1"`).
- Activation par Enter/Space via l'élément `<button>`.
---
## Events
### update:modelValue
- Émis au clic sur une tuile.
- Retourne l'`id` (`string`) du site sélectionné.
### change
- Émis au clic sur une tuile, en complément de `update:modelValue`.
- Retourne l'objet `Site` complet (`{ id, name, color }`) utile pour déclencher des actions (appel API, filtrage) sans avoir à relire le tableau `sites` côté consommateur.
</docs>
<script setup lang="ts">
import { ref } from 'vue'
import MalioSiteSelector from '../../components/malio/site/SiteSelector.vue'
const sites = [
{ id: 'chatellerault', name: 'Châtellerault', color: '#0055ff' },
{ id: 'saint-jean', name: 'Saint-Jean', color: '#16a34a' },
{ id: 'pommevic', name: 'Pommevic', color: '#dc2626' },
]
const sitesTwo = [
{ id: 'nord', name: 'Usine Nord', color: '#7c3aed' },
{ id: 'sud', name: 'Usine Sud', color: '#ea580c' },
]
const sitesFive = [
{ id: 's1', name: 'Site 1', color: '#0ea5e9' },
{ id: 's2', name: 'Site 2', color: '#14b8a6' },
{ id: 's3', name: 'Site 3', color: '#f59e0b' },
{ id: 's4', name: 'Site 4', color: '#ec4899' },
{ id: 's5', name: 'Site 5', color: '#6366f1' },
]
const threeValue = ref('chatellerault')
const twoValue = ref('nord')
const fiveValue = ref('s3')
</script>

View File

@@ -12,6 +12,7 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
'tiptap-markdown': path.resolve(__dirname, 'node_modules/tiptap-markdown/dist/tiptap-markdown.es.js'),
},
},
css: {
@@ -19,6 +20,17 @@ export default defineConfig({
plugins: [tailwindcss(), autoprefixer()],
},
},
ssr: {
noExternal: ['tiptap-markdown', /^@tiptap\//],
},
optimizeDeps: {
include: [
'tiptap-markdown',
'@tiptap/vue-3',
'@tiptap/starter-kit',
'@tiptap/extension-placeholder',
],
},
},
plugins: [HstVue()],
})

760
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"files": [
"app/**",
"nuxt.config.ts",
"tailwind.config.ts",
"README.md",
"COMPONENTS.md"
],
@@ -41,7 +42,15 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"@tiptap/extension-color": "^3.22.5",
"@tiptap/extension-highlight": "^3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-text-style": "^3.22.5",
"@tiptap/pm": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/vue-3": "^3.22.5",
"maska": "^3.2.0",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"tiptap-markdown": "^0.9.0"
}
}

View File

@@ -1,12 +1,16 @@
import type {Config} from 'tailwindcss'
import {fileURLToPath} from 'node:url'
import {dirname, join} from 'node:path'
const dir = dirname(fileURLToPath(import.meta.url))
export default {
content: [
'./app/**/*.{vue,js,ts}',
'./app/**/*.story.{vue,js,ts}',
'./.playground/**/*.{vue,js,ts}',
'./histoire.setup.ts',
'./histoire.config.ts',
join(dir, 'app/**/*.{vue,js,ts}'),
join(dir, 'app/**/*.story.{vue,js,ts}'),
join(dir, '.playground/**/*.{vue,js,ts}'),
join(dir, 'histoire.setup.ts'),
join(dir, 'histoire.config.ts'),
],
theme: {
extend: {