|
|
@@ -1,28 +1,45 @@
|
|
|
+<!--
|
|
|
+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.
|
|
|
+
|
|
|
+## Exemple d'utilisation
|
|
|
+```vue
|
|
|
+<TreeSelect
|
|
|
+ v-model="selectedValues"
|
|
|
+ :items="hierarchicalItems"
|
|
|
+ :max-visible-chips="3"
|
|
|
+ label="Sélectionner des éléments"
|
|
|
+/>
|
|
|
+```
|
|
|
+-->
|
|
|
<template>
|
|
|
<v-select
|
|
|
- v-model="selectedItems"
|
|
|
+ :model-value="modelValue"
|
|
|
+ v-bind="$attrs"
|
|
|
:items="flattenedItems"
|
|
|
item-title="label"
|
|
|
item-value="value"
|
|
|
multiple
|
|
|
chips
|
|
|
closable-chips
|
|
|
- :menu-props="{ maxHeight: 400 }"
|
|
|
+ :menu-props="{ maxHeight: 500 }"
|
|
|
@update:menu="onMenuUpdate"
|
|
|
- v-bind="$attrs"
|
|
|
+ @update:model-value="$emit('update:modelValue', $event)"
|
|
|
>
|
|
|
<template #prepend-item>
|
|
|
+ <!-- Champs de recherche textuelle -->
|
|
|
<v-text-field
|
|
|
v-model="searchText"
|
|
|
density="compact"
|
|
|
hide-details
|
|
|
- placeholder="Rechercher..."
|
|
|
+ :placeholder="$t('search') + '...'"
|
|
|
prepend-inner-icon="fas fa-magnifying-glass"
|
|
|
variant="outlined"
|
|
|
clearable
|
|
|
class="mx-2 my-2"
|
|
|
@click.stop
|
|
|
@input="onSearchInput"
|
|
|
+ @click:clear="onSearchInput"
|
|
|
/>
|
|
|
<v-divider class="mt-2"/>
|
|
|
</template>
|
|
|
@@ -37,10 +54,10 @@
|
|
|
{{ item.raw.label }}
|
|
|
</v-chip>
|
|
|
<span
|
|
|
- v-if="maxVisibleChips && index === maxVisibleChips && selectedItems.length > maxVisibleChips"
|
|
|
+ v-if="maxVisibleChips && index === maxVisibleChips && modelValue.length > maxVisibleChips"
|
|
|
class="text-grey text-caption align-self-center"
|
|
|
>
|
|
|
- (+{{ selectedItems.length - maxVisibleChips }} autres)
|
|
|
+ (+{{ modelValue.length - maxVisibleChips }} {{ $t('others') }})
|
|
|
</span>
|
|
|
</template>
|
|
|
|
|
|
@@ -87,12 +104,12 @@
|
|
|
<template v-else>
|
|
|
<v-list-item
|
|
|
@click="toggleItem(item.raw.value!)"
|
|
|
- :active="selectedItems.includes(item.raw.value!)"
|
|
|
+ :active="modelValue.includes(item.raw.value!)"
|
|
|
:class="{ 'pl-12': item.raw.level === 2, 'pl-8': item.raw.level === 1 }"
|
|
|
>
|
|
|
<template #prepend>
|
|
|
<v-checkbox
|
|
|
- :model-value="selectedItems.includes(item.raw.value!)"
|
|
|
+ :model-value="modelValue.includes(item.raw.value!)"
|
|
|
@click.stop="toggleItem(item.raw.value!)"
|
|
|
color="primary"
|
|
|
:hide-details="true"
|
|
|
@@ -137,89 +154,41 @@ const props = defineProps({
|
|
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
|
|
-// État réactif
|
|
|
-const expandedCategories = 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)
|
|
|
+const expandedCategories: Ref<Set<string>> = ref(new Set())
|
|
|
+const expandedSubcategories: Ref<Set<string>> = ref(new Set())
|
|
|
+const searchText: Ref<string> = ref('')
|
|
|
|
|
|
- // 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
|
|
|
+/**
|
|
|
+ * A callback function that is triggered when the menu's open state is updated.
|
|
|
+ */
|
|
|
const onMenuUpdate = (isOpen: boolean) => {
|
|
|
// Réinitialiser la recherche quand le menu se ferme
|
|
|
if (!isOpen && searchText.value) {
|
|
|
searchText.value = ''
|
|
|
+ onSearchInput()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// Modèle v-model
|
|
|
-const selectedItems = computed({
|
|
|
- get: () => props.modelValue,
|
|
|
- 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
|
|
|
+/**
|
|
|
+ * 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.
|
|
|
+ *
|
|
|
+ * Logic:
|
|
|
+ * - If there is a search text:
|
|
|
+ * - Filters items to include only level 2 items matching the search text.
|
|
|
+ * - Ensures parent categories and subcategories are added to the result.
|
|
|
+ * - Expands categories and subcategories relevant to search results.
|
|
|
+ * - Without a search text:
|
|
|
+ * - Recursively processes items to include all relevant categories,
|
|
|
+ * subcategories, and individual items based on the expanded states.
|
|
|
+ *
|
|
|
+ * @returns {SelectItem[]} Flattened and organized list of items.
|
|
|
+ */
|
|
|
const flattenedItems = computed(() => {
|
|
|
const result: SelectItem[] = []
|
|
|
const hasSearch = !!searchText.value.trim()
|
|
|
+ // TODO: simplifier et découper
|
|
|
|
|
|
// Si une recherche est active, afficher uniquement les éléments de niveau 2 qui correspondent
|
|
|
if (hasSearch) {
|
|
|
@@ -300,7 +269,13 @@ const flattenedItems = computed(() => {
|
|
|
return result
|
|
|
})
|
|
|
|
|
|
-// Méthodes
|
|
|
+/**
|
|
|
+ * 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.
|
|
|
+ */
|
|
|
const toggleCategory = (categoryId: string) => {
|
|
|
if (expandedCategories.value.has(categoryId)) {
|
|
|
expandedCategories.value.delete(categoryId)
|
|
|
@@ -316,6 +291,11 @@ const toggleCategory = (categoryId: string) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Toggles the expansion state of a subcategory.
|
|
|
+ *
|
|
|
+ * @param {string} subcategoryId - The unique identifier of the subcategory to be toggled.
|
|
|
+ */
|
|
|
const toggleSubcategory = (subcategoryId: string) => {
|
|
|
if (expandedSubcategories.value.has(subcategoryId)) {
|
|
|
expandedSubcategories.value.delete(subcategoryId)
|
|
|
@@ -324,8 +304,14 @@ 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.
|
|
|
+ */
|
|
|
const toggleItem = (value: string) => {
|
|
|
- const currentSelection = [...selectedItems.value]
|
|
|
+ const currentSelection = [...props.modelValue]
|
|
|
const index = currentSelection.indexOf(value)
|
|
|
|
|
|
if (index > -1) {
|
|
|
@@ -334,11 +320,95 @@ const toggleItem = (value: string) => {
|
|
|
currentSelection.push(value)
|
|
|
}
|
|
|
|
|
|
- selectedItems.value = currentSelection
|
|
|
+ emit('update:modelValue', currentSelection)
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * 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.
|
|
|
+ */
|
|
|
const removeItem = (value: string) => {
|
|
|
- selectedItems.value = selectedItems.value.filter(item => item !== value)
|
|
|
+ emit('update:modelValue', 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 = 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Determines if a given item matches the current search text by checking its label
|
|
|
+ * and, for certain items, the 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 label of the item itself
|
|
|
+ * - The label of its parent subcategory
|
|
|
+ * - The label of the grandparent category (if applicable)
|
|
|
+ *
|
|
|
+ * For all other item types, only the item's label is checked.
|
|
|
+ *
|
|
|
+ * @param {SelectItem} 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: 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)
|
|
|
}
|
|
|
</script>
|
|
|
|