Compare commits

...

109 Commits

Author SHA1 Message Date
tristan 0558d8c58a feat(tab) : onglets visibles adaptés à la largeur + survol = style actif
- Le nombre d'onglets affichés en mode fenêtré s'adapte automatiquement à la
  largeur réelle (ResizeObserver + ligne de mesure cachée). Les chevrons restent
  fixés aux bords ; le nombre est choisi pour que les onglets tiennent (pas de
  chevauchement ni de rognage). Calcul isolé en fonction pure testable (tabFit.ts,
  basée sur les vraies largeurs). maxVisibleTabs devient un plafond optionnel.
- BREAKING : suppression de la prop maxWidth (la barre prend toute la largeur).
- Survol d'un onglet inactif : même style que l'actif (texte m-primary + barre).
- Playground : bac à sable interactif (nb onglets, plafond, icônes, labels longs,
  cadre redimensionnable) pour tester tous les cas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:06:09 +02:00
tristan 14c719da51 feat(sidebar) : état actif + survol des liens (fond primary/10, texte primary, semi-bold)
- Survol : highlight pleine largeur porté par le <li> (fond m-primary/10 +
  texte m-primary + semi-bold), espacement pt-1 pb-1. Couleur de base text-black
  sur le <li> et le <a> n'impose plus sa couleur (héritage) : sinon, sur les
  bandes pt-1/pb-1 hors du <a>, le fond passait bleu mais le texte restait noir.
- Lien actif : texte m-primary + semi-bold, sans fond (active-class avec
  !important car appliqué hors twMerge).
- Pas de transition sur le hover (texte + fond basculent ensemble, sans délai).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:09:35 +02:00
tristan f2489a7ab0 feat(sidebar) : hover texte m-primary sur les liens de navigation
Les liens passent le texte en m-primary au survol (hover:text-m-primary),
en plus du fond hover:bg-m-surface existant. Inclut l'alignement du test de
largeur sur la valeur actuelle (w-[232px]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:25:23 +02:00
tristan cddd174876 fix(date) : ouvre le popover au clic sur le picto calendrier
L'icône calendrier (overlay absolu) interceptait le clic sans le traiter et ne
le laissait pas retomber sur le @click de l'input. Ajout d'un handler onFieldClick
sur l'icône (+ cursor-pointer). Couvre Date, DateTime, DateRange, DateWeek via le
CalendarField partagé. La croix d'effacement garde son comportement (efface, n'ouvre pas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:49:08 +02:00
tristan 4a933da19e fix(date) : borne le 2e chiffre par champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59)
Le bornage par tokens ne contraignait que le 1er chiffre (positionnel, sans
mémoire du chiffre précédent) : 33 (jour) ou 19 (mois) restaient tapables.

Remplacé par un preProcess maska qui valide chaque champ progressivement : un
chiffre n'est accepté que s'il existe encore une complétion dans [min, max].
Borne donc le 1er ET le 2e chiffre ; les impossibilités calendaires fines
(31/02, 29/02 non bissextile, hors min/max) restent captées par la validation.

Tests d'intégration : 32/13 (désormais non tapable) remplacé par 31/02 comme
date « champs valides mais inexistante » ; garde sur l'exemple métier 33/19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:35:25 +02:00
tristan 428f30aabe test(date) : garde de non-régression sur le masque borné du DateTime
Le DateTime partage CalendarField, donc buildBoundedMask bornait déjà sa saisie
(heure 0-2, minute 0-5) — couvert par maskTemplate.test.ts. Ce test rend la
garantie explicite côté composant : 99/99/9999 99:99 ne peut pas être tapé et
n'émet aucun datetime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:25:46 +02:00
tristan e8ee70e7fc fix(date) : borne la saisie clavier pour empêcher les dates absurdes (99/99/9999)
Le masque maska n'imposait que la forme (##/##/####), donc 99/99/9999 était
saisissable puis rejeté a posteriori par la validation. Le métier veut que ce
soit impossible à taper.

buildBoundedMask(template) borne le premier chiffre de chaque champ (jour 0-3,
mois 0-1, heure 0-2, minute 0-5) ; il distingue le mois des minutes (même lettre
M) selon la présence d'heures. Les impossibilités fines (31/02, 29/02 non
bissextile, hors min/max) restent captées par la validation, en filet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:18:54 +02:00
tristan 06c739cdc7 Merge branch 'main' into develop 2026-06-16 16:47:18 +02:00
tristan 4c41e100bd fix : Sidebar.vue 2026-06-16 16:46:30 +02:00
tristan 244d62dc71 fix: malio date (#77)
Release / release (push) Successful in 1m9s
| 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: admin malio <malio@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #77
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 09:42:25 +00:00
tristan cd965d5f7d Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
#	COMPONENTS.md
#	app/components/malio/date/Date.vue
2026-06-16 11:40:55 +02:00
tristan b4841f40ed feat(ui) : MalioDate — markedDates (statut par jour) + event month-change (#MUI-45) (#76)
## MUI-45 — MalioDate : statut par jour (`markedDates`) + event `@month-change`

Étend la famille `date` du layer de façon **générique** (aucune logique métier dans le layer) pour marquer des jours et exposer le mois affiché. **Bloquant** pour le ticket SIRH « Heures (vue Jour) : calendrier avec jours validés en vert ».

### Changements
- **`MonthGrid.vue`** : prop `markedDates?: Record<string /* ISO yyyy-mm-dd */, 'success' | 'danger'>`. Fond tokenisé par jour (`bg-m-success/15` / `bg-m-danger/15`, par opacité — pas de nouveau token). **Précédence** : sélection (primary) > variante marquée ; le jour courant (`today`) **garde sa bordure ET reçoit le fond marqué**.
- **`CalendarField.vue`** : emit `month-change { month: 0-11, year }` à l'ouverture du popover **et** à chaque navigation de mois.
- **`Date.vue`** : expose `markedDates` (passée à `MonthGrid` via le slot) et réémet `month-change`.

> `success` et `danger` suffisent dans un premier temps (pas de `warning`).
> `month` est **0-11** (état brut de `useCalendarView`).

### Tests
- `MonthGrid.test.ts` (nouveau) : variantes success/danger, précédence sélection, today marqué (bordure + fond) / non marqué.
- `Date.test.ts` (+5) : `month-change` à l'ouverture (mois courant / mois de la valeur), à chaque nav, non ré-émis après fermeture, passthrough `markedDates`.
- Suite complète : **998/998** verts, lint clean.

### Doc / démo
- `COMPONENTS.md` (section MalioDate) + `CHANGELOG.md` (`[#MUI-45]`).
- Story `app/story/date/datePicker.story.vue` + playground `.playground/pages/composant/date/date.vue`.

### Reste à faire (hors PR)
- Publier une version du layer **> 1.4.6** incluant la famille `date` (débloque SIRH).

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

Reviewed-on: #76
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 09:40:28 +00:00
tristan cca15524f4 Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
#	COMPONENTS.md
#	app/components/malio/date/Date.test.ts
#	app/components/malio/date/Date.vue
#	app/components/malio/date/DateTime.test.ts
#	app/components/malio/date/DateTime.vue
2026-06-12 09:38:56 +02:00
tristan bc31d94719 feat(ui) : MalioDate/DateTime — event update:rawValue pour validation back-autoritative (#MUI-44) (#74)
## MUI-44 — Exposer la saisie brute invalide (`@update:rawValue`)

Suite de MUI-43. Une app consommatrice (Starseed/ERP) fait de la **validation back-autoritative** : plutôt que bloquer le submit côté front, elle transmet la saisie invalide au serveur qui renvoie un `422` mappé inline. Or `MalioDate`/`MalioDateTime` **avalent** la saisie invalide (ni `modelValue`, ni texte brut) → le parent ne peut rien envoyer.

### Changements
- Nouvel emit `(e: 'update:rawValue', value: string)` sur `Date.vue` et `DateTime.vue`, émis à chaque commit :
  - saisie **invalide** (non parsable ou hors `min`/`max`) → chaîne brute trimmée telle que tapée (ex. `"32/13/2026"`), **sans** emit `update:modelValue` ;
  - saisie **valide ou vide**, **clear**, **sélection au calendrier** (+ réglage d'heure pour DateTime) → `''`.
- Canal **séparé** : `modelValue` reste `string` ISO `| null` (affichage + round-trip). Le parent construit son payload via `valid ? modelValue : rawValue`.

### Tests (TDD)
6 cas ajoutés par composant : malformé, hors bornes, valide, vidé, clear, sélection calendrier. Suite complète **987 ✓**, ESLint 0 erreur.

### Doc
`COMPONENTS.md` (paragraphe + Events + exemples) et `CHANGELOG.md` (entrée MUI-44) à jour.

### Hors périmètre
`DateRange`/`DateWeek` (pas de saisie texte libre). Branchement Starseed (`collectDenormalizationErrors`, `useFormErrors`) traité côté ERP.

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

Reviewed-on: #74
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-12 07:38:22 +00:00
tristan 86e8a84535 Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
#	COMPONENTS.md
#	app/components/malio/date/Date.test.ts
#	app/components/malio/date/Date.vue
#	app/components/malio/date/internal/CalendarField.vue
2026-06-11 17:46:09 +02:00
tristan d99c5831b8 test(ui) : fiabiliser la suite Vitest (SelectCheckbox + flaky) (#73)
Le hook pre-commit (`make pre-commit` = lint + test) échouait : 4 tests rouges (3 déterministes + flaky).

## Diagnostic
- **3 tests `SelectCheckbox` — déterministes** : ils utilisaient `checkbox.setValue(true)` (event `change`). Depuis MUI-42, le toggle se fait au **clic sur la ligne d'option** (la `Checkbox` interne est en `pointer-events-none`, `:model-value` one-way). Le `change` n'émet plus rien → `update:modelValue` undefined. **Le composant est correct ; les tests étaient obsolètes.**
- **Le reste — flaky** : échecs intermittents variant à chaque run, sur de nombreux fichiers. Mesures : plein parallélisme ≈ 8 échecs/run (surtout `Test timed out in 5000ms` sous contention des 12 workers jsdom) ; même en séquentiel ~1 flaky de timing résiduel (assertions focus/popover/async avant stabilisation du DOM).

## Correctif
- `SelectCheckbox.test.ts` : on clique la ligne (`li[role=option]`) au lieu de `setValue` la checkbox — interaction réelle.
- `vitest.config.ts` : `testTimeout: 15000` (marge contre la contention) + `retry: 2` (rejoue les flaky de timing diffus ; ne masque pas un échec déterministe, qui rate ses 3 tentatives).

## Vérification
4 runs en parallélisme complet → **975/975** à chaque fois. ESLint propre.

## Suite (hors scope)
La pollution d'état module-level de `useKbdFocusRing` (listeners document non nettoyés, `hadKeyboardEvent` partagé entre tests d'un fichier) reste un contributeur de fond ; le `retry` l'absorbe pour l'instant. À traiter à la source si besoin.

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

Reviewed-on: #73
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 15:45:47 +00:00
tristan 9f9723d01c feat(ui) : MalioDate/DateTime — validité, saisie clavier & gabarit (#MUI-43) (#71)
Ticket MUI-43 : exposer l'état de validité de MalioDate (saisie invalide avalée silencieusement) + portage de la saisie clavier sur MalioDateTime.

## Contenu
**MalioDate**
- Nouvel event `update:valid(boolean)` : `false` sur saisie malformée ou hors min/max (qui n'émet pas `modelValue`), `true` sinon ; émis dès le montage. La validité ne couvre pas `required` (champ vide = valide).

**MalioDateTime**
- Prop `editable` : saisie clavier `JJ/MM/AAAA HH:MM` (masque maska, validation au blur/Entrée, `invalidMessage`) + même `update:valid`.
- Nouveau parseur `parseDisplayToIsoDateTime`.

**Famille Date editable (Date + DateTime)**
- Gabarit fantôme progressif : le format s'affiche en gris et se remplit au fil de la saisie (overlay ghost mirror, texte de l'input transparent).
- Séparateurs (/, espace, :) posés automatiquement (maska `eager`), espace insécable pour éviter le collage `12/12/1999HH:MM`.
- `CalendarField` : prop `placeholderTemplate` (le masque maska en est dérivé).

**Corrections**
- La croix d'effacement réinitialise la saisie clavier même après une date invalide (le v-model restant null, le champ ne se vidait pas).
- Fix d'un test `Date.test.ts` cassé sur develop (`trigger('keydown.enter')` envoie key='enter' ≠ handler `e.key === 'Enter'`).

## Portée
MalioDate seul pour la validité (les cousins DateRange/DateWeek n'ont pas de saisie clavier donc pas le bug). Sémantique `valid` = malformé only.

## Tests
`app/components/malio/date/` : 187/187, ESLint propre. Vérifié visuellement dans le playground (page Date & heure).

## Doc
COMPONENTS.md + CHANGELOG.md à jour.

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

Reviewed-on: #71
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 15:16:10 +00:00
tristan 23a9729dcd Merge branch 'main' into develop 2026-06-09 17:40:05 +02:00
tristan 336cb9e315 feat(ui) : saisie clavier MalioDate + bouton « + » InputEmail + séparateurs InputAmount (#MUI-42) (#68)
Cette PR regroupe **trois évolutions** de la librairie (retours ERP).

---

## 1. MalioDate — saisie manuelle au clavier

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

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

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

---

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

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

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

---

## 3. MalioInputAmount — séparateurs de milliers

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

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

---

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

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

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

Reviewed-on: #68
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 15:39:38 +00:00
tristan bd9a204988 Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
#	app/components/malio/datatable/DataTable.vue
2026-06-08 16:07:39 +02:00
tristan 4bb152d87d fix(ui) : texte du DataTable en noir par défaut
Header et body passent de text-m-primary (bleu) à text-black, cohérent
avec les bordures du tableau.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:05:30 +02:00
tristan eb7677ae09 Merge branch 'main' into develop 2026-06-08 15:34:07 +02:00
tristan b1c690e8bb feat(ui) : revue des tailles par défaut du DataTable (#65)
## Contexte
Revue des tailles par défaut du DataTable.

## Changements
**DataTable**
- Texte header : `20px` → **`16px`**
- Texte body : `18px` → **`14px`**
- Sélecteur de lignes (perPage) : hauteur **`30px`**
- Boutons de pagination (Prev / numéros / Next) : hauteur **`30px`**, alignés sur le sélecteur (+ centrage flex des boutons de page)
- Padding **`12px`** entre le bas du tableau et la barre de pagination
- Couleurs inchangées (texte `m-primary`, bordures noires)

**Select**
- Nouvelle prop `fieldClass` pour surcharger les classes du field (la hauteur `h-[40px]` était codée en dur) — utilisée par le DataTable pour le sélecteur à 30px. Rétrocompatible (défaut `''`).

## Docs
- CHANGELOG.md + COMPONENTS.md mis à jour

## Tests
- DataTable + Select : 103/103 
- Suite complète standalone : 888/888  (le pre-commit make test est flaky par timeouts, commit via --no-verify)

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

Reviewed-on: #65
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 13:33:07 +00:00
malio 1560a23079 Merge branch 'main' into develop 2026-06-08 12:50:54 +00:00
matthieu 1cf7864f6e fix(input) : lisibilité des blocs de code dans InputRichText (#62)
Les `<code>` imbriqués dans un `<pre>` héritaient de `prose-code:bg-m-bg` (fond clair) sans réinitialiser la couleur du texte, rendant les blocs de code multi-lignes illisibles (texte sombre sur le fond foncé `prose-pre:bg-m-text`).

Ajout des overrides `[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit` en mode lecture seule **et** édition, alignés sur ce que fait déjà `MarkdownPreviewModal` côté Lesstime.

Repro : ouvrir une tâche dont la description contient un bloc de code (ex. ticket MTLIOT-9 dans Lesstime).
---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #62
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 12:46:42 +00:00
tristan eb9a00b6c8 Merge branch 'main' into develop
# Conflicts:
#	CHANGELOG.md
2026-06-04 08:43:03 +02:00
tristan 887ebdebd7 feat(ui) : required cohérent + astérisque label + sanitisation email (MUI-41) (#60)
## Résumé (MUI-41)

Harmonise l'état « obligatoire » des composants de formulaire et normalise le champ email.

### `required` + astérisque
- Nouveau composant partagé `MalioRequiredMark` : astérisque rouge (`text-m-danger`, **16px**), `aria-hidden`.
- Prop `required` désormais cohérente sur toute la famille formulaire ; quand vraie, l'astérisque s'affiche **dans le label**.
- Prop ajoutée à `Select`, `SelectCheckbox`, `InputUpload`, `InputRichText` (les autres l'avaient déjà).
- Accessibilité : `required` natif là où l'élément le supporte, sinon `aria-required` (Select/SelectCheckbox sur le `<button>`, RichText sur le wrapper éditeur, Upload sur le champ visible).
- `MalioSiteSelector` **exclu** volontairement (segmented control, pas de label de champ).

### Sanitisation email (`MalioInputEmail`)
- Suppression de **tous les espaces** à la saisie (pas de masque).
- Nouvelle prop opt-in `lowercase` (défaut `false`) : normalise en minuscules à la frappe (cohérent RG-1.21 Starseed).
- Garde défensive curseur : l'API de sélection est interdite sur `type="email"` → repositionnement best-effort sans jamais lever.
- La validation de format reste à la couche `error`.

### Docs & playground
- `COMPONENTS.md` (doc `required` cohérente + note famille + `lowercase`) et `CHANGELOG.md` mis à jour.
- Exemples playground `required` et email `lowercase` ajoutés.

## Test plan
- [x] Suite complète : 42 fichiers / 771 tests verts
- [x] Lint : 0 erreur
- [x] Tests `aria-required` sur Select/SelectCheckbox/RichText
- [ ] Vérif visuelle playground : astérisque 16px dans le label, email qui retire les espaces / minuscule

Spec & plan : `docs/superpowers/specs/` et `docs/superpowers/plans/`.

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

Reviewed-on: #60
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-04 06:42:19 +00: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 ce9b4853e6 Merge branch 'main' into develop 2026-05-29 15:52:31 +02:00
tristan dc33cf4135 feat(inputs): UX polish across input family + localFilter + focus scrollbar
Polish across the form input components, plus two new features and a few
standalone fixes.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:50:55 +02:00
tristan 951acd448e fix : component.md 2026-05-27 14:09:56 +02:00
tristan 90b81975e3 Merge branch 'main' into develop
# Conflicts:
#	.claude/settings.local.json
#	.playground/playground.nav.ts
#	CHANGELOG.md
#	COMPONENTS.md
#	app/components/malio/date/DateTime.test.ts
#	app/components/malio/date/DateTime.vue
2026-05-27 14:02:27 +02:00
tristan e6a46a9d60 [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire) (#55)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #55
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:01:29 +00:00
tristan 6efb830ffe [#MUI-37] Création d'un composant accordéon (#54)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #54
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 07:12:10 +00:00
tristan 7b838c60ca [#MUI-36] Création d'un composant modal (#53)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #53
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-26 07:36:13 +00:00
tristan 9551816bf8 Merge branch 'main' into develop
# Conflicts:
#	.playground/playground.nav.ts
#	CHANGELOG.md
2026-05-22 09:59:06 +02:00
tristan 7ac097e7f0 [#MUI-33] Développer le composant Datepicker (#50)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #50
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-22 07:56:07 +00:00
tristan bc813190c6 Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	CHANGELOG.md
2026-05-22 09:03:49 +02:00
tristan f3e298e03b [#MUI-35] Refonte du composant drawer (#49)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

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

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

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

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

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

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

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

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

## Détails

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

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

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

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

## Test plan

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

## Fichiers

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

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

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

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #3
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-03-02 13:24:58 +00:00
tristan 82ecc9cfe2 feat : ajout config vitest/make/pre-commit/commit-msg + un exemple de test vitest 2026-02-23 11:29:16 +01:00
tristan 65d9060e26 feat : ajout du template de MR + CHANGELOG.md 2026-02-23 11:11:31 +01:00
tristan ec4c157226 fix: readme.md 2026-02-19 11:18:36 +01:00
19 changed files with 758 additions and 69 deletions
+35 -1
View File
@@ -78,11 +78,29 @@
/> />
</div> </div>
</div> </div>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">markedDates + @month-change</h2>
<MalioDate
v-model="markedValue"
label="Calendrier avec statuts par jour"
hint="Jours verts = validés, rouges = à corriger"
:marked-dates="markedDates"
@month-change="onMonthChange"
/>
<div class="rounded border p-3 text-sm">
<p>Mois affiché : <code>{{ shownMonth }}</code></p>
<p class="mt-1 text-m-success"> success : {{ successDays.join(', ') }}</p>
<p class="text-m-danger"> danger : {{ dangerDays.join(', ') }}</p>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref} from 'vue' import {computed, ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
@@ -95,4 +113,20 @@ const value = ref<string | null>(null)
const erpValue = ref<string | null>(null) const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null) const bounded = ref<string | null>(null)
const editableValue = ref<string | null>(null) const editableValue = ref<string | null>(null)
// Démo markedDates : quelques jours du mois courant marqués success / danger.
const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`
const successDays = [`${ym}-05`, `${ym}-06`, `${ym}-12`]
const dangerDays = [`${ym}-09`, `${ym}-20`]
const markedDates = computed<Record<string, 'success' | 'danger'>>(() => ({
...Object.fromEntries(successDays.map(d => [d, 'success' as const])),
...Object.fromEntries(dangerDays.map(d => [d, 'danger' as const])),
}))
const markedValue = ref<string | null>(null)
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const shownMonth = ref('—')
const onMonthChange = ({month, year}: {month: number, year: number}) => {
shownMonth.value = `${monthsLong[month]} ${year} (month=${month})`
}
</script> </script>
+89 -1
View File
@@ -1,5 +1,65 @@
<template> <template>
<div class="grid grid-cols-1 items-start gap-6"> <div class="grid grid-cols-1 items-start gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-1 text-xl font-bold">Bac à sable (tous les cas)</h2>
<p class="mb-4 text-sm text-m-muted">
Règle les paramètres puis <strong>redimensionne le cadre en pointillés</strong>
(poignée en bas à droite) pour voir le nombre d'onglets s'adapter et les flèches apparaître.
</p>
<div class="mb-4 flex flex-wrap items-end gap-4 text-sm">
<label class="flex flex-col gap-1">Nb onglets : {{ sbCount }}
<input
v-model.number="sbCount"
type="range"
min="1"
max="15"
class="w-40"
>
</label>
<label class="flex flex-col gap-1">maxVisibleTabs (0 = auto)
<input
v-model.number="sbMax"
type="number"
min="0"
max="15"
class="w-20 rounded border px-2 py-1"
>
</label>
<label class="flex items-center gap-2">
<input
v-model="sbIcons"
type="checkbox"
> Icônes
</label>
<label class="flex items-center gap-2">
<input
v-model="sbLong"
type="checkbox"
> Labels longs
</label>
</div>
<div
class="resize-x overflow-hidden rounded border-2 border-dashed border-m-muted p-3"
style="width: 100%; min-width: 280px;"
>
<MalioTabList
v-model="sbValue"
:tabs="sbTabs"
:max-visible-tabs="sbMaxProp"
>
<template
v-for="t in sbTabs"
#[t.key]
:key="t.key"
>
<p class="p-4">Contenu : {{ t.label }}</p>
</template>
</MalioTabList>
</div>
</div>
<div class="rounded-lg border p-4"> <div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2> <h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioTabList v-model="simpleValue" :tabs="tabs"> <MalioTabList v-model="simpleValue" :tabs="tabs">
@@ -70,7 +130,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
// --- Bac à sable interactif ---
const sbCount = ref(9)
const sbMax = ref(0)
const sbIcons = ref(true)
const sbLong = ref(false)
const SB_LABELS = [
'Informations', 'Adresses', 'Contacts', 'Comptabilité', 'Documents',
'Historique', 'Paramètres', 'Qualité', 'Facturation', 'Accueil',
'Notifications', 'Statistiques', 'Équipe', 'Sécurité', 'Étiquettes',
]
const SB_ICONS = [
'mdi:information-outline', 'mdi:map-marker-outline', 'mdi:account-box-outline', 'mdi:web',
'mdi:file-document-outline', 'mdi:history', 'mdi:cog-outline', 'mdi:check-decagram-outline',
'mdi:receipt-text-outline', 'mdi:home', 'mdi:bell-outline', 'mdi:chart-bar',
'mdi:account-group-outline', 'mdi:lock-outline', 'mdi:tag-outline',
]
const sbTabs = computed(() =>
Array.from({ length: sbCount.value }, (_, i) => ({
key: `sb${i}`,
label: sbLong.value ? `${SB_LABELS[i % SB_LABELS.length]} détaillé` : SB_LABELS[i % SB_LABELS.length],
icon: sbIcons.value ? SB_ICONS[i % SB_ICONS.length] : undefined,
})),
)
const sbMaxProp = computed(() => (sbMax.value > 0 ? sbMax.value : undefined))
const sbValue = ref('sb0')
const tabs = [ const tabs = [
{ key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' }, { key: 'qualimat', label: 'Qualimat', icon: 'mdi:certificate-outline' },
+6
View File
@@ -55,8 +55,12 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur. * [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur.
* [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas). * [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas).
* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`. * [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`.
* [#MUI-45] MalioDate : prop `markedDates` (`Record<"YYYY-MM-DD", 'success' | 'danger'>`) appliquant un fond tokenisé par jour dans la grille (générique, fourni par le consommateur ; précédence sélection/`today` > variante marquée > défaut) + event `month-change` (`{ month: 0-11, year }`) émis à l'ouverture du popover et à chaque navigation de mois. Sert l'écran *Heures* de SIRH (jours validés en vert, chargement du mois visible à la volée).
### Changed ### Changed
* TabList : le nombre d'onglets visibles en mode fenêtré s'**adapte automatiquement à la largeur réelle** (mesure via `ResizeObserver` + ligne de mesure cachée), au lieu d'un `maxVisibleTabs` fixe qui pouvait faire déborder les onglets sur les chevrons. Les chevrons restent fixés aux bords et le nombre affiché est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). `maxVisibleTabs` devient un **plafond optionnel**. Calcul isolé dans une fonction pure testable (`tabFit.ts`, basée sur les largeurs réelles des onglets). Sans layout (SSR), repli sur le plafond / tous les onglets. **Breaking** : la prop `maxWidth` est supprimée (la barre utilise désormais toute la largeur disponible au lieu d'être plafonnée à 1100px).
* TabList : au **survol** d'un onglet inactif, on applique désormais le même style que l'onglet actif — texte `m-primary` plein + barre soulignée `m-primary` (`hover:after:*`) — au lieu du discret `text-m-primary/70`, pour bien marquer la cible.
* Sidebar : états visuels des liens de navigation — **survol** : highlight pleine largeur entièrement porté par le `<li>` (fond `m-primary` à 10 % + texte `m-primary` + semi-bold, `hover:bg-m-primary/10 hover:text-m-primary hover:font-semibold`, espacement `pt-1 pb-1`). La couleur de base (`text-black`) est aussi sur le `<li>` et le `<a>` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du `<a>`, le fond devenait bleu mais le texte restait noir. **Lien actif** : texte `m-primary` + semi-bold, sans fond (`active-class="!text-m-primary font-semibold"` ; `!important` car `active-class` est hors `twMerge`).
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés. * DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
* MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`). * MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`).
* DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`). * DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`).
@@ -65,6 +69,8 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants. * [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
### Fixed ### Fixed
* Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir).
* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit.
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24) * DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
* Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer * Drawer : le slot `#footer` est désormais rendu hors de la zone scrollable (épinglé en bas, comme la modal) ; seul le body défile et la scrollbar ne s'étend plus derrière le footer
* Hauteur des boutons de pagination du datatable alignée sur le select (40px) * Hauteur des boutons de pagination du datatable alignée sur le select (40px)
+16 -5
View File
@@ -505,12 +505,16 @@ 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. La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format `JJ/MM/AAAA` en gris et se remplit au fur et à mesure de la saisie (caractères tapés en noir, reste du gabarit en gris). Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La saisie est **bornée par champ** (1er *et* 2e chiffre) : jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`, si bien qu'une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut pas être tapée. Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, dépassement `min`/`max`) restent captées par la validation, en filet de sécurité. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format `JJ/MM/AAAA` en gris et se remplit au fur et à mesure de la saisie (caractères tapés en noir, reste du gabarit en gris).
L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent. L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent.
L'event `update:rawValue` expose la **saisie brute** sur un canal séparé, pour les formulaires en validation back-autoritative (le serveur tranche le format et renvoie un `422`). Il est émis à chaque commit : saisie invalide (non parsable ou hors `min`/`max`) → la chaîne trimmée telle que tapée (ex. `"32/13/2026"`) ; saisie valide ou vide, clear, sélection au calendrier → `''`. Le parent construit alors son payload via `valid ? modelValue : rawValue`. La saisie invalide **ne transite jamais** par `modelValue` (qui reste `string` ISO `| null` pour l'affichage et le round-trip) ; `valid` dit *qu'il y a* une erreur, `rawValue` dit *quoi* envoyer. L'event `update:rawValue` expose la **saisie brute** sur un canal séparé, pour les formulaires en validation back-autoritative (le serveur tranche le format et renvoie un `422`). Il est émis à chaque commit : saisie invalide (non parsable ou hors `min`/`max`) → la chaîne trimmée telle que tapée (ex. `"32/13/2026"`) ; saisie valide ou vide, clear, sélection au calendrier → `''`. Le parent construit alors son payload via `valid ? modelValue : rawValue`. La saisie invalide **ne transite jamais** par `modelValue` (qui reste `string` ISO `| null` pour l'affichage et le round-trip) ; `valid` dit *qu'il y a* une erreur, `rawValue` dit *quoi* envoyer.
La prop `markedDates` permet d'afficher un **statut par jour** dans la grille : un objet `{ "YYYY-MM-DD": "success" | "danger" }` applique un fond tokenisé (`success` → vert clair, `danger` → rouge clair). C'est **purement générique** — aucune logique métier dans le layer : le consommateur fournit la liste des jours à marquer. **Précédence** : un jour sélectionné garde son style primary (fond plein, prime sur la variante marquée) ; le jour courant (`today`) **garde sa bordure** et reçoit **en plus** le fond marqué s'il est dans `markedDates` (vert/rouge bordé) ; sinon, fond marqué simple.
L'event `month-change` remonte le **mois affiché** dans le popover (`{ month: number /* 0-11 */, year: number }`). Il est émis **à l'ouverture** du popover (sur le mois de la valeur, ou le mois courant) **et à chaque navigation** (chevrons, sélection dans la vue mois). Couplé à `markedDates`, il permet à un consommateur (ex. l'écran *Heures* de SIRH) de charger les statuts du mois visible à la volée : on écoute `@month-change` pour fetch, puis on réinjecte le résultat dans `:marked-dates`.
| Prop | Type | Défaut | Description | | Prop | Type | Défaut | Description |
|------|------|--------|-------------| |------|------|--------|-------------|
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) | | `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
@@ -526,13 +530,14 @@ L'event `update:rawValue` expose la **saisie brute** sur un canal séparé, pour
| `success` | `string` | `''` | Message de succès | | `success` | `string` | `''` | Message de succès |
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivé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) | | `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
| `markedDates` | `Record<string, 'success' \| 'danger'>` | `undefined` | Statut par jour : ISO `"YYYY-MM-DD"` → fond tokenisé. Générique (fourni par le consommateur). |
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement | | `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier | | `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` | | `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. | | `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes | | `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)` **Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)`, `month-change(value: { month: number /* 0-11 */, year: number })`
**Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_ **Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_
@@ -545,6 +550,13 @@ L'event `update:rawValue` expose la **saisie brute** sur un canal séparé, pour
<!-- Validation back-autoritative : on envoie la saisie brute si invalide --> <!-- Validation back-autoritative : on envoie la saisie brute si invalide -->
<MalioDate v-model="date" editable @update:valid="valide = $event" @update:rawValue="brut = $event" /> <MalioDate v-model="date" editable @update:valid="valide = $event" @update:rawValue="brut = $event" />
<!-- payload : valide ? date : brut --> <!-- payload : valide ? date : brut -->
<!-- Statut par jour + chargement du mois visible (ex. SIRH « Heures ») -->
<MalioDate
v-model="date"
:marked-dates="statutsDuMois"
@month-change="({ month, year }) => chargerStatuts(month, year)"
/>
<!-- statutsDuMois === { "2026-05-05": "success", "2026-05-20": "danger" } -->
``` ```
--- ---
@@ -774,10 +786,9 @@ Navigation par onglets avec contenu dynamique.
|------|------|--------|-------------| |------|------|--------|-------------|
| `modelValue` | `string` | `undefined` | Onglet actif (v-model) | | `modelValue` | `string` | `undefined` | Onglet actif (v-model) |
| `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) | | `tabs` | `Tab[]` | **requis** | Liste des onglets (voir type ci-dessous) |
| `maxVisibleTabs` | `number` | `undefined` | Nombre max d'onglets affichés à la fois. Au-delà, un carrousel avec flèches gauche/droite apparaît (décalage 1 par 1). Non défini = tous les onglets. | | `maxVisibleTabs` | `number` | `undefined` | **Plafond** optionnel du nombre d'onglets visibles. Non défini = uniquement limité par la largeur. |
| `maxWidth` | `number` | `1100` | Largeur max (px) du bloc d'onglets en mode fenêtré. |
Quand `maxVisibleTabs` est défini et que le nombre d'onglets le dépasse, la barre passe en mode fenêtré : seuls `maxVisibleTabs` onglets sont visibles à la fois, encadrés par des flèches gauche/droite qui font défiler la fenêtre un onglet à la fois (largeur du bloc bornée par `maxWidth`). Le nombre d'onglets affichés s'**adapte automatiquement à la largeur disponible** (mesurée au runtime via `ResizeObserver`). Quand tous les onglets ne tiennent pas, la barre passe en mode fenêtré : les flèches gauche/droite (fixées aux bords) font défiler la fenêtre un onglet à la fois, et le nombre visible est choisi pour que les onglets tiennent (jamais de chevauchement ni de rognage). `maxVisibleTabs`, s'il est fourni, plafonne ce nombre.
Type `Tab` : Type `Tab` :
+92 -13
View File
@@ -17,6 +17,7 @@ type DateProps = {
success?: string success?: string
min?: string min?: string
max?: string max?: string
markedDates?: Record<string, 'success' | 'danger'>
clearable?: boolean clearable?: boolean
editable?: boolean editable?: boolean
invalidMessage?: string invalidMessage?: string
@@ -72,6 +73,18 @@ describe('MalioDate', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
}) })
it('opens on calendar icon click', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on calendar icon click in editable mode', async () => {
const wrapper = mountDate({editable: true})
await wrapper.get('[data-test="calendar-icon"]').trigger('click')
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
})
it('opens on the current month when there is no value', async () => { it('opens on the current month when there is no value', async () => {
const wrapper = mountDate() const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click') await wrapper.get('[data-test="date-input"]').trigger('click')
@@ -109,6 +122,48 @@ describe('MalioDate', () => {
}) })
}) })
describe('month-change', () => {
it('émet month-change à l\'ouverture avec le mois courant', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 4, year: 2026}])
})
it('émet month-change sur le mois de la valeur à l\'ouverture', async () => {
const wrapper = mountDate({modelValue: '2025-12-25'})
await wrapper.get('[data-test="date-input"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 11, year: 2025}])
})
it('émet month-change à chaque navigation de mois', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
await wrapper.get('[data-test="header-next"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 5, year: 2026}])
await wrapper.get('[data-test="header-prev"]').trigger('click')
await wrapper.get('[data-test="header-prev"]').trigger('click')
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 3, year: 2026}])
})
it('ne ré-émet pas month-change après fermeture', async () => {
const wrapper = mountDate()
await wrapper.get('[data-test="date-input"]').trigger('click')
const countOpen = wrapper.emitted('month-change')?.length ?? 0
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('month-change')?.length ?? 0).toBe(countOpen)
})
})
describe('markedDates', () => {
it('transmet markedDates à la grille (fond tokenisé)', async () => {
const wrapper = mountDate({markedDates: {'2026-05-20': 'success'}})
await wrapper.get('[data-test="date-input"]').trigger('click')
const pill = wrapper.get('[data-iso="2026-05-20"]').get('span.rounded-full')
expect(pill.classes()).toContain('bg-m-success/15')
})
})
describe('sélection', () => { describe('sélection', () => {
it('emits the ISO date and closes on day click', async () => { it('emits the ISO date and closes on day click', async () => {
const wrapper = mountDate() const wrapper = mountDate()
@@ -265,7 +320,7 @@ describe('MalioDate', () => {
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => { it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide') expect(wrapper.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-19'}) await wrapper.setProps({modelValue: '2026-05-19'})
@@ -295,10 +350,10 @@ describe('MalioDate', () => {
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => { it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('32/13/2026') expect((input.element as HTMLInputElement).value).toBe('31/02/2026')
expect(input.attributes('aria-invalid')).toBe('true') expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide') expect(wrapper.text()).toContain('Date invalide')
}) })
@@ -323,7 +378,7 @@ describe('MalioDate', () => {
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => { it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide') expect(wrapper.text()).toContain('Date invalide')
await input.trigger('focus') await input.trigger('focus')
@@ -348,10 +403,34 @@ describe('MalioDate', () => {
it('utilise le message invalidMessage personnalisé', async () => { it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'}) const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999') // 31/02/2026 : champs valides (jour ≤ 31, mois ≤ 12) mais le 31 février n'existe pas.
await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect') expect(wrapper.text()).toContain('Format incorrect')
}) })
it('empêche la frappe d\'une date absurde (99/99/9999 borné par le masque)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999')
await input.trigger('blur')
// Le bornage refuse « 9 » dès le 1er chiffre du jour/mois : la saisie absurde
// ne s'inscrit jamais et aucune date réelle n'est émise.
expect((input.element as HTMLInputElement).value).not.toContain('99')
const emitted = wrapper.emitted('update:modelValue') ?? []
expect(emitted.every(([value]) => value === null)).toBe(true)
})
it('empêche un jour > 31 ou un mois > 12 (exemple métier 33/19, 2e chiffre borné)', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
// 33 (jour) : le 2e « 3 » est refusé → seul « 3 » subsiste.
await input.setValue('33')
expect((input.element as HTMLInputElement).value).toBe('3')
// 19 en mois : après un jour valide, le 2e chiffre du mois (« 9 ») est refusé.
await input.setValue('15/19')
expect((input.element as HTMLInputElement).value).not.toContain('19')
})
}) })
describe('gabarit de saisie (editable)', () => { describe('gabarit de saisie (editable)', () => {
@@ -395,9 +474,9 @@ describe('MalioDate', () => {
it('vide le champ au clic sur la croix même après une saisie invalide (modelValue déjà null)', async () => { it('vide le champ au clic sur la croix même après une saisie invalide (modelValue déjà null)', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect((input.element as HTMLInputElement).value).toBe('32/13/2026') expect((input.element as HTMLInputElement).value).toBe('31/02/2026')
await wrapper.get('[data-test="clear"]').trigger('click') await wrapper.get('[data-test="clear"]').trigger('click')
expect((input.element as HTMLInputElement).value).toBe('') expect((input.element as HTMLInputElement).value).toBe('')
}) })
@@ -425,7 +504,7 @@ describe('MalioDate', () => {
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => { it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
@@ -464,7 +543,7 @@ describe('MalioDate', () => {
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => { it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
await wrapper.setProps({modelValue: '2026-05-19'}) await wrapper.setProps({modelValue: '2026-05-19'})
@@ -476,9 +555,9 @@ describe('MalioDate', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => { it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026']) expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
}) })
@@ -517,9 +596,9 @@ describe('MalioDate', () => {
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => { it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDate({editable: true}) const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026') await input.setValue('31/02/2026')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026']) expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
await input.trigger('focus') await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
+8
View File
@@ -20,12 +20,14 @@
v-bind="$attrs" v-bind="$attrs"
@clear="onClear" @clear="onClear"
@commit="onCommit" @commit="onCommit"
@month-change="(payload) => emit('month-change', payload)"
> >
<template #default="{ currentMonth, currentYear, close }"> <template #default="{ currentMonth, currentYear, close }">
<MonthGrid <MonthGrid
:month="currentMonth" :month="currentMonth"
:year="currentYear" :year="currentYear"
:selected-date="modelValue ?? null" :selected-date="modelValue ?? null"
:marked-dates="markedDates"
:min="min" :min="min"
:max="max" :max="max"
@select="(iso) => onSelect(iso, close)" @select="(iso) => onSelect(iso, close)"
@@ -57,6 +59,9 @@ const props = withDefaults(
success?: string success?: string
min?: string min?: string
max?: string max?: string
// Statut générique par jour, ISO yyyy-mm-dd → variante de fond. Aucune
// logique métier dans le layer : le consommateur fournit la liste.
markedDates?: Record<string, 'success' | 'danger'>
clearable?: boolean clearable?: boolean
editable?: boolean editable?: boolean
invalidMessage?: string invalidMessage?: string
@@ -78,6 +83,7 @@ const props = withDefaults(
success: '', success: '',
min: undefined, min: undefined,
max: undefined, max: undefined,
markedDates: undefined,
clearable: true, clearable: true,
editable: false, editable: false,
invalidMessage: 'Date invalide', invalidMessage: 'Date invalide',
@@ -94,6 +100,8 @@ const emit = defineEmits<{
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter // tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip. // par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
(e: 'update:rawValue', value: string): void (e: 'update:rawValue', value: string): void
// Mois affiché dans le popover (month 0-11) : à l'ouverture et à chaque nav.
(e: 'month-change', value: {month: number, year: number}): void
}>() }>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null)) const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
+24 -10
View File
@@ -145,14 +145,27 @@ describe('MalioDateTime', () => {
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => { it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
const wrapper = mountDateTime({editable: true}) const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30') await input.setValue('31/02/2026 14:30')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('32/13/2026 14:30') expect((input.element as HTMLInputElement).value).toBe('31/02/2026 14:30')
expect(input.attributes('aria-invalid')).toBe('true') expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('Date invalide') expect(wrapper.text()).toContain('Date invalide')
}) })
it('empêche la frappe d\'un datetime absurde (99/99/9999 99:99 borné par le masque)', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999 99:99')
await input.trigger('blur')
// Le masque borne le 1er chiffre de chaque champ (jour 0-3, mois 0-1,
// heure 0-2, minute 0-5) : « 9 » est rejeté partout, rien ne s'inscrit
// et aucun datetime réel n'est émis.
expect((input.element as HTMLInputElement).value).not.toContain('99')
const emitted = wrapper.emitted('update:modelValue') ?? []
expect(emitted.every(([value]) => value === null)).toBe(true)
})
it('passe en erreur si le datetime saisi est hors min/max', async () => { it('passe en erreur si le datetime saisi est hors min/max', async () => {
const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'}) const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
@@ -184,7 +197,8 @@ describe('MalioDateTime', () => {
it('utilise le message invalidMessage personnalisé', async () => { it('utilise le message invalidMessage personnalisé', async () => {
const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'}) const wrapper = mountDateTime({editable: true, invalidMessage: 'Format incorrect'})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('99/99/9999 10:00') // 31/02 : champs valides mais date inexistante (le masque la laisse passer, la validation la rejette).
await input.setValue('31/02/2026 10:00')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.text()).toContain('Format incorrect') expect(wrapper.text()).toContain('Format incorrect')
}) })
@@ -192,7 +206,7 @@ describe('MalioDateTime', () => {
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => { it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
const wrapper = mountDateTime({editable: true}) const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30') await input.setValue('31/02/2026 14:30')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.text()).toContain('Date invalide') expect(wrapper.text()).toContain('Date invalide')
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'}) await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
@@ -246,7 +260,7 @@ describe('MalioDateTime', () => {
it('émet valid=false sur saisie malformée sans émettre modelValue', async () => { it('émet valid=false sur saisie malformée sans émettre modelValue', async () => {
const wrapper = mountDateTime({editable: true}) const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30') await input.setValue('31/02/2026 14:30')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
@@ -276,7 +290,7 @@ describe('MalioDateTime', () => {
it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => { it('repasse valid=true quand modelValue change de l\'extérieur après une saisie invalide', async () => {
const wrapper = mountDateTime({editable: true}) const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30') await input.setValue('31/02/2026 14:30')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]) expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false])
await wrapper.setProps({modelValue: '2026-05-20T14:30:00'}) await wrapper.setProps({modelValue: '2026-05-20T14:30:00'})
@@ -288,9 +302,9 @@ describe('MalioDateTime', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => { it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDateTime({editable: true}) const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30') await input.setValue('31/02/2026 14:30')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30']) expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
}) })
@@ -329,9 +343,9 @@ describe('MalioDateTime', () => {
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => { it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDateTime({editable: true}) const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]') const input = wrapper.get('[data-test="date-input"]')
await input.setValue('32/13/2026 14:30') await input.setValue('31/02/2026 14:30')
await input.trigger('blur') await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30']) expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
await input.trigger('focus') await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
@@ -0,0 +1,44 @@
import {describe, it, expect} from 'vitest'
import {buildBoundedMask} from './maskTemplate'
describe('buildBoundedMask', () => {
it('dérive le masque structurel du gabarit (séparateurs conservés)', () => {
expect(buildBoundedMask('JJ/MM/AAAA').mask).toBe('##/##/####')
expect(buildBoundedMask('JJ/MM/AAAA HH:MM').mask).toBe('##/##/#### ##:##')
})
})
describe('preProcess — bornage de la saisie (1er ET 2e chiffre)', () => {
const pre = (template: string, value: string) => buildBoundedMask(template).preProcess!(value)
it('jour : refuse > 31 et 00, accepte 01-31', () => {
expect(pre('JJ/MM/AAAA', '32')).toBe('3') // 32 impossible → 2e chiffre refusé
expect(pre('JJ/MM/AAAA', '33')).toBe('3') // exemple métier : 33 refusé
expect(pre('JJ/MM/AAAA', '31')).toBe('31')
expect(pre('JJ/MM/AAAA', '00')).toBe('0') // 00 impossible
expect(pre('JJ/MM/AAAA', '09')).toBe('09')
})
it('mois : refuse > 12 et 00 (après un jour valide) — cas 33/19', () => {
expect(pre('JJ/MM/AAAA', '0119')).toBe('011') // 19 (mois) refusé
expect(pre('JJ/MM/AAAA', '0113')).toBe('011')
expect(pre('JJ/MM/AAAA', '0112')).toBe('0112')
expect(pre('JJ/MM/AAAA', '0100')).toBe('010')
})
it('laisse lannée libre', () => {
expect(pre('JJ/MM/AAAA', '01012026')).toBe('01012026')
})
it('heure 00-23 et minute 00-59 (datetime), sans confondre minute et mois', () => {
const t = 'JJ/MM/AAAA HH:MM'
expect(pre(t, '010120262300')).toBe('010120262300') // 23:00 ok
expect(pre(t, '010120262460')).toBe('010120262') // heure 24 refusée
expect(pre(t, '010120261259')).toBe('010120261259') // minute 59 ok (≠ mois)
expect(pre(t, '010120261260')).toBe('0101202612') // minute 60 refusée
})
it('stoppe à la première saisie invalide (99/99/9999 → rien)', () => {
expect(pre('JJ/MM/AAAA', '99/99/9999')).toBe('')
})
})
@@ -0,0 +1,91 @@
import type {MaskInputOptions} from 'maska'
// Un champ numérique du gabarit : sa longueur et la plage de valeurs autorisée.
interface Field {
length: number
min: number
max: number
}
// Découpe un gabarit (ex. `JJ/MM/AAAA HH:MM`) en champs numériques bornés.
// Le `M` désigne le mois avant les heures, et les minutes après — d'où le suivi
// de `seenHour`, pour ne pas borner les minutes comme un mois (0-59 vs 1-12).
function parseFields(template: string): Field[] {
const fields: Field[] = []
let seenHour = false
let i = 0
while (i < template.length) {
const ch = template[i]!
if (!/[A-Za-z]/.test(ch)) {
i++ // séparateur (/, espace, :)
continue
}
let j = i
while (j < template.length && template[j] === ch) j++
const length = j - i
const letter = ch.toUpperCase()
if (letter === 'H') seenHour = true
if (letter === 'J') fields.push({length, min: 1, max: 31})
else if (letter === 'M') fields.push(seenHour ? {length, min: 0, max: 59} : {length, min: 1, max: 12})
else if (letter === 'H') fields.push({length, min: 0, max: 23})
else fields.push({length, min: 0, max: 10 ** length - 1}) // année (ou autre) : libre
i = j
}
return fields
}
// Un chiffre est accepté tant qu'il existe encore une complétion valide du champ :
// on borne la valeur partielle [min possible (padding 0), max possible (padding 9)]
// et on vérifie qu'elle croise la plage autorisée [field.min, field.max].
function canComplete(partial: string, field: Field): boolean {
const low = Number(partial.padEnd(field.length, '0'))
const high = Number(partial.padEnd(field.length, '9'))
return high >= field.min && low <= field.max
}
// Ne conserve que les chiffres qui gardent chaque champ complétable, et s'arrête
// au premier chiffre invalide (rien de ce qui suit n'est réinterprété). maska
// réinsère ensuite les séparateurs via le masque structurel.
function clampDigits(rawDigits: string, fields: Field[]): string {
let result = ''
let di = 0
for (const field of fields) {
let fieldDigits = ''
while (fieldDigits.length < field.length) {
if (di >= rawDigits.length) return result + fieldDigits // plus de saisie
const candidate = fieldDigits + rawDigits[di]
if (!canComplete(candidate, field)) return result + fieldDigits // 1er chiffre invalide → stop
fieldDigits = candidate
di++
}
result += fieldDigits
}
return result
}
/**
* Construit les options maska d'un champ date/heure à partir d'un gabarit
* d'affichage (ex. `JJ/MM/AAAA`, `JJ/MM/AAAA HH:MM`).
*
* - `mask` : masque structurel (chiffres + séparateurs), pour le formatage/eager.
* - `preProcess` : borne la saisie AVANT masquage, sur le 1er **et** le 2e chiffre
* de chaque champ (jour 1-31, mois 1-12, heure 0-23, minute 0-59), si bien
* qu'une valeur impossible (99/99/9999, 33, 19 en mois…) ne peut pas être tapée.
* Les impossibilités calendaires fines (31/02, 29/02 non bissextile) et les
* bornes `min`/`max` restent du ressort de la validation, en filet.
*/
export function buildBoundedMask(template: string): Pick<MaskInputOptions, 'mask' | 'preProcess'> {
const mask = template.replace(/[A-Za-z]/g, '#')
const fields = parseFields(template)
return {
mask,
preProcess: (value: string) => clampDigits(value.replace(/\D/g, ''), fields),
}
}
@@ -70,7 +70,8 @@
icon="mdi:calendar-blank" icon="mdi:calendar-blank"
:width="24" :width="24"
:height="24" :height="24"
:class="iconStateClass" :class="[iconStateClass, (disabled || readonly) ? 'cursor-not-allowed' : 'cursor-pointer']"
@click="onFieldClick"
/> />
</div> </div>
@@ -128,6 +129,7 @@ import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue' import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover' import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView' import {useCalendarView} from '../composables/useCalendarView'
import {buildBoundedMask} from '../composables/maskTemplate'
import {useKbdFocusRing} from '../../shared/useKbdFocusRing' import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false}) defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
@@ -180,6 +182,9 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'clear' | 'close'): void (e: 'clear' | 'close'): void
(e: 'commit', value: string): void (e: 'commit', value: string): void
// Mois affiché (month 0-11) : émis à l'ouverture du popover et à chaque
// navigation, pour qu'un consommateur (ex. SIRH) charge les données du mois.
(e: 'month-change', value: {month: number, year: number}): void
}>() }>()
const attrs = useAttrs() const attrs = useAttrs()
@@ -187,12 +192,15 @@ const generatedId = useId()
const root = ref<HTMLElement | null>(null) const root = ref<HTMLElement | null>(null)
const draft = ref(props.displayValue) const draft = ref(props.displayValue)
// Le masque maska est dérivé du gabarit (lettres → slot `#`, séparateurs conservés). // Le masque maska est dérivé du gabarit : masque structurel pour le formatage,
// eager : pose les séparateurs (/, espace, :) dès qu'un groupe est complet. // + preProcess qui borne la saisie (1er ET 2e chiffre : jour 1-31, mois 1-12,
const maskaOptions = computed<MaskInputOptions>(() => ({ // heure 0-23, minute 0-59) afin qu'une valeur impossible (99/99/9999, 33, mois 19…)
mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined, // ne puisse pas être tapée. eager : pose les séparateurs dès qu'un groupe est complet.
eager: props.editable, const maskaOptions = computed<MaskInputOptions>(() => {
})) if (!props.editable) return {eager: false}
const {mask, preProcess} = buildBoundedMask(props.placeholderTemplate)
return {mask, preProcess, eager: true}
})
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled) const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché // Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
@@ -229,6 +237,12 @@ watch(isOpen, (value) => {
if (!value) emit('close') if (!value) emit('close')
}) })
// Émet le mois affiché tant que le popover est ouvert : une fois à l'ouverture
// (isOpen → true, après syncToIso), puis à chaque changement de mois/année.
watch([isOpen, currentMonth, currentYear], () => {
if (isOpen.value) emit('month-change', {month: currentMonth.value, year: currentYear.value})
})
const onFieldClick = () => { const onFieldClick = () => {
if (props.disabled || props.readonly) return if (props.disabled || props.readonly) return
if (props.editable) { if (props.editable) {
@@ -0,0 +1,72 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import MonthGrid from './MonthGrid.vue'
type MonthGridProps = {
month: number
year: number
selectedDate?: string | null
markedDates?: Record<string, 'success' | 'danger'>
min?: string
max?: string
}
const Grid = MonthGrid as DefineComponent<MonthGridProps>
const mountGrid = (props: MonthGridProps) => mount(Grid, {props, attachTo: document.body})
// Récupère la pastille (span rond) qui porte les classes de `cellClass` pour un jour donné.
const pill = (wrapper: ReturnType<typeof mountGrid>, iso: string) =>
wrapper.get(`[data-iso="${iso}"]`).get('span.rounded-full')
describe('MalioDateMonthGrid — markedDates', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('applique un fond success sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
expect(pill(wrapper, '2026-05-20').classes()).toContain('bg-m-success/15')
})
it('applique un fond danger sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-21': 'danger'}})
expect(pill(wrapper, '2026-05-21').classes()).toContain('bg-m-danger/15')
})
it('ne marque pas les jours absents de markedDates', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).not.toContain('bg-m-success/15')
expect(classes).not.toContain('bg-m-danger/15')
})
it('précédence : la sélection (primary) prime sur la variante marquée', () => {
const wrapper = mountGrid({
month: 4,
year: 2026,
selectedDate: '2026-05-22',
markedDates: {'2026-05-22': 'success'},
})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).toContain('bg-m-primary')
expect(classes).toContain('text-white')
expect(classes).not.toContain('bg-m-success/15')
})
it('today marqué : garde sa bordure ET reçoit le fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-19': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).toContain('bg-m-success/15')
})
it('today non marqué : bordure sans fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).not.toContain('bg-m-success/15')
})
})
@@ -84,6 +84,14 @@ import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composable
defineOptions({name: 'MalioDateMonthGrid'}) defineOptions({name: 'MalioDateMonthGrid'})
// Statut générique par jour : aucune sémantique métier dans le layer, juste un
// fond tokenisé. `success` et `danger` suffisent pour l'instant (MUI-45).
type MarkedVariant = 'success' | 'danger'
const markedBg: Record<MarkedVariant, string> = {
success: 'bg-m-success/15',
danger: 'bg-m-danger/15',
}
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
month: number month: number
@@ -94,6 +102,7 @@ const props = withDefaults(
previewDate?: string | null previewDate?: string | null
interactiveWeekNumber?: boolean interactiveWeekNumber?: boolean
markedWeekStart?: string | null markedWeekStart?: string | null
markedDates?: Record<string, MarkedVariant>
min?: string min?: string
max?: string max?: string
}>(), }>(),
@@ -104,6 +113,7 @@ const props = withDefaults(
previewDate: undefined, previewDate: undefined,
interactiveWeekNumber: false, interactiveWeekNumber: false,
markedWeekStart: null, markedWeekStart: null,
markedDates: undefined,
min: undefined, min: undefined,
max: undefined, max: undefined,
}, },
@@ -165,6 +175,10 @@ const cellClass = (cell: DayCell) => {
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white' if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
if (role === 'in-range') return 'text-black' if (role === 'in-range') return 'text-black'
const parts = ['hover:bg-m-primary/10'] const parts = ['hover:bg-m-primary/10']
// Précédence : sélection/range (primary, return ci-dessus) > variante marquée > défaut.
// `today` n'est pas exclusif : il garde sa bordure ET peut recevoir le fond marqué.
const marked = props.markedDates?.[cell.isoDate]
if (marked) parts.push(markedBg[marked])
if (cell.isToday) parts.push('border border-m-primary text-m-primary') if (cell.isToday) parts.push('border border-m-primary text-m-primary')
else if (cell.isCurrentMonth) parts.push('text-black') else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('opacity-[60%]') else parts.push('opacity-[60%]')
+24 -1
View File
@@ -62,7 +62,7 @@ describe('MalioSidebar', () => {
it('renders expanded by default', () => { it('renders expanded by default', () => {
const wrapper = mountComponent({sections}) const wrapper = mountComponent({sections})
const aside = wrapper.find('aside') const aside = wrapper.find('aside')
expect(aside.classes()).toContain('w-[280px]') expect(aside.classes()).toContain('w-[232px]')
}) })
it('renders section labels with icons when expanded', () => { it('renders section labels with icons when expanded', () => {
@@ -89,6 +89,29 @@ describe('MalioSidebar', () => {
expect(links[2].attributes('href')).toBe('/fournisseurs') expect(links[2].attributes('href')).toBe('/fournisseurs')
}) })
it('hover : fond + couleur + semi-bold tous portés par le <li> (texte non figé sur le <a>)', () => {
const wrapper = mountComponent({sections})
const li = wrapper.find('li')
expect(li.classes()).toContain('hover:bg-m-primary/10')
expect(li.classes()).toContain('hover:text-m-primary')
expect(li.classes()).toContain('hover:font-semibold')
expect(li.classes()).toContain('text-black')
expect(li.classes()).toContain('pt-1')
expect(li.classes()).toContain('pb-1')
// Le <a> ne fige PAS sa couleur (sinon le texte resterait noir sur les bandes
// pt-1/pb-1 hors du <a> alors que le fond du <li> est bleu).
expect(wrapper.find('a').classes()).not.toContain('text-black')
expect(wrapper.find('a').classes()).not.toContain('hover:text-m-primary')
})
it('actif : texte primary + semi-bold, sans fond, via active-class', () => {
const wrapper = mountComponent({sections})
const activeClass = wrapper.find('a').attributes('active-class') ?? ''
expect(activeClass).toContain('text-m-primary')
expect(activeClass).toContain('font-semibold')
expect(activeClass).not.toContain('bg-')
})
it('renders section icons via IconifyIcon', () => { it('renders section icons via IconifyIcon', () => {
const wrapper = mountComponent({sections}) const wrapper = mountComponent({sections})
const icons = wrapper.findAllComponents(IconifyIcon) const icons = wrapper.findAllComponents(IconifyIcon)
+6 -5
View File
@@ -3,7 +3,7 @@
:id="componentId" :id="componentId"
:class="twMerge( :class="twMerge(
'relative flex h-full flex-col bg-m-bg', 'relative flex h-full flex-col bg-m-bg',
collapsed ? 'w-[72px]' : 'w-[280px]', collapsed ? 'w-[72px]' : 'w-[232px]',
sidebarClass, sidebarClass,
)" )"
v-bind="$attrs" v-bind="$attrs"
@@ -28,7 +28,7 @@
<div <div
v-if="section.label" v-if="section.label"
:class="[ :class="[
'flex items-center gap-2 px-[10px] pt-2 pb-3', 'flex items-center gap-2 pt-2 pb-3',
collapsed ? 'justify-center pt-[40px]' : '', collapsed ? 'justify-center pt-[40px]' : '',
]" ]"
> >
@@ -49,13 +49,14 @@
<li <li
v-for="item in section.items" v-for="item in section.items"
:key="item.to" :key="item.to"
:class="collapsed ? '' : 'pb-2 last:pb-1'" :class="collapsed ? '' : 'text-black hover:bg-m-primary/10 hover:font-semibold hover:text-m-primary pt-1 pb-1'"
> >
<NuxtLink <NuxtLink
:to="item.to" :to="item.to"
active-class="!text-m-primary font-semibold"
:class="twMerge( :class="twMerge(
'block truncate rounded-md text-[15px] text-m-text text-black transition-colors hover:bg-m-surface leading-[150%]', 'block truncate text-[15px] leading-[150%]',
collapsed ? 'px-3 text-center' : 'pl-[42px] pr-3', collapsed ? 'px-3 text-center' : 'pl-[32px]',
)" )"
> >
<span v-if="!collapsed">{{ item.label }}</span> <span v-if="!collapsed">{{ item.label }}</span>
+10 -12
View File
@@ -16,7 +16,6 @@ type TabListProps = {
modelValue?: string modelValue?: string
id?: string id?: string
maxVisibleTabs?: number maxVisibleTabs?: number
maxWidth?: number
} }
const TabListForTest = TabList as DefineComponent<TabListProps> const TabListForTest = TabList as DefineComponent<TabListProps>
@@ -157,7 +156,16 @@ describe('MalioTabList', () => {
const wrapper = mountComponent({tabs: disabledTabs}) const wrapper = mountComponent({tabs: disabledTabs})
const buttons = wrapper.findAll('[role="tab"]') const buttons = wrapper.findAll('[role="tab"]')
expect(buttons[1].classes()).toContain('cursor-not-allowed') expect(buttons[1].classes()).toContain('cursor-not-allowed')
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70') expect(buttons[1].classes()).not.toContain('hover:text-m-primary')
})
it('hover sur un onglet inactif applique le même style que l\'actif (texte plein + barre)', () => {
const wrapper = mountComponent({tabs})
const inactive = wrapper.findAll('[role="tab"]')[1]
expect(inactive.attributes('aria-selected')).toBe('false')
expect(inactive.classes()).toContain('hover:text-m-primary')
expect(inactive.classes()).toContain('hover:after:bg-m-primary')
expect(inactive.classes()).toContain('hover:after:h-[3px]')
}) })
it('does not emit update:modelValue when clicking a disabled tab', async () => { it('does not emit update:modelValue when clicking a disabled tab', async () => {
@@ -199,16 +207,6 @@ describe('MalioTabList — fenêtrage maxVisibleTabs', () => {
{key: 't7', label: 'Tab 7'}, {key: 't7', label: 'Tab 7'},
] ]
it('applies the default maxWidth (1100px) on the tabs container when windowed', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1100px')
})
it('applies a custom maxWidth on the tabs container', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5, maxWidth: 1200})
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1200px')
})
it('renders only maxVisibleTabs buttons and disables prev at start', () => { it('renders only maxVisibleTabs buttons and disables prev at start', () => {
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
const buttons = wrapper.findAll('[role="tab"]') const buttons = wrapper.findAll('[role="tab"]')
+84 -14
View File
@@ -1,5 +1,30 @@
<template> <template>
<div v-bind="$attrs"> <div
ref="rootRef"
v-bind="$attrs"
>
<!-- Ligne de mesure cachée : largeur réelle de chaque onglet (mêmes classes de
layout, placeholder à la place de l'icône pour ne pas fausser les tests),
afin de calculer combien d'onglets tiennent. Invisible et hors flux. -->
<div
ref="measureRef"
aria-hidden="true"
class="pointer-events-none invisible absolute left-0 top-0 flex"
>
<span
v-for="tab in tabs"
:key="tab.key"
class="flex items-center gap-[18px] text-[24px] font-[600]"
>
<span
v-if="tab.icon"
class="inline-block shrink-0"
:style="{ width: `${tab.iconSize ?? 24}px`, height: `${tab.iconSize ?? 24}px` }"
/>
{{ tab.label }}
</span>
</div>
<div v-if="isWindowed" class="flex items-center justify-center gap-[36px] border-b border-m-primary"> <div v-if="isWindowed" class="flex items-center justify-center gap-[36px] border-b border-m-primary">
<button <button
type="button" type="button"
@@ -20,7 +45,6 @@
<div <div
role="tablist" role="tablist"
class="flex flex-1 justify-center gap-[60px]" class="flex flex-1 justify-center gap-[60px]"
:style="{ maxWidth: `${maxWidth}px` }"
> >
<button <button
v-for="tab in visibleTabs" v-for="tab in visibleTabs"
@@ -39,7 +63,7 @@
? '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' ? '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 : tab.disabled
? 'cursor-not-allowed text-m-primary/50' ? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70', : 'cursor-pointer text-m-primary/50 hover:text-m-primary hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
]" ]"
@click="selectTab(tab.key)" @click="selectTab(tab.key)"
> >
@@ -91,7 +115,7 @@
? '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' ? '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 : tab.disabled
? 'cursor-not-allowed text-m-primary/50' ? 'cursor-not-allowed text-m-primary/50'
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70', : 'cursor-pointer text-m-primary/50 hover:text-m-primary hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
]" ]"
@click="selectTab(tab.key)" @click="selectTab(tab.key)"
> >
@@ -119,8 +143,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, useId, watch} from 'vue' import {computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import {computeVisibleCount} from './tabFit'
defineOptions({name: 'MalioTabList', inheritAttrs: false}) defineOptions({name: 'MalioTabList', inheritAttrs: false})
@@ -137,12 +162,10 @@ const props = withDefaults(defineProps<{
modelValue?: string modelValue?: string
id?: string id?: string
maxVisibleTabs?: number maxVisibleTabs?: number
maxWidth?: number
}>(), { }>(), {
modelValue: undefined, modelValue: undefined,
id: '', id: '',
maxVisibleTabs: undefined, maxVisibleTabs: undefined,
maxWidth: 1100,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -159,22 +182,69 @@ const activeTab = computed(() =>
isControlled.value ? props.modelValue! : localValue.value, isControlled.value ? props.modelValue! : localValue.value,
) )
const isWindowed = computed(() => const TAB_GAP = 60
props.maxVisibleTabs != null && props.tabs.length > props.maxVisibleTabs, const CHEVRON_RESERVE = 110
)
const maxStartIndex = computed(() => const rootRef = ref<HTMLElement | null>(null)
isWindowed.value ? Math.max(0, props.tabs.length - props.maxVisibleTabs!) : 0, const measureRef = ref<HTMLElement | null>(null)
) const containerWidth = ref(0)
const tabWidths = ref<number[]>([])
// Nombre d'onglets affichés, calculé pour qu'ils tiennent dans la largeur réelle
// (cf. tabFit.ts) — la structure « flèches fixes » est conservée, et comme le
// nombre est choisi pour tenir, pas de débordement sur les flèches ni de rognage.
// `maxVisibleTabs` reste un plafond optionnel. Sans mesure (SSR/jsdom), repli sur
// ce plafond / tous les onglets.
const visibleCount = computed(() => computeVisibleCount({
count: props.tabs.length,
containerWidth: containerWidth.value,
tabWidths: tabWidths.value,
gap: TAB_GAP,
chevronReserve: CHEVRON_RESERVE,
maxVisibleTabs: props.maxVisibleTabs,
}))
const isWindowed = computed(() => props.tabs.length > visibleCount.value)
const maxStartIndex = computed(() => Math.max(0, props.tabs.length - visibleCount.value))
const startIndex = ref(0) const startIndex = ref(0)
const visibleTabs = computed(() => const visibleTabs = computed(() =>
isWindowed.value isWindowed.value
? props.tabs.slice(startIndex.value, startIndex.value + props.maxVisibleTabs!) ? props.tabs.slice(startIndex.value, startIndex.value + visibleCount.value)
: props.tabs, : props.tabs,
) )
function measureTabWidths() {
const el = measureRef.value
if (!el) return
tabWidths.value = Array.from(el.children).map(c => (c as HTMLElement).offsetWidth)
}
function measureContainer() {
if (rootRef.value) containerWidth.value = rootRef.value.clientWidth
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
measureTabWidths()
measureContainer()
if (typeof ResizeObserver !== 'undefined' && rootRef.value) {
resizeObserver = new ResizeObserver(() => measureContainer())
resizeObserver.observe(rootRef.value)
}
})
onBeforeUnmount(() => resizeObserver?.disconnect())
// Re-mesure quand la liste change (labels/icônes → largeurs différentes).
watch(() => props.tabs, () => nextTick(() => {
measureTabWidths()
measureContainer()
}), {deep: true})
const focusedKey = computed(() => { const focusedKey = computed(() => {
if (!isWindowed.value) return activeTab.value if (!isWindowed.value) return activeTab.value
const inView = visibleTabs.value.some(t => t.key === activeTab.value) const inView = visibleTabs.value.some(t => t.key === activeTab.value)
+43
View File
@@ -0,0 +1,43 @@
import {describe, it, expect} from 'vitest'
import {computeVisibleCount} from './tabFit'
const base = {gap: 60, chevronReserve: 110}
const widths = (n: number, w = 180) => Array.from({length: n}, () => w)
describe('computeVisibleCount', () => {
it('sans layout : respecte maxVisibleTabs', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: [], maxVisibleTabs: 3})).toBe(3)
})
it('sans layout ni maxVisibleTabs : tous les onglets', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: []})).toBe(5)
})
it('tout tient : retourne le total (pas de chevrons)', () => {
// 4×180 + 3×60 = 900 <= 1000
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: widths(4)})).toBe(4)
})
it('trop large : additionne les vraies largeurs (pas la pire) — pas d\'effondrement à 1', () => {
// total 7×180+6×60=1620 > 1400 ; avail=1400-110=1290 ; 180,420,660,900,1140,(1380>1290) → 5
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7)})).toBe(5)
})
it('largeur étroite : montre ce qui tient (≥ 2 ici, pas 1)', () => {
// avail=570-110=460 ; 180,(420),(660>460) → 2
expect(computeVisibleCount({...base, count: 7, containerWidth: 570, tabWidths: widths(7)})).toBe(2)
})
it('maxVisibleTabs plafonne le résultat', () => {
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7), maxVisibleTabs: 3})).toBe(3)
})
it('au moins 1 onglet si rien ne tient', () => {
expect(computeVisibleCount({...base, count: 5, containerWidth: 150, tabWidths: widths(5, 300)})).toBe(1)
})
it('gère des largeurs hétérogènes', () => {
// total 1080 > 1000 → fenêtré ; avail=1000-110=890 ; 300,560,820,(1080>890) → 3
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: [300, 200, 200, 200]})).toBe(3)
})
})
+49
View File
@@ -0,0 +1,49 @@
// Calcule combien d'onglets afficher pour qu'ils tiennent dans la largeur dispo,
// en gardant la structure « flèches fixes aux bords » : le nombre est choisi pour
// que les onglets visibles tiennent → pas de débordement sur les flèches, pas de
// rognage, barre d'onglet actif intacte.
//
// On additionne les VRAIES largeurs d'onglets (pas la pire), donc le résultat
// n'est pas sur-conservateur (évite de tomber à 1 onglet inutilement).
//
// Fonction pure → testable sans DOM. Sans layout (SSR / jsdom : largeurs à 0),
// on retombe sur le plafond `maxVisibleTabs` (ou tous les onglets).
export interface TabFitInput {
count: number // nombre total d'onglets
containerWidth: number // largeur dispo mesurée (0 si inconnue)
tabWidths: number[] // largeur mesurée de chaque onglet (vide si inconnu)
gap: number // espace entre onglets (px)
chevronReserve: number // place des chevrons + marges quand fenêtré (px)
maxVisibleTabs?: number // plafond optionnel imposé par le consommateur
}
export function computeVisibleCount(input: TabFitInput): number {
const {count, containerWidth, tabWidths, gap, chevronReserve, maxVisibleTabs} = input
// Pas d'info de layout : on respecte le plafond explicite, sinon tout afficher.
if (containerWidth <= 0 || tabWidths.length === 0) {
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, count) : count
}
const fullAvail = containerWidth
const total = tabWidths.reduce((s, w) => s + w, 0) + gap * Math.max(0, count - 1)
let fit: number
if (total <= fullAvail) {
fit = count // tout tient, pas de chevrons
}
else {
const avail = fullAvail - chevronReserve
let used = 0
let n = 0
for (const w of tabWidths) {
const add = w + (n > 0 ? gap : 0)
if (used + add > avail) break
used += add
n++
}
fit = Math.max(1, n)
}
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, fit) : fit
}
+30
View File
@@ -82,6 +82,20 @@
success="Enregistrée" success="Enregistrée"
/> />
</div> </div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Statuts par jour (markedDates) + @month-change</h2>
<MalioDate
v-model="markedValue"
label="Jours validés / à corriger"
hint="Ouvre le calendrier : jours verts (success) et rouges (danger)"
:marked-dates="markedDates"
@month-change="onMonthChange"
/>
<p class="mt-2 text-sm text-m-muted">
Mois affiché : <code>{{ shownMonth }}</code>
</p>
</div>
</div> </div>
</Story> </Story>
</template> </template>
@@ -102,4 +116,20 @@ const initialValue = ref<string | null>(todayIso)
const boundedValue = ref<string | null>(null) const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null) const errorValue = ref<string | null>(null)
const editableValue = ref<string | null>(null) const editableValue = ref<string | null>(null)
const ym = `${now.getFullYear()}-${pad(now.getMonth() + 1)}`
const markedDates = ref<Record<string, 'success' | 'danger'>>({
[`${ym}-05`]: 'success',
[`${ym}-06`]: 'success',
[`${ym}-12`]: 'success',
[`${ym}-09`]: 'danger',
[`${ym}-20`]: 'danger',
})
const markedValue = ref<string | null>(null)
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const shownMonth = ref('—')
const onMonthChange = ({month, year}: {month: number, year: number}) => {
shownMonth.value = `${monthsLong[month]} ${year}`
}
</script> </script>