refactor : Date.vue devient une enveloppe du shell CalendarField (#MUI-33)

This commit is contained in:
2026-05-20 11:53:42 +02:00
parent 19a1bb5e50
commit beb0e32b7e

View File

@@ -1,113 +1,41 @@
<template> <template>
<div> <CalendarField
<div :id="id"
ref="root" :display-value="displayValue"
:class="mergedGroupClass" :sync-to="modelValue ?? null"
>
<input
:id="inputId"
:name="name" :name="name"
data-test="date-input" :label="label"
readonly :placeholder="placeholder"
autocomplete="off"
:class="mergedInputClass"
:required="required" :required="required"
:disabled="disabled" :disabled="disabled"
:value="displayValue" :readonly="readonly"
:aria-invalid="!!error" :hint="hint"
:aria-describedby="describedBy" :error="error"
:aria-expanded="isOpen" :success="success"
aria-haspopup="dialog" :clearable="clearable"
v-bind="attrs" :input-class="inputClass"
placeholder="_" :label-class="labelClass"
type="text" :group-class="groupClass"
@click="onFieldClick" v-bind="$attrs"
@clear="emit('update:modelValue', null)"
> >
<template #default="{ currentMonth, currentYear, close }">
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-black"
aria-label="Effacer la date"
@click.stop="onClear"
>
<Icon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="onPrev"
@next="onNext"
@toggle-view="toggleView"
/>
<MonthGrid <MonthGrid
v-if="viewMode === 'days'"
:month="currentMonth" :month="currentMonth"
:year="currentYear" :year="currentYear"
:selected-date="modelValue ?? null" :selected-date="modelValue ?? null"
:min="min" :min="min"
:max="max" :max="max"
@select="onSelectDay" @select="(iso) => { emit('update:modelValue', iso); close() }"
/> />
<MonthPicker </template>
v-else </CalendarField>
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</div>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, useAttrs, useId, watch} from 'vue' import {computed, watch} from 'vue'
import {Icon} from '@iconify/vue' import CalendarField from './internal/CalendarField.vue'
import {twMerge} from 'tailwind-merge'
import CalendarHeader from './internal/CalendarHeader.vue'
import MonthGrid from './internal/MonthGrid.vue' import MonthGrid from './internal/MonthGrid.vue'
import MonthPicker from './internal/MonthPicker.vue'
import {useCalendarPopover} from './composables/useCalendarPopover'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat' import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', inheritAttrs: false}) defineOptions({name: 'MalioDate', inheritAttrs: false})
@@ -155,143 +83,11 @@ const props = withDefaults(
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>() const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const {isOpen, viewMode, open, close, toggleView} = useCalendarPopover(root)
const today = new Date()
const currentMonth = ref(today.getMonth())
const currentYear = ref(today.getFullYear())
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null)) const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
const isFilled = computed(() => displayValue.value.length > 0)
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
const syncViewToValue = () => {
const iso = props.modelValue
if (iso && isValidIso(iso)) {
currentMonth.value = Number(iso.slice(5, 7)) - 1
currentYear.value = Number(iso.slice(0, 4))
} else {
const now = new Date()
currentMonth.value = now.getMonth()
currentYear.value = now.getFullYear()
}
}
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
if (val && !isValidIso(val) && import.meta.dev) { if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`) console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
} }
if (isOpen.value) syncViewToValue()
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
close()
return
}
syncViewToValue()
open()
}
const onClear = () => emit('update:modelValue', null)
const onSelectDay = (iso: string) => {
emit('update:modelValue', iso)
close()
}
const onSelectMonth = (month: number) => {
currentMonth.value = month
toggleView()
}
const onPrev = () => {
if (viewMode.value === 'months') {
currentYear.value -= 1
return
}
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
const onNext = () => {
if (viewMode.value === 'months') {
currentYear.value += 1
return
}
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
}) })
</script> </script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>