ソースを参照

various fixes and optimizations on v-select

Olivier Massot 4 ヶ月 前
コミット
00d8b70db1
1 ファイル変更68 行追加33 行削除
  1. 68 33
      components/Ui/Input/TreeSelect.vue

+ 68 - 33
components/Ui/Input/TreeSelect.vue

@@ -38,7 +38,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
         clearable
         class="mx-2 my-2"
         @click.stop
-        @input="onSearchInput"
+        @input="onSearchInputDebounced"
         @click:clear.stop="onSearchClear"
       />
       <v-divider class="mt-2" />
@@ -51,11 +51,10 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
         closable
         @click:close="removeItem(item.raw.value!)"
       >
-        <!-- Use the label from the item if available, otherwise use the mapping -->
+        <!-- Always prioritize the mapping for consistent labels, fall back to item label if available -->
         {{
-          item.raw.label && item.raw.label !== item.raw.value
-            ? item.raw.label
-            : selectedItemsMap[item.raw.value] || item.raw.value
+          selectedItemsMap[item.raw.value] ||
+          (item.raw.label && item.raw.label !== item.raw.value ? item.raw.label : item.raw.value)
         }}
       </v-chip>
       <span
@@ -126,6 +125,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
         <v-list-item
           :active="modelValue.includes(item.raw.value!)"
           :class="{
+            'd-flex': true,
             'pl-12': item.raw.level === 2,
             'pl-8': item.raw.level === 1,
           }"
@@ -150,10 +150,12 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
 
 <script setup lang="ts">
 import StringUtils from '~/services/utils/stringUtils'
+import _ from 'lodash'
 
 interface SelectItem {
   id: string
   label: string
+  normalizedLabel?: string
   value?: string
   type: 'category' | 'subcategory' | 'item'
   parentId?: string
@@ -176,6 +178,17 @@ const props = defineProps({
   },
 })
 
+/**
+ * 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())
@@ -204,7 +217,7 @@ const toggleCategory = (categoryId: string) => {
   if (expandedCategories.value.has(categoryId)) {
     expandedCategories.value.delete(categoryId)
     // Fermer aussi les sous-catégories
-    const subcategories = props.items.filter(
+    const subcategories = normalizedItems.value.filter(
       (i) => i.parentId === categoryId && i.type === 'subcategory',
     )
     subcategories.forEach((sub) => {
@@ -271,7 +284,7 @@ const onSearchInput = () => {
 
   if (searchText.value.trim()) {
     // Trouver tous les éléments qui correspondent à la recherche
-    const matchingItems = props.items.filter(
+    const matchingItems = normalizedItems.value.filter(
       (item) =>
         item.type === 'item' && item.level === 2 && itemMatchesSearch(item),
     )
@@ -279,12 +292,12 @@ const onSearchInput = () => {
     // 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)
+      const subcategory = normalizedItems.value.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)
+        const category = normalizedItems.value.find((i) => i.id === subcategory.parentId)
         if (category) {
           expandedCategories.value.add(category.id)
         }
@@ -293,6 +306,8 @@ const onSearchInput = () => {
   }
 }
 
+const onSearchInputDebounced = _.debounce(onSearchInput, 200)
+
 const onSearchClear = () => {
   searchText.value = ''
   onSearchInput()
@@ -309,26 +324,30 @@ const anyWordStartsWith = (
   normalizedText: string,
   normalizedSearch: string,
 ): boolean => {
-  // Split the text into words
-  const words = normalizedText.split(' ')
+  if (normalizedText.indexOf(normalizedSearch) === 0) return true
+
+  const spaceIndex = normalizedText.indexOf(' ')
+  if (spaceIndex === -1) return false
+
+  return normalizedText
+    .split(' ')
+    .some(word => word.startsWith(normalizedSearch))
 
-  // Check if any word starts with the search term
-  return words.some((word) => word.startsWith(normalizedSearch))
 }
 
 /**
- * Determines if a given item matches the current search text by checking its label
- * and, for certain items, the labels of its parent elements.
+ * 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 label of the item itself
- * - The label of its parent subcategory
- * - The label of the grandparent category (if applicable)
+ * - 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 label is checked.
+ * 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.
  *
@@ -340,18 +359,22 @@ const itemMatchesSearch = (item: SelectItem): boolean => {
 
   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 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 (anyWordStartsWith(StringUtils.normalize(item.label), normalizedSearch))
+    if (anyWordStartsWith(itemWithNormalizedLabel.normalizedLabel!, 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)
+    const subcategory = normalizedItems.value.find((i) => i.id === item.parentId)
     if (
       subcategory &&
       anyWordStartsWith(
-        StringUtils.normalize(subcategory.label),
+        subcategory.normalizedLabel!,
         normalizedSearch,
       )
     )
@@ -359,11 +382,11 @@ const itemMatchesSearch = (item: SelectItem): boolean => {
 
     // 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)
+      const category = normalizedItems.value.find((i) => i.id === subcategory.parentId)
       if (
         category &&
         anyWordStartsWith(
-          StringUtils.normalize(category.label),
+          category.normalizedLabel!,
           normalizedSearch,
         )
       )
@@ -374,7 +397,7 @@ const itemMatchesSearch = (item: SelectItem): boolean => {
   }
 
   // Pour les autres types d'éléments, vérifier simplement leur label
-  return anyWordStartsWith(StringUtils.normalize(item.label), normalizedSearch)
+  return anyWordStartsWith(itemWithNormalizedLabel.normalizedLabel!, normalizedSearch)
 }
 
 /**
@@ -383,7 +406,7 @@ const itemMatchesSearch = (item: SelectItem): boolean => {
  * @returns {SelectItem[]} Les éléments de niveau 2 qui correspondent à la recherche.
  */
 const findMatchingLevel2Items = (): SelectItem[] => {
-  return props.items.filter(
+  return normalizedItems.value.filter(
     (item) =>
       item.type === 'item' && item.level === 2 && itemMatchesSearch(item),
   )
@@ -403,11 +426,11 @@ const buildSearchResultsList = (matchingItems: SelectItem[]): SelectItem[] => {
 
   for (const item of matchingItems) {
     // Trouver la sous-catégorie parente
-    const subcategory = props.items.find((i) => i.id === item.parentId)
+    const subcategory = normalizedItems.value.find((i) => i.id === item.parentId)
     if (!subcategory) continue
 
     // Trouver la catégorie parente
-    const category = props.items.find((i) => i.id === subcategory.parentId)
+    const category = normalizedItems.value.find((i) => i.id === subcategory.parentId)
     if (!category) continue
 
     // Ajouter la catégorie si elle n'est pas déjà présente
@@ -448,7 +471,7 @@ const processItemsRecursively = (
     if (item.type === 'category') {
       result.push(item)
       if (expandedCategories.value.has(item.id)) {
-        const subcategories = props.items.filter(
+        const subcategories = normalizedItems.value.filter(
           (i) => i.parentId === item.id && i.type === 'subcategory',
         )
         processItemsRecursively(subcategories, result, true)
@@ -457,7 +480,7 @@ const processItemsRecursively = (
       if (parentExpanded) {
         result.push(item)
         if (expandedSubcategories.value.has(item.id)) {
-          const subItems = props.items.filter(
+          const subItems = normalizedItems.value.filter(
             (i) => i.parentId === item.id && i.type === 'item',
           )
           processItemsRecursively(subItems, result, true)
@@ -476,7 +499,7 @@ const processItemsRecursively = (
  */
 const buildNormalModeList = (): SelectItem[] => {
   const result: SelectItem[] = []
-  const topLevelItems = props.items.filter((item) => !item.parentId)
+  const topLevelItems = normalizedItems.value.filter((item) => !item.parentId)
   processItemsRecursively(topLevelItems, result)
   return result
 }
@@ -508,8 +531,8 @@ const flattenedItems = computed(() => {
 const selectedItemsMap = computed(() => {
   const map: Record<string, string> = {}
 
-  // Find all selectable items (type 'item') in the original items array
-  const selectableItems = props.items.filter(
+  // Find all selectable items (type 'item') in the items array with normalized labels
+  const selectableItems = normalizedItems.value.filter(
     (item) => item.type === 'item' && item.value,
   )
 
@@ -529,6 +552,15 @@ const selectedItemsMap = computed(() => {
   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));
 }
@@ -539,5 +571,8 @@ const selectedItemsMap = computed(() => {
 
 :deep(.v-list) {
   padding-top: 0;
+  contain: content;
+  will-change: transform;
+  transform-style: preserve-3d;
 }
 </style>