b4841f40ed
## 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>
160 lines
4.4 KiB
Vue
160 lines
4.4 KiB
Vue
<template>
|
|
<CalendarField
|
|
:id="id"
|
|
:display-value="displayValue"
|
|
:sync-to="modelValue ?? null"
|
|
:name="name"
|
|
:label="label"
|
|
:placeholder="placeholder"
|
|
:required="required"
|
|
:disabled="disabled"
|
|
:readonly="readonly"
|
|
:hint="hint"
|
|
:error="mergedError"
|
|
:success="success"
|
|
:clearable="clearable"
|
|
:editable="editable"
|
|
:input-class="inputClass"
|
|
:label-class="labelClass"
|
|
:group-class="groupClass"
|
|
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)"
|
|
/>
|
|
</template>
|
|
</CalendarField>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, ref, watch} from 'vue'
|
|
import CalendarField from './internal/CalendarField.vue'
|
|
import MonthGrid from './internal/MonthGrid.vue'
|
|
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
|
|
|
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
id?: string
|
|
name?: string
|
|
label?: string
|
|
modelValue?: string | null
|
|
placeholder?: string
|
|
required?: boolean
|
|
disabled?: boolean
|
|
readonly?: boolean
|
|
hint?: string
|
|
error?: string
|
|
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
|
|
inputClass?: string
|
|
labelClass?: string
|
|
groupClass?: string
|
|
}>(),
|
|
{
|
|
id: '',
|
|
name: '',
|
|
label: '',
|
|
modelValue: undefined,
|
|
placeholder: 'JJ/MM/AAAA',
|
|
required: false,
|
|
disabled: false,
|
|
readonly: false,
|
|
hint: '',
|
|
error: '',
|
|
success: '',
|
|
min: undefined,
|
|
max: undefined,
|
|
markedDates: undefined,
|
|
clearable: true,
|
|
editable: false,
|
|
invalidMessage: 'Date invalide',
|
|
inputClass: '',
|
|
labelClass: '',
|
|
groupClass: '',
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', value: string | null): void
|
|
(e: 'update:valid', value: boolean): void
|
|
// Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut
|
|
// tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter
|
|
// par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip.
|
|
(e: 'update:rawValue', value: string): void
|
|
// Mois affiché dans le popover (month 0-11) : à l'ouverture et à chaque nav.
|
|
(e: 'month-change', value: {month: number, year: number}): void
|
|
}>()
|
|
|
|
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
|
|
|
const internalError = ref('')
|
|
const mergedError = computed(() => props.error || internalError.value)
|
|
|
|
// La validité ne reflète que la saisie : malformée/hors plage → false. Un champ
|
|
// vide est valide (l'obligation `required` reste à la charge du parent).
|
|
const setError = (message: string) => {
|
|
internalError.value = message
|
|
emit('update:valid', message === '')
|
|
}
|
|
|
|
const onCommit = (text: string) => {
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') {
|
|
setError('')
|
|
emit('update:rawValue', '')
|
|
emit('update:modelValue', null)
|
|
return
|
|
}
|
|
const iso = parseDisplayToIso(trimmed)
|
|
if (iso && isDateInRange(iso, props.min, props.max)) {
|
|
setError('')
|
|
emit('update:rawValue', '')
|
|
emit('update:modelValue', iso)
|
|
return
|
|
}
|
|
setError(props.invalidMessage)
|
|
emit('update:rawValue', trimmed)
|
|
}
|
|
|
|
const onClear = () => {
|
|
setError('')
|
|
emit('update:rawValue', '')
|
|
emit('update:modelValue', null)
|
|
}
|
|
|
|
const onSelect = (iso: string, close: () => void) => {
|
|
setError('')
|
|
emit('update:rawValue', '')
|
|
emit('update:modelValue', iso)
|
|
close()
|
|
}
|
|
|
|
// immediate : émet aussi la validité au montage, pour que le parent connaisse
|
|
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
|
|
watch(() => props.modelValue, (val) => {
|
|
setError('')
|
|
if (val && !isValidIso(val) && import.meta.dev) {
|
|
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
|
}
|
|
}, {immediate: true})
|
|
</script>
|