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:
@@ -17,6 +17,7 @@ type DateProps = {
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
markedDates?: Record<string, 'success' | 'danger'>
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
@@ -109,6 +110,48 @@ describe('MalioDate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('month-change', () => {
|
||||
it('émet month-change à l\'ouverture avec le mois courant', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 4, year: 2026}])
|
||||
})
|
||||
|
||||
it('émet month-change sur le mois de la valeur à l\'ouverture', async () => {
|
||||
const wrapper = mountDate({modelValue: '2025-12-25'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 11, year: 2025}])
|
||||
})
|
||||
|
||||
it('émet month-change à chaque navigation de mois', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-next"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 5, year: 2026}])
|
||||
await wrapper.get('[data-test="header-prev"]').trigger('click')
|
||||
await wrapper.get('[data-test="header-prev"]').trigger('click')
|
||||
expect(wrapper.emitted('month-change')?.at(-1)).toEqual([{month: 3, year: 2026}])
|
||||
})
|
||||
|
||||
it('ne ré-émet pas month-change après fermeture', async () => {
|
||||
const wrapper = mountDate()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const countOpen = wrapper.emitted('month-change')?.length ?? 0
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('month-change')?.length ?? 0).toBe(countOpen)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markedDates', () => {
|
||||
it('transmet markedDates à la grille (fond tokenisé)', async () => {
|
||||
const wrapper = mountDate({markedDates: {'2026-05-20': 'success'}})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const pill = wrapper.get('[data-iso="2026-05-20"]').get('span.rounded-full')
|
||||
expect(pill.classes()).toContain('bg-m-success/15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sélection', () => {
|
||||
it('emits the ISO date and closes on day click', async () => {
|
||||
const wrapper = mountDate()
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
v-bind="$attrs"
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
@month-change="(payload) => emit('month-change', payload)"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:selected-date="modelValue ?? null"
|
||||
:marked-dates="markedDates"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
@@ -57,6 +59,9 @@ const props = withDefaults(
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
// Statut générique par jour, ISO yyyy-mm-dd → variante de fond. Aucune
|
||||
// logique métier dans le layer : le consommateur fournit la liste.
|
||||
markedDates?: Record<string, 'success' | 'danger'>
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
@@ -78,6 +83,7 @@ const props = withDefaults(
|
||||
success: '',
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
markedDates: undefined,
|
||||
clearable: true,
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
@@ -94,6 +100,8 @@ const emit = defineEmits<{
|
||||
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
|
||||
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
|
||||
(e: 'update:rawValue', value: string): void
|
||||
// Mois affiché dans le popover (month 0-11) : à l'ouverture et à chaque nav.
|
||||
(e: 'month-change', value: {month: number, year: number}): void
|
||||
}>()
|
||||
|
||||
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
||||
|
||||
@@ -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%]')
|
||||
|
||||
Reference in New Issue
Block a user