loading...
Cover image for Making a simple-icon web component

Making a simple-icon web component

btopro profile image Bryan Ollendyke ・8 min read

UPDATED based on discussion with Michael API even easier and now does retroactive hydration if iconset is registered AFTER the icon is requested in the page.

yarn add @lrnwebcomponents/simple-icon

Video version

In this article I will cover how I made the simple-icon, simple-iconset, and hax-simple-icon to start meeting our future icon web needs and did it as web components!

History

Polymer project made this eye opening element (for me anyway) called iron-icon. "iron" elements in Polymer were like raw earth minerals. we used them as the foundations to build off of.

Only problem with this is when you set something that low in your foundation, it's really hard to move off of. We've been in the web components only game for ~3 years and as we've moved off of PolymerElement onto HTMLElement or LitElement, it's not always something we can do easily because of the other elements we built on.

Reasons to move off of iron-icon

  • Methodology for iconset's loads ALL icons for that set
  • uses PolymerElement which is dated and LitElement is waaaay smaller / faster
  • We want to improve performance / only load what we need

In looking around and meeting up with castastrophe and mwcz at haxcamp, they showed me their take on it at Red Hat called pfe-icon.

Their icon...

  • Loads individual SVGs as needed
  • supports libraries of elements
  • allows pointing to a repo of elements (like a url base for where all the icons will live)
  • works off of their lightweight subclass

Michael has a VERY impressive and detailed write up explaining how pfe-icon/set work that you should definitely read!

I chose not to use their specific element but used many conventions from it and some from iron-icons as well.

simple-icon

Code in repo

/**
 * Copyright 2020 The Pennsylvania State University
 * @license Apache-2.0, see License.md for full text.
 */
import { html, css } from "lit-element/lit-element.js";
import { SimpleColors } from "@lrnwebcomponents/simple-colors/simple-colors.js";
import "./lib/simple-iconset.js";
/**
 * `simple-icon`
 * `Render an SVG based icon`
 *
 * @microcopy - language worth noting:
 *  -
 *
 * @demo demo/index.html
 * @element simple-icon
 */
class SimpleIcon extends SimpleColors {
  /**
   * This is a convention, not the standard
   */
  static get tag() {
    return "simple-icon";
  }
  static get styles() {
    return [
      ...super.styles,
      css`
        :host {
          display: inline-flex;
          align-items: center;
          justify-content: center;
          position: relative;
          vertical-align: middle;
          height: var(--simple-icon-height, 24px);
          width: var(--simple-icon-width, 24px);
        }
        feFlood {
          flood-color: var(--simple-colors-default-theme-accent-8, #000000);
        }
      `
    ];
  }
  // render function
  render() {
    return html`
      <svg xmlns="http://www.w3.org/2000/svg">
        <filter
          color-interpolation-filters="sRGB"
          x="0"
          y="0"
          height="100%"
          width="100%"
        >
          <feFlood result="COLOR" />
          <feComposite operator="in" in="COLOR" in2="SourceAlpha" />
        </filter>
        <image
          xlink:href=""
          width="100%"
          height="100%"
          focusable="false"
          preserveAspectRatio="xMidYMid meet"
        ></image>
      </svg>
    `;
  }

  // properties available to the custom element for data binding
  static get properties() {
    return {
      ...super.properties,
      src: {
        type: String
      },
      icon: {
        type: String
      }
    };
  }
  firstUpdated(changedProperties) {
    if (super.firstUpdated) {
      super.firstUpdated(changedProperties);
    }
    const randomId =
      "f-" +
      Math.random()
        .toString()
        .slice(2, 10);
    this.shadowRoot.querySelector("image").style.filter = `url(#${randomId})`;
    this.shadowRoot.querySelector("filter").setAttribute("id", randomId);
  }
  /**
   * Set the src by the icon property
   */
  setSrcByIcon(context) {
    this.src = window.SimpleIconset.requestAvailability().getIcon(
      this.icon,
      context
    );
    return this.src;
  }
  updated(changedProperties) {
    if (super.updated) {
      super.updated(changedProperties);
    }
    changedProperties.forEach((oldValue, propName) => {
      if (propName == "icon") {
        if (this[propName]) {
          this.setSrcByIcon(this);
        } else {
          this.src = null;
        }
      }
      if (propName == "src") {
        // look this up in the registry
        if (this[propName]) {
          this.shadowRoot
            .querySelector("image")
            .setAttribute("xlink:href", this[propName]);
        }
      }
    });
  }
}
customElements.define(SimpleIcon.tag, SimpleIcon);
export { SimpleIcon };

We have a colorizing base class called SimpleColors (ala NikkiMK magic) which manages our global color-set and supplies the properties accent-color and dark to allow for easily defining and leveraging a consistent API for colors across our elements.

This is built on LitElement, and we use both all over the place, so it seemed like a good fit.

Notable bits of code

  • When the icon attribute changes we use that to do a look up for the src
  • When the src changes, it sets the xlink:href attribute in our shadowRoot (thanks Red Hat!)
  • By setting flood-color (new attribute to me!) in CSS to --simple-colors-default-theme-accent-8 we can effectively pick up and toggle the color to the "8th hue" relative to writing <simple-icon accent-color="blue">
  • we ask the state manager / singleton (simple-iconset) for the icon and pass in the current Node for context (important later on)

Creating an "iconset"

code in repo

/**
 * Singleton to manage iconsets
 */
class SimpleIconset extends HTMLElement {
  static get tag() {
    return "simple-iconset";
  }
  constructor() {
    super();
    this.iconsets = {};
    this.needsHydrated = [];
  }
  /**
   * Iconsets are to register a namespace in either manner:
   * object notation: key name of the icon with a specific path to the file
   * {
   *   icon: iconLocation,
   *   icon2: iconLocation2
   * }
   * string notation: assumes icon name can be found at ${iconLocationBasePath}${iconname}.svg
   * iconLocationBasePath
   */
  registerIconset(name, icons = {}) {
    if (typeof icons === "object") {
      this.iconsets[name] = { ...icons };
    } else if (typeof icons === "string") {
      this.iconsets[name] = icons;
    }
    // try processing anything that might have missed previously
    if (this.needsHydrated.length > 0) {
      let list = [];
      this.needsHydrated.forEach((item, index) => {
        // set the src from interns of the icon, returns if it matched
        // which will then push it into the list to be removed from processing
        if (item.setSrcByIcon(this)) {
          list.push(index);
        }
      });
      // process in reverse order to avoid key splicing issues
      list.reverse().forEach(val => {
        this.needsHydrated.splice(val, 1);
      });
    }
  }
  /**
   * return the icon location on splitting the string on : for position in the object
   * if the icon doesn't exist, it sets a value for future updates in the event
   * that the library for the icon registers AFTER the request to visualize is made
   */
  getIcon(val, context) {
    let ary = val.split(":");
    if (ary.length == 2 && this.iconsets[ary[0]]) {
      if (this.iconsets[ary[0]][ary[1]]) {
        return this.iconsets[ary[0]][ary[1]];
      } else {
        return `${this.iconsets[ary[0]]}${ary[1]}.svg`;
      }
    }
    // if we get here we just missed on the icon hydrating which means
    // either it's an invalid icon OR the library to register the icons
    // location will import AFTER (possible microtiming early on)
    // also weird looking by context is either the element asking about
    // itself OR the the iconset state manager checking for hydration
    if (context != this) {
      this.needsHydrated.push(context);
    }
    return null;
  }
}
/**
 * helper function for iconset developers to resolve relative paths
 */
function pathResolver(base, path = "") {
  return pathFromUrl(decodeURIComponent(base)) + path;
}
// simple path from url
function pathFromUrl(url) {
  return url.substring(0, url.lastIndexOf("/") + 1);
}

customElements.define(SimpleIconset.tag, SimpleIconset);
export { SimpleIconset, pathResolver, pathFromUrl };

window.SimpleIconset = window.SimpleIconset || {};
/**
 * Checks to see if there is an instance available, and if not appends one
 */
window.SimpleIconset.requestAvailability = () => {
  if (window.SimpleIconset.instance == null) {
    window.SimpleIconset.instance = document.createElement("simple-iconset");
  }
  document.body.appendChild(window.SimpleIconset.instance);
  return window.SimpleIconset.instance;
};
// self request so that when this file is referenced it exists in the dom
window.SimpleIconset.requestAvailability();

The iconset we use a technique we've used elsewhere which is to create a singleton element. A singleton is a singular web component that we attach to the body and then any time someone needs something from this piece of micro-state, we request access to the 1 copy. This is an approach we leverage for colors, modal, tooltips, etc to ensure 1 copy exists no matter how many elements in a given DOM leverage it.

Notable features

  • VanillaJS for the win. While simple-icon has dependencies, the iconset manager doesn't
  • window.SimpleIconset.requestAvailability(); ensures that ANYONE that calls for this file will force the singleton to be appended to the body via document.body.appendChild(window.SimpleIconset.instance);. We also have this function return an instance of the element so the same function appends 1 copy of the tag to the document as well as returns the Node
  • calling this file will self append the singleton; a bizarre design pattern but one we appreciate from a simplicity stand point (helps w/ timing too to avoid race conditions). See the last line of the file.
  • Very simple registerIconset spreads the object to the key in question OR if it has been passed a string, sets the string. If we have anything in needsHydrated we test it to see if it now has a definition w/ the existing library
  • Very simple getIcon takes a value (so icon="library:name") and returns the path to the svg by either looking it up in the object OR if the library name exists as a key in the iconsets object by is a string, it generates the assumed path based on name of the icon
  • needsHydrated is an array of DOM Nodes that asked to by hydrated with an icon name yet missed. We keep track of these Nodes and then when a new icon library is registered, we test if we were able to find a definition. This helps with timing and ensures libraries that register AFTER the DOM has started doing its work still operate as expected

This is fundamentally different from iron-iconset in that iron-iconset has a large dependency tree just to allow for registration of icons. Lastly, we need to register icons so that we can bother to use this :).

hax-iconset

So hax-iconset is built using iron-iconset. You can see the old code here if you like. Let's look at how we register icons:
code from repo

import { pathResolver } from "@lrnwebcomponents/simple-icon/lib/simple-iconset.js";
window.SimpleIconset.requestAvailability().registerIconset(
  "hax",
  `${pathResolver(import.meta.url)}svgs/`
);

Reading this. We pull in a helper function to resolve a basePath or path relative to the directory currently open in the browser (import.meta.url is sorta magic in that way, giving you knowledge of where this file sits in the front end). We do this in part because of our unbundled builds methodology, however it helps us know where the svgs are as they are relative to this location (see them here in the github repo). It sets this string location with hax as the key name.

Now when someone calls <simple-icon icon="hax:remix" accent-color="orange" dark></simple-icon> they'll get a consistent and rapidly loaded icon. Michael's blog post lays out all the http2 performance benefits of streaming specific assets to users and then being able to cache them as well after the fact when reusing an icon multiple times.

Before and After

Here's iron-icon loading 1 icon:
iron-icon load time

And now here's simple-icon with loading 1 icon:
simple-icon load time
Yes, 65 requests down to 25 and a reduction of 148.8kb! The methodology in iron-iconset means that all 69 icons in our hax icon library are loaded to load any one icon! Now, if we load all 69 icons we still see a 39kb reduction but realistically only like 10 or so are going to be visible at any given time.

Discussion

pic
Editor guide