Explorar o código

Adds TreeSelect component

Implements a new TreeSelect component for hierarchical data selection.

This component allows users to navigate and select items from a tree-like structure, enhancing the user experience for complex selection scenarios. It includes features such as category expansion/collapse, item selection, and display of selected items with chips.

Also introduces a demo page to showcase the component.
Olivier Massot hai 4 meses
pai
achega
6c685189da

+ 1 - 1
components/Layout/Dialog.vue

@@ -14,7 +14,7 @@
         "
       >
         <h3 :class="'d-flex theme-' + theme">
-          <v-icon icon="fa-solid fa-bullhorn" width="25" htight="25" />
+          <v-icon icon="fa-solid fa-bullhorn" width="25" height="25" />
           <span class="pt-4"><slot name="dialogType" /></span>
         </h3>
       </div>

+ 206 - 0
components/Ui/Input/TreeSelect.vue

@@ -0,0 +1,206 @@
+<template>
+  <v-select
+    v-model="selectedItems"
+    :items="flattenedItems"
+    item-title="label"
+    item-value="value"
+    multiple
+    chips
+    closable-chips
+    :menu-props="{ maxHeight: 400 }"
+    v-bind="$attrs"
+  >
+    <template #selection="{ item, index }">
+      <v-chip
+        v-if="index < maxVisibleChips"
+        :key="item.raw.value"
+        closable
+        @click:close="removeItem(item.raw.value)"
+      >
+        {{ item.raw.label }}
+      </v-chip>
+      <span
+        v-if="index === maxVisibleChips && selectedItems.length > maxVisibleChips"
+        class="text-grey text-caption align-self-center"
+      >
+        (+{{ selectedItems.length - maxVisibleChips }} autres)
+      </span>
+    </template>
+
+    <template #item="{ props, item }">
+      <template v-if="item.raw.type === 'category'">
+        <v-list-item
+          @click.stop="toggleCategory(item.raw.id)"
+          :ripple="false"
+          :class="{ 'v-list-item--active': expandedCategories.has(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
+          @click.stop="toggleSubcategory(item.raw.id)"
+          :ripple="false"
+          :class="{
+            'v-list-item--active': expandedSubcategories.has(item.raw.id),
+            'pl-8': true
+          }"
+        >
+          <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
+          v-bind="props"
+          :class="{ 'pl-12': item.raw.level === 2, 'pl-8': item.raw.level === 1 }"
+        >
+          <template #prepend>
+            <v-checkbox
+              :model-value="selectedItems.includes(item.raw.value)"
+              @click.stop="toggleItem(item.raw.value)"
+              color="primary"
+            />
+          </template>
+          <v-list-item-title>
+            {{ item.raw.label }}
+          </v-list-item-title>
+        </v-list-item>
+      </template>
+    </template>
+  </v-select>
+</template>
+
+<script setup lang="ts">
+interface SelectItem {
+  id: string
+  label: string
+  value?: string
+  type: 'category' | 'subcategory' | 'item'
+  parentId?: string
+  level: number
+}
+
+const props = defineProps({
+  modelValue: {
+    type: Array as PropType<string[]>,
+    required: true
+  },
+  items: {
+    type: Array as PropType<SelectItem[]>,
+    required: true
+  },
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+// État réactif
+const expandedCategories = ref<Set<string>>(new Set())
+const expandedSubcategories = ref<Set<string>>(new Set())
+
+// Modèle v-model
+const selectedItems = computed({
+  get: () => props.modelValue,
+  set: (value: string[]) => emit('update:modelValue', value)
+})
+
+// Construction de la liste aplatie pour v-select
+const flattenedItems = computed(() => {
+  const result: SelectItem[] = []
+
+  const processItems = (items: SelectItem[], parentExpanded = true) => {
+    for (const item of items) {
+      if (item.type === 'category') {
+        result.push(item)
+        if (expandedCategories.value.has(item.id)) {
+          const subcategories = props.items.filter(i =>
+            i.parentId === item.id && i.type === 'subcategory'
+          )
+          processItems(subcategories, true)
+        }
+      } else if (item.type === 'subcategory') {
+        if (parentExpanded) {
+          result.push(item)
+          if (expandedSubcategories.value.has(item.id)) {
+            const subItems = props.items.filter(i =>
+              i.parentId === item.id && i.type === 'item'
+            )
+            processItems(subItems, true)
+          }
+        }
+      } else if (item.type === 'item' && parentExpanded) {
+        result.push(item)
+      }
+    }
+  }
+
+  const topLevelItems = props.items.filter(item => !item.parentId)
+  processItems(topLevelItems)
+
+  return result
+})
+
+// Méthodes
+const toggleCategory = (categoryId: string) => {
+  if (expandedCategories.value.has(categoryId)) {
+    expandedCategories.value.delete(categoryId)
+    // Fermer aussi les sous-catégories
+    const subcategories = props.items.filter(i =>
+      i.parentId === categoryId && i.type === 'subcategory'
+    )
+    subcategories.forEach(sub => {
+      expandedSubcategories.value.delete(sub.id)
+    })
+  } else {
+    expandedCategories.value.add(categoryId)
+  }
+}
+
+const toggleSubcategory = (subcategoryId: string) => {
+  if (expandedSubcategories.value.has(subcategoryId)) {
+    expandedSubcategories.value.delete(subcategoryId)
+  } else {
+    expandedSubcategories.value.add(subcategoryId)
+  }
+}
+
+const toggleItem = (value: string) => {
+  const currentSelection = [...selectedItems.value]
+  const index = currentSelection.indexOf(value)
+
+  if (index > -1) {
+    currentSelection.splice(index, 1)
+  } else {
+    currentSelection.push(value)
+  }
+
+  selectedItems.value = currentSelection
+}
+
+const removeItem = (value: string) => {
+  selectedItems.value = selectedItems.value.filter(item => item !== value)
+}
+</script>
+
+<style scoped>
+.v-list-item--active {
+  background-color: rgba(var(--v-theme-primary), 0.1);
+}
+</style>

+ 53 - 0
pages/dev/poc_tree_select_input.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="pa-4">
+    <h2>Select Hiérarchique</h2>
+
+    <UiInputTreeSelect
+      v-model="selectedValues"
+      :items="hierarchicalItems"
+      label="Choisissez vos options"
+      placeholder="Sélectionnez des éléments..."
+      :max-visible-chips="2"
+    />
+
+    <div class="mt-4">
+      <h3>Valeurs sélectionnées :</h3>
+      <pre>{{ selectedValues }}</pre>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+const selectedValues = ref<string[]>([])
+
+const hierarchicalItems = ref([
+  // Catégories principales
+  { id: 'cat1', label: 'Électronique', type: 'category', level: 0 },
+  { id: 'cat2', label: 'Vêtements', type: 'category', level: 0 },
+
+  // Sous-catégories d'Électronique
+  { id: 'subcat1', label: 'Ordinateurs', type: 'subcategory', parentId: 'cat1', level: 1 },
+  { id: 'subcat2', label: 'Téléphones', type: 'subcategory', parentId: 'cat1', level: 1 },
+
+  // Items sous Ordinateurs
+  { id: 'item1', label: 'Laptop Gaming', value: 'laptop-gaming', type: 'item', parentId: 'subcat1', level: 2 },
+  { id: 'item2', label: 'Laptop Bureau', value: 'laptop-office', type: 'item', parentId: 'subcat1', level: 2 },
+  { id: 'item3', label: 'PC Desktop', value: 'pc-desktop', type: 'item', parentId: 'subcat1', level: 2 },
+
+  // Items sous Téléphones
+  { id: 'item4', label: 'iPhone', value: 'iphone', type: 'item', parentId: 'subcat2', level: 2 },
+  { id: 'item5', label: 'Android', value: 'android', type: 'item', parentId: 'subcat2', level: 2 },
+
+  // Sous-catégories de Vêtements
+  { id: 'subcat3', label: 'Homme', type: 'subcategory', parentId: 'cat2', level: 1 },
+  { id: 'subcat4', label: 'Femme', type: 'subcategory', parentId: 'cat2', level: 1 },
+
+  // Items sous Homme
+  { id: 'item6', label: 'Chemises', value: 'chemises-homme', type: 'item', parentId: 'subcat3', level: 2 },
+  { id: 'item7', label: 'Pantalons', value: 'pantalons-homme', type: 'item', parentId: 'subcat3', level: 2 },
+
+  // Items sous Femme
+  { id: 'item8', label: 'Robes', value: 'robes', type: 'item', parentId: 'subcat4', level: 2 },
+  { id: 'item9', label: 'Blouses', value: 'blouses', type: 'item', parentId: 'subcat4', level: 2 },
+])
+</script>