commit 67297ccdce0eaba1d49ec697f5ed2120366320c1 Author: Vitaliy Filippov Date: Thu Feb 7 17:44:07 2019 +0300 React table component with support for fixed row(s) and column(s) in pure CSS, using position: sticky and CSS grid diff --git a/FixedHeaderGridTable.js b/FixedHeaderGridTable.js new file mode 100644 index 0000000..65f6d8c --- /dev/null +++ b/FixedHeaderGridTable.js @@ -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 , 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
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.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 = (
+ {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: 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, (
+ {cell.props.children} +
)); + } + } + 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(
); + } + 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} +
); + } +} diff --git a/FixedHeaderGridTableBase.css b/FixedHeaderGridTableBase.css new file mode 100644 index 0000000..0e24cdf --- /dev/null +++ b/FixedHeaderGridTableBase.css @@ -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; +} diff --git a/FixedHeaderGridTableRT.css b/FixedHeaderGridTableRT.css new file mode 100644 index 0000000..ea7f8a8 --- /dev/null +++ b/FixedHeaderGridTableRT.css @@ -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); +}