| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: matthieu <matthieu@yuno.malio.fr> Reviewed-on: #70 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #70.
This commit is contained in:
@@ -18,6 +18,8 @@ type DateProps = {
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -258,4 +260,95 @@ describe('MalioDate', () => {
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await wrapper.setProps({modelValue: '2026-05-19'})
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('readonly')).toBeDefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await input.trigger('focus')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,14 +10,16 @@
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="error"
|
||||
:error="mergedError"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:editable="editable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="emit('update:modelValue', null)"
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
@@ -26,17 +28,17 @@
|
||||
:selected-date="modelValue ?? null"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, watch} from 'vue'
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
||||
|
||||
@@ -56,6 +58,8 @@ const props = withDefaults(
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -75,6 +79,8 @@ const props = withDefaults(
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
clearable: true,
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
@@ -85,7 +91,38 @@ const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>
|
||||
|
||||
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
||||
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
const onCommit = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIso(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
internalError.value = props.invalidMessage
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
close()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
internalError.value = ''
|
||||
if (val && !isValidIso(val) && import.meta.dev) {
|
||||
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="maskaOptions"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
readonly
|
||||
:readonly="inputReadonly"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="displayValue"
|
||||
:value="editable ? draft : displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
@@ -22,6 +23,10 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
@focus="onFocus(); onKbdFocus()"
|
||||
@input="onInput"
|
||||
@blur="onBlur(); onKbdBlur()"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -37,7 +42,7 @@
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="text-m-muted hover:text-m-primary"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
aria-label="Effacer la date"
|
||||
@click.stop="emit('clear')"
|
||||
>
|
||||
@@ -61,6 +66,7 @@
|
||||
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)]"
|
||||
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
|
||||
>
|
||||
<CalendarHeader
|
||||
:view-mode="viewMode"
|
||||
@@ -102,14 +108,19 @@
|
||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import {vMaska} from 'maska/vue'
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
import MalioRequiredMark from '../../shared/RequiredMark.vue'
|
||||
import CalendarHeader from './CalendarHeader.vue'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||
import {useCalendarView} from '../composables/useCalendarView'
|
||||
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
displayValue: string
|
||||
@@ -125,6 +136,7 @@ const props = withDefaults(
|
||||
error?: string
|
||||
success?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -142,6 +154,7 @@ const props = withDefaults(
|
||||
error: '',
|
||||
success: '',
|
||||
clearable: true,
|
||||
editable: false,
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
@@ -149,19 +162,32 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear' | 'close'): void
|
||||
(e: 'commit', value: string): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const draft = ref(props.displayValue)
|
||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
|
||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||
|
||||
watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
|
||||
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
||||
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isFilled = computed(() => props.displayValue.length > 0)
|
||||
const isFilled = computed(() =>
|
||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||
@@ -176,6 +202,13 @@ watch(isOpen, (value) => {
|
||||
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.editable) {
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
@@ -184,6 +217,56 @@ const onFieldClick = () => {
|
||||
open()
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (props.disabled || props.readonly || !props.editable) return
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
draft.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
closePopover()
|
||||
}
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (isOpen.value) {
|
||||
e.preventDefault()
|
||||
closePopover()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.editable) {
|
||||
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onFieldClick()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.syncTo, (value) => {
|
||||
if (isOpen.value) syncToIso(value)
|
||||
})
|
||||
@@ -210,6 +293,7 @@ const mergedInputClass = computed(() =>
|
||||
? 'border-m-success'
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user