Vue 3 Composition API - 'child' data in a tabs-like component

Viewed 1291

I have a TabGroup/TabItem component for both Vue 3 and Vue 2, but it's written in the Options API.

I'm creating a new carousel component, which shares a lot of the features, but I'm trying to write it in the Composition API as that's how we now write everything in our projects.

I am essentialy trying to access data on the child components, but I don't see an obvious way of doing it. I want to be able to determine which of the children is active within the group component, so it can manage things like keyboard navigation and so on.

  • I could use Vuex, but this is a generic component that will be used more than one place. I could give each some kind of ID to differentiate them in the store, but this component is also eventually going into a library, which won't have access to the Vuex instance of whatever project is using it.
  • Events don't seem like they'll work, because of the way the components are set up - the wrapper doesn't know what the children are, and they're just populated into the default slot. It seems like events will fire where the carousel is used, not inside the group.
  • I can't use refs for the same reason, they will be available where the carousel is created, not in the carousel-group. Plus I don't want anyone to have to add a ref to every panel just to use the component.
  • I can't provide the panels as an array to carousel-group, because every panel can contain any content, and I want that to be as easy as possible to set up.

For some background, the basic setup is this:

<carousel-group>
    <carousel-panel>
        Panel content
    </carousel-panel>
</carousel-group>

As part of the setup process I can access the children, similar to what I would do in the Options API, and get a list of the child panels.

panels.value = slots.default().filter(child => child.type.name === 'carousel-panel');

Each carousel-panel has instance data such as active, which is what I want to access. But getting the panels via the default slot doesn't come with any of those properties.

Any ideas would be great, as I'm a bit stumped at the moment. I don't really want to have to do this using the Options API.

1 Answers

What you're trying to do is called "compound components". You need to use provide/inject (equivalent of React Context in Vue) and let carousel-group know that a carousel-panel has been put inside.

I do this with registerChild in a table component. I took this from my repo at https://github.com/sethidden/vue-compound-components/tree/master/example-table

<!-- MyTable.vue -->
<script setup>
import {ref, provide, defineProps } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true,
  }
})

const registerChild = (child) => registeredChildren.value.push(child);
const unregisterChild = (child) => {
  registeredChildren.value = 
    registeredChildren.value.filter(
      registeredChild => registeredChild !== child
    );
};
  
const registeredChildren = ref([]);

provide('TheParent', { registerChild, unregisterChild })
  
</script>
<template>
columns: {{ registeredChildren.map(x=>x.itemPropertyName) }}
<table>
  <thead>
    <tr>
      <th v-for="column in registeredChildren" :key="column">
        <component :is="column.content" ref="column"/>
        {{ column.sortable ? 'sortable' : ''}}
      </th>
    </tr>
  </thead>
  <tbody>
    <tr v-for="item, i in items" :key="i">
      <td v-for="column, j in registeredChildren" :key="j">
        {{ item[column.itemPropertyName] }}
      </td>
    </tr>
  </tbody>
</table>

<!-- we want the mounted() lifecycle to fire on the components that are in the slot, but we dont want to show them -->
<div v-show="false">
  <slot/>
</div>
</template>

<!-- MyTableColumn.vue -->
<script setup>
import {onMounted,onBeforeUnmount,inject, defineProps, h, render, useContext} from 'vue'

const props = defineProps({
  itemPropertyName: {
    type: String,
    required: true,
  },
  sortable: {
    type: Boolean,
    default: false
  }
})
const { registerChild, unregisterChild } = inject('TheParent');
const ctx = useContext();

const childInfo = {
  itemPropertyName: props.itemPropertyName,
  sortable: props.sortable,

  // since ctx.slots.default is a render fn,
  // we pass slot content from here to the parent component
  // then render it in the parent using <component :is=""/>
  content: ctx.slots.default
}

registerChild(childInfo);
onBeforeUnmount(() => {
  unregisterChild(childInfo);
})

</script>
<template>
<div>
  <slot/>
</div>
</template>

Obviously this is a table component, but you get the idea - you can track which panels are inside a carousel-group by registering them on mount, using some property from the parent thanks to inject()

Another thing is that you want to manage which carousel-panel is active. Which panel is active would obviously be kept in carousel-group, but you also need to toggle visibility of panels using v-for="panes in registeredChildren" v-if="activePane === pane.id", but that can be achieved by something like:

<carousel-group>
  <carousel-pane id="1"/>
  <carousel-pane id="2"/>
  <carousel-pane id="3"/>
</carousel-group>

Then have the carousel-group's internal data property start with the first pane from registeredChildren then after 5s change to second and so on.

https://github.com/3nuc/vue-compound-components/blob/master/example-dropdown/Example.vue

Related