TreeSelect.vue 14 KB

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