Alpine: Alpine interaction with Custom Elements and Shadow DOM

Created on 12 Feb 2020  路  15Comments  路  Source: alpinejs/alpine

I've been doing a bit of experimentation with native custom elements (aka Web Components).
See https://developers.google.com/web/fundamentals/web-components/

The immediate problem is that in index.js the discoverComponents function uses document.querySelectorAll('[x-data]') to find the components. This does not find elements that live in a shadow DOM. (Even in "open" mode.)

Thus no Alpine functionality works within a custom element.

Likewise in utils.js the function walk will not recurse into the shadow DOM elements, and thus in component.js walkAndSkipNestedComponents will not find elements with x-data attributes inside custom components.

As a small experiment, i created my own "walk" function that added something like this:

//...
walk(node, callback)
if (node.shadowRoot) { // recurse into shadow DOM if custom component found
    walk(node.shadowRoot,callback);
}
//...

To handle custom components, one can create a modified version of discoverComponents:

discoverComponents: function(parent,callback) {
        const rootEls = parent.querySelectorAll('[x-data]');
        rootEls.forEach(rootEl => {
            callback(rootEl)
        })
}

And then call it either with discoverComponents(document,...) or discoverComponents(el.shadowRoot,...)

However, that would mean that we would need to walk the entire DOM, searching for custom elements, rather than being able to make a single call to querySelectorAll. One could imagine this being a performance issue.

These are just the beginnings of a discussion about making custom elements available to Alpine.

I realize that this is slightly beyond the original design of the library, which was intended to add interactivity to server-side generated HTML, and thus there is no need for custom elements, as the server could simply construct the required repeated boilerplate, thus obviating the need for custom components.

But I wanted to see if a combination of Alpine and Custom Elements could be a very lightweight replacement for Vue (or other frameworks) using native browser functionality as much as possible.

I'll do some more thinking and experimentation and post the results as comments here.

enhancement

Most helpful comment

A working Web Component with AlpineJS
https://webcomponents.dev/edit/4oNbCTP7NCFJftyKer7j

But obviously no support for ShadowDOM...

Would it be easy to pass the shadowRoot to AlpineJS so he can "hook" it up?
Something like that

// Pseudo code
connectedCallback() {
   this.shadowRoot.append(template.content.cloneNode(true));
    Alpine.hook(this.shadowRoot);
}

Is this possible?

All 15 comments

I did some exploration weeks ago seems I think it could be an interesting idea but I'm not sure that using the shadow DOM would follow the design of the library (we would end up with hiding everything again, the only difference would be that we use the shadow DOM instead of the virtual DOM).

My experiment was just about using a web component to pack a chuck of html and render it in the real DOM.

https://codepen.io/SimoTod/pen/xxbedqb

I'm looking forward to knowing your findings, though.

Interesting. Your example works because you just replace the innerHTML of the custom element, and don't attach a shadow DOM.

The Virtual DOM that other frameworks create is purely an internal data structure, whereas the Shadow DOM is a standard browser feature. So it's not "hidden" in the same sense...

I can probably get by with the innerHTML trick for now and avoid the Shadow DOM problems.

But the Shadow DOM gets you all sorts of useful things like scoped styles, parameterized content slots and custom events that might make life much easier in the long run. However these are exactly the types of things that would make the Alpine library code much more complex.

The abstractions provided by Shadow DOM might be more useful for someone writing a component library, whereas if the custom elements are hog-tied (i.e. tightly coupled) to my exact CSS and Javascript context, I can probably make it work.

Hi @tmalaher, I've tried your suggestions for a custom component using the shadow DOM.
The main blocker for a component library, in my opinion, is how we style the shadow DOM from outside. For example, if we have a modal component, devs must be able to change fonts, colors, etc.
How did you approach that? Did you define css hooks for everything in your component?

CSS Styling is definitely one issue, but even using custom components with plain static HTML you would have that problem (which is really one of API design for the component author).
My concern is more about dynamic behavior/content (x-data, @click, x-model, etc.)
Certainly there is an overlap when you get to the class and x-show, but at the moment I think most of the problem is how to get the javascript parts (data, behavior) to work.

But maybe I'm thinking about this the wrong way. Once the component is packaged up so that it has its own local javascript, how do you pass dynamic data from the parent (calling) page into the component so the component can use it to modify the shadow DOM?

My first thought was to use slots and pass HTML Elements into the component that it would inject into its shadow DOM. This fails because alpine does not delve into the shadow DOM instances looking for x-data attributes. So I thought we would need to extend alpine to understand shadow DOM structure and inspect the contents. This way the component itself would not need to know anything about alpine.

Is there a better way? Can we design a component that is alpine-aware such that it can "pull" data in from an x-data attribute on the root element of the custom component?

I believe that anything beyond a toy solution is going to need to use a shadow DOM, and can't just use innerHTML.

I'll add 2c in case it helps. I agree that innerHTML is not a real solution. Custom Elements(CE) is a standard and... things should be built on top of it, not around it. It defines a way to set attributes via .js or declare inline html (like tailwind). However, CE is not reactive. (and I use it w/ a global event bus to solve all issues). And I can see that it would not be a lot of work to create a base CE that has all the directives and properties into a BaseAlpineElement. I'd be happy to contribute some effort if this becomes a direction for Alpine v3. You would then extend a BaseAlpineElement to make MyCE and place that in html, and have all the directives and properties baked in, the html syntax would be very similar if not same. Happy to comment more if needed.

@thormeier My second attempt was just about modifing walkAndSkipNestedComponents to look for elements in the shadowDOM.

At least for the first iteration, it made sense to me to pass the x-data from the original DOM and just support other directives. We can also use slots (we can have modals, dropdown, etc) to pass the content we need to change, and that would work.

If it's just for internal purposes where you already know the style that you want to use, it will be okay.
I was thinking that, if you wanted to distribute that component, it would be really hard to style for other devs so from that point of you styling is a major problem. Normal HTML would follow the standard CSS rules but CE are sandboxed by design so styles don't leak in or out.

The changes to the Alpine core would be minimal so I think it could make it into the core.

@cekvenich It's another valid option. It probably doesn't need to be in the core but it could be an extension that you pull in if you don't need any core changes so maybe it doesn't even need to wait for v3 (talking about the idea, I haven't figure out if you need to hack in the core). I assume the styling problem will still be there. I create a generic modal component, my user want to make the title red, but the html tag is in the shadow DOM, how do they style it?

@SimoTod I do want to see your implementation for CE when it's ready.
As to style, I think the real answer is that CE is the standard way of doing it, and that we'll be in the same boat as everyone else going forward - some design patterns will emerge. The answer to your question depends on open or closed? and then get it to work. I have also imported the same style sheet in 'main' page and in each CE as a base, and from there customize.

@cekvenich I'm not going to be home this weekend, I'll share my work in progress on Monday. 馃憤

@SimoTod This is all open-source volunteer work so enjoy yourself as much as you can.

FTW, here is my hello world CE impl:

@cekvenich Thanks for sharing, I'll have a look.

This is my work in progress -> https://github.com/alpinejs/alpine/compare/master...SimoTod:feature/custom-components?expand=1

I said walkAndSkipNestedComponents but I was typing from my phone and I didn't remember the right name. What I changes is the walk function. As you can see the change is really small so including it in the core is not unrealistic, I think.

Here is an example(the component is really dumb, i know, it's just to show the concept) -> https://codepen.io/SimoTod/pen/OJVzyzZ

Known limitation: You can't define whole alpine components in the shadow DOM
Stuff like:

class ReusableComponent extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({mode: 'open'});
        shadowRoot.innerHTML = `
            <div x-data="{ foo: 'bar' }">
                <input x-model="foo" />
                <slot name="content"></slot>
            </div>
        `;
    }
}
window.customElements.define('reusable-component', ReusableComponent);

would not work but it makes sense to me that x-data should be passed in from the light DOM.

@SimoTod For sure you can pass x-data attribute to a CE. I'd be happy to write a full example if you want? But since it is open-source, it maybe be the weekend before I get to it, but let me know.

@cekvenich Yeah, there will probably be a smart way but I haven't focused on that part much since I was more interested in the general concept.
it may be tricky since the discoverComponents function uses a DOM selector but I'm not sure if there is a smart way to query all the shadow DOMs. Maybe we can use an Alpine-Aware component as you suggested and inherit from that one. Feel free to experiment whenever you can, I'll do the same when I have some spare time.

@SimoTod Will do. One obvious way is an eventBus. It may eliminate the need to discover. But lets play.

Hey guys, I'm going to close this issue since it's been stale for a few months now. If anybody wants to investigate Alpine & Web Components further, please re-open this issue and let us know your thoughts and ideas, or even better, open a new Discussion which is where we try to keep all of our feature requests. Cheers! 馃檪

A working Web Component with AlpineJS
https://webcomponents.dev/edit/4oNbCTP7NCFJftyKer7j

But obviously no support for ShadowDOM...

Would it be easy to pass the shadowRoot to AlpineJS so he can "hook" it up?
Something like that

// Pseudo code
connectedCallback() {
   this.shadowRoot.append(template.content.cloneNode(true));
    Alpine.hook(this.shadowRoot);
}

Is this possible?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

imliam picture imliam  路  5Comments

aolko picture aolko  路  5Comments

adevade picture adevade  路  3Comments

haipham picture haipham  路  4Comments

bep picture bep  路  4Comments