| 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:
@@ -2,6 +2,41 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
/* Anneau de focus clavier standard (navigation au Tab), invisible à la souris.
|
||||
Deux déclencheurs, même rendu :
|
||||
- .m-focus-ring → s'appuie sur :focus-visible natif. Pour les éléments
|
||||
où :focus-visible se limite déjà au clavier (boutons,
|
||||
onglets, tuiles, checkbox/radio…).
|
||||
- .m-focus-ring-kbd → classe ajoutée en JS (via useKbdFocusRing) uniquement
|
||||
quand le focus vient du clavier. Pour les champs texte,
|
||||
où :focus-visible natif se déclenche aussi à la souris.
|
||||
Le `:focus` sur .m-focus-ring-kbd élève la spécificité pour passer devant le
|
||||
`outline-none` des inputs. */
|
||||
.m-focus-ring:focus-visible,
|
||||
.m-focus-ring-kbd:focus {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
|
||||
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
|
||||
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
|
||||
reste sans contour pour un raccord sans couture. */
|
||||
.m-combo-ring-top {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 -2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
.m-combo-ring-bottom {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Globales ── */
|
||||
|
||||
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
||||
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 m-focus-ring',
|
||||
variantClasses.value,
|
||||
props.buttonClass,
|
||||
),
|
||||
|
||||
@@ -52,7 +52,7 @@ const isFilled = computed(() => props.variant === 'filled')
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 m-focus-ring',
|
||||
isFilled.value
|
||||
? props.disabled
|
||||
? 'bg-m-disabled text-white cursor-not-allowed'
|
||||
|
||||
@@ -180,6 +180,11 @@ const onChange = (event: Event) => {
|
||||
border-color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.inp-cbx:focus-visible + .cbx span:first-child {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cbx span:first-child svg {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Prev"
|
||||
label="Préc."
|
||||
:disabled="page <= 1"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page précédente"
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Next"
|
||||
label="Suiv."
|
||||
:disabled="page >= totalPages"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page suivante"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue('12.5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
||||
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||
})
|
||||
|
||||
it('accepts commas but normalizes them to dots', async () => {
|
||||
@@ -106,7 +106,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue('0012,345abc')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
|
||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
||||
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||
})
|
||||
|
||||
it('normalizes a leading decimal separator', async () => {
|
||||
@@ -115,7 +115,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue(',5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
||||
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||
})
|
||||
|
||||
it('keeps the normalized decimal value on blur', async () => {
|
||||
@@ -126,7 +126,7 @@ describe('MalioInputAmount', () => {
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
|
||||
expect(input.element.value).toBe('12.5')
|
||||
expect(input.element.value).toBe('12,5')
|
||||
})
|
||||
|
||||
it('keeps integer values unchanged on blur', async () => {
|
||||
@@ -230,4 +230,52 @@ describe('MalioInputAmount', () => {
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||
})
|
||||
|
||||
it('groupe un grand montant avec décimales', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567,89')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('12345')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('maxLength autorise une valeur à la limite', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('1234')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||
})
|
||||
|
||||
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||
const wrapper = mountInputAmount({maxLength: 4})
|
||||
|
||||
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
:autocomplete="autocomplete"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:minlength="minLength"
|
||||
:disabled="disabled"
|
||||
:value="currentValue"
|
||||
:value="formattedValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
@@ -21,7 +20,7 @@
|
||||
inputmode="decimal"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="onBlur"
|
||||
>
|
||||
|
||||
@@ -66,9 +65,13 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
|
||||
|
||||
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -126,6 +129,7 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
@@ -145,6 +149,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -190,40 +195,37 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const normalizeAmount = (value: string) => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Keep the DOM input value, local state, and v-model emission in sync.
|
||||
const updateValue = (target: HTMLInputElement, value: string) => {
|
||||
target.value = value
|
||||
if (!isControlled.value) {
|
||||
localValue.value = value
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Normalize while typing so the field never keeps invalid amount characters.
|
||||
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
updateValue(target, normalizeAmount(target.value))
|
||||
const rawText = target.value
|
||||
const caret = target.selectionStart ?? rawText.length
|
||||
const model = normalizeAmount(rawText)
|
||||
|
||||
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
|
||||
if (props.maxLength != null && model.length > Number(props.maxLength)) {
|
||||
target.value = formattedValue.value
|
||||
const restored = Math.min(Math.max(0, caret - 1), formattedValue.value.length)
|
||||
target.setSelectionRange(restored, restored)
|
||||
return
|
||||
}
|
||||
|
||||
const display = formatGroupedAmount(model)
|
||||
const sig = countSignificant(rawText, caret)
|
||||
target.value = display
|
||||
const newCaret = caretFromSignificant(display, sig)
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = model
|
||||
}
|
||||
emit('update:modelValue', model)
|
||||
}
|
||||
|
||||
// Keep the blur handler only for focus-driven UI state.
|
||||
const onBlur = () => {
|
||||
isFocused.value = false
|
||||
onKbdBlur()
|
||||
}
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onKbdBlur"
|
||||
@click="onInputClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
@@ -90,6 +91,7 @@
|
||||
: hasSuccess
|
||||
? 'border-m-success select-scrollbar-success'
|
||||
: 'border-m-primary select-scrollbar-primary',
|
||||
keyboardFocused ? 'm-combo-ring-bottom' : '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -150,13 +152,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: string | number
|
||||
@@ -321,6 +326,7 @@ const labelPositionClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -400,6 +406,7 @@ const onInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
onKbdFocus()
|
||||
if (props.disabled || props.readonly) return
|
||||
isFocused.value = true
|
||||
isOpen.value = true
|
||||
@@ -446,7 +453,20 @@ const closeAndRevert = () => {
|
||||
isFocused.value = false
|
||||
}
|
||||
|
||||
// Garde l'option active visible dans la liste défilante quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
// Tab : laisse le focus partir mais ferme la liste (et valide la saisie courante)
|
||||
if (event.key === 'Tab') {
|
||||
if (isOpen.value) closeAndCommit()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeAndRevert()
|
||||
@@ -479,7 +499,25 @@ const onKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
// Liste fermée : ouvre et place sur la dernière option (APG)
|
||||
if (!isOpen.value) {
|
||||
isOpen.value = true
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
return
|
||||
}
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// Home / End : première / dernière option quand la liste est ouverte
|
||||
if (isOpen.value && event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (isOpen.value && event.key === 'End') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ type InputEmailProps = {
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
lowercase?: boolean
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
@@ -315,4 +318,78 @@ describe('MalioInputEmail', () => {
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('does not render add button by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders add button when addable is true', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits add event when add button is clicked', async () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit add when disabled', async () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not emit add when readonly', async () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables add button when disabled', () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the email icon to the left automatically when addable', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
const icon = wrapper.get('[data-test="icon"]')
|
||||
expect(icon.classes()).toContain('left-[10px]')
|
||||
expect(icon.classes()).not.toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('keeps the email icon on the right when addable is false', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('uses the default add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
|
||||
})
|
||||
|
||||
it('allows overriding the add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
type="email"
|
||||
inputmode="email"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -40,6 +40,23 @@
|
||||
:class="[iconStateClass, iconPositionClass]"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
@@ -65,9 +82,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -88,6 +108,9 @@ const props = withDefaults(
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
lowercase?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
@@ -110,6 +133,9 @@ const props = withDefaults(
|
||||
success: '',
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
addable: false,
|
||||
addIconName: 'mdi:plus',
|
||||
addButtonLabel: 'Ajouter une adresse email',
|
||||
lowercase: false,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
@@ -141,6 +167,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -177,6 +204,14 @@ const mergedLabelClass = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
|
||||
const describedBy = computed(() => {
|
||||
const ids: string[] = []
|
||||
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||
@@ -187,6 +222,7 @@ const describedBy = computed(() => {
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
|
||||
const sanitizeEmail = (v: string) => {
|
||||
@@ -222,25 +258,38 @@ const onInput = (event: Event) => {
|
||||
emit('update:modelValue', sanitized)
|
||||
}
|
||||
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
|
||||
const effectiveIconPosition = computed(() =>
|
||||
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||
)
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
if (!props.iconName) return ''
|
||||
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
|
||||
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isMinusDisabled"
|
||||
@click="decrement"
|
||||
>
|
||||
@@ -35,11 +36,12 @@
|
||||
inputmode="numeric"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isPlusDisabled"
|
||||
@click="increment"
|
||||
>
|
||||
@@ -73,9 +75,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -184,6 +189,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
placeholder="_"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -70,9 +70,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -147,6 +150,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -85,9 +85,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -167,6 +170,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -69,9 +69,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -150,6 +153,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
textInput,
|
||||
showCounterComputed ? 'pb-6' : '',
|
||||
rounded,
|
||||
keyboardFocused ? 'm-focus-ring-kbd' : '',
|
||||
]"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
@@ -32,8 +33,8 @@
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
/>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -89,9 +90,12 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
|
||||
@@ -26,8 +26,10 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="openFilePicker"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@keydown.enter.prevent="openFilePicker"
|
||||
@keydown.space.prevent="openFilePicker"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -38,17 +40,33 @@
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<IconifyIcon
|
||||
v-if="displayIcon"
|
||||
icon="mdi:cloud-arrow-up-outline"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="icon"
|
||||
:class="[
|
||||
iconStateClass,
|
||||
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
v-if="displayIcon || showClear"
|
||||
class="absolute right-[10px] top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||
>
|
||||
<button
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
aria-label="Retirer le fichier"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:close"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<IconifyIcon
|
||||
v-if="displayIcon"
|
||||
icon="mdi:cloud-arrow-up-outline"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="icon"
|
||||
:class="[iconStateClass, 'pointer-events-none']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<p
|
||||
@@ -75,9 +93,12 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -94,6 +115,7 @@ const props = withDefaults(
|
||||
displayIcon?: boolean
|
||||
accept?: string
|
||||
required?: boolean
|
||||
clearable?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
@@ -111,6 +133,7 @@ const props = withDefaults(
|
||||
displayIcon: true,
|
||||
accept: '',
|
||||
required: false,
|
||||
clearable: false,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
@@ -143,6 +166,7 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md cursor-pointer',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -153,7 +177,9 @@ const mergedInputClass = computed(() =>
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
props.displayIcon ? '!pr-10' : '',
|
||||
showClear.value
|
||||
? (props.displayIcon ? '!pr-16' : '!pr-10')
|
||||
: (props.displayIcon ? '!pr-10' : ''),
|
||||
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
disabled.value ? 'cursor-not-allowed' : '',
|
||||
@@ -191,8 +217,21 @@ const describedBy = computed(() => {
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'file-selected', file: File): void
|
||||
(event: 'clear'): void
|
||||
}>()
|
||||
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !isReadonly.value,
|
||||
)
|
||||
|
||||
const onClear = () => {
|
||||
if (props.disabled || isReadonly.value) return
|
||||
if (!isControlled.value) localValue.value = ''
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
fileInputRef.value?.click()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
|
||||
|
||||
describe('normalizeAmount', () => {
|
||||
it('garde le point décimal', () => {
|
||||
expect(normalizeAmount('12.5')).toBe('12.5')
|
||||
})
|
||||
it('convertit la virgule en point et nettoie', () => {
|
||||
expect(normalizeAmount('0012,345abc')).toBe('12.34')
|
||||
})
|
||||
it('normalise une décimale en tête', () => {
|
||||
expect(normalizeAmount(',5')).toBe('0.5')
|
||||
})
|
||||
it('retire les espaces', () => {
|
||||
expect(normalizeAmount('1 234 567')).toBe('1234567')
|
||||
})
|
||||
it('limite à 2 décimales', () => {
|
||||
expect(normalizeAmount('1234.567')).toBe('1234.56')
|
||||
})
|
||||
it('garde une décimale en cours de saisie', () => {
|
||||
expect(normalizeAmount('12.')).toBe('12.')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une saisie non numérique', () => {
|
||||
expect(normalizeAmount('abc')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(normalizeAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGroupedAmount', () => {
|
||||
it('groupe la partie entière par 3 avec des espaces', () => {
|
||||
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
|
||||
})
|
||||
it('utilise la virgule comme séparateur décimal', () => {
|
||||
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
|
||||
})
|
||||
it('affiche une virgule pour une décimale en cours', () => {
|
||||
expect(formatGroupedAmount('12.')).toBe('12,')
|
||||
})
|
||||
it('gère les valeurs sous 1000 sans séparateur', () => {
|
||||
expect(formatGroupedAmount('12')).toBe('12')
|
||||
})
|
||||
it('groupe avec une décimale en tête', () => {
|
||||
expect(formatGroupedAmount('0.5')).toBe('0,5')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une chaîne vide', () => {
|
||||
expect(formatGroupedAmount('')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(formatGroupedAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countSignificant', () => {
|
||||
it('compte les caractères hors espaces à gauche du curseur', () => {
|
||||
expect(countSignificant('1 234', 5)).toBe(4)
|
||||
})
|
||||
it('ignore un espace juste avant le curseur', () => {
|
||||
expect(countSignificant('1 234', 2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('caretFromSignificant', () => {
|
||||
it('place le curseur après le n-ième caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
|
||||
})
|
||||
it('place le curseur en fin si on dépasse', () => {
|
||||
expect(caretFromSignificant('1 234', 10)).toBe(5)
|
||||
})
|
||||
it('place le curseur au début pour 0 caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234', 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
|
||||
export const normalizeAmount = (value: string): string => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
|
||||
export const formatGroupedAmount = (model: string): string => {
|
||||
if (model === '') return ''
|
||||
const hasDot = model.includes('.')
|
||||
const [integerPart = '', decimalPart = ''] = model.split('.')
|
||||
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
|
||||
}
|
||||
|
||||
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
if (sig <= 0) return 0
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
@@ -179,6 +179,11 @@ const onChange = (event: Event) => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.radio-control input[type='radio']:focus-visible {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.radio-control.is-error input[type='radio'] {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
}
|
||||
|
||||
@@ -37,15 +37,24 @@
|
||||
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
||||
rounded,
|
||||
textField,
|
||||
keyboardFocused
|
||||
? (isOpen
|
||||
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||
: 'm-focus-ring-kbd')
|
||||
: '',
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@focus="onKbdFocus"
|
||||
@blur="onKbdBlur"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -134,7 +143,10 @@
|
||||
? 'border-m-danger'
|
||||
: hasSuccess
|
||||
? 'border-m-success'
|
||||
: 'border-m-primary'
|
||||
: 'border-m-primary',
|
||||
keyboardFocused
|
||||
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -184,13 +196,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number | null
|
||||
@@ -344,7 +359,68 @@ function toggle() {
|
||||
function select(value: string | number | null) {
|
||||
emit('update:modelValue', value)
|
||||
close()
|
||||
buttonRef.value?.blur()
|
||||
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
|
||||
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
|
||||
// les options ; au clavier le focus n'a jamais quitté le bouton.
|
||||
buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
// Garde l'option active visible quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
// Tab : laisse le focus partir mais ferme la liste
|
||||
if (e.key === 'Tab') {
|
||||
if (isOpen.value) close()
|
||||
return
|
||||
}
|
||||
|
||||
// Liste fermée : ouverture au clavier
|
||||
if (!isOpen.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Liste ouverte
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const opt = normalizedOptions.value[activeIndex.value]
|
||||
if (opt) select(opt.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = normalizedOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
|
||||
@@ -37,15 +37,24 @@
|
||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||
rounded,
|
||||
textField,
|
||||
keyboardFocused
|
||||
? (isOpen
|
||||
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||
: 'm-focus-ring-kbd')
|
||||
: '',
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="!isOpen ? undefined : (activeIndex === -1 ? selectAllId : (activeIndex >= 0 ? optionId(activeIndex) : undefined))"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@focus="onKbdFocus"
|
||||
@blur="onKbdBlur"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -162,7 +171,10 @@
|
||||
? 'border-m-danger'
|
||||
: hasSuccess
|
||||
? 'border-m-success'
|
||||
: 'border-m-primary'
|
||||
: 'border-m-primary',
|
||||
keyboardFocused
|
||||
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -174,18 +186,23 @@
|
||||
</li>
|
||||
<li
|
||||
v-if="displaySelectAll && normalizedOptions.length > 0"
|
||||
class="border-b border-m-muted/30 px-3 py-2"
|
||||
:id="selectAllId"
|
||||
role="option"
|
||||
:aria-selected="allSelected"
|
||||
class="cursor-pointer border-b border-m-muted/30 px-3 py-2"
|
||||
:class="[activeIndex === -1 ? 'bg-m-muted/10' : '']"
|
||||
@mouseenter="activeIndex = -1"
|
||||
@mousedown.prevent
|
||||
@click="toggleAll"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:label="selectAllLabel"
|
||||
:disabled="disabled"
|
||||
group-class="!mt-0"
|
||||
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
||||
group-class="!mt-0 pointer-events-none"
|
||||
label-class="option-checkbox w-full font-semibold"
|
||||
tabindex="-1"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
@@ -194,7 +211,7 @@
|
||||
:key="String(opt.value)"
|
||||
role="option"
|
||||
:aria-selected="isChecked(opt.value)"
|
||||
class="px-3 py-2"
|
||||
class="cursor-pointer px-3 py-2"
|
||||
:class="[
|
||||
index === activeIndex ? 'bg-m-muted/10' : '',
|
||||
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
||||
@@ -202,16 +219,16 @@
|
||||
]"
|
||||
@mouseenter="activeIndex = index"
|
||||
@mousedown.prevent
|
||||
@click="toggleOption(opt.value)"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isChecked(opt.value)"
|
||||
:label="opt.label || '\u00A0'"
|
||||
:disabled="disabled"
|
||||
group-class="!mt-0"
|
||||
label-class="option-checkbox w-full cursor-pointer"
|
||||
group-class="!mt-0 pointer-events-none"
|
||||
label-class="option-checkbox w-full"
|
||||
tabindex="-1"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="toggleOption(opt.value)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -235,14 +252,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import Checkbox from '../checkbox/Checkbox.vue'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number
|
||||
@@ -302,6 +322,9 @@ const openDirection = ref<'down' | 'up'>('down')
|
||||
const uid = useId()
|
||||
const buttonId = `custom-select-btn-${uid}`
|
||||
const listboxId = `custom-select-listbox-${uid}`
|
||||
const selectAllId = `custom-select-all-${uid}`
|
||||
// Index actif le plus bas : -1 cible la ligne « tout sélectionner » quand elle est affichée
|
||||
const lowestIndex = computed(() => (props.displaySelectAll && normalizedOptions.value.length > 0 ? -1 : 0))
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const listHeight = ref(0)
|
||||
const normalizedOptions = computed<Option[]>(() => props.options)
|
||||
@@ -427,6 +450,70 @@ function toggleAll() {
|
||||
nextTick(() => buttonRef.value?.focus())
|
||||
}
|
||||
|
||||
// Garde l'option active visible quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (!isOpen.value) return
|
||||
await nextTick()
|
||||
const id = index === -1 ? selectAllId : (index >= 0 ? optionId(index) : null)
|
||||
if (id) document.getElementById(id)?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
// Tab : laisse le focus partir mais ferme la liste
|
||||
if (e.key === 'Tab') {
|
||||
if (isOpen.value) close()
|
||||
return
|
||||
}
|
||||
|
||||
// Liste fermée : ouverture au clavier
|
||||
if (!isOpen.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Liste ouverte (multi-select : Entrée/Espace togglent et la liste reste ouverte)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
// -1 = ligne « tout sélectionner »
|
||||
if (activeIndex.value === -1) {
|
||||
toggleAll()
|
||||
return
|
||||
}
|
||||
const opt = normalizedOptions.value[activeIndex.value]
|
||||
if (opt) toggleOption(opt.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, lowestIndex.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = lowestIndex.value
|
||||
return
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = normalizedOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (!root.value) return
|
||||
if (!root.value.contains(e.target as Node)) close()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {ref} from 'vue'
|
||||
|
||||
/**
|
||||
* Détection de la modalité de focus (clavier vs souris/tactile).
|
||||
*
|
||||
* Sur les champs texte, `:focus-visible` natif se déclenche AUSSI au clic souris
|
||||
* (le navigateur suppose qu'on va taper). Pour n'afficher l'anneau de focus qu'à
|
||||
* la navigation clavier (Tab), on suit la dernière interaction au niveau document
|
||||
* et on n'arme l'anneau que si le focus a été précédé d'un évènement clavier.
|
||||
*
|
||||
* Le visuel « champ actif » existant (grossissement, label flottant, bordure bleue)
|
||||
* reste piloté par `:focus` et n'est pas affecté : ce composable ne gère QUE l'anneau.
|
||||
*/
|
||||
|
||||
let hadKeyboardEvent = false
|
||||
let listenersAttached = false
|
||||
|
||||
function ensureGlobalListeners() {
|
||||
if (listenersAttached || typeof document === 'undefined') return
|
||||
listenersAttached = true
|
||||
|
||||
// capture=true pour observer l'évènement avant qu'il n'atteigne sa cible
|
||||
document.addEventListener('keydown', () => {
|
||||
hadKeyboardEvent = true
|
||||
}, true)
|
||||
|
||||
const markPointer = () => {
|
||||
hadKeyboardEvent = false
|
||||
}
|
||||
document.addEventListener('mousedown', markPointer, true)
|
||||
document.addEventListener('pointerdown', markPointer, true)
|
||||
document.addEventListener('touchstart', markPointer, true)
|
||||
}
|
||||
|
||||
export function useKbdFocusRing() {
|
||||
ensureGlobalListeners()
|
||||
|
||||
const keyboardFocused = ref(false)
|
||||
|
||||
const onFocus = () => {
|
||||
keyboardFocused.value = hadKeyboardEvent
|
||||
}
|
||||
const onBlur = () => {
|
||||
keyboardFocused.value = false
|
||||
}
|
||||
|
||||
return {keyboardFocused, onFocus, onBlur}
|
||||
}
|
||||
@@ -28,6 +28,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date de naissance"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||
<MalioDate
|
||||
@@ -91,4 +101,5 @@ const simpleValue = ref<string | null>(null)
|
||||
const initialValue = ref<string | null>(todayIso)
|
||||
const boundedValue = ref<string | null>(null)
|
||||
const errorValue = ref<string | null>(null)
|
||||
const editableValue = ref<string | null>(null)
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputAmount
|
||||
@@ -251,6 +262,7 @@ import {ref} from 'vue'
|
||||
import MalioInputAmount from '../../components/malio/input/InputAmount.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const bigValue = ref('1234567.89')
|
||||
const hintValue = ref('')
|
||||
const disabledValue = ref('1500.00')
|
||||
const readonlyValue = ref('2450.75')
|
||||
|
||||
@@ -18,6 +18,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputEmail
|
||||
v-model="addableValue"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputEmail
|
||||
@@ -251,6 +264,9 @@ import {ref} from 'vue'
|
||||
import MalioInputEmail from '../../components/malio/input/InputEmail.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const addableValue = ref('')
|
||||
const addClicks = ref(0)
|
||||
const onAdd = () => { addClicks.value += 1 }
|
||||
const leftIconValue = ref('')
|
||||
const noIconValue = ref('')
|
||||
const hintValue = ref('')
|
||||
|
||||
Reference in New Issue
Block a user