Search code examples
vuejs3

how to bind slot variable to scoped slots


I am new to vue 3 component and renderer function, just want to use a simple table component with column meta data with slot supports, like below-attached code snippets, I I don't know how to implement the TheColumn part, the slot #header & #cell cannot work in this version. in method 'columns()', the column item doesn't keep header() & cell() objects too. I googled but didn't find answer yet. anybody help explain in more details? thanks.

  • App.Vue:

     <template>
       <TheTable :data="data">
         <Column label="Name" prop="name" />
         <Column label="City">
           <template #header> {{ translate ? '城市' : 'City' }} </template>
           <template #cell="{item}"> 
              {{ translate ? cities[item.city] || item.city: item.city}}
           </template>
         </Column>
         <Column label="Email">
           <template #cell="{ item }"> {{ item.email }} </template>
         </Column>
       </TheTable>
      </template>
    
     <script>
    
       import TheTable from './TheTable.vue';
       import TheColumn from './TheColumn.vue';
    
       export default {
         name: 'App',
         components: {
           TheTable,
           Column: TheColumn,
        },
        data() {
          return {
           translate: true,
           cities: {
             NY: '纽约',
             LA: '洛杉基',
           },
           data: [
            {
              city: 'NY',
              name: 'Jacky',
              email: '[email protected]'
            },
            {
               city: 'LA',
               name: 'Tom',
               email: '[email protected]'
            }
          ],
          }
        }
      }
      </script>
    
  • TheTable.vue:

     <template>
     <div>
         <table>
             <thead>
                 <tr>
                     <th v-for="(header, column) in columns" :key="column">
                         <slot name="header">{{ header.label }}</slot>
                     </th>
                 </tr>
             </thead>
             <tbody>
                 <tr v-for="(row, index) in data" :key="index">
                     <td v-for="(header, column) in columns" :key="column"> 
                         <slot name="cell" :item="row"> {{ row[header.prop] }}  </slot>
                     </td>
                 </tr>
             </tbody>
          </table>
        </div>
     </template>
    
     <script>
       export default {
         name: 'TheTable',
         props: {
           data: {
             type: Array,
             required: true
          },
        },
        computed: {
           columns() {
             let list = this.$slots && this.$slots.default && this.$slots.default()
                 .filter(e => e.type.name === 'TheColumn')
                 .map(slot => {
                     let a = {
                         label: slot.props.label,
                         prop: slot.props.prop,
                         slotHeader: slot.props.header,
                         slotCell: slot.props.cell
                     };
                     return a;
                 }) || [];
             console.log('list ' + list);
             return list;
           },
        },
      }
     </script>
    
  • TheColumn.vue:

    <script>
     const TheColumn = (props, context) => {
     return [];
    }
    
    export default TheColumn;
    </script>
    

Solution

  • Playground

    Basically you should create an array of header and cell components from the default slot filled with TheColumn to render in TheTable. The only problem is to detect TheColumn in production, I used an isColumn property. Also I

    1. Changed the cell to default slot for more compact column definitions
    2. Used Composition API to make the snippet coding faster, you can refactor it to the Options API

    App.vue (the template changed):

    <template>
       <TheTable :data="data">
         <Column :label prop="name" />
         <Column>
           <template #header> {{ translate ? '城市' : 'City' }} </template>
           <template  #="{ item }"> {{ translate ? cities[item.city] || item.city: item.city}}</template>
         </Column>
         <Column v-if="showEmail" label="Email" #="{ item }">
           {{ item.email }}
         </Column>
       </TheTable>
       <div style="display:flex; gap: 8px; margin-top:16px">
        <button @click="label = label === 'Name' ? 'Name changed' : 'Name'">Toggle name label</button>
        <button @click="showEmail = !showEmail">Toggle email column</button>
        <button @click="translate = !translate">Toggle translate</button>
       </div>
    </template>
    
    

    TheTable.vue

    <script setup>
    
    import {h, useSlots, createTextVNode, computed} from 'vue';
    
    const $slots = useSlots();
    
    defineProps({
        data: Array
    });
    
    const columns = computed(() => {
        const header = [], cells = [];
        let idx = 0;
        $slots.default().map(vnode => {
            if(! ('isColumn' in (vnode.type.props ?? {}))) return; // detect the Column component in production
            header[idx] = vnode.props?.label ? 
                () => createTextVNode(vnode.props.label) : () => vnode.children.header?.();
            cells[idx] = vnode.props?.prop ? 
                ({item}) => createTextVNode(item[vnode.props.prop]) : props => [h(vnode.children.default, props)];
            idx++;
        });
        return {header, cells};
    });
    
    </script>
    
    <template>
     <div>
         <table>
             <thead>
                 <tr>
                     <th v-for="(child, column) in columns.header" :key="column">
                         <component :is="child"/>
                     </th>
                 </tr>
             </thead>
             <tbody>
                 <tr v-for="(row, index) in data" :key="index">
                     <td v-for="(cell, column) in columns.cells" :key="column"> 
                         <component :is="cell" :item="row"></component>
                     </td>
                 </tr>
             </tbody>
          </table>
        </div>
     </template>
    

    TheColumn.vue:

    <script setup>
    defineProps({
      isColumn: Boolean
    })
    </script>
    <template></template>