Compare commits

...

5 Commits

Author SHA1 Message Date
tristan 251c939ba0 fix: sidebar active style (#82)
Release / release (push) Successful in 48s
| 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: #82
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-19 14:01:41 +00:00
tristan b6fcd3c186 fix: component style (#80)
Release / release (push) Successful in 1m56s
| 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: #80
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-19 13:22:12 +00:00
tristan e664731cb8 fix: sidebar style (#78)
Release / release (push) Successful in 1m24s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

---------

Co-authored-by: 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: #78
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 14:47:56 +00: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 29bd6abcfe fix: Date + DateTime new emit update rawValue (#75)
Release / release (push) Successful in 1m8s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

---------

Co-authored-by: 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: #75
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-12 07:40:13 +00:00
37 changed files with 1361 additions and 125 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>
@@ -0,0 +1,294 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">Champs désactivés (disabled)</h1>
<p class="text-sm text-m-muted">
Tous les champs de formulaire dans leur état <code>disabled</code>, vides puis remplis.
Règles : texte + label grisés, <code>cursor-not-allowed</code>, et <strong>aucune affordance
interactive</strong> (pas de bouton « + », pas de croix « x », pas de chevron, pas d'œil ;
tags et valeurs grisés).
</p>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputText</h2>
<div class="space-y-4">
<MalioInputText
label="Référence (vide)"
:disabled="true"
/>
<MalioInputText
model-value="Commande #A-2048"
label="Référence (rempli)"
icon-name="mdi:lock-outline"
icon-size="20"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputEmail (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputEmail
label="Adresse email (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputEmail
model-value="contact@malio.fr"
label="Adresse email (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPhone (addable → pas de « + »)</h2>
<div class="space-y-4">
<MalioInputPhone
label="Téléphone (vide)"
:addable="true"
:disabled="true"
/>
<MalioInputPhone
model-value="+33 6 12 34 56 78"
label="Téléphone (rempli)"
:addable="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputPassword (pas d'œil)</h2>
<div class="space-y-4">
<MalioInputPassword
label="Mot de passe (vide)"
:disabled="true"
/>
<MalioInputPassword
model-value="motdepasse123"
label="Mot de passe (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAmount</h2>
<div class="space-y-4">
<MalioInputAmount
label="Montant (vide)"
:disabled="true"
/>
<MalioInputAmount
model-value="1250.00"
label="Montant (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputNumber</h2>
<div class="space-y-4">
<MalioInputNumber
label="Quantité (vide)"
:disabled="true"
/>
<MalioInputNumber
model-value="42"
label="Quantité (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputTextArea</h2>
<div class="space-y-4">
<MalioInputTextArea
label="Description (vide)"
:size="3"
:disabled="true"
/>
<MalioInputTextArea
model-value="Ce texte est désactivé et ne peut pas être modifié."
label="Description (rempli)"
:size="3"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputUpload (pas de croix)</h2>
<div class="space-y-4">
<MalioInputUpload
label="Fichier (vide)"
:disabled="true"
/>
<MalioInputUpload
model-value="document.pdf"
label="Fichier (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioInputAutocomplete (pas de chevron)</h2>
<div class="space-y-4">
<MalioInputAutocomplete
label="Pays (vide)"
:options="countryOptions"
:disabled="true"
/>
<MalioInputAutocomplete
model-value="de"
label="Pays (rempli)"
:options="countryOptions"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelect (pas de chevron)</h2>
<div class="space-y-4">
<MalioSelect
label="Catégorie (vide)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
<MalioSelect
:model-value="'a'"
label="Catégorie (rempli)"
:options="categoryOptions"
empty-option-label="Aucune sélection"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioSelectCheckbox version tags (grisés)</h2>
<div class="space-y-4">
<MalioSelectCheckbox
label="Catégories (vide)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
<MalioSelectCheckbox
:model-value="['a', 'b', 'c']"
label="Catégories (rempli)"
:options="categoryOptions"
:display-tag="true"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDate (pas de croix)</h2>
<div class="space-y-4">
<MalioDate
label="Date de naissance (vide)"
:disabled="true"
/>
<MalioDate
model-value="2026-06-15"
label="Date de naissance (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateTime</h2>
<div class="space-y-4">
<MalioDateTime
label="Date et heure (vide)"
:disabled="true"
/>
<MalioDateTime
model-value="2026-12-25T09:30:00"
label="Date et heure (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateRange</h2>
<div class="space-y-4">
<MalioDateRange
label="Période (vide)"
:disabled="true"
/>
<MalioDateRange
:model-value="rangeValue"
label="Période (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioDateWeek</h2>
<div class="space-y-4">
<MalioDateWeek
label="Semaine (vide)"
:disabled="true"
/>
<MalioDateWeek
model-value="2026-W52"
label="Semaine (rempli)"
:disabled="true"
/>
</div>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">MalioTimePicker (pas de croix)</h2>
<div class="space-y-4">
<MalioTimePicker
label="Heure (vide)"
:disabled="true"
/>
<MalioTimePicker
model-value="14:30"
label="Heure (rempli)"
:disabled="true"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type Option = {label: string; value: string | number}
const countryOptions: Option[] = [
{label: 'France', value: 'fr'},
{label: 'Belgique', value: 'be'},
{label: 'Canada', value: 'ca'},
{label: 'Suisse', value: 'ch'},
{label: 'Luxembourg', value: 'lu'},
{label: 'Allemagne', value: 'de'},
]
const categoryOptions: Option[] = [
{label: 'Catégorie A', value: 'a'},
{label: 'Catégorie B', value: 'b'},
{label: 'Catégorie C', value: 'c'},
]
const rangeValue = ref<{start: string; end: string}>({start: '2026-12-20', end: '2026-12-31'})
</script>
+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' },
+1
View File
@@ -70,6 +70,7 @@ export const navSections: SidebarSection[] = [
icon: 'mdi:dots-horizontal', icon: 'mdi:dots-horizontal',
items: [ items: [
{label: 'Champs readonly', to: '/composant/divers/readonly'}, {label: 'Champs readonly', to: '/composant/divers/readonly'},
{label: 'Champs disabled', to: '/composant/divers/disabled'},
{label: 'Heure', to: '/composant/time/time'}, {label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'}, {label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'}, {label: 'Formulaire client', to: '/composant/form/client'},
+9
View File
@@ -54,8 +54,14 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). Nouveau parseur `parseDisplayToIsoDateTime`. * [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). Nouveau parseur `parseDisplayToIsoDateTime`.
* [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur. * [#MUI-43] 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-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
* Cohérence du mode **`disabled`** sur toute la famille formulaire (calqué sur InputText : texte + label grisés, `cursor-not-allowed`, aucune affordance interactive). Concrètement, quand `disabled` : le **bouton « + »** d'ajout disparaît (InputPhone, InputEmail), l'**œil** de révélation disparaît (InputPassword), le **chevron** disparaît (Select, SelectCheckbox, InputAutocomplete), la **croix d'effacement** reste masquée (date, upload, time), le **label** passe en `text-m-muted` (Select, SelectCheckbox, famille Date via CalendarField, TimePicker), et les **tags** du SelectCheckbox + la valeur du Select passent en gris. (InputText, InputAmount, InputNumber, InputTextArea, InputRichText, Checkbox, RadioButton, InputUpload étaient déjà conformes.)
* TabList : le nombre d'onglets visibles en mode fenêtré s'**adapte automatiquement à la largeur réelle** (mesure via `ResizeObserver` + ligne de mesure cachée), au lieu d'un `maxVisibleTabs` fixe qui pouvait faire déborder les onglets sur les chevrons. Les chevrons restent fixés aux bords et le nombre affiché est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). `maxVisibleTabs` devient un **plafond optionnel**. Calcul isolé dans une fonction pure testable (`tabFit.ts`, basée sur les largeurs réelles des onglets). Sans layout (SSR), repli sur le plafond / tous les onglets. **Breaking** : la prop `maxWidth` est supprimée (la barre utilise désormais toute la largeur disponible au lieu d'être plafonnée à 1100px).
* TabList : au **survol** d'un onglet inactif, on applique désormais le même style que l'onglet actif — texte `m-primary` plein + barre soulignée `m-primary` (`hover:after:*`) — au lieu du discret `text-m-primary/70`, pour bien marquer la cible.
* Sidebar : états visuels des liens de navigation — **survol** : highlight pleine largeur entièrement porté par le `<li>` (fond `m-primary` à 10 % + texte `m-primary` + semi-bold, `hover:bg-m-primary/10 hover:text-m-primary hover:font-semibold`, espacement `pt-1 pb-1`). La couleur de base (`text-black`) est aussi sur le `<li>` et le `<a>` ne fige plus sa couleur (il hérite) : sinon, sur les bandes `pt-1`/`pb-1` situées hors du `<a>`, le fond devenait bleu mais le texte restait noir. **Lien actif** : texte `m-primary` + semi-bold, sans fond (`active-class="!text-m-primary font-semibold"` ; `!important` car `active-class` est hors `twMerge`).
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés. * 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`).
@@ -64,6 +70,9 @@ 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
* Sidebar : le **lien actif** reste actif sur les **sous-routes** (match par préfixe via `useRoute().path` au lieu de l'`active-class` de NuxtLink qui dépendait de l'imbrication des routes) — ex. `/supplier` reste surligné sur `/supplier/1/edit`. Nouvelle option `exact: true` par item pour forcer le match strict.
* Famille Date (CalendarField) : le **clic sur le picto calendrier** ouvre désormais le popover (le `<Icon>` en overlay absolu interceptait le clic sans le traiter, et ne le laissait pas retomber sur l'input). Couvre Date, DateTime, DateRange, DateWeek. La croix d'effacement conserve son comportement (efface sans ouvrir).
* Famille Date editable (MalioDate, MalioDateTime) : la saisie clavier est désormais **bornée par champ** sur le premier **et** le second chiffre (jour `01-31`, mois `01-12`, heure `00-23`, minute `00-59`) — une valeur hors plage (`99/99/9999`, un jour `33`, un mois `19`…) ne peut plus être tapée (auparavant saisissable puis rejetée a posteriori par la validation). Les impossibilités calendaires fines (`31/02`, 29/02 non bissextile, hors `min`/`max`) restent captées par la validation. Implémenté via `buildBoundedMask(template)` (CalendarField) : un `preProcess` maska valide chaque champ progressivement (un chiffre n'est accepté que s'il reste une complétion valide dans la plage) ; il distingue le mois des minutes (même lettre `M`) selon la présence d'heures dans le gabarit.
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24) * 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)
+28 -8
View File
@@ -505,10 +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.
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) |
@@ -524,13 +530,14 @@ L'event `update:valid` remonte l'état de validité de la saisie au parent (`tru
| `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)` **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.)_
@@ -540,6 +547,16 @@ L'event `update:valid` remonte l'état de validité de la saisie au parent (`tru
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" /> <MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
<MalioDate v-model="date" label="Date de naissance" editable /> <MalioDate v-model="date" label="Date de naissance" editable />
<MalioDate v-model="date" label="Date de naissance" editable @update:valid="dateValide = $event" /> <MalioDate v-model="date" label="Date de naissance" editable @update:valid="dateValide = $event" />
<!-- Validation back-autoritative : on envoie la saisie brute si invalide -->
<MalioDate v-model="date" editable @update:valid="valide = $event" @update:rawValue="brut = $event" />
<!-- payload : valide ? date : brut -->
<!-- Statut par jour + chargement du mois visible (ex. SIRH « Heures ») -->
<MalioDate
v-model="date"
:marked-dates="statutsDuMois"
@month-change="({ month, year }) => chargerStatuts(month, year)"
/>
<!-- statutsDuMois === { "2026-05-05": "success", "2026-05-20": "danger" } -->
``` ```
--- ---
@@ -694,16 +711,17 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M
| `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)` **Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)`
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction. Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format en gris et se remplit au fil de la saisie (cf. MalioDate). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`. Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format en gris et se remplit au fil de la saisie (cf. MalioDate). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`. L'event `update:rawValue` expose la saisie brute pour la validation back-autoritative (mêmes règles que `MalioDate` : texte trimmé sur saisie invalide, `''` sinon — clear et sélection au calendrier compris).
```vue ```vue
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" /> <MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
<!-- rdv === "2026-05-20T14:30:00" --> <!-- rdv === "2026-05-20T14:30:00" -->
<MalioDateTime v-model="rdv" label="Rendez-vous" editable @update:valid="rdvValide = $event" /> <MalioDateTime v-model="rdv" label="Rendez-vous" editable @update:valid="rdvValide = $event" />
<MalioDateTime v-model="rdv" editable @update:valid="valide = $event" @update:rawValue="brut = $event" />
``` ```
--- ---
@@ -768,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` :
@@ -868,7 +885,10 @@ Barre latérale de navigation rétractable.
| `sidebarClass` | `string` | `''` | Classes CSS sidebar | | `sidebarClass` | `string` | `''` | Classes CSS sidebar |
| `toggleClass` | `string` | `''` | Classes CSS bouton toggle | | `toggleClass` | `string` | `''` | Classes CSS bouton toggle |
**Type SidebarSection :** `{ title?: string, items: { label: string, icon?: string, to?: string, href?: string, active?: boolean }[] }` **Type SidebarSection :** `{ label?: string, icon?: string, items: SidebarItem[] }`
**Type SidebarItem :** `{ label: string, to: string, exact?: boolean }`
**Lien actif :** un lien est marqué actif (texte `m-primary` + semi-bold) quand la route courante **est ce lien ou une de ses sous-routes** (match par préfixe) — ex. `/supplier` reste actif sur `/supplier/1/edit`. Mettre `exact: true` sur l'item force le match strict (actif uniquement sur la route exacte). Indépendant de l'imbrication des routes côté consommateur.
**Events :** `update:modelValue(value: boolean)` **Events :** `update:modelValue(value: boolean)`
**Slots :** `logo` (sidebar ouverte), `logo-collapsed` (sidebar fermée) **Slots :** `logo` (sidebar ouverte), `logo-collapsed` (sidebar fermée)
+159 -9
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()
@@ -183,6 +238,23 @@ describe('MalioDate', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
}) })
it('disabled : label grisé', () => {
const wrapper = mountDate({disabled: true, label: 'Date'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
})
it('disabled : pas de croix d\'effacement même avec une valeur', () => {
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
expect(wrapper.find('[data-test="clear"]').exists()).toBe(false)
})
it('disabled + rempli : icône calendrier grisée (pas noire)', () => {
const wrapper = mountDate({disabled: true, modelValue: '2026-05-19'})
const icon = wrapper.get('[data-test="calendar-icon"]')
expect(icon.classes()).toContain('text-m-muted')
expect(icon.classes()).not.toContain('text-black')
})
it('does not open when readonly', async () => { it('does not open when readonly', async () => {
const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'}) const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'})
await wrapper.get('[data-test="date-input"]').trigger('click') await wrapper.get('[data-test="date-input"]').trigger('click')
@@ -265,7 +337,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 +367,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 +395,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 +420,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 +491,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 +521,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,11 +560,65 @@ 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'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true]) expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
}) })
}) })
describe('saisie brute (update:rawValue)', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet le texte brut trimmé sur saisie hors min/max', async () => {
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('25/12/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('19/05/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
})
it('émet rawValue vide sur saisie vidée au blur', async () => {
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide sur clear', async () => {
const wrapper = mountDate({modelValue: '2026-05-19'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDate({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('31/02/2026')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026'])
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
})
}) })
+17
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',
@@ -90,6 +96,12 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void (e: 'update:valid', value: boolean): void
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
(e: 'update:rawValue', value: string): void
// Mois affiché dans le popover (month 0-11) : à l'ouverture et à chaque nav.
(e: 'month-change', value: {month: number, year: number}): void
}>() }>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null)) const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
@@ -108,25 +120,30 @@ const onCommit = (text: string) => {
const trimmed = text.trim() const trimmed = text.trim()
if (trimmed === '') { if (trimmed === '') {
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', null) emit('update:modelValue', null)
return return
} }
const iso = parseDisplayToIso(trimmed) const iso = parseDisplayToIso(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) { if (iso && isDateInRange(iso, props.min, props.max)) {
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso) emit('update:modelValue', iso)
return return
} }
setError(props.invalidMessage) setError(props.invalidMessage)
emit('update:rawValue', trimmed)
} }
const onClear = () => { const onClear = () => {
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', null) emit('update:modelValue', null)
} }
const onSelect = (iso: string, close: () => void) => { const onSelect = (iso: string, close: () => void) => {
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso) emit('update:modelValue', iso)
close() close()
} }
+74 -6
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,11 +290,65 @@ 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'})
expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true]) expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true])
}) })
}) })
describe('saisie brute (update:rawValue)', () => {
it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet le texte brut trimmé sur saisie hors min/max', async () => {
const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('25/12/2026 10:00')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026 10:00'])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('20/05/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00'])
})
it('émet rawValue vide sur saisie vidée au blur', async () => {
const wrapper = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide sur clear', async () => {
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
await wrapper.get('[data-test="clear"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
it('émet rawValue vide quand on sélectionne une date au calendrier', async () => {
const wrapper = mountDateTime({editable: true})
const input = wrapper.get('[data-test="date-input"]')
await input.setValue('31/02/2026 14:30')
await input.trigger('blur')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['31/02/2026 14:30'])
await input.trigger('focus')
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual([''])
})
})
}) })
+10
View File
@@ -103,6 +103,10 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
(e: 'update:valid', value: boolean): void (e: 'update:valid', value: boolean): void
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
(e: 'update:rawValue', value: string): void
}>() }>()
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre). // pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
@@ -129,6 +133,7 @@ function onSelectDay(iso: string) {
const now = new Date() const now = new Date()
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes()) const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', composeDateTime(iso, time)) emit('update:modelValue', composeDateTime(iso, time))
} }
@@ -136,6 +141,7 @@ function onTimeChange(value: string | null) {
if (!value) return if (!value) return
if (datePart.value) { if (datePart.value) {
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', composeDateTime(datePart.value, value)) emit('update:modelValue', composeDateTime(datePart.value, value))
} }
else { else {
@@ -147,21 +153,25 @@ function onCommit(text: string) {
const trimmed = text.trim() const trimmed = text.trim()
if (trimmed === '') { if (trimmed === '') {
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', null) emit('update:modelValue', null)
return return
} }
const iso = parseDisplayToIsoDateTime(trimmed) const iso = parseDisplayToIsoDateTime(trimmed)
if (iso && isDateInRange(iso, props.min, props.max)) { if (iso && isDateInRange(iso, props.min, props.max)) {
setError('') setError('')
emit('update:rawValue', '')
emit('update:modelValue', iso) emit('update:modelValue', iso)
return return
} }
setError(props.invalidMessage) setError(props.invalidMessage)
emit('update:rawValue', trimmed)
} }
function onClear() { function onClear() {
setError('') setError('')
pendingTime.value = '' pendingTime.value = ''
emit('update:rawValue', '')
emit('update:modelValue', null) emit('update:modelValue', null)
} }
@@ -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) {
@@ -344,11 +358,13 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: isReadonly.value : props.disabled
? isFilled.value ? 'text-black' : 'text-m-muted' ? 'text-m-muted'
: isOpen.value : isReadonly.value
? 'text-m-primary' ? isFilled.value ? 'text-black' : 'text-m-muted'
: 'peer-placeholder-shown:text-m-muted text-black', : isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass, props.labelClass,
), ),
) )
@@ -356,6 +372,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => { const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger' if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success' if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted' if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary' if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black' if (isFilled.value) return 'text-black'
@@ -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%]')
@@ -375,6 +375,12 @@ describe('MalioInputAutocomplete', () => {
expect(wrapper.get('input').classes()).toContain('cursor-not-allowed') expect(wrapper.get('input').classes()).toContain('cursor-not-allowed')
}) })
it('hides the chevron when disabled', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('sets readonly attribute', () => { it('sets readonly attribute', () => {
const wrapper = mountComponent({readonly: true}) const wrapper = mountComponent({readonly: true})
@@ -64,7 +64,7 @@
class="animate-spin text-m-primary" class="animate-spin text-m-primary"
/> />
<IconifyIcon <IconifyIcon
v-else v-else-if="!disabled"
icon="mdi:chevron-down" icon="mdi:chevron-down"
:width="20" :width="20"
:height="20" :height="20"
+2 -10
View File
@@ -339,12 +339,10 @@ describe('MalioInputEmail', () => {
expect(wrapper.emitted('add')).toHaveLength(1) expect(wrapper.emitted('add')).toHaveLength(1)
}) })
it('does not emit add when disabled', async () => { it('hides the add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true}) const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click') expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
expect(wrapper.emitted('add')).toBeUndefined()
}) })
it('does not emit add when readonly', async () => { it('does not emit add when readonly', async () => {
@@ -355,12 +353,6 @@ describe('MalioInputEmail', () => {
expect(wrapper.emitted('add')).toBeUndefined() expect(wrapper.emitted('add')).toBeUndefined()
}) })
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => { it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true}) const wrapper = mountComponent({addable: true, readonly: true})
+1 -2
View File
@@ -41,9 +41,8 @@
/> />
<button <button
v-if="addable" v-if="addable && !disabled"
type="button" type="button"
:disabled="disabled"
:aria-label="addButtonLabel" :aria-label="addButtonLabel"
data-test="add-button" data-test="add-button"
:class="mergedAddButtonClass" :class="mergedAddButtonClass"
+1 -1
View File
@@ -205,7 +205,7 @@ const mergedLabelClass = computed(() =>
'cursor-pointer text-black mr-4 text-[18px]', 'cursor-pointer text-black mr-4 text-[18px]',
hasError.value ? 'text-m-danger' : '', hasError.value ? 'text-m-danger' : '',
hasSuccess.value ? 'text-m-success' : '', hasSuccess.value ? 'text-m-success' : '',
props.disabled ? 'cursor-not-allowed text-black/60' : '', props.disabled ? 'cursor-not-allowed text-m-muted' : '',
props.labelClass, props.labelClass,
), ),
) )
@@ -91,6 +91,12 @@ describe('MalioInputPassword', () => {
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false) expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
}) })
it('hides the eye icon when disabled', () => {
const wrapper = mountComponent({disabled: true})
expect(wrapper.find('[data-test="icon"]').exists()).toBe(false)
})
it('renders icon by default', () => { it('renders icon by default', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
+1 -1
View File
@@ -33,7 +33,7 @@
</label> </label>
<IconifyIcon <IconifyIcon
v-if="displayIcon" v-if="displayIcon && !disabled"
:icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'" :icon="isPasswordVisible ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
:width="24" :width="24"
:height="24" :height="24"
+2 -10
View File
@@ -253,12 +253,10 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toHaveLength(1) expect(wrapper.emitted('add')).toHaveLength(1)
}) })
it('does not emit add when disabled', async () => { it('hides the add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true}) const wrapper = mountComponent({addable: true, disabled: true})
await wrapper.get('[data-test="add-button"]').trigger('click') expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
expect(wrapper.emitted('add')).toBeUndefined()
}) })
it('does not emit add when readonly', async () => { it('does not emit add when readonly', async () => {
@@ -269,12 +267,6 @@ describe('MalioInputPhone', () => {
expect(wrapper.emitted('add')).toBeUndefined() expect(wrapper.emitted('add')).toBeUndefined()
}) })
it('disables add button when disabled', () => {
const wrapper = mountComponent({addable: true, disabled: true})
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
})
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => { it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
const wrapper = mountComponent({addable: true, readonly: true}) const wrapper = mountComponent({addable: true, readonly: true})
+1 -2
View File
@@ -42,9 +42,8 @@
/> />
<button <button
v-if="addable" v-if="addable && !disabled"
type="button" type="button"
:disabled="disabled"
:aria-label="addButtonLabel" :aria-label="addButtonLabel"
data-test="add-button" data-test="add-button"
:class="mergedAddButtonClass" :class="mergedAddButtonClass"
+11 -2
View File
@@ -237,12 +237,21 @@ describe('MalioSelect', () => {
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black') expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
}) })
it('shows muted chevron color when disabled', () => { it('hides the chevron when disabled', () => {
const wrapper = mount(SelectForTest, { const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', options, disabled: true}, props: {modelValue: 'fr', options, disabled: true},
}) })
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted') expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the selected value when disabled', () => {
const wrapper = mount(SelectForTest, {
props: {modelValue: 'fr', label: 'Pays', options, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
expect(wrapper.get('button span.block').classes()).toContain('text-black/60')
}) })
it('shows danger chevron color on error even when open', async () => { it('shows danger chevron color on error even when open', async () => {
+12 -9
View File
@@ -65,15 +65,17 @@
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: isReadonly : disabled
? isOptionSelected ? 'text-m-muted'
? 'text-black' : isReadonly
: 'text-m-muted' ? isOptionSelected
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black' ? 'text-black'
: 'text-m-muted', : 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel, textLabel,
]" ]"
:style="labelTransformStyle" :style="labelTransformStyle"
@@ -85,13 +87,14 @@
class="block truncate" class="block truncate"
:class="[ :class="[
textValue, textValue,
isOptionSelected ? 'text-black' : 'select-none text-transparent' isOptionSelected ? (disabled ? 'text-black/60' : 'text-black') : 'select-none text-transparent'
]" ]"
> >
{{ selectedLabel || '\u00A0' }} {{ selectedLabel || '\u00A0' }}
</span> </span>
<span <span
v-if="!disabled"
data-test="chevron" data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2" class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[ :class="[
@@ -227,12 +227,23 @@ describe('MalioSelectCheckbox', () => {
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black') expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-black')
}) })
it('shows muted chevron color when disabled', () => { it('hides the chevron when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, { const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], options, disabled: true}, props: {modelValue: ['fr'], options, disabled: true},
}) })
expect(wrapper.get('[data-test="chevron"]').classes()).toContain('text-m-muted') expect(wrapper.find('[data-test="chevron"]').exists()).toBe(false)
})
it('greys the label and the tags when disabled', () => {
const wrapper = mount(SelectCheckboxForTest, {
props: {modelValue: ['fr'], label: 'Pays', options, displayTag: true, disabled: true},
})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
const tag = wrapper.findAll('span.inline-flex')[0]
expect(tag.classes()).toContain('text-black/60')
expect(tag.classes()).not.toContain('text-black')
}) })
it('shows danger chevron color on error even when open', async () => { it('shows danger chevron color on error even when open', async () => {
+14 -10
View File
@@ -65,15 +65,17 @@
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess : hasSuccess
? 'text-m-success' ? 'text-m-success'
: isReadonly : disabled
? isOptionSelected ? 'text-m-muted'
? 'text-black' : isReadonly
: 'text-m-muted' ? isOptionSelected
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black' ? 'text-black'
: 'text-m-muted', : 'text-m-muted'
: isOpen
? 'text-m-primary'
: isOptionSelected
? 'text-black'
: 'text-m-muted',
textLabel, textLabel,
]" ]"
:style="labelTransformStyle" :style="labelTransformStyle"
@@ -89,7 +91,8 @@
<span <span
v-for="option in selectedOptions" v-for="option in selectedOptions"
:key="String(option.value)" :key="String(option.value)"
class="inline-flex max-w-full items-center rounded-md border border-black px-2 text-sm leading-none text-black" class="inline-flex max-w-full items-center rounded-md border px-2 text-sm leading-none"
:class="disabled ? 'border-black/40 text-black/60' : 'border-black text-black'"
> >
<span class="truncate pb-[2px]">{{ option.label }}</span> <span class="truncate pb-[2px]">{{ option.label }}</span>
</span> </span>
@@ -113,13 +116,14 @@
:class="[ :class="[
textValue, textValue,
label ? 'pl-24' : '', label ? 'pl-24' : '',
isOptionSelected ? 'text-black' : 'text-m-muted' disabled ? 'text-black/60' : isOptionSelected ? 'text-black' : 'text-m-muted'
]" ]"
> >
{{ selectionSummary }} {{ selectionSummary }}
</span> </span>
<span <span
v-if="!disabled"
data-test="chevron" data-test="chevron"
class="absolute right-3 top-1/2 -translate-y-1/2" class="absolute right-3 top-1/2 -translate-y-1/2"
:class="[ :class="[
+70 -2
View File
@@ -1,12 +1,14 @@
import {describe, expect, it} from 'vitest' import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils' import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue' import type {DefineComponent} from 'vue'
import {createMemoryHistory, createRouter} from 'vue-router'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import Sidebar from './Sidebar.vue' import Sidebar from './Sidebar.vue'
type SidebarItem = { type SidebarItem = {
label: string label: string
to: string to: string
exact?: boolean
} }
type SidebarSection = { type SidebarSection = {
@@ -50,19 +52,35 @@ const stubs = {
}, },
} }
function makeRouter(path = '/') {
const router = createRouter({
history: createMemoryHistory(),
routes: [{path: '/:all(.*)*', component: {template: '<div />'}}],
})
router.push(path)
return router
}
function mountComponent(props: SidebarProps, slots?: Record<string, string>) { function mountComponent(props: SidebarProps, slots?: Record<string, string>) {
return mount(SidebarForTest, { return mount(SidebarForTest, {
props, props,
slots, slots,
global: {stubs}, global: {stubs, plugins: [makeRouter()]},
}) })
} }
// Monte avec le router positionné sur `path` (pour tester l'état actif).
async function mountAt(path: string, props: SidebarProps = {sections}) {
const router = makeRouter(path)
await router.isReady()
return mount(SidebarForTest, {props, global: {stubs, plugins: [router]}})
}
describe('MalioSidebar', () => { 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 +107,56 @@ 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 : route exacte → lien en primary + semi-bold, sans fond', () => {
return mountAt('/reception').then((wrapper) => {
const link = wrapper.findAll('a')[0]
expect(link.classes()).toContain('font-semibold')
expect(link.classes()).toContain('!text-m-primary')
expect(link.classes().some(c => c.startsWith('bg-'))).toBe(false)
})
})
it('actif : reste actif sur une sous-route (match par préfixe)', async () => {
const wrapper = await mountAt('/reception/1/edit')
expect(wrapper.findAll('a')[0].classes()).toContain('font-semibold')
})
it('actif : les autres liens ne sont pas actifs sur une sous-route', async () => {
const wrapper = await mountAt('/reception/1/edit')
expect(wrapper.findAll('a')[1].classes()).not.toContain('font-semibold')
})
it('exact : pas actif sur une sous-route', async () => {
const exactSections: SidebarSection[] = [
{label: 'S', icon: 'mdi:home', items: [{label: 'R', to: '/reception', exact: true}]},
]
const wrapper = await mountAt('/reception/1/edit', {sections: exactSections})
expect(wrapper.find('a').classes()).not.toContain('font-semibold')
})
it('exact : actif sur la route exacte', async () => {
const exactSections: SidebarSection[] = [
{label: 'S', icon: 'mdi:home', items: [{label: 'R', to: '/reception', exact: true}]},
]
const wrapper = await mountAt('/reception', {sections: exactSections})
expect(wrapper.find('a').classes()).toContain('font-semibold')
})
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)
+22 -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-2',
collapsed ? 'justify-center pt-[40px]' : '', collapsed ? 'justify-center pt-[40px]' : '',
]" ]"
> >
@@ -49,13 +49,15 @@
<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]',
isActive(item) ? '!text-m-primary font-semibold' : '',
)" )"
> >
<span v-if="!collapsed">{{ item.label }}</span> <span v-if="!collapsed">{{ item.label }}</span>
@@ -86,6 +88,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, useId} from 'vue' import {computed, ref, useId} from 'vue'
import {useRoute} from 'vue-router'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
@@ -94,6 +97,10 @@ defineOptions({name: 'MalioSidebar', inheritAttrs: false})
export type SidebarItem = { export type SidebarItem = {
label: string label: string
to: string to: string
// Par défaut, un lien est actif sur sa route ET ses sous-routes (préfixe) :
// `/supplier` reste actif sur `/supplier/1/edit`. `exact: true` force le match
// strict (actif uniquement sur la route exacte).
exact?: boolean
} }
export type SidebarSection = { export type SidebarSection = {
@@ -122,6 +129,16 @@ const emit = defineEmits<{
const generatedId = useId() const generatedId = useId()
const componentId = computed(() => props.id || `malio-sidebar-${generatedId}`) const componentId = computed(() => props.id || `malio-sidebar-${generatedId}`)
const route = useRoute()
// Actif si la route courante est le lien lui-même OU une de ses sous-routes
// (match par préfixe), pour que `/supplier` reste actif sur `/supplier/1/edit`.
// `item.exact` force le match strict.
function isActive(item: SidebarItem): boolean {
const path = route.path
if (item.exact) return path === item.to
return path === item.to || path.startsWith(`${item.to}/`)
}
const isControlled = computed(() => props.modelValue !== undefined) const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false) const localValue = ref(false)
+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
}
@@ -53,6 +53,14 @@ describe('MalioTimePicker', () => {
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
}) })
it('disabled + rempli : label et icône horloge grisés', () => {
const wrapper = mountPicker({disabled: true, label: 'Heure', modelValue: '14:30'})
expect(wrapper.get('label').classes()).toContain('text-m-muted')
const icon = wrapper.get('[data-test="clock-icon"]')
expect(icon.classes()).toContain('text-m-muted')
expect(icon.classes()).not.toContain('text-black')
})
it('n\'ouvre pas le popover si readonly', async () => { it('n\'ouvre pas le popover si readonly', async () => {
const wrapper = mountPicker({readonly: true}) const wrapper = mountPicker({readonly: true})
await wrapper.get('[data-test="time-field"]').trigger('click') await wrapper.get('[data-test="time-field"]').trigger('click')
+8 -5
View File
@@ -219,11 +219,13 @@ const mergedLabelClass = computed(() =>
? 'text-m-danger' ? 'text-m-danger'
: hasSuccess.value : hasSuccess.value
? 'text-m-success' ? 'text-m-success'
: isReadonly.value : props.disabled
? isFilled.value ? 'text-black' : 'text-m-muted' ? 'text-m-muted'
: isOpen.value : isReadonly.value
? 'text-m-primary' ? isFilled.value ? 'text-black' : 'text-m-muted'
: 'text-black peer-placeholder-shown:text-m-muted', : isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
props.labelClass, props.labelClass,
), ),
) )
@@ -231,6 +233,7 @@ const mergedLabelClass = computed(() =>
const iconStateClass = computed(() => { const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger' if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success' if (hasSuccess.value) return 'text-m-success'
if (props.disabled) return 'text-m-muted'
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted' if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
if (isOpen.value) return 'text-m-primary' if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black' if (isFilled.value) return 'text-black'
+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>