table: improve moving column UI & UX (2)
Gohomewho
Posted on December 24, 2022
We added a ghost column representing the moving column previously. It always follows our cursor in the entire window although the swapping functionality only happens in the header section.
While there is nothing wrong about it, we can still improve this feature to make it easier for use.
The code we have so far.
We can limit the ghost within the header area which tells users that the boundary of moving. In the meantime, we don't want to force users to control their pointers to stay within the header area. It might sound complicated to do this, but in fact we already did most of the job. We just need to modify something.
Currently, we set ghost initial position to overlap the column inside createGhost
function, and we update ghost position in handleMove
function on pointer move.
makeColumnsSwappable.js
columnsContainer.addEventListener('pointerdown', e => {]
// ...
function createGhost() {
const ghost = firstTarget.cloneNode(true)
copyElementStyleToAnother(firstTarget, ghost)
ghost.style.position = 'fixed'
ghost.style.pointerEvents = 'none'
// we set ghost initial position to overlap the column
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) {
// we update ghost position on pointer move
ghost.style.left = e.clientX + pointerOffset.x + 'px'
ghost.style.top = e.clientY + pointerOffset.y + 'px'
// ...
}
// ...
}
Now we only want ghost column to stay in the header section. It should be able to move horizontally but not vertically. We have set ghost column initial x
and y
position correctly, which means that we only need to update its x
on pointer move. Let's remove the code that change its y
.
function handleMove(e) {
ghost.style.left = e.clientX + pointerOffset.x + 'px'
// this line change ghost vertical position
// ghost.style.top = e.clientY + pointerOffset.y + 'px'
// ...
}
Notice that now ghost column only moves within the header section even though our cursor doesn't stay precisely in the header section.
After this change, we would expect columns to swap when ghost column moves over another column. Currently, columns only swap if our cursor is in the header section. Because we use e.target
from "pointermove"
to get the secondTarget
and we made sure we only swap columns if secondTarget
is one of the columnElements
. So we need to change how we get the secondTarget
.
The logic is similar to how we move ghost column. We want to be able to move our cursor freely, but we use new x
with a locked y
to get the secondTarget
. This means that we need a way to use x
and y
to get an element, and there is an API for it, which is Document.elementFromPoint(). If you have never seen Document.elementFromPoint()
, I would encourage you to try to google what keywords you need to search to find this API. It is a good exercise that when you kind of know what to do but not exactly how to do.
We can get the locked y
from "pointerdown"
.
columnsContainer.addEventListener('pointerdown', e => {
// If `e.target` is able to get the correct `firstTarget`
// `lockedY` should be able to get `secondTarget`
// because the columns stay on the same horizontal level
const lockedY = e.clientY
// ...
// we a check here for `firstTarget`
if (firstTargetIndex === -1)
return
// ...
}
Change how we get secondTarget
from directly using e.target
from "pointermove"
to using the new e.clientX
with locked y
to get the target on that position.
function handleMove(e) {
// ...
const secondTarget = document.elementFromPoint(e.clientX, lockedY)
// ...
It should work like this. We can swap columns without making cursor stay in the header section.
To quickly recap, We successfully make this change by removing the code that update ghost column y
position, so ghost column stays on the same horizontal level.
// remove from `handleMove` function
ghost.style.top = e.clientY + pointerOffset.y + 'px'
We get lockedY
on "pointerdown"
, so we can use elementFromPoint
with this value to get a element from a point.
// added at the top of
// columnsContainer.addEventListener('pointerdown', e => {
const lockedY = e.clientY
Lastly, we change how we get secondTarget
.
// it was secondTarget = e.target
const secondTarget = document.elementFromPoint(e.clientX, lockedY)
It's not a surprise that with this small amount of change can make a huge difference. Because we have designed pretty clear how to swap columns by getting firstTarget
and secondTarget
. In this exmaple, we only change how we get the secondTarget
. The value of secondTarget
doesn't change, so everything should still work.
Currently, ghost column can move out of header section. It makes more sense to make ghost column only move within the area. To do that, we need to calculate the boundary that ghost column can move.
columnsContainer.addEventListener('pointerdown', e => {
// get the position information of columns container
const columnsContainerRect = columnsContainer.getBoundingClientRect()
// ...
function handleMove(e) {
const newCursorX = e.clientX
// new ghost `x` value
let newGhostX = newCursorX + pointerOffset.x
// if new `x` is over the left boundary of `columnsContainerRect`
if (newGhostX < columnsContainerRect.x) {
// we want to use the `x` value of `columnsContainerRect`
// so ghost column doesn't move pass the left boundary
newGhostX = columnsContainerRect.x
}
ghost.style.left = newGhostX + 'px'
// ...
}
}
Ghost column can no longer move pass the left boundary.
Next, let's deal with the right boundary. At first glance, we might want to calculate if the right edge of ghost column is over the right edge of columnsContainerRect
. It's intuitive to think that way, but that will require more work. Since we use ghost.style.left
to update ghost position, we can instead calculate a max value for left which can represents the limit that ghost column can move toward right.
Create a variable ghostXBoundary
to store the information of ghost x
boundary and use it to conditionally limit ghost position in handleMove
.
const ghostXBoundary = {
// the min value that ghost.style.left can set
min: columnsContainerRect.x,
// the max value that ghost.style.left can set
// ghost column's width equals to `firstTargetRect.width`
// if you don't know how this work
// try removing `- firstTargetRect.width` to see the difference
max: columnsContainerRect.right - firstTargetRect.width
}
function handleMove(e) {
const newCursorX = e.clientX
let newGhostX = newCursorX + pointerOffset.x
// conditions to change `newGhostX`
// to limit ghost to stay in the boundary
if (newGhostX < ghostXBoundary.min)
newGhostX = ghostXBoundary.min
else if (newGhostX > ghostXBoundary.max) {
newGhostX = ghostXBoundary.max
}
ghost.style.left = newGhostX + 'px'
// ...
}
Now ghost stays in the boundary.
One thing you might have noticed is that the ghost column position is slightly off. It's not perfectly stay in the header section.
When we see something like this, we can try to exaggerate the problem to help us inspect it. First, I want to enlarge the border size from 1px
to 10px
.
myTables.css
.my-table,
.my-table th,
.my-table td {
border: 10px solid lightgray;
border-collapse: collapse;
padding: 8px;
}
We can see that the bug is exaggerated a little bit. Although it is not obviously what's going on, we can guess that border has some impact to this bug.
So next we can keep modifying other stuff related to borders. Let's comment out border-collapse: collapse;
.
It seems like when we clone the styles from a column to ghost. It doesn't take that border collapse into account. We need to do that ourselves.
function createGhost() {
const ghost = firstTarget.cloneNode(true)
copyElementStyleToAnother(firstTarget, ghost)
// cut borderWidth to half when ghost has border collapse style
if (ghost.style.borderCollapse === 'collapse') {
const halfBorderWidth = (parseFloat(ghost.style.borderWidth) / 2)
ghost.style.borderWidth = halfBorderWidth + 'px'
}
ghost.style.position = 'fixed'
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
}
Note that we cannot directly set borderWidth
to half without making that condition check, because it will break other situations. It's an edge case so we should handle it separately.
Nice! Ghost column now fits in the container.
Finally, don't forget to clean up what we did for exaggerating the bug! Depend on devices, You might still see that bug if the border width is set to 1px
, but I experienced that as a hardware issue related to how pixel are drawn on screen which we have no control. It will be safer to set a value to 2px or above if possible.
Posted on December 24, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.