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