Making a simple-icon web component
Bryan Ollendyke
Posted on September 3, 2020
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
/**
* 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 thesrc
- When the
src
changes, it sets thexlink: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"
/**
* 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 inneedsHydrated
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:
And now here's simple-icon
with loading 1 icon:
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.
Posted on September 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.