Files
backend/resources/js/Components/Supervisor/OutlineSelect.vue
T

267 lines
8.3 KiB
Vue

<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>