436 lines
14 KiB
JavaScript
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)
|
|
}
|