Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suggestion: v-on on slots #4781

Closed
BartCorremans opened this issue Jan 24, 2017 · 5 comments

Comments

@BartCorremans
Copy link

commented Jan 24, 2017

This ties in to question/suggestion #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>
...
  1. 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>
...
  1. The proposed solution in #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.

@yyx990803

This comment has been minimized.

Copy link
Member

commented Jan 24, 2017

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.

@yyx990803 yyx990803 closed this Jan 24, 2017

@BartCorremans

This comment has been minimized.

Copy link
Author

commented Jan 24, 2017

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.

@eljefedelrodeodeljefe

This comment has been minimized.

Copy link

commented Jun 1, 2017

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.

@jcupps

This comment has been minimized.

Copy link

commented Dec 31, 2018

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 <select> element. Doing it the standard "Vue way", its use would look something like:

<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-select> component, it may require that only <my-option> components be passed to the default slot, just as a native <select> element can only have <option> elements as children.

@drmzio

This comment has been minimized.

Copy link

commented Jun 12, 2019

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
5 participants
You can’t perform that action at this time.