TreeSelect.vue 19 KB

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