hsl-slider-refactor.txt

Tutorial

hsl-slider refactor

Try the working demo ↓

In my first article, I made a basic web component that can adjust my site’s 2-colour scheme by reading the custom properties assigned to :root and binding them to a ranged input.

For my first pass, I chose to declare the HSL slider UI imperatively.

const labelEl = document.createElement("label");
const inputEl = document.createElement("input");
inputEl.type = "range";

labelEl.appendChild(inputEl);
this.appendChild(labelEl);

And honestly, there was no specific reason why I chose this method, other than that it’s how I’ve been writing JavaScript-based interfaces (primarily in React) for the past 10 years, HTML is just the delivery mechanism for JavaScript bundles containing UI code. Even worse, my component’s features are very specific; it assumes that a website would want to adjust individual HSL values that have to be present in a CSS file.

But today, we’re going to undertake a brief refactor, where instead of using JavaScript to draw the UI DOM tree, we’ll instead just fallback to good old, reliable HTML and wrap the parts we want to make interactive in our refactored component, which (spoiler alert!) also won’t be restricted to just HSL now.

The Markup

To start, I’ll insert all those HTML elements I described in the original component as HTML, along with the refactored and renamed theme-control component.

<div class="hsl-container">
  <fieldset>
    <legend>Primary Colour</legend>
    <theme-control>
      <label for="primary-h">H</label>
      <os-range>
        <input
          type="range"
          id="primary-h"
          name="--theme-primary-hue"
          max="360"
          data-unit="deg"
        />
      </os-range>
      <div></div>
      <label for="primary-s">S</label>
      <os-range>
        <input
          type="range"
          id="primary-s"
          name="--theme-primary-saturation"
          data-unit="%"
        />
      </os-range>
      <div></div>
      <label for="primary-l">L</label>
      <os-range>
        <input
          type="range"
          id="primary-l"
          name="--theme-primary-lightness"
          data-unit="%"
        />
      </os-range>
      <div></div>
    </theme-control>
  </fieldset>
  <fieldset>
    <legend>Secondary Colour</legend>
    <theme-control>
      <label for="secondary-h">H</label>
      <os-range>
        <input
          type="range"
          id="secondary-h"
          name="--theme-secondary-hue"
          max="360"
          data-unit="deg"
        />
      </os-range>
      <div></div>
      <label for="secondary-s">S</label>
      <os-range>
        <input
          type="range"
          id="secondary-s"
          name="--theme-secondary-saturation"
          data-unit="%"
        />
      </os-range>
      <div></div>
      <label for="secondary-l">L</label>
      <os-range>
        <input
          type="range"
          id="secondary-l"
          name="--theme-secondary-lightness"
          data-unit="%"
        />
      </os-range>
      <div></div>
    </theme-control>
  </fieldset>
</div>

A couple of things to note here:

  1. Each input’s name attribute is bound to the CSS custom property it can mutate.
  2. I don’t love it, but each input declares the unit symbol that the updated CSS value will belong to, so I don’t have to parse all the possibilities in JS.
  3. I’ve deleted the color attribute from the component, since it’s no longer required to build the CSS property name dynamically; they’re all statically assigned by the HTML.

Updated constructor

Now I can refactor the theme-control’s constructor to simply query its children for all the input[type=range], read the corresponding CSS value bound to the input and assign it as the starting value, as well as the adjacent div’s text reflecting the live value.

 const style = getComputedStyle(this.stylesSource);
/** @type NodeListOf<HTMLInputElement> */
const inputs = this.querySelectorAll("input[type=range]");

inputs.forEach((inputEl) => {
  /** @type HTMLDivElement */
  const amountEl = inputEl.parentNode.nextElementSibling;
  const cssProp = style.getPropertyValue(inputEl.name);
  const unit = inputEl.getAttribute("data-unit") ?? "";
  // Basic sanitation to coerce the value into a number
  const cssPropValue = parseInt(cssProp.trim());

  // Assign the default value
  if (isNaN(cssPropValue)) {
    this.stylesSource.style.setProperty(cssPropValue, inputEl.value + unit);
  } else {
    inputEl.value = cssPropValue;
    // This is optional, used to trigger event listeners for presentational
    // components in Chromium/WebKit
    inputEl.dispatchEvent(
      new Event("input", {
        bubbles: true,
        cancelable: true,
      }),
    );
  }
  amountEl.textContent = cssPropValue + unit;
});

this.addEventListener("input", this);

There’s a lot less heavy lifting by the component now, it’s just responsible for keeping track of input changes and assigning the default values, but even then if there’s no match it can just defer to the HTML’s assigned value.

Event Handler

Handling the input event works basically the same, though again I’m doing less parsing and internally and instead just deferring to the HTML structure for the values I need.

handleChange(event) {
	/** @type HTMLDivElement */
	const amountEl = event.target.parentElement?.nextElementSibling;
	const unit = event.target.getAttribute("data-unit") ?? "";
	const value = event.target.value + unit;
	
	if (amountEl) {
	  amountEl.textContent = value;
	}
	this.stylesSource.style.setProperty(event.target.name, value);
}

Bonus: Theme anything

One other design flaw in my first pass was that the component was limited to only target the :root’s CSS properties. But what if the component needed to control a singular parent deeper in the DOM tree? To address this gap I added a stylesSource property to the ThemeControl class, which defaults to the document.documentElement, but if a source attribute is assigned to the theme-control component it’ll use that DOM node instead.

class ThemeControl extends HTMLElement {
  /** @type HTMLElement */
  stylesSource = document.documentElement;

  constructor() {
    super();
    if (this.hasAttribute("source") && this.getAttribute("source")) {
      this.stylesSource = this.closest(this.getAttribute("source"));
    }
    // ...
}

I’ve added a second dialog to this post that lets you control the RGB of its parent element. Have fun creating anarchy with my site’s theme!

Try the RGB demo ↓

An Aside

I’m pretty new to writing developer-centric content, or even content in general, but it’s been fun walking through a silly feature I wanted to add to my site, then iterating on it. I enjoy the process so far and want to continue to improve as I publish more tutorials and snippets

807 Words

Published

HTML Web Component Example

Adjust the sliders to change the website theme

Primary Colour
Secondary Colour

RGB example

Now it's really easy to make an RGB slider, and in this case is just scoped to the primary theme colour of this window.

Primary Colour