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

Non-shadow dom API #42

Closed
LarsDenBakker opened this issue May 6, 2018 · 21 comments
Closed

Non-shadow dom API #42

LarsDenBakker opened this issue May 6, 2018 · 21 comments
Assignees

Comments

@LarsDenBakker
Copy link
Contributor

LarsDenBakker commented May 6, 2018

As a long-time user of Polymer, one of my biggest issues with Polymer.Element is it's hard buy-in with shadow dom. There is a method that you can overwrite to avoid creating a shadow root, but you really have to know what you're doing and then there are still issues around styling. Especially on browsers that need shady dom / css.

Shadow dom is a powerful tool and makes a lot of sense for building re-usable UI components. When building large complex applications, it is useful to split code up into separate components But in our experience, using shadow dom for all these private components has a lot of downsides. Closely associated components are unnecessarily isolated making tests more complicated, shared styles are duplicated many times over and especially on browser that do not have native shadow dom performance is negatively impacted. I think in the applications we build, over half our components do not need to use shadow dom.

The current support for elements without shadow dom is to overwrite _createRoot and return 'this'. I'm worried that this is not a very intuitive API and already requires some in-depth knowledge of how this works internally. I'm hoping that there can be more official support for creating elements without shadow dom and that it's use cases are covered by documentation, demos and tests. I'd be happy to contribute this where possible.

@moebiusmania
Copy link

moebiusmania commented May 8, 2018

@LarsDenBakker this is a real good point, and for my experience a shared one. BTW YouTube itself doesn't use full Shadow DOM for the same reasons.

The _createRoot => this trick may be intuitive or not, but at least as opposite of Polymer it let you decide to use Shadow DOM or not individually, the main downside is that you lose support for the <slot> element.

It would be nice to have a sort of bultin slot polyfill activated when you opt to not use Shadow DOM.

@sorvell sorvell self-assigned this Jun 8, 2018
@sorvell
Copy link
Member

sorvell commented Jun 8, 2018

We'd like to encourage a default of using Shadow DOM because it's the right option when making a re-usable element since these need encapsulated dom and css.

You have a point that when making an application, it is sometimes reasonable not to use Shadow DOM, and this is why we provide the _createRoot override point.

We're open to making this API simple and would welcome a proposal / PR.

@LarsDenBakker
Copy link
Contributor Author

LarsDenBakker commented Jun 9, 2018

@sorvell Thanks for your response. I'd be happy to make a PR, but we'd need to iron out some architecture first.

A pattern I'd like to explore is the following:

<app-element-a>
  <style> element-d { border: 1px solid black; } </style>

  <app-element-b>
    <element-d>
      #shadowroot
    </element-d>
  </app-element-b>

  <app-element-c>
    <element-d>
      #shadowroot
    </element-d>
  </app-element-c>

</app-element-a>

In this example (pseudocode) element a, b and c don't use shadow dom, but render their template to the light dom. Element d is a reusable components and uses shadow dom.

Element a renders styles which applies to all instances of element d in the dom of both children.

This works fine on native shadow dom, the styles will automatically apply from element a down to the first shadow root it reaches. But shady dom will scope the styles so that element a cannot select elements into element b. We can't turn off scoping, as it will make the styles apply everywhere, but perhaps there is some solution to this? An alternative could be to define scopes manually and pass them down as properties to sub components.

In terms of API, what I think would make it cleaner, and give more opportunities for optimization, is if we separate out the common functionality from LitElement into a LitElementBase class and then provide a LitElementLight class which renders to light dom. This will make it easier to add other features that make sense for rendering to light dom, and also reduce the required knowledge of internals for developers.

@Westbrook
Copy link
Contributor

@LarsDenBakker could you clarify this paragraph a little further?

This works fine on native shadow dom, but shady dom will hoist the styles to the head and scope the styles so that element a cannot select elements into element b. We can't turn off scoping, as it will make the styles apply everywhere, but perhaps there is some solution to this? An alternative could be to define scopes manually and pass them down as properties to sub components.

When you say "We can't turn off scoping, as it will make the styles apply everywhere" I can't understand how that's different from element-a having shadow dom so it's styles don't leak, but element-b and element-c using light dom so the styles from element-a apply to them.

It would seem that this approach would be available at current without any API changes, and wanted to make sure I understood you correctly. I've been doing something similar by extending the _createRoot technique as follows when not wanting to use scoped styles:

  /**
   * Set root to `this` so that styles are not contained.
   *
   * @return {object}
   */
  _createRoot() {
    return this;
  }
  /**
   * Prevent the hoisting and scoping of styles.
   */
  _applyRender(result, node) {
    render(result, node);
  }

My use case at current is to apply style to a page (when not building my app from a single shared parent node, and seems like it would do the work you're looking for if applied to element-b and element-c in your example.

Thanks in advance for sharing your thoughts, this is an important sticking point to work though in advance of bringing users in from other style application techniques!

@LarsDenBakker
Copy link
Contributor Author

@Westbrook

Given:

<app-element-a>
  <style> element-d { border: 1px solid black; } </style>

  <app-element-b>
    <element-d>
      #shadowroot
    </element-d>
  </app-element-b>

  <app-element-c>
    <element-d>
      #shadowroot
    </element-d>
  </app-element-c>

</app-element-a>

Compiles to shady dom like so (pseudocode):

<head>
  <style scope="app-element-a">
    element-d.app-element-a {
      border: 1px solid black;
    }
  </style>
</head>

<app-element-a>
  <style> element-d { border: 1px solid black; } </style>

  <app-element-b class="style-scope app-element-a">
    <element-d class="style-scope app-element-b">
      #shadowroot
    </element-d>
  </app-element-b>

  <app-element-c class="style-scope app-element-a">
    <element-d class="style-scope app-element-b">
      #shadowroot
    </element-d>
  </app-element-c>

</app-element-a>

So where in native shadow dom, a selector like element-d would match the elements inside element-b and element-c, in shady dom the selectors are scoped to only the dom of element-a.

We could do what you're saying and use regular render instead of shady render, but it has two downsides. One is that it removes other parts from the shady dom polyfill such as css mixins and css variables. The other is that scoping is turned off. So instead of matching only the instances of element-d up until the first shadow root inside element-a, it will match all instances of element-d on the page.

I'm also interested in elements being able to style upwards like you're describing. We have a very large application with architecturally separated pages developed by different teams. I'd like for the active page to be able to theme the larger application. With native shadow dom it's possible.

@LarsDenBakker
Copy link
Contributor Author

LarsDenBakker commented Jun 12, 2018

After looking into this further, it seems I'm not entirely correct. I was running into the above situation because I had no top level element with a shadow root, but when there is it works more like expected.

Styles are correctly scoped from the top shadow root down to the bottom shadow root across components. However, styles within custom elements without shadow roots are not hoisted.

I made a jsbin to illustrate what I mean. View both on chrome.
This construction works in shadow dom:
http://jsbin.com/pufosopeki/1/edit?html,output
But not in shady dom (forced polyfill):
http://jsbin.com/mazimigara/1/edit?html,output

I don't understand enough about how shady dom/css works, but perhaps lit/lit#337 will make this work correctly?

@sorvell
Copy link
Member

sorvell commented Aug 27, 2018

Sorry for the delay on this. The new way to customize the rendering element is to implement createRenderRoot() (renamed from _createRoot).

We'd like to encourage use of Shadow DOM in LitElement and this is why it's the default. Since it's straightforward to customize this, we're inclined to leave it as is for now. It is completely reasonable to create a subclass that customizes this behavior as you see fit.

@sorvell sorvell closed this as completed Aug 27, 2018
@asbachb
Copy link

asbachb commented Aug 28, 2018

I wonder if there's a workaround for having slot functionality when disabling Shadow DOM?!

@atla5
Copy link

atla5 commented Oct 24, 2018

i was able to toggle between shadow and light DOM in LitElement by implementing createRenderRoot() as return this;, as per @sorvell's statement above and fitting the documentation in @thepassle's README (last bullet):

createRenderRoot(){ return this; }

as advertised, it did "break" the <slot> functionality. could someone explain why this is the case?

@Christian24
Copy link
Contributor

@atla5 <slot> is only available when using shadow dom by shadow dom specification.

@jolleekin
Copy link

@asbachb @atla5 Here's one workaround to the missing slot functionality.

@customElement('my-element' as any)
export class MyElement extends LitElement {
  /** The header content. Usually a [TemplateResult] but could be anything. */
  header?: any;

  /** The footer content. Usually a [TemplateResult] but could be anything. */
  footer?: any;

  /**
   * The body template function.
   *
   * The return value is usually a [TemplateResult] but could be anything.
   */
  bodyTemplate?: () => any;

  protected createRenderRoot() {
    return this;
  }

  protected render(): TemplateResult {
    return html`
      <style>
        /*
        Can't use :host here since we're rendering into the light DOM.

        NOTE:
        Duplicated styles will happen if there are more than one
        my-element in the same scope.
        */
        my-element {
          display: block;
        }
      </style>
      ${this.header}
      ${this.bodyTemplate ? this.bodyTemplate() : null}
      ${this.footer}
    `;
  }
}

Usage

<my-element
  .header=${html`<h2>Header</h2>`}
  .bodyTemplate=${() => html`<p>Body</p>`}
>
</my-element>

@Rybadour
Copy link

@jolleekin Does this mean there is no way have child element like my below code without shadow DOM?

<custom-list>
   <li>foo</li>
   ...
</custom-list>

@jsilvermist
Copy link

@Rybadour You would have to query the elements and manually append them at load time if I'm not mistaken.

@pgoforth
Copy link

pgoforth commented Nov 29, 2018

The issue is compounded by the fact that returning any TemplateResult in your render method will automatically remove the children that used to be in your component and replace them with what was rendered in the template. What we need is a way to easily re-add the children into the generated template in much the same way that React allows you to add children.

the only way I've been able to get around it is by doing something similar to the following:

const makeSlot = (name) => {
    const slot = document.createElement('slot');
    if (name) {
        slot.name = name;
    }
    return slot;
};

class CustomElement extends LitElement {
    constructor() {
        super();

        this.shadowRoot.appendChild(makeSlot('before'));
        this.shadowRoot.appendChild(makeSlot());
        this.shadowRoot.appendChild(makeSlot('after'));
    }

    connectedCallback() {
        super.connectedCallback();
        this.observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.removedNodes.forEach((node) => {
                    if (node.nodeType !== Node.COMMENT_NODE) {
                        this.appendChild(node);
                    }
                });
            });
        });

        this.observer.observe(this, {
            childList: true,
        });
    }

    firstUpdate() {
        this.observer.disconnect();
    }

/* ... */

    createRenderRoot() {
        this.attachShadow({mode: "open"});
        return this;
    }

    render() {
        return html`
            <div slot="before">Templated Content Before</div>
            <div>Main Content - will show up before children</div>
            <div slot="after">Templated Content After</div>
        `;
    }
}

customElements.define(`pseudo-shadow-custom`, CustomElement);
<pseudo-shadow-custom>
    <p>Markup Child</p>
<pseudo-shadow-custom>

Would render

<pseudo-shadow-custom>
    <div slot="before">Templated Content Before</div>
    <div>Main Content - will show up before children</div>
    <p>Markup Child</p>
    <div slot="after">Templated Content After</div>
<pseudo-shadow-custom>

@asbachb @LarsDenBakker @Rybadour
In this method, you are still kind-of using the shadow-dom, but all your elements are rendering in the context of the custom element...so your CSS will cascade, you can still make custom elements that use templates and leverage lit-element. Hopefully this solution can accommodate your needs. If this functionality works for everyone, it would be a great addition and I could make a PR for it.

@RameezAijaz
Copy link

If you just want to render all the children inside host's dom then using connectedCallback() lifecycle event even with empty body will make child component render inside the host without shadowDom

@customElement('test-element')
export class TestElement extends LitElement {

    connectedCallback(){
    }
    createRenderRoot() {
        return this;
    }

    render() {
        return html`
        `;
    }
}

Then following code

<test-element>
     <p>Hello World</p>
</test-element>

will render

<test-element>
     <p>Hello World</p>
</test-element>

@aaronanderson
Copy link

aaronanderson commented Feb 8, 2020

This worked for me:

@customElement('test-element')
export class TestElement extends LitElement {

  elementChildren: Array<Element> = [];

  createRenderRoot() {
    return this;
  }

  connectedCallback() {
    this.elementChildren = Array.from(this.children);
    super.connectedCallback();
  }

  render() {
    return html`<div>${this.elementChildren}</div>`;
  }  

}

@svdoever
Copy link

svdoever commented Jun 5, 2020

@aaronanderson seems to work for me as well!

I created a file LitElementLight.ts:

import { LitElement } from 'lit-element';

class LitElementLight extends LitElement {
    elementChildren: Array<ChildNode> = [];
    slotContents: any;

    connectedCallback() {
        this.elementChildren = Array.from(this.childNodes);
        super.connectedCallback();
    }

    get slotElements(): ChildNode[] {
        return this.elementChildren;
    }

    createRenderRoot() {
        return this;
    }
}
    
export {
    LitElementLight
};

and now can create "light" elements as follows:

test-light.ts:

import { html, customElement } from 'lit-element';
import { LitElementLight } from './LitElementLight';

@customElement('test-light')
class TestLight extends LitElementLight {
    render() {
        return html`
            <div>I'm styled by the light!</div>
            <div>${this.slotElements}</div> 
            <div>Styling is global</div> 
    `;
    }
}

declare global {
    interface HTMLElementTagNameMap {
        'test-light': TestLight;
    }
}

@fouad-j
Copy link

fouad-j commented Oct 29, 2020

Hello,

Thanks @svdoever and @aaronanderson for your proposal.

I improved the code to allow displaying Slot in different parts of custom element.

import {LitElement} from 'lit-element';

class LitElementLight extends LitElement {
    slotMap: object;

    connectedCallback() {
        this.slotMap = Array
            .from(this.renderRoot.querySelectorAll('[slot]'))
            .reduce((map, obj) => ({
                ...map,
                [obj.getAttribute('slot')]: obj
            }), {});

        super.connectedCallback();
    }

    protected getSlot(slotName: string): ChildNode {
        return this.slotMap && this.slotMap[slotName];
    }

    createRenderRoot() {
        return this;
    }
}

export {LitElementLight};
@customElement('test-light')
class TestLight extends LitElementLight {
    render() {
        return html`
            <div>LitElement content bla...</div>
            <div>${this.getSlot('actions')}</div>
            <div>Styling is global</div>
    `;
    }
}

declare global {
    interface HTMLElementTagNameMap {
        'test-light': TestLight;
    }
}
<test-light>
  <div slot="actions">
    <button type="button" class="btn btn-primary" (click)="toggleImage()">Angular Action</button>
    <button type="button" class="btn btn-secondary" v-on:click="counter += 1">VueJS Action</button>
  </div>
</test-light>

I created a public gist
Please let me know if you have any suggestion

@justinfagnani
Copy link
Member

@svdoever the problem with that approach is that it breaks composition. Since you record the light DOM children on at connected, you can get into a number of situations where the children are overwritten later, or re-recorded later.

The simplest example is disconnecting and reconnecting:

const el = new TestLight();
document.body.append(el);
console.log(el.innerHTML); // shows 3 divs
const container = document.createElement('div');
document.body.append(container);
container.append(el); // should throw as you're trying to add the second div as a child to itself

Another example is using this with a template system:

const go = (children) => render(
    html`<test-light>${children}</test-light>`,
    document.body);
go(html`<p>render 1</p>`); // renders 3 divs, with <p> as child of second
go(html`<p>render 2</p>`); // renders one <p> that overwrites the divs

There are of other cases where this breaks too. This is a big reason I'm not comfortable with most of the proposed solutions in the space. Shadow DOM is a composition primitive that's valuable precisely because composition is hard to impossible to get right without it.

@svdoever
Copy link

This is the reason why I had to move over to using StencilJS, where slots are supported without shadow dom.

@andyjessop
Copy link

This is the biggest issue for us adopting web components. We would have to use the light DOM because we use CSSModules to style our components, but if we can't have composition then web components are frankly unusable in a modern web app without huge friction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests