feat(ui) : MalioDate — markedDates (statut par jour) + event month-change (#MUI-45) (#76)

## MUI-45 — MalioDate : statut par jour (`markedDates`) + event `@month-change`

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

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

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

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

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

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

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

Reviewed-on: #76
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #76.
This commit is contained in:
2026-06-16 09:40:28 +00:00
committed by Autin
parent cca15524f4
commit b4841f40ed
9 changed files with 225 additions and 2 deletions
@@ -180,6 +180,9 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'clear' | 'close'): void
(e: 'commit', value: string): void
// Mois affiché (month 0-11) : émis à l'ouverture du popover et à chaque
// navigation, pour qu'un consommateur (ex. SIRH) charge les données du mois.
(e: 'month-change', value: {month: number, year: number}): void
}>()
const attrs = useAttrs()
@@ -229,6 +232,12 @@ watch(isOpen, (value) => {
if (!value) emit('close')
})
// Émet le mois affiché tant que le popover est ouvert : une fois à l'ouverture
// (isOpen → true, après syncToIso), puis à chaque changement de mois/année.
watch([isOpen, currentMonth, currentYear], () => {
if (isOpen.value) emit('month-change', {month: currentMonth.value, year: currentYear.value})
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (props.editable) {
@@ -0,0 +1,72 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import type {DefineComponent} from 'vue'
import MonthGrid from './MonthGrid.vue'
type MonthGridProps = {
month: number
year: number
selectedDate?: string | null
markedDates?: Record<string, 'success' | 'danger'>
min?: string
max?: string
}
const Grid = MonthGrid as DefineComponent<MonthGridProps>
const mountGrid = (props: MonthGridProps) => mount(Grid, {props, attachTo: document.body})
// Récupère la pastille (span rond) qui porte les classes de `cellClass` pour un jour donné.
const pill = (wrapper: ReturnType<typeof mountGrid>, iso: string) =>
wrapper.get(`[data-iso="${iso}"]`).get('span.rounded-full')
describe('MalioDateMonthGrid — markedDates', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
})
afterEach(() => vi.useRealTimers())
it('applique un fond success sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
expect(pill(wrapper, '2026-05-20').classes()).toContain('bg-m-success/15')
})
it('applique un fond danger sur un jour marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-21': 'danger'}})
expect(pill(wrapper, '2026-05-21').classes()).toContain('bg-m-danger/15')
})
it('ne marque pas les jours absents de markedDates', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).not.toContain('bg-m-success/15')
expect(classes).not.toContain('bg-m-danger/15')
})
it('précédence : la sélection (primary) prime sur la variante marquée', () => {
const wrapper = mountGrid({
month: 4,
year: 2026,
selectedDate: '2026-05-22',
markedDates: {'2026-05-22': 'success'},
})
const classes = pill(wrapper, '2026-05-22').classes()
expect(classes).toContain('bg-m-primary')
expect(classes).toContain('text-white')
expect(classes).not.toContain('bg-m-success/15')
})
it('today marqué : garde sa bordure ET reçoit le fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-19': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).toContain('bg-m-success/15')
})
it('today non marqué : bordure sans fond marqué', () => {
const wrapper = mountGrid({month: 4, year: 2026, markedDates: {'2026-05-20': 'success'}})
const classes = pill(wrapper, '2026-05-19').classes()
expect(classes).toContain('border-m-primary')
expect(classes).not.toContain('bg-m-success/15')
})
})
@@ -84,6 +84,14 @@ import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composable
defineOptions({name: 'MalioDateMonthGrid'})
// Statut générique par jour : aucune sémantique métier dans le layer, juste un
// fond tokenisé. `success` et `danger` suffisent pour l'instant (MUI-45).
type MarkedVariant = 'success' | 'danger'
const markedBg: Record<MarkedVariant, string> = {
success: 'bg-m-success/15',
danger: 'bg-m-danger/15',
}
const props = withDefaults(
defineProps<{
month: number
@@ -94,6 +102,7 @@ const props = withDefaults(
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
markedDates?: Record<string, MarkedVariant>
min?: string
max?: string
}>(),
@@ -104,6 +113,7 @@ const props = withDefaults(
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
markedDates: undefined,
min: undefined,
max: undefined,
},
@@ -165,6 +175,10 @@ const cellClass = (cell: DayCell) => {
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
if (role === 'in-range') return 'text-black'
const parts = ['hover:bg-m-primary/10']
// Précédence : sélection/range (primary, return ci-dessus) > variante marquée > défaut.
// `today` n'est pas exclusif : il garde sa bordure ET peut recevoir le fond marqué.
const marked = props.markedDates?.[cell.isoDate]
if (marked) parts.push(markedBg[marked])
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('opacity-[60%]')