vuejs / vue

This is the repo for Vue 2. For Vue 3, go to https://github.com/vuejs/core
http://v2.vuejs.org
MIT License
207.59k stars 33.66k forks source link

Suggestion: Plugin directive for slot #2507

Closed paulpflug closed 8 years ago

paulpflug commented 8 years ago

Hi, Fairly often (1, 2, 3) when building reusable components I use the same pattern of nested components. The pattern consists of a parent component using the slot-directive and a child component which must be nested to work correctly. The child component also has a slot-directive to contain the custom content.

The aim of the pattern is to have a defined parent-child behavior with the support for custom content. (Would be useful for all sorts of popular ui-components, like wizard, accordion, etc.)

Example from vue-position-after-transform:

<vc-pat> //parent component
  <vc-patc> //child component
    Text 1  //custom content
  </vc-patc>
  <vc-patc> // parent:child - 1:n
    Text 2
  </vc-patc>
</vc-pat>

There are several problems with this approach in vue. Most important, components build with this pattern can't be nested and data passing to the child is verbose (using $children in the parent and filtering only the "real" child components).

example from vue-position-after-transform:

for child in @$children
  # reason why I can't nest the components, 
  # $children will contain ALL somewhere nested patc-components
  # not only the direct children
  if child.isPatc 
    process(child)

I was thinking about it for a while and I found a good solution: An additional directive 'plugin' on the slot component with the following features:

The last point would allow different children with different behavior on the same parent, for example when building an accordion, there could be a child component which collapses when another one is opened and one which remains opened.

In the template of the parent component slot with plugin would be used and in the child slot without plugin

The downside: I looked into the slot component and found no easy way of achieving this.

What do you think?

prog-rajkamal commented 8 years ago

I also got a feature request about slot api and plan to raise an issue tonight. that might be helpful as it deals with slots accepting components.

fullfs commented 8 years ago

@paulpflug @prog-rajkamal Hello! I think you guys have got a point. I also have a feeling slot api deserves some updates to be more useful. My opinion is slot's content and its holder lack some sort of communication methods. In that scope @paulpflug 's solution looks like a way. But as I know, @yyx990803 already has some certain plans on the case. I can say it by looking at #1726 I think it's a good chance to discuss it a little

yyx990803 commented 8 years ago

The description is a bit hard to follow, can you provide an example of using your proposed API?

paulpflug commented 8 years ago

Pseudocode collapsible implementation:

//parent:
<template lang="jade">
ul
  slot(plugin="isCollapsible" @notify="processClick")
</template>
<script lang="coffee">
module.exports =
  methods:
    processClick: (child) ->
      @$dispatch "close",child
</script>

//child
<template lang="jade">
li
  a(@click="onClick")
    slot(name="header")
  .collapsible-body
    slot No content
</template>
<script lang="coffee">
module.exports =
  data: ->
    isCollapsible: true
  methods:
    onClick: ->
      @$broadcast "notify",@
  events:
    close: (child) -> 
       if child != @ 
         #do the closing work
</script>

//usage
<template lang="jade">
collapsible
  collapsible-entry
    p(slot="header") header
    p content
  collapsible-entry
    p(slot="header") header 2
    p content 2
</template>
<script lang="coffee">
module.exports =
  components:
    "collapsible": #parent
    "collapsible-entry": #child
</script>

in this example the main difference is, that @notify="processClick" on slot should be working, that said it is probably not the best example. The idea is, to limit the <slot> directive with plugin attribute to only accept components and behave a bit like <component> directive instead.

I will try to implement that, but will probably take a while

paulpflug commented 8 years ago

Let me put it another way: a plugin directive which is a mixture of the slot and component directives. I think it would make sense to put this behavior behind an attribute at slot but in principle it could also be an own directive. The main difference to slot is, it takes only vue-components as children and v-on/v-bind works, and the difference to component is, it can handle custom content and takes values from the global scope:

//usage
<template lang="jade">
collapsible
  collapsible-entry
    p(slot="header") header
    p {{content}}
  collapsible-entry
    p(slot="header") header 2
    p {{content2}}
</template>
<script lang="coffee">
module.exports =
  data: ->
    content: "text1"
    content: "text2"
  components:
    "collapsible": #parent
    "collapsible-entry": #child
</script>
fullfs commented 8 years ago

@yyx990803 Let me put it as I understand it in few words: It is a suggestion about a way of communication between component and its slot's data that doesn't belong to it (compiled inside parent's scope). It is a way to pass data between this dimensions. Some kind of reverse props and events handling

luxlogica commented 8 years ago

As a new Vue.js user, I came across this limitation straight away, when trying to build 'nested' components. Concrete example: an accordion element.

An accordion element will usually be made up of a 'heading' and a 'content' area. Clicking the heading will toggle the display of the content. This should be something that should be fun and easy to program with vue.

The heading will usually be simple, often containing just text and possibly an icon to indicate visually whether the accordion is opened or closed. The content, however, can contain large sections of text with sub-headings, images, and other block-level elements. In plain HTML, an accordion might be structured simply as this:

INTENDED RESULT: this is what we'd like our HTML to look like in the end.

<div class="accordion">
    <div class="heading">
        <i class="fa fa-chevron-right"></i> My Accordion
    </div>
    <div class="content">
        Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    </div>
</div>

Some javascript is usually then added to make the heading work as a toggle, showing and hiding the content area.

Now, what we'd like to be able to do with vue, is to define accordions in our HTML like this:

INTENDED USAGE: this is how we'd like to code our accordion in our HTML markup.

<accordion>
    <heading icon="chevron-right">My Accordion</heading>
    <content open>
        Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    </content>
</accordion>

Defining the 'heading' and 'content' elements as vue components on their own is quite straight-forward - we could do it like this, which already implements a FontAwesome icon in the heading, and keeps track of whether the content is open/visible or not:

Vue.component('heading', {
    template: "<div class='heading'><i v-if='icon' class='fa fa-{{ icon}}'></i><slot></slot></div>",
    props: ['icon']
});

Vue.component('content',{
    template: "<div v-show='open' class='content'><slot></slot></div>",
    props: { open: {type: boolean, default: false }}
});

But, how do we combine these 2 into an 'accordion' component? As both the 'heading' and the 'content' components are particular to the context of the 'accordion', we should define them inside the 'accordion' component itself. But then, what should we use as a template for the accordion?

Vue.component('accordion', {
    components: {
        heading: {/* heading template + props goes here */},
        content: {/* content template + props goes here */}
    },
    template: "<div class='accordion'><slot></slot></accordion>"
});

The problem with this, is that the content of <slot> is not parsed by Vue - and therefore, the declared components that may be in it don't get used. Vue will simply 'plonk' the content of the HTML tag into the DOM, without looking at it. So, using the markup code in 'intended usage' above, with the component defined like this, we will get...:

<div class="accordion">
    <heading icon="chevron-right">My Accordion</heading>
    <content open>
        Lorem ipsum dolor sit amet, consectetuer aqipiscing elit.
    </content>
</div>

We just end up with some unrecognisable pseudo-HTML elements on the page... As you can imagine, that won't trigger any functionality we might have defined for the sub-elements, either. And there are other problems, too: if the HTML has elements inside the <accordion> apart from a <heading> and <content>, or has them in a different order, they would just be plonked 'as they are' in the original markup - which could cause us all kinds of layout problems.

A workaround for that, is to try and use 'named slots' in my accordion, as suggested in the vue documentation, like this:

Vue.component('accordion', {
    /* component definition of heading and content go here */
    /* ... */

    template: "<div class='accordion'><heading><slot name='heading'></slot></heading>" +
                    "<content><slot name='content'></slot></content></div>"
});

This means, that we would no longer be able to use <heading> and <content> elements directly in our HTML, but rather use 'normal' elements, and place them into the <accordion> via 'slot' attributes, like this:

<accordion icon="chevron-right" open>
    <div slot="heading">My Accordion</div>
    <div slot="content">
        Lorem ipsum dolor sit amet, consectetuer aqipiscing elit.
    </div>
</accordion>

Note that this is a far departure from the intended usage above. Also, as we're no longer using a 'heading' or 'content' element directly, we'd have to move their props to the parent accordion element... This is not the syntax we wanted, is less modular, and it will also produce HTML that is overly nested and convoluted:

<div class="accordion">
    <div class="heading">
        <i class="fa fa-chevron-right"><i> <div slot="heading">My Accordion</div>
    </div>
   <div class="content" open>
        <div slot="content">
            Lorem ipsum dolor sit amet, consectetuer aqipiscing elit.
        </div>
    </div>
</div>

In the end, it would be simpler to not use sub-components at all, and define the whole thing as a single 'accordion' component, with a single template. Of course, this means that vue is actually promoting embedded solutions, rather than modular ones: the 'heading' element, for instance, could be useful also in several other contexts - i.e., modals, alert boxes, view panels, etc.

Lastly, the problem gets compounded even more, if you think that on any given page, I will hardly ever be using a single accordion by itself: I will often have several at once on a list. And in some instances, I might want to coordinate their 'open' status, so that only 1 accordion can be open at any given time.

<accordion-group>
    <accordion>...</accordion>
    <accordion open>...</accordion>
    <accordion>...</accordion>
    <accordion>...</accordion>
</accordion-group>

Vue already has a great event-management system, that allows us to bubble up/down and propagate messages, as well as prop-binding, which would allow us to easily communicate changes between parent/children/grandchildren. But as pointed out by @paulpflug, handling situations such as this where <slot> and sub-components are involved together, is either convoluted or impossible, as shown here - specially for a newbie like me.

As it currently stands, it seems to me it is not possible to use vue components - and slots - to enable users to use the code as it appears in intended usage above, to produce the HTML in intended result.

It also seems to me, that as vue is unable to analyse and parse <slot> content, the usefulness of sub-elements is limited to use-cases where either there is only 1 sub-element present in the parent, or where sub-element's contents can be passed entirely by props. If I understand correctly, the proposed solutions so far fall broadly into 2 categories:

1) fully parse and analyse the content of <slot> elements, looking for any possible embedded components, and implementing their logic along the way

2) define a new type of <slot>, which would mark the existence of a sub-element in the innerHTML, and would look for it - i.e., the template for our accordion above, using such a slot, could look something like this:

<template id="accordion">
    <div class="accordion">
        <slot component="heading"></slot>
        <slot component="content"></slot>
    </div>
</template>

This would search the <slot> of the <accordion> element, and if it finds a <heading> element, it would insert it in the position of <slot component="heading">. It would do the same with the <content> component. We could also have named and default slots, along with the 'component' slots, like this:

GREAT SOLUTION that is elegant and simple enough for even newbies to understand:

<template id="accordion">
    <div class="accordion">
        <slot component="heading"></slot>
        <slot component="content"><slot></slot></slot>
        <slot name="footer"></slot>
    </div>
</template>

In this case, the 'heading' and 'content' components, if found, would be placed in their 'component' slots. In the remainder content, an element with an attribute of slot='footer' would be placed in the named slot. Any remaining elements would be placed in the default slot. Note, that the default slot would only be placed if a 'content' component was also present. This would indeed give us the "intended result", from the "intended usage" code, as well as making it incredibly easier to nest modular components ad infinitum!

This is my "newbie" overview. I hope I'm not misquoting or misunderstanding anyone, and hopefully some might find this 'plain English' explanation useful.

And last of all: please, keep up the good work on Vue. It's awesome.

prog-rajkamal commented 8 years ago

@luxlogica

The problem with this, is that the content of is not parsed by Vue - and therefore, the declared components that may be in it don't get used

actually Vue is acting as intended. the problem is you are attaching heading and content components to accordion but you are using them in main scope instead of accordion's scope.

js fiddle for demo.

https://jsfiddle.net/3kLjbyge/2/

luxlogica commented 8 years ago

@prog-rajkamal thank you for the heads-up! However, that is still not an ideal solution, because <slot> doesn't discriminate or filter what's being put into it. In our case, we wanted to make sure that the only thing that gets put into the 'heading' slot is a 'heading' component, and a 'content' into the 'content' slot. This, for instance, should not be accepted:

<accordion>
    <p slot="heading">My Accordion</p>
    <aside slot="content">Lorem ipsum dolor sit amet, adipiscing elit.</aside>
</accordion>

...but using simple named slots, it will. Sometimes, this behaviour - accepting anything - is functionality that is needed, indeed. But usually, when composing complex, nested components, we want the opposite: to be able to be very specific about what type/tag of sub-component goes into what slot. This is why in such use-cases it would be better to be able to refer to component by type/tag - not name.

And if we are already identifying the sub-component by its tag, then there is no need for the 'slot' attribute in the markup - making for cleaner, more succinct coding!

prog-rajkamal commented 8 years ago

If you want to restrict acceptable slot to a single component, you might as well define slot as

    <heading> 
           <slot name="heading"> 
           </slot>
    </heading>

The above code, of course, does not allow you to set attributes to <heading> but effectively does what you require.

Right now, I can not think of any other way to do it,. but once programmatic slot API lands in Vue, we would be able to access slots and throw errors/warnings for incorrect usage.

I would like to see your suggestion implemented. But as a plugin, not in Vue core

luxlogica commented 8 years ago

Thank you for your suggestion. If you have a look at the code I proposed above in my original comment, that was one of the solutions I came up with - the one that reduces modularity, and produces the convoluted HTML in the end.

IMHO this kind of functionality is essential for easily composing nested views of even basic complexity - as we have just shown - so it should definitely be part of the core. A detailed programmatic API would be extremely useful for advanced programmers wanting to push the boundaries of the library, but the functionality described above is basic, and achieving it should be easy, even for newcomers.

prog-rajkamal commented 8 years ago

the reason why i don't want such functionality in core is because it is not general enough.

Practically, what we actually want is to validate the html fragment passed to slots. your suggestion however considers only one specific case i.e. the passed html fragment must have root node of a specific type.

What if a user wants to allow 2 or 3 types of tags? or disallow a certain set of tags?I am sure there are other validation rules possible on slots too. surely those cases should be considered as well, if we are talking about vue's core

luxlogica commented 8 years ago

Ah, I see your point. I was indeed thinking of only a much narrower use-case. If we want to cover all these use-cases, we could perhaps implement a 'selector' directive, that takes a CSS selector, like this:

SELECTOR SLOT

<!-- picking all elements with a certain tag name for inclusion in the slot -->
<slot selector="nav"></slot>

<!-- picking all elements from a range of tag names -->
<slot selector="section, div, p"></slot>

<!-- normal CSS selectors could be used -->
<slot selector="#heading strong"></slot>
<slot selector="input.large.text, input.large.number"></slot>

<!-- use selectors like ':first-of-type' or ':last-of-type' to select a single element  -->
<slot selector="li:first-of-type"></slot>

<!-- ':not()' selector could be used to exclude elements -->
<slot selector=":not(div)"></slot>

But however the filtering of the content is done, the key here is to then analyse the content after it's been placed in the slot, looking for possible components that need to be resolved in the context of the parent. I suspect that this is where the issue may be, as it may be computationally very expensive to do. Hence, the suggestion of a separate 'component' slot type: a slot with this directive already tells Vue that we're looking for an element that is a component, and should be analysed.

COMPONENT SLOT

<!-- insert all components found with tag 'my-component' -->
<slot component="my-component"></slot>

<!-- insert the first component found with tag 'my-component' -->
<slot component="my-component:first-of-type"></slot>

<!-- insert all components with any of the named tags -->
<slot component="my-comp1, my-comp2"></slot>

So while 'selector' slots would be simply 'plonked' in their slot location - like named slots do now - 'component' slots would actually be inserted and then analysed and run in the context of their parent. This would allow Vue to know where computation is needed, rather than having to analyse everything.

Now, in order to cover every possible use-case, we just need to establish the 'order of operation' for the slots:

ORDER OF SLOT OPERATIONS

  1. resolve component slots, and remove them from the passed content
  2. resolve selector slots, and remove them from the remaining content
  3. place any left-over content in the default slot

This would enable essentially a very wide range of selecting and filtering of slots, using a simple, declarative syntax, which would cover the vast majority of use cases - and without the need for a newbie to learn an API! ;-)

prog-rajkamal commented 8 years ago

@luxlogica I think we have hijacked this issue. It would be better if you were to create a separate issue for this feature.

paulpflug commented 8 years ago

I think @luxlogica's proposal for component slot is very close to my plugin thing. But for reusable components the selector pattern - take this and that from the children - is bad, because it will be within the component, probably hidden from the user.

So I would prefer to stick with the name parameter:

<slot component="my-component"></slot>
// vs.
<slot plugin name="my-component"></slot>"

// usage
<parent-component>
  <my-component/>
</parent-component>
// vs.
<parent-component>
  <my-component slot="my-component"/>
</parent-component>

This is more verbosity, but in this case that is worth, as it must be assumed, that the user doesn't know a thing about parent-component.

On the other hand selectors provide some functionality, but honestly I haven't encountered a case where name and a clever nesting of components didn't solve the problem..

luxlogica commented 8 years ago

@paulpflug My idea is for the end-user to have exactly the usage you proposed. I'm in total agreement with you: we should enable the developer to author complex, nested elements, but allowing for the simplest possible syntax for the end-user. Like you, my proposal aims to allow the end-user to do:

<!-- target usage in HTML -->
<parent-component>
    <child-component</child-component>
</parent-component>

<!-- we do not want to have to do this -->
<parent-component>
    <child-component slot="child"></child-component>
</parent-component>

However, as @prog-rajkamal pointed out - quite rightly - there are a lot of use-cases that go beyond this example, and which we hadn't thought of initially. The idea to use CSS selectors came up as a solution that can cover a wider range of use-cases, and is probably more future-proof.

Where our suggestions differ:

  1. I humbly suggest we use the name "component slots" rather than "plugin slots" - it seems more semantically correct: we are not inserting 'plugins' (which can be something else in the Vue world), we are inserting previously defined 'components'.
  2. the use of CSS selectors for component slots, rather than merely the component names, and
  3. the creation of selector slots, which would basically substitute the current 'named' slots, and would also use CSS selectors (making slot='name' unnecessary).

The end-user will not need to know how that component was implemented - which type slots were used, where, and how. All the end-user needs to know is how to compose their element in their HTML code, using the custom tags we created with our components - exactly like in your example.

Using my accordion example from above, this is what the usage and implementation would be like:

<!-- usage in HTML markup -->
<accordion>
    <heading>My Accordion</heading>
    <content>Lorem ipsum dolor sit amet!</content>
</accordion>

<!-- template implementation of child components -->
<template id="heading">
   <div class="heading">
        <slot></slot>
    </div>
</template>

<template id="content">
    <div class="content">
        <slot></slot>
    </div>
</template>

<!-- template implementation of parent component -->
<template id="accordion">
    <div class="accordion">
        <slot component="heading"></slot>
        <slot component="content"></slot>
    </div>
</template>

This would make it easier for both developer, and end-user.