Writing a web component that wraps a standard HTML element might alleviate the need for is="" syntax

By Jan Miksovsky on February 29, 2016

What if you want to create a web component that extends the behavior of a standard HTML element like a link? An early draft of the Custom Elements specification allowed you to do this with a special syntax, but the fate of that syntax is in doubt. We've been trying to create custom variations of standard elements without that support, and wanted to share our progress. Our results are mixed: more positive than we expected, but with some downsides.

Why would you want to extend a standard HTML element?

Perhaps there's a standard element does almost everything you want, but you want it to give it custom properties, methods, or behavior. Interactive elements like links, buttons, and various forms of input are common examples.

Suppose you want a custom anchor element that knows when it's pointing to the page the user is currently looking at. Such a situation often comes up in navigation elements like site headers and app toolbars. On our own site, for example, we have a header with some links at the top to our Tutorial and About Us pages. If the user's currently on the About Us page, we want to highlight the About Us link so the user can confirm their location:

While such highlighting is easy enough to arrange through link styling and dynamically choosing CSS classes in page templates, it seems weird that a link can't just handle this highlighting itself. The link should be able to just combine the information it already has access to — its own destination, and the address of the current page — and determine for itself whether to apply highlighting.

We recently released a simple component called basic-current-anchor that does this. We did this partly because it's a modestly useful component, and also because it's a reasonable testing ground for ways to extend the behavior of a standard element like an anchor.

What's the best way to implement a component that extends a standard element?

Option 1: Recreating a standard element from scratch (Bad idea)

Creating an anchor element completely from scratch turns out to be ferociously complicated. You'd think you could just apply some styling to make an element blue and underlined, define an href attribute/property, and then open the indicated location when the user clicks. But there's far more to an anchor element than that. A sample of the problems you'll face:

  1. The result of clicking the link depends on which modifier keys the user is pressing when they click. They may want to open the link in a new tab or window, and the key they usually press to accomplish that varies by browser and operating system.
  2. You'll need to do work to handle the keyboard.
  3. Standard links can change their color if the user has visited the destination page. That knowledge of browser history is not available to you through a DOM API, so your custom anchor element won't know which color to display.
  4. When you hover over a standard <a> element, the browser generally shows the link destination in a status bar. But there is no way to set the status bar text in JavaScript. That's probably a good thing! It would be annoying for sites to change the status bar for nefarious purposes. But even with a solid justification for doing so, your custom anchor element has no way to show text in the status bar.
  5. Right-clicking or long-tapping a standard link produces a context menu that includes link-specific commands like "Copy Address". Again, this is a browser feature to which you have no access in JavaScript, so your custom anchor element can't offer these commands.
  6. A standard anchor element has a number of accessibility features that are used by users with screen readers and other assistive techologies. While you can work around the problem to some extent with ARIA, there are numerous gaps in implementing accessibilty completely from scratch.

Given this (likely incomplete) litany of problems, we view this option as a non-starter, and would strongly advise others to not go down this road. It's a terrible, terrible idea.

Option 2: Hope/wait for is="" syntax to be supported

The original Custom Elements spec called for an extends option for document.registerElement() to indicate the tag of a standard element you wanted to extend:

  class MyCustomAnchor { ... }
  document.registerElement('my-custom-anchor', {
    prototype: MyCustomAnchor.prototype,
    extends: 'a'
  });

Having done that, you could then create your custom variant of the standard element by using the standard tag, and then adding an is attribute indicating the name of your element.

  <body>
    <a is="my-custom-anchor" href="https://example.com">A custom link</a>
  </body>

However, at a W3C committee meeting in January, Apple indicated that they felt like this feature would likely generate many subtle problems. They do not want such problems to jeopardize the success of Custom Elements v1.0, and have argued that it should be excluded from the Custom Elements specification for now. Google and others would like to see this feature remain. But without unanimous support, the feature's future is unclear, and we're reluctant to depend on it.

Option 3: Use the Shadow DOM polyfill just for elements with is attributes

The web component polyfills already support the is="" syntax, so in theory you could keep using the polyfill even in browsers where native Shadow DOM is available. But that feels weird for a couple of reasons. First, the polyfill won't load if native Shadow DOM is available, so you'd have to subvert that behavior. You'd have to keep just enough of the polyfill alive to handle just custom element instances using the is="" syntax. That doesn't sound like fun. And, second, if is="" isn't offically endorsed by all the browsers, it's future is somewhat uncertain, so it's seems somewhat risky to invest in it.

You could also try to manually reproduce what the Shadow DOM polyfill is doing, but that seems like an even worse answer. Your approach won't be standard even in name, and so you'll create a burden for people that want to use your component.

Option 4: Wrap a standard element

Since we think it's inadvisable to recreate standard elements from scratch (option 1 above), and are nervous about depending on a standard syntax in the near future (options 2 and 3), we want to explore other options under our control. The most straightforward alternative seems to be wrapping a standard element. The general idea is to create a custom element that exposes the same API as a standard element, but delegates all the work to a real instance of a standard element sitting inside the custom element's Shadow DOM subtree. This sort of works, but with some important caveats.

The process of wrapping a standard element is consistent enough across all standard element types that we can try to find a general solution. We've made our initial implementation available in the latest v0.7.3 release of Basic Web Components, in the form of a new base class called WrappedStandardElement. This component serves both as a base class for wrapped standard elements, and a class factory that generates such wrappers.

We've used this facility to refactor an existing component called basic-autosize-textarea (which wraps a standard textarea), and deliver a new component, basic-current-anchor. The latter wraps a standard anchor element to deliver the feature discussed above: the anchor marks itself as current if it points to the current page. You can view a simple demo.

The definition of basic-current-anchor wraps a standard anchor like this:

  // Wrap a standard anchor element.
  class CurrentAnchor extends WrappedStandardElement.wrap('a') {
    // Override the href property so we can do work when it changes.
    get href() {
      // We don't do any custom work here, but need to provide a getter so that
      // the setter below doesn't obscure the base getter.
      return super.href;
    }
    set href(value) {
      super.href = value;
      /* Do custom work here */
    }
  }
  document.registerElement('basic-current-anchor', CurrentAnchor);

The WrappedStandardElement.wrap('a') returns a new class that does several things:

  1. The class' createdCallback creates a Shadow DOM subtree that contains an instance of the standard element being wrapped. A runtime instance of <basic-current-anchor> will look like this:
      <basic-current-anchor>
        #shadow-root
          <a id="inner">
            <slot></slot>
          </a>
      </basic-current-anchor>
    Note that the inner <a> includes a <slot> element. This will render any content inside the <basic-current-anchor> inside the standard <a> element, which is what we want.
  2. All getter/setter properties in the API of the wrapped standard class are defined on the outer wrapper class and forwarded to the inner inner <a> element. Here, CurrentAnchor will end up exposing HTMLAnchorElement properties like href and forwarding those to the inner anchor. Such forwarded properties can be overridden, as shown above, to augment the standard behavior with custom behavior. Our CurrentAnchor class overrides href above so that, if the href is changed at runtime, the link updates its own visual appearance.
  3. Certain events defined by standard elements will be re-raised across the Shadow DOM boundary. The Shadow DOM spec defines a list of events that will not bubble up across a Shadow DOM boundary. For example, if you wrap a standard <textarea>, the change event on the textarea will not bubble up outside the custom element wrapper. That's an issue for components like basic-autosize-textarea. Since Shadow DOM normally swallows change inside a shadow subtree, someone using basic-autosize-textarea wouldn't be able to listen to change events coming from the inner textarea. To fix that, WrappedStandardElement automatically wires up event listeners for such events on the inner standard element. When those events happen, the custom element will re-raise those events in the light DOM world. This lets users of basic-autosize-textarea listen to change events as expected.

Because this approach uses a real instance of the standard element in question, many aspects of the standard element's behavior work as normal for free. For example, an instance of <basic-current-anchor> will exhibit all the appearance and behavior of a standard <a> described above. That includes mouse behavior, status bar behavior, keyboard behavior, accessibility behavior, etc. That's a huge relief!

But this approach has one significant limitation: styling. Because our custom element isn't called "a", CSS rules that apply to a elements will no longer work. Link pseudo classes like :visited won't work either. Worse, because there's essentially no meaningful standard styling solution for web components that works across the polyfilled browsers, it's not clear how to provide a good styling solution.

Things will become a little easier when CSS Variables are implemented everywhere, but even that is a sub-optimal solution to styling a wrapped standard element. For one thing, you would need to separately define new CSS variables for every attribute someone might want to style. That includes inventing variables to replace standard CSS pseudo-classes. Next, someone using your wrapped element would need to duplicate all the styling rules to use both the standard attributes and your custom CSS variables. That mess gets worse with each wrapped standard element added to a project, since each will likely to define different (or, worse, conflicting) variable names.

For the time being, we're trying a different solution, which is to define the interesting CSS attributes on a wrapped element using the CSS inherit value. E.g., a <basic-current-anchor> element currently has internal styling for the inner standard anchor that effectively does this:

  <style>
  a {
    color: inherit;
    text-decoration: inherit;
  }
  </style>

What that means is that the inner anchor will have no color or text decoration (underline) by default. Instead, it will pick up whatever color or text-decoration is applied to the outer custom element. That's fairly close to what we want, but still not ideal. If someone neglects to specify a color, for example, they'll end up with links that are (most likely) black instead of the expected blue.

In practice, we may be able to live with that. The typical use case for our basic-current-anchor component, for example, is in navigation elements like toolbars, where web applications nearly always provide custom link styling that overrides the standard colors anyway. That said, styling represents a significant complication in this wrapping approach, and should be carefully considered if trying this.

Wrapping up

It would obviously be preferable for the Custom Elements specification to address the extension of standard elements when that becomes possible. But we're pragmatic, and would rather see Custom Elements v1.0 ship without is="" support if that means it comes sooner — as long as the problem is eventually solved correctly. Until then, wrapping a standard element may provide a stopgap solution to create a custom element extending standard behavior. It's not ideal, but may be sufficient for common cases.

This is a complex area, and we could easily be overlooking things in our analysis. If you have thoughts on this topic, or know of an issue not discussed here, please give us a heads up!

Tweet

« Blog home