Selaa lähdekoodia

Improves TreeSelect component functionality

Refactors the TreeSelect component to enhance search and selection behavior.

- Improves the user experience by persisting the expansion state of categories when clearing the search text. Only categories with selected items will be expanded.
- Adds i18n support for search placeholder and "others" text in chip display.
- Introduces a test case to verify the expansion state after clearing the search.
- Updates the component to use `modelValue` for v-model binding for better Vue 3 compatibility.
Olivier Massot 4 kuukautta sitten
vanhempi
commit
5dd647a1da
3 muutettua tiedostoa jossa 158 lisäystä ja 85 poistoa
  1. 154 84
      components/Ui/Input/TreeSelect.vue
  2. 3 1
      i18n/lang/fr.json
  3. 1 0
      pages/dev/poc_tree_select_input.vue

+ 154 - 84
components/Ui/Input/TreeSelect.vue

@@ -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>
 

+ 3 - 1
i18n/lang/fr.json

@@ -770,5 +770,7 @@
   "information": "Information",
   "refresh_needed": "Actualisation requise",
   "refresh_needed_message": "La page a besoin d'être actualisée pour afficher les dernières modifications.",
-  "refresh_page": "Actualiser la page"
+  "refresh_page": "Actualiser la page",
+  "search": "Rechercher",
+  "others": "autres"
 }

+ 1 - 0
pages/dev/poc_tree_select_input.vue

@@ -8,6 +8,7 @@
       label="Choisissez vos options"
       placeholder="Sélectionnez des éléments..."
       :max-visible-chips="2"
+      ref="treeSelect"
     />
 
     <div class="mt-4">