Skip to content

Proposal: Composable shadow roots, for extending builtins and more #1108

@LeaVerou

Description

@LeaVerou

Background & Use Cases

Extending native elements is a very common need, but the customizable built-ins proposal was blocked by WebKit, and for good reasons. Indeed, there are many issues with that direction:

  • Clumsy user-facing syntax: While <button is="foo-button"> may provide the small benefit of a nicer fallback, for many use cases it's not acceptable syntax. Authors generally want <foo-button>, and the element it extends is often an implementation detail. Encoding it in every single component instance makes syntax much clumsier for component authors, who often won't understand why some components are <foo-something> and other times <something is="foo-something">. For things where <button is="foo-button"> is actually acceptable, custom attributes tend to be a better fit.
  • Limited capabilities: Native elements typically have closed shadow roots. Without being able to tweak the element's UI, there are very few useful things that can be done.

However, the lack of ways to extend native elements is a real problem. Components typically end up either recreating the native element and its API entirely, or wrapping it, both with very serious maintainability and ergonomics issues.

  • Recreating native elements introduces a usability cliff, as now adding a small feature to a native element requires recreating everything. It is also not maintainable, as no userland code can really keep up with the rate the web platform is evolving.
  • Wrapping native elements (e.g. a <foo-button> whose shadow root contains a <button>) both requires recreating the native's API and is a styling disaster. How can components "redirect" styling on <foo-button> so that it is applied to their internal <button>? One way is display: contents on the host, but it causes several problems (e.g. getClientRects() and methods that depend on it, such as getBoundingClientRect() stops working, overriding display on the host from the outside breaks everything, etc.). In practice, components often end up requiring that authors style parts instead, or expose custom properties for common stylings, both with suboptimal DX (example).

Note that this is not specific to natives, component consumers may also want to extend other web components they don't control. For example, to define their own WCs that customize components from a third-party library.

This proposal attempts to sketch out a way to solve these problems based on regular ES class inheritance (with no user-facing HTML syntax that replicates the relationship) and a syntax for composing shadow roots without needing access to them, by piggybacking on slots.

Note

Please note that this is a proposal, not a fully-fleshed out spec, and a pretty ambitious one at that. You can definitely poke holes at it; there are many kinks and details to be ironed out, but I think the general direction could be promising.

Goals

  • Extend native elements and other web components, even if their shadow roots are closed, without violating encapsulation.
  • Inherit JS API, attributes, accessors, AT, CSS pseudo-classes like :checked, CSS pseudo-elements like ::backdrop, and optionally, styling.
  • Do not encode the inheritance relationship in HTML syntax (except perhaps as an optional rendering hint)
  • Have control over the child component API, including exposing a subset of its parent component API or using different defaults for some attributes
  • Ability to reuse and extend visual rendering, not just behavior
  • Ability to reuse behavior, while completely overriding visual rendering

Non-goals

  • Multiple inheritance. E.g. if you want to add a href to a button, how can you inherit from both the APIs of <a> and <button>? This is out of scope for this proposal, just like it is for JS. But one advantage of this proposal is that once JS gets native class mixins, elements can automatically benefit.

Relationship to other proposals

Custom attributes

Custom attributes could help when the desired functionality can be expressed as a trait, but:

  1. Custom attributes are not an appropriate solution for all use cases, often the customizations desired are fundamental to the identity of the element.
  2. With no ability to tweak the element's shadow DOM, their functionality is also limited.

This proposal does not compete with custom attributes; in fact the way it outlines for composing different shadow roots with different levels of encapsulation can be used by custom attributes as well.

ElementInternals.type

However, it does provide a more generalizable alternative to ElementInternals.type. ElementInternals.type involves an alternative "magic" inheritance mechanism that doesn't go through extends (and thus super still resolves to HTMLElement), the resulting element inherits some properties from the parent but not others, and whose values are an odd mix of element types (button, label) and values (submit, reset). Additionally, its design violates several TAG principles around how properties are supposed to work, such as having IDL attributes with (huge) side effects or throwing on setters.

Instead of introducing new, unprecedented paradigms about how web platform technologies work, this proposal largely attempts to make existing concepts such as class inheritance to Just Work™.

Composable Shadow Roots Proposal

The core idea of composable shadow roots is that slots are a powerful mechanism for merging two DOM structures in a controlled way, and can be repurposed for combining two shadow roots with potentially different encapsulation types (since natives typically have closed shadow roots) without breaking encapsulation.

Subclassing via regular JS extends

A core idea of this proposal is to allow author WCs to extend natives without having to expose the inheritance relationship to their users on every instance of the component. User-facing syntax remains the same as regular WCs, which also means existing WCs can be "upgraded" to extend from natives without usage having to be updated. Yes, we lose the fallback behavior, but to most authors (a) this does not seem to be a benefit that is worth the user-facing API tradeoff (b) we can still have it as an optional hint, see like attribute below.

So, this just would just work:

class FooButton extends HTMLButtonElement {

}

customElements.define("foo-button", FooButton);

With nothing more than the extends declaration, the resulting element behaves identically to the native one, just with a different element name and none of the UA styles. Its shadow root is the same closed shadow root of its superclass, and thus, the subclass has no access to it (this.shadowRoot is null).

Note

Note that if this.shadowRoot is null, we can't even use adoptedStyleSheets to add styles, so this has very limited utility without composable shadow roots (see below).

How to subclass specific types of elements?

HTML often overloads HTML elements that components may want to handle separately, e.g. <input type>, <button type> etc.
It's a little awkward, but if the child component does not want to expose the parent component's type attribute/property, it can hardcode it to a specific value:

class MyInput extends HTMLInputElement {
	constructor() {
		super();
		super.type = "text";
	}
	
	get type() { return super.type }
	set type(v) {}
}

How to have different values for an attribute with the same name?

Similarly to the example above, suppose we want to have a <foo-button> component with a type attribute that defaults to button, but could also be set to submit, and we don't want to allow reset.
We can just regular JS to transform the value before passing it to the superclass:

class FooButton extends HTMLButtonElement {
	constructor() {
		super();
		if (super.type !== "submit") super.type = "button";
	}
	
	get type() { return super.type }
	set type(v) {
		if (v === "submit") super.type = "submit";
		else super.type = "button";
	}
}

What about ElementInternals?

The child component would inherit the same internals as its parent. If attachInternals() is used any values set are composed with the parent values:

  • states are composed via union
  • Any scalar values set (e.g. ARIA) override parent ones.

Composable shadow roots via slots

For some (few) use cases, not tweaking the element's UI may be fine, since the only extensions required can be implemented through JS subclassing. However, most use cases do require some kind of DOM extension as well, even if it's just adding styles.

The core idea of this proposal is that this kind of DOM amending required is usually additive, i.e. adding elements around the native element's shadow root (but within its shadow host) or around the shadow host.

For those, this proposal introduces a new <slot> attribute: type which can be used to designate certain slot elements as special purpose (more types may be added in the future). To mark a slot as "this is where the superclass' shadow root goes", the subclass WC would use <slot type="supershadow">. For the whole shadow host, it could be <slot type="super">. The exact values are TBB; alternatively, it could even be a special slot name that is syntactically distinct (e.g. <slot name="$super">).

If the subclass calls attachShadow({mode: "open"}), the shadow root automatically gets initialized with "" just like it is today, and authors need to add <slot type="supershadow"></slot> to still render the parent shadow root in it. Authors can also add any other content around it, including other slots, e.g.:

<slot name="prefix"></slot>
<slot type="supershadow"></slot>
<slot name="suffix"></slot>

Note

Why not initialize with '<slot type="supershadow"></slot>'?
Note that while this is primarily designed to facilitate extending builtins, it is not restricted to them, and can be used to extend any element class. Therefore, initializing with '<slot type="supershadow"></slot>' would not be web-compatible.

The slot would otherwise behave like a regular slot for open shadow roots, and as if it had no slotted content for closed shadow roots, including:

  • Methods like assignedNodes() or assignedElements()
  • the :has-slotted pseudo-class
  • the slotchange event: for closed shadow roots it can not fire, for open shadow roots it could fire just fine.

Note that the subclass could simply choose not to include any special slot if it doesn't desire to render the actual superclass shadow root anywhere. This is useful for cases where one wants to completely change presentation, but still inherit all API methods. In that case, the superclass element still exists, but is disconnected, and API methods still apply to that disconnected element (and fire events as normal, which can be listened to by the subclass). Components could even include the slot sometimes and not others, depending on certain conditions.

Edge cases:

  • If multiple special slots are specified, the first one wins, just like what happens today if multiple slots are specified with the same name.
  • In case of slot naming conflicts, child component slots have priority over parent slots regardless of source order. So if they use slots with the same name, the child's slots always win. This mainly affects extending other WCs, since natives don't use slots.

Other potential extensions (non-MVP)

<slot type="super">

Alternatively, authors could use <slot type="super"> to encapsulate the entire element, while still adding DOM around it. This is similar to the existing pattern of wrapping natives, except authors do not need to recreate the entire element API, they get all properties, attributes, and methods for free, and they automatically get redirected to the encapsulated element (unless overridden to do something else). This is useful for cases where one wants to create higher-level components that add labels, tooltips, etc. For example, one could do this to create an input with a label auto-generated from its label attribute or label slot, hints, etc. (such as this).

<label><slot name="label">{{ attrs.label }}</slot>
  <slot type="super"></slot>
</label>
<slot name="hint"></slot>

In that case, all superclass DOM methods would be automatically "forwarded" to the internal element.

The like attribute and the CSS :like() pseudo-class

If fallback display like the corresponding native element is desired, we could flip the relationship: <foo-button like="button"> instead of <button is="foo-button">. But that would be an optional UA hint for optimization, and not required for inheritance to work. like would be a global attribute, that is valid on all custom elements. E.g. <foo-button like="button"> would also render like a functional button and be exposed like a button in the AT, even if no <foo-button> ever gets registered.

To help with styling, we can introduce a new :like(<ident>) pseudo-class which is basically the CSS equivalent of instanceof (whether the like attribute is used or not). E.g in the hierarchy foo-icon-button < foo-button < button, :like(button) would match instances of all three. Then, UA stylesheets and authors could style :like(button) instead of button and said styling would apply to button subclasses too.

Note

One issue with this is that this is typically desirable when using <slot type="supershadow"> but not when using <slot type="super">.

To take advantage of :like() even when full-blown inheritance is not desirable, perhaps there could be an ElementInternals.like property to override.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions