feat: Ajout des composants modal, accordeon, datetime avec selecteur d'heure à la molette (#56)
Release / release (push) Successful in 2m38s

| 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: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #56
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #56.
This commit is contained in:
2026-05-27 12:11:51 +00:00
committed by Autin
parent 7d7b2fb720
commit acd531f69e
36 changed files with 6581 additions and 30 deletions
+236
View File
@@ -0,0 +1,236 @@
<template>
<div ref="root">
<div :class="mergedGroupClass">
<input
:id="inputId"
:name="name"
data-test="time-field"
readonly
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-m-primary"
aria-label="Effacer l'heure"
@click.stop="onClear"
>
<Icon icon="mdi:close" :width="16" :height="16" />
</button>
<Icon
data-test="clock-icon"
icon="mdi:clock-outline"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<!-- Mode overlay (par défaut) : popover absolu au-dessus du contenu suivant. -->
<div
v-if="isOpen && !staticPopover"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<TimeWheels
:model-value="wheelsValue"
@update:model-value="onWheelChange"
/>
</div>
</div>
<!-- Mode statique : molette en flux (hors du groupe à hauteur fixe) le
conteneur parent (ex. popover du DateTime) grandit pour l'englober. -->
<div
v-if="isOpen && staticPopover"
data-test="popover"
role="dialog"
class="relative mt-4 w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<TimeWheels
:model-value="wheelsValue"
@update:model-value="onWheelChange"
/>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import TimeWheels from './internal/TimeWheels.vue'
defineOptions({name: 'MalioTimePicker', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: string | null
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
staticPopover?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'HH:MM',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
staticPopover: false,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const localValue = ref<string | null>(null)
const isControlled = computed(() => props.modelValue !== undefined)
const currentValue = computed(() => (isControlled.value ? props.modelValue : localValue.value))
const inputId = computed(() => props.id?.toString() || `malio-time-picker-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const displayValue = computed(() => currentValue.value ?? '')
const isFilled = computed(() => displayValue.value.length > 0)
const wheelsValue = computed(() => currentValue.value || '00:00')
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
const commit = (value: string | null) => {
if (!isControlled.value) localValue.value = value
emit('update:modelValue', value)
}
const onWheelChange = (value: string) => commit(value)
const onClear = () => {
commit(null)
}
const onFieldClick = () => {
if (props.disabled || props.readonly) return
isOpen.value = !isOpen.value
}
const onMouseDown = (event: MouseEvent) => {
if (!isOpen.value || !root.value) return
if (!root.value.contains(event.target as Node)) isOpen.value = false
}
onMounted(() => document.addEventListener('mousedown', onMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown))
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed border-m-muted text-black/60' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !rounded-b-none !py-[9px]' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left text-sm font-medium transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isOpen.value
? 'text-m-primary'
: 'text-black peer-placeholder-shown:text-m-muted',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>