TreeSelect.vue 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. <template>
  2. <v-select
  3. v-model="selectedItems"
  4. :items="flattenedItems"
  5. item-title="label"
  6. item-value="value"
  7. multiple
  8. chips
  9. closable-chips
  10. :menu-props="{ maxHeight: 400 }"
  11. v-bind="$attrs"
  12. >
  13. <template #selection="{ item, index }">
  14. <v-chip
  15. v-if="index < maxVisibleChips"
  16. :key="item.raw.value"
  17. closable
  18. @click:close="removeItem(item.raw.value)"
  19. >
  20. {{ item.raw.label }}
  21. </v-chip>
  22. <span
  23. v-if="index === maxVisibleChips && selectedItems.length > maxVisibleChips"
  24. class="text-grey text-caption align-self-center"
  25. >
  26. (+{{ selectedItems.length - maxVisibleChips }} autres)
  27. </span>
  28. </template>
  29. <template #item="{ props, item }">
  30. <template v-if="item.raw.type === 'category'">
  31. <v-list-item
  32. @click.stop="toggleCategory(item.raw.id)"
  33. :ripple="false"
  34. :class="{ 'v-list-item--active': expandedCategories.has(item.raw.id) }"
  35. >
  36. <template #prepend>
  37. <v-icon
  38. :icon="'fas ' + (expandedCategories.has(item.raw.id) ? 'fa-angle-down' : 'fa-angle-right')"
  39. size="small"
  40. />
  41. </template>
  42. <v-list-item-title class="font-weight-medium">
  43. {{ item.raw.label }}
  44. </v-list-item-title>
  45. </v-list-item>
  46. </template>
  47. <template v-else-if="item.raw.type === 'subcategory'">
  48. <v-list-item
  49. @click.stop="toggleSubcategory(item.raw.id)"
  50. :ripple="false"
  51. :class="{
  52. 'v-list-item--active': expandedSubcategories.has(item.raw.id),
  53. 'pl-8': true
  54. }"
  55. >
  56. <template #prepend>
  57. <v-icon
  58. :icon="'fas ' + (expandedSubcategories.has(item.raw.id) ? 'fa-angle-down' : 'fa-angle-right')"
  59. size="small"
  60. />
  61. </template>
  62. <v-list-item-title>
  63. {{ item.raw.label }}
  64. </v-list-item-title>
  65. </v-list-item>
  66. </template>
  67. <template v-else>
  68. <v-list-item
  69. v-bind="props"
  70. :class="{ 'pl-12': item.raw.level === 2, 'pl-8': item.raw.level === 1 }"
  71. >
  72. <template #prepend>
  73. <v-checkbox
  74. :model-value="selectedItems.includes(item.raw.value)"
  75. @click.stop="toggleItem(item.raw.value)"
  76. color="primary"
  77. />
  78. </template>
  79. <v-list-item-title>
  80. {{ item.raw.label }}
  81. </v-list-item-title>
  82. </v-list-item>
  83. </template>
  84. </template>
  85. </v-select>
  86. </template>
  87. <script setup lang="ts">
  88. interface SelectItem {
  89. id: string
  90. label: string
  91. value?: string
  92. type: 'category' | 'subcategory' | 'item'
  93. parentId?: string
  94. level: number
  95. }
  96. const props = defineProps({
  97. modelValue: {
  98. type: Array as PropType<string[]>,
  99. required: true
  100. },
  101. items: {
  102. type: Array as PropType<SelectItem[]>,
  103. required: true
  104. },
  105. })
  106. const emit = defineEmits(['update:modelValue'])
  107. // État réactif
  108. const expandedCategories = ref<Set<string>>(new Set())
  109. const expandedSubcategories = ref<Set<string>>(new Set())
  110. // Modèle v-model
  111. const selectedItems = computed({
  112. get: () => props.modelValue,
  113. set: (value: string[]) => emit('update:modelValue', value)
  114. })
  115. // Construction de la liste aplatie pour v-select
  116. const flattenedItems = computed(() => {
  117. const result: SelectItem[] = []
  118. const processItems = (items: SelectItem[], parentExpanded = true) => {
  119. for (const item of items) {
  120. if (item.type === 'category') {
  121. result.push(item)
  122. if (expandedCategories.value.has(item.id)) {
  123. const subcategories = props.items.filter(i =>
  124. i.parentId === item.id && i.type === 'subcategory'
  125. )
  126. processItems(subcategories, true)
  127. }
  128. } else if (item.type === 'subcategory') {
  129. if (parentExpanded) {
  130. result.push(item)
  131. if (expandedSubcategories.value.has(item.id)) {
  132. const subItems = props.items.filter(i =>
  133. i.parentId === item.id && i.type === 'item'
  134. )
  135. processItems(subItems, true)
  136. }
  137. }
  138. } else if (item.type === 'item' && parentExpanded) {
  139. result.push(item)
  140. }
  141. }
  142. }
  143. const topLevelItems = props.items.filter(item => !item.parentId)
  144. processItems(topLevelItems)
  145. return result
  146. })
  147. // Méthodes
  148. const toggleCategory = (categoryId: string) => {
  149. if (expandedCategories.value.has(categoryId)) {
  150. expandedCategories.value.delete(categoryId)
  151. // Fermer aussi les sous-catégories
  152. const subcategories = props.items.filter(i =>
  153. i.parentId === categoryId && i.type === 'subcategory'
  154. )
  155. subcategories.forEach(sub => {
  156. expandedSubcategories.value.delete(sub.id)
  157. })
  158. } else {
  159. expandedCategories.value.add(categoryId)
  160. }
  161. }
  162. const toggleSubcategory = (subcategoryId: string) => {
  163. if (expandedSubcategories.value.has(subcategoryId)) {
  164. expandedSubcategories.value.delete(subcategoryId)
  165. } else {
  166. expandedSubcategories.value.add(subcategoryId)
  167. }
  168. }
  169. const toggleItem = (value: string) => {
  170. const currentSelection = [...selectedItems.value]
  171. const index = currentSelection.indexOf(value)
  172. if (index > -1) {
  173. currentSelection.splice(index, 1)
  174. } else {
  175. currentSelection.push(value)
  176. }
  177. selectedItems.value = currentSelection
  178. }
  179. const removeItem = (value: string) => {
  180. selectedItems.value = selectedItems.value.filter(item => item !== value)
  181. }
  182. </script>
  183. <style scoped>
  184. .v-list-item--active {
  185. background-color: rgba(var(--v-theme-primary), 0.1);
  186. }
  187. </style>