90ed4a213f
Release / release (push) Successful in 1m10s
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] 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: #72 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
372 lines
11 KiB
Vue
372 lines
11 KiB
Vue
<template>
|
||
<div>
|
||
<div
|
||
ref="root"
|
||
:class="mergedGroupClass"
|
||
>
|
||
<input
|
||
:id="inputId"
|
||
v-maska="maskaOptions"
|
||
:name="name"
|
||
data-test="date-input"
|
||
:readonly="inputReadonly"
|
||
autocomplete="off"
|
||
:class="mergedInputClass"
|
||
:required="required"
|
||
:disabled="disabled"
|
||
:value="editable ? draft : displayValue"
|
||
:aria-invalid="!!error"
|
||
:aria-describedby="describedBy"
|
||
:aria-expanded="isOpen"
|
||
aria-haspopup="dialog"
|
||
v-bind="attrs"
|
||
placeholder="_"
|
||
type="text"
|
||
@click="onFieldClick"
|
||
@focus="onFocus(); onKbdFocus()"
|
||
@input="onInput"
|
||
@blur="onBlur(); onKbdBlur()"
|
||
@keydown="onKeydown"
|
||
>
|
||
|
||
<div
|
||
v-if="showGhost"
|
||
data-test="format-ghost"
|
||
aria-hidden="true"
|
||
class="pointer-events-none absolute left-0 right-0 top-1/2 flex h-10 -translate-y-1/2 items-center overflow-hidden whitespace-nowrap rounded-md border border-transparent pl-3 pr-10 text-lg"
|
||
><span
|
||
data-test="ghost-typed"
|
||
class="text-black"
|
||
>{{ ghostTyped }}</span><span
|
||
data-test="ghost-remaining"
|
||
class="text-m-muted"
|
||
>{{ ghostRemaining }}</span></div>
|
||
|
||
<label
|
||
v-if="label"
|
||
:for="inputId"
|
||
:class="mergedLabelClass"
|
||
>
|
||
{{ label }}<MalioRequiredMark v-if="required" />
|
||
</label>
|
||
|
||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||
<button
|
||
v-if="showClear"
|
||
type="button"
|
||
data-test="clear"
|
||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||
aria-label="Effacer la date"
|
||
@click.stop="onClearClick"
|
||
>
|
||
<Icon
|
||
icon="mdi:close"
|
||
:width="16"
|
||
:height="16"
|
||
/>
|
||
</button>
|
||
<Icon
|
||
data-test="calendar-icon"
|
||
icon="mdi:calendar-blank"
|
||
:width="24"
|
||
:height="24"
|
||
:class="iconStateClass"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-if="isOpen"
|
||
data-test="popover"
|
||
role="dialog"
|
||
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
|
||
>
|
||
<CalendarHeader
|
||
:view-mode="viewMode"
|
||
:current-month="currentMonth"
|
||
:current-year="currentYear"
|
||
@prev="goToPrev"
|
||
@next="goToNext"
|
||
@toggle-view="toggleView"
|
||
/>
|
||
<slot
|
||
v-if="viewMode === 'days'"
|
||
:current-month="currentMonth"
|
||
:current-year="currentYear"
|
||
:close="closePopover"
|
||
/>
|
||
<MonthPicker
|
||
v-else
|
||
:selected-month="currentMonth"
|
||
@select="onSelectMonth"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<p
|
||
v-if="reserveMessageSpace || hint || error || success"
|
||
:id="`${inputId}-describedby`"
|
||
:class="[
|
||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||
'mt-1 ml-[2px] text-xs',
|
||
reserveMessageSpace ? 'min-h-[1rem]' : '',
|
||
]"
|
||
>
|
||
{{ error || success || hint }}
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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
|
||
syncTo: string | null
|
||
id?: string
|
||
name?: string
|
||
label?: string
|
||
placeholder?: string
|
||
required?: boolean
|
||
disabled?: boolean
|
||
readonly?: boolean
|
||
hint?: string
|
||
error?: string
|
||
success?: string
|
||
clearable?: boolean
|
||
editable?: boolean
|
||
placeholderTemplate?: string
|
||
inputClass?: string
|
||
labelClass?: string
|
||
groupClass?: string
|
||
reserveMessageSpace?: boolean
|
||
}>(),
|
||
{
|
||
id: '',
|
||
name: '',
|
||
label: '',
|
||
placeholder: 'JJ/MM/AAAA',
|
||
required: false,
|
||
disabled: false,
|
||
readonly: false,
|
||
hint: '',
|
||
error: '',
|
||
success: '',
|
||
clearable: true,
|
||
editable: false,
|
||
placeholderTemplate: 'JJ/MM/AAAA',
|
||
inputClass: '',
|
||
labelClass: '',
|
||
groupClass: '',
|
||
reserveMessageSpace: true,
|
||
},
|
||
)
|
||
|
||
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)
|
||
// Le masque maska est dérivé du gabarit (lettres → slot `#`, séparateurs conservés).
|
||
// eager : pose les séparateurs (/, espace, :) dès qu'un groupe est complet.
|
||
const maskaOptions = computed<MaskInputOptions>(() => ({
|
||
mask: props.editable ? props.placeholderTemplate.replace(/[A-Za-z]/g, '#') : undefined,
|
||
eager: props.editable,
|
||
}))
|
||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||
|
||
// Gabarit fantôme : la partie saisie (noire) + le reste du gabarit (gris), affiché
|
||
// par-dessus l'input (dont le texte est rendu transparent en mode editable).
|
||
// Espaces → insécables : un espace en bord de span (flex-item) serait sinon rogné,
|
||
// collant la suite du gabarit à la date (« 12/12/1999HH:MM »).
|
||
const nbsp = (s: string) => s.replace(/ /g, ' ')
|
||
const ghostTyped = computed(() => nbsp(draft.value))
|
||
const ghostRemaining = computed(() => nbsp(props.placeholderTemplate.slice(draft.value.length)))
|
||
|
||
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.editable ? draft.value.length : props.displayValue.length) > 0,
|
||
)
|
||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||
const showGhost = computed(() => props.editable && (isOpen.value || isFilled.value))
|
||
const showClear = computed(() =>
|
||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||
)
|
||
const describedBy = computed(() =>
|
||
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
|
||
)
|
||
|
||
watch(isOpen, (value) => {
|
||
if (!value) emit('close')
|
||
})
|
||
|
||
const onFieldClick = () => {
|
||
if (props.disabled || props.readonly) return
|
||
if (props.editable) {
|
||
if (!isOpen.value) {
|
||
syncToIso(props.syncTo)
|
||
open()
|
||
}
|
||
return
|
||
}
|
||
if (isOpen.value) {
|
||
closePopover()
|
||
return
|
||
}
|
||
syncToIso(props.syncTo)
|
||
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
|
||
}
|
||
|
||
// Reset local immédiat : sur saisie invalide, modelValue est déjà null, donc le
|
||
// watch(displayValue) ne se redéclenche pas — il faut vider le draft soi-même.
|
||
const onClearClick = () => {
|
||
draft.value = ''
|
||
emit('clear')
|
||
}
|
||
|
||
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)
|
||
})
|
||
|
||
const onSelectMonth = (m: number) => {
|
||
selectMonth(m)
|
||
toggleView()
|
||
}
|
||
|
||
const mergedGroupClass = computed(() =>
|
||
twMerge('relative flex h-12 w-full items-center', props.groupClass),
|
||
)
|
||
|
||
const mergedInputClass = computed(() =>
|
||
twMerge(
|
||
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
|
||
isReadonly.value
|
||
? 'border-black'
|
||
: isFilled.value ? 'border-black' : 'border-m-muted',
|
||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
||
hasError.value
|
||
? 'border-m-danger'
|
||
: hasSuccess.value
|
||
? 'border-m-success'
|
||
: 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') : '',
|
||
// En mode editable, le texte réel est masqué : c'est le gabarit fantôme qui l'affiche.
|
||
props.editable ? 'text-transparent caret-black' : '',
|
||
props.inputClass,
|
||
),
|
||
)
|
||
|
||
const mergedLabelClass = computed(() =>
|
||
twMerge(
|
||
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
|
||
(isReadonly.value ? isFilled.value : (isFilled.value || isOpen.value)) ? '-translate-y-[1.25rem] scale-90' : '',
|
||
hasError.value
|
||
? 'text-m-danger'
|
||
: hasSuccess.value
|
||
? 'text-m-success'
|
||
: isReadonly.value
|
||
? isFilled.value ? 'text-black' : 'text-m-muted'
|
||
: isOpen.value
|
||
? 'text-m-primary'
|
||
: 'peer-placeholder-shown:text-m-muted text-black',
|
||
props.labelClass,
|
||
),
|
||
)
|
||
|
||
const iconStateClass = computed(() => {
|
||
if (hasError.value) return 'text-m-danger'
|
||
if (hasSuccess.value) return 'text-m-success'
|
||
if (isReadonly.value) return isFilled.value ? 'text-black' : 'text-m-muted'
|
||
if (isOpen.value) return 'text-m-primary'
|
||
if (isFilled.value) return 'text-black'
|
||
return 'text-m-muted'
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.floating-label {
|
||
background: white;
|
||
padding: 0 0.25rem;
|
||
}
|
||
</style>
|