fixed-header-grid-table/FixedHeaderGridTable.js

436 lines
14 KiB
JavaScript

/**
* 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+
* Version: 2019-03-13
*/
import React from 'react';
import PropTypes from 'prop-types';
import bindCache from './bind-cache.js';
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.array.isRequired,
columnWidths: PropTypes.arrayOf(PropTypes.string),
isResizable: PropTypes.arrayOf(PropTypes.bool),
noWrapDefault: PropTypes.bool,
hasRowHover: PropTypes.bool,
hasStickyColumn: PropTypes.bool,
hasMultiSticky: PropTypes.bool,
hasZebra: PropTypes.bool,
}
state = {
userWidths: [],
}
activeRow = null
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 = row || null;
// Row highlight
if (this.props.hasRowHover !== false)
{
const css = this.props.theme || default_css;
if (this.activeRow != row)
{
if (this.activeRow != null)
{
for (let e of this.rootNode.querySelectorAll('*[row="'+this.activeRow+'"]'))
{
e.className = e.className.replace(' '+css.hover, '');
}
}
this.activeRow = row;
if (this.activeRow != null)
{
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 != null)
{
for (let e of this.rootNode.querySelectorAll('*[row="'+this.activeRow+'"]'))
{
e.className = e.className.replace(' '+css.hover, '');
}
}
this.activeRow = null;
}
if (this.props.isResizable === undefined || this.props.isResizable)
{
this.onResizeMouseLeave(e);
}
}
setRoot = (e) =>
{
this.rootNode = e;
}
decorateCells = (status, row, decorateProps) =>
{
const rowKey = decorateProps && decorateProps.key;
const css = this.props.theme || default_css;
let cells = [];
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 + ' ' +
(status == 'first' ? ' '+css.first_row : '') +
(status == 'last' ? ' '+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={rowKey} col={j} key={rowKey+'-'+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: rowKey,
key: rowKey+'-'+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++;
}
return cells;
}
render()
{
const css = this.props.theme || default_css;
let rows = this.props.rows;
let cells = [];
let i = 0;
for (let row of rows)
{
let st = i == 0 ? 'first' : (i == rows.length-1 ? 'last' : '');
if (React.isValidElement(row))
{
// Allow element rows
// In fact, this should be the default use-case
cells.push(React.cloneElement(row, {
decorateCells: this.bind('decorateCells', st),
}));
}
else
{
// In other case just process cells in-place
// BUT this is slow, because such cells are reconciled on each render
cells.push.apply(cells, this.decorateCells(st, row, { key: i }));
}
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 key={"mc"+col} className={css.measure_col}
style={{gridColumn: (col+1)}}></div>);
}
for (let row = 0; row < rows.length; row++)
{
cells.push(<div key={"mr"+row} 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}
onMouseOver={this.props.onMouseOver}
onMouseOut={this.props.onMouseOut}
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>);
}
bind = bindCache(this)
}