TreeSelect.vue 15 KB

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