DEV Community

Gohomewho
Gohomewho

Posted on

table: improve moving column UI & UX (2)

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.

ghost column follows cursor

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

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

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'

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

Notice that now ghost column only moves within the header section even though our cursor doesn't stay precisely in the header section.
now ghost column only moves within 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

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

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)

  // ...
Enter fullscreen mode Exit fullscreen mode

It should work like this. We can swap columns without making cursor stay in the header section.
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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Lastly, we change how we get secondTarget.

// it was secondTarget = e.target
const secondTarget = document.elementFromPoint(e.clientX, lockedY)
Enter fullscreen mode Exit fullscreen mode

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'

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

Ghost column can no longer move pass the left boundary.
limit ghost 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'

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

Now ghost stays in the boundary.
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.
ghost column position is slightly off

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
the bug is exaggerated

So next we can keep modifying other stuff related to borders. Let's comment out border-collapse: collapse;.
no bug after comment out  raw `border-collapse: collapse;` endraw

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
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)