DEV Community

Cover image for table: make the columns swappable
Gohomewho
Gohomewho

Posted on

table: make the columns swappable

In the previous tutorial, we make a table that can take any kind of data and display them. Displaying information is the most important thing. But the order of the columns is fixed. It won't be a problem until our audiences grow and their opinions conflict where we can't just change the order manually.

In this tutorial, we will use addEventListener to make the columns swappable. So people can move the columns to any order they want. If you are not familiar with addEventListener, you can checkout out my verbose article on the topic.

Here is the code from the previous tutorial.

To make the columns swappable, we need to know which two targets to swap. It's not complex if you think about it. Every time we move a column, we are swapping its position with either its closest left or right column. The first target will be the column element we want to move, and the second target will be either its left or right element. With this approach, we could move columns around.

To get the first target, we can addEventListener to each column elements. we can also use the event delegation and addEventListener to their parent. We are going to use event delegation.

We created column elements inside createTableHead function, we can addEventListener to their parent tr. We choose 'pointerdown' over 'click' event so our code will work well with both touch and mouse devices. You can learn more about pointer events on JavaScript.info.

function createTableHead(columns) {
  const thead = document.createElement('thead')
  const tr = document.createElement('tr')

  columns.forEach(columnName => {
    const th = document.createElement('th')
    th.textContent = columnName
    tr.appendChild(th)
  });

  // `addEventListener` to column elements' parent 
  tr.addEventListener('pointerdown', e => {
    const firstTarget = e.target
    console.log(firstTarget)
  })

  thead.appendChild(tr)
  return thead
}
Enter fullscreen mode Exit fullscreen mode

Because of event propagation, e.target can be any element inside tr. It's not quite obvious because currently we don't have any style and other elements columns, so clicking on a column always gives us a column element. Since we want to move column elements, we need to make sure we get the correct column elements but not their descendants. We can check if e.target is in the tr.children.

// ...

  tr.addEventListener('pointerdown', e => {
    // `tr.children` gives us a HTMLCollection not an array
    // but we can convert it to an array like this `[...tr.children]`
    // so we can use `.indexOf()`
    const columnElements = [...tr.children]
    const firstTarget = e.target
    // `.indexOf()` returns `-1` if it can't find the item
    const firstTargetIndex = columnElements.indexOf(firstTarget)

    // if `e.target` is not a column element
    // we want to early return and do nothing
    if (firstTargetIndex === -1)
      return

  })

// ...
Enter fullscreen mode Exit fullscreen mode

We have the first target. Now we want to get the second target when moving. Add a 'pointermove' event listener to tr and get the second target. It's similar as the steps above.

  tr.addEventListener('pointerdown', e => {
    const columnElements = [...tr.children]
    const firstTarget = e.target
    const firstTargetIndex = columnElements.indexOf(firstTarget)

    if (firstTargetIndex === -1)
      return

    // add a pointermove event listener here 
    tr.addEventListener('pointermove', e2 => {
      const secondTarget = e2.target
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return
    })
  })
Enter fullscreen mode Exit fullscreen mode

After we have both first target and second target, We can start writing the logic to move them. We can use their indexes as a clue to indicate we are moving a column to left or right.

// this is inside tr.addEventListener('pointerdown', e => {
// ...

    tr.addEventListener('pointermove', e2 => {
      const secondTarget = e2.target
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return

      // if `firstTarget` and `secondTarget` are the same element
      // we want to early return and do nothing
      if (firstTarget === secondTarget)
        return

      // if firstTargetIndex > secondTargetIndex
      // it means the second target is on the left side of first target
      // it also means that we want to move the first target to left
      const isMoveToLeft = firstTargetIndex > secondTargetIndex
      // same logic apply to moving to right
      const isMoveToRight = firstTargetIndex < secondTargetIndex

      if (isMoveToLeft) {
        // insert `firstTarget` before `secondTarget`
        // which moves `firstTarget` to the left side of `secondTarget`
        secondTarget.insertAdjacentElement('beforebegin', firstTarget)
      } else if (isMoveToRight) {
        // insert `firstTarget` after `secondTarget`
        // which moves `firstTarget` to the right side of `secondTarget`
        secondTarget.insertAdjacentElement('afterend', firstTarget)
      }
//...
Enter fullscreen mode Exit fullscreen mode

We can move columns now, but it never stops. We need to stop it at some point. We can do that by adding a 'pointerup' event listener to tr and remove the 'pointermove'.

  tr.addEventListener('pointerdown', e => {
    //...
    tr.addEventListener('pointermove', e2 => {
      //...
    })

    // We want to stop the 'pointermove' when 'pointerup'
    tr.addEventListener('pointerup', e3 => {

    })
  })
Enter fullscreen mode Exit fullscreen mode

The callback function inside 'pointermove' is inline. We have to extract the function out in order to reference it at removeEventListener.

  tr.addEventListener('pointerdown', e => {
    //...

    // extract the inline function from 'pointermove' to here
    function handleMove(e) {
      const secondTarget = e.target
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return

      if (firstTarget === secondTarget)
        return

      const isMoveToLeft = firstTargetIndex > secondTargetIndex
      const isMoveToRight = firstTargetIndex < secondTargetIndex

      if (isMoveToLeft) {
        secondTarget.insertAdjacentElement('beforebegin', firstTarget)
      } else if (isMoveToRight) {
        secondTarget.insertAdjacentElement('afterend', firstTarget)
      }
    }

    // use the extracted function `handleMove` on 'pointermove'
    tr.addEventListener('pointermove', handleMove)

    // stop moving on 'pointerup'
    tr.addEventListener('pointerup', () => {
      // remove the 'pointermove' event listener on `tr`
      // which references the function `handleMove`
      tr.removeEventListener('pointermove', handleMove)
    })
  })
Enter fullscreen mode Exit fullscreen mode

We add other event listeners 'pointermove' and 'pointerup' to tr on 'pointerdown'. We clean up 'pointermove' on 'pointerup', but we didn't clean up 'pointerup'. This means that every time we 'pointerdown', we add an extra 'pointerup' event listener. We can make 'pointerup' only fire once.

//...
    tr.addEventListener('pointerup', () => {
      tr.removeEventListener('pointermove', handleMove)
      // add configuration { once: true }
    }, { once: true })
//...
Enter fullscreen mode Exit fullscreen mode

You can add a console.log('hi') to 'pointerup' and click on a column many times to see what's the difference without { once: true }.

Now we can start moving and stop moving. If you've followed the tutorial, you might notice that the moving isn't quite right. You are correct! And we are going to fix it.

The problem is that we didn't update some variables after a move.

  tr.addEventListener('pointerdown', e => {
    // `tr.children` is a HTMLCollection, it can automatically update
    // but we convert it to an array, it becomes a fix order
    // we need to update this array after a move
    // to get the correct index of elements
    const columnElements = [...tr.children]
    // the `firstTarget` is always the 'pointerdown' one
    const firstTarget = e.target
    // `firstTargetIndex` will change after a move
    // so we should update this
    const firstTargetIndex = columnElements.indexOf(firstTarget)

    if (firstTargetIndex === -1)
      return

    function handleMove(e) {
      // secondTarget will change
      // but we always get the latest secondTarget on `'pointermove'`
      // so this won't be a problem
      const secondTarget = e.target
      // getting `secondTargetIndex` has a problem
      // because we don't update `columnElements` after a move
      // so the index would be incorrect
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return

      if (firstTarget === secondTarget)
        return

      // indexes become incorrect
      // so `isMoveToLeft` and `isMoveToRight ` become incorrect
      const isMoveToLeft = firstTargetIndex > secondTargetIndex
      const isMoveToRight = firstTargetIndex < secondTargetIndex

      // These also become incorrect
      if (isMoveToLeft) {
        secondTarget.insertAdjacentElement('beforebegin', firstTarget)
      } else if (isMoveToRight) {
        secondTarget.insertAdjacentElement('afterend', firstTarget)
      }

      // so after a move
      // we need to update columnElements and firstTargetIndex 
    }

    tr.addEventListener('pointermove', handleMove)

    tr.addEventListener('pointerup', () => {
      tr.removeEventListener('pointermove', handleMove)
    }, { once: true })
  })
Enter fullscreen mode Exit fullscreen mode

Fix the problem.

  tr.addEventListener('pointerdown', e => {
    // change this to let
    let columnElements = [...tr.children]
    const firstTarget = e.target
    // change this to let
    let firstTargetIndex = columnElements.indexOf(firstTarget)

    if (firstTargetIndex === -1)
      return

    function handleMove(e) {
      const secondTarget = e.target
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return

      if (firstTarget === secondTarget)
        return

      const isMoveToLeft = firstTargetIndex > secondTargetIndex
      const isMoveToRight = firstTargetIndex < secondTargetIndex

      if (isMoveToLeft) {
        secondTarget.insertAdjacentElement('beforebegin', firstTarget)
      } else if (isMoveToRight) {
        secondTarget.insertAdjacentElement('afterend', firstTarget)
      }

      // update `columnElements` and `firstTargetIndex` after a move
      columnElements = [...tr.children]
      firstTargetIndex = columnElements.indexOf(firstTarget)
    }

    tr.addEventListener('pointermove', handleMove)

    tr.addEventListener('pointerup', () => {
      tr.removeEventListener('pointermove', handleMove)
    }, { once: true })
  })
Enter fullscreen mode Exit fullscreen mode

We should be able to move columns like this.
move table header columns

Great! Now we can move the columns in the table header, we should also apply that change to the table body, otherwise columns won't match the data!

How do we do that? If you think about it, the structure of the table header and a table row is very similar. They both have a container that wraps the column elements. So the logic to move the elements might be the same. Let's try it out. First, extract the logic of moving columns to a function.

    function handleMove(e) {
      const secondTarget = e.target
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return

      if (firstTarget === secondTarget)
        return

      // use the function
      swapColumns(tr, firstTargetIndex, secondTargetIndex)

      columnElements = [...tr.children]
      firstTargetIndex = columnElements.indexOf(firstTarget)
    }

    // make a function that can swap two columns
    function swapColumns(container, firstTargetIndex, secondTargetIndex) {
      // get column elements of the container
      const columns = container.children

      // get `firstTarget` and `secondTarget` with the indexes
      const firstTarget = columns[firstTargetIndex]
      const secondTarget = columns[secondTargetIndex]

      const isMoveToLeft = firstTargetIndex > secondTargetIndex
      const isMoveToRight = firstTargetIndex < secondTargetIndex

      if (isMoveToLeft) {
        secondTarget.insertAdjacentElement('beforebegin', firstTarget)
      } else if (isMoveToRight) {
        secondTarget.insertAdjacentElement('afterend', firstTarget)
      }
    }
Enter fullscreen mode Exit fullscreen mode

Note that we don't pass firstTarget and secondTarget directly to swapColumns. First, we can get them through container, firstTargetIndex, secondTargetIndex. Second, container, firstTargetIndex, secondTargetIndex is the common point between the table header and a table row.

Next, we can use the function swapColumns to update the table body rows.

function handleMove(e) {
  const secondTarget = e.target
  const secondTargetIndex = columnElements.indexOf(secondTarget)

  if (secondTargetIndex === -1)
    return

  if (firstTarget === secondTarget)
    return

  swapColumns(tr, firstTargetIndex, secondTargetIndex)

  // we don't have access to the rows 
  // so we get them here
  const rows = [...table.querySelectorAll('tbody > tr')]

  // update the rows
  rows.forEach((columnsContainer) => {
    swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
  })

  columnElements = [...tr.children]
  firstTargetIndex = columnElements.indexOf(firstTarget)
}
Enter fullscreen mode Exit fullscreen mode

Move table columns altogether.
move columns altogether

When we are moving columns, we don't intent to select text. I found that selection quite annoying, so let's disable it.

tr.addEventListener('pointerdown', e => {
  let columnElements = [...tr.children]
  const firstTarget = e.target
  let firstTargetIndex = columnElements.indexOf(firstTarget)

  // create a function so we can reference it
  function preventDefault(e) {
    e.preventDefault()
  }

  // disable text selection
  document.addEventListener('selectstart', preventDefault)

  if (firstTargetIndex === -1)
    return

  function handleMove(e) { 
    //...
  }

  function swapColumns(container, firstTargetIndex, secondTargetIndex) { 
    // ...
  }

  tr.addEventListener('pointermove', handleMove)

  tr.addEventListener('pointerup', () => {
    tr.removeEventListener('pointermove', handleMove)

    // remove the text selection disable
    // so we can still select text and copy the data 
    document.removeEventListener('selectstart', preventDefault)
  }, { once: true })
})
Enter fullscreen mode Exit fullscreen mode

We can still select text when we are not moving columns.
disable text selection

We also want to change the target we add 'pointerup' from tr to document, so we don't have to 'pointerup' in the tr.

//...
    // change `tr.addEventListener` to `document.addEventListener`
    document.addEventListener('pointerup', () => {
      tr.removeEventListener('pointermove', handleMove)
      document.removeEventListener('selectstart', preventDefault)
    }, { once: true })
//...
Enter fullscreen mode Exit fullscreen mode

Throughout this tutorial, we add the logic directly to tr in the createTableHead function. We extract the logic that moves columns to the function swapColumns, so we can update any other columns which is the most important part. This means that we can extract the whole logic, so it can work on any container that follows this structure.

Let's create a function makeColumnsSwappable to the bottom of the file and copy everything from 'pointerdown'. Rseplace all references from tr to columnsContainer, so it can work on any container.

function makeColumnsSwappable(columnsContainer) {
  // tr -> columnsContainer
  columnsContainer.addEventListener('pointerdown', e => {
    // tr -> columnsContainer
    let columnElements = [...columnsContainer.children]
    const firstTarget = e.target
    let firstTargetIndex = columnElements.indexOf(firstTarget)

    function preventDefault(e) {
      e.preventDefault()
    }

    document.addEventListener('selectstart', preventDefault)

    if (firstTargetIndex === -1)
      return

    function handleMove(e) {
      const secondTarget = e.target
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return

      if (firstTarget === secondTarget)
        return

      // tr -> columnsContainer
      swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)

      const rows = [...table.querySelectorAll('tbody > tr')]
      rows.forEach((columnsContainer) => {
        swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
      })

      // tr -> columnsContainer
      columnElements = [...columnsContainer.children]
      firstTargetIndex = columnElements.indexOf(firstTarget)
    }

    function swapColumns(container, firstTargetIndex, secondTargetIndex) {
      const columns = container.children
      const firstTarget = columns[firstTargetIndex]
      const secondTarget = columns[secondTargetIndex]
      const isMoveToLeft = firstTargetIndex > secondTargetIndex
      const isMoveToRight = firstTargetIndex < secondTargetIndex

      if (isMoveToLeft) {
        secondTarget.insertAdjacentElement('beforebegin', firstTarget)
      } else if (isMoveToRight) {
        secondTarget.insertAdjacentElement('afterend', firstTarget)
      }
    }

    // tr -> columnsContainer
    columnsContainer.addEventListener('pointermove', handleMove)

    document.addEventListener('pointerup', () => {
      // tr -> columnsContainer
      columnsContainer.removeEventListener('pointermove', handleMove)
      document.removeEventListener('selectstart', preventDefault)
    }, { once: true })
  })
}
Enter fullscreen mode Exit fullscreen mode

Use the makeColumnsSwappable function. It should works as before.

function createTableHead(columns) {
  const thead = document.createElement('thead')
  const tr = document.createElement('tr')

  columns.forEach(columnName => {
    const th = document.createElement('th')
    th.textContent = columnName
    tr.appendChild(th)
  });

  // use it here
  makeColumnsSwappable(tr)

  thead.appendChild(tr)
  return thead
}
Enter fullscreen mode Exit fullscreen mode

Since we extract the logic, makeColumnsSwappable doesn't have to be called inside createTableHead. Maybe we don't want every table to have the moving feature. We can use it as a plugin. Remove makeColumnsSwappable(tr) inside createTableHead and use it at where we created the table.

// This is where we created the table.
const table = createTable(nameOfDefaultColumns, users, columnFormatter)
document.body.appendChild(table)
// remember that the container of table header columns 
// is structured as thead > tr 
const columnsContainer = table.querySelector('thead').firstElementChild
// only make this table header columns to be swappable
makeColumnsSwappable(columnsContainer)
Enter fullscreen mode Exit fullscreen mode

There is one last thing we haven't handled in the makeColumnsSwappable function. We used to get the table body rows and update them. Now we can't do that anymore. We can't assume there are other columns to update. We need that information to be passed from outside.

// add a paremeter `elementsToPatch` and defaults to empty array
// so we can call .forEach() directly
function makeColumnsSwappable(columnsContainer, elementsToPatch = []) {
  // ...
    function handleMove(e) {
      // ...
      // change these
        const rows = [...table.querySelectorAll('tbody > tr')]
        rows.forEach((columnsContainer) => {
          swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
        })

      // to this 
      elementsToPatch.forEach((columnsContainer) => {
        swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
      })

      // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

makeColumnsSwappable by default only update the columnsContainer that it takes directly, unless elementsToPatch is specified.

Lastly, we need to pass elementsToPatch to makeColumnsSwappable to make the columns in the table body rows swap again.

const table = createTable(nameOfDefaultColumns, users, columnFormatter)
document.body.appendChild(table)
const columnsContainer = table.querySelector('thead').firstElementChild
// get `elementsToPatch`
const elementsToPatch = table.querySelectorAll('tbody > tr')
// pass `elementsToPatch` 
// if we comment out this line
// the table will remain the same as the previous tutorial
makeColumnsSwappable(columnsContainer, elementsToPatch)
Enter fullscreen mode Exit fullscreen mode

Our result
result gif

If you have noticed that sometimes two columns swap really fast and flash, that's a flaw in our current logic. I'll give you a clue if you want to fix it. We need to actually check the pointer is moving left or right rather than using the elements' indexes.

The code we make in this tutorial.

function makeColumnsSwappable(columnsContainer, elementsToPatch = []) {
  columnsContainer.addEventListener('pointerdown', e => {
    let columnElements = [...columnsContainer.children]
    const firstTarget = e.target
    let firstTargetIndex = columnElements.indexOf(firstTarget)

    function preventDefault(e) {
      e.preventDefault()
    }

    document.addEventListener('selectstart', preventDefault)

    if (firstTargetIndex === -1)
      return

    function handleMove(e) {
      const secondTarget = e.target
      const secondTargetIndex = columnElements.indexOf(secondTarget)

      if (secondTargetIndex === -1)
        return

      if (firstTarget === secondTarget)
        return

      swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)

      elementsToPatch.forEach((columnsContainer) => {
        swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
      })

      columnElements = [...columnsContainer.children]
      firstTargetIndex = columnElements.indexOf(firstTarget)
    }

    function swapColumns(container, firstTargetIndex, secondTargetIndex) {
      const columns = container.children
      const firstTarget = columns[firstTargetIndex]
      const secondTarget = columns[secondTargetIndex]
      const isMoveToLeft = firstTargetIndex > secondTargetIndex
      const isMoveToRight = firstTargetIndex < secondTargetIndex

      if (isMoveToLeft) {
        secondTarget.insertAdjacentElement('beforebegin', firstTarget)
      } else if (isMoveToRight) {
        secondTarget.insertAdjacentElement('afterend', firstTarget)
      }
    }

    columnsContainer.addEventListener('pointermove', handleMove)

    document.addEventListener('pointerup', () => {
      columnsContainer.removeEventListener('pointermove', handleMove)
      document.removeEventListener('selectstart', preventDefault)
    }, { once: true })
  })
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)