Supercharging number inputs
The number input type provides a nice way for to deal with numbers. You can set bounds with the min
and max
attributes and users can press up and down to go add or remove 1, or if you add the step
attribute, go up or down by a step. But what if we want to let the user go up or down with different step sizes?
Unfortunately, step doesn’t just dictate how much to add or remove, but also where the number gets clipped to. If you have an input with value 5 and step 10 and you press up, you don’t get 15 (5 + 10), but you get 10 (the nearest multiple of step).
So what if we want people to enter any number but also want to give people a way to increase by 10? Or even by 100 or by 0.1?
Thats what the Chromium and Polypane devtools let you do when editing numbers. In them you can hold shift to add or remove 10, cmd/ctrl to add or remove 100 and alt to add/remove 0.1.
With the upcoming rulers and guides in Polypane I wanted to make sure editing numbers for positioning rulers and sizing grids worked just like that.
How to build a better input type=number
Let’s go over the behavior first. When someone uses the arrow keys in the input field, we want the following to happen:
- If they press up or down, we want to add or subtract 1
- If they hold shift and press up or down, we want to add or subtract 10
- If they hold alt and press up or down, we want to add or subtract 0.1
- If they hold ctrl and press up or down, we want to add or subtract 100. On Mac, we want to use the cmd key for consistency.
- If the input is empty, we calculate from the
min
attribute value. - Any other interaction with the input should use the default behavior.
In short, I want it to work like this input:
My implementation
Here’s the full code and as you can see, it’s relatively terse, just around 20 lines of code.
const isMac = navigator.platform === 'MacIntel';
document.querySelector("input").addEventListener("keydown", e => {
if (['ArrowUp','ArrowDown'].includes(e.key)) {
e.preventDefault();
const currentValue = isNaN(parseFloat(e.target.value))
? parseFloat(e.target.getAttribute("min")) || 0
: parseFloat(e.target.value);
const direction = e.key === 'ArrowUp' ? 1 : -1;
const modifier = (isMac ? e.metaKey : e.ctrlKey) ? 100 : e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
const decimals = Math.max(
(currentValue.toString().split(".")[1] || "").length,
e.altKey ? 1 : 0
);
const newValue = currentValue + direction * modifier;
e.target.value = newValue.toFixed(decimals);
}
});
Some parts of this code are not immediately obvious so let’s go over the code above line by line so we understand what it’s doing and how we end up with the behavior we want.
const isMac = navigator.platform === 'MacIntel';
We start by declaring a variable that we need later. For Windows and Linux, ctrl
is the key we want to use but on Mac it’s more common to use cmd
. isMac
is a boolean we can use to determine which of these to use.
document.querySelector('input').addEventListener('keydown', e => {
...
}
Then we add an event listener to the input element. The event we want to listen to is the keydown
event, since it’s the event that tells us which key is pressed and also which modifier keys are pressed. The modifier keys we’re interested in are shift
, alt
, ctrl
and cmd
. That last one is called the metaKey
in code because it’s the cmd
key on Mac, but the windows
key on in Windows.
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
...
}
If a user types or moves the caret left or right we don’t want to do anything. We add an if-clause around our code so it’s only ran when the user presses up or down. When the user presses the up or down key, we call e.preventDefault()
. This will prevent the input from being updated, because we’ll be doing that ourselves instead.
const currentValue = isNaN(parseFloat(e.target.value))
? parseFloat(e.target.getAttribute("min")) || 0
: parseFloat(e.target.value);
You might think getting the value is as easy as getting e.target.value
, but unfortunately we have to do a little more work. e.target.value
is always a string, even for inputs with type number, so to do any math we need to convert it to a number. Because we need to be able to add/subtract 0.1, we need to use a float instead of an int.
But if the input is empty and we call parseFloat
it returns a NaN
value. Since we can’t add or subtract from NaN
, we need to test for that. If the input is empty, then we either get the minimum value if it’s there, or default to 0. The minimum value is also a string, so we also need to convert that.
If the min
attribute is undefined it becomes NaN
, and NaN || 0
resolves to 0, so we’re always good.
const direction = e.key === 'ArrowUp' ? 1 : -1;
We already know from the if clause that the user pressed up or down, now we check which of those two it was so we can determine whether we need to add or subtract. We store this as a +1 or a -1 in a “direction” variable, which we can use to multiply the step value with later.
const modifier = (isMac ? e.metaKey : e.ctrlKey) ?
100 :
e.shiftKey ?
10 :
e.altKey ?
0.1 :
1;
Then we figure out which modifier key is pressed. The event property can tell us that. e.shiftKey
, e.altKey
etc. will be true if that key was pressed down while we were pressing up or down.
We first check for the meta or control key, depending on whether we are on a mac or not, with (isMac ? e.metaKey : e.ctrlKey)
. If that’s true we’re going to add or subtract by a hundred. If the shift key is pressed instead, we add or subtract with 10 and if the alt key is pressed, by 0.1. If none of these keys are pressed, we add or subtract by 1, the “default” behavior.
const decimals = Math.max(
(currentValue.toString().split(".")[1] || "").length,
e.altKey ? 1 : 0
);
Things get a little tricky because we work with floats. Floats in JavaScript can give unexpected results due to rounding. Specifically if you add for example 0.1 and 0.2, the value you get is 0.30000000000000004. Now, fair enough, that’s kinda mostly sorta 0.3.
The amount of zero’s is too large to really matter when doing basic calculations, But 0.30000000000000004 wouldn’t look that great in our input. I just want it to say “0.3”. To achieve that we need to know what the maximum number of decimals before the calculation is, and that is either the amount of decimals in the current input, or 1 if the alt key is pressed, whichever of those two is larger. We store this value so we can use it later on.
const newValue = currentValue + direction * modifier;
This is the actual calculation. We know the current value, how much to add or subtract and if it needs to be added or subtracted.
We multiply the modifier (how much to add) with the direction (which is either +1 or -1) so that when we add it to the current value, it’s either added or subtracted.
At this point we have calculated the new value, but due to the potentially weird rounding I mentioned earlier, we can’t just set it to the new value as the input value, since it might have a lot of decimals. Instead, we use toFixed
with the number of decimals we found earlier:
e.target.value = newValue.toFixed(decimals);
And that’s all the code you need to get a supercharged number input.
This input lets people quickly increase or decrease the value, or precisely hone in on a number, depending on which modifier key someone presses.
If you make use of this I’d love to know. For me, I’m using this in Polypane in the element editor, and in the upcoming grids and guides overlays.