ahmed
ahmed

Reputation: 14666

Vuejs 3 how to pass variable to all elements inside a slot

I'm using VueJs 3 to create a dynamic table component, which involves sending down loop index variable to all child components inside a slot

the usage of the component as following

<template>
  <h1>my table</h1>
  <MyTable
    :source="[
      { fname: 'Jhon', lname: 'Smith' },
      { fname: 'Arthur', lname: 'Cromer' },
    ]"
  >
    <MyTableColumn cellTitle="First Name" cellValue="fname"></MyTableColumn>
    <MyTableColumn cellTitle="Last Name" cellValue="lname"></MyTableColumn>
  </MyTable>
</template>

The above shows what i am trying to achieve in which you may set the table data source (array) and any number of columns where each column accepts a title and cell value.

I'm not sure how to send the iteration index (v-for index) down to all components within a slot.

here is the MyTable.vue template

<template>
  <table>
    <tr v-for="(row, index) in $props.source" :key="index">
      {{
        (currentRow = index)
      }}
      <slot></slot>
    </tr>
  </table>
</template>

I've tried scoped slots but did not help. and tried the provide/inject technique by providing the index from MyTable.vue and injecting it in each instance of MyTableColumn.vue, but this hasn't worked as expected because the injected variable always has the last index. not sure if it requires usage of render function.

I've have spent the last couple of days trying to find a solution with no luck.

here you can access the full code demo which has the unexpected behaviour

Upvotes: 5

Views: 10442

Answers (3)

tao
tao

Reputation: 90013

Let's start with a simple slot example:

<template>
  content which always renders
  <slot name="slotName" :foo="'bar'">
    content to be rendered if slot is not present
  </slot>
  some more content which always renders
</template>

Given the above slotted component, the following markup:

<Slotted>
  <template #slotName="{foo}">
    replacement content for slot - {{ foo }}
  </template>
</Slotted>

would produce

content which always renders
  replacement content for slot - bar
some more content which always renders

, while <Slotted /> would produce:

content which always renders
  content to be rendered if slot is not present
some more content which always renders

Implementing the above into a table, we define the rendered columns as fields and define a slot for each field's cell and one for each field's header. They're dynamic.

To replace any column cell or column header, provide a template for that column's cells. Inside default content, nest a general slot for all cells (#cell) and one for all headers (#header). If provided, will be used for any cell (or header) which doesn't have a specified template. If not provided, default content.

In code:

Table.vue

<template>
  <table>
    <thead>
      <tr>
        <th v-for="{label, key} in fields" :key="key">
          <slot :name="`header(${key})`"
                v-bind="{ label, propName: key }">
            <slot name="header">
              {{ label }}
            </slot>
          </slot>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(item, index) in rows" :key="index">
        <td v-for="field in fields" :key="field.key">
          <slot :name="`cell(${field.key})`"
                v-bind="{ 
                  item, 
                  index, 
                  value: item[field.key],
                  field
                }">
            <slot name="cell"
                  v-bind="{ 
                    item, 
                    index, 
                    value: item[field.key],
                    field
                  }">
              {{ item[field.key] }}
            </slot>
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

Use it as:

  <Table :rows="rows" :fields="fields">
    <template #cell(fname)="{ value, index }">
      <span style="color: red" v-text="`${index} - ${value}" />
    </template>
    <template #header(fname)="{ label }">
      <span style="color: blue" v-text="label" />
    </template>
    <template #header="{ label }">
      <!-- applies to all header cells which don't have 
           a #header({key}) template provided.
           The ones which do (eg: `#header(fname)`) override this. -->
      <span :style="{fontStyle: 'italic'}" v-text="label" />
    </template>

  </Table>

Working example.

The beauty of this approach is that now you can use this <Table /> to render any items, without having to change the <Table /> component. You only have to provide the slots which should render something other than the default content.

Note: Inside each slot you have access to whatever was bound to the slot scope. In this case { item, index, value, field } for cells and { label, key } for header cells. Obviously, you're not limited to those. You can bind whatever you want to them.

Upvotes: 2

Estus Flask
Estus Flask

Reputation: 222334

The alternative to using scoped slots and associated boilerplate code is to add props to children in render function, this is done by modifying vnode objects, similarly to this answer.

MyTable can have a limited set of components that are expected to be used as its children and may receive special treatment, e.g. MyTableColumn.

() => {
  const tableChildren = slots.default?.() || [];
  const tableColumns = tableChildren.filter(vnode => vnode.type === MyTableColumn);
  const tableHeaders = tableColumns.map(vnode => vnode.props.cellTitle);

  return h('table', {}, [
    h('tr', {}, [
      ...tableHeaders.map(header => h('td', {}, header)),
    ]),
    ...props.source.map((row, index) => {
      return h('tr', {}, [
        ...tableColumns.map(vnode => {
          const name = vnode.props.cellValue

          return {
            ...vnode,
            props: {
              ...vnode.props,
              index,
              value: row[name]
            }
          };
        })
      ])
    })
  ...

Here additional index and value props are added to MyTableColumn vnodes prior to rendering.

This can be changed by making MyTableColumn not being rendered at all, just providing necessary information about columns to MyTable and optionally custom layout for them. This approach is more popular in React ecosystem, but can also be seen in Primevue DataTable, for instance. It can be seen that Column components have no templates and so aren't rendered.

Upvotes: 2

tony19
tony19

Reputation: 138216

Rendering rows

You can use scoped slots to pass data from MyTable.vue to its slot children:

  1. In MyTable.vue, bind the value of index and row to the <slot>:
<tr v-for="(row, index) in $props.source" :key="index">
  <slot :index="index" :row="row"></slot>
</tr>
  1. In App.vue, access MyTable.vue's slot props via v-slot on a <template>, and bind them to the MyTableColumn's props:
<MyTable
  :source="[
    { fname: 'Jhon', lname: 'Smith' },
    { fname: 'Arthur', lname: 'Cromer' },
  ]"
>
                           👇
  <template v-slot="{ index, row }">
                                             👇              👇
    <MyTableColumn cellTitle="First Name" :index="index" :cellValue="row.fname"></MyTableColumn>
                                             👇              👇
    <MyTableColumn cellTitle="Last Name" :index="index" :cellValue="row.lname"></MyTableColumn>
  </template>
</MyTable>

Rendering headers

  1. In MyTable.vue, add a headers prop to contain an array of column titles, and render them above the table body:
defineProps({
  headers: Array,
})
<table>
  <tr>
    <td v-for="header in headers">{{ header }}</td>
  </tr>
  <!-- table body... -->
</table>
  1. In App.vue, bind the desired column titles to <MyTable>.headers:
<MyTable :headers="['First Name', 'Last Name']" ⋯>

demo

Upvotes: 10

Related Questions