Creating custom components with Vuetify - Inheriting props/events/slots in Composition API
Onur Elibol
Posted on November 14, 2020
Hi everyone!
Lately I have been working on customising Vuetify components to have default component look/feel and I wanted to share some best practices that I have learned. In this article, I will try to show you how to inherit/forward props, events and slots from the parent component with minimal effort. I took Vuetify as an example here, but the practices could be applied on any other UI framework that offers props/events/slots. Hope it will be useful for someone at some point.
Why
Basically the main reason is that you sometimes have a use-case to cover, and you need to create a re-usable/customisable component based on a Vuetify (or any other UI framework) component. Well here comes the why; when you create a custom component, you don't want to lose what the UI framework you use offers by default.
I am going to define 2 different use-cases here to base my examples on.
First use-case is to be able to use Vuetify components with pre-defined props, for example
v-text-field
by default outlined, with a standard color etc. But you still want to be able overrideoutlined
orcolor
property outside even if they are defined by default.Second use-case is building a custom component based on Vuetify components. Let's say you are going to build a component that is a combination of
v-text-field
andv-card
. But you still want to be able to use default props/events/slots ofv-text-field
on your custom component without defining it all manually.
With the next version of Vuetify (v3.0 Titan), this cases will be covered easily actually. Here is an example how it is going to look like:
Vuetify 3 updates
But as long as we don't have any access to these stuff yet, we are still stuck with the current approach. So here comes how we can do it with minimal effort.
How
Now when you are using Vuetify, all the components have some sort of pre-defined props/events/slots. To cover both use-cases above, most important thing is inheriting these props/events/slots from the parent component. So how do we do that?
Lets start with creating our custom text field with pre-defined props:
// CustomTextField.vue
<template>
<v-text-field
:value="value"
outlined
color="primary"
@input="v => $emit('input', v)" />
</template>
Great, now we can use it like:
// Parent.vue
<custom-text-field v-model="value" />
Note: v-model
is sugar syntax for :value="value" @input="v => $emit('input', v)"
We have created our CustomTextField and it is by default outlined and has primary color. Now what about if we want to use flat
or dense
or any other prop that v-text-field
has oncustom-text-field
? Or what if we need to override outlined
and color
property at some point, how are we going to do that? Basically we can't, as outlined
and color
is defined statically here and nothing is changing them.
So currently adding these props to our custom component as below would NOT work (for now):
<custom-text-field v-model="value" dense :outlined="false" />
This because of we are not inheriting any props that were sent from Parent.
Inheriting Props
To inherit props from parent, we can use a small trick that would help us. In Vue, each parent component sends the attributes that are added itself. To access those we can simply use $attrs
in our template to bind everything that were sent from parent like this:
// CustomTextField.vue
<template>
<v-text-field
v-bind="$attrs"
:value="value"
outlined
color="primary"
@input="v => $emit('input', v)" />
</template>
<script>
export default {
inheritAttrs: false
}
</script>
And voila! Now our <v-text-field>
inside CustomTextField component inherits all attributes that were added to <custom-text-field>
. So we can now easily use every prop that v-text-field
provides on <custom-text-field>
and override anything pre-defined props inside like this:
// Parent.vue
<custom-text-field v-model="value" dense :outlined="false" />
IMPORTANT
The way $attrs
behave differs between Vue 2.x and 3.x! Some differences are:
- In Vue 2.x
$attrs
does not includestyles
andclasses
that were sent from parent - In Vue 3.x
$attrs
includesstyles
andclasses
that were sent from parent. Also$listeners
are now included inside$attrs
which I will talk about later
For more information check details at Vue 3 docs.
Inheriting Events
Alright, we are now inheriting props from parent so we can use our custom text field as we are using v-text-field
with props. So what about events? How can we forward all the events that are happening on <v-text-field>
to <custom-text-field>
?
The solution is simple here as well:
// CustomTextField.vue
<template>
<v-text-field
v-bind="$attrs"
:value="value"
outlined
color="primary"
v-on="$listeners"
@input="v => $emit('input', v)" />
</template>
<script>
export default {
inheritAttrs: false
}
</script>
We just bind $listeners
with v-on
and thats it! So now we can easily add any event that <v-text-field>
provides to <custom-text-field>
like this:
// Parent.vue
<custom-text-field
v-model="value"
dense
:outlined="false"
@blur="onFocus"
@keypress="onKeypress" />
IMPORTANT
$listeners is removed in Vue 3.x and is included inside $attrs. So if you are using Vue 3.x, binding the component with $attrs
will be enough to bind $listeners
, like here:
// bind props, attrs, class, style in Vue 3.x
<v-text-field v-bind="$attrs" />
For more information check details at Vue 3 docs.
Inheriting Slots
Slots are a little bit different than props or events. There is for sure different ways to do this, but here is what I am doing to forward all the slots that were sent from parent to child.
I start with picking all the slot names that were sent from the parent inside a computed:
// CustomTextField.vue
export default {
setup(props, ctx) {
const parentSlots = computed(() => Object.keys(ctx.slots))
return { parentSlots }
}
}
Then inside the <template>
part I am looping through the slots to declare all the slots dynamically like this:
// CustomTextField.vue
// Vue 2.x way, binding $listeners with v-on not needed in Vue 3.x
<template>
<v-text-field
v-bind="$attrs"
:value="value"
outlined
color="primary"
v-on="$listeners"
@input="v => $emit('input', v)"
>
<!-- Dynamically inherit slots from parent -->
<template v-for="slot in parentSlots" #[slot]>
<slot :name="slot" />
</template>
</v-text-field>
</template>
<script>
export default {
setup(props, ctx) {
const parentSlots = computed(() => Object.keys(ctx.slots))
return { parentSlots }
}
}
</script>
Note that # is shorthand for v-slot
. Here we could also use:
<template v-for="slot in parentSlots" #[slot]="props">
<slot :name="slot" :props="props" />
</template>
to forward slot props as well. But the v-text-field
component then does not render the slots that has not any props. I suppose this is a bug in Vuetify. Issue here
Great! So now we are even forwarding v-text-field slots from parent to child which means we can use slots of <v-text-field>
like this:
// Parent.vue
<custom-text-field
v-model="value"
dense
:outlined="false"
@blur="onFocus"
@keypress="onKeypress"
>
<template #label>Custom Label</template>
<template #message>Custom Message</template>
</custom-text-field>
BONUS: Custom usage of props/events/slots
We are now done with inheritance. But what if you need to use some of your $attrs
on another element? For example inside your custom component, you have <v-text-field>
and <v-card>
and you want share color
property in both. At this point there are different ways to go. But as long as I like to keep things organised, I use computed to organise/control it from one point.
Example:
// CustomTextField.vue
// Vue 2.x way, binding $listeners with v-on not needed in Vue 3.x
<template>
<div>
<v-text-field
v-bind="[textFieldDefaults, $attrs]"
:value="value"
v-on="$listeners"
@input="v => $emit('input', v)"
>
<template v-for="slot in parentSlots" #[slot]>
<slot :name="slot" />
</template>
</v-text-field>
<v-card v-bind="cardDefaults">
<v-card-text>I am a card</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
setup(props, ctx) {
const parentSlots = computed(() => Object.keys(ctx.slots))
const textFieldDefaults = computed(() => ({
outlined: true,
dense: true,
color: 'primary'
}))
const cardDefaults = computed(() => ({
color: ctx.attrs.color || 'primary'
}))
return { parentSlots, textFieldDefaults, cardDefaults }
}
}
</script>
So what is happening here? We have created 2 computed variables, one for v-text-field
defaults and one for v-card
.
- In
textFieldDefaults
we define our default text field props and then binding it withv-bind=[textFieldDefaults, $attrs]
. If any of default values were sent from top, values inside$attrs
overrides our default props. - In
cardDefaults
we only takecolor
property. If you wish, you could easily add any other prop or listener that were sent from the parent here. - Both
textFieldDefaults
andcardDefaults
must be declared as computed, to be able make them reactive and listen to the changes that are happening in parent.
Conclusion
To sum up, Vue offers many different options for us to achieve what we need to do. It is very easy to create custom components that are based on any UI framework without losing what the framework already offers us. There could be for sure some edge cases, but I think with the approach I tried to explain above, you can solve most of them. Hopefully, this post helped you to understand the concept!
Thank you for reading!
Posted on November 14, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.