In this series, we will make our table columns resizable. This time I won't show everything step by step. You can take this as an challenge to do on your own, and watch my solution if you get stuck. Maybe you'll come up with better solution!
How to add a feature
We've been making column swappable. Now, we want to add another feature "resizable". Can they work together? Do we need to change other code to adopt this feature? We probably don't have straight answers until really implement it. But asking these questions to ourselves can help us make better decisions.
To resize a column, we need to add a handle for user to interact with. So besides column name, there should be a resize handler. This means the structure of the column will be different than createTableHead
which only render column name. we will need to make a new function to create this structure.
function createResizableTableHead(columns, options = {}) {
const {
// settings for individual column's min width
columnsMinWidth,
// settings for individual column's label
// e.g. the column key from database is firstName
// but we won't show that directly to end user
// so we make a mapping like 'firstName' -> 'first name'
columnsLabel = {}
} = options
const thead = document.createElement('thead')
const tr = document.createElement('tr')
columns.forEach(columnKey => {
const th = document.createElement('th')
// save column name on the element,
// so we know which column a element represents
th.dataset.columnKey = columnKey
// set display: flex; on `th` would break
// the table layout algorithm
// so we need a wrapper `div` to do that
const wrapper = document.createElement('div')
wrapper.classList.add('resizable-column-wrapper')
th.appendChild(wrapper)
const content = document.createElement('div')
const resizeHandle = document.createElement('div')
content.classList.add('resizable-column-content')
resizeHandle.classList.add('resizable-column-handle')
// here we simply add text to a div
// but we can also make a column formatter
// similar to what we did for `createTableRow`
// if we need more complex markup
content.textContent = columnsLabel[columnKey] || columnKey
wrapper.append(content, resizeHandle)
tr.appendChild(th)
});
makeColumnsResizable(tr, { columnsMinWidth })
thead.appendChild(tr)
return thead
}
The overall structure of makeColumnsResizable
is very similar to makeColumnsSwappable
. We would want them to work together. We would also want to make the logic consistent.
function makeColumnsResizable(columnsContainer, options = {}) {
const {
// elements to resize together with the target column
elementsToPatch = [],
// setting of each column min width
columnsMinWidth = {},
// default value of each column min width
DEFAULT_MIN_COLUMN_WIDTH = 100
} = options
columnsContainer.classList.add('resizable-columns-container')
const _elementsToPatch = [columnsContainer, ...elementsToPatch]
const columnElements = [...columnsContainer.children]
columnElements.forEach((column) => {
column.classList.add('resizable-column')
const minWidthSetting = columnsMinWidth[column.dataset.columnKey]
if (minWidthSetting) {
// set width does not work on table because
// it has built-in layout algorithm
column.style.minWidth = minWidthSetting + 'px'
// we are still setting width because
// `makeColumnsResizable` is not made specifically for table
column.style.width = minWidthSetting + 'px'
}
})
columnsContainer.addEventListener('pointerdown', e => {
// because we use event delegation pattern,
// `e.target` could be other irrelevant elements
// so we need to make sure that the event
// is triggered by a resize handle
const resizeHandle = e.target.closest('.resizable-column-handle')
if (!resizeHandle)
return
// stop event propagation so we don't trigger resize
// and swap at the same time. this is used with
// { capture: true } to make sure this event handler has
// higher priority and don't propagate to others.
// it is also possible to use e.stopImmediatePropagation()
// in this case because this event listener of 'pointerdown'
// is added before the one from `makeColumnsSwappable`
e.stopPropagation()
const column = e.target.closest('.resizable-column')
const indexOfColumn = [...columnsContainer.children].indexOf(column)
const minColumnWidth =
columnsMinWidth[column.dataset.columnKey] || DEFAULT_MIN_COLUMN_WIDTH
// prevent text selection when moving columns
document.addEventListener('selectstart', preventDefault)
const initialColumnWidth = parseFloat(getComputedStyle(column).width)
const initialCursorX = e.clientX
// elements that are in the same column
const elementsToResize = _elementsToPatch.map((columnsContainer) => {
return columnsContainer.children[indexOfColumn]
})
// calculate how much to resize
function handleMove(e) {
const newCursorX = e.clientX
const moveDistance = newCursorX - initialCursorX
let newColumnWidth = initialColumnWidth + moveDistance
// we don't want to resize column width below its
// minimal value so if `newColumnWidth` is lower than
// `minColumnWidth` we want to use `minColumnWidth`, which
// value would be the "bigger" one of Math.max()
newColumnWidth = Math.max(newColumnWidth, minColumnWidth)
// if we need to frequently update UI, use
// `requestAnimationFrame` to make it optimal
requestAnimationFrame(() => {
elementsToResize.forEach((element) => {
element.style.minWidth = newColumnWidth + 'px'
element.style.width = newColumnWidth + 'px'
})
})
}
document.addEventListener('pointermove', handleMove)
// clean up event listeners
document.addEventListener('pointerup', e => {
document.removeEventListener('pointermove', handleMove)
document.removeEventListener('selectstart', preventDefault)
// this clean up listener only needs to run once
// after 'pointerdown'
}, { once: true })
// capture of 'pointerdown' is used with e.preventDefault()
// as mentioned above
}, { capture: true })
}
If you don't know what is event propagation and event delegation, and what e.stopPropagation()
and { capture: true }
do. Check out my addEventListener tutorial may help.
Some CSS for resizable column.
.resizable-column-wrapper {
/* make its content layout horizontally */
display: flex;
}
.resizable-column-content {
/* makes this element can grow and shrink
within the free space of flexbox */
flex: 1;
text-align: left;
padding: 8px;
}
.resizable-column-handle {
width: 20px;
background: rgba(255, 0, 0, 0.154);
cursor: col-resize;
/* makes this element not to shrink */
flex-shrink: 0;
}
Update createTable
to be able to create a table of resizable and swappable columns through options.
import { makeColumnsResizable } from "./makeColumnsResizable.js"
import { makeColumnsSwappable } from "./makeColumnsSwappable.js"
export function createTable(columns, dataList, options = {}) {
const {
columnFormatter = {},
resizeOptions = {},
swapOptions = {},
} = options
const table = document.createElement('table')
// create resizable structure if resize option is enable
const thead = resizeOptions.enable
? createResizableTableHead(columns, resizeOptions)
: createTableHead(columns)
const tbody = createTableBody(columns, dataList, columnFormatter)
table.append(thead, tbody)
// make columns swappable if swap option is enable
if (swapOptions.enable) {
const columnsContainer = table.querySelector('thead').firstElementChild
const elementsToPatch = table.querySelectorAll('tbody > tr')
makeColumnsSwappable(columnsContainer, elementsToPatch)
}
return table
}
We can create a table like this.
// const users = [...]
// const nameOfDefaultColumns = [...]
// const columnFormatter = [...]
createTable(nameOfDefaultColumns, users, {
columnFormatter,
resizeOptions: {
enable: true,
columnsMinWidth: {
id: 50
}
},
swapOptions: {
enable: true,
},
})
There are more to consider
The code above should already work, but it doesn't work well. We can resize columns. What happens if total columns width is larger than the container. We need to make some changes to makeColumnsSwappable
.
We would want ghost to stay within the container.
function getGhostBoundary() {...}
What about scroll? We need a way to scroll the container.
function getScrollContainerFunc() {...}
And decide when to scroll. moveGhost
handles the logic that ghost touches the edges, so we can also make it start the scroll.
function moveGhost() {...}
Then, how to swap after auto scrolling? Some code from handleMove
is doing that job. We need to extract it out to reuse it.
function handleSwap() {...}
Do we want to pass handleSwap
all the way down to getScrollContainerFunc
from createGhostColumn
? Or there is other way to do it? I think handleSwap
is more reasonable to be called directly inside makeColumnsSwappable
. So I use a custom event to notify the universe that I just auto scroll.
// inside `startScroll`
const event = new CustomEvent('custom:autoscroll', {
detail: {
direction,
predicateGhostEdgeX
}
})
ghost.dispatchEvent(event)
We can addEventListener
to ghost on that custom event inside makeColumnsSwappable
to handleSwap
. This flow is more clear to me.
// inside `makeColumnsSwappable`
ghost.element.addEventListener('custom:autoscroll', (e) => {
const { direction, predicateGhostEdgeX } = e.detail
handleSwap({
isMoveToLeft: direction === 'left',
isMoveToRight: direction === 'right',
ghostLeft: predicateGhostEdgeX.left,
ghostRight: predicateGhostEdgeX.right
})
})
There are other pieces to consider, but these are the big picture.
The result of my solution. The ghost should stay within the content box area of the container. Auto scroll should swap as well.
Note that we don't need to pass elementsToPatch
to makeColumnsResizable
in this case, because browser will adjust columns width with its table algorithm. Another thing to note is that the table algorithm prevent the content to overflow. Resizing to a width that is too small won't take effect. To make makeColumnsResizable
also work on other tags e.g. a bunch of div, The content of columns should be aware of potential overflow and handled with line clamp.
I have added as much as possible comments to the source code for this section. If you are still confused, feel free to leave a comment below.
Top comments (0)