How to make a rotational (knob) input with Web Components Part 2
Tags: webomponents, webdev, customelements, vanillajs
Published: 2020-12-22 01:03:50

This picks up from the code we wrote last time.

{% codepen https://codepen.io/ndesmic/pen/BaLKwmp %}

And to recap these are our requirements:

- User must be able to use mouse or touch to rotate input
- Rolls over at 360 degrees
- Arrow keys should increment a minimal number of degrees
- Input should fallback without web component support

I'm going to augment this a little more since I have a whole post to fill so we'll be adding:

- Mouse wheel should increment input
- User should be able to define minimum steps/snap points
- Input/Output with either be radians or degrees depending on `unit` attribute.

# Cleanup

Before we start we'll want to do a little cleanup.  First thing is to remove the `#isManipulating` property.  I thought I might want to use it but turns out I didn't.  You may if you plan to add style hooks for the manipulating state but it turned out that wasn't super useful for me.  Next, is to abstract some of the code to update the component because we'll need to use it elsewhere:

```js
updateValue(valueRad){
    const finalValue = (this.#unit === "rad" ? valueRad : radiansToDegrees(valueRad)).toFixed(this.#precision);
    const valueDeg  = radiansToDegrees(valueRad);
    this.dom.input.value = finalValue;
    this.dom.value.textContent = finalValue;
    this.dom.pointer.style = `transform: rotateZ(-${valueDeg}deg)`;
    fireEvent(this.dom.input, "input");
    fireEvent(this.dom.input, "change");
}
```
Nothing new, but internally *I'm going to represent the angle with radians* (bolded because this is important and it's easy to confuse) which is why I call it `valueRad`.

Then we can update `onPointerMove`:

```js
onPointerMove(e){
    const offsetX = e.clientX - this.#center.x;
    const offsetY = this.#center.y - e.clientY;  //y-coords flipped
    let rad;
    if (offsetX >= 0 && offsetY >= 0){ rad = Math.atan(offsetY / offsetX); }
    else if (offsetX < 0 && offsetY >= 0) { rad = (Math.PI / 2) + Math.atan(-offsetX / offsetY); }
    else if (offsetX < 0 && offsetY < 0) { rad = Math.PI + Math.atan(offsetY / offsetX); }
    else { rad = (3 * Math.PI / 2) + Math.atan(offsetX / -offsetY); }
    const deg = radiansToDegrees(rad);
    const finalValue = (this.#unit === "rad" ? rad : deg).toFixed(this.#precision);
    this.dom.pointer.style = `transform: rotateZ(-${deg}deg)`;
    this.dom.value.textContent = finalValue;
    if(this.#trigger === "manipulate"){
        this.updateValue(rad);
    } else {
        this.#currentValue = rad;
    }
}
```
We need to keep the manual updates to `this.dom.value.textContent` and `this.dom.pointer` because these should show the in progress manipulation as well, but `updateValue` will also be used for keyboard and mouse wheel which don't have the manipulation state.  You could also choose to decouple the events from the display updates too.

# Mouse Wheel

Aside from moving the mouse around the screen it might be even easier to just use the mouse wheel.  This is easily done with a `wheel` event:

```js
attachEvents(){
    //this.dom.svg.addEventListener("pointerdown", this.onPointerDown);
    this.addEventListener("wheel", this.onWheel);
}
onWheel(e){
    const delta = e.deltaY * (this.#unit === "rad" ? this.#stepAmount : degreesToRadians(this.#stepAmount)) / 100;
    const newValue = normalizeAngle(this.parse(this.dom.input.value || 0) + delta);
    this.updateValue(newValue)
}
parse(unparsedValue){
    const value = parseFloat(unparsedValue);
    return this.#unit === "rad" ? value : degreesToRadians(value);
}
```

The wheel event has multiple axes X, Y, and Z though only Y is actually common.  On a normal ratcheted wheel the increments are always `100` or `-100` depending on the direction.  On a smooth scrolling wheel they snap to those values as well.  The first line is to scale the amount, here we're saying that one mouse wheel "notch" is worth `this.#stepAmount` and this is the minimum that can be input.  Unfortunately, the radians vs degrees complicate matters a little bit.  Basically, the `unit` property will not just determine the display and output but also how to interpret the `stepAmount` as well.  Then we apply the converted value to the current value using the input as a source of truth.  Since the current value is always text we need to parse it but there is an edge-case when the input is first created as it will be empty string `""` so we handle that with a short-circuit `OR` (nullish coalescence doesn't work for empty string).

```js
const TWO_PI = Math.PI * 2;
function normalizeAngle(angle){
    if (angle < 0) {
        return TWO_PI - (Math.abs(angle) % TWO_PI);
    }
    return angle % TWO_PI;
}
```
Normalize angle is a good toolbox function when dealing with geometry.  Angle greater than `PI *2` (360 deg) are normalized back to a 360 degree scale as are angles that are negative.  Depending on what you are doing you may wish to do the normalization in degrees instead but since I said we're doing internal representation with radians I chose that.

# Getting the Step

I introduced `#stepAmount` but didn't say where it comes from.  There's a couple routes we could take.  We could take it from an attribute on the custom element.  In this case I'm still leaning on the input as a the source of truth so I take it from the standard `step` attribute on an input.  This leads to a problem though, what happens if the step changes?  We can listen to these changes by using a mutation observer.

```js
attachEvents(){
    this.dom.svg.addEventListener("pointerdown", this.onPointerDown);
    this.addEventListener("wheel", this.onWheel);
    this.mutationObserver = new MutationObserver(this.onInputChange);
    this.mutationObserver.observe(this.dom.input, { attributes: true });
}
```
In `attachEvents` I add the observer.  What this does is listen for DOM changes.  We're only interested in attributes so I pass in `attributes: true` for the observe options and attach it to the input.  Here's the callback function:

```js
onInputChange(mutationList){
    for(const mutation of mutationList){
        if(mutation.attributeName === "step"){
            this.updateSteps();
        }
    }
}
```
For each change the observer gives back a list of things that changed.  Since we only told it to listen to attribute changes, we'll only get those.  We see if the attribute was the one we are interested in (`step`) and update the steps.

```js
updateSteps(){
    if(!this.dom.input.hasAttribute(step)){
        this.#steps = null;
        this.#stepAmount = 1;
    }
    this.#stepAmount = parseFloat(this.dom.input.getAttribute("step") || 1);
    const stepsAmountRad = this.#unit === "rad" ? this.#stepAmount : degreesToRadians(this.#stepAmount);
    this.#steps = getSteps(stepsAmountRad, TWO_PI);
}
```
Here we'll parse the attribute, note that it doesn't have defined unit, it's just based on what `#unit` holds.  We'll also generate a list of valid values which will be helpful later to define snap points.  The steps are internal and thus will always be radians so we might need to convert.

```js
//not a class method
function getSteps(step, end, start = 0) {
    const steps = [start];
    let current = start + step;
    while (current < end) {
        steps.push(current);
        current += step;
    }
    steps.push(end);
    return steps;
}
```
`getSteps` is a helper function you can add to your toolbox.  It just gets all the steps between to point including the ends.  It's also a 1-dimensional LERP (linear interpolation) if you think in those terms or want to generalize it.

# Arrow Keys

We're faced with a couple ways in which we can define the way keypresses work:

1) 1 press = 1 step
2) Holding down repeats steps
3) Holding down repeats steps with acceleration

The first is pretty straightforward, you press the key but until it's released it won't step again.  For #2 you choose a delay and then continue to advance steps.  For #3 you step, choose a delay and repeat but after a slightly longer delay you change to a bigger step amount.  I'm going to do #1 since it's the most straightforward but feel free to customize.  If you choose #3 it might be wise to specify a `big-step` attribute to determine the large step.  I'm also only going to use the up/down arrow keys for simplicity but you can also choose to include left/right and use those for `big-step` too.  If I ever do a part 3, I might include those.

```js
onKeydown(e){
    if(e.which !== 38 && e.which !== 40) return;
    const delta = (this.#unit === "rad" ? this.#stepAmount : degreesToRadians(this.#stepAmount)) * (e.which === 40 ? -1 : 1);
    const newValue = normalizeAngle(this.parse(this.dom.input.value || 0) + delta);
    this.updateValue(newValue)
}
```
Handling key down is nearly identical to using the mouse wheel.  The difference is that we filter out keys that are not up (38) or down (40) and also make the delta negative or positive depending on the key pressed.

But what does it mean to attach this keypress event?  It means we can get keypresses when it's the element with focus.  However if you've tried so far we cannot actually focus this element.  To fix this, at the very bottom of the `render` method I added this:

```js
if(!this.tabIndex <= 0){
    this.tabIndex = 0;
}
```
This will ensure it's focusable but doesn't define the order.  If the user set their own then we'll use that instead.

# Return to mouse manipulation

Point moving changes very little:

```js
onPointerMove(e){
    const offsetX = e.clientX - this.#center.x;
    const offsetY = this.#center.y - e.clientY;  //y-coords flipped
    let rad;
    if (offsetX >= 0 && offsetY >= 0){ rad = Math.atan(offsetY / offsetX); }
    else if (offsetX < 0 && offsetY >= 0) { rad = (Math.PI / 2) + Math.atan(-offsetX / offsetY); }
    else if (offsetX < 0 && offsetY < 0) { rad = Math.PI + Math.atan(offsetY / offsetX); }
    else { rad = (3 * Math.PI / 2) + Math.atan(offsetX / -offsetY); }
    
    rad = this.#steps === null ? rad : getClosest(rad, this.#steps); //this is new!
    const deg = radiansToDegrees(rad);
    const finalValue = (this.#unit === "rad" ? rad : deg).toFixed(this.#precision);
    this.dom.pointer.style = `transform: rotateZ(-${deg}deg)`;
    this.dom.value.textContent = finalValue;
    if(this.#trigger === "manipulate"){
        this.updateValue(rad);
    } else {
        this.#currentValue = rad;
    }
}
```
There's a new line in the middle that uses `getClosest` if steps are defined.

```js
export function getClosest(value, possibleValues) {
    let highIndex = possibleValues.length;
    let lowIndex = 0;
    let midIndex;

    while (lowIndex < highIndex) {
        midIndex = Math.floor((highIndex + lowIndex) / 2);
        if (value === possibleValues[midIndex]) return possibleValues[midIndex];
        if (value < possibleValues[midIndex]) {
            if (midIndex > 0 && value > possibleValues[midIndex - 1]) {
                return value - possibleValues[midIndex + 1] >= possibleValues[midIndex] - value
                    ? possibleValues[midIndex]
                    : possibleValues[midIndex - 1]
            }
            highIndex = midIndex;
        }
        else {
            if (midIndex < highIndex - 1 && value < possibleValues[midIndex + 1]) {
                return value - possibleValues[midIndex] >= possibleValues[midIndex + 1] - value
                    ? possibleValues[midIndex + 1]
                    : possibleValues[midIndex]
            }
            lowIndex = midIndex + 1;
        }
    }
    return possibleValues[midIndex]
}
```
This algorithm/coding whiteboard exercise (please don't), is an efficient way to get the closest value from a set of ordered values using binary search.  You can of course use a more naïve approach but I figure it might as well be done right if I'm going to post it.  Still, I needed a little bit of help to write it.

With this the mouse manipulation will snap to the step points and can't select values in-between.

# Demo

{% codepen https://codepen.io/ndesmic/pen/abmJppY %}

So one of the things we were after was making this control accessible.  Largely, we were successful as it has many different input abilities and can be used with a keyboard.  The problem is, it didn't really work for a screen reader I was using (Chrome + NVDA).  It seems that when the input is adopted by the rotational input, it stops being visible to the screen reader and the screen reader doesn't know how to handle the custom element. NVDA + Firefox works because Firefox chokes on the private fields (if you want this to work in Firefox, or any other of my components using private fields change the "#" to "_") and falls back to a normal numeric input, so at least that part works pretty nicely.  So, we'll have to address this somehow, at the moment I'm not sure the best way to go about it but I worry it may entail an overhaul of the decorated input strategy we've been using.