DEV Community

Gohomewho
Gohomewho

Posted on

table: make columns resizable

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 and after


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

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

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

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

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

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() {...}
Enter fullscreen mode Exit fullscreen mode

What about scroll? We need a way to scroll the container.

function getScrollContainerFunc() {...}
Enter fullscreen mode Exit fullscreen mode

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() {...}
Enter fullscreen mode Exit fullscreen mode

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() {...}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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)