In the previous tutorial, we make a table that can take any kind of data and display them. Displaying information is the most important thing. But the order of the columns is fixed. It won't be a problem until our audiences grow and their opinions conflict where we can't just change the order manually.
In this tutorial, we will use addEventListener
to make the columns swappable. So people can move the columns to any order they want. If you are not familiar with addEventListener
, you can checkout out my verbose article on the topic.
Here is the code from the previous tutorial.
To make the columns swappable, we need to know which two targets to swap. It's not complex if you think about it. Every time we move a column, we are swapping its position with either its closest left or right column. The first target will be the column element we want to move, and the second target will be either its left or right element. With this approach, we could move columns around.
To get the first target, we can addEventListener
to each column elements. we can also use the event delegation and addEventListener
to their parent. We are going to use event delegation.
We created column elements inside createTableHead
function, we can addEventListener
to their parent tr
. We choose 'pointerdown'
over 'click'
event so our code will work well with both touch and mouse devices. You can learn more about pointer events on JavaScript.info.
function createTableHead(columns) {
const thead = document.createElement('thead')
const tr = document.createElement('tr')
columns.forEach(columnName => {
const th = document.createElement('th')
th.textContent = columnName
tr.appendChild(th)
});
// `addEventListener` to column elements' parent
tr.addEventListener('pointerdown', e => {
const firstTarget = e.target
console.log(firstTarget)
})
thead.appendChild(tr)
return thead
}
Because of event propagation, e.target
can be any element inside tr
. It's not quite obvious because currently we don't have any style and other elements columns, so clicking on a column always gives us a column element. Since we want to move column elements, we need to make sure we get the correct column elements but not their descendants. We can check if e.target
is in the tr.children
.
// ...
tr.addEventListener('pointerdown', e => {
// `tr.children` gives us a HTMLCollection not an array
// but we can convert it to an array like this `[...tr.children]`
// so we can use `.indexOf()`
const columnElements = [...tr.children]
const firstTarget = e.target
// `.indexOf()` returns `-1` if it can't find the item
const firstTargetIndex = columnElements.indexOf(firstTarget)
// if `e.target` is not a column element
// we want to early return and do nothing
if (firstTargetIndex === -1)
return
})
// ...
We have the first target. Now we want to get the second target when moving. Add a 'pointermove'
event listener to tr
and get the second target. It's similar as the steps above.
tr.addEventListener('pointerdown', e => {
const columnElements = [...tr.children]
const firstTarget = e.target
const firstTargetIndex = columnElements.indexOf(firstTarget)
if (firstTargetIndex === -1)
return
// add a pointermove event listener here
tr.addEventListener('pointermove', e2 => {
const secondTarget = e2.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
})
})
After we have both first target and second target, We can start writing the logic to move them. We can use their indexes as a clue to indicate we are moving a column to left or right.
// this is inside tr.addEventListener('pointerdown', e => {
// ...
tr.addEventListener('pointermove', e2 => {
const secondTarget = e2.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
// if `firstTarget` and `secondTarget` are the same element
// we want to early return and do nothing
if (firstTarget === secondTarget)
return
// if firstTargetIndex > secondTargetIndex
// it means the second target is on the left side of first target
// it also means that we want to move the first target to left
const isMoveToLeft = firstTargetIndex > secondTargetIndex
// same logic apply to moving to right
const isMoveToRight = firstTargetIndex < secondTargetIndex
if (isMoveToLeft) {
// insert `firstTarget` before `secondTarget`
// which moves `firstTarget` to the left side of `secondTarget`
secondTarget.insertAdjacentElement('beforebegin', firstTarget)
} else if (isMoveToRight) {
// insert `firstTarget` after `secondTarget`
// which moves `firstTarget` to the right side of `secondTarget`
secondTarget.insertAdjacentElement('afterend', firstTarget)
}
//...
We can move columns now, but it never stops. We need to stop it at some point. We can do that by adding a 'pointerup'
event listener to tr
and remove the 'pointermove'
.
tr.addEventListener('pointerdown', e => {
//...
tr.addEventListener('pointermove', e2 => {
//...
})
// We want to stop the 'pointermove' when 'pointerup'
tr.addEventListener('pointerup', e3 => {
})
})
The callback function inside 'pointermove'
is inline. We have to extract the function out in order to reference it at removeEventListener
.
tr.addEventListener('pointerdown', e => {
//...
// extract the inline function from 'pointermove' to here
function handleMove(e) {
const secondTarget = e.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
if (firstTarget === secondTarget)
return
const isMoveToLeft = firstTargetIndex > secondTargetIndex
const isMoveToRight = firstTargetIndex < secondTargetIndex
if (isMoveToLeft) {
secondTarget.insertAdjacentElement('beforebegin', firstTarget)
} else if (isMoveToRight) {
secondTarget.insertAdjacentElement('afterend', firstTarget)
}
}
// use the extracted function `handleMove` on 'pointermove'
tr.addEventListener('pointermove', handleMove)
// stop moving on 'pointerup'
tr.addEventListener('pointerup', () => {
// remove the 'pointermove' event listener on `tr`
// which references the function `handleMove`
tr.removeEventListener('pointermove', handleMove)
})
})
We add other event listeners 'pointermove'
and 'pointerup'
to tr
on 'pointerdown'
. We clean up 'pointermove'
on 'pointerup'
, but we didn't clean up 'pointerup'
. This means that every time we 'pointerdown'
, we add an extra 'pointerup'
event listener. We can make 'pointerup'
only fire once.
//...
tr.addEventListener('pointerup', () => {
tr.removeEventListener('pointermove', handleMove)
// add configuration { once: true }
}, { once: true })
//...
You can add a console.log('hi')
to 'pointerup'
and click on a column many times to see what's the difference without { once: true }
.
Now we can start moving and stop moving. If you've followed the tutorial, you might notice that the moving isn't quite right. You are correct! And we are going to fix it.
The problem is that we didn't update some variables after a move.
tr.addEventListener('pointerdown', e => {
// `tr.children` is a HTMLCollection, it can automatically update
// but we convert it to an array, it becomes a fix order
// we need to update this array after a move
// to get the correct index of elements
const columnElements = [...tr.children]
// the `firstTarget` is always the 'pointerdown' one
const firstTarget = e.target
// `firstTargetIndex` will change after a move
// so we should update this
const firstTargetIndex = columnElements.indexOf(firstTarget)
if (firstTargetIndex === -1)
return
function handleMove(e) {
// secondTarget will change
// but we always get the latest secondTarget on `'pointermove'`
// so this won't be a problem
const secondTarget = e.target
// getting `secondTargetIndex` has a problem
// because we don't update `columnElements` after a move
// so the index would be incorrect
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
if (firstTarget === secondTarget)
return
// indexes become incorrect
// so `isMoveToLeft` and `isMoveToRight ` become incorrect
const isMoveToLeft = firstTargetIndex > secondTargetIndex
const isMoveToRight = firstTargetIndex < secondTargetIndex
// These also become incorrect
if (isMoveToLeft) {
secondTarget.insertAdjacentElement('beforebegin', firstTarget)
} else if (isMoveToRight) {
secondTarget.insertAdjacentElement('afterend', firstTarget)
}
// so after a move
// we need to update columnElements and firstTargetIndex
}
tr.addEventListener('pointermove', handleMove)
tr.addEventListener('pointerup', () => {
tr.removeEventListener('pointermove', handleMove)
}, { once: true })
})
Fix the problem.
tr.addEventListener('pointerdown', e => {
// change this to let
let columnElements = [...tr.children]
const firstTarget = e.target
// change this to let
let firstTargetIndex = columnElements.indexOf(firstTarget)
if (firstTargetIndex === -1)
return
function handleMove(e) {
const secondTarget = e.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
if (firstTarget === secondTarget)
return
const isMoveToLeft = firstTargetIndex > secondTargetIndex
const isMoveToRight = firstTargetIndex < secondTargetIndex
if (isMoveToLeft) {
secondTarget.insertAdjacentElement('beforebegin', firstTarget)
} else if (isMoveToRight) {
secondTarget.insertAdjacentElement('afterend', firstTarget)
}
// update `columnElements` and `firstTargetIndex` after a move
columnElements = [...tr.children]
firstTargetIndex = columnElements.indexOf(firstTarget)
}
tr.addEventListener('pointermove', handleMove)
tr.addEventListener('pointerup', () => {
tr.removeEventListener('pointermove', handleMove)
}, { once: true })
})
We should be able to move columns like this.
Great! Now we can move the columns in the table header, we should also apply that change to the table body, otherwise columns won't match the data!
How do we do that? If you think about it, the structure of the table header and a table row is very similar. They both have a container that wraps the column elements. So the logic to move the elements might be the same. Let's try it out. First, extract the logic of moving columns to a function.
function handleMove(e) {
const secondTarget = e.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
if (firstTarget === secondTarget)
return
// use the function
swapColumns(tr, firstTargetIndex, secondTargetIndex)
columnElements = [...tr.children]
firstTargetIndex = columnElements.indexOf(firstTarget)
}
// make a function that can swap two columns
function swapColumns(container, firstTargetIndex, secondTargetIndex) {
// get column elements of the container
const columns = container.children
// get `firstTarget` and `secondTarget` with the indexes
const firstTarget = columns[firstTargetIndex]
const secondTarget = columns[secondTargetIndex]
const isMoveToLeft = firstTargetIndex > secondTargetIndex
const isMoveToRight = firstTargetIndex < secondTargetIndex
if (isMoveToLeft) {
secondTarget.insertAdjacentElement('beforebegin', firstTarget)
} else if (isMoveToRight) {
secondTarget.insertAdjacentElement('afterend', firstTarget)
}
}
Note that we don't pass firstTarget
and secondTarget
directly to swapColumns
. First, we can get them through container, firstTargetIndex, secondTargetIndex
. Second, container, firstTargetIndex, secondTargetIndex
is the common point between the table header and a table row.
Next, we can use the function swapColumns
to update the table body rows.
function handleMove(e) {
const secondTarget = e.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
if (firstTarget === secondTarget)
return
swapColumns(tr, firstTargetIndex, secondTargetIndex)
// we don't have access to the rows
// so we get them here
const rows = [...table.querySelectorAll('tbody > tr')]
// update the rows
rows.forEach((columnsContainer) => {
swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
})
columnElements = [...tr.children]
firstTargetIndex = columnElements.indexOf(firstTarget)
}
Move table columns altogether.
When we are moving columns, we don't intent to select text. I found that selection quite annoying, so let's disable it.
tr.addEventListener('pointerdown', e => {
let columnElements = [...tr.children]
const firstTarget = e.target
let firstTargetIndex = columnElements.indexOf(firstTarget)
// create a function so we can reference it
function preventDefault(e) {
e.preventDefault()
}
// disable text selection
document.addEventListener('selectstart', preventDefault)
if (firstTargetIndex === -1)
return
function handleMove(e) {
//...
}
function swapColumns(container, firstTargetIndex, secondTargetIndex) {
// ...
}
tr.addEventListener('pointermove', handleMove)
tr.addEventListener('pointerup', () => {
tr.removeEventListener('pointermove', handleMove)
// remove the text selection disable
// so we can still select text and copy the data
document.removeEventListener('selectstart', preventDefault)
}, { once: true })
})
We can still select text when we are not moving columns.
We also want to change the target we add 'pointerup'
from tr
to document
, so we don't have to 'pointerup'
in the tr
.
//...
// change `tr.addEventListener` to `document.addEventListener`
document.addEventListener('pointerup', () => {
tr.removeEventListener('pointermove', handleMove)
document.removeEventListener('selectstart', preventDefault)
}, { once: true })
//...
Throughout this tutorial, we add the logic directly to tr
in the createTableHead
function. We extract the logic that moves columns to the function swapColumns
, so we can update any other columns which is the most important part. This means that we can extract the whole logic, so it can work on any container that follows this structure.
Let's create a function makeColumnsSwappable
to the bottom of the file and copy everything from 'pointerdown'
. Rseplace all references from tr
to columnsContainer
, so it can work on any container.
function makeColumnsSwappable(columnsContainer) {
// tr -> columnsContainer
columnsContainer.addEventListener('pointerdown', e => {
// tr -> columnsContainer
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
function handleMove(e) {
const secondTarget = e.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
if (firstTarget === secondTarget)
return
// tr -> columnsContainer
swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
const rows = [...table.querySelectorAll('tbody > tr')]
rows.forEach((columnsContainer) => {
swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
})
// tr -> columnsContainer
columnElements = [...columnsContainer.children]
firstTargetIndex = columnElements.indexOf(firstTarget)
}
function swapColumns(container, firstTargetIndex, secondTargetIndex) {
const columns = container.children
const firstTarget = columns[firstTargetIndex]
const secondTarget = columns[secondTargetIndex]
const isMoveToLeft = firstTargetIndex > secondTargetIndex
const isMoveToRight = firstTargetIndex < secondTargetIndex
if (isMoveToLeft) {
secondTarget.insertAdjacentElement('beforebegin', firstTarget)
} else if (isMoveToRight) {
secondTarget.insertAdjacentElement('afterend', firstTarget)
}
}
// tr -> columnsContainer
columnsContainer.addEventListener('pointermove', handleMove)
document.addEventListener('pointerup', () => {
// tr -> columnsContainer
columnsContainer.removeEventListener('pointermove', handleMove)
document.removeEventListener('selectstart', preventDefault)
}, { once: true })
})
}
Use the makeColumnsSwappable
function. It should works as before.
function createTableHead(columns) {
const thead = document.createElement('thead')
const tr = document.createElement('tr')
columns.forEach(columnName => {
const th = document.createElement('th')
th.textContent = columnName
tr.appendChild(th)
});
// use it here
makeColumnsSwappable(tr)
thead.appendChild(tr)
return thead
}
Since we extract the logic, makeColumnsSwappable
doesn't have to be called inside createTableHead
. Maybe we don't want every table to have the moving feature. We can use it as a plugin. Remove makeColumnsSwappable(tr)
inside createTableHead
and use it at where we created the table.
// This is where we created the table.
const table = createTable(nameOfDefaultColumns, users, columnFormatter)
document.body.appendChild(table)
// remember that the container of table header columns
// is structured as thead > tr
const columnsContainer = table.querySelector('thead').firstElementChild
// only make this table header columns to be swappable
makeColumnsSwappable(columnsContainer)
There is one last thing we haven't handled in the makeColumnsSwappable
function. We used to get the table body rows and update them. Now we can't do that anymore. We can't assume there are other columns to update. We need that information to be passed from outside.
// add a paremeter `elementsToPatch` and defaults to empty array
// so we can call .forEach() directly
function makeColumnsSwappable(columnsContainer, elementsToPatch = []) {
// ...
function handleMove(e) {
// ...
// change these
const rows = [...table.querySelectorAll('tbody > tr')]
rows.forEach((columnsContainer) => {
swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
})
// to this
elementsToPatch.forEach((columnsContainer) => {
swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
})
// ...
}
}
makeColumnsSwappable
by default only update the columnsContainer
that it takes directly, unless elementsToPatch
is specified.
Lastly, we need to pass elementsToPatch
to makeColumnsSwappable
to make the columns in the table body rows swap again.
const table = createTable(nameOfDefaultColumns, users, columnFormatter)
document.body.appendChild(table)
const columnsContainer = table.querySelector('thead').firstElementChild
// get `elementsToPatch`
const elementsToPatch = table.querySelectorAll('tbody > tr')
// pass `elementsToPatch`
// if we comment out this line
// the table will remain the same as the previous tutorial
makeColumnsSwappable(columnsContainer, elementsToPatch)
If you have noticed that sometimes two columns swap really fast and flash, that's a flaw in our current logic. I'll give you a clue if you want to fix it. We need to actually check the pointer is moving left or right rather than using the elements' indexes.
The code we make in this tutorial.
function makeColumnsSwappable(columnsContainer, elementsToPatch = []) {
columnsContainer.addEventListener('pointerdown', e => {
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
function handleMove(e) {
const secondTarget = e.target
const secondTargetIndex = columnElements.indexOf(secondTarget)
if (secondTargetIndex === -1)
return
if (firstTarget === secondTarget)
return
swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
elementsToPatch.forEach((columnsContainer) => {
swapColumns(columnsContainer, firstTargetIndex, secondTargetIndex)
})
columnElements = [...columnsContainer.children]
firstTargetIndex = columnElements.indexOf(firstTarget)
}
function swapColumns(container, firstTargetIndex, secondTargetIndex) {
const columns = container.children
const firstTarget = columns[firstTargetIndex]
const secondTarget = columns[secondTargetIndex]
const isMoveToLeft = firstTargetIndex > secondTargetIndex
const isMoveToRight = firstTargetIndex < secondTargetIndex
if (isMoveToLeft) {
secondTarget.insertAdjacentElement('beforebegin', firstTarget)
} else if (isMoveToRight) {
secondTarget.insertAdjacentElement('afterend', firstTarget)
}
}
columnsContainer.addEventListener('pointermove', handleMove)
document.addEventListener('pointerup', () => {
columnsContainer.removeEventListener('pointermove', handleMove)
document.removeEventListener('selectstart', preventDefault)
}, { once: true })
})
}
Top comments (0)