Files
malio-layer-ui/app/components/malio/date/Date.vue
T
tristan 41010060ff feat : sélecteur d'année dans le calendrier (3ᵉ niveau) (#83)
## Sélecteur d'année dans le calendrier (3ᵉ niveau de navigation)

Ajoute un 3ᵉ niveau de navigation à la famille de composants date, et corrige le bornage min/max du sélecteur de mois.

### Comportement
- Clic sur le champ → calendrier (vue **jours**)
- Clic sur l'en-tête → **sélecteur de mois**
- **Re-clic sur l'en-tête → sélecteur d'année** (grille de 12 ans, chevrons paginant par pas de 12 ans, fenêtre centrée sur l'année courante − 5)
- Clic sur une année → retour au sélecteur de mois ; clic sur un mois → retour à la grille de jours
- Les props `min`/`max` **grisent les mois ET les années** hors plage (corrige l'asymétrie : le `MonthPicker` affichait jusqu'ici tous les mois)

En-tête contextuel : « Mai 2026 » (jours) / « 2026 » (mois) / « 2020 – 2031 » (années).

### Périmètre
- Shell partagé `internal/CalendarField.vue` → bénéficie aux 4 composants publics `Date`, `DateRange`, `DateTime`, `DateWeek`
- **Aucune API publique modifiée**
- Nouveau composant `internal/YearPicker.vue` (calqué sur `MonthPicker`)
- Helpers purs `isMonthInRange` / `isYearInRange` (comparaison par préfixe ISO, bornes inclusives)
- State machine `viewMode` à 3 niveaux (`useCalendarPopover` / `useCalendarView`)

### Tests
- Suite date **246/246 verte**, ESLint propre
- Unitaires : helpers, `YearPicker`, `MonthPicker` (grisage), composables (pagination ±12, recentrage, `selectYear`)
- e2e `Date.test.ts` : flux complet jours→mois→années→mois→jours + grisage min/max

### Process
Développé en brainstorming → spec → plan → exécution TDD (un commit par étape). Spec et plan inclus sous `docs/superpowers/`.

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

Reviewed-on: #83
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-22 09:28:55 +00:00

162 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"
:min="min"
:max="max"
: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>