| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- <!--
- Composant de sélection hiérarchique simplifié pour les arbres de ressources.
- Les items fournis sont déjà triés, traduits et structurés au format ResourceTreeContent.
- ## Exemple d'utilisation
- ```vue
- <TreeSelect
- v-model="selectedValues"
- :items="resourceTreeContent"
- :max-visible-chips="3"
- label="Sélectionner des éléments"
- />
- ```
- -->
- <template>
- <v-select
- :model-value="modelValue"
- :label="label"
- v-bind="$attrs"
- :items="flattenedItems"
- item-title="label"
- item-value="value"
- :variant="variant"
- multiple
- :menu-props="{ maxHeight: 400 }"
- @update:model-value="$emit('update:modelValue', $event)"
- >
- <template #selection="{ item, index }">
- <v-chip
- v-if="maxVisibleChips && index < maxVisibleChips"
- :key="item.raw.value"
- closable
- @click:close="removeItem(String(item.raw.value!))"
- >
- {{ item.raw.label }}
- </v-chip>
- <span
- v-if="
- maxVisibleChips &&
- index === maxVisibleChips &&
- modelValue.length > maxVisibleChips
- "
- class="text-grey text-caption align-self-center"
- >
- (+{{ modelValue.length - maxVisibleChips }} autres)
- </span>
- </template>
- <template #item="{ item }">
- <template v-if="item.raw.type === 'category'">
- <v-list-item
- :ripple="false"
- :class="{
- 'v-list-item--active': expandedCategories.has(item.raw.id),
- }"
- @click.stop="toggleCategory(item.raw.id)"
- >
- <template #prepend>
- <v-icon
- :icon="
- 'fas ' +
- (expandedCategories.has(item.raw.id)
- ? 'fa-angle-down'
- : 'fa-angle-right')
- "
- size="small"
- />
- </template>
- <v-list-item-title class="font-weight-medium">
- {{ item.raw.label }}
- </v-list-item-title>
- </v-list-item>
- </template>
- <template v-else-if="item.raw.type === 'subcategory'">
- <v-list-item
- :ripple="false"
- :class="{
- 'v-list-item--active': expandedSubcategories.has(item.raw.id),
- 'pl-8': true,
- }"
- @click.stop="toggleSubcategory(item.raw.id)"
- >
- <template #prepend>
- <v-icon
- :icon="
- 'fas ' +
- (expandedSubcategories.has(item.raw.id)
- ? 'fa-angle-down'
- : 'fa-angle-right')
- "
- size="small"
- />
- </template>
- <v-list-item-title>
- {{ item.raw.label }}
- </v-list-item-title>
- </v-list-item>
- </template>
- <template v-else>
- <v-list-item
- :active="modelValue.includes(String(item.raw.value!))"
- :class="{
- 'd-flex': true,
- 'pl-12': item.raw.level === 2,
- 'pl-8': item.raw.level === 1,
- }"
- @click="toggleItem(String(item.raw.value!))"
- >
- <template #prepend>
- <v-checkbox
- :model-value="modelValue.includes(String(item.raw.value!))"
- color="primary"
- :hide-details="true"
- @click.stop="toggleItem(String(item.raw.value!))"
- />
- </template>
- <v-list-item-title>
- {{ item.raw.label }}
- </v-list-item-title>
- </v-list-item>
- </template>
- </template>
- </v-select>
- </template>
- <script setup lang="ts">
- import { ref, computed, type PropType, type Ref } from 'vue'
- import type { ResourceTreeContent } from '~/types/data'
- interface FlattenedItem {
- id: string
- label: string
- value?: string
- type: 'category' | 'subcategory' | 'item'
- level: number
- parentId?: string
- }
- const props = defineProps({
- modelValue: {
- type: Array as PropType<string[]>,
- required: true,
- },
- items: {
- type: [Array, Object] as PropType<ResourceTreeContent>,
- required: true,
- },
- maxVisibleChips: {
- type: Number,
- required: false,
- default: null,
- },
- label: {
- type: String,
- required: false,
- default: '',
- },
- variant: {
- type: String as PropType<
- | 'filled'
- | 'outlined'
- | 'plain'
- | 'underlined'
- | 'solo'
- | 'solo-inverted'
- | 'solo-filled'
- | undefined
- >,
- required: false,
- default: 'outlined',
- },
- })
- const emit = defineEmits(['update:modelValue'])
- const expandedCategories: Ref<Set<string>> = ref(new Set())
- const expandedSubcategories: Ref<Set<string>> = ref(new Set())
- /**
- * Flatten the ResourceTreeContent into a flat array for v-select
- */
- const flattenedItems = computed(() => {
- const items: FlattenedItem[] = []
- let itemCounter = 0
- if (Array.isArray(props.items)) {
- // Simple array of strings
- props.items.forEach((item) => {
- items.push({
- id: `item-${itemCounter++}`,
- label: item,
- value: item,
- type: 'item',
- level: 0,
- })
- })
- } else {
- // Object structure (2 or 3 levels)
- Object.entries(props.items).forEach(([categoryKey, categoryValue]) => {
- const categoryId = `category-${categoryKey}`
- // Add category
- items.push({
- id: categoryId,
- label: categoryKey,
- type: 'category',
- level: 0,
- })
- if (Array.isArray(categoryValue)) {
- // 2-level structure: Record<string, string[]>
- if (expandedCategories.value.has(categoryId)) {
- categoryValue.forEach((item) => {
- items.push({
- id: `item-${itemCounter++}`,
- label: item,
- value: item,
- type: 'item',
- level: 1,
- parentId: categoryId,
- })
- })
- }
- } else {
- // 3-level structure: Record<string, Record<string, string[]>>
- if (expandedCategories.value.has(categoryId)) {
- Object.entries(categoryValue).forEach(([subcategoryKey, subcategoryValue]) => {
- const subcategoryId = `subcategory-${categoryKey}-${subcategoryKey}`
- // Add subcategory
- items.push({
- id: subcategoryId,
- label: subcategoryKey,
- type: 'subcategory',
- level: 1,
- parentId: categoryId,
- })
- if (expandedSubcategories.value.has(subcategoryId)) {
- subcategoryValue.forEach((item) => {
- items.push({
- id: `item-${itemCounter++}`,
- label: item,
- value: item,
- type: 'item',
- level: 2,
- parentId: subcategoryId,
- })
- })
- }
- })
- }
- }
- })
- }
- return items
- })
- /**
- * Toggle category expansion
- */
- const toggleCategory = (categoryId: string) => {
- if (expandedCategories.value.has(categoryId)) {
- expandedCategories.value.delete(categoryId)
- // Also collapse all subcategories
- Array.from(expandedSubcategories.value).forEach(subId => {
- if (subId.startsWith(`subcategory-${categoryId.replace('category-', '')}-`)) {
- expandedSubcategories.value.delete(subId)
- }
- })
- } else {
- expandedCategories.value.add(categoryId)
- }
- }
- /**
- * Toggle subcategory expansion
- */
- const toggleSubcategory = (subcategoryId: string) => {
- if (expandedSubcategories.value.has(subcategoryId)) {
- expandedSubcategories.value.delete(subcategoryId)
- } else {
- expandedSubcategories.value.add(subcategoryId)
- }
- }
- /**
- * Toggle item selection
- */
- const toggleItem = (value: string) => {
- const currentSelection = [...props.modelValue]
- const index = currentSelection.indexOf(value)
- if (index > -1) {
- currentSelection.splice(index, 1)
- } else {
- currentSelection.push(value)
- }
- emit('update:modelValue', currentSelection)
- }
- /**
- * Remove item from selection
- */
- const removeItem = (value: string) => {
- emit(
- 'update:modelValue',
- props.modelValue.filter((item) => item !== value),
- )
- }
- </script>
|