Files
Inventory/frontend/app/components/common/SearchSelect.vue
Matthieu 0255d7dda1 feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
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>
2026-05-11 14:32:06 +02:00

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>