TreeSelect.vue 16 KB

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