table: improve moving column UI & UX
Tags: webdev, javascript, tutorial, css
Published: 2022-12-17 03:50:07

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](https://github.com/ga676005/table/tree/0d68bb4892efcb8d805869f1888a61675ffda7fc) 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**
```js
const table = createTable(nameOfDefaultColumns, users, columnFormatter)
table.classList.add('my-table')
```

**style.css**
```css
.my-table,
.my-table th,
.my-table td {
  border: 1px solid lightgray;
  border-collapse: collapse;
  padding: 8px;
}
```

The table looks much better.
![styled table](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/crse0f2y141hcoowe46o.png)



Let's add some hints to users that columns can be interactive. 
**style.css**
```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;
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yzbn68qt2vxrx0zu7hm2.gif)


We can emphasize more on which column is being moved. 
```css
.my-table th:hover {
  background-color: hsla(0, 0%, 77%, 0.5);
}
```

This doesn't look right. We are moving "website" column, but the "company" column sometimes got that emphasized style. 
![emphasized style not look right](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o4hsfx5z8oaf9iqkrky9.gif)This 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. 

```js
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 })
  })
}
```

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.
```css
/* 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);
}
```

Now we don't have that weird hover style when we are moving columns. 
![no weird hover style](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rfawjgje2z2jlkl7hoqh.gif)

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.
```css
.item:hover {
  background-color: hsla(0, 0%, 77%, 0.5);
}

.item.selected {
  background-color: hsla(0, 0%, 60%, 0.5);
}
```
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. 
```js
// 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'
  // ...
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q0an541x7t8bk38jo4hf.gif)

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](https://bobbyhadz.com/blog/javascript-copy-all-styles-from-one-element-to-another) written by Borislav Hadzhiev.

Let's use it in our code. Create a `helpers.js`.
```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;
}
```

I already made our script type to module in index.html so we can use `import`, `export` statements.
```html
<script src="main.js" type="module"></script>
```

Import `copyElementStyleToAnother` at the top of main.js.
```js
import { copyElementStyleToAnother } from "./helpers.js"
```
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
```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
}
```

Nice! Now ghost column looks exactly like the column we are moving.
![ghost column looks exactly like the column we are moving.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nb7jgxv58s4fhs3akagt.gif)

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.
```js
// ...
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'
  //...
}
```

This is looking much natural.
![ghost column is looking much natural](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m3llwox3zhshypv0ruee.gif)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qm8dijprcm1dmqb8v48r.gif)

If not, I recommend you check out my [addEventListener](https://dev.to/gohomewho/addeventlistener-3o6k) 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`.
```js
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

  // ...
}
```
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. 
```js
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
}
```
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 ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/15pn5shlmfwhrhd4wh91.gif)

---

##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.