refactor : Date.vue devient une enveloppe du shell CalendarField (#MUI-33)
This commit is contained in:
@@ -1,113 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="root"
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
>
|
||||
|
||||
<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
|
||||
v-if="viewMode === 'days'"
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:selected-date="modelValue ?? null"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="onSelectDay"
|
||||
/>
|
||||
<MonthPicker
|
||||
v-else
|
||||
: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>
|
||||
<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="error"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="emit('update:modelValue', null)"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:selected-date="modelValue ?? null"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import CalendarHeader from './internal/CalendarHeader.vue'
|
||||
import {computed, watch} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import MonthPicker from './internal/MonthPicker.vue'
|
||||
import {useCalendarPopover} from './composables/useCalendarPopover'
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
||||
@@ -155,143 +83,11 @@ const props = withDefaults(
|
||||
|
||||
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 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) => {
|
||||
if (val && !isValidIso(val) && import.meta.dev) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.floating-label {
|
||||
background: white;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user