DEV Community

Cover image for Form and function
Rowan Merewood
Rowan Merewood

Posted on

Form and function

This is the second in a series of posts detailing how I built the πŸ₯‘ Rotavo PWA. Give it a whirl and see what you can draw!

In the previous instalment we built the touch-controlled <input-knob> component that let us create a rotating knob with a value attribute corresponding to its angle. However, fun as that might have been it's still missing some functionality to be practical for general use.

Contents

♻️ Freshen up

First though, let's freshen up the previous demo with a few cosmetic improvements. I said we were going to build something that resembled a volume control knob on a stereo. Now, we got the rotating behaviour in there but our knob was square... traditionally they're round. You were all kind enough not to mention that, but we should fix that before we move on.

We also want an indicator for which way is "up" on our knob. As we saw in the final demo last time, we can just add any content we need inside the element. So we'll drop in a little triangle indicator there:

<input-knob value="2.5"><div class="mark">β–²</div></input-knob>

Enter fullscreen mode Exit fullscreen mode

We then need to style that so it's central:

.mark {
  display: inline-block;
  width: 100%;
  text-align: center;
  font: bold 200% monospace;
  color: #356211;
}
Enter fullscreen mode Exit fullscreen mode

The primary change on the control is using border-radius to round off those corners. We're also going to put the shadow on the outer input-knob element as that does not rotate. If we had the shadow on the inner ::part(container) it looks as if the shadow moves around the element instead, which is not the effect we're after.

input-knob {
  border-radius: 100%;
  box-shadow: 0 0.3rem 0.3rem rgba(0, 0, 0, 0.5);
}

input-knob::part(container) {
  box-sizing: border-box;
  background: #cadbbc;
  border: 1rem double #356211;
  border-bottom: 1rem solid #356211;
  border-radius: 100%;
  width: 8rem;
  height: 8rem;
}
Enter fullscreen mode Exit fullscreen mode

Rounded corners! The original CSS holy grail - revel in it.

There is also a little improvement we can make to the styling of :host on our Shadow DOM template as well. Specifically, we want the cursor to change into the little πŸ‘† pointer indicating to the user they can interact with it.

:host {
  display: inline-block;
  user-select: none;
  touch-action: none;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘‚ Quite the event

Now we've added a little polish to our element, we've got something visually pleasing but it's hard to react to any changes when the user interacts with the element. In the same way that we listen for Pointer events inside the element, we want to emit our own events in order to respond to them in the wider application.

Much like the Pointer events, we want to track the beginning, middle, and end of the interaction. That means we will create three Event types:

  • knob-move-start: when the element is touched / clicked
  • knob-move-change: when the element is moved
  • knob-move-end: when the element is released

We're going to emit these events at the end of each handler inside of the element, because we want to be sure that we've done all the necessary work inside the element before anything attempts to process the event.

// class InputKnob
_rotationStart() {
  // βœ‚οΈ existing code hidden      
  const evt = new Event('knob-move-start', { bubbles: true });
  this.dispatchEvent(evt);
}

_rotationChange() {
  // βœ‚οΈ existing code hidden    
  const evt = new Event('knob-move-change', { bubbles: true });
  this.dispatchEvent(evt);
}

_rotationEnd() {
  // βœ‚οΈ existing code hidden
  const evt = new Event('knob-move-end', { bubbles: true });
  this.dispatchEvent(evt);
}
Enter fullscreen mode Exit fullscreen mode

Note, we need to make sure we specify bubbles: true because our listener is going to be a parent element. You can try removing this and you'll see the event never "bubbles up" to the parent nodes.

With these events firing, we can listen for them just like any other:

document.addEventListener('knob-move-start', logEvent);
Enter fullscreen mode Exit fullscreen mode

Take a peek at the demo below to see how we're using the logEvent() function to light up some <span> elements when the events fire.

βš–οΈ Have some sense of proportion

Currently the value of the element maps directly to its angle. If we had a volume control that went from, say, 0 to 11 then that's what we want the value to match. Otherwise we're forcing our developer to do the conversion from the angle to the value themselves, which is just rude. To address this, we'll add a scale attribute where our developer can specify the value for a full rotation.

First, let's add that new attribute to the element. We want the usual attribute-to-property mirroring, however a little note - our default value is 1 as scale will be a multiplier and multiplying by 0 will always give us... well, 0 again. Let's drop that in:

// class InputKnob
static get observedAttributes() {
  return ['value', 'scale'];
}

get scale() {
  return this.hasAttribute('scale') ? this.getAttribute('scale') :1;
}

set scale(scale) {
  this.setAttribute('scale', scale);
}
Enter fullscreen mode Exit fullscreen mode

However now value and _angle are dependent on scale, so we have a bit of linking up to do. Whenever one of our attributes changes, we need to make sure we recalculate:

attributeChangedCallback(attrName, oldVal, newVal) {
  this._angle = (TWO_PI / this.scale) * (this.value % this.scale);
  this._drawState();
}
Enter fullscreen mode Exit fullscreen mode

So, if our scale is 10 and our value is 5, then that should be half a rotation on the knob - or an _angle of Ο€ - or pointing straight down.

The matching part is now when the _angle changes, we also need to update the value.

// _rotationChange()
this.value = this._angle / (TWO_PI / this.scale);
Enter fullscreen mode Exit fullscreen mode

So, to reverse what we had above if the angle comes out at Ο€ then we should expect a value of 5. That's... well, that's actually it for adding scale. So, you can verify that in the demo below. We've set the scale to 10, so ⬆️ = 0, ➑️ = 2.5, ⬇️ = 5, ⬅️ = 7.5. Give it a πŸ”ƒ below!

As a little bonus, take a peek at the CSS in this demo. The layout uses a CSS Grid layout with grid-template-areas where you basically draw a little text diagram of the layout you want. So, the arrangement of the items above is literally:

grid-template-areas: 
  ".  ⬆️ . "
  "⬅️ πŸŽ›οΈ ➑️"
  ".  ⬇️ . ";
Enter fullscreen mode Exit fullscreen mode

Would I recommend this in production? Who knows... I mean, I've seen far worse.

πŸ”’ Enforcing limits

While there's a certain whimsical freedom in being able to spin the knob infinitely, every so often we have a need to set some limits. A volume control would make no sense if it allowed values below zero, and if you can go higher than 11 - well, who knows what the consequences might be.

Let's set up some attributes to hold the minimum and maximum limits for the element, appropriately named min and max. This is, hopefully unsurprisingly, the same as the scale attribute we added earlier.

// class InputKnob
static get observedAttributes() {
  return ['value', 'scale', 'min', 'max'];
}

get min() {
  return this.hasAttribute('min') ? this.getAttribute('min') : null;
}

set min(min) {
  this.setAttribute('min', parseFloat(min));
}

get max() {
  return this.hasAttribute('max') ? this.getAttribute('max') : null;
}

set max(max) {
  this.setAttribute('max', parseFloat(max));
}
Enter fullscreen mode Exit fullscreen mode

The default value is null since we won't want to enforce the limit if it's not set. In other words, if the attribute is null: the limit does not exist

That means instead of just calculating and setting the _angle and value we need to check if they're within the bounds first. The calculations stay the same, we're just renaming to _attemptedAngle and _attemptedValue. Then we check to see if the limit is set that our attempted value is on the right side of it before we transfer the value over.

// _rotationChange()
this._attemptedAngle =
  this._initialAngle
  - this._initialTouchAngle
  + Math.atan2(this._touchY - this._centerY, this._touchX - this._centerX);
this._attemptedAngle = (this._attemptedAngle + TWO_PI) % TWO_PI;
this._attemptedValue = this._attemptedAngle / (TWO_PI / this.scale);

if (
  (this.min === null || this._attemptedValue >= this.min) &&
  (this.max === null || this._attemptedValue <= this.max)
) {
  this._angle = this._attemptedAngle;
  this.value = this._attemptedValue;
}
Enter fullscreen mode Exit fullscreen mode

With that logic in place, now we can add a knob that restricts its movement between two values:

<input-knob value="5" scale="10" min="2.5" max="7.5">
Enter fullscreen mode Exit fullscreen mode

Give it a go in the demo. Spin all you like, but those upper values are all off limits! β›”

πŸ§›β€β™‚οΈ Count rotations

If you're the type of engineer whose natural inclination is to immediately break the lovingly crafted code in front of you, then you may wondered, "what happens if max is higher than the scale?" Luckily, nothing breaks per se but it does make that max value a bit meaningless as we can never reach it. Well... unless we can count the number of rotations. For example, one complete turn gets us to 10, another complete turn gets us to 20, and so on. Think of it like a winch or a crank pulling a bucket out of a well - as you turn the crank, the rope winds in or out until it reaches the top or bottom.

We're not going to expose _rotations as an attribute since it's a result of value and scale. If we did have it there, we would need to introduce some confusing precedence rules about what happens if you set things that conflict and... eugh, I'm not touching that. Anyway, let's get that derived _rotations value initialised when the element is connected.

attributeChangedCallback(attrName, oldVal, newVal) {
  this._angle = (TWO_PI / this.scale) * (this.value % this.scale);
  this._rotations = Math.floor(this.value / this.scale);
  this._drawState();
}
Enter fullscreen mode Exit fullscreen mode

You can see the parallel to how the angle is set: _angle is the remainder (or modulus) of the value divided by the scale. The number of _rotations is the whole value (or the quotient) of the value divided by the scale. That's the pairing of a % b on the first lint and Math.floor(a / b) on the second.

To track when there's a change in rotation we are going to divide our element into four quadrants. A move between either of the top quadrants is going to count as a change in rotation.

A round element with quadrants overlaid. A circular path indicates a touch going from the top-left to the top-right quadrant resulting in an increment to the rotation.

Moving in or out of the lower quadrants is going to just be movement within the same rotation. This change means we need to track the previous angle so we have something to compare against when we calculate the new one.

The last bit to consider before we look at the code is that we now effectively have two modes of operation for our element. The first we've seen - spin the knob and once you go over the upper scale then you loop round to 0. However, now we're tracking rotations we've got a value that increases on every rotation. It's probably not a good idea to have a control that lets the user increase the value to infinity, so we should ensure that's bounded in some way. That means the check we're going to add is that we will only track _rotations if the min and max values are set. I know, I know - we're not currently validating those attributes at all... but, I've got to save some content for the next article!

Right, let's step through tracking that change in rotation:

// _rotationChange()
// Grab the previous angle for comparison
this._previousAttemptedAngle = this._attemptedAngle;
this._attemptedAngle = // βœ‚οΈ calculate attempted angle

// Track rotations if max and min are set
if (this.max !== null && this.min !== null) {
  // +1 rotation if:
  // new angle is in the top-right quadrant, e.g. < Β½Ο€
  // old angle is in the top-left quadrant, e.g. > 1Β½Ο€
  if (this._attemptedAngle < 1.57 && this._previousAttemptedAngle > 4.71) {
    this._attemptedRotations++;
  }
  // -1 rotation if:
  // old angle is in the top-right quadrant, e.g. < Β½Ο€
  // new angle is in the top-left quadrant, e.g. > 1Β½Ο€
  else if (this._previousAttemptedAngle < 1.57 && this._attemptedAngle > 4.71) {
    this._attemptedRotations--;
  }
}

// New value now includes the rotations
this._attemptedValue =
    (this._attemptedAngle / (TWO_PI / this.scale))
  + (this.scale * this._attemptedRotations);

// Update everything if the value is within bounds
if (
  (this.min === null || this._attemptedValue >= this.min) &&
  (this.max === null || this._attemptedValue <= this.max)
) {
  this._angle = this._attemptedAngle;
  this._rotations = this._attemptedRotations;
  this.value = this._attemptedValue;
}
Enter fullscreen mode Exit fullscreen mode

Demo time! Let's bring it full circle (aaay! πŸ₯) with our first demo where we had an <input type="range"> controlling the <input-knob>. Flip it back and reverse it, so we've got an <input-knob> controlling an <input type="range">.

We can set the tags up like so:

<input-knob value="50" scale="10" min="0" max="100">...</input-knob>
<input type="range" class="progress" min="0" max="100">
Enter fullscreen mode Exit fullscreen mode

Then using the same listener for knob-move-change we update those values:

// logEvent()
const curValue = Number.parseFloat(knob.value).toFixed(3);
showValue.textContent = curValue;
range.value = curValue;
Enter fullscreen mode Exit fullscreen mode

Now 10 rotations of the element should take you all the way from 0 to πŸ’―. Ideal place to wrap things up for this entry, I think.

Next time we are going to ensure our component is accessible, because while the touch input is fun - it's not an option for everyone.

🎁 Bonus content

Oh ho, I couldn't leave you without a little treat at the end now, could I? So, continuing the somewhat dubious tradition of borrowing my colleagues' faces (thank you / apologies to Jake Archibald) please feel free to discover what happens when you wind up this... "Jake in a box".

Top comments (0)