/** * 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 , 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
with properties or just any valid * react element contents (i.e. it a string, an element or an array) * * [ [
long string
, '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 = (
{this.props.noWrapDefault ?
{cell}
: cell}
); } 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, (
{cell.props.children}
)); } } 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(
); } for (let row = 0; row < rows.length; row++) { cells.push(
); } } 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 (
{cells} {this.props.isResizable === undefined || this.props.isResizable ?
: null}
); } bind = bindCache(this) }