| 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: #42 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
416 lines
11 KiB
Vue
416 lines
11 KiB
Vue
<template>
|
|
<div>
|
|
<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>
|
|
|
|
<div
|
|
v-if="displayTags && selectedOptions.length > 0"
|
|
class="flex flex-wrap items-center justify-start gap-1"
|
|
:class="[label ? 'pt-1' : '']"
|
|
>
|
|
<span
|
|
v-for="option in selectedOptions"
|
|
:key="String(option.value)"
|
|
class="inline-flex max-w-full items-center rounded-md border border-black px-2 text-sm leading-none text-black"
|
|
>
|
|
<span class="truncate pb-[2px]">{{ option.label }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<span
|
|
v-else-if="displayTag && emptyOptionLabel"
|
|
class="block truncate text-right"
|
|
:class="[
|
|
textValue,
|
|
label ? 'pl-24' : '',
|
|
'text-m-muted'
|
|
]"
|
|
>
|
|
{{ emptyOptionLabel }}
|
|
</span>
|
|
|
|
<span
|
|
v-if="!displayTag"
|
|
class="block truncate text-right"
|
|
:class="[
|
|
textValue,
|
|
label ? 'pl-24' : '',
|
|
isOptionSelected ? 'text-black' : 'text-m-muted'
|
|
]"
|
|
>
|
|
{{ selectionSummary }}
|
|
</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%-4px)] rounded-b-md border-t-0'
|
|
: 'bottom-[calc(100%-4px)] 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-if="displaySelectAll"
|
|
class="border-b border-m-muted/30 px-3 py-2"
|
|
@mousedown.prevent
|
|
>
|
|
<Checkbox
|
|
:model-value="allSelected"
|
|
:label="selectAllLabel"
|
|
:disabled="disabled"
|
|
group-class="!mt-0"
|
|
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
|
tabindex="-1"
|
|
@update:model-value="toggleAll"
|
|
/>
|
|
</li>
|
|
<li
|
|
v-for="(opt, index) in normalizedOptions"
|
|
:id="optionId(index)"
|
|
:key="String(opt.value)"
|
|
role="option"
|
|
:aria-selected="isChecked(opt.value)"
|
|
class="px-3 py-2"
|
|
:class="[
|
|
index === activeIndex ? 'bg-m-muted/10' : '',
|
|
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
|
'text-black'
|
|
]"
|
|
@mouseenter="activeIndex = index"
|
|
@mousedown.prevent
|
|
>
|
|
<Checkbox
|
|
:model-value="isChecked(opt.value)"
|
|
:label="opt.label || '\u00A0'"
|
|
:disabled="disabled"
|
|
group-class="!mt-0"
|
|
label-class="option-checkbox w-full cursor-pointer"
|
|
tabindex="-1"
|
|
@update:model-value="toggleOption(opt.value)"
|
|
/>
|
|
</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>
|
|
</div>
|
|
</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'
|
|
import Checkbox from '../checkbox/Checkbox.vue'
|
|
|
|
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
|
|
|
type Option = {
|
|
label: string;
|
|
value: string | number
|
|
}
|
|
const props = withDefaults(defineProps<{
|
|
modelValue: Array<string | number>
|
|
options?: Option[]
|
|
emptyOptionLabel?: string
|
|
label?: string
|
|
hint?: string
|
|
error?: string
|
|
success?: string
|
|
textField?: string
|
|
textValue?: string
|
|
textLabel?: string
|
|
rounded?: string
|
|
displayTag?: boolean
|
|
displaySelectAll?: boolean
|
|
selectAllLabel?: string
|
|
disabled?: boolean
|
|
groupClass?: string
|
|
}>(), {
|
|
options: () => [],
|
|
emptyOptionLabel: '',
|
|
label: '',
|
|
hint: '',
|
|
error: '',
|
|
success: '',
|
|
textField: 'text-lg',
|
|
textValue: 'text-lg',
|
|
textLabel: 'text-sm',
|
|
rounded: 'rounded-md',
|
|
displayTag: false,
|
|
displaySelectAll: false,
|
|
selectAllLabel: 'Tout sélectionner',
|
|
disabled: false,
|
|
groupClass: '',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', v: Array<string | number>): 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[]>(() => props.options)
|
|
const mergedGroupClass = computed(() =>
|
|
twMerge('relative w-full h-12 flex items-center', props.groupClass),
|
|
)
|
|
const hasError = computed(() => !!props.error)
|
|
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
|
const isOptionSelected = computed(() =>
|
|
props.modelValue.length > 0
|
|
)
|
|
const selectedOptions = computed(() =>
|
|
normalizedOptions.value.filter(option => props.modelValue.includes(option.value)),
|
|
)
|
|
const displayTags = computed(() =>
|
|
props.displayTag && selectedOptions.value.length > 0,
|
|
)
|
|
const shouldFloatLabel = computed(() =>
|
|
isOpen.value || displayTags.value
|
|
)
|
|
const selectionSummary = computed(() =>
|
|
`${props.modelValue.length}/${normalizedOptions.value.length}`
|
|
)
|
|
const allSelected = computed(() =>
|
|
normalizedOptions.value.length > 0
|
|
&& normalizedOptions.value.every(opt => props.modelValue.includes(opt.value)),
|
|
)
|
|
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 => props.modelValue.includes(o.value))
|
|
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 isChecked(value: string | number) {
|
|
return props.modelValue.includes(value)
|
|
}
|
|
|
|
function toggleOption(value: string | number) {
|
|
if (isChecked(value)) {
|
|
emit('update:modelValue', props.modelValue.filter(item => item !== value))
|
|
return
|
|
}
|
|
emit('update:modelValue', [...props.modelValue, value])
|
|
}
|
|
|
|
function toggleAll() {
|
|
if (allSelected.value) {
|
|
emit('update:modelValue', [])
|
|
} else {
|
|
emit('update:modelValue', normalizedOptions.value.map(opt => opt.value))
|
|
}
|
|
}
|
|
|
|
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;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
: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>
|