| 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é Reviewed-on: #22 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
338 lines
9.0 KiB
Vue
338 lines
9.0 KiB
Vue
<template>
|
|
<div
|
|
ref="root"
|
|
:class="mergedGroupClass"
|
|
>
|
|
<button
|
|
:id="buttonId"
|
|
type="button"
|
|
class="grow-height peer relative w-full border bg-white pl-3 pr-10 py-1 text-left outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
|
:class="[
|
|
hasError
|
|
? isOpen
|
|
? openDirection === 'down'
|
|
? 'rounded-b-none !border-2 !border-m-danger !border-b-0'
|
|
: 'rounded-t-none !border-2 !border-m-danger !border-t-0'
|
|
: 'border-m-danger'
|
|
: hasSuccess
|
|
? isOpen
|
|
? openDirection === 'down'
|
|
? 'rounded-b-none !border-2 !border-m-success !border-b-0'
|
|
: 'rounded-t-none !border-2 !border-m-success !border-t-0'
|
|
: 'border-m-success'
|
|
: isOpen
|
|
? openDirection === 'down'
|
|
? 'rounded-b-none !border-2 !border-m-primary !border-b-0'
|
|
: 'rounded-t-none !border-2 !border-m-primary !border-t-0'
|
|
: isOptionSelected
|
|
? 'border-black'
|
|
: 'border-m-muted',
|
|
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : 'cursor-pointer',
|
|
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
|
rounded,
|
|
textField,
|
|
]"
|
|
:aria-expanded="isOpen"
|
|
:aria-controls="listboxId"
|
|
:aria-invalid="hasError"
|
|
:aria-describedby="describedBy"
|
|
:disabled="disabled"
|
|
@click="toggle"
|
|
>
|
|
<label
|
|
v-if="label"
|
|
class="floating-label pointer-events-none absolute left-3 inline-block origin-left transition-transform duration-150 font-medium"
|
|
:class="[
|
|
isOpen ? 'top-2 z-30' : 'top-2',
|
|
hasError
|
|
? 'text-m-danger'
|
|
: hasSuccess
|
|
? 'text-m-success'
|
|
: isOpen
|
|
? 'text-m-primary'
|
|
: isOptionSelected
|
|
? 'text-black'
|
|
: 'text-m-muted',
|
|
textLabel,
|
|
]"
|
|
:style="labelTransformStyle"
|
|
>
|
|
{{ label }}
|
|
</label>
|
|
|
|
<span
|
|
class="block truncate"
|
|
:class="[
|
|
textValue,
|
|
isOptionSelected ? 'text-black' : 'select-none text-transparent'
|
|
]"
|
|
>
|
|
{{ selectedLabel || '\u00A0' }}
|
|
</span>
|
|
|
|
<span
|
|
class="absolute right-3 top-1/2 -translate-y-1/2"
|
|
:class="[
|
|
hasError
|
|
? 'text-m-danger'
|
|
: hasSuccess
|
|
? 'text-m-success'
|
|
: 'text-current'
|
|
]"
|
|
>
|
|
<slot name="icon">
|
|
<IconifyIcon
|
|
icon="mdi:chevron-down"
|
|
width="20"
|
|
class="transition-transform duration-300"
|
|
:class="isOpen ? 'rotate-180' : 'rotate-0'"
|
|
/>
|
|
</slot>
|
|
</span>
|
|
</button>
|
|
|
|
<ul
|
|
v-if="isOpen"
|
|
:id="listboxId"
|
|
ref="listRef"
|
|
role="listbox"
|
|
:aria-labelledby="buttonId"
|
|
class="absolute left-0 right-0 z-20 max-h-60 w-full overflow-auto border-2 bg-white"
|
|
:class="[
|
|
openDirection === 'down'
|
|
? 'top-[calc(100%-2px)] rounded-b-md border-t-0'
|
|
: 'bottom-[calc(100%-2px)] rounded-t-md border-b-0',
|
|
hasError
|
|
? 'select-scrollbar-error'
|
|
: hasSuccess
|
|
? 'select-scrollbar-success'
|
|
: 'select-scrollbar-primary',
|
|
hasError
|
|
? 'border-m-danger'
|
|
: hasSuccess
|
|
? 'border-m-success'
|
|
: 'border-m-primary'
|
|
]"
|
|
>
|
|
<li
|
|
v-for="(opt, index) in normalizedOptions"
|
|
:id="optionId(index)"
|
|
:key="String(opt.value)"
|
|
role="option"
|
|
:aria-selected="opt.value === modelValue"
|
|
class="cursor-pointer px-3 py-2"
|
|
:class="[
|
|
index === activeIndex ? 'bg-m-muted/10' : '',
|
|
opt.value === modelValue ? 'bg-m-muted/10 font-semibold' : '',
|
|
opt.value === null ? 'text-black/40' : 'text-black'
|
|
]"
|
|
@mouseenter="activeIndex = index"
|
|
@mousedown.prevent
|
|
@click="select(opt.value)"
|
|
>
|
|
{{ opt.label || '\u00A0' }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<p
|
|
v-if="hint || hasError || hasSuccess"
|
|
:id="`${buttonId}-describedby`"
|
|
:class="[
|
|
hasError
|
|
? 'text-m-danger'
|
|
: hasSuccess
|
|
? 'text-m-success'
|
|
: 'text-m-muted',
|
|
'mt-1 ml-[2px] text-xs',
|
|
]"
|
|
>
|
|
{{ error || success || hint }}
|
|
</p>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
|
import {twMerge} from 'tailwind-merge'
|
|
|
|
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
|
|
|
type Option = {
|
|
label: string;
|
|
value: string | number | null
|
|
}
|
|
const props = withDefaults(defineProps<{
|
|
modelValue: string | number | null
|
|
options?: Option[]
|
|
emptyOptionLabel?: string
|
|
label?: string
|
|
hint?: string
|
|
error?: string
|
|
success?: string
|
|
minWidth?: string
|
|
maxWidth?: string
|
|
textField?: string
|
|
textValue?: string
|
|
textLabel?: string
|
|
rounded?: string
|
|
disabled?: boolean
|
|
groupClass?: string
|
|
}>(), {
|
|
options: () => [],
|
|
emptyOptionLabel: '',
|
|
label: '',
|
|
hint: '',
|
|
error: '',
|
|
success: '',
|
|
minWidth: 'w-96',
|
|
maxWidth: '',
|
|
textField: 'text-lg',
|
|
textValue: 'text-lg',
|
|
textLabel: 'text-sm',
|
|
rounded: 'rounded-md',
|
|
disabled: false,
|
|
groupClass: '',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', v: string | number | null): void
|
|
}>()
|
|
const root = ref<HTMLElement | null>(null)
|
|
const isOpen = ref(false)
|
|
const activeIndex = ref(-1)
|
|
const openDirection = ref<'down' | 'up'>('down')
|
|
const uid = useId()
|
|
const buttonId = `custom-select-btn-${uid}`
|
|
const listboxId = `custom-select-listbox-${uid}`
|
|
const listRef = ref<HTMLElement | null>(null)
|
|
const listHeight = ref(0)
|
|
const normalizedOptions = computed<Option[]>(() => [
|
|
{label: props.emptyOptionLabel, value: null},
|
|
...props.options,
|
|
])
|
|
const mergedGroupClass = computed(() =>
|
|
twMerge('relative mt-4 w-full', props.minWidth, props.maxWidth, props.groupClass),
|
|
)
|
|
const hasError = computed(() => !!props.error)
|
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
|
const isOptionSelected = computed(() =>
|
|
props.options.some(o => o.value === props.modelValue)
|
|
)
|
|
const shouldFloatLabel = computed(() =>
|
|
isOpen.value || isOptionSelected.value
|
|
)
|
|
const selectedLabel = computed(() =>
|
|
props.options.find(o => o.value === props.modelValue)?.label ?? ''
|
|
)
|
|
const describedBy = computed(() =>
|
|
(hasError.value || hasSuccess.value || !!props.hint) ? `${buttonId}-describedby` : undefined,
|
|
)
|
|
|
|
function optionId(index: number) {
|
|
return `custom-select-opt-${uid}-${index}`
|
|
}
|
|
|
|
function updateOpenDirection() {
|
|
if (!root.value) return
|
|
|
|
const rect = root.value.getBoundingClientRect()
|
|
const estimatedListHeight = Math.min(normalizedOptions.value.length * 40, 240)
|
|
const spaceBelow = window.innerHeight - rect.bottom
|
|
const spaceAbove = rect.top
|
|
|
|
openDirection.value =
|
|
spaceBelow >= estimatedListHeight || spaceBelow >= spaceAbove
|
|
? 'down'
|
|
: 'up'
|
|
}
|
|
|
|
function open() {
|
|
updateOpenDirection()
|
|
isOpen.value = true
|
|
|
|
const selectedIndex = normalizedOptions.value.findIndex(o => o.value === props.modelValue)
|
|
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0
|
|
|
|
nextTick(() => {
|
|
if (openDirection.value === 'up' && listRef.value) {
|
|
listHeight.value = listRef.value.offsetHeight
|
|
} else {
|
|
listHeight.value = 0
|
|
}
|
|
})
|
|
}
|
|
|
|
const labelTransformStyle = computed(() => {
|
|
// label non flottant
|
|
if (!shouldFloatLabel.value) {
|
|
return {}
|
|
}
|
|
|
|
// fermé ou ouverture vers le bas : comportement classique
|
|
if (!isOpen.value || openDirection.value === 'down') {
|
|
return {
|
|
transform: 'translateY(-1.15rem) scale(0.9)',
|
|
}
|
|
}
|
|
|
|
// ouverture vers le haut : on remonte en fonction de la hauteur de la liste
|
|
const extraOffset = 8 // marge visuelle au-dessus de la liste en px
|
|
const total = 4 +listHeight.value + extraOffset
|
|
// 18 ≈ 1.15rem pour garder la même base que votre flottant actuel
|
|
|
|
return {
|
|
transform: `translateY(-${total}px) scale(0.9)`,
|
|
}
|
|
})
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
function toggle() {
|
|
if (props.disabled) return
|
|
if (isOpen.value) {
|
|
close()
|
|
return
|
|
}
|
|
open()
|
|
}
|
|
|
|
function select(value: string | number | null) {
|
|
emit('update:modelValue', value)
|
|
close()
|
|
}
|
|
|
|
function onClickOutside(e: MouseEvent) {
|
|
if (!root.value) return
|
|
if (!root.value.contains(e.target as Node)) close()
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('mousedown', onClickOutside))
|
|
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|
</script>
|
|
|
|
<style scoped>
|
|
.floating-label {
|
|
background: white;
|
|
padding: 0 0.25rem;
|
|
}
|
|
|
|
:deep(ul[role="listbox"]) {
|
|
scrollbar-width: auto;
|
|
}
|
|
|
|
:deep(.select-scrollbar-primary) {
|
|
scrollbar-color: rgb(var(--m-primary)) transparent;
|
|
}
|
|
|
|
:deep(.select-scrollbar-error) {
|
|
scrollbar-color: #000000 transparent;
|
|
}
|
|
|
|
:deep(.select-scrollbar-success) {
|
|
scrollbar-color: #000000 transparent;
|
|
}
|
|
|
|
</style>
|