Native JavaScript Implements Sortable Tabs

Introduction

Recently I made a tab page, which requires support for dragging and sliding and reordering. It is a strong interactive control. The most common scenario is the worksheet tab button below the Excel table, which supports dragging and sorting.

Analysis

First, to achieve the drag effect, we use pointerdown, pointermove, pointerup on the Tab to listen for mouse or touch events.

Furthermore, when dragging a Tab label, the horizontal position of the Tab is updated in real time. We set the middle position of the current sliding Tab as the reference line. Once the reference line touches the edge of the Tab on both sides, it is considered that it needs to be connected with the Tab on both sides. Switch locations.

In detail, when clicking Tab, we copy the current Tab and put it in another layer of DOM in the same position as the Tab container, as the element that drags the real-time display position, and then hides the source Tab but keeps the placeholder, so that the overall Tab Containers are not affected by user interaction and will be cleaner. Before real-time dragging, we also need to record all tab positions, widths, edge positions and other information in advance, so that when dragging in real-time, the baseline position and the stored Tab position information are directly compared. That is, it can be judged whether it is necessary to exchange positions.

Code

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sortable Tabs</title>
    <style>
        * {
            margin: 0;
            padding: 0;
        }

        .container {
            display: flex;
            justify-content: center;
            align-items: center;

            height: 100vh;
        }

        .tabs {
            border: 1px solid #d3d3d3;
            user-select: none;
        }

        .tab {
            display: inline-block;
            border: 1px solid gray;
            padding: 10px 20px;

            touch-action: none;
        }

        .tab:hover {
            cursor: pointer;
        }

        .boxContainer {
            position: fixed;
            display: none;
        }

        .box {
            position: relative;
        }

        .active {
            background-color: #b2b2b2;
        }
    </style>
</head>

<body>

    <div class="container">
        <div class="tabs"><div class="tab active" style="width: 50px;">Tab1</div><div class="tab" style="width: 100px;">Tab2</div><div class="tab">Tab3</div><div class="tab">Tab4</div><div class="tab">Tab5</div></div>

        <div class="boxContainer">
        </div>
    </div>


    <script>

        let tabContainer = document.querySelector('.tabs')
        let tabs = document.querySelectorAll('.tab')
        let boxContainer = document.querySelector('.boxContainer')

        let tabsPos = []
        let boxMiddle = 0;

        collecPos();

        Array.from(tabs).forEach((tab, i) => {

            let x = 0;

            let moveX

            tab.ondragstart = () => false;

            tab.onpointerdown = (event) => {
                event.preventDefault(); // prevent selection start (browser action)

                x = event.clientX - tab.getBoundingClientRect().left;

                tab.setPointerCapture(event.pointerId);

                createMoveBox(tabContainer, tab)

                tab.onpointermove = (event) => {

                    const moveXDiff = event.clientX - moveX
                    moveX = event.clientX

                    let newLeft = event.clientX - x - tabContainer.getBoundingClientRect().left;

                    // if the pointer is out of slider => adjust left to be within the boundaries
                    if (newLeft < 0) {
                        newLeft = 0;
                    }
                    let rightEdge = tabContainer.offsetWidth - tab.offsetWidth;
                    if (newLeft > rightEdge) {
                        newLeft = rightEdge;
                    }

                    const box = boxContainer.querySelector('.tab')
                    box.style.left = newLeft + "px";

                    checkSwitch(tab, moveXDiff, box)
                }

                tab.onpointerup = (event) => {
                    tab.onpointermove = null;
                    tab.onpointerup = null;

                    boxContainer.style.display = 'none'
          
                    tab.style.visibility = 'visible'

                    reOrderTabs()

                };
            }

        })

        function collecPos() {
            tabsPos = []
            const listPos = Array.from(tabs).map((tab, i) => {
                const left = tab.getBoundingClientRect().left;

                return {
                    pos: left,
                    i: i,
                    tabRect: tab.getBoundingClientRect()
                }
            })

            listPos.sort((left, right) => left.pos - right.pos)

            listPos.reduce((left, right, i) => {
                const posInfos = {
                    pos: right.pos,
                    leftEle: {
                        i: left.i,
                        left: left.tabRect.left,
                        width: left.tabRect.width,
                        transformX: 0,
                    },
                    rightEle: {
                        i: right.i,
                        left: right.tabRect.left,
                        width: right.tabRect.width,
                        transformX: 0,
                    },
                }
                tabsPos.push(posInfos)
                return right
            })

        }

        function checkSwitch(tab, moveXDiff, box) {
            boxMiddle = box.getBoundingClientRect().left + (box.offsetWidth / 2)

            // When swiping right
            if (moveXDiff > 0) {
                for (let index = tabsPos.length - 1; index >= 0; index--) {
                    const posInfo = tabsPos[index];
                    if (boxMiddle - 5 > posInfo.pos) {
                        let tabLeft = tabs[posInfo.leftEle.i]
                        let tabRight = tabs[posInfo.rightEle.i]

                        const tabRectLeft = tab.getBoundingClientRect().left
                        const otherRectLeft = tabRectLeft === posInfo.leftEle.left ? posInfo.rightEle.left : posInfo.leftEle.left;

                        // The current tab must be on the left to support switching
                        if (tabRectLeft < otherRectLeft) {

                            changeTransform(posInfo)
                        }

                        return
                    }
                }
            }

            // When swiping left 
            else if (moveXDiff < 0) {
                for (let index = 0; index <= tabsPos.length - 1; index++) {
                    const posInfo = tabsPos[index];
                    if (boxMiddle + 5 < posInfo.pos) {
                        let tabLeft = tabs[posInfo.leftEle.i]
                        let tabRight = tabs[posInfo.rightEle.i]

                        const tabRectLeft = tab.getBoundingClientRect().left
                        const otherRectLeft = tabRectLeft === posInfo.leftEle.left ? posInfo.rightEle.left : posInfo.leftEle.left;

                        // The current tab must be on the right to support switching
                        if (tabRectLeft > otherRectLeft) {

                            changeTransform(posInfo)
                        }

                        return
                    }
                }
            }

        }

        function changeTransform(posInfo) {
            for (let index = 0; index < tabsPos.length; index++) {
                const element = tabsPos[index];

                let newLeftEle
                let newRightEle
                // swap places
                if (element.pos === posInfo.pos) {

                    const pos = posInfo.leftEle.left + posInfo.rightEle.width

                    newLeftEle = {
                        i: posInfo.rightEle.i,
                        width: posInfo.rightEle.width,
                        left: posInfo.leftEle.left,
                        transformX: posInfo.rightEle.transformX - posInfo.leftEle.width
                    }


                    newRightEle = {
                        i: posInfo.leftEle.i,
                        width: posInfo.leftEle.width,
                        left: pos,
                        transformX: posInfo.leftEle.transformX + posInfo.rightEle.width
                    }

                    posInfo = {
                        pos: pos,
                        leftEle: newLeftEle,
                        rightEle: newRightEle,
                    }

                    tabsPos[index] = Object.assign(element, posInfo)

                    // Update previous position tab
                    if (index !== 0) {
                        tabsPos[index - 1].rightEle = newLeftEle
                    }

                    // Update the next location tab
                    if (index !== tabsPos.length - 1) {
                        tabsPos[index + 1].leftEle = newRightEle
                    }

                    // Modify location
                    tabs[posInfo.leftEle.i].style.transform = `translateX(${posInfo.leftEle.transformX}px)`;
                    tabs[posInfo.rightEle.i].style.transform = `translateX(${posInfo.rightEle.transformX}px)`;

                    return
                }

            }

        }

        function createMoveBox(tabContainer, tab) {

            boxContainer.style.display = 'block'
            const tabContainerRect = tabContainer.getBoundingClientRect()
            const tabRect = tab.getBoundingClientRect()
            boxContainer.style.left = tabContainerRect.left + 'px'
            boxContainer.style.top = tabContainerRect.top + 'px'
            boxContainer.style.width = tabContainerRect.width + 'px'
            boxContainer.style.height = tabContainerRect.height + 'px'

            
            setActive(tab)

            const tabClone = tab.cloneNode(true)
            // tabClone.style.width = tab.offsetWidth + 'px';
            tabClone.style.left = tabRect.left - tabContainerRect.left + 'px';
            tabClone.classList.add('box')

            boxContainer.innerHTML = ''
            boxContainer.appendChild(tabClone)

            tab.style.visibility = 'hidden'

        }

        function reOrderTabs() {
            tabsPos.forEach((tabPos,index) => {

                const tab = tabs[tabPos.leftEle.i]
                tab.style.transform = 'translateX(0px)'
                tabContainer.appendChild(tab)

                if(index === tabsPos.length - 1){
                    const tabLast = tabs[tabPos.rightEle.i]
                    tabLast.style.transform = 'translateX(0px)'
                    tabContainer.appendChild(tabLast)
                }
            });

            collecPos()
        }

        function setActive(tab) {
            tab.classList.add('active')
            tabs.forEach((tabEle) => {
                if (tabEle !== tab) {
                    tabEle.classList.remove('active')
                }
            })
        }

    </script>
</body>

</html>

Conclusion

The above is the case of the tab page that supports sliding to switch positions as summarized by me. In general, there is no problem, but during the test, we found that if the sliding is too fast, there will still be a bug , please let me know if you know how to fix it, thanks a lot. And this DEMO is actually not perfect. It is best to support scrolling even when Tab overflows. This interested friend can try it.

Comments