hsl-slider refactor
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:
- Each input’s
name
attribute is bound to the CSS custom property it can mutate. - 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.
- 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!
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