Where to Discuss?

Local Group

Preface

Goal: Porting to Vue3 using different pattern. Solving this.$children removal issue.

In order to select tab in click event, all the children data should be available in all child components, This is why I need to pass all tabs data in parent component, along with the children data, to all child components.

I assume you have read the previous article, the Vue component, and Vue router, and Vue2 application.


Pattern Changes

From the vue2 tabs to vue3 tabs.

We are going to overhaul the previous pattern, with these changes:

  1. Using composition API instead of options API. Following the newly released vue3.

  2. Using javascript data instead of template slot. Using provide and inject mechanism, instead of $children.

The $children Removal

There must be another way 🙂.

As you might already know, the $children API, has been deprecated in vue3. The issue is my vue2 tabs component, rely heavily with this.$children. I spent two days, trying to find out, how to emulate this.$children, and find no luck. I started to think that I might be trapped in XY problem.

Then I suddenly realize that I can achieve similar result, if I could change entirely, the way I write this component pattern.

The Vue2 Tabs

Data is provided as html template, in a component. This way is comfortable when we have a lot of html tags.

<template>
   <tabs>
      <tab name="home" title="Home" color="bg-blue-500">
        <h3>Home</h3>
        <p>Lorem ipsum dolor sit amet,
           consectetur adipiscing elit.
           Quisque in faucibus magna.</p>
      </tab>
      …
  </tabs>
</template>

We can just easily passing the html data through slots.

The Vue3 Tabs

Data is provided in javascript array, in external file. This way, we can programatically passing data, between component. By using basic types means, an easy way without black magic.

let tabsArray = [
  { name: 'home', title: 'Home', color: 'bg-blue-500',
    text: `Lorem ipsum dolor sit amet,
      consectetur adipiscing elit.
      Quisque in faucibus magna.` },
  { name: 'team', title: 'Team', color: 'bg-teal-500',
    text: `Nulla luctus nisl in venenatis vestibulum.
      Nam consectetur blandit consectetur.` },
  { name: 'news', title: 'News', color: 'bg-red-500',
    text: `Phasellus vel tempus mauris,
      quis mattis leo.
      Mauris quis velit enim.` },
  { name: 'about', title: 'About', color: 'bg-orange-500',
    text: `Interdum et malesuada fames ac ante
      ipsum primis in faucibus.
      Nulla vulputate tortor turpis,
      at eleifend eros bibendum quis.` }
];

export { tabsArray };

This new component might not be the best practice, but the new component works well. This should be enough for me.


Application: The Vue3 Tabs

Before we discuss about component, consider refresh our memory, to get the big picture onhow this application works.

Source Examples

You can obtain source examples here:

Preview

You can test in your favorite browser:

Vue App: Component Preview: Enhanced: Mobile

Components

Step by step component is named as below:

  1. MainMockup: Basic (limited) interactivity.
  2. MainSimple: Fully working simple tabs.
  3. MainEnhanced: Fully working enhanced tabs.

You are going to see how elegant vue3 composition, in solving issue, compared with vue2 counterpart. The provide and inject mechanism is already happened in vue2, and it is getting better in vue3.

Directory Structure

Consider to get organized.

I have refactor each component into

  1. Main,
  2. Tabs,
  3. TabHeader,
  4. TabContent
$ tree src
src
├── App.vue
├── assets
│   └── css
│       ├── background-colors.css
│       ├── border-radius.css
│       ├── enhanced-layout.css
│       └── simple-layout.css
├── components
│   ├── enhanced
│   │   ├── MainEnhanced.vue
│   │   ├── TabContentEnhanced.vue
│   │   ├── TabHeaderEnhanced.vue
│   │   └── TabsEnhanced.vue
│   ├── mockup
│   │   ├── MainMockup.vue
│   │   ├── TabContentMockup.vue
│   │   ├── TabHeaderMockup.vue
│   │   └── TabsMockup.vue
│   ├── simple
│   │   ├── MainSimple.vue
│   │   ├── TabContentSimple.vue
│   │   ├── TabHeaderSimple.vue
│   │   └── TabsSimple.vue
│   └── TitleHeading.vue
├── main.js
├── router
│   └── index.js
└── tabsArray.js

7 directories, 21 files

Vue Tabs App: App Tree Structure

We do not really need Main*.vue, and we can instead use Tabs*.vue directly. My intention is explaining an alternative implementation, without using this.$children. So we can compare with original code.

main.js

The main.js is exactly the same as previous vue3 router example.

import { createApp, reactive } from 'vue'
import App from './App.vue'
import router from './router'

const Title = reactive({
  computed: {
    pageTitle: function() {
      return this.$route.meta.title;
    }
  },
  created () {
    document.title = this.$route.meta.title;
  },
  watch: {
    $route(to) {
      document.title = to.meta.title;
    },
  }
})

createApp(App)
  .use(router)
  .mixin(Title)
  .mount('#app')

App.vue

We should gather all these three components above in src/App.vue.

<template>
  <div id="app">
    <section class="link-nav">
      <router-link to="simple-mockup">Simple Mockup</router-link>
      <router-link to="simple-tabs"  >Simple Tabs</router-link>
      <router-link to="enhanced-tabs">Enhanced Tabs</router-link>
    </section>

    <TitleHeading/>

    <router-view></router-view>
  </div>
</template>

<script>
import TitleHeading from './components/TitleHeading.vue'

export default {
  name: 'App',
  components: {
    TitleHeading
  }
}
</script>

<style>
body { font-family: Arial, Helvetica, sans-serif; }
#app { margin: 1rem; }
section.link-nav { margin-bottom: 1rem; }
a { padding-right: 1rem; color: #00796b; }
</style>

Router: index.js

And the index is also similar.

import { createWebHistory, createRouter } from "vue-router";
import MainMockup from   '@/components/mockup/MainMockup'
import MainSimple from   '@/components/simple/MainSimple'
import MainEnhanced from '@/components/enhanced/MainEnhanced'

const routes = [
  {
    path: '/',
    name: 'default',
    component: MainEnhanced,
    meta: { title: 'Default Page' }
  },
  {
    path: '/simple-mockup',
    name: 'MainMockup',
    component: MainMockup,
    meta: { title: 'Simple Tabs - Mockup' }
  },
  {
    path: '/simple-tabs',
    name: 'MainSimple',
    component: MainSimple,
    meta: { title: 'Simple Tabs - Component' }
  },
  {
    path: '/enhanced-tabs',
    name: 'MainEnhanced',
    component: MainEnhanced,
    meta: { title: 'Enhanced Tabs - Component' }
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

Heading Component

The TitleHeading.vue component is, exactly the same as previous router example.

<template>
  <section>
    <h1>{{ $route.meta.title }}</h1>
  </section>
</template>

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

I think we are done with the application setup. We are ready for the element explanation.


Component: MainMockup

Main Component: Complete

Without using mixin, we can have the main component as below:

<template>
  <tabs />
</template>

<script>
import { provide } from 'vue'
import { tabsArray } from '@/tabsArray.js'
import tabs from '@/components/mockup/TabsMockup.vue'

export default {
  name: 'MainMockup',
  components: { tabs },
  setup() {
    provide('tabsArray', tabsArray)
  }
}
</script>

Main Component: Passing Data

The template is simply without data:

<template>
  <tabs />
</template>

The data passed using provide mechanism as below:

  setup() {
    provide('tabsArray', tabsArray)
  }

Since the data is reusable between three components, It is better to put the source in external file.

import { tabsArray } from '@/tabsArray.js'

Of course you can embed the data inside the component instead.

Tabs Component: The Container

This component do all the horse works.

<template>
  …
</template>

<script>
import { inject } from 'vue'
import tabheader  from '@/components/mockup/TabHeaderMockup.vue'
import tabcontent from '@/components/mockup/TabContentMockup.vue'

export default {
  name: 'TabsMockup',
  components: { tabheader, tabcontent },
  setup() {
    const tabs = inject('tabsArray')
    return { tabs }
  }
}
</script>

<style scoped>
  @import '../../assets/css/simple-layout.css';
</style>

Tabs Component: Getting Data from The Parent

We can utilize the provided data in child component.

  setup() {
    const tabs = inject('tabsArray')
    return { tabs }
  }

You can spot the difference between examples. In contrast with the previous vue2 tabs mechanism, as below:

  data() {
    return {
      tabs: this.$children
    }
  }

Tabs Component: The Template

We have two separated v-for running in two different components. Feel free to bind each props in element for each iteration.

  • tabheader,

  • tabcontent.

<template>
  <main class="tabs">
    <div class="tab-headers">
      <tabheader
        v-for="tab in tabs" v-bind:key="tab.name"
        :title="tab.title" :color="tab.color"
        :tabname="tab.name"
      />
    </div>

    <div class="tab-spacer"></div>

    <div class="tab-contents">
      <tabcontent
        v-for="tab in tabs" v-bind:key="tab.name"
        :text="tab.text" :title="tab.title" :color="tab.color"
        :tabname="tab.name"
      />
    </div>
  </main>
</template>

I utilize :tabname="tab.name" instead of :name="tab.name", so this identifier does not mistakenly collide, with this.name (component name).

Tab Header Component

We do not have any javascript interactivity yet. This is just contain basic props definition.

<template>
  <div class="bg-gray-700">{{ title }}</div>
</template>

<script>
export default {
  name: 'TabHeaderMockup',
  props: {
    tabname: { type: String, required: true },
    title:   { type: String, required: true },
    color:   { type: String, required: true }
  }
}
</script>

You can turn the tab into colourful tab header, by using v-bind as below:

<template>
  <div v-bind:class="color">{{ title }}</div>
</template>

Tab Content Component

For this mockup, we only show team content.

<template>
  <div v-bind:class="color"
       v-show="this.tabname == 'team'">
    <h3>{{ title }}</h3>
    <p>{{ text }}</p>
  </div>
</template>

<script>
export default {
  name: 'TabContentMockup',
  props: {
    tabname:  { type: String, required: true },
    title:    { type: String, required: true },
    text:     { type: String, required: true },
    color:    { type: String, required: true }
  }
}
</script>

Preview

You can enjoy testing in your favorite browser:

Vue App: Component Preview: Mockup: Tablet


Component: MainSimple

Main Component

Very similar with previous mockup, we only change the component name.

import tabs from '@/components/simple/TabsSimple.vue'

Tabs Component: The Template

Nothing is changed. It is exactly the same with previous mockup template.

This component also use the same stylesheet.

Tabs Component: The Script

Manage selected tab.

Since we want to manage selected tab, for both header and content, we need to provide the variable, for both child components. We also need this variable to be reactive too, based on click event.

import { inject } from 'vue'
import { provide, ref } from 'vue'
import tabheader  from '@/components/simple/TabHeaderSimple.vue'
import tabcontent from '@/components/simple/TabContentSimple.vue'

export default {
  name: 'TabsSimple',
  components: { tabheader, tabcontent },
  setup() {
    const tabs = inject('tabsArray')

    const tabSelected = ref('team')
    provide('tabSelected', tabSelected)

    return { tabs }
  }
}

You can spot the improvement in above script.

Tab Header Component

We setup the javascript interactivity for this tab header component.

<template>
  <div 
    v-on:click="selectTabByName(this.tabname)"
    v-bind:class="[activeClass(), colorClass()]"
   >{{ title }}</div>
</template>

<script>
import { inject } from 'vue'

export default {
  name: 'TabHeaderSimple',
  props: {
    tabname: { type: String, required: true },
    title:   { type: String, required: true },
    color:   { type: String, required: true }
  },
  setup() {
    const tabSelected = inject('tabSelected')
    return { tabSelected }
  },
  mounted() {
    this.selectTabByName(this.tabSelected);
  },
  methods: {
    selectTabByName(tabName) {
      this.tabSelected = tabName
    },
    activeClass : function () {
      return this.tabname == this.tabSelected ? 'active' : '';
    },
    colorClass  : function () {
      return this.tabname == this.tabSelected ?
               this.color : 'bg-gray-700';
    }
  }
}
</script>

Tab Header Component: Selecting Tab

How does it works?

It is similar wih previous vue2 example, but this time using tabSelected with provide/inject mechanism. The tabname has been bound using v-for.

    v-on:click="selectTabByName(this.tabname)"

The methods is just oneliner.

  setup() {
    const tabSelected = inject('tabSelected')
    return { tabSelected }
  },
  methods: {
    selectTabByName(tabName) {
      this.tabSelected = tabName
    }
  }

With this script we also initialize the default tab using mounted.

  mounted() {
    this.selectTabByName(this.tabSelected);
  },

Tab Header Component: Binding Color

We are no longer rely on tab parameter anymore, as we did with vue2 examples.

v-bind:class="[activeClass(tab), colorClass(tab)]"

But instead it is simpler.

<template>
  <div 
    v-on:click="selectTabByName(this.tabname)"
    v-bind:class="[activeClass(), colorClass()]"
   >{{ title }}</div>
</template>

This can be happened, because we already have tabname bound to each tab.

  methods: {
    activeClass : function () {
      return this.tabname == this.tabSelected ? 'active' : '';
    },
    colorClass  : function () {
      return this.tabname == this.tabSelected ?
               this.color : 'bg-gray-700';
    }
  }

Tab Content Component

We also have a few improvement here:

<template>
  <div v-bind:class="this.color" 
       v-show="this.tabname == this.tabSelected">
    <h3>{{ title }}</h3>
    <p>{{ text }}</p>
  </div>
</template>

<script>
import { inject } from 'vue'

export default {
  name: 'TabContentMockup',
  props: {
    tabname:  { type: String, required: true },
    title:    { type: String, required: true },
    text:     { type: String, required: true },
    color:    { type: String, required: true }
  },
  setup() {
    const tabSelected = inject('tabSelected')   
    return { tabSelected }
  }
}
</script>

We can choose which tab shown by already bound tabname.

  <div v-bind:class="this.color" 
       v-show="this.tabname == this.tabSelected">
    …
  </div>

The tabSelected is also using provide/inject mechanism.

  setup() {
    const tabSelected = inject('tabSelected')
    return { tabSelected }
  }

Preview

Again, you can enjoy testing in your favorite browser:

Vue App: Component Preview: Simple: Tablet


Component: MainEnhanced

This is our fully functional tabs component.

Main Component

Very similar with previous mockup, we only change the component name.

import tabs from '@/components/enhanced/TabsEnhanced.vue'

Tabs Component: The Template

We have different class name using -enh, to avoid stylesheet collision.

<template>
  <main class="tabs-enh">
    <div class="tab-enh-headers">
      <tabheader
        v-for="tab in tabs" v-bind:key="tab.name"
        :title="tab.title" :color="tab.color"
        :tabname="tab.name"
      />
    </div>

    <div class="tab-enh-spacer"></div>

    <div class="tab-enh-contents">
      <tabcontent
        v-for="tab in tabs" v-bind:key="tab.name"
        :text="tab.text" :title="tab.title" :color="tab.color"
        :tabname="tab.name"
      />
    </div>
  </main>
</template>

The rest is the same. No difference.

Tabs Component: The Stylesheet

This component use different stylesheet.

  @import '../../assets/css/enhanced-layout.css';
  @import '../../assets/css/background-colors.css';
  @import '../../assets/css/border-radius.css';

Tabs Component: The Script

Manage selected tab.

Since we want to also manage hovered tab, we also need to provide the tabHovered variable. We also need this variable to be reactive too, based on mouse event.

import { inject } from 'vue'
import { provide, ref } from 'vue'
import tabheader  from '@/components/enhanced/TabHeaderEnhanced.vue'
import tabcontent from '@/components/enhanced/TabContentEnhanced.vue'

export default {
  name: 'TabsEnhanced',
  components: { tabheader, tabcontent },
  setup() {
    const tabs = inject('tabsArray')

    const tabSelected = ref('team')
    const tabHovered  = ref('')
    provide('tabSelected', tabSelected)
    provide('tabHovered',  tabHovered)

    return { tabs }
  }
}

You can spot both tabSelected and tabHovered in above script. Both contain the name of the tab as string, instead of just boolean value.

There is no need to return value, for provide. But we need to return value, for inject in both child component.

Tab Header Component

We setup more javascript interactivity for this tab header component.

<template>
  <div 
    v-on:click="selectTabByName(this.tabname)"
    v-on:mouseenter="this.tabHovered = this.tabname"
    v-on:mouseleave="this.tabHovered = ''"
    v-bind:class="[activeClass(), colorClass()]">
    <div
      v-bind:class="{ 'bg-white' : this.tabname == this.tabSelected }"
     >{{ title }}</div>
  </div>
</template>

<script>
import { inject } from 'vue'

export default {
  name: 'TabHeaderSimple',
  props: {
    tabname: { type: String, required: true },
    title:   { type: String, required: true },
    color:   { type: String, required: true }
  },
  setup() {
    const tabSelected = inject('tabSelected')
    const tabHovered  = inject('tabHovered')
    return { tabSelected, tabHovered }
  },
  mounted() {
    this.selectTabByName(this.tabSelected);
  },
  methods: {
    selectTabByName(tabName) {
      this.tabSelected = tabName
    },
    activeClass : function () {
      return this.tabname == this.tabSelected ? 'active' : '';
    },
    colorClass  : function () {
      return this.tabname == this.tabSelected ?
               this.color : 'bg-gray-700';
    }
  }
}
</script>

Tab Header Component: Template

We have more <div>:

<template>
  <div …>
    <div
      v-bind:class="{ 'bg-white' : this.tabname == this.tabSelected }"
     >{{ title }}</div>
  </div>
</template>

Tab Header Component: Synching Hover

How does it works?

It is similar wih previous vue2 example, but this time using tabHovered with provide/inject mechanism. The tabname has been bound using v-for.

    v-on:mouseenter="this.tabHovered = this.tabname"
    v-on:mouseleave="this.tabHovered = ''"

And in script we also initialize the default tab using mounted.

  setup() {
    const tabSelected = inject('tabSelected')
    const tabHovered  = inject('tabHovered')
    return { tabSelected, tabHovered }
  }

Tab Content Component

We also have a few improvement here:

<template>
  <div v-bind:class="this.color" 
       v-show="this.tabname == this.tabSelected">
    <div class="tab-enh-content"
         v-bind:class="{ 'is-hovered' : this.tabHovered == this.tabname}">
      <h3>{{ title }}</h3>
      <p>{{ text }}</p>
    </div>
  </div>
</template>

<script>
import { inject } from 'vue'

export default {
  name: 'TabContentMockup',
  props: {
    tabname:  { type: String, required: true },
    title:    { type: String, required: true },
    text:     { type: String, required: true },
    color:    { type: String, required: true }
  },
  setup() {
    const tabSelected = inject('tabSelected')
    const tabHovered  = inject('tabHovered')
    return { tabSelected, tabHovered }
  }
}
</script>

Tab content Component: Template

We have more <div>:

<template>
  <div …>
    <div class="tab-enh-content" …>
       …
    </div>
  </div>
</template>

Tab Content Component: Synching Hover

How does it works?

We can alter the color of the hovered tab header using bound tabname.

    <div class="tab-enh-content"
         v-bind:class="{ 'is-hovered' : this.tabHovered == this.tabname}">
      <h3>{{ title }}</h3>
      <p>{{ text }}</p>
    </div>

The tabHovered is also using provide/inject mechanism.

  setup() {
    const tabSelected = inject('tabSelected')
    const tabHovered  = inject('tabHovered')
    return { tabSelected, tabHovered }
  }

Preview

At last, our fully functional, tabs component in browser.

Vue App: Component Preview: Enhanced: Tablet

I must admit that my vue knowledge is still limited. I’m open to other possibity, and also other issue comes with this component pattern.

I still have so much to learn.


What’s Next?

Later.