|
|
@@ -1,12 +1,12 @@
|
|
|
<!--
|
|
|
-Composant de sélection hiérarchique à plusieurs niveaux permettant de naviguer
|
|
|
-et sélectionner des éléments organisés en catégories et sous-catégories.
|
|
|
+Composant de sélection hiérarchique simplifié pour les arbres de ressources.
|
|
|
+Les items fournis sont déjà triés, traduits et structurés au format ResourceTreeContent.
|
|
|
|
|
|
## Exemple d'utilisation
|
|
|
```vue
|
|
|
<TreeSelect
|
|
|
v-model="selectedValues"
|
|
|
- :items="hierarchicalItems"
|
|
|
+ :items="resourceTreeContent"
|
|
|
:max-visible-chips="3"
|
|
|
label="Sélectionner des éléments"
|
|
|
/>
|
|
|
@@ -15,7 +15,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
|
|
|
<template>
|
|
|
<v-select
|
|
|
:model-value="modelValue"
|
|
|
- :label="$t(label)"
|
|
|
+ :label="label"
|
|
|
v-bind="$attrs"
|
|
|
:items="flattenedItems"
|
|
|
item-title="label"
|
|
|
@@ -23,30 +23,8 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
|
|
|
:variant="variant"
|
|
|
multiple
|
|
|
:menu-props="{ maxHeight: 400 }"
|
|
|
- @update:menu="onMenuUpdate"
|
|
|
@update:model-value="$emit('update:modelValue', $event)"
|
|
|
>
|
|
|
- <template #prepend-item>
|
|
|
- <!-- Champs de recherche textuelle -->
|
|
|
- <v-text-field
|
|
|
- ref="searchInput"
|
|
|
- v-model="searchText"
|
|
|
- density="compact"
|
|
|
- hide-details
|
|
|
- :placeholder="$t('search') + '...'"
|
|
|
- prepend-inner-icon="fas fa-magnifying-glass"
|
|
|
- variant="outlined"
|
|
|
- clearable
|
|
|
- class="mx-2 my-2"
|
|
|
- @click.stop="focusSearchInput"
|
|
|
- @mousedown.stop
|
|
|
- @keydown.stop="onKeyDown"
|
|
|
- @input="onSearchInputDebounced"
|
|
|
- @click:clear.stop="onSearchClear"
|
|
|
- />
|
|
|
- <v-divider class="mt-2" />
|
|
|
- </template>
|
|
|
-
|
|
|
<template #selection="{ item, index }">
|
|
|
<v-chip
|
|
|
v-if="maxVisibleChips && index < maxVisibleChips"
|
|
|
@@ -54,7 +32,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
|
|
|
closable
|
|
|
@click:close="removeItem(String(item.raw.value!))"
|
|
|
>
|
|
|
- {{ selectedItemsMap[item.raw.value] }}
|
|
|
+ {{ item.raw.label }}
|
|
|
</v-chip>
|
|
|
<span
|
|
|
v-if="
|
|
|
@@ -64,7 +42,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
|
|
|
"
|
|
|
class="text-grey text-caption align-self-center"
|
|
|
>
|
|
|
- (+{{ modelValue.length - maxVisibleChips }} {{ $t('others') }})
|
|
|
+ (+{{ modelValue.length - maxVisibleChips }} autres)
|
|
|
</span>
|
|
|
</template>
|
|
|
|
|
|
@@ -148,19 +126,16 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import StringUtils from '~/services/utils/stringUtils'
|
|
|
-import _ from 'lodash'
|
|
|
-import { ref, computed, nextTick, type PropType, type Ref } from 'vue'
|
|
|
-import type { TreeSelectItem } from '~/types/layout'
|
|
|
+import { ref, computed, type PropType, type Ref } from 'vue'
|
|
|
+import type { ResourceTreeContent } from '~/types/data'
|
|
|
|
|
|
-interface SelectItem {
|
|
|
+interface FlattenedItem {
|
|
|
id: string
|
|
|
label: string
|
|
|
- normalizedLabel?: string
|
|
|
- value?: number
|
|
|
+ value?: string
|
|
|
type: 'category' | 'subcategory' | 'item'
|
|
|
- parentId?: string
|
|
|
level: number
|
|
|
+ parentId?: string
|
|
|
}
|
|
|
|
|
|
const props = defineProps({
|
|
|
@@ -169,7 +144,7 @@ const props = defineProps({
|
|
|
required: true,
|
|
|
},
|
|
|
items: {
|
|
|
- type: Array as PropType<TreeSelectItem[]>,
|
|
|
+ type: [Array, Object] as PropType<ResourceTreeContent>,
|
|
|
required: true,
|
|
|
},
|
|
|
maxVisibleChips: {
|
|
|
@@ -177,23 +152,11 @@ const props = defineProps({
|
|
|
required: false,
|
|
|
default: null,
|
|
|
},
|
|
|
- nbLevel: {
|
|
|
- type: Number,
|
|
|
- required: false,
|
|
|
- default: 3,
|
|
|
- },
|
|
|
- /**
|
|
|
- * Label du champ
|
|
|
- * Si non défini, c'est le nom de propriété qui est utilisé
|
|
|
- */
|
|
|
label: {
|
|
|
type: String,
|
|
|
required: false,
|
|
|
- default: null,
|
|
|
+ default: '',
|
|
|
},
|
|
|
- /**
|
|
|
- * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
|
|
|
- */
|
|
|
variant: {
|
|
|
type: String as PropType<
|
|
|
| 'filled'
|
|
|
@@ -210,131 +173,103 @@ const props = defineProps({
|
|
|
},
|
|
|
})
|
|
|
|
|
|
-const searchInput = ref()
|
|
|
-
|
|
|
-/**
|
|
|
- * Force le focus sur l'input de recherche
|
|
|
- */
|
|
|
-const focusSearchInput = () => {
|
|
|
- nextTick(() => {
|
|
|
- if (searchInput.value?.$el) {
|
|
|
- const input = searchInput.value.$el.querySelector('input')
|
|
|
- if (input) {
|
|
|
- input.focus()
|
|
|
- }
|
|
|
- }
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Gère les événements clavier pour éviter les conflits avec la navigation du menu
|
|
|
- */
|
|
|
-const onKeyDown = (event: KeyboardEvent) => {
|
|
|
- // Empêcher la propagation pour tous les caractères alphanumériques
|
|
|
- // et les touches spéciales de navigation
|
|
|
- if (
|
|
|
- event.key.length === 1 || // Caractères simples (a, c, etc.)
|
|
|
- ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(
|
|
|
- event.key,
|
|
|
- )
|
|
|
- ) {
|
|
|
- event.stopPropagation()
|
|
|
- }
|
|
|
-
|
|
|
- // Empêcher également Escape de fermer le menu quand on est dans l'input
|
|
|
- if (event.key === 'Escape') {
|
|
|
- event.stopPropagation()
|
|
|
- // Optionnel : vider le texte de recherche
|
|
|
- if (searchText.value) {
|
|
|
- searchText.value = ''
|
|
|
- onSearchClear()
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-/**
|
|
|
- * A computed property that normalizes the labels of all items upfront.
|
|
|
- * This avoids having to normalize labels during each search operation.
|
|
|
- */
|
|
|
-const normalizedItems = computed(() => {
|
|
|
- return props.items.map((item) => ({
|
|
|
- ...item,
|
|
|
- normalizedLabel: StringUtils.normalize(item.label),
|
|
|
- }))
|
|
|
-})
|
|
|
-
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
|
|
const expandedCategories: Ref<Set<string>> = ref(new Set())
|
|
|
const expandedSubcategories: Ref<Set<string>> = ref(new Set())
|
|
|
-const searchText: Ref<string> = ref('')
|
|
|
|
|
|
/**
|
|
|
- * Expands all parent categories and subcategories of selected items.
|
|
|
+ * Flatten the ResourceTreeContent into a flat array for v-select
|
|
|
*/
|
|
|
-const expandParentsOfSelectedItems = () => {
|
|
|
- expandedCategories.value.clear()
|
|
|
- expandedSubcategories.value.clear()
|
|
|
-
|
|
|
- for (const selectedId of props.modelValue) {
|
|
|
- const item = normalizedItems.value.find(
|
|
|
- (i) => i.value === Number(selectedId),
|
|
|
- )
|
|
|
- if (!item) continue
|
|
|
-
|
|
|
- let parentId = null
|
|
|
- if (props.nbLevel === 3) {
|
|
|
- // Trouver la sous-catégorie
|
|
|
- const subcategory = normalizedItems.value.find(
|
|
|
- (i) => i.id === item.parentId,
|
|
|
- )
|
|
|
- if (subcategory) {
|
|
|
- expandedSubcategories.value.add(subcategory.id)
|
|
|
- parentId = subcategory.parentId
|
|
|
- }
|
|
|
- } else {
|
|
|
- parentId = item.parentId
|
|
|
- }
|
|
|
-
|
|
|
- if (parentId) {
|
|
|
- // Trouver la catégorie
|
|
|
- const category = normalizedItems.value.find((i) => i.id === parentId)
|
|
|
- if (category) {
|
|
|
- expandedCategories.value.add(category.id)
|
|
|
+const flattenedItems = computed(() => {
|
|
|
+ const items: FlattenedItem[] = []
|
|
|
+ let itemCounter = 0
|
|
|
+
|
|
|
+ if (Array.isArray(props.items)) {
|
|
|
+ // Simple array of strings
|
|
|
+ props.items.forEach((item) => {
|
|
|
+ items.push({
|
|
|
+ id: `item-${itemCounter++}`,
|
|
|
+ label: item,
|
|
|
+ value: item,
|
|
|
+ type: 'item',
|
|
|
+ level: 0,
|
|
|
+ })
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ // Object structure (2 or 3 levels)
|
|
|
+ Object.entries(props.items).forEach(([categoryKey, categoryValue]) => {
|
|
|
+ const categoryId = `category-${categoryKey}`
|
|
|
+
|
|
|
+ // Add category
|
|
|
+ items.push({
|
|
|
+ id: categoryId,
|
|
|
+ label: categoryKey,
|
|
|
+ type: 'category',
|
|
|
+ level: 0,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (Array.isArray(categoryValue)) {
|
|
|
+ // 2-level structure: Record<string, string[]>
|
|
|
+ if (expandedCategories.value.has(categoryId)) {
|
|
|
+ categoryValue.forEach((item) => {
|
|
|
+ items.push({
|
|
|
+ id: `item-${itemCounter++}`,
|
|
|
+ label: item,
|
|
|
+ value: item,
|
|
|
+ type: 'item',
|
|
|
+ level: 1,
|
|
|
+ parentId: categoryId,
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 3-level structure: Record<string, Record<string, string[]>>
|
|
|
+ if (expandedCategories.value.has(categoryId)) {
|
|
|
+ Object.entries(categoryValue).forEach(([subcategoryKey, subcategoryValue]) => {
|
|
|
+ const subcategoryId = `subcategory-${categoryKey}-${subcategoryKey}`
|
|
|
+
|
|
|
+ // Add subcategory
|
|
|
+ items.push({
|
|
|
+ id: subcategoryId,
|
|
|
+ label: subcategoryKey,
|
|
|
+ type: 'subcategory',
|
|
|
+ level: 1,
|
|
|
+ parentId: categoryId,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (expandedSubcategories.value.has(subcategoryId)) {
|
|
|
+ subcategoryValue.forEach((item) => {
|
|
|
+ items.push({
|
|
|
+ id: `item-${itemCounter++}`,
|
|
|
+ label: item,
|
|
|
+ value: item,
|
|
|
+ type: 'item',
|
|
|
+ level: 2,
|
|
|
+ parentId: subcategoryId,
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
+ })
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-/**
|
|
|
- * A callback function that is triggered when the menu's open state is updated.
|
|
|
- */
|
|
|
-const onMenuUpdate = (isOpen: boolean) => {
|
|
|
- if (isOpen) {
|
|
|
- expandParentsOfSelectedItems()
|
|
|
- }
|
|
|
- // Réinitialiser la recherche quand le menu se ferme
|
|
|
- else if (searchText.value) {
|
|
|
- searchText.value = ''
|
|
|
- onSearchInput()
|
|
|
- }
|
|
|
-}
|
|
|
+ return items
|
|
|
+})
|
|
|
|
|
|
/**
|
|
|
- * Toggles the expanded state of a given category. If the category is currently
|
|
|
- * expanded, it will collapse the category and also collapse its subcategories.
|
|
|
- * If the category is not expanded, it will expand the category.
|
|
|
- *
|
|
|
- * @param {string} categoryId - The unique identifier of the category to toggle.
|
|
|
+ * Toggle category expansion
|
|
|
*/
|
|
|
const toggleCategory = (categoryId: string) => {
|
|
|
if (expandedCategories.value.has(categoryId)) {
|
|
|
expandedCategories.value.delete(categoryId)
|
|
|
- // Fermer aussi les sous-catégories
|
|
|
- const subcategories = normalizedItems.value.filter(
|
|
|
- (i) => i.parentId === categoryId && i.type === 'subcategory',
|
|
|
- )
|
|
|
- subcategories.forEach((sub) => {
|
|
|
- expandedSubcategories.value.delete(sub.id)
|
|
|
+ // Also collapse all subcategories
|
|
|
+ Array.from(expandedSubcategories.value).forEach(subId => {
|
|
|
+ if (subId.startsWith(`subcategory-${categoryId.replace('category-', '')}-`)) {
|
|
|
+ expandedSubcategories.value.delete(subId)
|
|
|
+ }
|
|
|
})
|
|
|
} else {
|
|
|
expandedCategories.value.add(categoryId)
|
|
|
@@ -342,9 +277,7 @@ const toggleCategory = (categoryId: string) => {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Toggles the expansion state of a subcategory.
|
|
|
- *
|
|
|
- * @param {string} subcategoryId - The unique identifier of the subcategory to be toggled.
|
|
|
+ * Toggle subcategory expansion
|
|
|
*/
|
|
|
const toggleSubcategory = (subcategoryId: string) => {
|
|
|
if (expandedSubcategories.value.has(subcategoryId)) {
|
|
|
@@ -355,10 +288,7 @@ const toggleSubcategory = (subcategoryId: string) => {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * A function that toggles the inclusion of a specific value in
|
|
|
- * the selected items list.
|
|
|
- *
|
|
|
- * @param {string} value - The value to toggle in the selected items list.
|
|
|
+ * Toggle item selection
|
|
|
*/
|
|
|
const toggleItem = (value: string) => {
|
|
|
const currentSelection = [...props.modelValue]
|
|
|
@@ -374,11 +304,7 @@ const toggleItem = (value: string) => {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Removes the specified item from the model value and emits an update event.
|
|
|
- *
|
|
|
- * @param {string} value - The item to be removed from the model value.
|
|
|
- * @emits update:modelValue - A custom event emitted with the updated model value
|
|
|
- * after the specified item has been removed.
|
|
|
+ * Remove item from selection
|
|
|
*/
|
|
|
const removeItem = (value: string) => {
|
|
|
emit(
|
|
|
@@ -386,338 +312,4 @@ const removeItem = (value: string) => {
|
|
|
props.modelValue.filter((item) => item !== value),
|
|
|
)
|
|
|
}
|
|
|
-
|
|
|
-/**
|
|
|
- * Fonction appellée lorsque l'input de recherche textuelle est modifié
|
|
|
- */
|
|
|
-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 = normalizedItems.value.filter(
|
|
|
- (item) =>
|
|
|
- item.type === 'item' &&
|
|
|
- item.level === props.nbLevel - 1 &&
|
|
|
- itemMatchesSearch(item),
|
|
|
- )
|
|
|
- // Pour chaque élément correspondant, ajouter ses parents aux ensembles d'expansion
|
|
|
- for (const item of matchingItems) {
|
|
|
- let category
|
|
|
- if (props.nbLevel === 3) {
|
|
|
- // Trouver et ajouter la sous-catégorie parente
|
|
|
- const subcategory = normalizedItems.value.find(
|
|
|
- (i) => i.id === item.parentId,
|
|
|
- )
|
|
|
- if (subcategory) {
|
|
|
- expandedSubcategories.value.add(subcategory.id)
|
|
|
-
|
|
|
- // Trouver et ajouter la catégorie parente
|
|
|
- category = normalizedItems.value.find(
|
|
|
- (i) => i.id === subcategory.parentId,
|
|
|
- )
|
|
|
- }
|
|
|
- } else {
|
|
|
- // Trouver et ajouter la catégorie parente
|
|
|
- category = normalizedItems.value.find((i) => i.id === item.parentId)
|
|
|
- }
|
|
|
-
|
|
|
- if (category) {
|
|
|
- expandedCategories.value.add(category.id)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const onSearchInputDebounced = _.debounce(onSearchInput, 200)
|
|
|
-
|
|
|
-const onSearchClear = () => {
|
|
|
- searchText.value = ''
|
|
|
- onSearchInput()
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Checks if any word in the normalized text starts with the normalized search term.
|
|
|
- *
|
|
|
- * @param {string} normalizedText - The normalized text to check.
|
|
|
- * @param {string} normalizedSearch - The normalized search term.
|
|
|
- * @returns {boolean} `true` if any word in the text starts with the search term; otherwise, `false`.
|
|
|
- */
|
|
|
-const anyWordStartsWith = (
|
|
|
- normalizedText: string,
|
|
|
- normalizedSearch: string,
|
|
|
-): boolean => {
|
|
|
- if (normalizedText.indexOf(normalizedSearch) === 0) return true
|
|
|
-
|
|
|
- const spaceIndex = normalizedText.indexOf(' ')
|
|
|
- if (spaceIndex === -1) return false
|
|
|
-
|
|
|
- return normalizedText
|
|
|
- .split(' ')
|
|
|
- .some((word) => word.startsWith(normalizedSearch))
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Determines if a given item matches the current search text by checking its normalized label
|
|
|
- * and, for certain items, the normalized labels of its parent elements.
|
|
|
- *
|
|
|
- * The search text is normalized using `StringUtils.normalize` before comparison.
|
|
|
- * If no search text is provided, the item matches by default.
|
|
|
- *
|
|
|
- * For items of type `item` at level 2, the function checks:
|
|
|
- * - The normalized label of the item itself
|
|
|
- * - The normalized label of its parent subcategory
|
|
|
- * - The normalized label of the grandparent category (if applicable)
|
|
|
- *
|
|
|
- * For all other item types, only the item's normalized label is checked.
|
|
|
- *
|
|
|
- * The matching is done by checking if any word in the normalized label starts with the normalized search text.
|
|
|
- *
|
|
|
- * @param {TreeSelectItem} item - The item to evaluate against the search text.
|
|
|
- * @returns {boolean} `true` if the item or its relevant parent(s) match the search text; otherwise, `false`.
|
|
|
- */
|
|
|
-const itemMatchesSearch = (item: TreeSelectItem): boolean => {
|
|
|
- if (!searchText.value) return true
|
|
|
-
|
|
|
- const normalizedSearch = StringUtils.normalize(searchText.value)
|
|
|
-
|
|
|
- // Find the item with normalized label from our computed property
|
|
|
- const itemWithNormalizedLabel = normalizedItems.value.find(
|
|
|
- (i) => i.id === item.id,
|
|
|
- )
|
|
|
- if (!itemWithNormalizedLabel) return false
|
|
|
-
|
|
|
- // Si c'est un élément de niveau nbLevel - 1, vérifier son label et les labels de ses parents
|
|
|
- if (item.type === 'item' && item.level === props.nbLevel - 1) {
|
|
|
- // Vérifier le label de l'élément
|
|
|
- if (
|
|
|
- anyWordStartsWith(
|
|
|
- itemWithNormalizedLabel.normalizedLabel!,
|
|
|
- normalizedSearch,
|
|
|
- )
|
|
|
- )
|
|
|
- return true
|
|
|
-
|
|
|
- let parentId = item.parentId
|
|
|
- if (props.nbLevel === 3) {
|
|
|
- // Trouver et vérifier le label de la sous-catégorie parente
|
|
|
- const subcategory = normalizedItems.value.find((i) => i.id === parentId)
|
|
|
- if (
|
|
|
- subcategory &&
|
|
|
- anyWordStartsWith(subcategory.normalizedLabel!, normalizedSearch)
|
|
|
- )
|
|
|
- return true
|
|
|
-
|
|
|
- // Trouver et vérifier le label de la catégorie parente
|
|
|
- if (subcategory && subcategory.parentId) {
|
|
|
- parentId = subcategory.parentId
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Trouver et vérifier le label de la catégorie parente
|
|
|
- const category = normalizedItems.value.find((i) => i.id === parentId)
|
|
|
- if (
|
|
|
- category &&
|
|
|
- anyWordStartsWith(category.normalizedLabel!, normalizedSearch)
|
|
|
- )
|
|
|
- return true
|
|
|
-
|
|
|
- return false
|
|
|
- }
|
|
|
-
|
|
|
- // Pour les autres types d'éléments, vérifier simplement leur label
|
|
|
- return anyWordStartsWith(
|
|
|
- itemWithNormalizedLabel.normalizedLabel!,
|
|
|
- normalizedSearch,
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Filtre les éléments de niveau 2 qui correspondent au texte de recherche.
|
|
|
- *
|
|
|
- * @returns {TreeSelectItem[]} Les éléments de niveau 2 qui correspondent à la recherche.
|
|
|
- */
|
|
|
-const findMatchingLevel2Items = (): TreeSelectItem[] => {
|
|
|
- return normalizedItems.value.filter(
|
|
|
- (item) =>
|
|
|
- item.type === 'item' &&
|
|
|
- item.level === props.nbLevel - 1 &&
|
|
|
- itemMatchesSearch(item),
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Construit une liste hiérarchique d'éléments basée sur les résultats de recherche.
|
|
|
- * Pour chaque élément correspondant, ajoute sa hiérarchie complète (catégorie et sous-catégorie).
|
|
|
- *
|
|
|
- * @param {TreeSelectItem[]} matchingItems - Les éléments correspondant à la recherche.
|
|
|
- * @returns {TreeSelectItem[]} Liste hiérarchique incluant les éléments et leurs parents.
|
|
|
- */
|
|
|
-const buildSearchResultsList = (
|
|
|
- matchingItems: TreeSelectItem[],
|
|
|
-): TreeSelectItem[] => {
|
|
|
- const result: TreeSelectItem[] = []
|
|
|
- const addedCategoryIds = new Set<string>()
|
|
|
- const addedSubcategoryIds = new Set<string>()
|
|
|
-
|
|
|
- for (const item of matchingItems) {
|
|
|
- let parentId = item.parentId
|
|
|
- if (props.nbLevel === 3) {
|
|
|
- // Trouver la sous-catégorie parente
|
|
|
- const subcategory = normalizedItems.value.find((i) => i.id === parentId)
|
|
|
- if (!subcategory) continue
|
|
|
-
|
|
|
- // 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)
|
|
|
- expandedSubcategories.value.add(subcategory.id)
|
|
|
- }
|
|
|
- parentId = subcategory.parentId
|
|
|
- }
|
|
|
-
|
|
|
- // Trouver la catégorie parente
|
|
|
- const category = normalizedItems.value.find((i) => i.id === 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)
|
|
|
- expandedCategories.value.add(category.id)
|
|
|
- }
|
|
|
-
|
|
|
- // Ajouter l'élément
|
|
|
- result.push(item)
|
|
|
- }
|
|
|
-
|
|
|
- return result
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Traite récursivement les éléments pour construire une liste hiérarchique
|
|
|
- * basée sur l'état d'expansion des catégories et sous-catégories.
|
|
|
- *
|
|
|
- * @param {TreeSelectItem[]} items - Les éléments à traiter.
|
|
|
- * @param {TreeSelectItem[]} result - Le tableau résultat à remplir.
|
|
|
- * @param {boolean} parentExpanded - Indique si le parent est développé.
|
|
|
- */
|
|
|
-const processItemsRecursively = (
|
|
|
- items: TreeSelectItem[],
|
|
|
- result: TreeSelectItem[],
|
|
|
- parentExpanded = true,
|
|
|
-): void => {
|
|
|
- for (const item of items) {
|
|
|
- if (item.type === 'category') {
|
|
|
- result.push(item)
|
|
|
- if (expandedCategories.value.has(item.id)) {
|
|
|
- const subcategories = normalizedItems.value.filter(
|
|
|
- (i) =>
|
|
|
- i.parentId === item.id &&
|
|
|
- ((props.nbLevel == 2 && i.type === 'item') ||
|
|
|
- (props.nbLevel == 3 && i.type === 'subcategory')),
|
|
|
- )
|
|
|
- processItemsRecursively(subcategories, result, true)
|
|
|
- }
|
|
|
- } else if (item.type === 'subcategory') {
|
|
|
- if (parentExpanded) {
|
|
|
- result.push(item)
|
|
|
- if (expandedSubcategories.value.has(item.id)) {
|
|
|
- const subItems = normalizedItems.value.filter(
|
|
|
- (i) => i.parentId === item.id && i.type === 'item',
|
|
|
- )
|
|
|
- processItemsRecursively(subItems, result, true)
|
|
|
- }
|
|
|
- }
|
|
|
- } else if (item.type === 'item' && parentExpanded) {
|
|
|
- result.push(item)
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Construit une liste hiérarchique d'éléments en mode normal (sans recherche).
|
|
|
- *
|
|
|
- * @returns {TreeSelectItem[]} Liste hiérarchique basée sur l'état d'expansion.
|
|
|
- */
|
|
|
-const buildNormalModeList = (): TreeSelectItem[] => {
|
|
|
- const result: TreeSelectItem[] = []
|
|
|
- const topLevelItems = normalizedItems.value.filter((item) => !item.parentId)
|
|
|
- processItemsRecursively(topLevelItems, result)
|
|
|
- return result
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * A computed property that generates a flattened and organized list of items
|
|
|
- * from a hierarchical structure, based on the current search text and
|
|
|
- * expanded categories/subcategories.
|
|
|
- *
|
|
|
- * @returns {TreeSelectItem[]} Flattened and organized list of items.
|
|
|
- */
|
|
|
-const flattenedItems = computed(() => {
|
|
|
- const hasSearch = !!searchText.value.trim()
|
|
|
-
|
|
|
- if (hasSearch) {
|
|
|
- const matchingItems = findMatchingLevel2Items()
|
|
|
- return buildSearchResultsList(matchingItems)
|
|
|
- }
|
|
|
-
|
|
|
- return buildNormalModeList()
|
|
|
-})
|
|
|
-
|
|
|
-/**
|
|
|
- * A computed property that maps selected values to their corresponding labels.
|
|
|
- * This is used to display the correct labels in the chips when the dropdown is closed.
|
|
|
- *
|
|
|
- * @returns {Record<string, string>} A map of selected values to their labels.
|
|
|
- */
|
|
|
-const selectedItemsMap = computed(() => {
|
|
|
- const map: Record<string, string> = {}
|
|
|
-
|
|
|
- // Find all selectable items (type 'item') in the items array with normalized labels
|
|
|
- const selectableItems = normalizedItems.value.filter(
|
|
|
- (item) => item.type === 'item' && item.value,
|
|
|
- )
|
|
|
-
|
|
|
- // Create a map of values to labels
|
|
|
- selectableItems.forEach((item) => {
|
|
|
- if (item.value) {
|
|
|
- map[item.value] = item.label
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- return map
|
|
|
-})
|
|
|
</script>
|
|
|
-
|
|
|
-<style scoped lang="scss">
|
|
|
-.v-list-item--active {
|
|
|
- background-color: rgba(var(--v-theme-primary), 0.1);
|
|
|
-}
|
|
|
-
|
|
|
-.v-list-item {
|
|
|
- contain: layout style paint;
|
|
|
- height: 40px !important; /* Ensure consistent height for virtual scrolling */
|
|
|
- min-height: 40px !important;
|
|
|
- max-height: 40px !important;
|
|
|
- padding-top: 0 !important;
|
|
|
- padding-bottom: 0 !important;
|
|
|
-}
|
|
|
-
|
|
|
-.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;
|
|
|
- contain: content;
|
|
|
- will-change: transform;
|
|
|
- transform-style: preserve-3d;
|
|
|
-}
|
|
|
-</style>
|