Files
notytex/frontend/src/components/assessment/DomainAutocomplete.vue
Bertrand Benjamin 6cca179346
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m3s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m14s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
refactor(ui): unify frontend around compact, desktop-first design
Extract shared utilities (color functions, icon registry), replace hero
banners with compact PageHeader, add TrimesterSelector/ConfirmDialog/
Breadcrumb components, consolidate off-palette colors to design tokens,
convert AssessmentListView to table layout, compress ResultsView stats
into horizontal bar, and inline ClassFormView as a modal in ClassListView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:37:46 +01:00

317 lines
8.5 KiB
Vue

<template>
<div class="relative">
<input
ref="inputRef"
v-model="searchQuery"
type="text"
:placeholder="placeholder"
class="input w-full text-xs py-1"
autocomplete="off"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeydown"
/>
<!-- Suggestions dropdown -->
<div
v-if="showSuggestions && (suggestions.length > 0 || canCreate)"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-48 overflow-y-auto"
>
<!-- Existing domains -->
<div
v-for="(domain, idx) in suggestions"
:key="domain.id"
class="px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 flex items-center"
:class="{ 'bg-primary-100': idx === selectedIndex }"
@mousedown.prevent="selectDomain(domain)"
>
<div
class="w-3 h-3 rounded-full mr-2 flex-shrink-0"
:style="{ backgroundColor: domain.color }"
></div>
<span v-html="highlightMatch(domain.name)"></span>
</div>
<!-- Create new option -->
<div
v-if="canCreate"
class="px-3 py-2 text-sm cursor-pointer hover:bg-accent-100 border-t border-gray-200 flex items-center"
:class="{ 'bg-accent-100': selectedIndex === suggestions.length }"
@mousedown.prevent="openCreateModal"
>
<svg class="w-4 h-4 text-accent-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
<span class="text-accent-600 font-medium">Créer "{{ searchQuery }}"</span>
</div>
</div>
<!-- Create Domain Modal -->
<Modal v-model="showCreateModal" title="Créer un nouveau domaine">
<div class="space-y-4">
<div>
<label class="label">Nom du domaine *</label>
<input
v-model="newDomain.name"
type="text"
class="input w-full"
placeholder="Ex: Calcul mental"
/>
</div>
<div>
<label class="label">Couleur associée</label>
<div class="flex flex-wrap gap-2 mb-3">
<button
v-for="color in colorOptions"
:key="color"
type="button"
class="w-8 h-8 rounded-full border-2 transition-all"
:class="newDomain.color === color ? 'ring-2 ring-accent-500 border-white' : 'border-gray-300 hover:border-gray-400'"
:style="{ backgroundColor: color }"
@click="newDomain.color = color"
></button>
</div>
<input
v-model="newDomain.color"
type="color"
class="w-full h-10 border border-gray-300 rounded cursor-pointer"
/>
</div>
<div>
<label class="label">Description (optionnel)</label>
<textarea
v-model="newDomain.description"
class="input w-full"
rows="2"
placeholder="Description du domaine..."
></textarea>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!newDomain.name || creatingDomain"
@click="createDomain"
>
{{ creatingDomain ? 'Création...' : 'Créer' }}
</button>
</div>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import Modal from '@/components/common/Modal.vue'
import configService from '@/services/config'
import { useNotificationsStore } from '@/stores/notifications'
const props = defineProps({
modelValue: {
type: Object,
default: null
},
placeholder: {
type: String,
default: 'Saisissez un domaine...'
}
})
const emit = defineEmits(['update:modelValue'])
const notifications = useNotificationsStore()
const inputRef = ref(null)
const searchQuery = ref('')
const suggestions = ref([])
const showSuggestions = ref(false)
const selectedIndex = ref(-1)
const showCreateModal = ref(false)
const creatingDomain = ref(false)
let debounceTimer = null
const colorOptions = [
'#3b82f6', '#10b981', '#f59e0b', '#8b5cf6',
'#ef4444', '#06b6d4', '#84cc16', '#f97316'
]
const newDomain = ref({
name: '',
color: '#3b82f6',
description: ''
})
// Check if we can show "create new" option
const canCreate = computed(() => {
if (!searchQuery.value.trim()) return false
const exactMatch = suggestions.value.find(
d => d.name.toLowerCase() === searchQuery.value.toLowerCase()
)
return !exactMatch
})
// Initialize from modelValue
watch(() => props.modelValue, (val) => {
if (val && val.name) {
searchQuery.value = val.name
} else {
searchQuery.value = ''
}
}, { immediate: true })
async function searchDomains(query) {
if (!query.trim()) {
suggestions.value = []
return
}
try {
const result = await configService.searchDomains(query)
suggestions.value = result.domains || []
} catch (error) {
console.error('Error searching domains:', error)
suggestions.value = []
}
}
function onInput() {
clearTimeout(debounceTimer)
selectedIndex.value = -1
if (!searchQuery.value.trim()) {
suggestions.value = []
emit('update:modelValue', null)
return
}
debounceTimer = setTimeout(() => {
searchDomains(searchQuery.value)
}, 300)
}
function onFocus() {
if (searchQuery.value.trim()) {
searchDomains(searchQuery.value)
}
showSuggestions.value = true
}
function onBlur() {
// Delay to allow click on suggestions
setTimeout(() => {
showSuggestions.value = false
selectedIndex.value = -1
}, 200)
}
function onKeydown(e) {
const totalItems = suggestions.value.length + (canCreate.value ? 1 : 0)
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (totalItems > 0) {
selectedIndex.value = Math.min(selectedIndex.value + 1, totalItems - 1)
}
break
case 'ArrowUp':
e.preventDefault()
if (totalItems > 0) {
selectedIndex.value = Math.max(selectedIndex.value - 1, -1)
}
break
case 'Enter':
e.preventDefault()
if (selectedIndex.value >= 0) {
if (selectedIndex.value < suggestions.value.length) {
selectDomain(suggestions.value[selectedIndex.value])
} else if (canCreate.value) {
openCreateModal()
}
} else if (suggestions.value.length > 0) {
selectDomain(suggestions.value[0])
}
break
case 'Escape':
showSuggestions.value = false
selectedIndex.value = -1
inputRef.value?.blur()
break
case 'Tab':
// Ne pas bloquer le Tab - laisser passer au champ suivant
// Si des suggestions sont affichées, les fermer
if (showSuggestions.value) {
showSuggestions.value = false
selectedIndex.value = -1
}
// Ne pas appeler e.preventDefault() pour permettre la navigation naturelle
break
}
}
function selectDomain(domain) {
searchQuery.value = domain.name
showSuggestions.value = false
selectedIndex.value = -1
emit('update:modelValue', domain)
nextTick(() => inputRef.value?.focus())
}
function highlightMatch(text) {
const query = searchQuery.value.toLowerCase()
const lowerText = text.toLowerCase()
if (lowerText.includes(query)) {
const index = lowerText.indexOf(query)
return (
text.substring(0, index) +
'<strong>' + text.substring(index, index + query.length) + '</strong>' +
text.substring(index + query.length)
)
}
return text
}
function openCreateModal() {
newDomain.value = {
name: searchQuery.value,
color: '#3b82f6',
description: ''
}
showSuggestions.value = false
showCreateModal.value = true
}
async function createDomain() {
if (!newDomain.value.name) return
creatingDomain.value = true
try {
const created = await configService.createDomain(newDomain.value)
showCreateModal.value = false
selectDomain(created)
notifications.success('Domaine créé avec succès')
} catch (error) {
console.error('Error creating domain:', error)
notifications.error(error.response?.data?.detail || 'Erreur lors de la création du domaine')
} finally {
creatingDomain.value = false
}
}
</script>