Search code examples
vue.jsvuejs3web-component

Referencing the host element in Vue v3 Custom Element components


OK so I know a few variations on this question have been asked already, across the various versions and APIs of Vue... But I haven't been able to figure it out so here's the context as to why I think mine is different:

I'm trying to build some components which:

  1. Are internally complex enough that building with Vue rather than just native web components is helpful, but...
  2. Will run outside Vue context on the page (not in a Vue app), so are packaged as Web Components / Custom Elements from Vue, and...
  3. Implement data inputs that will be used inside <form>s (again, not in Vue apps).

One challenge with this is that Vue Web Components use shadow DOM, and forms don't automatically traverse shadow roots to look for inputs: So making the form actually see and submit the components' inner data is not automatic.

It seems like there's some hope as detailed in this helpful blog post: A new ElementInternals API and element-internals-polyfill NPM package by which components can indicate data up to forms. Implementing a "form-associated custom element" requires setting a static readonly boolean property (easy enough) but also linking something like:

// (`this` == the custom HTMLElement itself)
const _internals = this.attachInternals();

_internals.setFormValue(value);

Problem is, I'm really struggling to figure out where I can hook in to have access to both:

  • The mounted DOM element (the one above the shadow root, i.e. <my-custom-element>, not just some ref() in the template), and
  • Reactive state of the component to get value

...So far I'm mostly using Vue's composition and script setup APIs which admittedly feel like they make this even harder: For example onMounted doesn't define this at all. But even using the equivalent options API mounted: () => {} I see this.$el seems to be the first element in the template/shadow root, not the parent custom element that owns the shadow root.

I also looked at going the other way - starting from the created CustomElement class and trying to work back through to useful Vue data & hooks... But couldn't find a way here either:

import { defineCustomElement } from "vue";
import MyCustomComponent from "./components/MyCustomComponent.ce.vue"
const MyCustomElement = defineCustomElement(MyCustomComponent);
class MyCustomElementFormAssoc extends MyCustomElement {
  static get formAssociated() {
    return true;
  }

  constructor(initialProps?: Record<string, any> | undefined) {
    super(initialProps);
    const _internals = this.attachInternals();

    // But here the component isn't even mounted yet - this._instance doesn't
    // exist and presumably reactive state doesn't either, so can't do:
    //   _internals.setFormValue(someValueState);
  }
}
customElements.define("my-custom-element", MyCustomElementFormAssoc);

So while in general, in line with other Vue 3 answers "there is no single root element and we should use refs instead", in my case I'm specifically trying to access the Custom Element defining the component - not the element(s) inside the template. The rendered DOM looks something like:

    <my-custom-element class="this-one-is">
      #shadow-root (open)
      <div class="custom-element-template-can-have-multiple-roots"></div>
      <div class="but-these-are-not-the-elements-im-looking-for"></div>
    </my-custom-element>

Does anybody know how it can be done?


Solution

  • Agree this is a bad code smell and a signal to evaluate whether Vue is really a good fit for the use case in general: Hacking around with hybrid Web Components that aren't quite native but aren't quite Vue either is likely to be a maintenance burden even if it works today.

    But needs must - and my current workaround for this is to track back from some reference element in the template (doesn't really matter what) via DOM, like this:

    // (MyCustomComponent.ce.vue script setup)
    
    import { ref } from "vue";
    
    const someTemplateRef = ref();
    
    onMounted(() => {
      const hostNode = someTemplateRef.value.getRootNode()?.host;
    });