React table component with support for fixed row(s) and column(s) in pure CSS, using position: sticky and CSS grid
commit
67297ccdce
|
@ -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>);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
Loading…
Reference in New Issue