|
@@ -8,14 +8,31 @@
|
|
|
chips
|
|
chips
|
|
|
closable-chips
|
|
closable-chips
|
|
|
:menu-props="{ maxHeight: 400 }"
|
|
:menu-props="{ maxHeight: 400 }"
|
|
|
|
|
+ @update:menu="onMenuUpdate"
|
|
|
v-bind="$attrs"
|
|
v-bind="$attrs"
|
|
|
>
|
|
>
|
|
|
|
|
+ <template #prepend-item>
|
|
|
|
|
+ <v-text-field
|
|
|
|
|
+ v-model="searchText"
|
|
|
|
|
+ density="compact"
|
|
|
|
|
+ hide-details
|
|
|
|
|
+ placeholder="Rechercher..."
|
|
|
|
|
+ prepend-inner-icon="fas fa-magnifying-glass"
|
|
|
|
|
+ variant="outlined"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ class="mx-2 my-2"
|
|
|
|
|
+ @click.stop
|
|
|
|
|
+ @input="onSearchInput"
|
|
|
|
|
+ />
|
|
|
|
|
+ <v-divider class="mt-2"/>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
<template #selection="{ item, index }">
|
|
<template #selection="{ item, index }">
|
|
|
<v-chip
|
|
<v-chip
|
|
|
v-if="maxVisibleChips && index < maxVisibleChips"
|
|
v-if="maxVisibleChips && index < maxVisibleChips"
|
|
|
:key="item.raw.value"
|
|
:key="item.raw.value"
|
|
|
closable
|
|
closable
|
|
|
- @click:close="removeItem(item.raw.value)"
|
|
|
|
|
|
|
+ @click:close="removeItem(item.raw.value!)"
|
|
|
>
|
|
>
|
|
|
{{ item.raw.label }}
|
|
{{ item.raw.label }}
|
|
|
</v-chip>
|
|
</v-chip>
|
|
@@ -27,7 +44,7 @@
|
|
|
</span>
|
|
</span>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
- <template #item="{ props, item }">
|
|
|
|
|
|
|
+ <template #item="{ item }">
|
|
|
<template v-if="item.raw.type === 'category'">
|
|
<template v-if="item.raw.type === 'category'">
|
|
|
<v-list-item
|
|
<v-list-item
|
|
|
@click.stop="toggleCategory(item.raw.id)"
|
|
@click.stop="toggleCategory(item.raw.id)"
|
|
@@ -69,14 +86,14 @@
|
|
|
|
|
|
|
|
<template v-else>
|
|
<template v-else>
|
|
|
<v-list-item
|
|
<v-list-item
|
|
|
- @click="toggleItem(item.raw.value)"
|
|
|
|
|
- :active="selectedItems.includes(item.raw.value)"
|
|
|
|
|
|
|
+ @click="toggleItem(item.raw.value!)"
|
|
|
|
|
+ :active="selectedItems.includes(item.raw.value!)"
|
|
|
:class="{ 'pl-12': item.raw.level === 2, 'pl-8': item.raw.level === 1 }"
|
|
:class="{ 'pl-12': item.raw.level === 2, 'pl-8': item.raw.level === 1 }"
|
|
|
>
|
|
>
|
|
|
<template #prepend>
|
|
<template #prepend>
|
|
|
<v-checkbox
|
|
<v-checkbox
|
|
|
- :model-value="selectedItems.includes(item.raw.value)"
|
|
|
|
|
- @click.stop="toggleItem(item.raw.value)"
|
|
|
|
|
|
|
+ :model-value="selectedItems.includes(item.raw.value!)"
|
|
|
|
|
+ @click.stop="toggleItem(item.raw.value!)"
|
|
|
color="primary"
|
|
color="primary"
|
|
|
:hide-details="true"
|
|
:hide-details="true"
|
|
|
/>
|
|
/>
|
|
@@ -91,6 +108,8 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
|
|
+import StringUtils from '~/services/utils/stringUtils'
|
|
|
|
|
+
|
|
|
interface SelectItem {
|
|
interface SelectItem {
|
|
|
id: string
|
|
id: string
|
|
|
label: string
|
|
label: string
|
|
@@ -121,6 +140,47 @@ const emit = defineEmits(['update:modelValue'])
|
|
|
// État réactif
|
|
// État réactif
|
|
|
const expandedCategories = ref<Set<string>>(new Set())
|
|
const expandedCategories = ref<Set<string>>(new Set())
|
|
|
const expandedSubcategories = ref<Set<string>>(new Set())
|
|
const expandedSubcategories = ref<Set<string>>(new Set())
|
|
|
|
|
+const searchText = ref('')
|
|
|
|
|
+
|
|
|
|
|
+// Fonction pour gérer l'entrée de recherche
|
|
|
|
|
+const onSearchInput = () => {
|
|
|
|
|
+ // Réinitialiser les états d'expansion dans tous les cas
|
|
|
|
|
+ expandedCategories.value.clear()
|
|
|
|
|
+ expandedSubcategories.value.clear()
|
|
|
|
|
+
|
|
|
|
|
+ if (searchText.value.trim()) {
|
|
|
|
|
+ // Trouver tous les éléments qui correspondent à la recherche
|
|
|
|
|
+ const matchingItems = props.items.filter(item =>
|
|
|
|
|
+ item.type === 'item' &&
|
|
|
|
|
+ item.level === 2 &&
|
|
|
|
|
+ itemMatchesSearch(item)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // Pour chaque élément correspondant, ajouter ses parents aux ensembles d'expansion
|
|
|
|
|
+ for (const item of matchingItems) {
|
|
|
|
|
+ // Trouver et ajouter la sous-catégorie parente
|
|
|
|
|
+ const subcategory = props.items.find(i => i.id === item.parentId)
|
|
|
|
|
+ if (subcategory) {
|
|
|
|
|
+ expandedSubcategories.value.add(subcategory.id)
|
|
|
|
|
+
|
|
|
|
|
+ // Trouver et ajouter la catégorie parente
|
|
|
|
|
+ const category = props.items.find(i => i.id === subcategory.parentId)
|
|
|
|
|
+ if (category) {
|
|
|
|
|
+ expandedCategories.value.add(category.id)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // Si la recherche est vide, toutes les catégories restent repliées (déjà réinitialisées)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Fonction pour gérer l'ouverture/fermeture du menu
|
|
|
|
|
+const onMenuUpdate = (isOpen: boolean) => {
|
|
|
|
|
+ // Réinitialiser la recherche quand le menu se ferme
|
|
|
|
|
+ if (!isOpen && searchText.value) {
|
|
|
|
|
+ searchText.value = ''
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
// Modèle v-model
|
|
// Modèle v-model
|
|
|
const selectedItems = computed({
|
|
const selectedItems = computed({
|
|
@@ -128,10 +188,86 @@ const selectedItems = computed({
|
|
|
set: (value: string[]) => emit('update:modelValue', value)
|
|
set: (value: string[]) => emit('update:modelValue', value)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+// Fonction pour vérifier si un élément correspond à la recherche
|
|
|
|
|
+const itemMatchesSearch = (item: SelectItem): boolean => {
|
|
|
|
|
+ if (!searchText.value) return true
|
|
|
|
|
+
|
|
|
|
|
+ const normalizedSearch = StringUtils.normalize(searchText.value)
|
|
|
|
|
+
|
|
|
|
|
+ // Si c'est un élément de niveau 2, vérifier son label et les labels de ses parents
|
|
|
|
|
+ if (item.type === 'item' && item.level === 2) {
|
|
|
|
|
+ // Vérifier le label de l'élément
|
|
|
|
|
+ if (StringUtils.normalize(item.label).includes(normalizedSearch)) return true
|
|
|
|
|
+
|
|
|
|
|
+ // Trouver et vérifier le label de la sous-catégorie parente
|
|
|
|
|
+ const subcategory = props.items.find(i => i.id === item.parentId)
|
|
|
|
|
+ if (subcategory && StringUtils.normalize(subcategory.label).includes(normalizedSearch)) return true
|
|
|
|
|
+
|
|
|
|
|
+ // Trouver et vérifier le label de la catégorie parente
|
|
|
|
|
+ if (subcategory && subcategory.parentId) {
|
|
|
|
|
+ const category = props.items.find(i => i.id === subcategory.parentId)
|
|
|
|
|
+ if (category && StringUtils.normalize(category.label).includes(normalizedSearch)) return true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Pour les autres types d'éléments, vérifier simplement leur label
|
|
|
|
|
+ return StringUtils.normalize(item.label).includes(normalizedSearch)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// Construction de la liste aplatie pour v-select
|
|
// Construction de la liste aplatie pour v-select
|
|
|
const flattenedItems = computed(() => {
|
|
const flattenedItems = computed(() => {
|
|
|
const result: SelectItem[] = []
|
|
const result: SelectItem[] = []
|
|
|
|
|
+ const hasSearch = !!searchText.value.trim()
|
|
|
|
|
+
|
|
|
|
|
+ // Si une recherche est active, afficher uniquement les éléments de niveau 2 qui correspondent
|
|
|
|
|
+ if (hasSearch) {
|
|
|
|
|
+ // Trouver tous les éléments de niveau 2 qui correspondent à la recherche
|
|
|
|
|
+ const matchingItems = props.items.filter(item =>
|
|
|
|
|
+ item.type === 'item' &&
|
|
|
|
|
+ item.level === 2 &&
|
|
|
|
|
+ itemMatchesSearch(item)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // Ensemble pour suivre les catégories et sous-catégories déjà ajoutées
|
|
|
|
|
+ const addedCategoryIds = new Set<string>()
|
|
|
|
|
+ const addedSubcategoryIds = new Set<string>()
|
|
|
|
|
+
|
|
|
|
|
+ // Pour chaque élément correspondant, ajouter sa hiérarchie complète
|
|
|
|
|
+ for (const item of matchingItems) {
|
|
|
|
|
+ // Trouver la sous-catégorie parente
|
|
|
|
|
+ const subcategory = props.items.find(i => i.id === item.parentId)
|
|
|
|
|
+ if (!subcategory) continue
|
|
|
|
|
+
|
|
|
|
|
+ // Trouver la catégorie parente
|
|
|
|
|
+ const category = props.items.find(i => i.id === subcategory.parentId)
|
|
|
|
|
+ if (!category) continue
|
|
|
|
|
+
|
|
|
|
|
+ // Ajouter la catégorie si elle n'est pas déjà présente
|
|
|
|
|
+ if (!addedCategoryIds.has(category.id)) {
|
|
|
|
|
+ result.push(category)
|
|
|
|
|
+ addedCategoryIds.add(category.id)
|
|
|
|
|
+ // S'assurer que la catégorie est considérée comme "expanded" pendant la recherche
|
|
|
|
|
+ expandedCategories.value.add(category.id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Ajouter la sous-catégorie si elle n'est pas déjà présente
|
|
|
|
|
+ if (!addedSubcategoryIds.has(subcategory.id)) {
|
|
|
|
|
+ result.push(subcategory)
|
|
|
|
|
+ addedSubcategoryIds.add(subcategory.id)
|
|
|
|
|
+ // S'assurer que la sous-catégorie est considérée comme "expanded" pendant la recherche
|
|
|
|
|
+ expandedSubcategories.value.add(subcategory.id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Ajouter l'élément
|
|
|
|
|
+ result.push(item)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ return result
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Comportement normal sans recherche
|
|
|
const processItems = (items: SelectItem[], parentExpanded = true) => {
|
|
const processItems = (items: SelectItem[], parentExpanded = true) => {
|
|
|
for (const item of items) {
|
|
for (const item of items) {
|
|
|
if (item.type === 'category') {
|
|
if (item.type === 'category') {
|
|
@@ -210,4 +346,16 @@ const removeItem = (value: string) => {
|
|
|
.v-list-item--active {
|
|
.v-list-item--active {
|
|
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+.search-icon {
|
|
|
|
|
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:deep(.v-field__prepend-inner) {
|
|
|
|
|
+ padding-top: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:deep(.v-list) {
|
|
|
|
|
+ padding-top: 0;
|
|
|
|
|
+}
|
|
|
</style>
|
|
</style>
|