The Artima Developer Community
Sponsored Link

Web Buzz Forum
I wrote a Web Component

0 replies on 1 page.

Welcome Guest
  Sign In

Go back to the topic listing  Back to Topic List Click to reply to this topic  Reply to this Topic Click to search messages in this forum  Search Forum Click for a threaded view of the topic  Threaded View   
Previous Topic   Next Topic
Flat View: This topic has 0 replies on 1 page
Stuart Langridge

Posts: 2006
Nickname: aquarius
Registered: Sep, 2003

Stuart Langridge is a web, JavaScript, and Python hacker, and sometimes all three at once.
I wrote a Web Component Posted: Nov 12, 2017 6:00 AM
Reply to this message Reply

This post originated from an RSS feed registered with Web Buzz by Stuart Langridge.
Original Post: I wrote a Web Component
Feed Title: as days pass by
Feed URL: http://feeds.feedburner.com/kryogenix
Feed Description: scratched tallies on the prison wall
Latest Web Buzz Posts
Latest Web Buzz Posts by Stuart Langridge
Latest Posts From as days pass by

Advertisement

I’ve been meaning to play with Web Components for a little while now. After I saw Ben Nadel create a Twitter tweet progress indicator with Angular and Lucas Leandro did the same with Vue.js I thought, here’s a chance to experiment.

Web Components involve a whole bunch of different dovetailing specs; HTML imports, custom elements, shadow DOM, HTML templates. I didn’t want to have to use the HTML template and import stuff if I could avoid it, and pleasantly you actually don’t need it. Essentially, you can create a custom element named whatever-you-want and then just add <whatever-you-want someattr="somevalue">content here</whatever-you-want> elements to your page, and it all works. This is good.

To define a new type of element, you use window.customElements.define('your-element-name', YourClass).1 YourClass is an ES2016 JavaScript class. 2 So, we start like this:


window.customElements.define('twitter-circle-count', class extends HTMLElement {
});

The class has a constructor method which sets everything up. In our case, we’re going to create an SVG with two circles: the “indicator” (which is the one that changes colour and fills in as you add characters), and the “track” (which is the one that’s always present and shows where the line of the circle goes). Then we shrink and grow the “indicator” circle by using Jake Archibald’s dash-offset technique. This is all perfectly expressed by Ben Nadel’s diagram, which I hope he doesn’t mind me borrowing because it’s great.

So, we need to dynamically create an SVG. The SVG we want will look basically like this:


<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
  <circle cx="50" cy="50" r="45" 
  style="stroke: #9E9E9E"></circle>
  <circle cx="50" cy="50" r="45" 
  style="stroke: #333333)"></circle>
</svg>

Let’s set that SVG up in our element’s constructor:


window.customElements.define('twitter-circle-count', class extends HTMLElement {
  constructor() {
    /* You must call super() first in the constructor. */
    super();

    /* Create the SVG. Note that we need createElementNS, not createElement */
    var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("viewBox", "0 0 100 100");
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");

    /* Create the track. Note createElementNS. Note also that "this" refers to
       this element, so we've got a reference to it for later. */
    this.track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    this.track.setAttribute("cx", "50");
    this.track.setAttribute("cy", "50");
    this.track.setAttribute("r", "45");
    /* And create the indicator, by duplicating the track */
    this.indicator = this.track.cloneNode(true);

    svg.appendChild(this.track);
    svg.appendChild(this.indicator);
  }
});

Now we need to actually add that created SVG to the document. For that, we create a shadow root. This is basically a little separate HTML document, inside your element, which is isolated from the rest of the page. Styles set in the main page won’t apply to stuff in your component; styles set in your component won’t leak out to the rest of the page.3 This is easy with attachShadow, which returns you this shadow root, which you can then treat like a normal node:


window.customElements.define('twitter-circle-count', class extends HTMLElement {
  constructor() {
    super();
    var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("viewBox", "0 0 100 100");
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
    this.track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    this.track.setAttribute("cx", "50");
    this.track.setAttribute("cy", "50");
    this.track.setAttribute("r", "45");
    this.indicator = this.track.cloneNode(true);

    svg.appendChild(this.track);
    svg.appendChild(this.indicator);
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(svg);
  }
});

Now, we want to allow people to set the colours of our circles. The way to do this is with CSS custom properties. Basically, you can invent any new property name you like, as long as it’s prefixed with --. So we invent two: --track-color and --circle-color. We then set the two circles to be those colours by using CSS’s var() syntax; this lets us say “use this variable if it’s set, or use this default value if it isn’t”. So our user can style our element with twitter-circle-count { --track-color: #eee; } and it’ll work.

Annoyingly, it doesn’t seem to be easily possible to use existing CSS properties for this; there doesn’t seem to be a good way to have the standard property color set the circle colour.4 One has to use a custom variable even if there’s a “real” CSS property that would be appropriate. I’m hoping I’m wrong about this and there is a sensible way to do it that I just haven’t discovered.5


window.customElements.define('twitter-circle-count', class extends HTMLElement {
  constructor() {
    super();
    var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("viewBox", "0 0 100 100");
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
    this.track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    this.track.setAttribute("cx", "50");
    this.track.setAttribute("cy", "50");
    this.track.setAttribute("r", "45");
    this.indicator = this.track.cloneNode(true);
    this.track.style.stroke = "var(--track-color, #9E9E9E)";
    this.indicator.style.stroke = "var(--circle-color, #333333)";
    svg.appendChild(this.track);
    svg.appendChild(this.indicator);
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(svg);
  }
});

We want our little element to be inline-block. To set properties on the element itself, from inside the element, there is a special CSS selector, :host.6 Add a <style> element inside the component and it only applies to the component (this is special “scoped style” magic), and setting :host styles the root of your element:


window.customElements.define('twitter-circle-count', class extends HTMLElement {
  constructor() {
    super();
    var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("viewBox", "0 0 100 100");
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
    this.track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    this.track.setAttribute("cx", "50");
    this.track.setAttribute("cy", "50");
    this.track.setAttribute("r", "45");
    this.indicator = this.track.cloneNode(true);
    this.track.style.stroke = "var(--track-color, #9E9E9E)";
    this.indicator.style.stroke = "var(--circle-color, #333333)";
    svg.appendChild(this.track);
    svg.appendChild(this.indicator);
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(svg);
    var style = document.createElement("style");
    style.innerHTML = ":host { display: inline-block; position: relative; contain: content; }";
    shadowRoot.appendChild(style);
  }
});

Next, we need to be able to set the properties which define the value of the counter — how much progress it should show. Having value and max properties similar to an <input type="range"> seems logical here. For this, we define a little function setDashOffset which sets the stroke-dashoffset style on our indicator. We then call that function in two places. One is in connectedCallback, a method which is called when our custom element is first inserted into the document. The second is whenever our value or max attributes change. That gets set up by defining observedAttributes, which returns a list of attributes that we want to watch; whenever one of those attributes changes, attributeChangedCallback is called.


window.customElements.define('twitter-circle-count', class extends HTMLElement {
  static get observedAttributes() {
    return ['value', 'max'];
  }
  attributeChangedCallback(name, oldValue, newValue) {
    this.setDashOffset();
  }
  setDashOffset() {
    var mx = parseInt(this.getAttribute("max"), 10);
    if (isNaN(mx)) mx = 100;
    var value = parseInt(this.getAttribute("value"), 10);
    if (isNaN(value)) value = 0;
    this.indicator.style.strokeDashoffset = this.circumference - 
        (value * this.circumference / mx);
  }
  constructor() {
    super();
    var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("viewBox", "0 0 100 100");
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
    this.track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    this.track.setAttribute("cx", "50");
    this.track.setAttribute("cy", "50");
    this.track.setAttribute("r", "45");
    this.indicator = this.track.cloneNode(true);

    this.track.style.stroke = "var(--track-color, #9E9E9E)";
    this.indicator.style.stroke = "var(--circle-color, #333333)";
    /* We know what the circumference of our circle is. It doesn't matter
       how big the element is, because the SVG is always 100x100 in its own
       "internal coordinates": that's what the viewBox means. So the circle
       always has a 45px radius, and so its circumference is always the same,
       2πr. Store this for later. */
    this.circumference = 3.14 * (45 * 2);

    svg.appendChild(this.track);
    svg.appendChild(this.indicator);
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(svg);
    var style = document.createElement("style");
    style.innerHTML = ":host { display: inline-block; position: relative; contain: content; }";
    shadowRoot.appendChild(style);
  }
  connectedCallback() {
    this.setDashOffset();
  }
});

This works if the user of the component does counter.setAttribute("value", "50"), but it doesn’t make counter.value = 50 work, and it’s nice to provide these direct JavaScript APIs as well. For that we need to define a getter and a setter for each.


window.customElements.define('twitter-circle-count', class extends HTMLElement {
  static get observedAttributes() {
    return ['value', 'max'];
  }
  attributeChangedCallback(name, oldValue, newValue) {
    this.setDashOffset();
  }
  setDashOffset() {
    var mx = parseInt(this.getAttribute("max"), 10);
    if (isNaN(mx)) mx = this.defaultMax;
    var value = parseInt(this.getAttribute("value"), 10);
    if (isNaN(value)) value = this.defaultValue;
    this.indicator.style.strokeDashoffset = this.circumference - (
        value * this.circumference / mx);
  }
  get value() {
    var value = this.getAttribute('value');
    if (isNaN(value)) return this.defaultValue;
    return value;
  }
  set value(value) { this.setAttribute("value", value); }
  get max() {
    var mx = this.getAttribute('max');
    if (isNaN(mx)) return this.defaultMax;
    return max;
  }
  set value(value) { this.setAttribute("value", value); }
  constructor() {
    super();
    var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("viewBox", "0 0 100 100");
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
    this.track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    this.track.setAttribute("cx", "50");
    this.track.setAttribute("cy", "50");
    this.track.setAttribute("r", "45");
    this.indicator = this.track.cloneNode(true);
    this.track.style.stroke = "var(--track-color, #9E9E9E)";
    this.indicator.style.stroke = "var(--circle-color, #333333)";
    this.circumference = 3.14 * (45 * 2);
    svg.appendChild(this.track);
    svg.appendChild(this.indicator);
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(svg);
    var style = document.createElement("style");
    style.innerHTML = ":host { display: inline-block; position: relative; contain: content; }";
    shadowRoot.appendChild(style);
    this.defaultValue = 50;
    this.defaultMax = 100;
  }
  connectedCallback() {
    this.setDashOffset();
  }
});

And that’s all we need. We can now create our twitter-circle-count element and hook it up to a textarea like this:

<twitter-circle-count value="0" max="280"></twitter-circle-count>
<p>Type in here</p>
<textarea rows=3 cols=40></textarea>
twitter-circle-count {
  width: 30px;
  height: 30px;
  --track-color: #ddd;
  --circle-color: #333;
  --text-color: #888;
}
document.querySelector("textarea").addEventListener("keyup", function() {
  document.querySelector("twitter-circle-count").setAttribute("value", this.value.length);
}, false);

and it works! I also added a text counter and a couple of other nicenesses, such as making the indicator animate to its position, and included a polyfill to add support in browsers that don’t have it.7

Here’s the counter:

Type some text in here:

  1. I relied for a lot of this understanding on Google’s web components documentation by Eric Bidelman.
  2. All this stuff is present already in Chrome; for other browsers you may need polyfills, and I’ll get to that later.
  3. Pedant posse: yes, it’s a bit more complicated than this. One step at a time.
  4. It would be possible to have color apply to our circle colour by monitoring changes to the element’s style, but that’s a nightmare.
  5. QML does this by setting “aliases”; in a component, you can say property alias foo: subelement.bar and setting foo on an instance of my component propagates through and sets bar on the subelement. This is a really good idea, and I wish Web Components did it somehow.
  6. Firefox doesn’t seem to support this yet, either :host or scoping styles so they don’t leak out of the component, so I’ve also set display:inline-block and position:relative on the twitter-circle-count selector in my normal CSS. This should be fixed soon.
  7. Mikeal Rogers has a really nice technique here for bundling your web component with a polyfill which is also worth considering.

Read: I wrote a Web Component

Topic: What If Previous Topic   Next Topic Topic: Telegram notifications for Jenkins builds

Sponsored Links



Google
  Web Artima.com   

Copyright © 1996-2019 Artima, Inc. All Rights Reserved. - Privacy Policy - Terms of Use