calebdwilliams / element-internals-polyfill

A polyfill for the element internals specification
MIT License
97 stars 30 forks source link

Element Internals Polyfill

This package is a polyfill for the ElementInternals standard. The specification is supported by current releases of Chromium and Firefox.

Use case

The primary use case for ElementInternals right now is allowing custom elements full participation in HTML forms. To do this, it provides any element designated as formAssociated access to a handful of utilities.

The ElementInternals API also offers users access to increased utilities for accessibility by exposing the Accessibility Object Model to the element.

Installation

This package is available on npm under the name element-internals-polyfill and can be installed with npm, yarn, unpkg or however else you consume dependencies.

Example commands:

npm:

npm i element-internals-polyfill

yarn:

yarn add element-internals-polyfill

skypack:

import 'https://cdn.skypack.dev/element-internals-polyfill';

unpkg:

import 'https://unpkg.com/element-internals-polyfill';

How it works

To do this, add the static get formAssociated to a custom element and call the attachInternals method to return a new instance of the ElementInternals interface:

class MyElement extends HTMLElement {
  constructor() {
    super();
    this._internals = this.attachInternals();
  }
}

This works by doing several things under the hood. First, there is a feature check for the ElementInternals object on the window. If that does not exist, the polyfill wires up a global MutationObserver on the document to watch for additions to the DOM that the polyfill might need.

It also monkey-patches HTMLElement.prototype.attachShadow to wire up a similar listener on any created shadow roots and to remove the watcher if the shadow root is removed.

The polyfill will also monkey-patch window.FormData to attach any custom elements to that feature as well.

The currently-supported features of the polyfill are:

Form-associated custom elements

To create a form-associated custom element using ElementInternals, the element's class must have a static formAssociated member that returns true.

class MyFormControl extends HTMLElement {
  static get formAssociated() {
    return true;
  }

  constructor() {
    super();
    this.internals = this.attachInternals();
  }
}

In the above example, the form control will now have access to several unique APIs for participating in a form:

In addition to the above the ElementInternals prototype has access to several form-specific methods including:

Accessibility controls

In addition to form controls, ElementInternals will also surface several accessibility methods for any element with internals attached. A list of supported properties (and their associated attributes) follows:

For example, if you are creating a control that has a checked property, you will likely could set the internals.ariaChecked property to 'true'. In polyfilled browsers, this will result in adding aria-checked="true" to the host's attributes. In fully-supported browsers, this attribute will not be reflected although the checked property will be reflected in the accessibility object model.

class CheckedControl extends HTMLElement {
  #checked = false;
  #internals = this.attachInternals();

  get checked() {
    return this.#checked;
  }

  set checked(isChecked) {
    this.#checked = isChecked;
    this.#internals.ariaChecked = isChecked.toString();
  }
}

State API

ElementInternals exposes an API for creating custom states on an element. For instance if a developer wanted to signify to users that an element was in state foo, they could call internals.states.add('--foo'). This would make the element match the selector :--foo. Unfortunately in non-supporting browsers this is an invalid selector and will throw an error in JS and would cause the parsing of a CSS rule to fail. As a result, this polyfill will add states using the state--foo attribute to the host element, as well as a state--foo shadow part in supporting browsers.

In order to properly select these elements in CSS, you will need to duplicate your rule as follows:

/** Supporting browsers */
:--foo { 
  color: rebeccapurple;
}

/** Polyfilled browsers */
[state--foo] {
  color: rebeccapurple;
}

The shadow part allows matching the custom state from outside a shadow tree, with similarly duplicated rules:

/** Supporting browsers */
::part(bar):--foo {
  color: rebeccapurple;
}

/** Polyfilled browsers (that however support shadow parts) */
::part(bar state--foo) {
  color: rebeccapurple;
}

Trying to combine selectors like :--foo, [state--foo] will cause the parsing of the rule to fail because :--foo is an invalid selector. As a potential optimization, you can use CSS @supports as follows:

@supports selector(:--foo) {
  /** Native supporting code here */
}

@supports not selector([state--foo]) {
  /** Code for polyfilled browsers here */
}

Be sure to understand how your supported browsers work with CSS @supports before using the above strategy.

Current limitations

A note about versioning

This packages doesn't necessarily follow semantic versioning. As the spec is still under consideration and implementation by browser vendors, the features supported by this package will change (generally following Chrome's implementation).