React table component with support for fixed row(s) and column(s) in pure CSS, using position: sticky and CSS grid

master
Vitaliy Filippov 2019-02-07 17:44:07 +03:00
commit 67297ccdce
3 changed files with 563 additions and 0 deletions

403
FixedHeaderGridTable.js Normal file
View File

@ -0,0 +1,403 @@
/**
* React "mini-grid" - table component with support for fixed row(s) and column(s)
* and also column resizing, in pure CSS, using position: sticky and CSS grid layout
*
* http://yourcmc.ru/git/vitalif-js/fixed-header-grid-table
*
* Author: Vitaliy Filippov <vitalif@yourcmc.ru>, 2019
* License: dual-license MPL 2.0 or GPL 3.0+
*/
import React from 'react';
import PropTypes from 'prop-types';
import default_css from './FixedHeaderGridTableRT.css';
/**
* WARNING: It is CRUCIAL to use style-loader with `singleton: true` option in your Webpack
* configuration to avoid bad positioning of sticky cells. Use it like this:
*
* {
* loader: "style-loader",
* options: {
* singleton: true,
* }
* },
*/
/**
* `rows` must be an array of arrays of cells
*
* Each cell should be either a <div> with properties or just any valid
* react element contents (i.e. it a string, an element or an array)
*
* [ [ <div className="wrap" sticky_row="2" sticky_col="2" onClick={...}>long string</div>, 'string' ], ... ]
*/
export class FixedHeaderGridTable extends React.PureComponent
{
static propTypes = {
theme: PropTypes.object,
rows: PropTypes.arrayOf(PropTypes.array).isRequired,
isResizable: PropTypes.arrayOf(PropTypes.bool),
noWrapDefault: PropTypes.bool,
hasRowHover: PropTypes.bool,
hasStickyColumn: PropTypes.bool,
hasMultiSticky: PropTypes.bool,
hasZebra: PropTypes.bool,
}
state = {
userWidths: [],
}
activeRow = 0
componentDidMount()
{
this.componentDidUpdate();
if (this.props.hasMultiSticky)
{
window.addEventListener('resize', this.repositionStickyCells);
}
}
componentWillUnmount()
{
if (this.props.hasMultiSticky)
{
window.removeEventListener('resize', this.repositionStickyCells);
}
}
componentDidUpdate()
{
this.repositionStickyCells();
}
repositionStickyCells = () =>
{
if (!this.props.hasMultiSticky)
{
return;
}
const css = this.props.theme || default_css;
let mc, mr, r, m;
// row and column may be positive (stick to beginning) or negative (stick to end) or 0 (do not stick)
let stick = this.rootNode.querySelectorAll('*[sticky_row]');
for (let node of stick)
{
r = parseInt(node.getAttribute('sticky_row'));
if (r)
{
if (!mr)
{
mr = [].slice.apply(this.rootNode.querySelectorAll('div.'+css.measure_row))
.map(e => e.offsetTop);
}
if (r > 0 && (m = mr[r-1]))
{
node.style.top = m+'px';
}
else if (r == -1)
{
node.style.bottom = '0px';
}
else if (r < 0 && (m = mr[mr.length+r+1]))
{
node.style.bottom = (this.rootNode.scrollHeight-m)+'px';
}
}
}
stick = this.rootNode.querySelectorAll('*[sticky_col]');
for (let node of stick)
{
r = parseInt(node.getAttribute('sticky_col'));
if (r)
{
if (!mc)
{
mc = [].slice.apply(this.rootNode.querySelectorAll('div.'+css.measure_col))
.map(e => e.offsetLeft);
}
if (r > 0 && (m = mc[r-1]))
{
node.style.left = m+'px';
}
else if (r == -1)
{
node.style.right = '0px';
}
else if (r < 0 && (m = mc[mc.length+r+1]))
{
node.style.right = (this.rootNode.scrollWidth-m)+'px';
}
}
}
}
onMouseDown = (ev) =>
{
if (this.curResize)
{
this.curResize.active = true;
ev.preventDefault();
ev.stopPropagation();
}
}
onMouseUp = (ev) =>
{
if (this.curResize && this.curResize.active)
{
this.curResize.active = false;
this._resize.style.display = 'none';
this.setState({ userWidths: [ ...this.state.userWidths ] });
}
}
onResizeMouseMove = (ev) =>
{
const rect = this.rootNode.getBoundingClientRect();
const y = ev.pageY - rect.top;
if (y > this.rootNode.children[0].offsetHeight)
{
this.onResizeMouseLeave();
return;
}
if (this.curResize && this.curResize.active)
{
let diff = ev.pageX - this.curResize.x;
if (diff < -this.curResize.w1+54)
diff = -this.curResize.w1+54;
this._resize.style.left = (this.curResize.sx + diff - 3) + 'px';
this.state.userWidths[this.curResize.i] = (this.curResize.w1 + diff);
this.columnWidths[this.curResize.i] = (this.curResize.w1 + diff) + 'px';
this.rootNode.style.gridTemplateColumns = this.columnWidths.join(' ');
}
else
{
let x = ev.pageX;
for (let i = 0; i < this.columnWidths.length; i++)
{
let cell = this.rootNode.children[i].getBoundingClientRect();
let sx = cell.right;
if (x >= sx-5 && x <= sx+5 && (this.props.isResizable === undefined || this.props.isResizable[i]))
{
this.curResize = {
active: false,
i,
x,
sx: sx,
w1: cell.width,
};
this._resize.style.display = 'block';
this._resize.style.left = (sx-5)+'px';
this._resize.style.top = cell.top+'px';
this._resize.style.height = cell.height+'px';
return;
}
}
this.onResizeMouseLeave();
}
}
onResizeDblClick = (ev) =>
{
if (this.curResize)
{
const userWidths = [ ...this.state.userWidths ];
userWidths[this.curResize.i] = null;
this.setState({ userWidths });
ev.preventDefault();
ev.stopPropagation();
}
}
onResizeMouseLeave = (ev) =>
{
if (this.curResize)
{
this.curResize = null;
this._resize.style.display = 'none';
}
}
setResizeHandle = (e) =>
{
this._resize = e;
}
onMouseMove = (e) =>
{
let row;
let c = e.target;
while (c && c != this.rootNode)
{
row = c.getAttribute('row');
if (row)
{
break;
}
c = c.parentNode;
}
// Row highlight
if (this.props.hasRowHover !== false)
{
const css = this.props.theme || default_css;
if (this.activeRow != row)
{
if (this.activeRow > 0)
{
for (let e of this.rootNode.querySelectorAll('*[row="'+this.activeRow+'"]'))
{
e.className = e.className.replace(' '+css.hover, '');
}
}
this.activeRow = row;
if (this.activeRow > 0)
{
for (let e of this.rootNode.querySelectorAll('*[row="'+this.activeRow+'"]'))
{
e.className = e.className+' '+css.hover;
}
}
}
}
if (this.props.isResizable === undefined || this.props.isResizable)
{
this.onResizeMouseMove(e);
}
}
onMouseLeave = (e) =>
{
if (this.props.hasRowHover !== false)
{
// Remove row highlight
const css = this.props.theme || default_css;
if (this.activeRow > 0)
{
for (let e of this.rootNode.querySelectorAll('*[row="'+this.activeRow+'"]'))
{
e.className = e.className.replace(' '+css.hover, '');
}
}
this.activeRow = 0;
}
if (this.props.isResizable === undefined || this.props.isResizable)
{
this.onResizeMouseLeave(e);
}
}
setRoot = (e) =>
{
this.rootNode = e;
}
render()
{
const css = this.props.theme || default_css;
let rows = this.props.rows;
let cells = [];
let i = 0;
for (let row of rows)
{
let j = 0;
for (let cell of row)
{
if (cell == null)
{
// skip null or undefined cells
continue;
}
const isDiv = React.isValidElement(cell) && cell.type == 'div';
const className = ' ' + css.cell + ' ' +
(i == 0 ? ' '+css.first_row : '') +
(i == rows.length-1 ? ' '+css.last_row : '') +
(j == 0 && (!isDiv || !cell.props.style ||
!cell.props.style.gridColumn && !cell.props.style.gridColumnStart ||
cell.props.style.gridColumn == 1 || cell.props.style.gridColumnStart == 1) ? ' '+css.first_col : '') +
(j == row.length-1 ? ' '+css.last_col : '') +
(j & 1 ? ' '+css.even : '');
if (!isDiv)
{
cell = (<div className={className} row={i} col={j} key={i+'-'+j}>
{this.props.noWrapDefault
? <div className={css.ellipsis}>{cell}</div>
: cell}
</div>);
}
else
{
let spec = {};
let cls = cell.props.className
? cell.props.className.replace(/(^|\s)(wrap)(?=$|\s)/g, (m, m1, m2) => { spec[m2] = true; return ''; })
: '';
if (cell.props.sticky_col < 0)
cls = cls + ' ' + css.stick_right;
else if (cell.props.sticky_col > 0)
cls = cls + ' ' + css.stick_left;
if (cell.props.sticky_row < 0)
cls = cls + ' ' + css.stick_bottom;
else if (cell.props.sticky_row > 0)
cls = cls + ' ' + css.stick_top;
let props = {
row: i,
col: j,
key: i+'-'+j,
className: cls+className,
};
if (spec.wrap || !this.props.noWrapDefault)
{
cell = React.cloneElement(cell, props);
}
else
{
// By default cells are non-wrappable with text-overflow: ellipsis
cell = React.cloneElement(cell, props, (<div className={css.ellipsis}>
{cell.props.children}
</div>));
}
}
cells.push(cell);
j++;
}
i++;
}
if (this.props.hasMultiSticky)
{
// Multiple sticky rows or columns requires calculating grid row/column sizes
// So we add some extra invisible cells to do this
for (let col = 0; col < this.props.columnWidths.length; col++)
{
cells.push(<div className={css.measure_col} style={{gridColumn: (col+1)}}></div>);
}
for (let row = 0; row < rows.length; row++)
{
cells.push(<div className={css.measure_row} style={{position: 'absolute', left: '-100px', width: '1px', height: '1px', gridColumn: 1, gridRow: (row+1)}}></div>);
}
}
const ws = this.state.userWidths || [];
this.columnWidths = this.props.columnWidths.map((w, i) => ws[i] ? ws[i]+'px' : w);
const cls = css.table + (this.props.hasStickyColumn !== false ? ' '+css.sticky_col : '')
+ (this.props.hasZebra ? ' '+css.zebra : '');
return (<div
ref={this.setRoot}
className={cls}
onMouseDown={this.props.isResizable === undefined || this.props.isResizable ? this.onMouseDown : null}
onMouseUp={this.props.isResizable === undefined || this.props.isResizable ? this.onMouseUp : null}
onDoubleClick={this.props.isResizable === undefined || this.props.isResizable ? this.onResizeDblClick : null}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
style={{position: 'relative', gridTemplateColumns: this.columnWidths.join(' ')}}>
{cells}
{this.props.isResizable === undefined || this.props.isResizable
? <div
className={css.resize_handle}
ref={this.setResizeHandle}>
</div>
: null}
</div>);
}
}

View File

@ -0,0 +1,87 @@
.table
{
height: 100%;
display: grid;
grid-auto-rows: max-content;
overflow: scroll;
position: relative;
}
.cell
{
}
.even
{
}
.first_col
{
}
.last_col
{
}
.stick_left, .stick_right, .stick_top, .stick_bottom, .first_row, .sticky_col .first_col
{
position: sticky;
z-index: 2;
background: white;
}
.stick_left.stick_top, .stick_right.stick_top, .stick_left.stick_bottom, .stick_right.stick_bottom,
.sticky_col .first_row.first_col, .first_row.stick_left, .first_row.stick_right
{
z-index: 3;
}
.first_row
{
top: 0;
}
.last_row
{
}
.sticky_col .first_col
{
left: 0;
}
.ellipsis
{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.resize_handle
{
position: fixed;
width: 7px;
top: 0;
left: 0;
display: none;
z-index: 12;
cursor: col-resize;
}
.measure_row
{
position: absolute;
left: -100px;
width: 1px;
height: 1px;
grid-column: 1;
}
.measure_col
{
position: absolute;
top: -100px;
width: 1px;
height: 1px;
grid-row: 1;
}

View File

@ -0,0 +1,73 @@
@import 'react-toolbox/components/colors.css';
@import 'react-toolbox/components/variables.css';
@import './FixedHeaderGridTableBase.css';
:root {
--table-hover-color: #eee;
--table-dividers: 1px solid #e6e6e6;
--table-selection-color: #f5f5f5;
--table-column-padding: calc(1.8 * var(--unit));
--table-cell-padding: calc(1.2 * var(--unit));
}
.cell
{
transition: 0.28s background cubic-bezier(0.4, 0, 0.2, 1);
padding: var(--table-cell-padding) var(--table-column-padding);
border-top: var(--table-dividers);
}
.first_col
{
padding-left: 24px;
}
.last_col
{
padding-right: 24px;
}
.first_row, .sticky_col .first_col
{
font-weight: 500;
color: #888;
}
.first_row
{
border-top: 0;
background: white;
}
.sticky
{
background: white;
}
.sticky_col .first_col
{
background: white;
}
.first_row:after
{
content: " ";
display: block;
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 1px;
background: linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(209,209,209,1) 50%, rgba(254,254,254,0) 100%);
}
.zebra .even
{
background: #f8f8f8;
}
.hover, .zebra .even.hover, .first_row.hover, .sticky.hover, .sticky_col .first_col.hover
{
background: var(--table-hover-color);
}