Intégration fonctionnalites V1
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Sélectionner…',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'dark',
|
||||
validator: (value) => ['dark', 'light'].includes(value),
|
||||
},
|
||||
bordered: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
const root = ref(null);
|
||||
const isOpen = ref(false);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
const normalizedValue = computed(() => {
|
||||
const value = props.modelValue;
|
||||
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
});
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const selected = props.options.find((option) => String(option.value) === normalizedValue.value);
|
||||
|
||||
return selected?.label ?? props.placeholder;
|
||||
});
|
||||
|
||||
const isDark = computed(() => props.variant === 'dark');
|
||||
|
||||
const wrapperClass = computed(() => (
|
||||
props.bordered
|
||||
? (isDark.value ? 'rounded-xl border border-white/25 p-4' : 'rounded-xl border border-slate-200 p-4')
|
||||
: ''
|
||||
));
|
||||
|
||||
const labelClass = computed(() => (
|
||||
isDark.value
|
||||
? 'flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-indigo-200'
|
||||
: 'flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-500'
|
||||
));
|
||||
|
||||
const triggerClass = computed(() => (
|
||||
isDark.value
|
||||
? 'border-white/20 bg-transparent text-white hover:border-white/30 focus:border-white/40 focus:ring-white/15'
|
||||
: 'border-slate-200 bg-white text-slate-800 hover:border-slate-300 focus:border-indigo-400 focus:ring-indigo-500/20'
|
||||
));
|
||||
|
||||
const menuClass = computed(() => (
|
||||
isDark.value
|
||||
? 'border-white/20 bg-slate-900/95 text-white shadow-xl shadow-black/30 backdrop-blur-md'
|
||||
: 'border-slate-200 bg-white text-slate-800 shadow-lg shadow-slate-200/60'
|
||||
));
|
||||
|
||||
const chevronClass = computed(() => (
|
||||
isDark.value ? 'text-indigo-200' : 'text-slate-400'
|
||||
));
|
||||
|
||||
const optionClass = (option, index) => {
|
||||
const isSelected = String(option.value) === normalizedValue.value;
|
||||
const isActive = index === activeIndex.value;
|
||||
|
||||
if (isDark.value) {
|
||||
if (isSelected) {
|
||||
return 'bg-indigo-500/25 text-white';
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
return 'bg-white/10 text-white';
|
||||
}
|
||||
|
||||
return 'text-indigo-100 hover:bg-white/10 hover:text-white';
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return 'bg-indigo-50 text-indigo-700';
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
return 'bg-slate-50 text-slate-900';
|
||||
}
|
||||
|
||||
return 'text-slate-700 hover:bg-slate-50';
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
|
||||
if (isOpen.value) {
|
||||
const selectedIndex = props.options.findIndex((option) => String(option.value) === normalizedValue.value);
|
||||
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
activeIndex.value = -1;
|
||||
};
|
||||
|
||||
const selectOption = (option) => {
|
||||
emit('update:modelValue', option.value);
|
||||
emit('change', option.value);
|
||||
close();
|
||||
};
|
||||
|
||||
const onClickOutside = (event) => {
|
||||
if (isOpen.value && root.value && !root.value.contains(event.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeydown = (event) => {
|
||||
if (!isOpen.value) {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
isOpen.value = true;
|
||||
activeIndex.value = Math.max(
|
||||
0,
|
||||
props.options.findIndex((option) => String(option.value) === normalizedValue.value),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
|
||||
if (props.options[activeIndex.value]) {
|
||||
selectOption(props.options[activeIndex.value]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" :class="wrapperClass">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
:class="labelClass"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div class="relative" :class="label ? 'mt-2' : ''">
|
||||
<button
|
||||
:id="id"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-lg border py-2.5 pl-3 pr-3 text-left text-sm font-medium transition focus:outline-none focus:ring-2"
|
||||
:class="triggerClass"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="listbox"
|
||||
@click.stop="toggle"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span class="truncate">{{ selectedLabel }}</span>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 transition-transform duration-200"
|
||||
:class="[chevronClass, isOpen ? 'rotate-180' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-150"
|
||||
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
|
||||
>
|
||||
<ul
|
||||
v-show="isOpen"
|
||||
class="absolute z-[100] mt-2 max-h-60 w-full overflow-auto rounded-xl border p-1.5"
|
||||
:class="menuClass"
|
||||
role="listbox"
|
||||
:aria-labelledby="id"
|
||||
>
|
||||
<li
|
||||
v-for="(option, index) in options"
|
||||
:key="`${option.value}-${index}`"
|
||||
role="option"
|
||||
:aria-selected="String(option.value) === normalizedValue"
|
||||
class="flex cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-2.5 text-sm transition"
|
||||
:class="optionClass(option, index)"
|
||||
@click.stop="selectOption(option)"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
<svg
|
||||
v-if="String(option.value) === normalizedValue"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:class="isDark ? 'text-indigo-300' : 'text-indigo-500'"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user