Vuetify コンポーネントのラッパーの作り方と注意点

# この記事でわかること

- Vuetify コンポーネントのラッパーコンポーネントの作り方
- Vuetify コンポーネントのラッパーコンポーネントの実装では特殊なワークアラウンドが必要な場合があることと、その経緯

# 結論

### 全てのスロットがスロットプロパティを持つコンポーネントの場合(ワークアラウンド不要)

```VXxxWrapper.vue
<template>
  <v-xxx
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]="slotProps"
    >
      <slot v-bind:name="name" v-bind="slotProps"></slot>
    </template>
  </v-xxx>
</template>
```

### 全てのスロットがスロットプロパティを持たないコンポーネント(v-card など)の場合

```VCardWrapper.vue
<template>
  <v-card
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]
    >  <!-- スロットプロパティを受け取らない -->
      <slot v-bind:name="name"></slot> <!-- スロットプロパティをバインドしない -->
    </template>
  </v-card>
</template>
```

### 一部のスロットのみスロットプロパティを持たないコンポーネント(v-data-table など)の場合

```VDataTableWrapper.vue
<template>
  <v-data-table
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]="slotProps"
    >
      <slot v-bind:name="name" v-bind="slotProps"></slot>
    </template>
    <!-- スロットプロパティを受け取らないスロットについて個別に実装する -->
    <template v-slot:footer.prepend>
      <slot name="footer.prepend"></slot>
    </template>
    <template v-slot:loading>
      <slot name="loading"></slot>
    </template>
    <template v-slot:no-data>
      <slot name="no-data"></slot>
    </template>
    <template v-slot:no-results>
      <slot name="no-results"></slot>
    </template>
    <template v-slot:progress>
      <slot name="progress"></slot>
    </template>
  </v-data-table>
</template>
```

### 静的なコンテンツを付加する場合

```VXxxWrapperWithStaticContent.vue
<template>
  <v-xxx
    v-bind="$attrs"
    v-on="$listeners"
  >
    STATIC_CONTENT_FOR_DEFAULT_SLOT
    <slot name="body"></slot>
    <!--
      デフォルトスロットのみ、静的なコンテンツと動的なコンテンツの共存が出来ないため、
      名前付きスロットを利用する
    -->

    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]
    >
      STATIC_CONTENT_FOR_NAMED_SLOT
      <slot v-bind:name="name"></slot>
    </template>
  </v-xxx>
</template>
```

# 詳細

## Vuetify のコンポーネントに適用する場合の制限

[Vue2 コンポーネントのラッパーコンポーネントの作り方](https://qiita.com/weapom923/items/0b1cac6cc71ef57596c2) の記事で書いた Vue2 コンポーネントラッパーの実装は、Vuetify のコンポーネントにも適用できるのだが、いくつか制限があることが実験的に分かった。

## スロットプロパティに関する制限

### v-card の場合

`v-card` の場合、

```VCardWrapper.vue
<template>
  <v-card
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]="slotProps"
    >
      <slot v-bind:name="name" v-bind="slotProps"></slot>
    </template>
  </v-card>
</template>
```

この実装ではスロットのテンプレートが、ラップする対象の `v-card` コンポーネントに伝搬されない。

```Main.vue
<template>
  <!-- 比較用: v-card コンポーネントをそのまま利用 -->
  <v-card>
    <v-card-title>TITLE</v-card-title>
    <template v-slot:progress>HOGE</template>
  </v-card>

  <!-- v-card ラッパーコンポーネントを利用 -->
  <v-card-wrapper>
    <v-card-title>TITLE</v-card-title> <!-- 伝搬される -->
    <template v-slot:progress>HOGE</template> <!-- 伝搬されない -->
  </v-card-wrapper>
</template>
```

これに対して、スロットプロパティを受け取らないようにすると、正しく伝搬させることができる。

```VCardWrapper2.vue
<template>
  <v-card
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]
    > <!-- スロットプロパティを受け取らない -->
      <slot v-bind:name="name"></slot> <!-- スロットプロパティをバインドしない -->
    </template>
  </v-card>
</template>
```

```Main.vue
<template>
  <v-card-wrapper2>
    <v-card-title>TITLE</v-card-title> <!-- 伝搬される -->
    <template v-slot:progress>HOGE</template> <!-- 伝搬される -->
  </v-card-wrapper2>
</template>
```

`v-card` の場合、スロットプロパティが必要なスロットは無いため、この対処で特に問題はない。

### v-data-table の場合

`v-data-table` の場合、

```VDataTableWrapper.vue
<template>
  <v-data-table
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]="slotProps"
    > <!-- スロットプロパティを受け取る -->
      <slot v-bind:name="name" v-bind="slotProps"></slot>
    </template>
  </v-data-table>
</template>
```

この実装で、多くのスロットテンプレートについては問題なく `v-data-table` に伝搬されるのだが、`v-card` の場合と同様、スロットプロパティを必要としないスロットについてのみ伝搬されない。

```Main.vue
<template>
  <!-- 比較用: v-data-table コンポーネントをそのまま利用 -->
  <v-data-table
    v-bind:headers="headers"
    v-bind:items="items"
  >
    <template v-slot:item.hoge="{ item, header }">
      {{ item[header.value] }}
    </template>
    <template v-slot:progress>HOGE</template>
  </v-data-table>

  <!-- v-data-table ラッパーコンポーネントを利用 -->
  <v-data-table-wrapper
    v-bind:headers="headers"
    v-bind:items="items"
  >
    <template v-slot:item.hoge="{ item, header }">
      {{ item[header.value] }}
    </template> <!-- 伝搬される -->
    <template v-slot:progress>HOGE</template> <!-- 伝搬されない -->
  </v-data-table-wrapper>
</template>
```

しかし `v-card` のように、スロットプロパティを受け取らないようにすると、スロットプロパティを必要とするスロットのレンダリングでエラーが発生する。

```VDataTableWrapper2.vue
<template>
  <v-data-table
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]
    > <!-- スロットプロパティを受け取らない -->
      <slot v-bind:name="name"></slot>
    </template>
  </v-data-table>
</template>
```

```Main.vue
<template>
  <v-data-table-wrapper2
    v-bind:headers="headers"
    v-bind:items="items"
  >
    <template v-slot:item.hoge="{ item, header }">
      {{ item[header.value] }}
    </template> <!-- レンダリングエラー -->
    <template v-slot:progress>HOGE</template> <!-- 伝搬される -->
  </v-data-table-wrapper2>
</template>
```

そのため、基本的にはスロットプロパティを受け取るようにした上で、以下のようなワークアラウンドを取る必要がある。

```VDataTableWrapper3.vue
<template>
  <v-data-table
    v-bind="$attrs"
    v-on="$listeners"
  >
    <slot></slot>
    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]="slotProps"
    > <!-- スロットプロパティを受け取る -->
      <slot v-bind:name="name" v-bind="slotProps"></slot>
    </template>
    <!-- スロットプロパティを受け取らないスロットについては個別に実装する -->
    <template v-slot:footer.prepend>
      <slot name="footer.prepend"></slot>
    </template>
    <template v-slot:loading>
      <slot name="loading"></slot>
    </template>
    <template v-slot:no-data>
      <slot name="no-data"></slot>
    </template>
    <template v-slot:no-results>
      <slot name="no-results"></slot>
    </template>
    <template v-slot:progress>
      <slot name="progress"></slot>
    </template>
  </v-data-table>
</template>
```

```Main.vue
<template>
  <v-data-table-wrapper3
    v-bind:headers="headers"
    v-bind:items="items"
  >
    <template v-slot:item.hoge="{ item, header }">
      {{ item[header.value] }}
    </template> <!-- 伝搬される -->
    <template v-slot:progress>HOGE</template> <!-- 伝搬される -->
  </v-data-table-wrapper3>
</template>
```

## スロットに対する静的なコンテンツの付加に関する制限

静的なコンテンツの付加については、デフォルトスロットに対してのみワークアラウンドが必要になる。例えば、以下のような `v-card` のラッパーコンポーネントについて、

```VCardWrapperWithStaticContent.vue
<template>
  <v-card
    v-bind="$attrs"
    v-on="$listeners"
  >
    STATIC_CONTENT_FOR_DEFAULT_SLOT
    <slot></slot>

    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]
    >
      STATIC_CONTENT_FOR_NAMED_SLOT
      <slot v-bind:name="name"></slot>
    </template>
  </v-card>
</template>
```

デフォルトスロットが利用されていない場合は `v-card` のデフォルトスロットに静的なコンテンツが流し込まれるが、デフォルトスロットが利用されている場合はそのスロットの内容のみが `v-card` のデフォルトスロットに流し込まれてしまう。

```Main.vue
<template>
  <v-card-wrapper-with-static-content>
    <!-- デフォルトスロットを利用していない: STATIC_CONTENT_FOR_DEFAULT_SLOT -->
    <!-- 名前付きスロットを利用していない: STATIC_CONTENT_FOR_NAMED_SLOT-->
  </v-card-wrapper-with-static-content>

  <v-card-wrapper-with-static-content>
    +++TITLE
    <!-- デフォルトスロットを利用している: +++TITLE (スロットの内容のみ) -->
    <template v-slot:progress>+++HOGE</template>
    <!-- 名前付きスロットを利用している: STATIC_CONTENT_FOR_NAMED_SLOT+++HOGE -->
  </v-card-wrapper-with-static-content>
</template>
```

この問題への対処法は現時点では見つかっていない。苦肉の策として、デフォルトスロットの代わりに名前付きスロットを利用する。

```VCardWrapperWithStaticContent2.vue
<template>
  <v-card
    v-bind="$attrs"
    v-on="$listeners"
  >
    STATIC_CONTENT_FOR_DEFAULT_SLOT
    <slot name="body"></slot> <!-- デフォルトスロット代わりの body スロット -->

    <template
      v-for="(_, name) in $scopedSlots"
      v-slot:[name]
    >
      STATIC_CONTENT_FOR_NAMED_SLOT
      <slot v-bind:name="name"></slot>
    </template>
  </v-card>
</template>
```

このラッパーコンポーネントを利用すると、

```Main.vue
<template>
  <v-card-wrapper-with-static-content2>
    <!-- body スロットを利用していない: STATIC_CONTENT_FOR_DEFAULT_SLOT -->
    <!-- 名前付きスロットを利用していない: STATIC_CONTENT_FOR_NAMED_SLOT -->
  </v-card-wrapper-with-static-content2>

  <v-card-wrapper-with-static-content2>
    <template v-slot:body>+++TITLE</template>
    <!-- body スロットを利用している: STATIC_CONTENT_FOR_DEFAULT_SLOT+++TITLE -->
    <template v-slot:progress>+++HOGE</template>
    <!-- 名前付きスロットを利用している: STATIC_CONTENT_FOR_NAMED_SLOT+++HOGE -->
  </v-card-wrapper-with-static-content2>
</template>
```