En mode creatable=true, le composant emit le texte tape en temps reel et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte ne matche aucune option. Mode strict (defaut) inchange. Le composant emit aussi 'focus' pour permettre au parent de charger les donnees au premier focus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
409 lines
11 KiB
Vue
409 lines
11 KiB
Vue
<template>
|
|
<div class="space-y-1 search-select">
|
|
<label v-if="$slots.label" class="label">
|
|
<span class="label-text">
|
|
<slot name="label" />
|
|
</span>
|
|
</label>
|
|
<div class="relative">
|
|
<input
|
|
ref="inputRef"
|
|
v-model="searchTerm"
|
|
type="text"
|
|
:placeholder="placeholder"
|
|
:class="inputClasses"
|
|
@focus="handleFocus"
|
|
@keydown.down.prevent="highlightNext"
|
|
@keydown.up.prevent="highlightPrevious"
|
|
@keydown.enter.prevent="selectHighlighted"
|
|
@input="handleInput"
|
|
>
|
|
<button
|
|
v-if="clearable && modelValue"
|
|
type="button"
|
|
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
|
|
aria-label="Effacer la sélection"
|
|
@click.stop="clearSelection"
|
|
>
|
|
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="toggleButtonClasses"
|
|
@click="toggleDropdown"
|
|
aria-label="Afficher les options"
|
|
>
|
|
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
|
|
<transition name="fade">
|
|
<div
|
|
v-if="openDropdown"
|
|
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
|
>
|
|
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-base-content/50">
|
|
<span class="loading loading-spinner loading-xs" />
|
|
Recherche en cours…
|
|
</div>
|
|
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
|
|
{{ emptyText }}
|
|
</div>
|
|
<ul v-else class="flex flex-col">
|
|
<li
|
|
v-for="(option, index) in displayedOptions"
|
|
:key="resolveValue(option) ?? index"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex w-full flex-col items-start gap-1 px-3 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
|
:class="{
|
|
'bg-base-200': isOptionSelected(option),
|
|
'bg-base-300/60': highlightedIndex === index
|
|
}"
|
|
@mouseenter="highlightedIndex = index"
|
|
@mouseleave="highlightedIndex = -1"
|
|
@click="selectOption(option)"
|
|
>
|
|
<span class="font-medium text-sm">
|
|
<slot name="option-label" :option="option">
|
|
{{ resolveLabel(option) }}
|
|
</slot>
|
|
</span>
|
|
<span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
|
|
<slot name="option-description" :option="option">
|
|
{{ resolveDescription(option) }}
|
|
</slot>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
<button
|
|
v-if="creatableSuggestion"
|
|
type="button"
|
|
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
|
|
@click="confirmCreatable"
|
|
>
|
|
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
|
|
Créer « {{ creatableSuggestion }} »
|
|
</button>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
|
import IconLucideX from '~icons/lucide/x'
|
|
import IconLucidePlus from '~icons/lucide/plus'
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: [String, Number],
|
|
default: ''
|
|
},
|
|
options: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: 'Rechercher…'
|
|
},
|
|
emptyText: {
|
|
type: String,
|
|
default: 'Aucun résultat'
|
|
},
|
|
loading: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
optionValue: {
|
|
type: [String, Function],
|
|
default: 'id'
|
|
},
|
|
optionLabel: {
|
|
type: [String, Function],
|
|
default: 'name'
|
|
},
|
|
optionDescription: {
|
|
type: [String, Function],
|
|
default: null
|
|
},
|
|
clearable: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
size: {
|
|
type: String,
|
|
default: 'md',
|
|
validator: (value) => ['xs', 'sm', 'md', 'lg'].includes(value)
|
|
},
|
|
maxVisible: {
|
|
type: Number,
|
|
default: 50
|
|
},
|
|
serverSearch: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
creatable: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
|
|
|
|
const searchTerm = ref('')
|
|
const openDropdown = ref(false)
|
|
const highlightedIndex = ref(-1)
|
|
const inputRef = ref(null)
|
|
|
|
const baseOptions = computed(() => Array.isArray(props.options) ? props.options : [])
|
|
|
|
const selectedOption = computed(() => {
|
|
return baseOptions.value.find(option => isEqualValue(resolveValue(option), props.modelValue)) || null
|
|
})
|
|
|
|
const displayedOptions = computed(() => {
|
|
const items = baseOptions.value.slice()
|
|
|
|
const filtered = (!props.serverSearch && searchTerm.value.trim())
|
|
? items.filter((option) => {
|
|
const term = searchTerm.value.trim().toLowerCase()
|
|
const label = resolveLabel(option).toLowerCase()
|
|
const description = resolveDescription(option)?.toLowerCase() || ''
|
|
return label.includes(term) || description.includes(term)
|
|
})
|
|
: items
|
|
|
|
if (props.maxVisible && filtered.length > props.maxVisible) {
|
|
return filtered.slice(0, props.maxVisible)
|
|
}
|
|
|
|
return filtered
|
|
})
|
|
|
|
const creatableSuggestion = computed(() => {
|
|
if (!props.creatable) return null
|
|
const term = searchTerm.value.trim()
|
|
if (!term) return null
|
|
// Show "Créer ..." only if no option matches exactly (case-insensitive)
|
|
const exists = baseOptions.value.some(option => {
|
|
const label = resolveLabel(option).toLowerCase()
|
|
return label === term.toLowerCase()
|
|
})
|
|
return exists ? null : term
|
|
})
|
|
|
|
const inputClasses = computed(() => {
|
|
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
|
const base = ['input', 'input-bordered', 'w-full', pr]
|
|
if (props.size === 'xs') base.push('input-xs')
|
|
if (props.size === 'sm') base.push('input-sm')
|
|
if (props.size === 'lg') base.push('input-lg')
|
|
return base.join(' ')
|
|
})
|
|
|
|
const toggleButtonClasses = computed(() => {
|
|
const base = ['absolute', 'top-1/2', '-translate-y-1/2', 'right-2', 'btn', 'btn-ghost']
|
|
if (props.size === 'xs' || props.size === 'sm') {
|
|
base.push('btn-xs')
|
|
} else {
|
|
base.push('btn-sm')
|
|
}
|
|
return base.join(' ')
|
|
})
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
() => {
|
|
if (props.creatable) {
|
|
if (searchTerm.value !== props.modelValue) {
|
|
searchTerm.value = String(props.modelValue ?? '')
|
|
}
|
|
return
|
|
}
|
|
if (!openDropdown.value) {
|
|
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
watch(
|
|
baseOptions,
|
|
(_newOptions) => {
|
|
if (!openDropdown.value && selectedOption.value) {
|
|
searchTerm.value = resolveLabel(selectedOption.value)
|
|
}
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(openDropdown, (isOpen) => {
|
|
if (isOpen) {
|
|
highlightedIndex.value = -1
|
|
}
|
|
})
|
|
|
|
function resolveValue (option) {
|
|
if (!option) {
|
|
return null
|
|
}
|
|
if (typeof props.optionValue === 'function') {
|
|
return props.optionValue(option)
|
|
}
|
|
return option[props.optionValue]
|
|
}
|
|
|
|
function resolveLabel (option) {
|
|
if (!option) {
|
|
return ''
|
|
}
|
|
if (typeof props.optionLabel === 'function') {
|
|
return props.optionLabel(option) || ''
|
|
}
|
|
return option[props.optionLabel] || ''
|
|
}
|
|
|
|
function resolveDescription (option) {
|
|
if (!option || !props.optionDescription) {
|
|
return ''
|
|
}
|
|
if (typeof props.optionDescription === 'function') {
|
|
return props.optionDescription(option) || ''
|
|
}
|
|
return option[props.optionDescription] || ''
|
|
}
|
|
|
|
function isEqualValue (a, b) {
|
|
if (a === b) {
|
|
return true
|
|
}
|
|
return String(a ?? '') === String(b ?? '')
|
|
}
|
|
|
|
function isOptionSelected (option) {
|
|
return isEqualValue(resolveValue(option), props.modelValue)
|
|
}
|
|
|
|
function selectOption (option) {
|
|
emit('update:modelValue', resolveValue(option) ?? '')
|
|
searchTerm.value = resolveLabel(option)
|
|
openDropdown.value = false
|
|
}
|
|
|
|
function handleFocus () {
|
|
openDropdown.value = true
|
|
if (searchTerm.value === '' && selectedOption.value) {
|
|
searchTerm.value = resolveLabel(selectedOption.value)
|
|
}
|
|
emit('focus')
|
|
}
|
|
|
|
function toggleDropdown () {
|
|
openDropdown.value = !openDropdown.value
|
|
if (openDropdown.value && selectedOption.value) {
|
|
searchTerm.value = resolveLabel(selectedOption.value)
|
|
}
|
|
if (openDropdown.value && inputRef.value) {
|
|
inputRef.value.focus()
|
|
}
|
|
}
|
|
|
|
function handleInput () {
|
|
if (!openDropdown.value) {
|
|
openDropdown.value = true
|
|
}
|
|
if (props.creatable) {
|
|
emit('update:modelValue', searchTerm.value)
|
|
}
|
|
emit('search', searchTerm.value)
|
|
}
|
|
|
|
function clearSelection () {
|
|
emit('update:modelValue', '')
|
|
searchTerm.value = ''
|
|
openDropdown.value = false
|
|
}
|
|
|
|
function confirmCreatable () {
|
|
if (creatableSuggestion.value) {
|
|
emit('update:modelValue', creatableSuggestion.value)
|
|
}
|
|
openDropdown.value = false
|
|
}
|
|
|
|
function closeDropdown () {
|
|
openDropdown.value = false
|
|
if (props.creatable) {
|
|
return // keep the typed text as-is
|
|
}
|
|
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
|
emit('update:modelValue', '')
|
|
} else if (selectedOption.value) {
|
|
searchTerm.value = resolveLabel(selectedOption.value)
|
|
}
|
|
}
|
|
|
|
function highlightNext () {
|
|
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
|
return
|
|
}
|
|
highlightedIndex.value = (highlightedIndex.value + 1) % displayedOptions.value.length
|
|
}
|
|
|
|
function highlightPrevious () {
|
|
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
|
return
|
|
}
|
|
highlightedIndex.value =
|
|
highlightedIndex.value <= 0
|
|
? displayedOptions.value.length - 1
|
|
: highlightedIndex.value - 1
|
|
}
|
|
|
|
function selectHighlighted () {
|
|
if (!openDropdown.value) {
|
|
return
|
|
}
|
|
if (highlightedIndex.value >= 0 && highlightedIndex.value < displayedOptions.value.length) {
|
|
selectOption(displayedOptions.value[highlightedIndex.value])
|
|
}
|
|
}
|
|
|
|
const handleGlobalClick = (event) => {
|
|
if (!openDropdown.value) {
|
|
return
|
|
}
|
|
const target = event.target
|
|
if (target?.closest?.('.search-select')) {
|
|
return
|
|
}
|
|
closeDropdown()
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('click', handleGlobalClick)
|
|
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('click', handleGlobalClick)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.12s ease;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|