Skip to content

Instantly share code, notes, and snippets.

@thomaswilburn
Last active April 15, 2020 01:52
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomaswilburn/5f103fc40753fd6fbc5bb2d4371c422e to your computer and use it in GitHub Desktop.
Save thomaswilburn/5f103fc40753fd6fbc5bb2d4371c422e to your computer and use it in GitHub Desktop.
So you want to make a primary results component
var ElementBase = require("../elementBase.js");
var Retriever = require("../retriever.js");
/*
Let's make a web component the elections20-primaries way! We're going to start by
subclassing our base element, since it makes the setup a little more declarative.
You'll see how that works in a minute.
*/
class DemoElement extends ElementBase {
/*
The constructor is called whenever we (or the browser) creates one of these
elements. We can use this to set up local state for this particular element, in
case it needs to store any values.
Crucially, you _cannot_ do anything in the constructor that changes the inside
of the tag itself (such as appending children or using innerHTML). We'll save
that for later.
*/
constructor() {
// the constructor _must_ call super() first, which is basically saying "run the setup for the parent class (ElementBase)"
super();
// let's add a retriever that watches a data file
this.fetch = new Retriever(this.load);
}
/*
In the constructor, we passed this.load directly to the retriever. We want
that function to always have this element as `this`, even if it's called
from an event listener like that. By adding it to the static boundMethods()
getter, ElementBase will bind it permanently for us. We'll also bind some
event handlers, to simplify later code.
*/
static get boundMethods() {
return ["load", "onClick"]
}
/*
Similarly to the way we bound methods, we can also ask the browser to let us
know when attributes are added, changed, or removed, by specifying the static
observedAttributes() getter. Unlike boundMethods, which comes from our custom
base class, this is actually a part of the built-in custom elements API.
*/
static get observedAttributes() {
return ["src"];
}
/*
We probably want attributes to be mirrored as properties--it should be possible
to set the source two ways:
- element.setAttribute("src", "x");
- element.src = x;
Any element that inherits from ElementBase can have this behavior for free,
just by creating a static mirroredProps() getter:
*/
static get mirroredProps() {
return ["src"];
}
/*
And here's where we get alerted to attribute changes. This callback gets
three arguments: the name of the attribute, the old value, and the new
value. If the attribute was added, the old value will be null, and if it was
removed, the new value is null. Otherwise, it'll be a string (but possibly
an empty string, like "").
attributeChangedCallback() is not just called once the element is on the
page. It's also called during startup for any elements that the browser finds
while reading the document. If we have <demo-element src="x"></demo-element>
in our page, this will be called with `"src", null, "x"` when the element is
first detected, after the constructor runs.
*/
attributeChangedCallback(attr, oldValue, currentValue) {
// a switch is a nice way to handle this function, since it lets us block out changes and offer default behavior
switch (attr) {
case "src":
// when the src is set, start watching the file.
this.fetch.watch(value);
break;
}
}
/*
One last little bit of set-up. Again, we're not allowed to change the contents
of the element in the constructor. Our base element does try to smooth this
out, however, by providing a standard way to define its inner markup in a
two-step process. The first step is to provide a static template() getter,
which returns an HTML string (either inline, as here, or required from another
file).
*/
static get template() {
return `
<pre data-as="output"></pre>
<button data-as="clicker">Click me</button>
`;
}
/*
At this point, our element has probably gone through the following steps:
1. It was created, either in markup or via document.createElement("demo-element");
2. The browser called our constructor, which set up a Retriever to watch files
3. The browser saw our src attribute and called attributeChangedCallback so we start monitoring its URL
4. Having a URL, the retriever has performed a fetch() to get the JSON file
5. With that file loaded, it calls our load() method and passes in the data.
6. If the retriever detects changes to the file, it'll call load() again.
*/
load(data) {
// Now we'll fill our template into the element, using the illuminate() method (which is defined on the ElementBase)
var elements = this.illuminate();
// elements will contain a property for anything tagged with a data-as attribute.
// let's print out the JSON data we got from the retriever in our <pre> tag:
elements.output.innerHTML = JSON.stringify(data, null, 2);
}
/*
ElementBase.illuminate() is probably the oddest part of our setup. Used in the
simplest way, it's just for splatting a template into an element and saving you
a call to this.querySelector(). But you can also use it to safely run setup
code that depends on that template markup directly.
Why is it called illuminate()? Well, there are two kinds of DOM that are
available to custom elements. There's the light DOM, which is the standard HTML
content you're used to. And then there's the shadow DOM, which is "hidden" from
scripts on the page. For example, the playback controls in a <video> tag are
built using shadow DOM. Shadow DOM is not just isolated from JavaScript, it's
also unaffected by page styles, which is useful when you're building a UI
component and you don't want the page's fonts or colors to mess with your code.
And while you're not allowed to touch the contents of an element in its
constructor, you _can_ mess with its Shadow DOM.
We're not using shadow DOM because we don't need that isolation, and in fact it
would just make things more complicated. But that means we need a way of filling
the light DOM. Hence, illuminate().
illuminate() does two things: first, it will fill the element with the contents
of your template() getter, but only the first time it's called (so it's safe to
call it as many times as you want). Second, it returns an object containing all
the elements you tagged with a data-as attribute, so you can access them.
We can take advantage of the write-once behavior of illuminate() by overriding
the base class's method with our own, and using it to add event listeners to
the templated HTML elements.
*/
illuminate() {
// first, we still need to call the ElementBase.illuminate() method to inject our template and get elements back
var elements = super.illuminate();
// next, we can add listeners, like our bound onClick() method
// this code to add the listener will only run once, no matter how many times you call this.illuminate()
elements.clicker.addEventListener("click", this.onClick);
// finally, we still need to return our elements, so that methods like load() can use them
return elements;
}
onClick() {
// remember, this is bound, so `this` will always be the element itself
console.log("You clicked the button in ", this);
}
}
// Now we define the element as a tag
// The class method wraps this in a try-catch for us, which is nice.
DemoElement.define("demo-element");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment