TreeSelect.vue 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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="maxVisibleChips && 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="maxVisibleChips && 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. @click="toggleItem(item.raw.value)"
  70. :active="selectedItems.includes(item.raw.value)"
  71. :class="{ 'pl-12': item.raw.level === 2, 'pl-8': item.raw.level === 1 }"
  72. >
  73. <template #prepend>
  74. <v-checkbox
  75. :model-value="selectedItems.includes(item.raw.value)"
  76. @click.stop="toggleItem(item.raw.value)"
  77. color="primary"
  78. :hide-details="true"
  79. />
  80. </template>
  81. <v-list-item-title>
  82. {{ item.raw.label }}
  83. </v-list-item-title>
  84. </v-list-item>
  85. </template>
  86. </template>
  87. </v-select>
  88. </template>
  89. <script setup lang="ts">
  90. interface SelectItem {
  91. id: string
  92. label: string
  93. value?: string
  94. type: 'category' | 'subcategory' | 'item'
  95. parentId?: string
  96. level: number
  97. }
  98. const props = defineProps({
  99. modelValue: {
  100. type: Array as PropType<string[]>,
  101. required: true
  102. },
  103. items: {
  104. type: Array as PropType<SelectItem[]>,
  105. required: true
  106. },
  107. maxVisibleChips: {
  108. type: Number,
  109. required: false,
  110. default: null
  111. }
  112. })
  113. const emit = defineEmits(['update:modelValue'])
  114. // État réactif
  115. const expandedCategories = ref<Set<string>>(new Set())
  116. const expandedSubcategories = ref<Set<string>>(new Set())
  117. // Modèle v-model
  118. const selectedItems = computed({
  119. get: () => props.modelValue,
  120. set: (value: string[]) => emit('update:modelValue', value)
  121. })
  122. // Construction de la liste aplatie pour v-select
  123. const flattenedItems = computed(() => {
  124. const result: SelectItem[] = []
  125. const processItems = (items: SelectItem[], parentExpanded = true) => {
  126. for (const item of items) {
  127. if (item.type === 'category') {
  128. result.push(item)
  129. if (expandedCategories.value.has(item.id)) {
  130. const subcategories = props.items.filter(i =>
  131. i.parentId === item.id && i.type === 'subcategory'
  132. )
  133. processItems(subcategories, true)
  134. }
  135. } else if (item.type === 'subcategory') {
  136. if (parentExpanded) {
  137. result.push(item)
  138. if (expandedSubcategories.value.has(item.id)) {
  139. const subItems = props.items.filter(i =>
  140. i.parentId === item.id && i.type === 'item'
  141. )
  142. processItems(subItems, true)
  143. }
  144. }
  145. } else if (item.type === 'item' && parentExpanded) {
  146. result.push(item)
  147. }
  148. }
  149. }
  150. const topLevelItems = props.items.filter(item => !item.parentId)
  151. processItems(topLevelItems)
  152. return result
  153. })
  154. // Méthodes
  155. const toggleCategory = (categoryId: string) => {
  156. if (expandedCategories.value.has(categoryId)) {
  157. expandedCategories.value.delete(categoryId)
  158. // Fermer aussi les sous-catégories
  159. const subcategories = props.items.filter(i =>
  160. i.parentId === categoryId && i.type === 'subcategory'
  161. )
  162. subcategories.forEach(sub => {
  163. expandedSubcategories.value.delete(sub.id)
  164. })
  165. } else {
  166. expandedCategories.value.add(categoryId)
  167. }
  168. }
  169. const toggleSubcategory = (subcategoryId: string) => {
  170. if (expandedSubcategories.value.has(subcategoryId)) {
  171. expandedSubcategories.value.delete(subcategoryId)
  172. } else {
  173. expandedSubcategories.value.add(subcategoryId)
  174. }
  175. }
  176. const toggleItem = (value: string) => {
  177. const currentSelection = [...selectedItems.value]
  178. const index = currentSelection.indexOf(value)
  179. if (index > -1) {
  180. currentSelection.splice(index, 1)
  181. } else {
  182. currentSelection.push(value)
  183. }
  184. selectedItems.value = currentSelection
  185. }
  186. const removeItem = (value: string) => {
  187. selectedItems.value = selectedItems.value.filter(item => item !== value)
  188. }
  189. </script>
  190. <style scoped lang="scss">
  191. .v-list-item--active {
  192. background-color: rgba(var(--v-theme-primary), 0.1);
  193. }
  194. </style>