Vue: Suggestion: v-on on slots

Created on 24 Jan 2017  ·  5Comments  ·  Source: vuejs/vue

This ties in to question/suggestion https://github.com/vuejs/vue/issues/4332 which was closed, but I have a common scenario where this would be very useful, and the proposed solution in that suggestion would be difficult to apply here.

Suggestion

A limitation I've ran into when authoring reusable components is that you can't add event handlers to a <slot>.

For example: I'm making a logout button I intend to reuse throughout multiple apps. I want to allow apps to override the actual button element, without having to worry about handling the click event to call the logout() method. This is a slightly contrived example but it illustrates my point.

What I would like to be able to do is the following:

logout-button.vue

<template>
    <div class="logout-button">
        <slot @click="logout()">
            <button type="button" class="btn btn-primary">Log out</button>
        </slot>
    </div>
</template>

<script>
    export default {
        methods: {
            logout() {
                // ...
            }
        }
    };
</script>

The @click handler on the slot would then be applied to the components within the slot (here just the button).

(ideally I'd also like to be able to use a <slot> as a component's root as long as it never ends up containing more than one element, but that's off topic here, and I'm not sure if that would ever be possible)

A parent component could then override the button like this:

parent-component.vue

...
<logout-button>
    <my-button>Sign out</my-button>
</logout-button>
...

And everything would still work. Clicking the <my-button> would trigger the @click handler.

Current solutions

To my knowledge, there are currently three ways to implement similar behaviour:

1) Add the @click directive to a wrapper element. This seems like an obvious choice here because we already need the wrapper for our component to work, but when you have multiple named slots, this clutters the DOM with useless elements.

logout-button.vue

<template>
    <div class="logout-button" @click="logout()">
        <slot>
            <button type="button" class="btn btn-primary">Log out</button>
        </slot>
    </div>
</template>

parent-component.vue

...
<logout-button>
    <my-button>Sign out</my-button>
</logout-button>
...

2) Use a scoped slot to pass the logout() method. This feels like a misuse of scoped slots and also tightly couples parent-component to the implementation of logout-button.

logout-button.vue

<template>
    <div class="logout-button">
        <slot :logout="logout">
            <button type="button" class="btn btn-primary" @click="logout()">Log out</button>
        </slot>
    </div>
</template>

parent-component.vue

...
<logout-button>
    <template scope="{ logout }">
        <my-button @click="logout()">Sign out</my-button>
    </template>
</logout-button>
...

3) The proposed solution in https://github.com/vuejs/vue/issues/4332 could work, but only if the reusable <my-button> I'm using to override logout-button's default slot were wrapped in another Vue component that knows which event to emit, again tightly coupling them and adding more complexity when I just want to use <my-button>.

It would look a little like this:

logout-button.vue

<template>
    <div class="logout-button">
        <slot>
            <button type="button" class="btn btn-primary" @click="logout()">Log out</button> <!-- alternative: @click="$emit('logout')" -->
        </slot>
    </div>
</template>

<script>
    export default {
        methods: {
            logout() {
                // ...
            }
        },
        created() {
            // catch the logout event emitted by component we insert in slot in parent template
            this.$on('logout', this.logout);
        }
    };
</script>

parent-component.vue

...
<logout-button>
    <my-logout-button></my-logout-button>
</logout-button>
...

my-logout-button.vue

<template>
    <my-button @click="$parent.$emit('logout')">Sign out</my-button>
</template>

Conclusion

I'm partial to solution 1 here, and perhaps 3 for more complex scenarios, but I feel like it would be even cleaner using my suggested syntax for the reasons stated above.

What do you think? Note that I'm fairly new to Vue, so if I've overlooked anything, I apologize.

Most helpful comment

As explained in #4332, it doesn't make sense to add listeners on <slot> because <slot> doesn't always render only a single element. This is why a wrapper element is required.

I'd also take a step back and say slot is simply not the proper mechanism for what you want to do.

When the actual visual content is expected to be provided by the parent component, the only thing your <logout-button> component does, in fact, is providing the implementation of the logout method. In this case scoped slots actually looks like the most plausible solution. However, if all it does is providing a JavaScript method, why should it be a component in the first place? It can simply be a JavaScript module that exports the logout method. Instead of importing a component and try to compose it in the template using esoteric techniques, you simply import the logout method and use it:

<button @click="logout"></button>
import { logout } from './auth-service'

export default {
  methods: {
    logout
  }
}

If your point is that you want to encapsulate some common markup/styling in <logout-button>, then I'd suggest allowing the customizations via props instead of slots.

All 5 comments

As explained in #4332, it doesn't make sense to add listeners on <slot> because <slot> doesn't always render only a single element. This is why a wrapper element is required.

I'd also take a step back and say slot is simply not the proper mechanism for what you want to do.

When the actual visual content is expected to be provided by the parent component, the only thing your <logout-button> component does, in fact, is providing the implementation of the logout method. In this case scoped slots actually looks like the most plausible solution. However, if all it does is providing a JavaScript method, why should it be a component in the first place? It can simply be a JavaScript module that exports the logout method. Instead of importing a component and try to compose it in the template using esoteric techniques, you simply import the logout method and use it:

<button @click="logout"></button>
import { logout } from './auth-service'

export default {
  methods: {
    logout
  }
}

If your point is that you want to encapsulate some common markup/styling in <logout-button>, then I'd suggest allowing the customizations via props instead of slots.

Thanks for the insights.

The example was perhaps a bit simple and you're right that just importing the method would be better in that case.

The idea behind the reusable components I'm creating is that they both provide functionality (e.g. the logout button, or something more complex like a navigation control that's automatically populated) as well as a default look and feel using our style guide. Apps can then implement these components without being involved with their implementation, but still override the styling not just through classes but also through custom elements.

In these more complex scenarios, scoped slots might indeed be the way to go.

I stumbled upon this and would follow @yyx990803 on that. The behaviour right now, however not documented well, is good enough. Maybe a PR for docs would be suited.

I agree with the OP's suggestion. The currently best recommended approach, using scoped slots, is verbose and hampers component reuse.

Say I'm authoring a component that mimics the native \

<my-select
 v-model="model"
 :options="options"
></my-select>

If I want to make it easy to add a custom class to the option elements, the recommended approach is something like:

<my-select
 v-model="model"
 :options="options"
 :optionClass="custom-option"
></my-select>

This seems like an anti-pattern to me, since we're using the custom "optionClass" rather than the native "class" HTML attribute. (Of course this doesn't just apply to class but any HTML attribute or component prop, which would otherwise have to be passed through via custom proxy props e.g. "optionClass", "optionId", "optionStyle", "optionTabIndex", etc.) A better approach would be to expose a slot for the options, allowing the parent to compose the structure like this:

<my-select v-model="model">
  <my-option
    v-for="option in options"
    class="custom-option"
  ></my-option>
</my-select>

I think this looks a lot better and is more similar to idiomatic HTML. Now, however, we run into a problem when we need to handle the click event for a specific option. My understanding is that the recommended best approach is to use scoped slots to pass a callback like this:

<my-select v-model="model" slot-scope="{ handleOptionClick }">
  <my-option
    v-for="option in options"
    class="custom-option"
    @click="handleOptionClick"
  ></my-option>
</my-select>

(I realize slot-scope would have to be used on an intermediate <template> in this case, but pseudo-code was used for simplicity.)

This works, but now imagine there are event handlers for focus, blur, and 5 different keyboard events. Not only does it become very verbose and cluttered, but it makes it much more difficult and unwieldy to reuse the component in multiple places. Furthermore as the OP mentioned it increases coupling; if, for example, one of the callbacks is renamed, every consumer would have to be updated.

Additionally, besides event handlers, I would also make a case for binding attributes. For example, I may require each option to have a certain id so it can be referenced with an ARIA attribute (this would pose a problem for the API, however, since using v-bind on a slot is currently the mechanism used for scoped slots).

it doesn't make sense to add listeners on <slot> because <slot> doesn't always render only a single element

Solution: attach listeners to each inserted element, or allow validation of slot content. I actually think validation would be a great addition; in the case of the example \

My Solution

Just create an event listener component (e.g. "EventListener") and all it does is render the default slot like so:

_EventListener.vue_

export default {
    name: 'EventListener'
    render() {
        return this.$slots.default;
    }
}

Now use this <event-listener> component and wrap it on your <slot>. Child components inside the slot should emit events to the parent like so: this.$parent.$emit('myevent').

Attach your custom events to the <event-listener @myevent="handleEvent"> component.

Was this page helpful?
0 / 5 - 0 ratings