vue-loader icon indicating copy to clipboard operation
vue-loader copied to clipboard

Allow incremental scoped styling for child components

Open loilo opened this issue 7 years ago • 17 comments

What problem does this feature solve?

Disclaimer: I could not create this issue via the Vue.js issue helper and therefore unfortunately could not auto-attach the feature request label. Got a 414 error from the GitHub servers, apparently this proposal is just too long to fit into a URL. 🙃

For Clarification: Whenever this proposal mentions parent/child components, it's not about nesting (as in vm.$parent / vm.$children) but about the relation between two components where one extends the other.

Status Quo

As described in this forum thread, there currently seems to be a lack of an easy way to incrementally add scoped styles to .vue child components. This makes creating reusable components harder than it needs to be.

Regarding styles, using extends currently is an all-or-nothing approach:

  • When no <style> block is present on a child component, it gets the same data-v-* attribute as its parent and thus inherits all of its styling.

    By example:

    Parent Component Source

    <template>
      <button class="button">Click me!</button>
    </template>
    
    <style scoped>
    .button {
      border: 1px solid red;
    }
    </style>
    

    Child Component Source

    <script>
    import Parent from 'parent'
    
    export default {
      extends: Parent
    }
    </script>
    

    Pseudo-Compiled Output

    <!-- Rendered Parent Component -->
    <!-- has a red border -->
    <button class="button" data-v-parent>Click me!</button>
    
    <!-- Rendered Child Component -->
    <!-- has a red border -->
    <button class="button" data-v-parent>Click me!</button>
    
    <style>
    .button[data-v-parent] {
      border: 1px solid red;
    }
    </style>
    
  • When a scoped <style> block is added to the child, it gets its own data-v-* attribute and parent styles are no longer applied to it at all.

    By example:

    Parent Component Source

    The same as before.

    <template>
      <button class="button">Click me!</button>
    </template>
    
    <style scoped>
    .button {
      border: 1px solid red;
    }
    </style>
    

    Child Component Source

    <script>
    import Parent from 'parent'
    
    export default {
      extends: Parent
    }
    </script>
    
    <style scoped>
    .button {
      color: blue;
    }
    </style>
    

    Pseudo-Compiled Output

    <!-- Rendered Parent Component -->
    <!-- has a red border -->
    <button class="button" data-v-parent>Click me!</button>
    
    <!-- Rendered Child Component -->
    <!-- has blue text but no red border -->
    <button class="button" data-v-child>Click me!</button>
    
    <style>
    .button[data-v-parent] {
      border: 1px solid red;
    }
    </style>
    
    <style>
    .button[data-v-child] {
      color: blue;
    }
    </style>
    

What does the proposed API look like?

I'd like to propose a new option for Vue component definitions. The name is debateable, but for now let's agree on calling it extendsScopedStyle.

The option

  • is a boolean flag, defaulting to false
  • is only available in Single File Components
  • does only apply if the component extends another Single File Component

Setting extendsScopedStyle to true on a child component definition will cause its relevant DOM nodes to inherit the parent's data-v-* attribute as well as receiving its own custom data-v-* attribute. This will apply both, parent and child styles, to the child component.

This also is best explained by example. The following code reflects the proposed behaviour with a custom <style> on the child component. The child does receive the parent's styling as well as its own styling.

Parent Component Source

Still the same as before.

<template>
  <button class="button">Click me!</button>
</template>

<style scoped>
.button {
  border: 1px solid red;
}
</style>

Child Component Source

<script>
import Parent from 'parent'

export default {
  extends: Parent,
  extendsScopedStyle: true
}
</script>

<style scoped>
.button {
  color: blue;
}
</style>

Pseudo-Compiled Output

<!-- Rendered Parent Component -->
<!-- has a red border -->
<button class="button" data-v-parent>Click me!</button>

<!-- Rendered Child Component -->
<!-- has blue text AND a red border -->
<button class="button" data-v-parent data-v-child>Click me!</button>

<style>
.button[data-v-parent] {
  border: 1px solid red;
}
</style>

<style>
.button[data-v-child] {
  color: blue;
}
</style>

Gotchas / Caveats

Specificity

To make sure parent styles are actually overridden by child styles, the order of <style> blocks in the resulting bundle matters. The child component styles would have to be inserted after the parent styles.

As far as I can tell there shouldn't be any conflicts in determining that order since there shouldn't be any kinds of cyclic dependencies between components. (Am I right here?)

Dependency Chains

While some component A extends component B, component B may extend component C and thus also be a child component itself.

Therefore, the data-v-* attribute of C needs to fall through to B as well as to A.

Location of the Flag

To be honest, having a new property on the Vue component object for an approach tied so tightly to Single File Components does not feel great.

However I was encouraged by the fact that

  • it'd not be the first option to not be globally applicable (speaking of name being respected everywhere but in Single File Components)

  • the alternative ways to signal the described behaviour seem terrible to me:

    Regarding the feature's scope, an extends-scoped-style attribute on the <template> block would probably be the best fitting option, since the template is what's actually affected. However, this is not possible because child components may not even have a <template> block.

    An extends-scoped-style attribute on a <style> block (which was my first approach for this proposal) does not feel very clean either because the described behaviour actually has nothing to do with a specific <style> block.

    Also it's valid to have multiple <style scoped> blocks in the same component — some may have that marker attribute, others may not. This would be a weird inconsistency because the feature can only be an on/off thing for the component as a whole.

loilo avatar Oct 12 '17 22:10 loilo

I just encountered this same problem when I was trying to figure out how to extend a base component, using the same template and methods on the parent, but overriding some of the styles. I definitely don't want to start playing with css imports. The proposed solution sounds good to me.

tlaak avatar Apr 12 '18 11:04 tlaak

I encountered this problem too. Will be great if the proposed solution accepted.

meikidd avatar Jun 20 '18 10:06 meikidd

same stuff for me. We have shared components, that we use in other projects. And at some point we need to change styles a little bit in some particular project for some particular shared component. With this extends problem we can't provide appropriate solution for this(

TatsuUkraine avatar Aug 15 '18 07:08 TatsuUkraine

So was this feature ever implemented???

Nanobrainiac avatar Nov 04 '18 02:11 Nanobrainiac

If it was, this issue would have been closed. 😉

loilo avatar Nov 04 '18 06:11 loilo

That's too bad...

Nanobrainiac avatar Nov 04 '18 06:11 Nanobrainiac

As almost always with Vue these days, there is a workaround with a slight amount of overhead, but the upside of being really expressive about what you are doing:

<template functional>
    <extended-component v-bind="props" v-on="listeners" class="extended-component" />
</template>
<script>
import ExtendableComponent from "@/extendable-component"; // Just an example

// Here we extend the component's internal behavior (methods, computed, injections, etc..)
// In other words: Here goes all the stuff messing with the instance of the extendable component:
const ExtendedComponent = ExtendableComponent.extend({ /* Here be dragons */});

// Here (and in the template) we extend the component's external behavior (props, events, etc...):
export default {
    functional: true, // We don't need an instance if we only want to statelessly wrap the component
    components: {
        ExtendedComponent
    },
    // Here be more dragons, but only stateless ones
}
</script>
<style scoped>
/* And here we extend the component's visual styles via deep selector ">>>" */
.extended-component >>> .class-within-ext-comp {

}
</style>

You can do any of these three extension steps independently and know exactly what kind of behavior each one is affecting. Also: If the extendable component supports them, overriding via css modules is a nice alternative too.

lorenzo-w avatar Nov 06 '18 09:11 lorenzo-w

I'm using something like the above now. However, it exposes the really tricky/annoying problem of CSS specificity. To use the piercing >>> selector (or /deep/ in Sass), a parent selector is needed, which increases specificity for each level of extension.

This gets messy very fast, and god forbid you'd have to override some component styles from outside.

Thanks for sharing anyway, maybe someone can make use of that.

loilo avatar Nov 06 '18 10:11 loilo

I like the idea of creating 'skins'. But my co-worker does not not. This is the idea:

<skin-button-danger> <button>Press me</button> </skin-button-danger>

...and in the skin-button-danger component you would use the piercing >>> selector to style the button. I like this method because it's very clear what you're doing. Any thoughts?

Nanobrainiac avatar Nov 07 '18 23:11 Nanobrainiac

See my comment above yours. 🙂

loilo avatar Nov 08 '18 06:11 loilo

I saw. I dont see how theyre so tricky. The idea with skins is that the base component never changes. Only the skins. And since the base component never changes, then after creating one skin, it can be easily duplicated, modified and applied as well.

And since your skin component is named, i personally think it makes your code more readable.

Nanobrainiac avatar Nov 08 '18 23:11 Nanobrainiac

I agree with the readability, I think this is the best currently existing solution as well.

The specificity however becomes a problem when you're not going to just skin finished components, but for example compose components. This will end up in multiple nesting levels and therefore add layers and layers of specificity.

loilo avatar Nov 08 '18 23:11 loilo

Yes i agree with the problem of multiple nested components.

Nanobrainiac avatar Nov 08 '18 23:11 Nanobrainiac

I think the workaround involving the Shadow-Piercing descendant combinator, /deep/ (aka >>>) for dynamic profile (in stylesheets) is problematic because

  1. it requires adding deep selector expressions for some (if not for all) styles of the subclassed component
  2. also >>> and /deep/ have been removed from the spec (see E) and are semantically invalid
  3. and I'm not sure if sass does even support it, still (can't find it in the docs)

Another workaround could be to use sass and explicitly import the stylesheets of the parent. Bit this would

  1. require to move the stylesheets to an extra file and corrupt the single file component methodology
  2. create duplicate css for every subclassed component (parent styles are duplicated every time)

I think the proposed solution is well-conceived. It would be awesome to see this implemented.

MartinMa avatar Apr 02 '19 09:04 MartinMa

Hope to see some progression on this issue. It's really annoying not being able to extend simply a component to make variations of it 😉

For the moment, I make an external stylesheet and import it in all children components with <style src="path/to/base-button.css">

ByScripts avatar Jun 07 '19 09:06 ByScripts

@yyx990803 Any chance of this being added before or after the release of vue 3.0?

UltraCakeBakery avatar Nov 26 '19 14:11 UltraCakeBakery

That's how the hack works

The idea is to use the _scopeId of the parent component

const component = { extends: Plan } Object.defineProperty(component, '_scopeId', { get: () => Plan._scopeId, set: () => {} })

export default component

@loilo @lorenzo-w @Nanobrainiac