acd531f69e
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>
237 lines
6.6 KiB
Vue
237 lines
6.6 KiB
Vue
<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>
|