TreeSelect.vue 19 KB

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