Building a Table Component with Dynamic Slot Names in Vue 3
Tags: vue, javascript, tutorial, webdev
Published: 2022-02-22 01:41:34

One of the coolest parts of Vue 3 (and 2) is a capability that is often times completely overlooked: the ability to use a `slot` with a **dynamic name**.

Among other things, this gives you a really powerful method of injecting data exactly where you want it within an array of children.

What does that mean for us? Well, let's build a **SimpleTable** component to find out.

Let's say you want to offer a table component that automatically builds its rows based on an array of objects called `items`:

```javascript
const items = ref([
  { id: 1, title: 'First entry', description: 'Lorem ipsum' },
  { id: 1, title: 'Second entry', description: 'Sit dolar' },
])
```

In order to build the columns, let's use another array of objects called `fields`:

```javascript
const fields = ref([
  { key: 'title', label: 'Title' },
  { key: 'description', label: 'Description' }
])
```

With `items` and `fields` defined, I'd like the table to be used like this:

```vue
<SimpleTable :items="items" :fields="fields" />
```

Awesome, with just a few `v-for` statements that loop over items and fields, and a little bit of logic, we can build out our **SimpleTable** component that automatically generates our rows and columns.

```vue
<template>
  <table>
    <thead>
      <tr>
        <th v-for="field in fields" :key="field.key">
          {{ field.label }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="item in items" :key="item.id">
        <td v-for="key in displayedFieldKeys">
          {{ item[key] }}
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script lang="ts" setup>
import { computed, PropType } from 'vue';

interface TableField {
  key: string
  label: string
}

interface TableItem {
  id: number
  [key: string]: unknown
}

const props = defineProps({
  fields: {
    type: Array as PropType<TableField[]>,
    default: () => []
  },
  items: {
    type: Array as PropType<TableItem[]>,
    default: () => []
  }
})

const displayedFieldKeys = computed(() => {
  return Object.entries(props.fields).map(([_key, value]) => value.key)
})
</script>
```

Totally cool, right!? But, what if we want to modify the content of those glorious table cells based on their field key? For example, making the title bold, or injecting additional data within each cell.

**Dynamic slot names** to the rescue!

Let's wrap the content of each table cell in one of these slots:

```vue
...
<tr v-for="item in items" :key="item.id">
  <td v-for="key in displayedFieldKeys">
    <slot
      :name="`cell(${key})`"
      :value="item[key]"
      :item="item"
    >
      {{ item[key] }}
    </slot>
  </td>
</tr>
...
```

Now, whenever you want to modify the content of a set of cells based on the field key, you can do this:

```vue
<SimpleTable :items="items" :fields="fields">
  <template #cell(title)="{ value, item }">
    <p>A bold item title: <strong>{{ value }}</strong></p>
    <p>Item ID for some reason: {{ item.id }}</p>
  </template>
</SimpleTable>
```

Neato! Now, you can pinpoint the content you want to modify without having to deal with excessive markup.

For the heck of it, I built out a slightly beefier version of this table component with some additional bells and whistles such as `caption` support, `col` styling, hiding and formatting fields, and determining whether to use `th` or `td` for cells.

Column sorting will come in a future revision of this article.

```vue
<template>
  <table>
    <caption v-if="!!$slots.caption || caption">
      <slot name="caption">{{ caption }}</slot>
    </caption>
    <colgroup>
      <template v-for="field in displayedFields" :key="field.key">
        <slot :name="`col(${field.key})`">
          <col>
        </slot>
      </template>
    </colgroup>
    <thead>
      <tr>
        <th v-for="field in displayedFields">
          <slot :name="`head(${field.key})`" :field="field">
            {{ field.label }}
          </slot>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="item in items" :key="item.id">
        <template v-for="key in displayedFieldKeys">
          <Component :is="cellElement(key as string)">
            <slot
              :name="`cell(${key})`"
              :value="format(item, (key as string))"
              :item="item"
              :format="(k: string) => format(item, k)"
            >
              {{ format(item, (key as string)) }}
            </slot>
          </Component>
        </template>
      </tr>
    </tbody>
  </table>
</template>

<script lang="ts" setup>
import { computed, PropType } from 'vue';

interface TableField {
  key: string
  label: string
  format?: Function
  hidden?: boolean
  header?: boolean
}

interface TableItem {
  id: number
  [key: string]: unknown
}

const props = defineProps({
  fields: { type: Array as PropType<TableField[]>, default: () => [] },
  items: { type: Array as PropType<TableItem[]>, default: () => [] },
  caption: { type: String, default: null }
})

const displayedFields = computed(() => props.fields.filter((i) => !i.hidden))

const displayedFieldKeys = computed(() => {
  return Object.entries(displayedFields.value).map(([_key, value]) => value.key)
})

const cellElement = (key: string) => {
  const field = props.fields.find((f) => f.key === key)
  return field && field.header ? 'th' : 'td'
}

const format = (item: TableItem, key: string) => {
  const field = props.fields.find((f) => f.key === key)
  return field && field.format ? field.format(item[key]) : item[key]
}
</script>
```

I hope you found this useful.

Thanks!