table: improve moving column UI & UX

gohomewho

Gohomewho

Posted on December 17, 2022

table: improve moving column UI & UX

We have made a table component and make the columns swappable. It works pretty well. But currently, we don't have any style for them. Someone who looks at the table probably won't know the columns can be moved. In this tutorial, we are going to improve the UI of our table, and we'll also improve some UX to make moving columns more natural.

The code we have so far.

First of all, let's add some style to make the table look more like a traditional table. Add a class to the table so we can add style to .my-table instead of every table.

main.js

const table = createTable(nameOfDefaultColumns, users, columnFormatter)
table.classList.add('my-table')
Enter fullscreen mode Exit fullscreen mode

style.css

.my-table,
.my-table th,
.my-table td {
  border: 1px solid lightgray;
  border-collapse: collapse;
  padding: 8px;
}
Enter fullscreen mode Exit fullscreen mode

The table looks much better.
styled table

Let's add some hints to users that columns can be interactive.
style.css

.my-table thead {
  transition: background-color .4s ease;
}

.my-table:hover thead {
  background-color: hsla(0, 0%, 87%, 0.5);
}

.my-table th {
  cursor: move;
}
Enter fullscreen mode Exit fullscreen mode

A subtle change on background-color tells users that there is something special in columns without drawing too much attention. We change cursor style when users hover on a column to give them a further hint.
hints for user that columns can be interactive

We can emphasize more on which column is being moved.

.my-table th:hover {
  background-color: hsla(0, 0%, 77%, 0.5);
}
Enter fullscreen mode Exit fullscreen mode

This doesn't look right. We are moving "website" column, but the "company" column sometimes got that emphasized style.
emphasized style not look rightThis is similar to an issue we had before. It's because the cursor is on top of the "company" column, so the hover style applies.

We don't need that hover style when we are moving columns. To do that, we need to change the selector. Because makeColumnsSwappable is a more specific feature, we can add classes to style it directly. That way when we use makeColumnsSwappable on something else, we will have a default style.

function makeColumnsSwappable(columnsContainer, elementsToPatch = []) {
  // add class to columnsContainer 
  columnsContainer.classList.add('columns-container')

  // remember that element.children is a HTMLCollection?
  // it does not have forEach method on it
  // so we use Array.from() to transform it to an array 
  // in order to use forEach to add class on each column
  Array.from(columnsContainer.children).forEach((column) => {
    column.classList.add('column')
  })

  // moving columns starts from here
  columnsContainer.addEventListener('pointerdown', e => {
    let lastCursorX = e.clientX
    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

    // add a class on `columnsContainer` and `firstTarget`
    // that indicates we are moving columns
    columnsContainer.classList.add('moving')
    firstTarget.classList.add('moving')

    // function handleMove ... 

    // function swapColumns ... 

    // I planned to show the difference between adding 'pointermove'
    // on `document` or `columnsContainer` , but I changed the 
    // target to `document` and write the rest of the article 
    // without noticing it. You can still try toggling between them 
    // throughout this tutorial to see what's the difference.
    document.addEventListener('pointermove', handleMove)

    document.addEventListener('pointerup', () => {
      document.removeEventListener('pointermove', handleMove)
      document.removeEventListener('selectstart', preventDefault)

      // clean up `moving` class after we stop moving columns
      columnsContainer.classList.remove('moving')
      firstTarget.classList.remove('moving')
    }, { once: true })
  })
}
Enter fullscreen mode Exit fullscreen mode

We remove the selector that style all columns hover state. We make a new selector that styles columns hover state when we are not moving. We make another selector that emphasizes the column that we are moving.

/* remove .my-table th:hover */

/* add these */
.columns-container:not(.moving) .column:hover {
  background-color: hsla(0, 0%, 77%, 0.5);
}

.column.moving {
  background-color: hsla(0, 0%, 60%, 0.5);
}
Enter fullscreen mode Exit fullscreen mode

Now we don't have that weird hover style when we are moving columns.
no weird hover style

Using selectors to style like this is a neat trick that when we have a list of items and they have different hover style and selected style. Instead of writing a common selector that styles all hover state and use another selector to overwrite the styles like this.

.item:hover {
  background-color: hsla(0, 0%, 77%, 0.5);
}

.item.selected {
  background-color: hsla(0, 0%, 60%, 0.5);
}
Enter fullscreen mode Exit fullscreen mode

We can use what we have just learned to make selectors that style specific things. This way we don't need to worry about specificity and the order of the selectors. Like in this example, if we flip the order of these two .item selectors, the result will be different. Because they both have (0, 2, 0) specificity, the one defined later wins.

Next, let's make moving column more interactive. We can make a ghost column to follow our cursor.

// columnsContainer.addEventListener('pointerdown', e => {
// ...
columnsContainer.classList.add('moving')
firstTarget.classList.add('moving')

function createGhost() {
  // clone the moving column
  const ghost = firstTarget.cloneNode(true)
  // make ghost appear at the cursor position
  ghost.style.position = 'fixed'
  ghost.style.left = e.clientX + 'px'
  ghost.style.top = e.clientY + 'px'
  document.body.appendChild(ghost)
  return ghost
}

const ghost = createGhost()

function handleMove(e) {
  // update ghost position when moving
  // so it follows our cursor
  ghost.style.left = e.clientX + 'px'
  ghost.style.top = e.clientY + 'px'
  // ...
}
Enter fullscreen mode Exit fullscreen mode

This looks more like we are really moving something. But the ghost doesn't look nice. It doesn't look like the column we clone!
a ghost column when moving

It is because that we don't have style any other than .column.moving that styles a column directly. The table columns styles are from .my-table. And the width of columns are automatically controlled by the browser. Apparently, .cloneNode(true) doesn't clone the style as what we would expect.

We can change selector and add styles to make column and ghost column look exactly the same, but this approach is only possible because we are authoring createTable and makeColumnsSwappable at the same time. We could make things work for this situation, but later when we use makeColumnsSwappable alone somewhere else. We will run into this problem again. So we need to think in a broader picture, a future proof solution.

At the moment writing, I don't know how to clone all styles from one element to another, so I google "clone all style" and find this excellent article written by Borislav Hadzhiev.

Let's use it in our code. Create a helpers.js.

// helpers.js
export function copyElementStyleToAnother(aElement, bElement) {
  const styles = window.getComputedStyle(aElement);

  let cssText = styles.cssText;

  if (!cssText) {
    cssText = Array.from(styles).reduce((str, property) => {
      return `${str}${property}:${styles.getPropertyValue(property)};`;
    }, '');
  }

  // 👇️ Assign css styles to element
  bElement.style.cssText = cssText;
}
Enter fullscreen mode Exit fullscreen mode

I already made our script type to module in index.html so we can use import, export statements.

<script src="main.js" type="module"></script>
Enter fullscreen mode Exit fullscreen mode

Import copyElementStyleToAnother at the top of main.js.

import { copyElementStyleToAnother } from "./helpers.js"
Enter fullscreen mode Exit fullscreen mode

Note that if you are following this tutorial using VSCode and its extension live server, typing "copyElementStyleToAnother" and chose to autocomplete will add that import statement for you, which is very convenient. But it doesn't include ".js" at the end, which break everything without showing any error!!! Again, I solve this by google. Just like every developer would do.

main.js

function createGhost() {
  const ghost = firstTarget.cloneNode(true)

  // copy `firstTarget` styles to `ghost`
  copyElementStyleToAnother(firstTarget, ghost)

  // overwrite these styles
  ghost.style.position = 'fixed'
  ghost.style.left = e.clientX + 'px'
  ghost.style.top = e.clientY + 'px'

  document.body.appendChild(ghost)
  return ghost
}
Enter fullscreen mode Exit fullscreen mode

Nice! Now ghost column looks exactly like the column we are moving.
ghost column looks exactly like the column we are moving.

But ghost column position is weird. It should overlap with the column when we just start moving. This is because how we set ghost column's position. We use cursor position e.clientX and e.clientY to set its left and top directly. We should also calculate the offset between cursor initial position and the column's edge, and take that into account.

To do that, we can use .getBoundingClientRect() to get an element 's position and use it to calculate the initial "pointerdown" offset.

// ...
columnsContainer.classList.add('moving')
firstTarget.classList.add('moving')

// calculate initial pointerdown offset to the column's edge
const firstTargetRect = firstTarget.getBoundingClientRect()
const pointerOffset = {
  x: firstTargetRect.x - e.clientX,
  y: firstTargetRect.y - e.clientY
}

function createGhost() {
  const ghost = firstTarget.cloneNode(true)
  copyElementStyleToAnother(firstTarget, ghost)
  ghost.style.position = 'fixed'

  // take that offset to calculate ghost column initial position
  ghost.style.left = e.clientX + pointerOffset.x + 'px'
  ghost.style.top = e.clientY + pointerOffset.y + 'px'

  document.body.appendChild(ghost)
  return ghost
}

const ghost = createGhost()

function handleMove(e) {
  // take that offset to calculate ghost column moving position
  ghost.style.left = e.clientX + pointerOffset.x + 'px'
  ghost.style.top = e.clientY + pointerOffset.y + 'px'
  //...
}
Enter fullscreen mode Exit fullscreen mode

This is looking much natural.
ghost column is looking much natural

Now we have a bug. Actually, it has been there sine we add ghost column, but the bug was not obvious previously. Swapping columns don't work properly. Can you guess why?

Swapping columns don't work properly

If not, I recommend you check out my addEventListener tutorial later. I don't have a straight answer to this in that tutorial, but I think it will help you understand how events work.

So the problem here is that after we add the ghost column. The "pointermove" event always happens on the ghost column. Our handleMove logic only allow the event target to be one of the columnElements, and ghost column is not one of the columnElements.

function handleMove(e) {
  // ...
  // after ghost column appears
  // e.target will always be the ghost column
  const secondTarget = e.target
  // since ghost column is not one of the `columnElements`
  const secondTargetIndex = columnElements.indexOf(secondTarget)

  // it can't pass this condition
  if (secondTargetIndex === -1)
    return

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

I'd like to add some explanation in case someone is confused why ghost column is not one of the columnElements. Ghost column is cloned from one column element. We only clone a element. We didn't add it to columnsContainer. columnElements are the children of columnsContainer. Ghost column is not a child of columnsContainer. So ghost column is not one of the columnElements.

Luckily, there is a very simple but powerful solution to this. There is a CSS property called pointer-events that can remove a element from pointer events mechanism.

function createGhost() {
  const ghost = firstTarget.cloneNode(true)
  copyElementStyleToAnother(firstTarget, ghost)
  ghost.style.position = 'fixed'

  // setting `pointer-events` to 'none '
  // is like removing this element from pointer events mechanism
  ghost.style.pointerEvents = 'none'

  ghost.style.left = e.clientX + pointerOffset.x + 'px'
  ghost.style.top = e.clientY + pointerOffset.y + 'px'

  document.body.appendChild(ghost)
  return ghost
}
Enter fullscreen mode Exit fullscreen mode

Setting pointer-events also works on "click" and "touch" events because they are both considered part of the pointer events.

Voilà! Swapping column works again.
swapping column works after setting pointer-events to none on ghost column


Exercise

I encourage you to google everything you don't know in this tutorial. For example, how to google to get a result that tells you getBoundingClientRect can get an element position? You don't have to process all the information you find. Knowing how to find something you need is enough. And You can reference them later when you find yourselves need more information.

💖 💪 🙅 🚩
gohomewho
Gohomewho

Posted on December 17, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related