table: improve moving column UI & UX
Gohomewho
Posted on December 17, 2022
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')
style.css
.my-table,
.my-table th,
.my-table td {
border: 1px solid lightgray;
border-collapse: collapse;
padding: 8px;
}
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;
}
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.
We can emphasize more on which column is being moved.
.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.
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.
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.
/* 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.
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);
}
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'
// ...
}
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!
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;
}
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>
Import copyElementStyleToAnother
at the top of main.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
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.
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'
//...
}
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?
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
// ...
}
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
}
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.
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.
Posted on December 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.