Skip to content

Snater/react-resizable-columns

Repository files navigation

react-resizable-columns

Work in progress

A hook making table columns resizable.

Features

  • Allows resizing table columns per dragging a drag handle.
  • Resets column width per double-click on the drag handle.
  • Allows resizing columns via keyboard arrow keys.

Usage

See the repository's /src/demo folder for a most basic example.

Column Width Definitions

useResizableColumns needs to be provided with an array of ColumnDefinition defining the order of the columns.

const columnWidthDefinitions = [
    // minimal
    {id: "col1"},
    // all properties
    {id: "col3", minWidth: 50, defaultWidth: 200},
];

const {
    colGroupWidths,
    containerRef,
    guideRef,
    onResize,
    onResizeStart,
    onResizeStop,
    updateWidth,
    widths,
} = useResizableColumns(columnWidthDefinitions);

Properties

Returned property Description
colGroupWidths Used to assemble the <colgroup/>
columnWidths Array of current column widths in pixels, in column order
containerRef Ref to be passed to the container wrapping the <table/>
guideRef Ref to be passed to the guide that is rendered along the <table/> in the table container
onResize Callback to be passed to the drag library's onResize callback
onResizeStart Callback to be passed to the drag library's onResizeStart callback
onResizeStop Callback to be passed to the drag library's onResizeStop callback
updateWidth May be used to programmatically trigger updating a column's width, e.g. by implementing a key event handler
widths A record of the current widths in pixel, indexed by column ids

Concepts

At this point, the hook comes without CSS, so styles need to be set up manually.

Table Container

The container constraints the space available for the table and also contains the guide shown for orientation while dragging.

<div className="container">
    <div className="guide"/>
    <table className="resizable-table">
        ...
    </table>
 </div>

The container needs the following styles:

.container {
    overflow: auto;
    position: relative;
}

overflow: auto ensures that if the combined width of all columns is larger than the container, horizontal scrollbars will allow scrolling. position: relative is for positioning the guide.

Table

The table layout is fixed and defined by the colgroup definition that is set up using the colgroupWidths:

<table class="resizable-table">
    {colGroupWidths && (
        <colgroup>
            {colGroupWidths.map((width, i) => (
                <col key={i} style={{ width }} />
            ))}
        </colgroup>
    )}
    ...
</table>

The table will always at least use the whole space offered by the container. However, the combined width of all columns may be larger than the width provided by the container. Therefore, the styles the table needs:

.resizable-table {
    border-collapse: collapse;
    border-spacing: 0;
    inline-size: fit-content;
    min-width: 100%;
    table-layout: fixed;
}

While the combined width of all columns is smaller than the width provided by the container, the last (right-most) column will receive width: auto per its <col/> definition. Yet, there might not be sufficient space for all columns in the container, causing overflow. Whenever there is overflow, the last column will fall back to its minimum size. This simplification eases layout distribution at the cost of the last column not being resizable itself:

<thead>
    <tr>
        {columnDefinitions.map((columnDefinition, i) => (
            // Prevent the column from being resizable when
            // `i === ´columnDefinitions.length - 1`
            ...
        ))}
    </tr>
</thead>

Guide

The guide is a vertical line shown while dragging to give orientation about the resized column size to the user.

Example of some minimal styles:

.guide {
    background-color: lightgray;
    bottom: 0;
    opacity: .4;
    pointer-events: none;
    position: absolute;
    top: 0;
    width: 2px;
    z-index: 2;
}

background-color, opacity and width can be adjusted, pointer-events: none is recommended, the remaining ones are mandatory.

Handle

The handle is a visual separator between the columns in the column headers. It is also the interaction element that can be dragged to resize a column. The handle is a custom element passed to the handle prop of the Resizable component. The implementation may look like this (respecting a11y):

const handle = (
    <div
        aria-label={`Resize column: ${title}`}
        aria-orientation="vertical"
        aria-valuemin={columnDefinition.minWidth ?? DEFAULT_MIN_WIDTH}
        aria-valuenow={width}
        className="handle"
        onKeyDown={handleKeyDown}
        role="separator"
        tabIndex={0}
    />
);

handleKeyDown could issue resizing per arrow keys:

const handleKeyDown = useCallback((event: KeyboardEvent) => {
    if (event.key === "ArrowLeft") {
        updateWidth(columnDefinition.id, width - KEY_RESIZING_STEP);
    } else if (event.key === "ArrowRight") {
        updateWidth(columnDefinition.id, width + KEY_RESIZING_STEP);
    }
}, [columnDefinition.id, updateWidth, width]);

The handle would be positioned absolute at the right edge of the header cell. It would define :before to be the visual column separator, while the handle itself would provide a larger area as click / drag target, i.e.:

.handle {
    cursor: col-resize;
    height: 100%;
    opacity: .4;
    padding: 0 4px;
    position: absolute;
    right: -5px;
    top: 0;
    width: 2px;
    z-index: 1;

    &:hover {
        opacity: .8;
    }

    &:before {
        background: lightgray;
        content: '';
        display: block;
        height: 100%;
        left: -1px;
        position: relative;
        width: 2px;
    }
}

For positioning the handle relative to the header cell, the cell needs to have position: relative:

.resizable-th {
    position: relative;
}

Resizable

The hook is agnostic about which drag library is used to handle resizing. The demo uses the Resizable component from react-resizable, but any library that exposes onResizeStart, onResize, and onResizeStop callbacks can be wired up in the same way. The component is supposed to wrap each table header cell, i.e.:

const thRef = useRef<HTMLTableCellElement>(null);

const header = (
    <th className="resizable-th" ref={thRef}>
        {title}
    </th>
);

if (!resizable) {
    return header;
}

const handle = ...;

return (
    <Resizable
        axis="x"
        handle={handle}
        maxConstraints={[1000, -1]}
        minConstraints={[columnDefinition.minWidth ?? DEFAULT_MIN_WIDTH, -1]}
        onResize={(event, { node }) => {
            onResize(event, columnDefinition.id, thRef, node);
        }}
        onResizeStart={onResizeStart}
        onResizeStop={(event, { node }) => {
            onResizeStop(event, columnDefinition.id, thRef, node);
        }}
        width={width}
    >
        {header}
    </Resizable>
);

Dragging

Dragging is generally taken care of by react-resizable. However, for improved UX, while dragging, the cursor should remain col-resize instead of the default cursor, and it is good to disable pointer-events to disable hover effect when hovering buttons. The useResizableColumns hook adds a .col-resize class to the <html/> element while dragging, which can be used to apply the necessary styles:

html.col-resize {
    cursor: col-resize;

    body {
        pointer-events: none;
        user-select: none;
    }
}

About

A hook for resizing table columns.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors