DEV Community

Dominic Myers
Dominic Myers

Posted on • Originally published at drmsite.blogspot.com on

Practicality over Purity

By Joshua Stairhime via scopio
Image by Joshua Stairhime

It was Voltaire who said that perfect is the enemy of good; it was me in many interviews who answered, "I'm a perfectionist", whenever anyone asked if I had a weakness. That's not strictly true; it's just something I read years ago about what to answer when asked that question... to be honest, it's good advice and a darn sight better than saying, "Benign dictatorship" when asked about my management style (I still think I would have been brilliant in that role too).

Anyway, it wasn’t precisely Voltaire’s aphorism that prompted me to write this; it was more along the lines of grokking that sometimes, being a purist can get in the way of making a useful thing. Let me explain a little more. I’ve been playing with Web Components for a long time, long before they became as popular as they seem to be now, and whenever I’ve created them, I’ve been conscious that the best place for them would be on NPM. Once on NPM, they can be imported using skypack or unpkg and used wherever without downloading and hosting them; they should work (Indeed, whenever I demo them on CodePen, that’s what I do to check the mechanism works).

To ensure that as many people find them helpful as possible, I make them as perfect as possible, anticipate where they might be used, and make them as flexible as possible. Even in the case of input elements, I try to make them form-associated (though that’s been a massive issue in the past—thanks to a dearth of information on making elements form-associated). This has stopped me from creating and using them in a more bespoke manner up until recently.

Recently, I’ve been involved as a subject-matter-expert (due to suffering a specific condition) with consulting and testing a research tool. I was provided with the underlying questionnaire to be used in that research. We were informed that a development team had been tasked with converting that questionnaire into an online tool which would record answers over days, weeks and months, and I thought that would be a fun way of filling a weekend – to try converting it myself. I’d also been reading about Beer CSS, which aims to translate a modern UI into an HTML semantic standard, which also sounded like a fun tool to play with.

As I started developing the application, I noticed that there would be many repeated code blocks. Each daily question was repeated six times, and the weekly question was repeated fourteen times. Once I started coding it, I noticed the only difference between the questions was the specific language used and the options. Each question had a radio button to click for the value appropriate to the respondent. This was a perfect place to use a slot within a web component!

I started with the weekly question and copied the markup I’d already implemented:

<article class="large-padding">
  <i>lock_open</i>
  <p>
    <slot></slot>
  </p>
  <div class="grid d-grid-10">
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="0">
        <span class="bold">None</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="1">
        <span class="bold">A little bit</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="2">
        <span class="bold">Moderately</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="3">
        <span class="bold">Quite a bit</span>
      </label>
    </div>
    <div class="col s10 m5 l2">
      <label class="radio">
        <input type="radio"
               name="Domain-2-1"
               value="4">
        <span class="bold">Extreme</span>
      </label>
    </div>
  </div>
</article>

Enter fullscreen mode Exit fullscreen mode

You’ll no doubt appreciate the sheer amount of copying and pasting required to get fourteen of these on the page simultaneously, but this translated into the following template literal within the component:

`
<article class="large-padding">
  <i${this.#disabled ? ' class="tertiary-text"' : ""}>
    ${this.#locked || this.#disabled ? "lock" : "lock\_open"}
  </i>
  <p><slot></slot></p>
  <div class="grid d-grid-10">
    ${this.labels.map((label, i) => `
      <div class="col s10 m5 l2">
        <label class="radio">
          <input type="radio"
                 name="${this.name}"
                 value="${i}"
                 ${this.#value === i ? " checked" : ""}
                 ${this.#locked || this.#disabled ? " disabled" : ""} />
          <span class="bold">${label}</span>
        </label>
      </div>
    `).join("")}
  </div>
</article>
`
Enter fullscreen mode Exit fullscreen mode

It was much neater, especially as the constructor had the values hard-coded:

this.labels = [
  "None",
  "A little bit",
  "Moderately",
  "Quite a bit",
  "Extreme",
];

Enter fullscreen mode Exit fullscreen mode

The keen-eyed amongst you will notice that we have several private values, namely #value, #locked, and #disabled. That’s not to say we don’t expose these values; we can set the value, locked, and disabled attributes, which will update the private values using getters and setters. Further, as we’ve defined the component as being form-associated, when we set the attribute from inside the element, the containing form can be notified of the change by dispatching a change event.

This is the complete code (as always, I’m more than happy to have input into how it might be improved):

import { v4 as uuidv4 } from "https://cdn.skypack.dev/uuid";

class WCEasyQ extends HTMLElement {
  #value = null;
  #locked;
  #disabled;

  static get observedAttributes() {
    return ["value", "name", "locked", "disabled"];
  }
  static formAssociated = true;

  constructor() {
    super();
    this.labels = [
      "None",
      "A little bit",
      "Moderately",
      "Quite a bit",
      "Extreme",
    ];
    this.internals = this.attachInternals();
    this.shadow = this.attachShadow({
      mode: "closed",
      delegatesFocus: true,
    });
    this.name = uuidv4();
  }

  get css() {
    return `
      <style>
        @import url("https://cdn.jsdelivr.net/npm/beercss@3.6.0/dist/cdn/beer.min.css");
        .d-grid-10 {
          margin-block-start: 1rem;
          ---gap: 1rem;
          display: grid;
          grid-template-columns: repeat(
            10,
            calc(10% - var(---gap) + (var(---gap) / 10))
          );
          gap: var(---gap);
        }
        article {
          & i {
            &.tertiary-text {
              cursor: not-allowed !important;
            }
            &:first-child {
              position: absolute;
              top: 10px;
              right: 10px;
              cursor: pointer;
            }
          }
        }
      </style>
    `;
  }

  get html() {
    return `
      <article class="large-padding">
        <i${this.#disabled ? ' class="tertiary-text"' : ""}>
          ${this.#locked || this.#disabled ? "lock" : "lock\_open"}
        </i>
        <p><slot></slot></p>
        <div class="grid d-grid-10">
          ${this.labels.map((label, i) => `
            <div class="col s10 m5 l2">
              <label class="radio">
                <input type="radio"
                       name="${this.name}"
                       value="${i}"
                       ${this.#value === i ? " checked" : ""}
                       ${this.#locked || this.#disabled ? " disabled" : ""} />
                <span class="bold">${label}</span>
              </label>
            </div>
          `).join("")}
        </div>
      </article>
    `;
  }

  set value(value) {
    if (value !== null) {
      this.setAttribute("value", Number(value));
      this.#value = Number(value);
    } else {
      this.removeAttribute("value");
      this.#value = null;
    }
    this.internals.setFormValue(this.#value);
  }

  get value() {
    this.#value =
      this.hasAttribute("value") && this.getAttribute("value") !== null
        ? Number(this.getAttribute("value"))
        : null;
    return this.#value;
  }

  set name(value) {
    this.setAttribute("name", this.name);
  }

  get name() {
    return this.hasAttribute("name") && this.getAttribute("name") !== null
      ? this.getAttribute("name")
      : this.name;
  }

  set locked(value) {
    this.#locked = value;
    this.render();
  }

  get locked() {
    this.#locked = this.hasAttribute("locked");
    return this.#locked;
  }

  set disabled(value) {}

  get disabled() {
    this.#disabled = this.hasAttribute("disabled");
    return this.#disabled;
  }

  handleDisabled(value) {
    this.render();
  }

  render() {
    if (this.shadow) {
      this.shadow.removeEventListener("change", this.handleChange);
    }
    if (this.icon) {
      this.icon.removeEventListener("click", this.handleClick);
    }
    this.shadow.innerHTML = `${this.css}${this.html}`;
    this.icon = this.shadow.querySelector("i");
    this.inputs = this.shadow.querySelectorAll("input");
    this.shadow.addEventListener("change", this.handleChange.bind(this));
    this.icon.addEventListener("click", this.handleClick.bind(this));
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      if (name === "locked") {
        this.#locked = this.hasAttribute("locked");
      }
      if (name === "disabled") {
        this.#disabled = this.hasAttribute("disabled");
      }
      if (name === "value") {
        this.value = newValue ? Number(newValue) : null;
      }
      this.render();
    }
  }

  handleClick(event) {
    event.preventDefault();
    this.#locked = !this.#locked;
    this.render();
  }

  handleChange(event) {
    this.value = Number(event.target.value);
    this.dispatchEvent(
      new CustomEvent("change", {
        bubbles: true,
        composed: true,
      }),
    );
  }

  connectedCallback() {
    this.render();
  }
}

customElements.define("wc-easy-question", WCEasyQ);

Enter fullscreen mode Exit fullscreen mode

Except for the hardcoded label values, this is an inherently reusable component. Still, the next element—the daily question elements—was far more custom, not least because it was my first attempt at using a component as a table row. This is the markup I needed to produce:

<tr is="wc-easy-question-row"
    name="Domain-1-1"
    value="2">
  <th class="weight-normal vertical-align-bottom">
    1. Some <span class="bold">strong</span> and important question?
  </th>
  <td class="center-align">
    <label class="radio" title="Not limied at all">
      <input type="radio"
             name="Domain-1-1"
             value="0"
             class="middle center">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="A little limited">
      <input type="radio"
             name="Domain-1-1"
             value="1"
             class="middle center">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="Moderately limited">
      <input type="radio"
             name="Domain-1-1"
             value="2"
             class="middle center"
             checked="">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="Very limited">
      <input type="radio"
             name="Domain-1-1"
             value="3"
             class="middle center">
      <span></span>
    </label>
  </td>
  <td class="center-align">
    <label class="radio" title="Totally limited / unable to do">
      <input type="radio"
             name="Domain-1-1"
             value="4"
             class="middle center">
      <span></span>
    </label>
  </td>
</tr>

Enter fullscreen mode Exit fullscreen mode

As you can see, we’re extending the HTMLTableRowElement and making it form-associated. This is the whole implementation:

import { v4 as uuidv4 } from "https://cdn.skypack.dev/uuid";

class WCEasyQRow extends HTMLTableRowElement {
  #value = null;
  #disabled = false;

  static get observedAttributes() {
    return ["value", "name", "disabled"];
  }

  static formAssociated = true;

  constructor() {
    super();
    this.labels = [
      "Not limied at all",
      "A little limited",
      "Moderately limited",
      "Very limited",
      "Totally limited / unable to do",
    ];
    this.name = uuidv4();
  }

  render() {
    this.removeEventListener("change", this.handleChange);
    const tds = this.querySelectorAll("td");
    for (const td of tds) {
      td.remove();
    }
    this.insertAdjacentHTML("beforeend", this.html);
    this.addEventListener("change", this.handleChange);
  }

  get html() {
    return this.labels
      .map(
        (label, i) => `
          <td class="center-align">
            <label class="radio"
                   title="${label}">
              <input type="radio"
                     name="${this.name}"
                     value="${i}"
                     class="middle center"
                     ${this.#disabled ? "disabled" : ""}
                     ${this.#value === i ? "checked" : ""} />
              <span></span>
            </label>
          </td>`,
      )
      .join("");
  }

  set name(value) {
    this.setAttribute("name", this.name);
  }

  get name() {
    return this.hasAttribute("name") && this.getAttribute("name") !== null
      ? this.getAttribute("name")
      : this.name;
  }

  set disabled(value) {}

  get disabled() {
    this.#disabled = this.hasAttribute("disabled");
    return this.#disabled;
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      if (name === "value") {
        this.#value = newValue ? Number(newValue) : null;
        this.render();
      }
      if (name === "disabled") {
        this.#disabled = this.hasAttribute("disabled");
        this.render();
      }
    }
  }

  connectedCallback() {
    this.render();
  }

  set value(value) {
    this.#value = Number(value);
    this.setAttribute("value", this.#value);
  }

  get value() {
    return this.hasAttribute("value") && this.getAttribute("value") !== null
      ? Number(this.getAttribute("value"))
      : null;
  }

  handleChange(event) {
    this.value = Number(event.target.value);
    if (this.#value !== Number(event.target.value)) {
      this.dispatchEvent(new Event("change"));
    }
  }
}

customElements.define("wc-easy-question-row", WCEasyQRow, {
  extends: "tr",
});

Enter fullscreen mode Exit fullscreen mode

Creating these components didn’t save me much time over copying and pasting the relevant markup and changing the text and names of the radio inputs. Still, it did mean that should I discover an issue when creating the inputs, I only had to address the problem in one file for all the relevant inputs to be fixed simultaneously. And more importantly, it meant the classes would act as templates for future implementations. I’m not adding them to npm as they are only helpful to me, but I can think about abstracting the classes in the future so that they might be more flexible. The second example – the extended table row element – is unsuitable for reuse in any current project I’m working on, but it might be in the future.

Interestingly, I did have a minor issue with them due to the sheer number of moving parts. I originally had a render function that ran once when the component mounted. I then did all sorts of interesting internal DOM manipulation, but every so often, the elements would not reflect the changes. So every time something needs to change in the DOM, I re-render it, and things don’t mess up now.

My primary concern is the hard coding of the values, but I’ve read that passing complicated object data in an attribute is considered a bad thing, so I’m not sure how best to address that other than using slots. Sure, I could do complicated things like using JSON strings or Base64 encoded data, but that seems to be getting away from the spirit of web components. I’ve read about passing data using properties. Still, for this example, at least, the only hard-coded values are the labels for the inputs, and they stop the same, so I might as well leave them like that; making them properties might increase the utility of the classes and encourage internalisation and reuse.

Perhaps making them proper web components suitable for use by others and thus worthy of popping into NPM might be a job when I have a little time. Including the specific CSS for the first component might also be an attribute which would increase that component's utility.

But, going back to Voltaire, as you’ll doubtless clock from this rambling, while I embraced the less-than-perfect (no utility outside the specific project and no aim to upload to npm) in creating these two components, creating them meant that I could see how they might be made more perfect; I particularly enjoyed the whole locking mechanism, and this is something that I’d like to explore more, though with an appreciation that this might not be required elsewhere – perhaps the locking functionality needs to be made optional before I abstract the class further. You’ll also notice the inclusion of uuidv4 so that, should I forget to add the name when adding the component, the radio buttons will all share the same name, preventing more than one radio button from being checked at a time.

In this instance, I chose Practicality over Purity, and that's fine in my books... for now.

Top comments (0)