WICG / webcomponents

Web Components specifications
Other
4.39k stars 376 forks source link

Exploration: HTML Module Imports and Exports #1059

Open EisenbergEffect opened 6 months ago

EisenbergEffect commented 6 months ago

Background

Extensive discussion on Declarative Custom Elements has brought the CG to a point of realizing that a good starting point would be HTML Modules. HTML Modules would serve as a container for DCEs, but also other reusable HTML resources, such as templates.

This being the case, we need to focus in on exactly how exports AND imports would work. While the export side of HTML Modules has been explored previously, the import side is largely undefined.

Strawman

In order to get conversation going, I'll propose a few basic ideas here.

HTML Module Exports

I'll begin by submitting that there are five possible export types:

To export any one of these, they must have an id, which will become the symbol under which the export is provided, and they must have the export attribute. Elements with only an id can be referenced within the module, but are not able to be imported. Both id and export are required for that.

Here are a few examples.

<!-- Becomes an export named pricingCard of type HTMLTemplateElement -->
<template export id="pricingCard">
  <div class="pricing-card">
    <div class="header">
      <h4 class="name"></h4>
    </div>
    <div class="body">
      <h1 class="title"></h1>
      <ul class="features">
      </ul>
      <button type="button" class="subscribe-action"></button>
    </div>
  </div>
</template>

<!-- Becomes an export named sharedHeaderStyles of type CSSStyleSheet -->
<style export id="sharedHeaderStyles">
  .shared-header {
    display: flex;
    justify-content: center;
  }
</style>

<!-- Becomes an export named sharedHeader of type DocumentFragment -->
<header class="shared-header" export id="sharedHeader">
  <ul>
    <li><a href="./home">Home</a></li>
    <li><a href="./features">Features</a></li>
    <li><a href="./pricing">Pricing</a></li>
    <li><a href="./faq">FAQs</a></li>
    <li><a href="./about">About</a></li>
  </ul>
</header>

The above types exist today, but in the future we could also add <element> (or some other tag) for declarative custom elements and <registry> for a declarative custom element registry.

Importing HTML Modules in JS

Given the above example, we could import in JavaScript, using import assertions.

import { 
  pricingCard, 
  sharedHeaderStyles,
  sharedHeader
} from "./my-resources.html" with { type: "html" };

You should not be able to import the entire document, as that would break the encapsulation of the module. You should only be able to import elements with an id and an export attribute. We could also enable an element with an export and no id to be imported as the default export.

Importing HTML Modules in HTML

Importing into HTML involves several tasks:

HTML already provides a way to declaratively use a custom element, but not a way to declaratively use a template or fragment. So, we'll need to add something new here.

We also have no way to declaratively define a custom element in a given HTML document or module.

First, let's tackle the issue of importing the resources from an HTML module. We could do that with an <import> element as such. Here are a few examples:

<import from="./my-resources.html#sharedHeader">
<import from="./my-resources.html#pricingCard" as="productCard">
<import from="./design-system.html#MyButton" as="ui-button">

The from attribute provides the path to the HTML module and also includes a fragment identifying the specific export. The as attribute provides a way to name the import within the current document or module. In the case of a template or fragment, this creates something with an id denoted by as. In the case of a custom element, it will call define with the value of as for the tag name.

To use an imported custom element, is no different than using any custom element:

<ui-button>Click Me!</ui-button>

Using a template or fragment is different. I propose adding a new element to handle this, named compose. It would look something like this:

<compose src="#sharedHeader"></compose>
<compose src="#productCard"></compose>

Note: We could enable the src attribute to both import and compose as a convenience. But I think importing and using are two different things and minimally need what is shown above.

Open Question: Could compose be a processing instruction or something else besides an element? Ideally, the composition should not affect the dom structure. It's really only a location where the composition should occur.

The above should all work when importing into documents or other HTML modules. One other scenario is worth mentioning: importing DCEs into a potential declarative custom element registry. That could look something like this:

<registry export id="my-registry">
  <import from="./my-element.html" as="your-element">
  <import from="./my-module.html#NamedElementTwo" as="element-two">
  <import from="./everything-from-another-registry.html">

  <element tag="an-inline-element"></element>
</registry>

And of course, you could import this entire registry into a document as follows:

<import from="./my-registry.html">

This would define all the contained elements in the current document, using their specified tag names. To change the names, we could add a mapping, similar to the way export parts work in shadow dom.

Feedback Request

Ok, that's a rough, initial sketch of how we might put some pieces together. Let's start the conversation around whether this meets all the needs, what are the semantics, etc.

justinfagnani commented 6 months ago

I tend to think that importing and doing things with the imports should be separated. Then you have a general mechanism for defining and consuming identifiers, and many features can plug into either side of that.

So I'd prefer to have an import just define a identifiers*, and something like a scoped custom element registry just use those identifiers. I don't think an import should also define a custom elements - that seems too coupled.

Importing identifiers

An import could allow you to import one or more identifiers from the module, and define a identifiers in some HTML scope:

<import src="./element-one.html" imports="element-one element-two"></import>

Because we want an equivalence between JS and HTML modules, this should work with JS too:

<import src="./element-one.js" type="js" imports="element-one element-two"></import>

I think this might be a reason to not use fragments in the imports. Though maybe when you import from JS, the fragments just reference exports?

Note that type="js" is similar to import attributes, but the default is flipped, because we might want HTML modules to be the default for <import>.

Another option is to just use the existing <script> tag, but allow it to import identifiers:

<script src="./element-one.html" type="html" imports="element-one"></script>

If we want to meet the use case of running with JavaScript disabled, could we say that type="html" is allowed if it doesn't contain or import JS itself?

Declaring local identifiers

We want HTML modules to be able to be be bundled and to just naturally be able to contain multiple component definitions. So we don't want to have to import an identifier, but also be able to declare them locally:

<!-- Defines, but does not register, a custom element class -->
<define id="element-one">...</define>

Note: I'm using the id attribute here. Can we just reuse IDs for identifiers? It'd be simpler conceptually

Using identifiers

Once we have a way to import or declare identifiers, we have unlocked HTML use-cases for non-side-effectful HTML modules. We can import a definition and register it ourselves:

<script src="./element-one.html" type="html" imports="element-one"></script>
<define id="element-two">
  <registry>
    <define tagname="el-one" from="element-one">
  </registry>
  <template>
    <el-one></el-one>
  </template>
</define>

Note I'm overloading <define> here to define an element globally when it's not a child of a <registry> and to define an element inside a scoped registry when it is. The example also implies that a declarative custom element definition just implicitly uses the first <registry> child. We would need a way to reference shared registries too.

We could also just import templates from a separate file:

<script src="./template.html" type="html" imports="template-one"></script>
<define id="element-one">
  <template from="template-one"></template>
</define>

Or maybe:

<script src="./template.html" type="html" imports="template-one"></script>
<define id="element-one" template="template-one">
</define>
jaredcwhite commented 6 months ago

@EisenbergEffect For script tag(s) inside of a module, are you still thinking it could utilize the import.meta.document idea once proposed in the past incarnation of HTML Modules? Or would there be some other method of getting at the document or fragments?

EisenbergEffect commented 6 months ago

@jaredcwhite Yes, I was thinking we could still use that in script tags within the module.

o-t-w commented 6 months ago

@EisenbergEffect

import { 
  pricingCard, 
  sharedHeaderStyles,
  sharedHeader
} from "./my-resources.html" with { type: "html" };

You are importing a CSSStyleSheet from sharedHeaderStyles as type: “html”, which seems weird.

EisenbergEffect commented 6 months ago

@o-t-w I agree. It's a bit of a quandry. While it would be more consistent for that to be a style element, what would be practically useful is a CSSStyleSheet.

sashafirsov commented 6 months ago

I disagree about decoupling of import and registering in the scope. It creates extra unused namespace and problems with name scope negotiation and recognizing by develloper.

All languages (try to find otherwise) including JS have import and mapping to name in the same statement.

The following convention would fix this irrationality:

<define id="element-two">
    <script src="./element-one.html" type="html" imports="element-one"></script>
    <define tagname="el-one" from="element-one">
    <template>
        <el-one></el-one>
    </template>
</define>

Not in favor of tag names though. It overlaps with standard ones (XSLT) wthout inheriting its semantics and syntax.

EisenbergEffect commented 6 months ago

I could see enabling decoupled import and define but I also would prefer to be able to do it in one go. That seems like it would be the most common need:

<import src="./some-module.html#MyElement" tag="my-element">

<my-element>Hello</my-element>
<my-element>World</my-element>

I think I would also want to be able to import and use a template in one go as well.

<template src="./some-module.html#myTemplate">
  Fallback content while the template is being loaded.
</template>

Could we get this working for declarative shadow dom too?

<template id="helloTemplate">Hello <slot></slot>!</template>

<say-hello>
  <template shadowrootmode="open" src="#helloTemplate"></template>
  World
</say-hello>

<say-hello>
  <template shadowrootmode="open" src="#helloTemplate"></template>
  Web Components
</say-hello>
justinfagnani commented 6 months ago

and problems with name scope negotiation and recognizing by develloper

what problems, exactly?

The reason we should separate importing a thing from registering a custom element is that they will necessarily be different namespaces. From an HTML module we could import a DOM node, say a template or a reference to a custom element, and we need a way to refer to those as well as potentially register custom elements. If the import statement registers elements, it'll need two features - one for importing an identifier, another for registering custom elements. This is too coupled. We'd still need a way to register elements by HTML reference that aren't from an import - like a local definition.

Jamesernator commented 6 months ago

I think this might be a reason to not use fragments in the imports. Though maybe when you import from JS, the fragments just reference exports?

Fragments already have existing behaviour with JS imports, in particular they trigger multiple evaluations (without triggering multiple fetches compared to using query params):

// mod.js

console.log(import.meta.url);
await import("./mod.js#one"); // Prints BASEURL/mod.js#one
await import("./mod.js#two"); // Prints BASEURL/mod.js#two

I think it would be preferable to have consistent behaviour rather than special casing import behaviour inside HTML. It'd probably be best if # triggered individual module evaluations for HTML modules well.


One problem that needs to be resolved is how to handle import attributes, in JS import attributes are required to import non-JS things like CSS. There's not really anyway to avoid these in HTML either as they define headers and the request type for the associated fetch.

It does seem cumbersome to have to specify with attributes in many cases though, if we import per element kind as suggested by @justinfagnani then we could have defaults:

<!-- default behaviour imports the default import with attributes { type: "css" }-->
<style src="./path-to-css-module.css"></style>

<!-- importname and type can be customized with attributes -->
<style src"./path-to-js-module.js" import="cssSheet" importtype="js"></style>
<style src="./path-to-html-module.html" import="cssSheet" importtype="html"></style>
NullVoxPopuli commented 6 months ago

Why this over document[id] or document.querySelector('#theID')?

o-t-w commented 6 months ago

Would it be better to initially focus on standardising how HTML modules work with import attributes in JavaScript before adding new syntax?

Also, I was re-reading the original GitHub issue for HTML modules and this reply seemed worth pondering as an alternative to separate using separate Id and export. https://github.com/WICG/webcomponents/issues/645#issuecomment-427447824

Jamesernator commented 1 month ago

Something that still needs to be considered for HTML modules is how to manage base urls for templates in HTML modules.

For example if you're using a template from an HTML module to make a custom element like:

<!-- template.html -->
<template export="default">
    <link rel="stylesheet" href="./styles.css" />
    <div id="someThing"></div>
</template>
import elTemplate from "./template.html";

class MyElement extends HTMLElement {
    #shadowRoot = this.#attachShadow({ mode: "closed" });

    constructor() {
        super();
        // OOPS the links are relative to the CURRENT document not to template.html
        this.#shadowRoot.append(elTemplate.content.cloneNode(true));
    }
}

then any links in your associated templates will refer to the current document not the document containing those templates.

DanielHerr commented 3 weeks ago

Using a template or fragment is different. I propose adding a new element to handle this, named compose. It would look something like this:

<compose src="#sharedHeader"></compose>
<compose src="#productCard"></compose>

Note: We could enable the src attribute to both import and compose as a convenience. But I think importing and using are two different things and minimally need what is shown above.

Open Question: Could compose be a processing instruction or something else besides an element? Ideally, the composition should not affect the dom structure. It's really only a location where the composition should occur.

Could <slot> be reused for this? Something like this but with the standard <slot>?

https://sergey.trysmudford.com/slots/