Last active
April 15, 2020 01:52
-
-
Save thomaswilburn/5f103fc40753fd6fbc5bb2d4371c422e to your computer and use it in GitHub Desktop.
So you want to make a primary results component
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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