Reputation: 6141
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 Tab
s inside are dynamic, i.e. if there is a v-if on the Tab
or when the Tab
s 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
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?
Upvotes: 3
Views: 4327
Reputation: 6903
I discovered a simple solution that works by having the tab
registering itself with the tabs
component using provide
+ inject
.
It identifies the tabs using a unique title, you could go by index instead if you wanted to.
Tabs
Component HTML:
<ul class="tabs">
<li
v-for="tab in tabs"
:key="tab.props.title"
:class="{ active: tab.props.title === activeTabTitle }"
>
<a
href="#"
@click="activeTabTitle = tab.props.title"
>{{ tab.props.title }}</a
>
</li>
</ul>
<div class="tab-content">
<slot></slot>
</div>
Tabs
Component setup script:
const activeTabTitle = defineModel();
const tabs = ref([]);
provide("tabsState", {
tabs,
activeTabTitle,
});
Tab
Component HTML:
<div v-if="title === activeTabTitle" class="tab-content">
<slot></slot>
</div>
Tab
Component setup script:
const { title } = defineProps({
title: String,
});
const { tabs, activeTabTitle } = inject("tabsState");
const tab = getCurrentInstance();
onBeforeMount(() => tabs.value.push(tab));
Upvotes: 1
Reputation: 539
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>
Upvotes: 0
Reputation: 6141
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.
Upvotes: -1
Reputation: 138366
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 key
s 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>
Upvotes: 2