TreeSelect.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. <!--
  2. Composant de sélection hiérarchique à plusieurs niveaux permettant de naviguer
  3. et sélectionner des éléments organisés en catégories et sous-catégories.
  4. ## Exemple d'utilisation
  5. ```vue
  6. <TreeSelect
  7. v-model="selectedValues"
  8. :items="hierarchicalItems"
  9. :max-visible-chips="3"
  10. label="Sélectionner des éléments"
  11. />
  12. ```
  13. -->
  14. <template>
  15. <v-select
  16. :model-value="modelValue"
  17. :label="$t(label)"
  18. v-bind="$attrs"
  19. :items="flattenedItems"
  20. item-title="label"
  21. item-value="value"
  22. multiple
  23. chips
  24. closable-chips
  25. :menu-props="{ maxHeight: 400 }"
  26. @update:menu="onMenuUpdate"
  27. @update:model-value="$emit('update:modelValue', $event)"
  28. >
  29. <template #prepend-item>
  30. <!-- Champs de recherche textuelle -->
  31. <v-text-field
  32. ref="searchInput"
  33. v-model="searchText"
  34. density="compact"
  35. hide-details
  36. :placeholder="$t('search') + '...'"
  37. prepend-inner-icon="fas fa-magnifying-glass"
  38. variant="outlined"
  39. clearable
  40. class="mx-2 my-2"
  41. @click.stop="focusSearchInput"
  42. @mousedown.stop
  43. @keydown.stop="onKeyDown"
  44. @input="onSearchInputDebounced"
  45. @click:clear.stop="onSearchClear"
  46. />
  47. <v-divider class="mt-2" />
  48. </template>
  49. <template #selection="{ item, index }">
  50. <v-chip
  51. v-if="maxVisibleChips && index < maxVisibleChips"
  52. :key="item.raw.value"
  53. closable
  54. @click:close="removeItem(item.raw.value!)"
  55. >
  56. <!-- Always prioritize the mapping for consistent labels, fall back to item label if available -->
  57. {{
  58. selectedItemsMap[item.raw.value] ||
  59. (item.raw.label && item.raw.label !== item.raw.value ? item.raw.label : item.raw.value)
  60. }}
  61. </v-chip>
  62. <span
  63. v-if="
  64. maxVisibleChips &&
  65. index === maxVisibleChips &&
  66. modelValue.length > maxVisibleChips
  67. "
  68. class="text-grey text-caption align-self-center"
  69. >
  70. (+{{ modelValue.length - maxVisibleChips }} {{ $t('others') }})
  71. </span>
  72. </template>
  73. <template #item="{ item }">
  74. <template v-if="item.raw.type === 'category'">
  75. <v-list-item
  76. :ripple="false"
  77. :class="{
  78. 'v-list-item--active': expandedCategories.has(item.raw.id),
  79. }"
  80. @click.stop="toggleCategory(item.raw.id)"
  81. >
  82. <template #prepend>
  83. <v-icon
  84. :icon="
  85. 'fas ' +
  86. (expandedCategories.has(item.raw.id)
  87. ? 'fa-angle-down'
  88. : 'fa-angle-right')
  89. "
  90. size="small"
  91. />
  92. </template>
  93. <v-list-item-title class="font-weight-medium">
  94. {{ item.raw.label }}
  95. </v-list-item-title>
  96. </v-list-item>
  97. </template>
  98. <template v-else-if="item.raw.type === 'subcategory'">
  99. <v-list-item
  100. :ripple="false"
  101. :class="{
  102. 'v-list-item--active': expandedSubcategories.has(item.raw.id),
  103. 'pl-8': true,
  104. }"
  105. @click.stop="toggleSubcategory(item.raw.id)"
  106. >
  107. <template #prepend>
  108. <v-icon
  109. :icon="
  110. 'fas ' +
  111. (expandedSubcategories.has(item.raw.id)
  112. ? 'fa-angle-down'
  113. : 'fa-angle-right')
  114. "
  115. size="small"
  116. />
  117. </template>
  118. <v-list-item-title>
  119. {{ item.raw.label }}
  120. </v-list-item-title>
  121. </v-list-item>
  122. </template>
  123. <template v-else>
  124. <v-list-item
  125. :active="modelValue.includes(item.raw.value!)"
  126. :class="{
  127. 'd-flex': true,
  128. 'pl-12': item.raw.level === 2,
  129. 'pl-8': item.raw.level === 1,
  130. }"
  131. @click="toggleItem(item.raw.value!)"
  132. >
  133. <template #prepend>
  134. <v-checkbox
  135. :model-value="modelValue.includes(item.raw.value!)"
  136. color="primary"
  137. :hide-details="true"
  138. @click.stop="toggleItem(item.raw.value!)"
  139. />
  140. </template>
  141. <v-list-item-title>
  142. {{ item.raw.label }}
  143. </v-list-item-title>
  144. </v-list-item>
  145. </template>
  146. </template>
  147. </v-select>
  148. </template>
  149. <script setup lang="ts">
  150. import StringUtils from '~/services/utils/stringUtils'
  151. import _ from 'lodash'
  152. interface SelectItem {
  153. id: string
  154. label: string
  155. normalizedLabel?: string
  156. value?: string
  157. type: 'category' | 'subcategory' | 'item'
  158. parentId?: string
  159. level: number
  160. }
  161. const props = defineProps({
  162. modelValue: {
  163. type: Array as PropType<string[]>,
  164. required: true,
  165. },
  166. items: {
  167. type: Array as PropType<SelectItem[]>,
  168. required: true,
  169. },
  170. maxVisibleChips: {
  171. type: Number,
  172. required: false,
  173. default: null,
  174. },
  175. /**
  176. * Label du champ
  177. * Si non défini, c'est le nom de propriété qui est utilisé
  178. */
  179. label: {
  180. type: String,
  181. required: false,
  182. default: null,
  183. },
  184. })
  185. const searchInput = ref()
  186. /**
  187. * Force le focus sur l'input de recherche
  188. */
  189. const focusSearchInput = () => {
  190. nextTick(() => {
  191. if (searchInput.value?.$el) {
  192. const input = searchInput.value.$el.querySelector('input')
  193. if (input) {
  194. input.focus()
  195. }
  196. }
  197. })
  198. }
  199. /**
  200. * Gère les événements clavier pour éviter les conflits avec la navigation du menu
  201. */
  202. const onKeyDown = (event: KeyboardEvent) => {
  203. // Empêcher la propagation pour tous les caractères alphanumériques
  204. // et les touches spéciales de navigation
  205. if (
  206. event.key.length === 1 || // Caractères simples (a, c, etc.)
  207. ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)
  208. ) {
  209. event.stopPropagation()
  210. }
  211. // Empêcher également Escape de fermer le menu quand on est dans l'input
  212. if (event.key === 'Escape') {
  213. event.stopPropagation()
  214. // Optionnel : vider le texte de recherche
  215. if (searchText.value) {
  216. searchText.value = ''
  217. onSearchClear()
  218. }
  219. }
  220. }
  221. /**
  222. * A computed property that normalizes the labels of all items upfront.
  223. * This avoids having to normalize labels during each search operation.
  224. */
  225. const normalizedItems = computed(() => {
  226. return props.items.map(item => ({
  227. ...item,
  228. normalizedLabel: StringUtils.normalize(item.label)
  229. }))
  230. })
  231. const emit = defineEmits(['update:modelValue'])
  232. const expandedCategories: Ref<Set<string>> = ref(new Set())
  233. const expandedSubcategories: Ref<Set<string>> = ref(new Set())
  234. const searchText: Ref<string> = ref('')
  235. /**
  236. * A callback function that is triggered when the menu's open state is updated.
  237. */
  238. const onMenuUpdate = (isOpen: boolean) => {
  239. // Réinitialiser la recherche quand le menu se ferme
  240. if (!isOpen && searchText.value) {
  241. searchText.value = ''
  242. onSearchInput()
  243. }
  244. }
  245. /**
  246. * Toggles the expanded state of a given category. If the category is currently
  247. * expanded, it will collapse the category and also collapse its subcategories.
  248. * If the category is not expanded, it will expand the category.
  249. *
  250. * @param {string} categoryId - The unique identifier of the category to toggle.
  251. */
  252. const toggleCategory = (categoryId: string) => {
  253. if (expandedCategories.value.has(categoryId)) {
  254. expandedCategories.value.delete(categoryId)
  255. // Fermer aussi les sous-catégories
  256. const subcategories = normalizedItems.value.filter(
  257. (i) => i.parentId === categoryId && i.type === 'subcategory',
  258. )
  259. subcategories.forEach((sub) => {
  260. expandedSubcategories.value.delete(sub.id)
  261. })
  262. } else {
  263. expandedCategories.value.add(categoryId)
  264. }
  265. }
  266. /**
  267. * Toggles the expansion state of a subcategory.
  268. *
  269. * @param {string} subcategoryId - The unique identifier of the subcategory to be toggled.
  270. */
  271. const toggleSubcategory = (subcategoryId: string) => {
  272. if (expandedSubcategories.value.has(subcategoryId)) {
  273. expandedSubcategories.value.delete(subcategoryId)
  274. } else {
  275. expandedSubcategories.value.add(subcategoryId)
  276. }
  277. }
  278. /**
  279. * A function that toggles the inclusion of a specific value in
  280. * the selected items list.
  281. *
  282. * @param {string} value - The value to toggle in the selected items list.
  283. */
  284. const toggleItem = (value: string) => {
  285. const currentSelection = [...props.modelValue]
  286. const index = currentSelection.indexOf(value)
  287. if (index > -1) {
  288. currentSelection.splice(index, 1)
  289. } else {
  290. currentSelection.push(value)
  291. }
  292. emit('update:modelValue', currentSelection)
  293. }
  294. /**
  295. * Removes the specified item from the model value and emits an update event.
  296. *
  297. * @param {string} value - The item to be removed from the model value.
  298. * @emits update:modelValue - A custom event emitted with the updated model value
  299. * after the specified item has been removed.
  300. */
  301. const removeItem = (value: string) => {
  302. emit(
  303. 'update:modelValue',
  304. props.modelValue.filter((item) => item !== value),
  305. )
  306. }
  307. /**
  308. * Fonction appellée lorsque l'input de recherche textuelle est modifié
  309. */
  310. const onSearchInput = () => {
  311. // Réinitialiser les états d'expansion dans tous les cas
  312. expandedCategories.value.clear()
  313. expandedSubcategories.value.clear()
  314. if (searchText.value.trim()) {
  315. // Trouver tous les éléments qui correspondent à la recherche
  316. const matchingItems = normalizedItems.value.filter(
  317. (item) =>
  318. item.type === 'item' && item.level === 2 && itemMatchesSearch(item),
  319. )
  320. // Pour chaque élément correspondant, ajouter ses parents aux ensembles d'expansion
  321. for (const item of matchingItems) {
  322. // Trouver et ajouter la sous-catégorie parente
  323. const subcategory = normalizedItems.value.find((i) => i.id === item.parentId)
  324. if (subcategory) {
  325. expandedSubcategories.value.add(subcategory.id)
  326. // Trouver et ajouter la catégorie parente
  327. const category = normalizedItems.value.find((i) => i.id === subcategory.parentId)
  328. if (category) {
  329. expandedCategories.value.add(category.id)
  330. }
  331. }
  332. }
  333. }
  334. }
  335. const onSearchInputDebounced = _.debounce(onSearchInput, 200)
  336. const onSearchClear = () => {
  337. searchText.value = ''
  338. onSearchInput()
  339. }
  340. /**
  341. * Checks if any word in the normalized text starts with the normalized search term.
  342. *
  343. * @param {string} normalizedText - The normalized text to check.
  344. * @param {string} normalizedSearch - The normalized search term.
  345. * @returns {boolean} `true` if any word in the text starts with the search term; otherwise, `false`.
  346. */
  347. const anyWordStartsWith = (
  348. normalizedText: string,
  349. normalizedSearch: string,
  350. ): boolean => {
  351. if (normalizedText.indexOf(normalizedSearch) === 0) return true
  352. const spaceIndex = normalizedText.indexOf(' ')
  353. if (spaceIndex === -1) return false
  354. return normalizedText
  355. .split(' ')
  356. .some(word => word.startsWith(normalizedSearch))
  357. }
  358. /**
  359. * Determines if a given item matches the current search text by checking its normalized label
  360. * and, for certain items, the normalized labels of its parent elements.
  361. *
  362. * The search text is normalized using `StringUtils.normalize` before comparison.
  363. * If no search text is provided, the item matches by default.
  364. *
  365. * For items of type `item` at level 2, the function checks:
  366. * - The normalized label of the item itself
  367. * - The normalized label of its parent subcategory
  368. * - The normalized label of the grandparent category (if applicable)
  369. *
  370. * For all other item types, only the item's normalized label is checked.
  371. *
  372. * The matching is done by checking if any word in the normalized label starts with the normalized search text.
  373. *
  374. * @param {SelectItem} item - The item to evaluate against the search text.
  375. * @returns {boolean} `true` if the item or its relevant parent(s) match the search text; otherwise, `false`.
  376. */
  377. const itemMatchesSearch = (item: SelectItem): boolean => {
  378. if (!searchText.value) return true
  379. const normalizedSearch = StringUtils.normalize(searchText.value)
  380. // Find the item with normalized label from our computed property
  381. const itemWithNormalizedLabel = normalizedItems.value.find(i => i.id === item.id)
  382. if (!itemWithNormalizedLabel) return false
  383. // Si c'est un élément de niveau 2, vérifier son label et les labels de ses parents
  384. if (item.type === 'item' && item.level === 2) {
  385. // Vérifier le label de l'élément
  386. if (anyWordStartsWith(itemWithNormalizedLabel.normalizedLabel!, normalizedSearch))
  387. return true
  388. // Trouver et vérifier le label de la sous-catégorie parente
  389. const subcategory = normalizedItems.value.find((i) => i.id === item.parentId)
  390. if (
  391. subcategory &&
  392. anyWordStartsWith(
  393. subcategory.normalizedLabel!,
  394. normalizedSearch,
  395. )
  396. )
  397. return true
  398. // Trouver et vérifier le label de la catégorie parente
  399. if (subcategory && subcategory.parentId) {
  400. const category = normalizedItems.value.find((i) => i.id === subcategory.parentId)
  401. if (
  402. category &&
  403. anyWordStartsWith(
  404. category.normalizedLabel!,
  405. normalizedSearch,
  406. )
  407. )
  408. return true
  409. }
  410. return false
  411. }
  412. // Pour les autres types d'éléments, vérifier simplement leur label
  413. return anyWordStartsWith(itemWithNormalizedLabel.normalizedLabel!, normalizedSearch)
  414. }
  415. /**
  416. * Filtre les éléments de niveau 2 qui correspondent au texte de recherche.
  417. *
  418. * @returns {SelectItem[]} Les éléments de niveau 2 qui correspondent à la recherche.
  419. */
  420. const findMatchingLevel2Items = (): SelectItem[] => {
  421. return normalizedItems.value.filter(
  422. (item) =>
  423. item.type === 'item' && item.level === 2 && itemMatchesSearch(item),
  424. )
  425. }
  426. /**
  427. * Construit une liste hiérarchique d'éléments basée sur les résultats de recherche.
  428. * Pour chaque élément correspondant, ajoute sa hiérarchie complète (catégorie et sous-catégorie).
  429. *
  430. * @param {SelectItem[]} matchingItems - Les éléments correspondant à la recherche.
  431. * @returns {SelectItem[]} Liste hiérarchique incluant les éléments et leurs parents.
  432. */
  433. const buildSearchResultsList = (matchingItems: SelectItem[]): SelectItem[] => {
  434. const result: SelectItem[] = []
  435. const addedCategoryIds = new Set<string>()
  436. const addedSubcategoryIds = new Set<string>()
  437. for (const item of matchingItems) {
  438. // Trouver la sous-catégorie parente
  439. const subcategory = normalizedItems.value.find((i) => i.id === item.parentId)
  440. if (!subcategory) continue
  441. // Trouver la catégorie parente
  442. const category = normalizedItems.value.find((i) => i.id === subcategory.parentId)
  443. if (!category) continue
  444. // Ajouter la catégorie si elle n'est pas déjà présente
  445. if (!addedCategoryIds.has(category.id)) {
  446. result.push(category)
  447. addedCategoryIds.add(category.id)
  448. expandedCategories.value.add(category.id)
  449. }
  450. // Ajouter la sous-catégorie si elle n'est pas déjà présente
  451. if (!addedSubcategoryIds.has(subcategory.id)) {
  452. result.push(subcategory)
  453. addedSubcategoryIds.add(subcategory.id)
  454. expandedSubcategories.value.add(subcategory.id)
  455. }
  456. // Ajouter l'élément
  457. result.push(item)
  458. }
  459. return result
  460. }
  461. /**
  462. * Traite récursivement les éléments pour construire une liste hiérarchique
  463. * basée sur l'état d'expansion des catégories et sous-catégories.
  464. *
  465. * @param {SelectItem[]} items - Les éléments à traiter.
  466. * @param {SelectItem[]} result - Le tableau résultat à remplir.
  467. * @param {boolean} parentExpanded - Indique si le parent est développé.
  468. */
  469. const processItemsRecursively = (
  470. items: SelectItem[],
  471. result: SelectItem[],
  472. parentExpanded = true,
  473. ): void => {
  474. for (const item of items) {
  475. if (item.type === 'category') {
  476. result.push(item)
  477. if (expandedCategories.value.has(item.id)) {
  478. const subcategories = normalizedItems.value.filter(
  479. (i) => i.parentId === item.id && i.type === 'subcategory',
  480. )
  481. processItemsRecursively(subcategories, result, true)
  482. }
  483. } else if (item.type === 'subcategory') {
  484. if (parentExpanded) {
  485. result.push(item)
  486. if (expandedSubcategories.value.has(item.id)) {
  487. const subItems = normalizedItems.value.filter(
  488. (i) => i.parentId === item.id && i.type === 'item',
  489. )
  490. processItemsRecursively(subItems, result, true)
  491. }
  492. }
  493. } else if (item.type === 'item' && parentExpanded) {
  494. result.push(item)
  495. }
  496. }
  497. }
  498. /**
  499. * Construit une liste hiérarchique d'éléments en mode normal (sans recherche).
  500. *
  501. * @returns {SelectItem[]} Liste hiérarchique basée sur l'état d'expansion.
  502. */
  503. const buildNormalModeList = (): SelectItem[] => {
  504. const result: SelectItem[] = []
  505. const topLevelItems = normalizedItems.value.filter((item) => !item.parentId)
  506. processItemsRecursively(topLevelItems, result)
  507. return result
  508. }
  509. /**
  510. * A computed property that generates a flattened and organized list of items
  511. * from a hierarchical structure, based on the current search text and
  512. * expanded categories/subcategories.
  513. *
  514. * @returns {SelectItem[]} Flattened and organized list of items.
  515. */
  516. const flattenedItems = computed(() => {
  517. const hasSearch = !!searchText.value.trim()
  518. if (hasSearch) {
  519. const matchingItems = findMatchingLevel2Items()
  520. return buildSearchResultsList(matchingItems)
  521. }
  522. return buildNormalModeList()
  523. })
  524. /**
  525. * A computed property that maps selected values to their corresponding labels.
  526. * This is used to display the correct labels in the chips when the dropdown is closed.
  527. *
  528. * @returns {Record<string, string>} A map of selected values to their labels.
  529. */
  530. const selectedItemsMap = computed(() => {
  531. const map: Record<string, string> = {}
  532. // Find all selectable items (type 'item') in the items array with normalized labels
  533. const selectableItems = normalizedItems.value.filter(
  534. (item) => item.type === 'item' && item.value,
  535. )
  536. // Create a map of values to labels
  537. selectableItems.forEach((item) => {
  538. if (item.value) {
  539. map[item.value] = item.label
  540. }
  541. })
  542. return map
  543. })
  544. </script>
  545. <style scoped lang="scss">
  546. .v-list-item--active {
  547. background-color: rgba(var(--v-theme-primary), 0.1);
  548. }
  549. .v-list-item {
  550. contain: layout style paint;
  551. height: 40px !important; /* Ensure consistent height for virtual scrolling */
  552. min-height: 40px !important;
  553. max-height: 40px !important;
  554. padding-top: 0 !important;
  555. padding-bottom: 0 !important;
  556. }
  557. .search-icon {
  558. color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
  559. }
  560. :deep(.v-field__prepend-inner) {
  561. padding-top: 0;
  562. }
  563. :deep(.v-list) {
  564. padding-top: 0;
  565. contain: content;
  566. will-change: transform;
  567. transform-style: preserve-3d;
  568. }
  569. </style>