TreeSelect.vue 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. <!--
  2. Composant de sélection hiérarchique simplifié pour les arbres de ressources.
  3. Les items fournis sont déjà triés, traduits et structurés au format ResourceTreeContent.
  4. ## Exemple d'utilisation
  5. ```vue
  6. <TreeSelect
  7. v-model="selectedValues"
  8. :items="resourceTreeContent"
  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="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:model-value="$emit('update:modelValue', $event)"
  26. >
  27. <template #selection="{ item, index }">
  28. <v-chip
  29. v-if="maxVisibleChips && index < maxVisibleChips"
  30. :key="item.raw.value"
  31. closable
  32. @click:close="removeItem(String(item.raw.value!))"
  33. >
  34. {{ item.raw.label }}
  35. </v-chip>
  36. <span
  37. v-if="
  38. maxVisibleChips &&
  39. index === maxVisibleChips &&
  40. modelValue.length > maxVisibleChips
  41. "
  42. class="text-grey text-caption align-self-center"
  43. >
  44. (+{{ modelValue.length - maxVisibleChips }} autres)
  45. </span>
  46. </template>
  47. <template #item="{ item }">
  48. <template v-if="item.raw.type === 'category'">
  49. <v-list-item
  50. :ripple="false"
  51. :class="{
  52. 'v-list-item--active': expandedCategories.has(item.raw.id),
  53. }"
  54. @click.stop="toggleCategory(item.raw.id)"
  55. >
  56. <template #prepend>
  57. <v-icon
  58. :icon="
  59. 'fas ' +
  60. (expandedCategories.has(item.raw.id)
  61. ? 'fa-angle-down'
  62. : 'fa-angle-right')
  63. "
  64. size="small"
  65. />
  66. </template>
  67. <v-list-item-title class="font-weight-medium">
  68. {{ item.raw.label }}
  69. </v-list-item-title>
  70. </v-list-item>
  71. </template>
  72. <template v-else-if="item.raw.type === 'subcategory'">
  73. <v-list-item
  74. :ripple="false"
  75. :class="{
  76. 'v-list-item--active': expandedSubcategories.has(item.raw.id),
  77. 'pl-8': true,
  78. }"
  79. @click.stop="toggleSubcategory(item.raw.id)"
  80. >
  81. <template #prepend>
  82. <v-icon
  83. :icon="
  84. 'fas ' +
  85. (expandedSubcategories.has(item.raw.id)
  86. ? 'fa-angle-down'
  87. : 'fa-angle-right')
  88. "
  89. size="small"
  90. />
  91. </template>
  92. <v-list-item-title>
  93. {{ item.raw.label }}
  94. </v-list-item-title>
  95. </v-list-item>
  96. </template>
  97. <template v-else>
  98. <v-list-item
  99. :active="modelValue.includes(String(item.raw.value!))"
  100. :class="{
  101. 'd-flex': true,
  102. 'pl-12': item.raw.level === 2,
  103. 'pl-8': item.raw.level === 1,
  104. }"
  105. @click="toggleItem(String(item.raw.value!))"
  106. >
  107. <template #prepend>
  108. <v-checkbox
  109. :model-value="modelValue.includes(String(item.raw.value!))"
  110. color="primary"
  111. :hide-details="true"
  112. @click.stop="toggleItem(String(item.raw.value!))"
  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>
  121. </v-select>
  122. </template>
  123. <script setup lang="ts">
  124. import { ref, computed, type PropType, type Ref } from 'vue'
  125. import type { ResourceTreeContent } from '~/types/data'
  126. interface FlattenedItem {
  127. id: string
  128. label: string
  129. value?: string
  130. type: 'category' | 'subcategory' | 'item'
  131. level: number
  132. parentId?: string
  133. }
  134. const props = defineProps({
  135. modelValue: {
  136. type: Array as PropType<string[]>,
  137. required: true,
  138. },
  139. items: {
  140. type: [Array, Object] as PropType<ResourceTreeContent>,
  141. required: true,
  142. },
  143. maxVisibleChips: {
  144. type: Number,
  145. required: false,
  146. default: null,
  147. },
  148. label: {
  149. type: String,
  150. required: false,
  151. default: '',
  152. },
  153. variant: {
  154. type: String as PropType<
  155. | 'filled'
  156. | 'outlined'
  157. | 'plain'
  158. | 'underlined'
  159. | 'solo'
  160. | 'solo-inverted'
  161. | 'solo-filled'
  162. | undefined
  163. >,
  164. required: false,
  165. default: 'outlined',
  166. },
  167. })
  168. const emit = defineEmits(['update:modelValue'])
  169. const expandedCategories: Ref<Set<string>> = ref(new Set())
  170. const expandedSubcategories: Ref<Set<string>> = ref(new Set())
  171. /**
  172. * Flatten the ResourceTreeContent into a flat array for v-select
  173. */
  174. const flattenedItems = computed(() => {
  175. const items: FlattenedItem[] = []
  176. let itemCounter = 0
  177. if (Array.isArray(props.items)) {
  178. // Simple array of strings
  179. props.items.forEach((item) => {
  180. items.push({
  181. id: `item-${itemCounter++}`,
  182. label: item,
  183. value: item,
  184. type: 'item',
  185. level: 0,
  186. })
  187. })
  188. } else {
  189. // Object structure (2 or 3 levels)
  190. Object.entries(props.items).forEach(([categoryKey, categoryValue]) => {
  191. const categoryId = `category-${categoryKey}`
  192. // Add category
  193. items.push({
  194. id: categoryId,
  195. label: categoryKey,
  196. type: 'category',
  197. level: 0,
  198. })
  199. if (Array.isArray(categoryValue)) {
  200. // 2-level structure: Record<string, string[]>
  201. if (expandedCategories.value.has(categoryId)) {
  202. categoryValue.forEach((item) => {
  203. items.push({
  204. id: `item-${itemCounter++}`,
  205. label: item,
  206. value: item,
  207. type: 'item',
  208. level: 1,
  209. parentId: categoryId,
  210. })
  211. })
  212. }
  213. } else {
  214. // 3-level structure: Record<string, Record<string, string[]>>
  215. if (expandedCategories.value.has(categoryId)) {
  216. Object.entries(categoryValue).forEach(([subcategoryKey, subcategoryValue]) => {
  217. const subcategoryId = `subcategory-${categoryKey}-${subcategoryKey}`
  218. // Add subcategory
  219. items.push({
  220. id: subcategoryId,
  221. label: subcategoryKey,
  222. type: 'subcategory',
  223. level: 1,
  224. parentId: categoryId,
  225. })
  226. if (expandedSubcategories.value.has(subcategoryId)) {
  227. subcategoryValue.forEach((item) => {
  228. items.push({
  229. id: `item-${itemCounter++}`,
  230. label: item,
  231. value: item,
  232. type: 'item',
  233. level: 2,
  234. parentId: subcategoryId,
  235. })
  236. })
  237. }
  238. })
  239. }
  240. }
  241. })
  242. }
  243. return items
  244. })
  245. /**
  246. * Toggle category expansion
  247. */
  248. const toggleCategory = (categoryId: string) => {
  249. if (expandedCategories.value.has(categoryId)) {
  250. expandedCategories.value.delete(categoryId)
  251. // Also collapse all subcategories
  252. Array.from(expandedSubcategories.value).forEach(subId => {
  253. if (subId.startsWith(`subcategory-${categoryId.replace('category-', '')}-`)) {
  254. expandedSubcategories.value.delete(subId)
  255. }
  256. })
  257. } else {
  258. expandedCategories.value.add(categoryId)
  259. }
  260. }
  261. /**
  262. * Toggle subcategory expansion
  263. */
  264. const toggleSubcategory = (subcategoryId: string) => {
  265. if (expandedSubcategories.value.has(subcategoryId)) {
  266. expandedSubcategories.value.delete(subcategoryId)
  267. } else {
  268. expandedSubcategories.value.add(subcategoryId)
  269. }
  270. }
  271. /**
  272. * Toggle item selection
  273. */
  274. const toggleItem = (value: string) => {
  275. const currentSelection = [...props.modelValue]
  276. const index = currentSelection.indexOf(value)
  277. if (index > -1) {
  278. currentSelection.splice(index, 1)
  279. } else {
  280. currentSelection.push(value)
  281. }
  282. emit('update:modelValue', currentSelection)
  283. }
  284. /**
  285. * Remove item from selection
  286. */
  287. const removeItem = (value: string) => {
  288. emit(
  289. 'update:modelValue',
  290. props.modelValue.filter((item) => item !== value),
  291. )
  292. }
  293. </script>