fix: accessibilité des composants (#70)
Release / release (push) Successful in 1m9s

| 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:
2026-06-09 15:40:44 +00:00
committed by Autin
parent 1131420960
commit 9f772a84ed
41 changed files with 3111 additions and 98 deletions
+93
View File
@@ -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')
})
})
})
+42 -5
View File
@@ -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,
),
)