table: make columns resizable
Tags: tutorial, javascript
Published: 2023-01-22 09:18:29

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!

The code [before](https://github.com/ga676005/table/tree/8f26b206c6b6f57bc1bde75d7cb1e46d28cec92d) and [after](https://github.com/ga676005/table/tree/097a7e0a97fb703d030f2c7ec63cb7e70e172659)

---


## 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.
```js
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.
```js
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](https://dev.to/gohomewho/addeventlistener-3o6k) tutorial may help.

Some CSS for resizable column. 
```css
.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.
```js
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.
```js
// 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.
```js
function getGhostBoundary() {...}
```

What about scroll? We need a way to scroll the container.
```js
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. 
```js
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.
```js
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. 
```js
// 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.
```js
// 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. 

![as description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ls64k19q1aufxfzzlym6.gif)


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](https://css-tricks.com/almanac/properties/l/line-clamp/).

I have added as much as possible comments to the [source code](https://github.com/ga676005/table/tree/097a7e0a97fb703d030f2c7ec63cb7e70e172659) for this section. If you are still confused, feel free to leave a comment below.