DEV Community

ndesmic
ndesmic

Posted on

How to make a rotational (knob) input with Web Components Part 2

This picks up from the code we wrote last time.

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:

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");
}
Enter fullscreen mode Exit fullscreen mode

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:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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);
}
Enter fullscreen mode Exit fullscreen mode

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).

const TWO_PI = Math.PI * 2;
function normalizeAngle(angle){
    if (angle < 0) {
        return TWO_PI - (Math.abs(angle) % TWO_PI);
    }
    return angle % TWO_PI;
}
Enter fullscreen mode Exit fullscreen mode

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.

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 });
}
Enter fullscreen mode Exit fullscreen mode

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:

onInputChange(mutationList){
    for(const mutation of mutationList){
        if(mutation.attributeName === "step"){
            this.updateSteps();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

//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;
}
Enter fullscreen mode Exit fullscreen mode

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.

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)
}
Enter fullscreen mode Exit fullscreen mode

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:

if(!this.tabIndex <= 0){
    this.tabIndex = 0;
}
Enter fullscreen mode Exit fullscreen mode

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:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

There's a new line in the middle that uses getClosest if steps are defined.

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]
}
Enter fullscreen mode Exit fullscreen mode

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

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.

Discussion (0)