Quantcast
Channel: Blog | Object Partners
Viewing all articles
Browse latest Browse all 93

How to make a multi-handled range input

$
0
0

The HTML range input is a great way to allow your users to manipulate a numeric value using their mouse. The range input that is currently provided by browsers only supports a single handle, which limits the input to modeling a single value.

This is a problem if you are modeling data that is composed of two or more numbers, such as a price range with a minimum and maximum price. In situations like this, the browser’s range input is not sufficient and you will either need to use a library that provides a multi-handled range input or build your own.

This article will explain how to implement a multi-handled range input in a way that’s concise, easy to understand, and touch-screen friendly. The example code is written using React but the concepts should easily translate to any of the other modern JavaScript frameworks (Vue, Angular, Svelte).

If you are looking for a quick solution, please see the github repository for this project, which includes a working demo and the code needed to build a multi-handled range input.

This article will not be a step-by-step tutorial. It’s meant to act as a supplement to the repository and explain some of the more complicated inner workings, without covering every single line of CSS or JavaScript. The github repository includes implementation of more advanced features, such as handle collisions, coloring the rail between handles, and knobs that handles can “snap to”. Most of the logic for these features is located in the slider component and may be covered in a future article.

Definitions

Here are some important terms that are used throughout this article:

  • A slider is a more common way of saying range input. A slider is composed of one or more handles on a rail.
  • A handle is the part of a slider that the user clicks and drags to change a value.
  • A rail is the horizontal bar that represents the range of possible values that a handle can be dragged to.
  • A position refers to the percent distance from the left of a slider. A handle in the middle of the rail has a position of 50(%), and two knobs at the beginning and end of the slider’s rail have positions of 0 and 100.

Making the slider

Component data

The primary component is Slider.js. This component is responsible for managing all data via props and state. Its child components are only used to break up the rendering logic into smaller chunks. 

The Slider component receives the majority of its data from its parent via props, and maintains very little of its own state. Below is a brief explanation of the props and state this component uses. These will be explained in more detail later.

Props

  • values – An array of numbers, the length of this array will equal the number of handles on the slider.
  • min – The minimum value that a handle can be set to.
  • max – The maximum value that a handle can be set to.
  • onChange – Function that is be called when the values prop needs to change.

State / ref

  • activeHandleIndex – The index of the handle that is currently being clicked/dragged (if any).
  • this.ref – The slider needs to keep a reference to itself in order to access its width and position in pixels.

Making the rail

The first step in making the slider is to create the rail. This is the easiest step and involves only a bit of styling.

// Imported from Rail.js
const Rail = () => <div className="Slider_rail"> </div>;

class Slider extends React.Component {

    constructor(props) {
        super(props);
        this.ref = React.createRef();
        this.state = { activeHandleIndex: null };
    }

    render() {
        return (
            <div className="Slider" ref={this.ref}>
                <Rail />
            </div>
        );
    }

}
.Slider {
    display: flex;
    align-items: center;
    position: relative;
    width: 100%;
    touch-action: none;
}

.Slider_rail {
    width: 100%;
    height: 8px;
    background: var(--rail-default-color);
}

The flexbox properties in .Slider help center the rail (and the handles eventually) vertically within the slider. The slider and rail are designed to expand their widths to fill their parent container. If you want a 300px slider, simply place it in a div with width: 300px.

Placing handles on the rail

The next step is to render the slider’s handles in the correct positions according to the slider’s values prop. Recall that values is an array of numbers passed to the slider as a prop. Each number will be controllable by a handle in the slider.

In order to place handles on the rail, you need to convert the slider’s values to percentage-based positions (see definition of position above). These positions will then be used to set the left property of each handle to place it perfectly within the slider.

Basic positioning

The CSS for positioning a handle using a percent looks like this:

.Slider {
    display: flex;
    align-items: center;
    position: relative;
    width: 100%;
}

.Slider_handle {
    position: absolute;
    left: 25%;
    transform: translate(-50%, 0);
}

What this CSS is doing is absolutely positioning the handle within the slider. Absolute positioning allows you to position the handles within the slider using the left property, and without affecting the layout of any other elements inside the slider. In this context, the left property takes a percentage and places the handle’s left side exactly that distance down the slider. In this example, left is hardcoded to 25% but in practice that percentage value will be dynamically set to the handle component’s position prop.

There is a problem with this positioning strategy: you want the center of a handle to visually represent its value, but the left property places a handle according to its left side. This is where transform saves the day by shifting 50% of the handle’s total width to the left, effectively allowing left to position the element by its center. 

This positioning strategy can be used for anything that you want to place on your slider by a percentage position.

Converting values to positions

But how do you determine the percentage-based position of a handle from it’s value? It all comes down to a bit of math. 

Think of each handle’s value as being on a range from the slider’s minimum possible value to its maximum possible value. A handle’s position can be thought of as the percent distance from min to max its value is. For example, in a slider representing values from 0 to 100, a value of 25 is 25% of the way from 0 to 100. On a range from 25 to 125, the value 25 is 0% of the way from 25 to 125.

The algorithm to convert a value into a position looks like this:

const value = 100;
const min = 50;
const max = 150;

const position = ((value - min) / (max - min)) * 100;

This concept can be easily represented by two pure functions (one is a reversal of the transformation which you will need later for the dragging logic):

const valueToPosition = (value, min, max) => (value - min) / (max - min) * 100;

const positionToValue = (pos, min, max) => ((pos / 100) * (max - min)) + min;

To translate this into an actual React component, see the following code:

// Imported from Handle.js
const Handle = ({ position, ...props }) => (
    <div
        className="Slider_handle"
        style={{ left: `${position}%` }}
        {...props}
    > </div>
);

class Slider extends React.Component {

    // ...

    /**
     * The handle's positions are a percentage distance down the rail
     * (using css `left: X%`). Need to scale the values from a range of min/max
     * to 0/1 and multiply by 100 for a percent.
     * @returns {number[]}
     */
    get handlePositions() {
        const { values, min, max } = this.props;
        return values.map(val => valueToPosition(val, min, max));
    }

    render() {
        const handles = this.handlePositions.map((position, handleIndex) => (
            <Handle
                position={position}
                key={`handle_${handleIndex}`}
            />
        ));

        return (
            <div className="Slider" ref={this.ref}>
                <Rail />
                {handles}
            </div>
        );
    }

}

Updating values on drag

At this point your Slider component has a unidirectional data flow of the values prop. You can hardcode the values to anything and the handles will be positioned correctly. 

To make your  component interactive you need a way to update a handle’s value when it is dragged. This can be accomplished by calling an onChange prop with the modified values as its parameter whenever you detect a handle has been dragged.

Event listeners

Unfortunately there isn’t a single browser event you can use to capture this dragging behavior. Instead, you will need to use three events:

  1. mousedown / touchstart will set the handle that fired this event to be “active”.
  2. mousemove / touchmove will use the cursor’s x position to calculate a new value for the active handle, if it exists, and call onChange with updated values.
  3. mouseup / touchend will unset the active handle “releasing the drag”.

NOTE: From this point forward the article will refer to these events exclusively by their mouse variants. Any mention of the `mousedown` event implies touchstart.

Here you can see how the mousedown event works:

class Slider extends React.Component {

    // ...

    setActiveHandleIndex(handleIndex) {
        this.setState({ activeHandleIndex: handleIndex });
    }

    render() {
        const handles = this.handlePositions.map((position, handleIndex) => (
            <Handle
                position={position}
                onMouseDown={() => this.setActiveHandleIndex(handleIndex)}
                onTouchStart={() => this.setActiveHandleIndex(handleIndex)}
                key={`handle_${handleIndex}`}
            />
        ));

        // ...
    }

}

The mouseup and mousemove events have to be registered on the document because a user rarely keeps their mouse perfectly on the handle they are dragging. The handle will follow the mouse horizontally as it’s dragged, but it is possible for the mouse to move away from the handle vertically. If these events were registered directly to the handles and not the document, they would not fire in situations where the mouse has left the bounds of the dragged handle.

Here you can see how the mouseup event is registered:

class Slider extends React.Component {

    // ...

    componentDidMount() {
        document.addEventListener('mouseup', this.clickEndHandler);
    }

    componentWillUnmount() {
        document.removeEventListener('mouseup', this.clickEndHandler);
    }

    clickEndHandler = () => this.setActiveHandleIndex(null);

    // ...

}

With mouseup and mousedown setting and unsetting the active handle, the only thing left to do is to implement the changes to the slider’s values whenever the mouse moves.

Drag logic

Whenever mousemove is triggered, the event data will provide you with the cursor’s x/y coordinates in pixels. You can ignore the y value because the slider’s handles only move horizontally (this obviously changes if you are making a vertical slider). With the x coordinate, cursorX, do the following:

  1. Check the activeHandleIndex of the slider. If it’s not set you do nothing, if it is set move to step 2.
  2. Convert cursorX from a pixel value to a position. The math here is identical to the conversion of a value to a position, but you are using the slider’s left-bound and right-bound pixel coordinates instead of the slider’s minimum and maximum possible values. In other words, you are figuring out what percent distance from the slider’s left boundary to the slider’s right boundary cursorX is.
  3. Convert the newly calculated position into a value by inverting the equation you used for converting handle values into positions. If the calculated position is outside the range of 0 – 100, just default it to the min/max value.
  4. Replace this.props.values[activeHandleIndex] with your calculated value. It’s important to create a copy of `this.props.values` so as not to modify your component’s props directly.
  5. Call this.props.onChange with the updated copy of this.props.values.

Here is how the logic translates into the Slider component:

class Slider extends React.Component {

    // ...

    componentDidMount() {
        document.addEventListener('mouseup', this.clickEndHandler);
        document.addEventListener('mousemove', this.mouseMoveHandler);
    }

    componentWillUnmount() {
        document.removeEventListener('mouseup', this.clickEndHandler);
        document.removeEventListener('mousemove', this.mouseMoveHandler);
    }

    /**
     * Coordinates, in pixels, of the slider component in the DOM.
     * @returns {{top: number|null, left: number|null, bottom: number|null, right: number|null}}
     */
    get sliderCoordinates() {
        return this.ref.current !== null
            ? this.ref.current.getBoundingClientRect()
            : { left: null, right: null, top: null, bottom: null };
    }

    /**
     * The length of this slider component in pixels.
     * @returns {number}
     */
    get sliderPixelLength() {
        const { left, right } = this.sliderCoordinates;
        return right - left;
    }

    /**
     * Calculate the new value from a dragged handle's position and call the `onChange` prop.
     * @param {number} cursorX - the x position (in pixels) of the mouse
     */
    drag(cursorX) {
        const { activeHandleIndex } = this.state;

        if (activeHandleIndex !== null) {
            const {
                values,
                min,
                max,
                onChange
            } = this.props;

            // Convert the cursor's X position from pixels to a percentage of the slider's width
            const cursorPosition = (cursorX - this.sliderCoordinates.left) / this.sliderPixelLength * 100;

            let cursorValue;
            if (cursorPosition < 0) {
                // If slider goes below 0%, set it to min
                cursorValue = min;
            } else if (cursorPosition > 100) {
                // If slider exceeds 100%, set it to the max
                cursorValue = max;
            } else {
                // Convert from percentage-based position back to true value
                cursorValue = positionToValue(cursorPosition, min, max);
            }

            let newValues = [ ...values ];
            newValues[activeHandleIndex] = cursorValue;

            onChange(newValues);
        }
    }

    mouseMoveHandler = e => this.drag(e.pageX);

    // ...

}

A note on accessibility

Figuring out how to make a slider input accessible is a losing battle. This is a component that requires precise mouse control.

Instead, you should place text inputs alongside the slider to give the user multiple options for manipulating the values. Effort spent making these inputs accessible will go further than effort spent making the slider’s handles tabbable and screen-reader friendly. 

As an example, all values in the slider demo are controllable by both text input and the slider itself.

Wrapping up

The purpose of this article is to demonstrate one way of creating a multi-handled range input while empowering you to implement this strategy in your own context. If you are looking to enhance the slider beyond what is covered in this article, remember that the github repository and demo include implementation of more advanced features.


Viewing all articles
Browse latest Browse all 93

Trending Articles