Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d06458a2a9 | |||
| 66ad644728 | |||
| 1c36d40bfd | |||
| 1bc3c11444 | |||
| 29c2bf48d3 | |||
| 8d7abc3406 | |||
| 06f9b0218a | |||
| a24748f7b1 | |||
| 62b23c53b4 | |||
| 9207d7bb95 | |||
| 2da30a9138 | |||
| ccc470d809 | |||
| c0a3037293 | |||
| 41010060ff | |||
| b0f060f909 | |||
| aef1550d7c | |||
| 5a06cf642f | |||
| be3d88ed45 | |||
| 06c739cdc7 | |||
| 4c41e100bd | |||
| cd965d5f7d | |||
| b4841f40ed | |||
| cca15524f4 | |||
| bc31d94719 | |||
| 86e8a84535 | |||
| d99c5831b8 | |||
| 9f9723d01c | |||
| 23a9729dcd | |||
| 336cb9e315 | |||
| bd9a204988 | |||
| 4bb152d87d | |||
| eb7677ae09 | |||
| b1c690e8bb | |||
| 1560a23079 | |||
| 1cf7864f6e | |||
| eb9a00b6c8 | |||
| 887ebdebd7 | |||
| aedfaa865d | |||
| 39eb6e6068 | |||
| ce9b4853e6 | |||
| dc33cf4135 | |||
| 526dcd1a84 | |||
| 280b650e49 | |||
| 951acd448e | |||
| 90b81975e3 | |||
| e6a46a9d60 | |||
| 6efb830ffe | |||
| 7b838c60ca | |||
| 9551816bf8 | |||
| 7ac097e7f0 | |||
| bc813190c6 | |||
| f3e298e03b | |||
| e2dabb0a26 | |||
| ac06ed9ae6 | |||
| b2e3a83bb9 | |||
| 9ed094ba86 | |||
| 1ffe63827d | |||
| eb21827686 | |||
| 6938e730b6 | |||
| 174f1f9a64 | |||
| 30efd482d8 | |||
| 7dec45b374 | |||
| ea92acff3a | |||
| a3421c02e9 | |||
| 5563d89743 | |||
| 640ff90187 | |||
| 2eb7a5247a | |||
| 3336ff0c69 | |||
| da3a4cb349 | |||
| 0ddae4dd70 | |||
| 23210e6868 | |||
| 1c0fcd24e3 | |||
| d74f3acc97 | |||
| 014a057196 | |||
| 73483b0573 | |||
| 4855923008 | |||
| fc844078a6 | |||
| 02495245a5 | |||
| 330fb2130b | |||
| 5acefc1d59 | |||
| e77bf49146 | |||
| f59f866354 | |||
| 660c3787fd | |||
| e9741ff38d | |||
| 32608c8f71 | |||
| e1965db04e | |||
| 0ad344bab9 | |||
| 96719be78d | |||
| b90baec571 | |||
| 384f86a3b3 | |||
| e8ddf4e083 | |||
| 7ee64289a8 | |||
| f09f8a91ac | |||
| bcadd46ce2 | |||
| e76337502a | |||
| 968b7087b5 | |||
| 3deba3f369 | |||
| cf46ab0c85 | |||
| 09cc3edf6f | |||
| c95a3657c0 | |||
| 9843f4d032 | |||
| 9d9b9c9dc4 | |||
| 187ef52865 | |||
| 9925f1ced4 | |||
| ded414ba1a | |||
| 11d60e687b | |||
| d3038994c3 | |||
| 0d350e12c6 | |||
| c6acaace27 | |||
| 927c7c3c70 | |||
| bf0aa92497 | |||
| 88dd76a0e4 | |||
| cc04114f89 | |||
| f456ea4ddf | |||
| 77364daa67 | |||
| 1ab7b2427a | |||
| 82ecc9cfe2 | |||
| 65d9060e26 | |||
| ec4c157226 |
@@ -68,6 +68,24 @@
|
||||
]"
|
||||
/>
|
||||
<MalioCheckbox label="Prestation de triage" groupClass="self-center"/>
|
||||
<MalioRadioGroup
|
||||
v-model="prestationChoice"
|
||||
:options="prestationOptions"
|
||||
inline
|
||||
required
|
||||
content-class="justify-between"
|
||||
error="Sélection requise"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="fournisseur"
|
||||
value=""
|
||||
label="Fournisseur"
|
||||
error="Sélection requise"
|
||||
:options="[
|
||||
{label: 'Fournisseur 1', value: 'Fournisseur 1'},
|
||||
{label: 'Fournisseur 2', value: 'Fournisseur 2'},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
@@ -188,6 +206,12 @@ const distributeur = ref<string>('')
|
||||
const phones = ref<string[]>([''])
|
||||
const nomDistributeur = ref<string>('')
|
||||
const nomCourtier = ref<string>('')
|
||||
const fournisseur = ref<string>('')
|
||||
const prestationChoice = ref<string | null>(null)
|
||||
const prestationOptions = [
|
||||
{label: 'Fond mouvant', value: 'fond-mouvant'},
|
||||
{label: 'Benne', value: 'benne'},
|
||||
]
|
||||
|
||||
function addPhoneInput() {
|
||||
phones.value.push('')
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="space-y-8 p-8">
|
||||
<section>
|
||||
<h2 class="mb-4 text-xl font-bold">Aligné avec un select (en ligne, erreur)</h2>
|
||||
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
|
||||
<MalioRadioGroup v-model="prestation" :options="yesNo" inline error="Sélection requise" />
|
||||
<MalioSelect
|
||||
v-model="fournisseur"
|
||||
label="Fournisseur"
|
||||
error="Sélection requise"
|
||||
:options="[
|
||||
{label: 'Fournisseur 1', value: 'Fournisseur 1'},
|
||||
{label: 'Fournisseur 2', value: 'Fournisseur 2'},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-4 text-xl font-bold">Empilé avec label</h2>
|
||||
<MalioRadioGroup v-model="categorie" :options="categories" label="Catégorie" />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-4 text-xl font-bold">Slot custom + requis</h2>
|
||||
<MalioRadioGroup v-model="civilite" inline required label="Civilité">
|
||||
<MalioRadioButton value="M" label="Monsieur" />
|
||||
<MalioRadioButton value="Mme" label="Madame" />
|
||||
</MalioRadioGroup>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const yesNo = [
|
||||
{label: 'Oui', value: 'oui'},
|
||||
{label: 'Non', value: 'non'},
|
||||
]
|
||||
const categories = [
|
||||
{label: 'Catégorie 1', value: 'cat1'},
|
||||
{label: 'Catégorie 2', value: 'cat2'},
|
||||
]
|
||||
|
||||
const prestation = ref<string | null>(null)
|
||||
const fournisseur = ref<string>('')
|
||||
const categorie = ref<string | null>(null)
|
||||
const civilite = ref<string | null>(null)
|
||||
</script>
|
||||
@@ -45,6 +45,7 @@ export const navSections: SidebarSection[] = [
|
||||
{label: 'Select Checkbox', to: '/composant/select/selectCheckbox'},
|
||||
{label: 'Checkbox', to: '/composant/checkbox/checkbox'},
|
||||
{label: 'Radio', to: '/composant/radio/radioButton'},
|
||||
{label: 'Radio (groupe)', to: '/composant/radio/radioGroup'},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,6 +56,8 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* [#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).
|
||||
* Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau de navigation — jours → mois → années) et grisage des mois et années hors `min`/`max`.
|
||||
* [#MUI-radio-group] Création d'un composant radio group (message unique, alignement select)
|
||||
|
||||
### 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.)
|
||||
|
||||
@@ -499,12 +499,55 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
||||
|
||||
---
|
||||
|
||||
## MalioRadioGroup
|
||||
|
||||
Groupe de boutons radio : possède la valeur, le `name` partagé et **un seul** message (erreur/succès/aide) avec espace réservé comme les autres champs — un groupe en ligne s'aligne donc avec un `MalioSelect` voisin. Les options sont déclarées via `:options` ou via le slot par défaut (`<MalioRadioButton>`).
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| number \| boolean \| null` | `undefined` | Valeur sélectionnée (v-model) |
|
||||
| `options` | `{label, value, disabled?}[]` | `[]` | Options déclaratives |
|
||||
| `label` | `string` | `''` | Label de groupe (legend, lié par `aria-labelledby`) |
|
||||
| `name` | `string` | auto | Nom natif partagé des radios |
|
||||
| `inline` | `boolean` | `false` | Disposition horizontale |
|
||||
| `disabled` | `boolean` | `false` | Désactive tout le groupe |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque dans la legend) |
|
||||
| `hint` / `error` / `success` | `string` | `''` | Message unique du groupe |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve la ligne de message (alignement) |
|
||||
| `groupClass` | `string` | `''` | Override `twMerge` du conteneur du groupe |
|
||||
| `contentClass` | `string` | `''` | Override `twMerge` de la zone des radios (ex. `justify-between`) |
|
||||
| `inputClass` | `string` | `''` | Override `twMerge` propagé à l'`input` de chaque radio |
|
||||
| `labelClass` | `string` | `''` | Override `twMerge` du **label de groupe** (legend), pas des labels d'options |
|
||||
|
||||
**Events :** `update:modelValue(value: string | number | boolean | null)`
|
||||
|
||||
**Accessibilité :** conteneur `role="radiogroup"`, `aria-labelledby` (si `label`), `aria-invalid` et `aria-describedby` sur le message unique. Les radios enfants héritent de l'état d'erreur/désactivé du groupe.
|
||||
|
||||
```vue
|
||||
<MalioRadioGroup
|
||||
v-model="prestation"
|
||||
:options="[{label: 'Oui', value: 'oui'}, {label: 'Non', value: 'non'}]"
|
||||
inline
|
||||
error="Sélection requise"
|
||||
/>
|
||||
|
||||
<MalioRadioGroup v-model="civilite" label="Civilité" inline>
|
||||
<MalioRadioButton value="M" label="Monsieur" />
|
||||
<MalioRadioButton value="Mme" label="Madame" />
|
||||
</MalioRadioGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MalioDate
|
||||
|
||||
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
||||
|
||||
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
||||
|
||||
Le calendrier propose trois niveaux de navigation : **jours** → clic sur l'en-tête → **sélecteur de mois** → nouveau clic sur l'en-tête → **sélecteur d'année** (grille de 12 ans avec l'année courante centrée en 2ᵉ ligne / 2ᵉ colonne, chevrons pour paginer par pas de 12 ans) → un clic de plus revient aux **jours** (cycle). L'en-tête affiche toujours « Mois Année » avec un chevron bas, quelle que soit la vue. Les props `min`/`max` grisent les mois et les années hors plage. Sélectionner une année revient au sélecteur de mois, sélectionner un mois revient à la grille de jours.
|
||||
|
||||
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.
|
||||
|
||||
@@ -185,6 +185,67 @@ describe('MalioDate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('vue années', () => {
|
||||
it('opens the year picker on second header toggle, current year centered (2nd row/2nd col)', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> mois
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> années
|
||||
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(true)
|
||||
// Le libellé reste « Mois Année » dans toutes les vues.
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
|
||||
// Année courante (2026) en 2e ligne / 2e colonne d'une grille 3 colonnes = index 4.
|
||||
const years = wrapper.findAll('[data-test="year"]')
|
||||
expect(years[4].attributes('data-year')).toBe('2026')
|
||||
expect(years[0].attributes('data-year')).toBe('2022')
|
||||
})
|
||||
|
||||
it('cycles back to day view on third header toggle', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> mois
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> années
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> jours
|
||||
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026')
|
||||
})
|
||||
|
||||
it('navigates days -> months -> years -> months -> days', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="year"][data-year="2024"]').trigger('click') // -> mois 2024
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2024')
|
||||
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click') // -> jours
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2024')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('paginates the year window by 12 with chevrons', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // années : fenêtre 2022–2033
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click') // +12 -> 2034–2045
|
||||
const years = wrapper.findAll('[data-test="year"]')
|
||||
expect(years[0].attributes('data-year')).toBe('2034')
|
||||
expect(years[11].attributes('data-year')).toBe('2045')
|
||||
})
|
||||
|
||||
it('greys out years outside [min, max]', async () => {
|
||||
const wrapper = mountDate({min: '2025-01-01', max: '2027-12-31'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="year"][data-year="2024"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-test="year"][data-year="2026"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vue mois', () => {
|
||||
it('switches to month view on header toggle', async () => {
|
||||
const wrapper = mountDate()
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:hint="hint"
|
||||
:error="mergedError"
|
||||
:success="success"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:hint="hint"
|
||||
:error="error"
|
||||
:success="success"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:min="min?.slice(0, 10)"
|
||||
:max="max?.slice(0, 10)"
|
||||
:hint="hint"
|
||||
:error="mergedError"
|
||||
:success="success"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:hint="hint"
|
||||
:error="error"
|
||||
:success="success"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
|
||||
import {formatIsoToDisplay, isDateInRange, isMonthInRange, isValidIso, isYearInRange, parseDisplayToIso} from './dateFormat'
|
||||
|
||||
describe('dateFormat', () => {
|
||||
describe('isValidIso', () => {
|
||||
@@ -59,4 +59,36 @@ describe('dateFormat', () => {
|
||||
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMonthInRange', () => {
|
||||
it('returns true when no bounds are given', () => {
|
||||
expect(isMonthInRange(2026, 4)).toBe(true)
|
||||
})
|
||||
it('respects the min bound by month (inclusive)', () => {
|
||||
expect(isMonthInRange(2026, 4, '2026-05-10')).toBe(true) // mai chevauche
|
||||
expect(isMonthInRange(2026, 3, '2026-05-10')).toBe(false) // avril < mai
|
||||
})
|
||||
it('respects the max bound by month (inclusive)', () => {
|
||||
expect(isMonthInRange(2026, 4, undefined, '2026-05-31')).toBe(true)
|
||||
expect(isMonthInRange(2026, 5, undefined, '2026-05-31')).toBe(false) // juin > mai
|
||||
})
|
||||
it('disables months in years outside the range', () => {
|
||||
expect(isMonthInRange(2025, 11, '2026-05-10')).toBe(false)
|
||||
expect(isMonthInRange(2027, 0, undefined, '2026-05-31')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isYearInRange', () => {
|
||||
it('returns true when no bounds are given', () => {
|
||||
expect(isYearInRange(2026)).toBe(true)
|
||||
})
|
||||
it('respects the min bound by year (inclusive)', () => {
|
||||
expect(isYearInRange(2026, '2026-05-10')).toBe(true)
|
||||
expect(isYearInRange(2025, '2026-05-10')).toBe(false)
|
||||
})
|
||||
it('respects the max bound by year (inclusive)', () => {
|
||||
expect(isYearInRange(2026, undefined, '2026-05-31')).toBe(true)
|
||||
expect(isYearInRange(2027, undefined, '2026-05-31')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,3 +24,16 @@ export function isDateInRange(iso: string, min?: string, max?: string): boolean
|
||||
if (max && iso > max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isMonthInRange(year: number, month: number, min?: string, max?: string): boolean {
|
||||
const ym = `${year}-${String(month + 1).padStart(2, '0')}`
|
||||
if (min && ym < min.slice(0, 7)) return false
|
||||
if (max && ym > max.slice(0, 7)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isYearInRange(year: number, min?: string, max?: string): boolean {
|
||||
if (min && year < Number(min.slice(0, 4))) return false
|
||||
if (max && year > Number(max.slice(0, 4))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -30,19 +30,21 @@ describe('useCalendarPopover', () => {
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
})
|
||||
|
||||
it('toggleView() switches between days and months', () => {
|
||||
it('cycleView() cycles days -> months -> years -> days', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
api.toggleView()
|
||||
api.cycleView()
|
||||
expect(api.viewMode.value).toBe('months')
|
||||
api.toggleView()
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
api.cycleView()
|
||||
expect(api.viewMode.value).toBe('years')
|
||||
api.cycleView()
|
||||
expect(api.viewMode.value).toBe('days') // boucle vers le bas depuis 'years'
|
||||
})
|
||||
|
||||
it('close() resets isOpen and viewMode', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
api.toggleView()
|
||||
api.cycleView()
|
||||
api.close()
|
||||
expect(api.isOpen.value).toBe(false)
|
||||
expect(api.viewMode.value).toBe('days')
|
||||
|
||||
@@ -2,7 +2,7 @@ import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
|
||||
|
||||
export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
||||
const isOpen = ref(false)
|
||||
const viewMode = ref<'days' | 'months'>('days')
|
||||
const viewMode = ref<'days' | 'months' | 'years'>('days')
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true
|
||||
@@ -12,8 +12,11 @@ export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
||||
isOpen.value = false
|
||||
viewMode.value = 'days'
|
||||
}
|
||||
const toggleView = () => {
|
||||
viewMode.value = viewMode.value === 'days' ? 'months' : 'days'
|
||||
// Le clic sur l'en-tête fait un cycle : jours → mois → années → jours.
|
||||
const cycleView = () => {
|
||||
if (viewMode.value === 'days') viewMode.value = 'months'
|
||||
else if (viewMode.value === 'months') viewMode.value = 'years'
|
||||
else viewMode.value = 'days'
|
||||
}
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
@@ -24,5 +27,5 @@ export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
|
||||
onMounted(() => document.addEventListener('mousedown', onMouseDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
|
||||
|
||||
return {isOpen, viewMode, open, close, toggleView}
|
||||
return {isOpen, viewMode, open, close, cycleView}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {ref} from 'vue'
|
||||
import {nextTick, ref} from 'vue'
|
||||
import {useCalendarView} from './useCalendarView'
|
||||
|
||||
describe('useCalendarView', () => {
|
||||
@@ -65,4 +65,27 @@ describe('useCalendarView', () => {
|
||||
expect(currentMonth.value).toBe(4)
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
|
||||
it('paginates years by 12 in years view', () => {
|
||||
const {yearPageStart, goToNext, goToPrev} = useCalendarView(ref('years'))
|
||||
const start = yearPageStart.value
|
||||
goToNext()
|
||||
expect(yearPageStart.value).toBe(start + 12)
|
||||
goToPrev()
|
||||
expect(yearPageStart.value).toBe(start)
|
||||
})
|
||||
|
||||
it('selectYear sets the current year', () => {
|
||||
const {currentYear, selectYear} = useCalendarView(ref('days'))
|
||||
selectYear(2030)
|
||||
expect(currentYear.value).toBe(2030)
|
||||
})
|
||||
|
||||
it('recenters the year page on entering years view (current - 4)', async () => {
|
||||
const mode = ref<'days' | 'months' | 'years'>('days')
|
||||
const {yearPageStart} = useCalendarView(mode)
|
||||
mode.value = 'years'
|
||||
await nextTick()
|
||||
expect(yearPageStart.value).toBe(2022) // 2026 - 4 (année courante en 2e ligne / 2e col)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import {ref, type Ref} from 'vue'
|
||||
import {ref, watch, type Ref} from 'vue'
|
||||
import {isValidIso} from './dateFormat'
|
||||
|
||||
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
export function useCalendarView(viewMode: Ref<'days' | 'months' | 'years'>) {
|
||||
const today = new Date()
|
||||
const currentMonth = ref(today.getMonth())
|
||||
const currentYear = ref(today.getFullYear())
|
||||
// Fenêtre de 12 ans calée pour que l'année courante tombe en 2e ligne / 2e
|
||||
// colonne d'une grille 3 colonnes (index 4) → début = année courante − 4.
|
||||
const yearPageStart = ref(today.getFullYear() - 4)
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'years') yearPageStart.value = currentYear.value - 4
|
||||
})
|
||||
|
||||
const goToPrev = () => {
|
||||
if (viewMode.value === 'years') {
|
||||
yearPageStart.value -= 12
|
||||
return
|
||||
}
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value -= 1
|
||||
return
|
||||
@@ -20,6 +31,10 @@ export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (viewMode.value === 'years') {
|
||||
yearPageStart.value += 12
|
||||
return
|
||||
}
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value += 1
|
||||
return
|
||||
@@ -36,6 +51,10 @@ export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
currentMonth.value = m
|
||||
}
|
||||
|
||||
const selectYear = (y: number) => {
|
||||
currentYear.value = y
|
||||
}
|
||||
|
||||
const syncToIso = (iso: string | null) => {
|
||||
if (iso && isValidIso(iso)) {
|
||||
currentMonth.value = Number(iso.slice(5, 7)) - 1
|
||||
@@ -47,5 +66,5 @@ export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
}
|
||||
}
|
||||
|
||||
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
|
||||
return {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
:current-year="currentYear"
|
||||
@prev="goToPrev"
|
||||
@next="goToNext"
|
||||
@toggle-view="toggleView"
|
||||
@toggle-view="cycleView"
|
||||
/>
|
||||
<slot
|
||||
v-if="viewMode === 'days'"
|
||||
@@ -97,10 +97,21 @@
|
||||
:close="closePopover"
|
||||
/>
|
||||
<MonthPicker
|
||||
v-else
|
||||
v-else-if="viewMode === 'months'"
|
||||
:selected-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="onSelectMonth"
|
||||
/>
|
||||
<YearPicker
|
||||
v-else
|
||||
:page-start="yearPageStart"
|
||||
:selected-year="currentYear"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="onSelectYear"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,6 +138,7 @@ import type {MaskInputOptions} from 'maska'
|
||||
import MalioRequiredMark from '../../shared/RequiredMark.vue'
|
||||
import CalendarHeader from './CalendarHeader.vue'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
import YearPicker from './YearPicker.vue'
|
||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||
import {useCalendarView} from '../composables/useCalendarView'
|
||||
import {buildBoundedMask} from '../composables/maskTemplate'
|
||||
@@ -157,6 +169,8 @@ const props = withDefaults(
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
reserveMessageSpace?: boolean
|
||||
min?: string
|
||||
max?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
@@ -176,6 +190,8 @@ const props = withDefaults(
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
reserveMessageSpace: true,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -215,8 +231,8 @@ watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
|
||||
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
||||
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
||||
const {isOpen, viewMode, open, close: closePopover, cycleView} = useCalendarPopover(root)
|
||||
const {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso} = useCalendarView(viewMode)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
||||
const hasError = computed(() => !!props.error)
|
||||
@@ -323,7 +339,12 @@ watch(() => props.syncTo, (value) => {
|
||||
|
||||
const onSelectMonth = (m: number) => {
|
||||
selectMonth(m)
|
||||
toggleView()
|
||||
viewMode.value = 'days'
|
||||
}
|
||||
|
||||
const onSelectYear = (y: number) => {
|
||||
selectYear(y)
|
||||
viewMode.value = 'months'
|
||||
}
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type="button"
|
||||
data-test="header-prev"
|
||||
class="ml-2 flex self-start rounded"
|
||||
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
|
||||
:aria-label="prevLabel"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<Icon
|
||||
@@ -32,7 +32,7 @@
|
||||
type="button"
|
||||
data-test="header-next"
|
||||
class="mr-2 flex self-start rounded"
|
||||
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
|
||||
:aria-label="nextLabel"
|
||||
@click="emit('next')"
|
||||
>
|
||||
<Icon
|
||||
@@ -51,7 +51,7 @@ import {Icon} from '@iconify/vue'
|
||||
defineOptions({name: 'MalioDateCalendarHeader'})
|
||||
|
||||
const props = defineProps<{
|
||||
viewMode: 'days' | 'months'
|
||||
viewMode: 'days' | 'months' | 'years'
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
}>()
|
||||
@@ -63,8 +63,21 @@ const emit = defineEmits<{
|
||||
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
||||
|
||||
// Libellé constant « Mois Année » dans toutes les vues (jours/mois/années) :
|
||||
// la grille affichée en dessous indique le niveau courant.
|
||||
const label = computed(() => {
|
||||
const name = monthsLong[props.currentMonth]
|
||||
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
|
||||
})
|
||||
|
||||
const prevLabel = computed(() =>
|
||||
props.viewMode === 'days' ? 'Mois précédent'
|
||||
: props.viewMode === 'months' ? 'Année précédente'
|
||||
: 'Période précédente',
|
||||
)
|
||||
const nextLabel = computed(() =>
|
||||
props.viewMode === 'days' ? 'Mois suivant'
|
||||
: props.viewMode === 'months' ? 'Année suivante'
|
||||
: 'Période suivante',
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
|
||||
const mountPicker = (props: { currentYear: number, selectedMonth?: number, min?: string, max?: string }) =>
|
||||
mount(MonthPicker, { props })
|
||||
|
||||
describe('MalioDateMonthPicker', () => {
|
||||
it('renders 12 months', () => {
|
||||
const wrapper = mountPicker({ currentYear: 2026 })
|
||||
expect(wrapper.findAll('[data-test="month"]')).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('emits select with the clicked month index', async () => {
|
||||
const wrapper = mountPicker({ currentYear: 2026 })
|
||||
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('disables months before min in the current year and does not emit', async () => {
|
||||
const wrapper = mountPicker({ currentYear: 2026, min: '2026-05-01' })
|
||||
const april = wrapper.get('[data-test="month"][data-month="3"]')
|
||||
expect(april.attributes('disabled')).toBeDefined()
|
||||
await april.trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables months after max in the current year', () => {
|
||||
const wrapper = mountPicker({ currentYear: 2026, max: '2026-05-31' })
|
||||
expect(wrapper.get('[data-test="month"][data-month="5"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-test="month"][data-month="4"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -9,14 +9,19 @@
|
||||
type="button"
|
||||
data-test="month"
|
||||
:data-month="index"
|
||||
:disabled="!isMonthInRange(currentYear, index, min, max)"
|
||||
:aria-disabled="!isMonthInRange(currentYear, index, min, max)"
|
||||
class="flex h-[45px] w-full items-center justify-center"
|
||||
:class="isMonthInRange(currentYear, index, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||
@click="emit('select', index)"
|
||||
>
|
||||
<span
|
||||
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||
:class="index === selectedMonth
|
||||
? 'bg-m-primary text-white'
|
||||
: 'text-black hover:bg-m-primary/10'"
|
||||
: isMonthInRange(currentYear, index, min, max)
|
||||
? 'text-black hover:bg-m-primary/10'
|
||||
: 'text-m-muted/30'"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
@@ -25,9 +30,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {isMonthInRange} from '../composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDateMonthPicker'})
|
||||
|
||||
defineProps<{selectedMonth?: number}>()
|
||||
defineProps<{
|
||||
currentYear: number
|
||||
selectedMonth?: number
|
||||
min?: string
|
||||
max?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{(e: 'select', month: number): void}>()
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import YearPicker from './YearPicker.vue'
|
||||
|
||||
const mountPicker = (props: {pageStart: number, selectedYear?: number, min?: string, max?: string}) =>
|
||||
mount(YearPicker, {props})
|
||||
|
||||
describe('MalioDateYearPicker', () => {
|
||||
it('renders 12 years from pageStart', () => {
|
||||
const wrapper = mountPicker({pageStart: 2021})
|
||||
const years = wrapper.findAll('[data-test="year"]')
|
||||
expect(years).toHaveLength(12)
|
||||
expect(years[0].attributes('data-year')).toBe('2021')
|
||||
expect(years[11].attributes('data-year')).toBe('2032')
|
||||
})
|
||||
|
||||
it('emits select with the clicked year', async () => {
|
||||
const wrapper = mountPicker({pageStart: 2021})
|
||||
await wrapper.get('[data-test="year"][data-year="2026"]').trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]).toEqual([2026])
|
||||
})
|
||||
|
||||
it('disables years outside [min, max] and does not emit', async () => {
|
||||
const wrapper = mountPicker({pageStart: 2021, min: '2025-01-01', max: '2027-12-31'})
|
||||
const out = wrapper.get('[data-test="year"][data-year="2024"]')
|
||||
expect(out.attributes('disabled')).toBeDefined()
|
||||
await out.trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('highlights the selected year', () => {
|
||||
const wrapper = mountPicker({pageStart: 2021, selectedYear: 2026})
|
||||
const span = wrapper.get('[data-test="year"][data-year="2026"] span')
|
||||
expect(span.classes()).toContain('bg-m-primary')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
data-test="year-picker"
|
||||
class="grid grid-cols-3 gap-3"
|
||||
>
|
||||
<button
|
||||
v-for="year in years"
|
||||
:key="year"
|
||||
type="button"
|
||||
data-test="year"
|
||||
:data-year="year"
|
||||
:disabled="!isYearInRange(year, min, max)"
|
||||
:aria-disabled="!isYearInRange(year, min, max)"
|
||||
class="flex h-[45px] w-full items-center justify-center"
|
||||
:class="isYearInRange(year, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||
@click="emit('select', year)"
|
||||
>
|
||||
<span
|
||||
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||
:class="year === selectedYear
|
||||
? 'bg-m-primary text-white'
|
||||
: isYearInRange(year, min, max)
|
||||
? 'text-black hover:bg-m-primary/10'
|
||||
: 'text-m-muted/30'"
|
||||
>
|
||||
{{ year }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {isYearInRange} from '../composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDateYearPicker'})
|
||||
|
||||
const props = defineProps<{
|
||||
pageStart: number
|
||||
selectedYear?: number
|
||||
min?: string
|
||||
max?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{(e: 'select', year: number): void}>()
|
||||
|
||||
const years = computed(() => Array.from({length: 12}, (_, i) => props.pageStart + i))
|
||||
</script>
|
||||
@@ -1,7 +1,9 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {computed, ref} from 'vue'
|
||||
import {describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import RadioButton from './RadioButton.vue'
|
||||
import {radioGroupContextKey, type RadioGroupContext, type RadioValue} from './context'
|
||||
|
||||
type RadioButtonProps = {
|
||||
id?: string
|
||||
@@ -193,3 +195,67 @@ describe('MalioRadioButton', () => {
|
||||
expect(wrapper.get('.radio-text').classes()).toContain('text-black')
|
||||
})
|
||||
})
|
||||
|
||||
const makeGroupCtx = (over: Partial<{
|
||||
selected: RadioValue; error: boolean; success: boolean
|
||||
disabled: boolean; readonly: boolean; required: boolean
|
||||
}> = {}) => {
|
||||
const selected = ref<RadioValue>(over.selected ?? null)
|
||||
const select = vi.fn((v: RadioValue) => { selected.value = v })
|
||||
const ctx: RadioGroupContext = {
|
||||
name: computed(() => 'grp'),
|
||||
isSelected: (v) => selected.value === v,
|
||||
select,
|
||||
hasError: computed(() => !!over.error),
|
||||
hasSuccess: computed(() => !!over.success),
|
||||
disabled: computed(() => !!over.disabled),
|
||||
readonly: computed(() => !!over.readonly),
|
||||
required: computed(() => !!over.required),
|
||||
describedBy: computed(() => 'grp-describedby'),
|
||||
}
|
||||
return {ctx, select, selected}
|
||||
}
|
||||
|
||||
const mountInGroup = (props: RadioButtonProps, ctx: RadioGroupContext) =>
|
||||
mount(RadioButtonForTest, {
|
||||
props,
|
||||
global: {provide: {[radioGroupContextKey as symbol]: ctx}},
|
||||
})
|
||||
|
||||
describe('MalioRadioButton dans un groupe', () => {
|
||||
it('hérite du name du groupe', () => {
|
||||
const {ctx} = makeGroupCtx()
|
||||
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
||||
expect(wrapper.get('input').attributes('name')).toBe('grp')
|
||||
})
|
||||
|
||||
it('coché selon isSelected du groupe', () => {
|
||||
const {ctx} = makeGroupCtx({selected: 'a'})
|
||||
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
|
||||
it('appelle ctx.select au change au lieu d\'émettre', async () => {
|
||||
const {ctx, select} = makeGroupCtx()
|
||||
const wrapper = mountInGroup({value: 'b', label: 'B'}, ctx)
|
||||
await wrapper.get('input').trigger('change')
|
||||
expect(select).toHaveBeenCalledWith('b')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reflète l\'erreur du groupe et ne rend aucun message propre', () => {
|
||||
const {ctx} = makeGroupCtx({error: true})
|
||||
const wrapper = mountInGroup({value: 'a', label: 'A', hint: 'ignoré'}, ctx)
|
||||
expect(wrapper.get('.radio-control').classes()).toContain('is-error')
|
||||
expect(wrapper.get('input').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('.radio-message').exists()).toBe(false)
|
||||
expect(wrapper.get('input').attributes('aria-describedby')).toBe('grp-describedby')
|
||||
})
|
||||
|
||||
it('hérite de disabled/required du groupe', () => {
|
||||
const {ctx} = makeGroupCtx({disabled: true, required: true})
|
||||
const wrapper = mountInGroup({value: 'a', label: 'A'}, ctx)
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('input').attributes('required')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div :class="mergedGroupClass">
|
||||
<div :class="mergedControlClass">
|
||||
<label :for="inputId" class="radio-indicator relative flex cursor-pointer items-center p-3">
|
||||
<label :for="inputId" :class="indicatorClass">
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
:name="resolvedName"
|
||||
:value="value"
|
||||
:checked="isChecked"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="!!error"
|
||||
:required="resolvedRequired"
|
||||
:disabled="resolvedDisabled"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:class="mergedInputClass"
|
||||
v-bind="attrs"
|
||||
@@ -29,7 +29,7 @@
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
{{ label }}<MalioRequiredMark v-if="resolvedRequired" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -44,9 +44,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {computed, inject, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {radioGroupContextKey, type RadioValue} from './context'
|
||||
|
||||
defineOptions({name: 'MalioRadioButton', inheritAttrs: false})
|
||||
|
||||
@@ -55,8 +56,8 @@ const props = withDefaults(
|
||||
id?: string
|
||||
label?: string
|
||||
name?: string
|
||||
modelValue?: string | number | boolean | null | undefined
|
||||
value?: string | number | boolean | null | undefined
|
||||
modelValue?: RadioValue
|
||||
value?: RadioValue
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -87,27 +88,45 @@ const props = withDefaults(
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const localValue = ref<string | number | boolean | null | undefined>(undefined)
|
||||
const localValue = ref<RadioValue>(undefined)
|
||||
const group = inject(radioGroupContextKey, null)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-radio-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const isChecked = computed(() =>
|
||||
isControlled.value ? props.modelValue === props.value : localValue.value === props.value,
|
||||
|
||||
const resolvedName = computed(() => (group ? group.name.value : props.name))
|
||||
const resolvedDisabled = computed(() => props.disabled || (group?.disabled.value ?? false))
|
||||
const resolvedReadonly = computed(() => props.readonly || (group?.readonly.value ?? false))
|
||||
const resolvedRequired = computed(() => props.required || (group?.required.value ?? false))
|
||||
|
||||
const isChecked = computed(() => {
|
||||
if (group) return group.isSelected(props.value)
|
||||
return isControlled.value ? props.modelValue === props.value : localValue.value === props.value
|
||||
})
|
||||
|
||||
const hasError = computed(() => (group ? group.hasError.value : !!props.error))
|
||||
const hasSuccess = computed(() =>
|
||||
group ? group.hasSuccess.value : !!props.success && !hasError.value,
|
||||
)
|
||||
|
||||
const shouldShowMessage = computed(
|
||||
() => !group && !!(props.hint || hasError.value || hasSuccess.value),
|
||||
)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const disabled = computed(() => props.disabled)
|
||||
const shouldShowMessage = computed(() => !!(props.hint || hasError.value || hasSuccess.value))
|
||||
|
||||
const describedBy = computed(() => {
|
||||
if (group) return group.describedBy.value
|
||||
if (!shouldShowMessage.value) return undefined
|
||||
return `${inputId.value}-describedby`
|
||||
})
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge(group ? 'radio-item w-auto' : 'radio-item mt-4 w-full', props.groupClass),
|
||||
)
|
||||
|
||||
const indicatorClass = computed(() =>
|
||||
twMerge(
|
||||
'radio-item mt-4 w-full',
|
||||
props.groupClass,
|
||||
'radio-indicator relative flex cursor-pointer items-center',
|
||||
group ? 'px-3 py-2.5' : 'p-3',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -116,7 +135,7 @@ const mergedControlClass = computed(() =>
|
||||
'radio-control flex items-center',
|
||||
hasError.value ? 'is-error' : '',
|
||||
hasSuccess.value ? 'is-success' : '',
|
||||
disabled.value ? 'is-disabled' : '',
|
||||
resolvedDisabled.value ? 'is-disabled' : '',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -133,7 +152,7 @@ const mergedLabelClass = computed(() =>
|
||||
isChecked.value ? 'text-black' : 'text-m-muted',
|
||||
hasError.value ? 'text-m-danger' : '',
|
||||
hasSuccess.value ? 'text-m-success' : '',
|
||||
disabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||
resolvedDisabled.value ? 'cursor-not-allowed text-black/60' : '',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
@@ -150,22 +169,27 @@ const mergedMessageClass = computed(() =>
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string | number | boolean | null | undefined): void
|
||||
(event: 'update:modelValue', value: RadioValue): void
|
||||
}>()
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (!props.readonly) return
|
||||
if (!resolvedReadonly.value) return
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
if (props.readonly) {
|
||||
if (resolvedReadonly.value) {
|
||||
const target = event.target as HTMLInputElement
|
||||
target.checked = isChecked.value
|
||||
return
|
||||
}
|
||||
|
||||
if (group) {
|
||||
group.select(props.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = props.value
|
||||
}
|
||||
@@ -205,8 +229,4 @@ const onChange = (event: Event) => {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.radio-item:has(+ .radio-item) .radio-message {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {h} from 'vue'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import RadioGroup from './RadioGroup.vue'
|
||||
import RadioButton from './RadioButton.vue'
|
||||
|
||||
type Opt = {label: string; value: string; disabled?: boolean}
|
||||
type RadioGroupProps = {
|
||||
modelValue?: string | number | boolean | null
|
||||
options?: Opt[]
|
||||
label?: string
|
||||
name?: string
|
||||
inline?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
groupClass?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
}
|
||||
|
||||
const RadioGroupForTest = RadioGroup as DefineComponent<RadioGroupProps>
|
||||
|
||||
const options: Opt[] = [
|
||||
{label: 'Oui', value: 'oui'},
|
||||
{label: 'Non', value: 'non'},
|
||||
]
|
||||
|
||||
const mountGroup = (props: RadioGroupProps = {}) =>
|
||||
mount(RadioGroupForTest, {props: {options, ...props}})
|
||||
|
||||
describe('MalioRadioGroup', () => {
|
||||
it('rend une option par entrée et un seul role=radiogroup', () => {
|
||||
const wrapper = mountGroup()
|
||||
expect(wrapper.findAll('input[type="radio"]')).toHaveLength(2)
|
||||
expect(wrapper.findAll('[role="radiogroup"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('partage le même name natif entre les radios', () => {
|
||||
const wrapper = mountGroup({name: 'prestation'})
|
||||
const names = wrapper.findAll('input').map(i => i.attributes('name'))
|
||||
expect(names).toEqual(['prestation', 'prestation'])
|
||||
})
|
||||
|
||||
it('coche selon modelValue', () => {
|
||||
const wrapper = mountGroup({modelValue: 'non'})
|
||||
const inputs = wrapper.findAll('input')
|
||||
expect((inputs[1].element as HTMLInputElement).checked).toBe(true)
|
||||
expect((inputs[0].element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('émet update:modelValue au clic sur une option', async () => {
|
||||
const wrapper = mountGroup()
|
||||
await wrapper.findAll('input')[1].trigger('change')
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['non'])
|
||||
})
|
||||
|
||||
it('affiche UN seul message d\'erreur réservant l\'espace', () => {
|
||||
const wrapper = mountGroup({error: 'Sélection requise'})
|
||||
const msgs = wrapper.findAll('[id$="-describedby"]')
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0].text()).toBe('Sélection requise')
|
||||
expect(msgs[0].classes()).toContain('min-h-[1rem]')
|
||||
expect(msgs[0].classes()).toContain('text-m-danger')
|
||||
expect(wrapper.find('.radio-message').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('propage l\'erreur aux radios enfants', () => {
|
||||
const wrapper = mountGroup({error: 'Sélection requise'})
|
||||
expect(wrapper.findAll('.radio-control.is-error')).toHaveLength(2)
|
||||
expect(wrapper.findAll('input').every(i => i.attributes('aria-invalid') === 'true')).toBe(true)
|
||||
})
|
||||
|
||||
it('reserveMessageSpace=false sans message : aucune ligne réservée', () => {
|
||||
const wrapper = mountGroup({reserveMessageSpace: false})
|
||||
expect(wrapper.find('[id$="-describedby"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('rend la legend et la lie via aria-labelledby', () => {
|
||||
const wrapper = mountGroup({label: 'Prestation'})
|
||||
const legendId = wrapper.find('[id$="-label"]').attributes('id')
|
||||
expect(wrapper.get('[id$="-label"]').text()).toContain('Prestation')
|
||||
expect(wrapper.get('[role="radiogroup"]').attributes('aria-labelledby')).toBe(legendId)
|
||||
})
|
||||
|
||||
it('inline : la zone radios réserve la hauteur d\'un champ (h-12 du select)', () => {
|
||||
const wrapper = mountGroup({inline: true})
|
||||
expect(wrapper.get('[role="radiogroup"]').classes()).toContain('min-h-[3rem]')
|
||||
})
|
||||
|
||||
it('contentClass est fusionné sur la zone des radios', () => {
|
||||
const wrapper = mountGroup({inline: true, contentClass: 'justify-between'})
|
||||
expect(wrapper.get('[role="radiogroup"]').classes()).toContain('justify-between')
|
||||
})
|
||||
|
||||
it('accepte des radios via le slot par défaut', () => {
|
||||
const wrapper = mount(RadioGroupForTest, {
|
||||
props: {modelValue: 'b'},
|
||||
slots: {
|
||||
default: () => [
|
||||
h(RadioButton, {value: 'a', label: 'A'}),
|
||||
h(RadioButton, {value: 'b', label: 'B'}),
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(wrapper.findAll('input')).toHaveLength(2)
|
||||
expect((wrapper.findAll('input')[1].element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div :class="mergedGroupClass">
|
||||
<span
|
||||
v-if="label"
|
||||
:id="`${groupId}-label`"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</span>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
:aria-labelledby="label ? `${groupId}-label` : undefined"
|
||||
:aria-invalid="hasError || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:class="contentZoneClass"
|
||||
>
|
||||
<MalioRadioButton
|
||||
v-for="(option, index) in options"
|
||||
:key="`${groupId}-opt-${index}`"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
:input-class="inputClass"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
:id="`${groupId}-describedby`"
|
||||
:class="[
|
||||
hasError
|
||||
? 'text-m-danger'
|
||||
: hasSuccess
|
||||
? 'text-m-success'
|
||||
: 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, provide, ref, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRadioButton from './RadioButton.vue'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {radioGroupContextKey, type RadioValue} from './context'
|
||||
|
||||
defineOptions({name: 'MalioRadioGroup', inheritAttrs: false})
|
||||
|
||||
interface RadioOption {
|
||||
label: string
|
||||
value: RadioValue
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: RadioValue
|
||||
options?: RadioOption[]
|
||||
label?: string
|
||||
name?: string
|
||||
inline?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
reserveMessageSpace?: boolean
|
||||
groupClass?: string
|
||||
contentClass?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: undefined,
|
||||
options: () => [],
|
||||
label: '',
|
||||
name: '',
|
||||
inline: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
reserveMessageSpace: true,
|
||||
groupClass: '',
|
||||
contentClass: '',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: RadioValue): void
|
||||
}>()
|
||||
|
||||
const generatedId = useId()
|
||||
const groupId = computed(() => props.name || `malio-radio-group-${generatedId}`)
|
||||
|
||||
const localValue = ref<RadioValue>(undefined)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const selectedValue = computed(() =>
|
||||
isControlled.value ? props.modelValue : localValue.value,
|
||||
)
|
||||
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const shouldShowMessage = computed(() => !!(props.hint || hasError.value || hasSuccess.value))
|
||||
const describedBy = computed(() =>
|
||||
props.reserveMessageSpace || shouldShowMessage.value
|
||||
? `${groupId.value}-describedby`
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const select = (value: RadioValue) => {
|
||||
if (props.readonly || props.disabled) return
|
||||
if (!isControlled.value) localValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
provide(radioGroupContextKey, {
|
||||
name: computed(() => groupId.value),
|
||||
isSelected: (value: RadioValue) => selectedValue.value === value,
|
||||
select,
|
||||
hasError,
|
||||
hasSuccess,
|
||||
disabled: computed(() => props.disabled),
|
||||
readonly: computed(() => props.readonly),
|
||||
required: computed(() => props.required),
|
||||
describedBy,
|
||||
})
|
||||
|
||||
const contentZoneClass = computed(() =>
|
||||
twMerge(
|
||||
props.inline
|
||||
? 'flex flex-wrap items-center gap-x-6 min-h-[3rem]'
|
||||
: 'flex flex-col gap-y-1',
|
||||
props.contentClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedGroupClass = computed(() => twMerge('w-full', props.groupClass))
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'mb-1 block text-sm text-m-text',
|
||||
hasError.value ? 'text-m-danger' : '',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {ComputedRef, InjectionKey} from 'vue'
|
||||
|
||||
export type RadioValue = string | number | boolean | null | undefined
|
||||
|
||||
export interface RadioGroupContext {
|
||||
name: ComputedRef<string>
|
||||
isSelected: (value: RadioValue) => boolean
|
||||
select: (value: RadioValue) => void
|
||||
hasError: ComputedRef<boolean>
|
||||
hasSuccess: ComputedRef<boolean>
|
||||
disabled: ComputedRef<boolean>
|
||||
readonly: ComputedRef<boolean>
|
||||
required: ComputedRef<boolean>
|
||||
describedBy: ComputedRef<string | undefined>
|
||||
}
|
||||
|
||||
export const radioGroupContextKey: InjectionKey<RadioGroupContext> = Symbol('MalioRadioGroup')
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<Story title="Input/RadioGroup">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Empilé</h2>
|
||||
<MalioRadioGroup v-model="stacked" :options="options" label="Catégorie" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">En ligne</h2>
|
||||
<MalioRadioGroup v-model="inline" :options="yesNo" inline label="Prestation" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioRadioGroup v-model="errored" :options="yesNo" inline error="Sélection requise" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Succès</h2>
|
||||
<MalioRadioGroup v-model="ok" :options="yesNo" inline success="Enregistré" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioRadioGroup v-model="disabled" :options="options" disabled label="Catégorie" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Requis + slot</h2>
|
||||
<MalioRadioGroup v-model="slotted" required label="Civilité" inline>
|
||||
<MalioRadioButton value="M" label="Monsieur" />
|
||||
<MalioRadioButton value="Mme" label="Madame" />
|
||||
</MalioRadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const options = [
|
||||
{label: 'Catégorie 1', value: 'cat1'},
|
||||
{label: 'Catégorie 2', value: 'cat2'},
|
||||
{label: 'Catégorie 3', value: 'cat3'},
|
||||
]
|
||||
const yesNo = [
|
||||
{label: 'Oui', value: 'oui'},
|
||||
{label: 'Non', value: 'non'},
|
||||
]
|
||||
|
||||
const stacked = ref<string | null>(null)
|
||||
const inline = ref<string | null>('oui')
|
||||
const errored = ref<string | null>(null)
|
||||
const ok = ref<string | null>('oui')
|
||||
const disabled = ref<string | null>('cat2')
|
||||
const slotted = ref<string | null>(null)
|
||||
</script>
|
||||
@@ -0,0 +1,934 @@
|
||||
# Sélecteur d'année dans le calendrier — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ajouter un 3ᵉ niveau de navigation au calendrier de la famille `date/` : depuis la vue mois, recliquer sur le header ouvre un sélecteur d'année calqué sur le sélecteur de mois, avec respect des bornes `min`/`max`.
|
||||
|
||||
**Architecture:** Le shell partagé `internal/CalendarField.vue` orchestre l'input, le popover, le header (`CalendarHeader`) et la commutation entre les vues `days` / `months` / `years`. `MonthPicker` et le nouveau `YearPicker` sont rendus dans `CalendarField` ; la grille de jours reste fournie par chaque consommateur via slot scoped. La logique d'état vit dans deux composables (`useCalendarPopover`, `useCalendarView`) et les bornes dans des helpers purs (`dateFormat.ts`).
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (palette `m-*`), `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Composants : `defineOptions({name: 'MalioXxx'})` (les internes utilisent `MalioDateXxx`, sans `inheritAttrs: false` pour les pickers — calquer l'existant `MonthPicker`).
|
||||
- Valeurs ISO : `min`/`max` sont des chaînes `YYYY-MM-DD` (`DateTime` les tronque via `.slice(0, 10)`).
|
||||
- Tests : Vitest run mode, fichiers colocalisés `*.test.ts`, jsdom. Déterminisme via `vi.setSystemTime(new Date(2026, 4, 19))` (19 mai 2026).
|
||||
- Lancer un test ciblé : `npx vitest run <chemin>`.
|
||||
- Commits : Conventional Commits **avec espace avant `:`** (`feat : message (#…)`), type minuscule, **pas de scope majuscule** (préférer sans scope). Terminer par `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
||||
- Hook pre-commit flaky (timeouts WSL2) : après 2 échecs flaky, vérifier le test ciblé à la main puis committer avec `--no-verify`.
|
||||
- Ne PAS toucher / committer `nuxt.config.ts` (modif ngrok locale intentionnelle). Stager des fichiers explicites, jamais `git add -A`.
|
||||
- Aucune API publique des 4 composants consommateurs ne change.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Helpers de plage mois/année (`dateFormat.ts`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/composables/dateFormat.ts`
|
||||
- Test: `app/components/malio/date/composables/dateFormat.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: rien.
|
||||
- Produces:
|
||||
- `isMonthInRange(year: number, month: number, min?: string, max?: string): boolean` (month 0-11)
|
||||
- `isYearInRange(year: number, min?: string, max?: string): boolean`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `dateFormat.test.ts` — l'import en tête devient :
|
||||
```ts
|
||||
import {formatIsoToDisplay, isDateInRange, isMonthInRange, isValidIso, isYearInRange, parseDisplayToIso} from './dateFormat'
|
||||
```
|
||||
Puis ajouter ces deux blocs `describe` à l'intérieur du `describe('dateFormat', …)` (après le bloc `isDateInRange`) :
|
||||
```ts
|
||||
describe('isMonthInRange', () => {
|
||||
it('returns true when no bounds are given', () => {
|
||||
expect(isMonthInRange(2026, 4)).toBe(true)
|
||||
})
|
||||
it('respects the min bound by month (inclusive)', () => {
|
||||
expect(isMonthInRange(2026, 4, '2026-05-10')).toBe(true) // mai chevauche
|
||||
expect(isMonthInRange(2026, 3, '2026-05-10')).toBe(false) // avril < mai
|
||||
})
|
||||
it('respects the max bound by month (inclusive)', () => {
|
||||
expect(isMonthInRange(2026, 4, undefined, '2026-05-31')).toBe(true)
|
||||
expect(isMonthInRange(2026, 5, undefined, '2026-05-31')).toBe(false) // juin > mai
|
||||
})
|
||||
it('disables months in years outside the range', () => {
|
||||
expect(isMonthInRange(2025, 11, '2026-05-10')).toBe(false)
|
||||
expect(isMonthInRange(2027, 0, undefined, '2026-05-31')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isYearInRange', () => {
|
||||
it('returns true when no bounds are given', () => {
|
||||
expect(isYearInRange(2026)).toBe(true)
|
||||
})
|
||||
it('respects the min bound by year (inclusive)', () => {
|
||||
expect(isYearInRange(2026, '2026-05-10')).toBe(true)
|
||||
expect(isYearInRange(2025, '2026-05-10')).toBe(false)
|
||||
})
|
||||
it('respects the max bound by year (inclusive)', () => {
|
||||
expect(isYearInRange(2026, undefined, '2026-05-31')).toBe(true)
|
||||
expect(isYearInRange(2027, undefined, '2026-05-31')).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/dateFormat.test.ts`
|
||||
Expected: FAIL — `isMonthInRange is not a function` / `isYearInRange is not a function`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Ajouter à la fin de `dateFormat.ts` :
|
||||
```ts
|
||||
export function isMonthInRange(year: number, month: number, min?: string, max?: string): boolean {
|
||||
const ym = `${year}-${String(month + 1).padStart(2, '0')}`
|
||||
if (min && ym < min.slice(0, 7)) return false
|
||||
if (max && ym > max.slice(0, 7)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isYearInRange(year: number, min?: string, max?: string): boolean {
|
||||
if (min && year < Number(min.slice(0, 4))) return false
|
||||
if (max && year > Number(max.slice(0, 4))) return false
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/dateFormat.test.ts`
|
||||
Expected: PASS (tous les blocs, anciens + nouveaux).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/composables/dateFormat.ts app/components/malio/date/composables/dateFormat.test.ts
|
||||
git commit -m "feat : helpers isMonthInRange/isYearInRange (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Machine à états 3 vues (`useCalendarPopover.ts`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/composables/useCalendarPopover.ts`
|
||||
- Test: `app/components/malio/date/composables/useCalendarPopover.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: rien.
|
||||
- Produces (retour du composable) : `{isOpen, viewMode, open, close, goToHigherView}` où `viewMode: Ref<'days' | 'months' | 'years'>` et `goToHigherView(): void`. **Remplace `toggleView`.**
|
||||
|
||||
- [ ] **Step 1: Update the failing test**
|
||||
|
||||
Dans `useCalendarPopover.test.ts`, remplacer le test `toggleView() switches between days and months` (lignes ~33-40) par :
|
||||
```ts
|
||||
it('goToHigherView() climbs days -> months -> years and stops', () => {
|
||||
const {api} = mountHost()
|
||||
api.open()
|
||||
api.goToHigherView()
|
||||
expect(api.viewMode.value).toBe('months')
|
||||
api.goToHigherView()
|
||||
expect(api.viewMode.value).toBe('years')
|
||||
api.goToHigherView()
|
||||
expect(api.viewMode.value).toBe('years') // no-op au niveau le plus haut
|
||||
})
|
||||
```
|
||||
Et dans le test `close() resets isOpen and viewMode` (lignes ~42-49), remplacer `api.toggleView()` par `api.goToHigherView()`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts`
|
||||
Expected: FAIL — `api.goToHigherView is not a function`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `useCalendarPopover.ts`, remplacer la ligne du ref et la fonction `toggleView` :
|
||||
```ts
|
||||
const viewMode = ref<'days' | 'months' | 'years'>('days')
|
||||
```
|
||||
```ts
|
||||
const goToHigherView = () => {
|
||||
if (viewMode.value === 'days') viewMode.value = 'months'
|
||||
else if (viewMode.value === 'months') viewMode.value = 'years'
|
||||
// 'years' : niveau le plus haut, no-op
|
||||
}
|
||||
```
|
||||
Et le `return` :
|
||||
```ts
|
||||
return {isOpen, viewMode, open, close, goToHigherView}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/useCalendarPopover.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/composables/useCalendarPopover.ts app/components/malio/date/composables/useCalendarPopover.test.ts
|
||||
git commit -m "feat : viewMode 3 niveaux + goToHigherView (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Navigation & fenêtre d'années (`useCalendarView.ts`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/composables/useCalendarView.ts`
|
||||
- Test: `app/components/malio/date/composables/useCalendarView.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `viewMode: Ref<'days' | 'months' | 'years'>` (depuis Task 2).
|
||||
- Produces (retour) : `{currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}` où `yearPageStart: Ref<number>` et `selectYear(y: number): void`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Dans `useCalendarView.test.ts` : ajouter l'import de `nextTick` en tête :
|
||||
```ts
|
||||
import {nextTick, ref} from 'vue'
|
||||
```
|
||||
Puis ajouter ces tests dans le `describe('useCalendarView', …)` :
|
||||
```ts
|
||||
it('paginates years by 12 in years view', () => {
|
||||
const {yearPageStart, goToNext, goToPrev} = useCalendarView(ref('years'))
|
||||
const start = yearPageStart.value
|
||||
goToNext()
|
||||
expect(yearPageStart.value).toBe(start + 12)
|
||||
goToPrev()
|
||||
expect(yearPageStart.value).toBe(start)
|
||||
})
|
||||
|
||||
it('selectYear sets the current year', () => {
|
||||
const {currentYear, selectYear} = useCalendarView(ref('days'))
|
||||
selectYear(2030)
|
||||
expect(currentYear.value).toBe(2030)
|
||||
})
|
||||
|
||||
it('recenters the year page on entering years view (current - 5)', async () => {
|
||||
const mode = ref<'days' | 'months' | 'years'>('days')
|
||||
const {yearPageStart} = useCalendarView(mode)
|
||||
mode.value = 'years'
|
||||
await nextTick()
|
||||
expect(yearPageStart.value).toBe(2021) // 2026 - 5
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/useCalendarView.test.ts`
|
||||
Expected: FAIL — `yearPageStart` / `selectYear` undefined.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `useCalendarView.ts` :
|
||||
|
||||
Élargir la signature et importer `watch` :
|
||||
```ts
|
||||
import {ref, watch, type Ref} from 'vue'
|
||||
import {isValidIso} from './dateFormat'
|
||||
|
||||
export function useCalendarView(viewMode: Ref<'days' | 'months' | 'years'>) {
|
||||
const today = new Date()
|
||||
const currentMonth = ref(today.getMonth())
|
||||
const currentYear = ref(today.getFullYear())
|
||||
const yearPageStart = ref(today.getFullYear() - 5)
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'years') yearPageStart.value = currentYear.value - 5
|
||||
})
|
||||
```
|
||||
|
||||
Dans `goToPrev`, ajouter la branche `years` en tête :
|
||||
```ts
|
||||
const goToPrev = () => {
|
||||
if (viewMode.value === 'years') {
|
||||
yearPageStart.value -= 12
|
||||
return
|
||||
}
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value -= 1
|
||||
return
|
||||
}
|
||||
if (currentMonth.value === 0) {
|
||||
currentMonth.value = 11
|
||||
currentYear.value -= 1
|
||||
} else {
|
||||
currentMonth.value -= 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dans `goToNext`, idem :
|
||||
```ts
|
||||
const goToNext = () => {
|
||||
if (viewMode.value === 'years') {
|
||||
yearPageStart.value += 12
|
||||
return
|
||||
}
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value += 1
|
||||
return
|
||||
}
|
||||
if (currentMonth.value === 11) {
|
||||
currentMonth.value = 0
|
||||
currentYear.value += 1
|
||||
} else {
|
||||
currentMonth.value += 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ajouter `selectYear` après `selectMonth` :
|
||||
```ts
|
||||
const selectYear = (y: number) => {
|
||||
currentYear.value = y
|
||||
}
|
||||
```
|
||||
|
||||
Mettre à jour le `return` :
|
||||
```ts
|
||||
return {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/useCalendarView.test.ts`
|
||||
Expected: PASS (anciens + nouveaux).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/composables/useCalendarView.ts app/components/malio/date/composables/useCalendarView.test.ts
|
||||
git commit -m "feat : pagination des années + selectYear + recentrage (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Header contextuel (`CalendarHeader.vue`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/internal/CalendarHeader.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `viewMode`, `currentMonth`, `currentYear`, `yearPageStart` (props).
|
||||
- Produces: émet `prev` / `next` / `toggle-view` (inchangé). En vue `years`, le bouton libellé n'émet pas `toggle-view` et ne montre pas le chevron-bas.
|
||||
|
||||
> Pas de test colocalisé pour `CalendarHeader` (l'existant n'en a pas) — vérifié via l'e2e en Task 8. Ce Task est purement implémentation ; pas de cycle test propre, donc on le commit avec son cycle de vérification = build/lint + e2e en Task 8. Le step de vérif ci-dessous est un contrôle visuel du diff.
|
||||
|
||||
- [ ] **Step 1: Implement the contextual label and years behavior**
|
||||
|
||||
Remplacer le `<template>` par :
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-prev"
|
||||
class="ml-2 flex self-start rounded"
|
||||
:aria-label="prevLabel"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:chevron-left"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-toggle"
|
||||
class="flex gap-1 rounded text-base font-medium"
|
||||
@click="viewMode !== 'years' && emit('toggle-view')"
|
||||
>
|
||||
<span class="mt-[2px]">{{ label }}</span>
|
||||
<Icon
|
||||
v-if="viewMode !== 'years'"
|
||||
icon="mdi:chevron-down"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-next"
|
||||
class="mr-2 flex self-start rounded"
|
||||
:aria-label="nextLabel"
|
||||
@click="emit('next')"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:chevron-right"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Dans le `<script setup>` : élargir le type `viewMode`, ajouter `yearPageStart`, et calculer `label` + `prevLabel` + `nextLabel` :
|
||||
```ts
|
||||
const props = defineProps<{
|
||||
viewMode: 'days' | 'months' | 'years'
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
yearPageStart: number
|
||||
}>()
|
||||
```
|
||||
```ts
|
||||
const label = computed(() => {
|
||||
if (props.viewMode === 'years') return `${props.yearPageStart} – ${props.yearPageStart + 11}`
|
||||
if (props.viewMode === 'months') return `${props.currentYear}`
|
||||
const name = monthsLong[props.currentMonth]
|
||||
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
|
||||
})
|
||||
|
||||
const prevLabel = computed(() =>
|
||||
props.viewMode === 'days' ? 'Mois précédent'
|
||||
: props.viewMode === 'months' ? 'Année précédente'
|
||||
: 'Période précédente',
|
||||
)
|
||||
const nextLabel = computed(() =>
|
||||
props.viewMode === 'days' ? 'Mois suivant'
|
||||
: props.viewMode === 'months' ? 'Année suivante'
|
||||
: 'Période suivante',
|
||||
)
|
||||
```
|
||||
(Supprimer les anciens `:aria-label` ternaires inline du template — remplacés par `prevLabel`/`nextLabel`.)
|
||||
|
||||
- [ ] **Step 2: Lint the file**
|
||||
|
||||
Run: `npx eslint app/components/malio/date/internal/CalendarHeader.vue`
|
||||
Expected: aucune erreur.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/internal/CalendarHeader.vue
|
||||
git commit -m "feat : header contextuel jours/mois/années (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Composant `YearPicker.vue` + tests
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/malio/date/internal/YearPicker.vue`
|
||||
- Test: `app/components/malio/date/internal/YearPicker.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `isYearInRange` (Task 1).
|
||||
- Produces: composant `<YearPicker :page-start :selected-year? :min? :max? @select="(year:number)=>…" />`. Rend 12 boutons `data-test="year"` avec `data-year`. Conteneur `data-test="year-picker"`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Créer `app/components/malio/date/internal/YearPicker.test.ts` :
|
||||
```ts
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import YearPicker from './YearPicker.vue'
|
||||
|
||||
const mountPicker = (props: {pageStart: number, selectedYear?: number, min?: string, max?: string}) =>
|
||||
mount(YearPicker, {props})
|
||||
|
||||
describe('MalioDateYearPicker', () => {
|
||||
it('renders 12 years from pageStart', () => {
|
||||
const wrapper = mountPicker({pageStart: 2021})
|
||||
const years = wrapper.findAll('[data-test="year"]')
|
||||
expect(years).toHaveLength(12)
|
||||
expect(years[0].attributes('data-year')).toBe('2021')
|
||||
expect(years[11].attributes('data-year')).toBe('2032')
|
||||
})
|
||||
|
||||
it('emits select with the clicked year', async () => {
|
||||
const wrapper = mountPicker({pageStart: 2021})
|
||||
await wrapper.get('[data-test="year"][data-year="2026"]').trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]).toEqual([2026])
|
||||
})
|
||||
|
||||
it('disables years outside [min, max] and does not emit', async () => {
|
||||
const wrapper = mountPicker({pageStart: 2021, min: '2025-01-01', max: '2027-12-31'})
|
||||
const out = wrapper.get('[data-test="year"][data-year="2024"]')
|
||||
expect(out.attributes('disabled')).toBeDefined()
|
||||
await out.trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('highlights the selected year', () => {
|
||||
const wrapper = mountPicker({pageStart: 2021, selectedYear: 2026})
|
||||
const span = wrapper.get('[data-test="year"][data-year="2026"] span')
|
||||
expect(span.classes()).toContain('bg-m-primary')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/internal/YearPicker.test.ts`
|
||||
Expected: FAIL — impossible de résoudre `./YearPicker.vue`.
|
||||
|
||||
- [ ] **Step 3: Write the component**
|
||||
|
||||
Créer `app/components/malio/date/internal/YearPicker.vue` :
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
data-test="year-picker"
|
||||
class="grid grid-cols-3 gap-3"
|
||||
>
|
||||
<button
|
||||
v-for="year in years"
|
||||
:key="year"
|
||||
type="button"
|
||||
data-test="year"
|
||||
:data-year="year"
|
||||
:disabled="!isYearInRange(year, min, max)"
|
||||
:aria-disabled="!isYearInRange(year, min, max)"
|
||||
class="flex h-[45px] w-full items-center justify-center"
|
||||
:class="isYearInRange(year, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||
@click="emit('select', year)"
|
||||
>
|
||||
<span
|
||||
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||
:class="year === selectedYear
|
||||
? 'bg-m-primary text-white'
|
||||
: isYearInRange(year, min, max)
|
||||
? 'text-black hover:bg-m-primary/10'
|
||||
: 'text-m-muted/30'"
|
||||
>
|
||||
{{ year }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {isYearInRange} from '../composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDateYearPicker'})
|
||||
|
||||
const props = defineProps<{
|
||||
pageStart: number
|
||||
selectedYear?: number
|
||||
min?: string
|
||||
max?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{(e: 'select', year: number): void}>()
|
||||
|
||||
const years = computed(() => Array.from({length: 12}, (_, i) => props.pageStart + i))
|
||||
</script>
|
||||
```
|
||||
|
||||
> Note : `@click` émet toujours, mais un `<button disabled>` ne déclenche pas l'événement clic dans jsdom/navigateur — le test « does not emit » le valide.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/internal/YearPicker.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/internal/YearPicker.vue app/components/malio/date/internal/YearPicker.test.ts
|
||||
git commit -m "feat : composant YearPicker avec bornage min/max (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Bornage min/max du `MonthPicker` + tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/internal/MonthPicker.vue`
|
||||
- Test (create): `app/components/malio/date/internal/MonthPicker.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `isMonthInRange` (Task 1).
|
||||
- Produces: `<MonthPicker :selected-month? :current-year :min? :max? @select="(month:number)=>…" />`. `currentYear` devient **requis** (nécessaire pour borner les mois).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Créer `app/components/malio/date/internal/MonthPicker.test.ts` :
|
||||
```ts
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
|
||||
const mountPicker = (props: {currentYear: number, selectedMonth?: number, min?: string, max?: string}) =>
|
||||
mount(MonthPicker, {props})
|
||||
|
||||
describe('MalioDateMonthPicker', () => {
|
||||
it('renders 12 months', () => {
|
||||
const wrapper = mountPicker({currentYear: 2026})
|
||||
expect(wrapper.findAll('[data-test="month"]')).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('emits select with the clicked month index', async () => {
|
||||
const wrapper = mountPicker({currentYear: 2026})
|
||||
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('disables months before min in the current year and does not emit', async () => {
|
||||
const wrapper = mountPicker({currentYear: 2026, min: '2026-05-01'})
|
||||
const april = wrapper.get('[data-test="month"][data-month="3"]')
|
||||
expect(april.attributes('disabled')).toBeDefined()
|
||||
await april.trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables months after max in the current year', () => {
|
||||
const wrapper = mountPicker({currentYear: 2026, max: '2026-05-31'})
|
||||
expect(wrapper.get('[data-test="month"][data-month="5"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-test="month"][data-month="4"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/internal/MonthPicker.test.ts`
|
||||
Expected: FAIL — le mois avril n'est pas désactivé (`disabled` undefined).
|
||||
|
||||
- [ ] **Step 3: Update the component**
|
||||
|
||||
Remplacer `MonthPicker.vue` par :
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
data-test="month-picker"
|
||||
class="grid grid-cols-3 gap-3"
|
||||
>
|
||||
<button
|
||||
v-for="(name, index) in months"
|
||||
:key="name"
|
||||
type="button"
|
||||
data-test="month"
|
||||
:data-month="index"
|
||||
:disabled="!isMonthInRange(currentYear, index, min, max)"
|
||||
:aria-disabled="!isMonthInRange(currentYear, index, min, max)"
|
||||
class="flex h-[45px] w-full items-center justify-center"
|
||||
:class="isMonthInRange(currentYear, index, min, max) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||
@click="emit('select', index)"
|
||||
>
|
||||
<span
|
||||
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||
:class="index === selectedMonth
|
||||
? 'bg-m-primary text-white'
|
||||
: isMonthInRange(currentYear, index, min, max)
|
||||
? 'text-black hover:bg-m-primary/10'
|
||||
: 'text-m-muted/30'"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {isMonthInRange} from '../composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDateMonthPicker'})
|
||||
|
||||
defineProps<{
|
||||
currentYear: number
|
||||
selectedMonth?: number
|
||||
min?: string
|
||||
max?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{(e: 'select', month: number): void}>()
|
||||
|
||||
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/internal/MonthPicker.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/internal/MonthPicker.vue app/components/malio/date/internal/MonthPicker.test.ts
|
||||
git commit -m "feat : bornage min/max du MonthPicker (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Câblage `CalendarField.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/internal/CalendarField.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useCalendarPopover` (`goToHigherView`), `useCalendarView` (`yearPageStart`, `selectYear`), `CalendarHeader` (prop `yearPageStart`), `MonthPicker` (props `currentYear`/`min`/`max`), `YearPicker` (Task 5).
|
||||
- Produces: `CalendarField` accepte les props `min?: string` et `max?: string` ; rend les 3 vues.
|
||||
|
||||
> Vérifié via l'e2e en Task 8 (pas de test colocalisé propre à `CalendarField`). Steps = implémentation + lint, le cycle test rouge/vert vit en Task 8.
|
||||
|
||||
- [ ] **Step 1: Add min/max props**
|
||||
|
||||
Dans le bloc `defineProps`, ajouter `min` et `max` (après `reserveMessageSpace?: boolean`) :
|
||||
```ts
|
||||
reserveMessageSpace?: boolean
|
||||
min?: string
|
||||
max?: string
|
||||
```
|
||||
Et dans `withDefaults({...})`, après `reserveMessageSpace: true,` :
|
||||
```ts
|
||||
reserveMessageSpace: true,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Import YearPicker and update composable destructuring**
|
||||
|
||||
Après `import MonthPicker from './MonthPicker.vue'` :
|
||||
```ts
|
||||
import YearPicker from './YearPicker.vue'
|
||||
```
|
||||
Remplacer les deux lignes de destructuration (≈218-219) :
|
||||
```ts
|
||||
const {isOpen, viewMode, open, close: closePopover, goToHigherView} = useCalendarPopover(root)
|
||||
const {currentMonth, currentYear, yearPageStart, goToPrev, goToNext, selectMonth, selectYear, syncToIso} = useCalendarView(viewMode)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the popover template**
|
||||
|
||||
Remplacer le bloc `<CalendarHeader>` + `<slot>` + `<MonthPicker>` (≈85-103) par :
|
||||
```vue
|
||||
<CalendarHeader
|
||||
:view-mode="viewMode"
|
||||
:current-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
:year-page-start="yearPageStart"
|
||||
@prev="goToPrev"
|
||||
@next="goToNext"
|
||||
@toggle-view="goToHigherView"
|
||||
/>
|
||||
<slot
|
||||
v-if="viewMode === 'days'"
|
||||
:current-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
:close="closePopover"
|
||||
/>
|
||||
<MonthPicker
|
||||
v-else-if="viewMode === 'months'"
|
||||
:selected-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="onSelectMonth"
|
||||
/>
|
||||
<YearPicker
|
||||
v-else
|
||||
:page-start="yearPageStart"
|
||||
:selected-year="currentYear"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="onSelectYear"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update handlers**
|
||||
|
||||
`goToHigherView` ne sert qu'au header (zoom arrière). À la **sélection**, on redescend d'un niveau. `useCalendarPopover` n'expose pas de setter de vue, mais `viewMode` est un `ref` déstructuré et donc directement mutable. Remplacer `onSelectMonth` (≈324-327) par les deux handlers suivants :
|
||||
```ts
|
||||
const onSelectMonth = (m: number) => {
|
||||
selectMonth(m)
|
||||
viewMode.value = 'days'
|
||||
}
|
||||
|
||||
const onSelectYear = (y: number) => {
|
||||
selectYear(y)
|
||||
viewMode.value = 'months'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lint**
|
||||
|
||||
Run: `npx eslint app/components/malio/date/internal/CalendarField.vue`
|
||||
Expected: aucune erreur (notamment, `viewMode` est bien mutable car c'est un `ref`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/internal/CalendarField.vue
|
||||
git commit -m "feat : intégration du sélecteur d'année dans CalendarField (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Binder min/max chez les consommateurs + e2e
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/Date.vue`
|
||||
- Modify: `app/components/malio/date/DateRange.vue`
|
||||
- Modify: `app/components/malio/date/DateTime.vue`
|
||||
- Modify: `app/components/malio/date/DateWeek.vue`
|
||||
- Test: `app/components/malio/date/Date.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CalendarField` props `min`/`max` (Task 7).
|
||||
- Produces: les 4 composants propagent leurs bornes au popover. API publique inchangée.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Dans `Date.test.ts`, à l'intérieur du `describe('vue mois', …)` (ou un nouveau `describe('vue années')`), ajouter :
|
||||
```ts
|
||||
describe('vue années', () => {
|
||||
it('opens the year picker on second header toggle', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> mois
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click') // -> années
|
||||
expect(wrapper.find('[data-test="year-picker"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2021 – 2032')
|
||||
})
|
||||
|
||||
it('navigates days -> months -> years -> months -> days', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="year"][data-year="2024"]').trigger('click') // -> mois 2024
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2024')
|
||||
await wrapper.get('[data-test="month"][data-month="0"]').trigger('click') // -> jours
|
||||
expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2024')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('paginates the year window with chevrons', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2033 – 2044')
|
||||
})
|
||||
|
||||
it('greys out years outside [min, max]', async () => {
|
||||
const wrapper = mountDate({min: '2025-01-01', max: '2027-12-31'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-toggle"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="year"][data-year="2024"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-test="year"][data-year="2026"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/Date.test.ts`
|
||||
Expected: FAIL — `year-picker` introuvable (Date.vue ne passe pas encore `min`/`max` à `CalendarField`).
|
||||
|
||||
- [ ] **Step 3: Bind min/max on each consumer's `<CalendarField>`**
|
||||
|
||||
Dans `Date.vue`, `DateRange.vue`, `DateWeek.vue` : ajouter sur la balise racine `<CalendarField …>` (au même niveau que les autres props, ex. après `:readonly="readonly"`) :
|
||||
```vue
|
||||
:min="min"
|
||||
:max="max"
|
||||
```
|
||||
Dans `DateTime.vue` : ajouter (les bornes y sont des `YYYY-MM-DDTHH:MM`, tronquer à la date) :
|
||||
```vue
|
||||
:min="min?.slice(0, 10)"
|
||||
:max="max?.slice(0, 10)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/Date.test.ts`
|
||||
Expected: PASS (anciens + nouveaux).
|
||||
|
||||
- [ ] **Step 5: Run the whole date suite (non-régression)**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/`
|
||||
Expected: PASS — `Date`, `DateRange`, `DateTime`, `DateWeek`, composables, pickers. (En cas de timeout flaky WSL2, relancer ; voir Global Constraints.)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/Date.vue app/components/malio/date/DateRange.vue app/components/malio/date/DateTime.vue app/components/malio/date/DateWeek.vue app/components/malio/date/Date.test.ts
|
||||
git commit -m "feat : propage min/max au popover + e2e sélecteur d'année (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Documentation (`COMPONENTS.md`, `CHANGELOG.md`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: comportement livré aux tasks 1-8.
|
||||
- Produces: doc à jour (convention projet : maj manuelle).
|
||||
|
||||
- [ ] **Step 1: Locate the date section in COMPONENTS.md**
|
||||
|
||||
Run: `grep -nE "MalioDate|Date|calendrier|sélecteur" COMPONENTS.md | head -20`
|
||||
Expected: repère la section calendrier/date. Si aucune entrée, l'ajouter à la suite des autres composants date.
|
||||
|
||||
- [ ] **Step 2: Document the 3-level navigation**
|
||||
|
||||
Ajouter (dans la section du calendrier date) une description du 3ᵉ niveau :
|
||||
```markdown
|
||||
Le calendrier propose trois niveaux de navigation : jours → clic sur l'en-tête →
|
||||
sélecteur de mois → nouveau clic sur l'en-tête → sélecteur d'année (grille de 12 ans,
|
||||
chevrons par pas de 12 ans). Les props `min`/`max` grisent les mois et les années hors
|
||||
plage. Sélectionner une année revient au sélecteur de mois, sélectionner un mois revient
|
||||
à la grille de jours.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add a CHANGELOG entry**
|
||||
|
||||
Sous la section non publiée (ou en tête, selon le format existant — vérifier `head -20 CHANGELOG.md`) :
|
||||
```markdown
|
||||
- Calendrier (Date/DateRange/DateTime/DateWeek) : sélecteur d'année (3ᵉ niveau) et
|
||||
grisage des mois/années hors `min`/`max`.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs : sélecteur d'année dans le calendrier (#date-year-picker)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes de vérification finale
|
||||
|
||||
- Suite ciblée complète : `npx vitest run app/components/malio/date/`
|
||||
- Lint global : `npm run lint`
|
||||
- Vérif manuelle navigateur : **proposer** à l'utilisateur (ne pas lancer le MCP Chrome sans accord — coût tokens). Parcours : ouvrir un `MalioDate`, cliquer le header 2× → grille d'années, paginer, choisir une année puis un mois, et tester un champ avec `min`/`max` pour voir le grisage.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
# Sélecteur d'année dans le calendrier (3ᵉ niveau de navigation)
|
||||
|
||||
**Date :** 2026-06-22
|
||||
**Statut :** Design validé
|
||||
**Composants concernés :** famille `date/` (`Date`, `DateRange`, `DateTime`, `DateWeek`) via le shell partagé `internal/CalendarField.vue`
|
||||
|
||||
## Contexte & objectif
|
||||
|
||||
Aujourd'hui le calendrier a deux vues : la grille de jours (`days`) et le sélecteur de
|
||||
mois (`months`). En cliquant sur le libellé « Mai 2026 » du header, on bascule entre les
|
||||
deux (`toggleView`).
|
||||
|
||||
On ajoute un **3ᵉ niveau** : depuis la vue mois, recliquer sur le header ouvre un
|
||||
**sélecteur d'année**, visuellement calqué sur le sélecteur de mois. Le tout doit
|
||||
fonctionner pour les 4 composants de la famille sans casser leur API publique.
|
||||
|
||||
### Flux cible
|
||||
|
||||
| Vue courante | Header affiche | Clic header → | Clic sur une cellule → |
|
||||
|---|---|---|---|
|
||||
| `days` | « Mai 2026 » | `months` | (jour) sélection + fermeture |
|
||||
| `months` | « 2026 » | `years` | `days` (mois choisi) |
|
||||
| `years` | « 2020 – 2031 »| _rien_ (niveau le plus haut) | `months` (année choisie) |
|
||||
|
||||
## Décisions de design (validées)
|
||||
|
||||
1. **Grille d'années** : 12 ans en `grid-cols-3` (4 lignes), calquée sur `MonthPicker`,
|
||||
avec chevrons de pagination `±12 ans`.
|
||||
2. **Header contextuel** : libellé adapté à la vue (voir tableau). Chevron-bas masqué en
|
||||
vue `years` (clic neutralisé).
|
||||
3. **min/max respectés** : le sélecteur d'année **grise** les années hors `[min, max]`,
|
||||
ET on **corrige** le `MonthPicker` existant pour qu'il grise aussi les mois hors
|
||||
plage (asymétrie actuelle : aujourd'hui tous les mois sont cliquables).
|
||||
4. **Cadrage de la fenêtre d'années** : centrée, `yearPageStart = currentYear − 5`
|
||||
(fenêtre `[courante−5 … courante+6]`), l'année courante apparaît ~au milieu.
|
||||
|
||||
## Architecture
|
||||
|
||||
Le shell `CalendarField` orchestre l'input, le popover, le header et la commutation de
|
||||
vues. `MonthPicker` et le nouveau `YearPicker` sont rendus **dans** `CalendarField`
|
||||
(contrairement à la grille de jours, fournie par chaque consommateur via slot scoped).
|
||||
|
||||
### 1. Machine à états des vues — `composables/useCalendarPopover.ts`
|
||||
|
||||
- `viewMode` : `'days' | 'months'` → **`'days' | 'months' | 'years'`**.
|
||||
- Remplacer `toggleView()` par **`goToHigherView()`** (zoom arrière) :
|
||||
`days → months`, `months → years`, `years → years` (no-op).
|
||||
- `open()` / `close()` repartent en `days` (inchangé).
|
||||
|
||||
### 2. Navigation & fenêtre d'années — `composables/useCalendarView.ts`
|
||||
|
||||
- Élargir le type `viewMode` à `'days' | 'months' | 'years'`.
|
||||
- Nouveau ref **`yearPageStart`**.
|
||||
- `watch(viewMode)` : à l'entrée en `years`, recentrer `yearPageStart = currentYear − 5`.
|
||||
- `goToPrev` / `goToNext` : branche `years` → `yearPageStart ∓ 12`
|
||||
(`months` → année ±1, `days` → mois ±1 : inchangés).
|
||||
- Nouveau **`selectYear(y)`** → `currentYear = y`.
|
||||
|
||||
### 3. Header — `internal/CalendarHeader.vue`
|
||||
|
||||
- Props : ajouter `yearPageStart: number`, élargir `viewMode`.
|
||||
- `label` calculé :
|
||||
- `days` → « Mai 2026 »
|
||||
- `months` → « 2026 »
|
||||
- `years` → `` `${yearPageStart} – ${yearPageStart + 11}` ``
|
||||
- Chevron-bas (`mdi:chevron-down`) masqué en vue `years` ; le bouton n'émet
|
||||
`toggle-view` que si `viewMode !== 'years'`.
|
||||
- aria-labels prev/next adaptés : « Période précédente/suivante » en vue `years`.
|
||||
|
||||
### 4. Nouveau composant — `internal/YearPicker.vue`
|
||||
|
||||
Calque de `MonthPicker` :
|
||||
- Props : `pageStart: number`, `selectedYear?: number`, `min?: string`, `max?: string`.
|
||||
- `years = Array.from({length: 12}, (_, i) => pageStart + i)`.
|
||||
- Pour chaque année : `disabled = !isYearInRange(year, min, max)` → `disabled`,
|
||||
`aria-disabled`, style muet (`text-m-muted/30`, `cursor-not-allowed`), pas d'émission.
|
||||
- Année sélectionnée : `bg-m-primary text-white` (comme le mois sélectionné).
|
||||
- Émet `select(year: number)`.
|
||||
- `defineOptions({name: 'MalioDateYearPicker'})`.
|
||||
- Attributs de test : `data-test="year-picker"`, `data-test="year"`, `data-year`.
|
||||
|
||||
### 5. Correction `MonthPicker` — `internal/MonthPicker.vue`
|
||||
|
||||
- Props : ajouter `currentYear: number`, `min?: string`, `max?: string`.
|
||||
- Pour chaque mois `index` : `disabled = !isMonthInRange(currentYear, index, min, max)`
|
||||
→ même traitement disabled que `YearPicker`.
|
||||
|
||||
### 6. Helpers purs — `composables/dateFormat.ts`
|
||||
|
||||
Comparaison par préfixe ISO (les chaînes ISO se comparent lexicographiquement) :
|
||||
|
||||
```ts
|
||||
export function isMonthInRange(year: number, month: number, min?: string, max?: string): boolean {
|
||||
const ym = `${year}-${String(month + 1).padStart(2, '0')}`
|
||||
if (min && ym < min.slice(0, 7)) return false
|
||||
if (max && ym > max.slice(0, 7)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isYearInRange(year: number, min?: string, max?: string): boolean {
|
||||
if (min && year < Number(min.slice(0, 4))) return false
|
||||
if (max && year > Number(max.slice(0, 4))) return false
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Câblage — `internal/CalendarField.vue` + consommateurs
|
||||
|
||||
- `CalendarField` : ajouter props `min?: string`, `max?: string`.
|
||||
- Récupérer `yearPageStart`, `selectYear`, `goToHigherView` des composables.
|
||||
- Template du popover :
|
||||
- `<CalendarHeader … :year-page-start="yearPageStart" @toggle-view="goToHigherView" />`
|
||||
- slot jours `v-if="viewMode === 'days'"` (inchangé)
|
||||
- `<MonthPicker v-else-if="viewMode === 'months'" :selected-month :current-year :min :max @select="onSelectMonth" />`
|
||||
- `<YearPicker v-else :page-start="yearPageStart" :selected-year="currentYear" :min :max @select="onSelectYear" />`
|
||||
- Handlers :
|
||||
- `onSelectMonth(m)` → `selectMonth(m)` puis vue `days`.
|
||||
- `onSelectYear(y)` → `selectYear(y)` puis vue `months`.
|
||||
- Les 4 consommateurs bindent `:min` / `:max` sur `<CalendarField>` (déjà disponibles
|
||||
comme props ISO ; `DateTime` tronque via `.slice(0, 10)`). **Aucune API publique ne
|
||||
change.**
|
||||
|
||||
## Effets de bord & cohérence
|
||||
|
||||
- `month-change` (émis sur `[isOpen, currentMonth, currentYear]`) : la pagination
|
||||
d'années ne touche pas `currentYear` → pas d'émission parasite. Sélectionner une année
|
||||
change `currentYear` → un `month-change` est émis (comportement attendu : le
|
||||
consommateur peut recharger les données).
|
||||
- Pas de navigation clavier dans les grilles (le `MonthPicker` actuel n'en a pas non
|
||||
plus) — hors scope.
|
||||
- `Escape` ferme le popover quelle que soit la vue (inchangé).
|
||||
|
||||
## Plan de tests (TDD)
|
||||
|
||||
- `dateFormat.test.ts` : `isMonthInRange`, `isYearInRange` (bornes, sans min/max, hors
|
||||
plage par année et par mois).
|
||||
- `useCalendarPopover.test.ts` : nouveau cycle `goToHigherView` (`days→months→years→years`),
|
||||
`close()` réinitialise à `days`.
|
||||
- `useCalendarView.test.ts` : nav années `yearPageStart ±12`, `selectYear`, recentrage à
|
||||
l'entrée en vue `years`.
|
||||
- `MonthPicker.test.ts` : mois hors plage grisés / non émis.
|
||||
- `YearPicker.test.ts` (nouveau) : rend 12 années depuis `pageStart`, année sélectionnée,
|
||||
années hors plage grisées, émet `select`.
|
||||
- Non-régression : `Date.test.ts`, `DateRange.test.ts`, `DateTime.test.ts`,
|
||||
`DateWeek.test.ts` doivent rester verts ; ajouter au moins un test de bout en bout du
|
||||
flux `days → months → years → months → days` dans `Date.test.ts`.
|
||||
- Déterminisme : `vi.setSystemTime(new Date(2026, 4, 19))` comme l'existant.
|
||||
|
||||
## Livrables doc (convention projet)
|
||||
|
||||
- Maj manuelle de `COMPONENTS.md` (documenter le 3ᵉ niveau du calendrier).
|
||||
- Entrée `CHANGELOG.md`.
|
||||
|
||||
## Non-objectifs (YAGNI)
|
||||
|
||||
- Pas de sélecteur de décennie/siècle (4ᵉ niveau).
|
||||
- Pas de saisie clavier directe de l'année dans la grille.
|
||||
- Pas de navigation au clavier (flèches) dans les grilles mois/années.
|
||||
@@ -0,0 +1,170 @@
|
||||
# MalioRadioGroup — conception
|
||||
|
||||
## Problème
|
||||
|
||||
`RadioButton.vue` porte le message (`error`/`success`/`hint`) sur **chaque** radio et
|
||||
masque tous les messages sauf le dernier via un hack CSS
|
||||
(`.radio-item:has(+ .radio-item) .radio-message { display: none }`). Conséquences :
|
||||
|
||||
- impossible d'aligner un groupe de radios sur un `MalioSelect` (cercles centrés sur la
|
||||
box, message sur la même ligne que le message du select) ;
|
||||
- pas de `reserveMessageSpace` alors que c'est la convention de tous les autres champs
|
||||
(Select, Input*, Date, Time, Checkbox) ;
|
||||
- l'affichage « un seul message » est un effet de bord CSS fragile.
|
||||
|
||||
Toutes les libs matures (MUI `RadioGroup`/`FormHelperText`, Vuetify `v-radio-group`,
|
||||
Element Plus `el-radio-group`, Ant Design `Radio.Group`) résolvent ça avec un **parent
|
||||
de groupe** qui possède la valeur, le `name` partagé et l'unique message. Le codebase a
|
||||
déjà ce précédent : `Accordion` + `AccordionItem` via `provide/inject` et un
|
||||
`context.ts`.
|
||||
|
||||
## Décisions
|
||||
|
||||
1. **Nouveau composant parent `MalioRadioGroup`** (les `RadioButton` deviennent des
|
||||
inputs simples). `RadioButton` reste utilisable seul.
|
||||
2. **API enfants** : prop `:options` (principal, cohérent avec `MalioSelect`) **+ slot
|
||||
par défaut** en repli (cas custom).
|
||||
3. **Label de groupe** : prop `label` **optionnelle**, rendue au-dessus en `<legend>`
|
||||
(accessibilité). Omise → le groupe s'aligne directement avec un select.
|
||||
|
||||
## Architecture
|
||||
|
||||
Calquée sur `Accordion`/`AccordionItem`.
|
||||
|
||||
### `app/components/malio/radio/context.ts` (nouveau)
|
||||
|
||||
```ts
|
||||
import type {InjectionKey} from 'vue'
|
||||
|
||||
export type RadioValue = string | number | boolean | null | undefined
|
||||
|
||||
export interface RadioGroupContext {
|
||||
name: ComputedRef<string>
|
||||
selectedValue: ComputedRef<RadioValue>
|
||||
isSelected: (value: RadioValue) => boolean
|
||||
select: (value: RadioValue) => void
|
||||
hasError: ComputedRef<boolean>
|
||||
hasSuccess: ComputedRef<boolean>
|
||||
disabled: ComputedRef<boolean>
|
||||
readonly: ComputedRef<boolean>
|
||||
required: ComputedRef<boolean>
|
||||
describedBy: ComputedRef<string | undefined>
|
||||
}
|
||||
|
||||
export const radioGroupContextKey: InjectionKey<RadioGroupContext> =
|
||||
Symbol('MalioRadioGroup')
|
||||
```
|
||||
|
||||
### `app/components/malio/radio/RadioGroup.vue` (nouveau)
|
||||
|
||||
`defineOptions({name: 'MalioRadioGroup', inheritAttrs: false})`.
|
||||
|
||||
**Props**
|
||||
|
||||
| prop | type | défaut | rôle |
|
||||
|------|------|--------|------|
|
||||
| `modelValue` | `RadioValue` | `undefined` | valeur sélectionnée (contrôlé/non-contrôlé) |
|
||||
| `options` | `{label, value, disabled?}[]` | `[]` | radios déclaratifs |
|
||||
| `label` | `string` | `''` | legend optionnelle au-dessus |
|
||||
| `name` | `string` | auto (`useId`) | `name` partagé des inputs |
|
||||
| `inline` | `boolean` | `false` | orientation horizontale |
|
||||
| `disabled` / `readonly` / `required` | `boolean` | `false` | propagés au groupe |
|
||||
| `hint` / `error` / `success` | `string` | `''` | message unique |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | réserve `min-h-[1rem]` (comme Select) |
|
||||
| `groupClass` / `inputClass` / `labelClass` | `string` | `''` | overrides `twMerge` |
|
||||
|
||||
**Rendu**
|
||||
|
||||
```html
|
||||
<div :class="mergedGroupClass">
|
||||
<span v-if="label" :id="`${groupId}-label`" :class="mergedLabelClass">
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</span>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
:aria-labelledby="label ? `${groupId}-label` : undefined"
|
||||
:aria-invalid="hasError || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:class="contentClass"
|
||||
>
|
||||
<!-- options -->
|
||||
<MalioRadioButton
|
||||
v-for="opt in options" :key="..."
|
||||
:label="opt.label" :value="opt.value" :disabled="opt.disabled"
|
||||
/>
|
||||
<!-- ou slot custom -->
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="reserveMessageSpace || hasError || hasSuccess || hint"
|
||||
:id="`${groupId}-describedby`"
|
||||
:class="messageClass" <!-- identique au Select : 'mt-1 ml-[2px] text-xs' (+min-h-[1rem]) -->
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- `contentClass` : `inline` → `flex flex-wrap items-center gap-x-6 min-h-10` ;
|
||||
empilé → `flex flex-col`. Le `min-h-10` fait coïncider la rangée de radios avec la box
|
||||
d'un `MalioSelect` (h-10), donc les cercles se centrent sur la box du select.
|
||||
- `messageClass` reprend **exactement** le markup du message de `Select.vue`
|
||||
(`mt-1 ml-[2px] text-xs`, couleur `text-m-danger`/`text-m-success`/`text-m-muted`,
|
||||
`min-h-[1rem]` si `reserveMessageSpace`) → alignement automatique avec le message du
|
||||
select voisin.
|
||||
- `v-model` géré ici : `select(value)` émet `update:modelValue` (et tient `localValue`
|
||||
en non-contrôlé).
|
||||
- `provide(radioGroupContextKey, …)`.
|
||||
|
||||
### `app/components/malio/radio/RadioButton.vue` (modifié)
|
||||
|
||||
- `const group = inject(radioGroupContextKey, null)`.
|
||||
- **Dans un groupe** :
|
||||
- `name` = `group.name` ; `isChecked` = `group.isSelected(value)` ;
|
||||
- styling erreur/succès (cercles rouges/verts) piloté par `group.hasError`/`hasSuccess` ;
|
||||
- `disabled`/`readonly`/`required` = groupe **OU** prop locale ;
|
||||
- au `change` → `group.select(value)` (au lieu d'`emit('update:modelValue')`) ;
|
||||
- **n'affiche aucun message** (`shouldShowMessage = false`) ;
|
||||
- `aria-describedby` = `group.describedBy`.
|
||||
- **Hors groupe** : comportement actuel inchangé.
|
||||
- **Supprimer** le CSS `.radio-item:has(+ .radio-item) .radio-message { display: none }`
|
||||
(remplacé par le vrai groupe).
|
||||
|
||||
## Usage cible (remplace le hack du formulaire client)
|
||||
|
||||
```html
|
||||
<MalioRadioGroup
|
||||
v-model="prestationChoice"
|
||||
:options="prestationOptions"
|
||||
inline
|
||||
error="Sélection requise"
|
||||
/>
|
||||
```
|
||||
|
||||
Les cercles s'alignent sur la box du `MalioSelect` voisin et le message sur le message
|
||||
du select, sans réglage manuel.
|
||||
|
||||
## Tests
|
||||
|
||||
- `RadioGroup.test.ts` (nouveau) : rendu options + slot, `v-model` (contrôlé/non),
|
||||
message unique, `reserveMessageSpace`, état erreur propagé aux enfants (`aria-invalid`,
|
||||
classe `is-error`), `inline`, `disabled`/`readonly`/`required`, a11y
|
||||
(`role=radiogroup`, `aria-labelledby`, `aria-describedby`).
|
||||
- `RadioButton.test.ts` : ajuster — vérifier le mode groupé (inject) et conserver le
|
||||
mode standalone. Retirer le test du hack CSS s'il existe.
|
||||
|
||||
## Documentation & playground
|
||||
|
||||
- `app/story/radio/RadioGroup.story.vue` (Histoire).
|
||||
- Page playground : variantes du groupe + entrée nav (`playground.nav.ts`).
|
||||
- Revert `client.vue` vers `MalioRadioGroup` (retirer `<style scoped>` et le `<p>`
|
||||
manuel).
|
||||
- Mise à jour manuelle de `COMPONENTS.md` + `CHANGELOG.md`.
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Pas de groupe de checkbox (séparé).
|
||||
- Pas de validation/form-state global.
|
||||
- Pas de layout en grille interne au groupe (inline / empilé suffisent).
|
||||
Reference in New Issue
Block a user