2

I'm trying to create a Tabs component in Vue 3 similar to this question here.

<tabs>
   <tab title="one">content</tab>
   <tab title="two" v-if="show">content</tab> <!-- this fails -->
   <tab :title="t" v-for="t in ['three', 'four']">{{t}}</tab> <!-- also fails -->
   <tab title="five">content</tab>
</tabs>

Unfortunately the proposed solution does not work when the Tabs inside are dynamic, i.e. if there is a v-if on the Tab or when the Tabs are rendered using a v-for loop - it fails.

I've created a Codesandbox for it here because it contains .vue files:

https://codesandbox.io/s/sleepy-mountain-wg0bi?file=%2Fsrc%2FApp.vue

enter image description here

I've tried using onBeforeUpdate like onBeforeMount, but that does not work either. Actually, it does insert new tabs, but the order of tabs is changed.

The biggest hurdle seems to be that there seems to be no way to get/set child data from parent in Vue 3. (like $children in Vue 2.x). Someone suggested to use this.$.subtree.children but then it was strongly advised against (and didn't help me anyway I tried).

Can anyone tell me how to make the Tab inside Tabs reactive and update on v-if, etc?

tony19
  • 125,647
  • 18
  • 229
  • 307
supersan
  • 5,671
  • 3
  • 45
  • 64

3 Answers3

2

This looks like a problem with using the item index as the v-for loop's key.

The first issue is you've applied v-for's key on a child element when it should be on the parent (on the <li> in this case).

<li v-for="(tab, i) in tabs">
  <a :key="i"> ❌
  </a>
</li>

Also, if the v-for backing array can have its items rearranged (or middle items removed), don't use the item index as the key because the index wouldn't provide a consistently unique value. For instance, if item 2 of 3 were removed from the list, the third item would be shifted up into index 1, taking on the key that was previously used by the removed item. Since no keys in the list have changed, Vue reuses the existing virtual DOM nodes as an optimization, and no rerendering occurs.

A good key to select in your case is the tab's title value, as that is always unique per tab in your example. Here's your new Tab.vue with the index replaced with a title prop:

// Tab.vue
export default {
  props: ["title"], 
  setup(props) {
    const isActive = ref(false)
    const tabs = inject("TabsProvider")

    watch(
      () => tabs.selectedIndex,
      () => {
        isActive.value = props.title === tabs.selectedIndex
      }                        
    )

    onBeforeMount(() => {
      isActive.value = props.title === tabs.selectedIndex
    })                       

    return { isActive }
  },
}

Then, update your Tabs.vue template to use the tab's title instead of i:

<li class="nav-item" v-for="tab in tabs" :key="tab.props.title">
  <a                                                     
    @click.prevent="selectedIndex = tab.props.title"
    class="nav-link"                          
    :class="tab.props.title === selectedIndex && 'active'"
    href="#"          
  >
    {{ tab.props.title }}
  </a>
</li>

demo

tony19
  • 125,647
  • 18
  • 229
  • 307
  • Thanks for the help. I understand the keying much better now. But in your demo it still does not render the `v-for` tabs: see the `Dynamic {{ t }}` in App.vue. Any ideas? – supersan Jan 04 '21 at 01:21
  • Also one more comment. in Vue 2 it wasn't required to "key" the tabs with `title` or anything because `$children` provided direct access to slot children. So it was perfectly fine to do `12`. Now it seems that we need to specify a `title` or some sort of key on each ``. Can you think of a way to identify the selectedTab without a tab.title or any other key? – supersan Jan 04 '21 at 01:23
  • @supersan I fixed the issue regarding the missing dynamic tabs (see updated demo), but this solution (and the original one you linked to) is not ideal IMO because it requires reaching into `VNode` internals to parse the Tab's titles just to show the tab headers. I think a better solution would be to pass the data as a prop on `Tabs` and/or using scoped slots. – tony19 Jan 04 '21 at 06:50
  • Also, consider using an existing tab component, or a library that provides one (e.g., Vuetify, BootstrapVue, Buefy, etc.). – tony19 Jan 04 '21 at 06:54
  • Thanks so much for your help again. You're right this Vnode thing sucks. Things were much simpler in Vue 2.x as we had direct access to slot's `$children` plus it was also reactive, so even if the tabs were dragged or sorted it would update automatically. I did look into Bootstrapvue, Vuetify, etc but those haven't migrated to Vue 3 just (who knows they are facing the same issues lol). Anyway so I'm sticking with Vue 2.x for now. It is more than enough for my needs anyway. I was only temped to upgrade because of `vite` which looks like a huge timesaver. – supersan Jan 04 '21 at 10:43
  • Sir, I am using what you wrote, but selectTab 0 is not active, how can we activate it? – Liza Apr 14 '22 at 12:35
0

This solution was posted by @anteriovieira in Vuejs forum and looks like the correct way to do it. The missing piece of puzzle was getCurrentInstance available during setup

The full working code can be found here:

https://codesandbox.io/s/vue-3-tabs-ob1it

I'm adding it here for reference of anyone coming here from Google looking for the same.

supersan
  • 5,671
  • 3
  • 45
  • 64
  • The Vuejs forum link doesn't work and the codesandbox.io link points to code that doesn't function. – Jason Aug 28 '23 at 17:38
0

Since access to slots is available as $slots in the template (see Vue documentation), you could also do the following:

// Tabs component

<template>
  <div v-if="$slots && $slots.default && $slots.default()[0]" class="tabs-container">
    <button
      v-for="(tab, index) in getTabs($slots.default()[0].children)"
      :key="index"
      :class="{ active: modelValue === index }"
      @click="$emit('update:model-value', index)"
    >
      <span>
        {{ tab.props.title }}
      </span>
    </button>
  </div>
  <slot></slot>
</template>

<script setup>
  defineProps({ modelValue: Number })

  defineEmits(['update:model-value'])

  const getTabs = tabs => {
    if (Array.isArray(tabs)) {
      return tabs.filter(tab => tab.type.name === 'Tab')
    } else {
      return []
    }
</script>

<style>
...
</style>

And the Tab component could be something like:

// Tab component

<template>
  <div v-show="active">
    <slot></slot>
  </div>
</template>

<script>
  export default { name: 'Tab' }
</script>

<script setup>
  defineProps({
    active: Boolean,
    title: String
  })
</script>

The implementation should look similar to the following (considering an array of objects, one for each section, with a title and a component):

...
<tabs v-model="active">
  <tab
    v-for="(section, index) in sections"
    :key="index"
    :title="section.title"
    :active="index === active"
  >
    <component
      :is="section.component"
    ></component>
  </app-tab>
</app-tabs>
...
<script setup>
import { ref } from 'vue'

const active = ref(0)
</script>

cmlima
  • 381
  • 3
  • 3