diff --git a/jest.config.js b/jest.config.js index cbf2f177de7..5a32e7f90e5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,53 +1,46 @@ module.exports = { collectCoverage: true, clearMocks: true, - coverageReporters: [ - "lcov" - ], + coverageReporters: ['lcov'], modulePathIgnorePatterns: [ - "/packages/*.*/dist/*.*", - "/packages/*.*/public/*.*", - "/packages/*.*/.cache/*.*" + '/packages/*.*/dist/*.*', + '/packages/*.*/public/*.*', + '/packages/*.*/.cache/*.*' ], coveragePathIgnorePatterns: [ - "/packages/*.*/dist/*.*", - "/packages/*.*/examples/*.*", - "/packages/*.docs.*", - "/packages/react-docs/*.*" + '/packages/*.*/dist/*.*', + '/packages/*.*/examples/*.*', + '/packages/*.docs.*', + '/packages/react-docs/*.*' ], modulePaths: [ - "/**/node_modules/", - "/packages/", - "/packages/patternfly-3/", - "/packages/patternfly-4/" - ], - roots: [ - "/packages" - ], - setupFiles: [ - "./test.env.js" + '/**/node_modules/', + '/packages/', + '/packages/patternfly-3/', + '/packages/patternfly-4/' ], + roots: ['/packages'], + setupFiles: ['./test.env.js'], snapshotSerializers: [ - "enzyme-to-json/serializer", - "/packages/patternfly-4/react-core/build/snapshot-serializer" + 'enzyme-to-json/serializer', + '/packages/patternfly-4/react-core/build/snapshot-serializer' ], transform: { - "^.+\\.(ts|tsx)?$": "ts-jest", - "^.+\\.jsx?$": "babel-jest", - "\\.(css)$": "/packages/patternfly-4/react-styles/jest-transform.js" + '^.+\\.(ts|tsx)?$': 'ts-jest', + '^.+\\.jsx?$': 'babel-jest', + '\\.(css)$': '/packages/patternfly-4/react-styles/jest-transform.js' }, testPathIgnorePatterns: [ - "/scripts/generators/", - "/packages/patternfly-4/react-integration/" - ], - transformIgnorePatterns: [ - "node_modules/(?!@patternfly|@novnc|tippy.js)" + '/scripts/generators/', + '/packages/patternfly-4/react-integration/', + '/node_modules/(?!lodash-es/.*)' ], + transformIgnorePatterns: ['node_modules/(?!@patternfly|@novnc|tippy.js|lodash-es)'], // https://github.com/kulshekhar/ts-jest/blob/master/docs/user/config/index.md - preset: "ts-jest/presets/js-with-babel", + preset: 'ts-jest/presets/js-with-babel', globals: { - "ts-jest": { - tsConfig: "packages/patternfly-4/react-core/tsconfig.jest.json" + 'ts-jest': { + tsConfig: 'packages/patternfly-4/react-core/tsconfig.jest.json' } } -}; \ No newline at end of file +}; diff --git a/packages/patternfly-4/react-docs/gatsby-node.js b/packages/patternfly-4/react-docs/gatsby-node.js index 9623502f2e4..24b7f06fe38 100644 --- a/packages/patternfly-4/react-docs/gatsby-node.js +++ b/packages/patternfly-4/react-docs/gatsby-node.js @@ -92,6 +92,7 @@ exports.onCreateWebpackConfig = ({ stage, actions }) => { '@patternfly/react-core': path.resolve(__dirname, '../react-core'), '@patternfly/react-icons': path.resolve(__dirname, '../../react-icons'), '@patternfly/react-inline-edit-extension': path.resolve(__dirname, '../react-inline-edit-extension'), + '@patternfly/react-virtualized-extension': path.resolve(__dirname, '../react-virtualized-extension'), '@patternfly/react-styled-system': path.resolve(__dirname, '../react-styled-system'), '@patternfly/react-styles': path.resolve(__dirname, '../react-styles'), '@patternfly/react-table': path.resolve(__dirname, '../react-table'), @@ -101,4 +102,4 @@ exports.onCreateWebpackConfig = ({ stage, actions }) => { } }, }) -}; +}; \ No newline at end of file diff --git a/packages/patternfly-4/react-table/package.json b/packages/patternfly-4/react-table/package.json index b5ca251c261..4431c988c5a 100644 --- a/packages/patternfly-4/react-table/package.json +++ b/packages/patternfly-4/react-table/package.json @@ -31,13 +31,14 @@ "@patternfly/react-core": "^3.18.1", "@patternfly/react-icons": "^3.9.2", "@patternfly/react-styles": "^3.2.2", - "exenv": "^1.2.2", - "reactabular-table": "^8.14.0" + "classnames": "^2.2.5", + "exenv": "^1.2.2" }, "peerDependencies": { "prop-types": "^15.6.1", "react": "^16.4.0", - "react-dom": "^15.6.2 || ^16.4.0" + "react-dom": "^15.6.2 || ^16.4.0", + "lodash-es": "4.x" }, "scripts": { "build": "yarn build:babel && node ./scripts/copyTS.js && node ./build/copyStyles.js", diff --git a/packages/patternfly-4/react-table/src/components/Table/Body.js b/packages/patternfly-4/react-table/src/components/Table/Body.js index b658b32c0ea..0783977e36d 100644 --- a/packages/patternfly-4/react-table/src/components/Table/Body.js +++ b/packages/patternfly-4/react-table/src/components/Table/Body.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Body } from 'reactabular-table'; +import { Body } from './base'; import PropTypes from 'prop-types'; import { TableContext } from './Table'; import { isRowExpanded } from './utils'; @@ -29,10 +29,14 @@ const flagVisibility = rows => { class ContextBody extends React.Component { onRow = (row, rowProps) => { - const { onRowClick } = this.props; + const { onRowClick, onRow } = this.props; + const extendedRowProps = { + ...rowProps, + ...(onRow ? onRow(row, rowProps) : {}) + }; return { row, - rowProps, + rowProps: extendedRowProps, onMouseDown: event => { const computedData = { isInput: event.target.tagName !== 'INPUT', diff --git a/packages/patternfly-4/react-table/src/components/Table/BodyWrapper.js b/packages/patternfly-4/react-table/src/components/Table/BodyWrapper.js index 4a0167a7b63..b9c99041ffa 100644 --- a/packages/patternfly-4/react-table/src/components/Table/BodyWrapper.js +++ b/packages/patternfly-4/react-table/src/components/Table/BodyWrapper.js @@ -6,29 +6,36 @@ import PropTypes from 'prop-types'; class BodyWrapper extends Component { render() { - const { mappedRows, ...props } = this.props; - if (mappedRows.some(row => row.hasOwnProperty('parent'))) { + const { mappedRows, tbodyRef, ...props } = this.props; + if (mappedRows && mappedRows.some(row => row.hasOwnProperty('parent'))) { return ( {mapOpenedRows(mappedRows, this.props.children).map((oneRow, key) => ( - + {oneRow.rows} ))} ); } - return ; + return ; } } BodyWrapper.propTypes = { rows: PropTypes.array, - onCollapse: PropTypes.func + onCollapse: PropTypes.func, + tbodyRef: PropTypes.func }; BodyWrapper.defaultProps = { - rows: [] + rows: [], + tbodyRef: null }; export default BodyWrapper; diff --git a/packages/patternfly-4/react-table/src/components/Table/Header.js b/packages/patternfly-4/react-table/src/components/Table/Header.js index af8944f75a8..ff8006dc864 100644 --- a/packages/patternfly-4/react-table/src/components/Table/Header.js +++ b/packages/patternfly-4/react-table/src/components/Table/Header.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Header } from 'reactabular-table'; +import { Header } from './base'; import PropTypes from 'prop-types'; import { TableContext } from './Table'; diff --git a/packages/patternfly-4/react-table/src/components/Table/Table.js b/packages/patternfly-4/react-table/src/components/Table/Table.js index 121d66d0036..b5349662c15 100644 --- a/packages/patternfly-4/react-table/src/components/Table/Table.js +++ b/packages/patternfly-4/react-table/src/components/Table/Table.js @@ -1,7 +1,7 @@ import React from 'react'; import styles from '@patternfly/patternfly/components/Table/table.css'; import stylesGrid from '@patternfly/patternfly/components/Table/table-grid.css'; -import { Provider } from 'reactabular-table'; +import { Provider } from './base'; import { DropdownPosition, DropdownDirection } from '@patternfly/react-core'; import { css, getModifier } from '@patternfly/react-styles'; import PropTypes from 'prop-types'; diff --git a/packages/patternfly-4/react-table/src/components/Table/__snapshots__/Table.test.js.snap b/packages/patternfly-4/react-table/src/components/Table/__snapshots__/Table.test.js.snap index 6b7b09a01fa..4015c5cf143 100644 --- a/packages/patternfly-4/react-table/src/components/Table/__snapshots__/Table.test.js.snap +++ b/packages/patternfly-4/react-table/src/components/Table/__snapshots__/Table.test.js.snap @@ -2399,6 +2399,7 @@ exports[`Actions table 1`] = ` ] } rows={Array []} + tbodyRef={null} > { + const { property, cell, props } = column; + const evaluatedProperty = property || (cell && cell.property); + const { + transforms = [], + formatters = [] + } = cell || {}; // TODO: test against this case + const extraParameters = { + columnIndex, + property: evaluatedProperty, + column, + rowData, + rowIndex, + rowKey + }; + const transformed = evaluateTransforms(transforms, rowData[evaluatedProperty], extraParameters); + + if (!transformed) { + console.warn('Table.Body - Failed to receive a transformed result'); // eslint-disable-line max-len, no-console + } + + return React.createElement( + renderers.cell, + { + key: `${columnIndex}-cell`, + ...mergeProps( + props, + cell && cell.props, + transformed + ) + }, + transformed.children || evaluateFormatters(formatters)(rowData[`_${evaluatedProperty}`] || + rowData[evaluatedProperty], extraParameters) + ); + }) + ); + } +} +BodyRow.defaultProps = tableBodyRowDefaults; +BodyRow.propTypes = tableBodyRowTypes; + +export default BodyRow; \ No newline at end of file diff --git a/packages/patternfly-4/react-table/src/components/Table/base/body.js b/packages/patternfly-4/react-table/src/components/Table/base/body.js new file mode 100644 index 00000000000..7c3b2e8e373 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/body.js @@ -0,0 +1,67 @@ +/** + * body.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import { isEqual, isFunction } from 'lodash-es'; +import React from 'react'; +import { tableBodyTypes, tableBodyDefaults, tableBodyContextTypes } from './types'; +import BodyRow from './body-row'; +import resolveRowKey from './resolve-row-key'; + +class Body extends React.Component { + shouldComponentUpdate(nextProps, nextState, nextContext) { + // eslint-disable-line no-unused-vars + // Skip checking props against `onRow` since that can be bound at render(). + // That's not particularly good practice but you never know how the users + // prefer to define the handler. + + // Check for wrapper based override. + const { renderers } = nextContext; + + if (renderers && renderers.body && renderers.body.wrapper.shouldComponentUpdate) { + if (isFunction(renderers.body.wrapper.shouldComponentUpdate)) { + return renderers.body.wrapper.shouldComponentUpdate.call(this, nextProps, nextState, nextContext); + } + + return true; + } + + return !(isEqual(omitOnRow(this.props), omitOnRow(nextProps)) && isEqual(this.context, nextContext)); + } + render() { + const { onRow, rows, rowKey, ...props } = this.props; + const { columns, renderers } = this.context; + + return React.createElement( + renderers.body.wrapper, + props, + rows.map((rowData, index) => { + const rowIndex = rowData._index || index; + const key = resolveRowKey({ rowData, rowIndex, rowKey }); + + return React.createElement(BodyRow, { + key, + renderers: renderers.body, + onRow, + rowKey: key, + rowIndex, + rowData, + columns + }); + }) + ); + } +} +Body.propTypes = tableBodyTypes; +Body.defaultProps = tableBodyDefaults; +Body.contextTypes = tableBodyContextTypes; + +function omitOnRow(props) { + const { onRow, ...ret } = props; // eslint-disable-line no-unused-vars + + return ret; +} + +export default Body; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/columns-are-equal.js b/packages/patternfly-4/react-table/src/components/Table/base/columns-are-equal.js new file mode 100644 index 00000000000..9541dad5bea --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/columns-are-equal.js @@ -0,0 +1,19 @@ +/** + * columns-are-equal.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import { isFunction, isEqualWith } from 'lodash-es'; + +function columnsAreEqual(oldColumns, newColumns) { + return isEqualWith(oldColumns, newColumns, (a, b) => { + if (isFunction(a) && isFunction(b)) { + return true; + } + + return undefined; + }); +} + +export default columnsAreEqual; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/evaluate-formatters.js b/packages/patternfly-4/react-table/src/components/Table/base/evaluate-formatters.js new file mode 100644 index 00000000000..cf0cfe47be1 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/evaluate-formatters.js @@ -0,0 +1,18 @@ +/** + * evaluate-formatters.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +function evaluateFormatters(formatters) { + return (value, extra) => + formatters.reduce( + (parameters, formatter) => ({ + value: formatter(parameters.value, parameters.extra), + extra + }), + { value, extra } + ).value; +} + +export default evaluateFormatters; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/evaluate-transforms.js b/packages/patternfly-4/react-table/src/components/Table/base/evaluate-transforms.js new file mode 100644 index 00000000000..b0e7deed564 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/evaluate-transforms.js @@ -0,0 +1,24 @@ +/** + * evaluate-transforms.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import { isFunction } from 'lodash-es'; +import mergeProps from './merge-props'; + +function evaluateTransforms(transforms = [], value, extraParameters = {}) { + if (process.env.NODE_ENV !== 'production') { + if (!transforms.every(isFunction)) { + throw new Error("All transforms weren't functions!", transforms); + } + } + + if (transforms.length === 0) { + return {}; + } + + return mergeProps(...transforms.map(transform => transform(value, extraParameters))); +} + +export default evaluateTransforms; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/header-row.js b/packages/patternfly-4/react-table/src/components/Table/base/header-row.js new file mode 100644 index 00000000000..f4925b14477 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/header-row.js @@ -0,0 +1,45 @@ +/** + * header-row.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import React from 'react'; +import evaluateFormatters from './evaluate-formatters'; +import evaluateTransforms from './evaluate-transforms'; +import mergeProps from './merge-props'; +import { tableHeaderRowTypes, tableHeaderRowDefaults } from './types'; + +const HeaderRow = ({ rowData, rowIndex, renderers, onRow }) => + React.createElement( + renderers.row, + onRow(rowData, { rowIndex }), + rowData.map((column, columnIndex) => { + const { property, header = {}, props = {} } = column; + const evaluatedProperty = property || (header && header.property); + const { label, transforms = [], formatters = [] } = header; + const extraParameters = { + columnIndex, + property: evaluatedProperty, + column + }; + const transformedProps = evaluateTransforms(transforms, label, extraParameters); + + if (!transformedProps) { + console.warn('Table.Header - Failed to receive a transformed result'); // eslint-disable-line max-len, no-console + } + + return React.createElement( + renderers.cell, + { + key: `${columnIndex}-header`, + ...mergeProps(props, header && header.props, transformedProps) + }, + transformedProps.children || evaluateFormatters(formatters)(label, extraParameters) + ); + }) + ); +HeaderRow.defaultProps = tableHeaderRowDefaults; +HeaderRow.propTypes = tableHeaderRowTypes; + +export default HeaderRow; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/header.js b/packages/patternfly-4/react-table/src/components/Table/base/header.js new file mode 100644 index 00000000000..49f5c06d01b --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/header.js @@ -0,0 +1,38 @@ +/** + * header.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import React from 'react'; +import { tableHeaderTypes, tableHeaderContextTypes } from './types'; +import HeaderRow from './header-row'; + +// eslint-disable-next-line react/prefer-stateless-function +class Header extends React.Component { + render() { + const { children, headerRows, onRow, ...props } = this.props; + const { renderers, columns } = this.context; + + // If headerRows aren't passed, default to bodyColumns as header rows + return React.createElement( + renderers.header.wrapper, + props, + [ + (headerRows || [columns]).map((rowData, rowIndex) => + React.createElement(HeaderRow, { + key: `${rowIndex}-header-row`, + renderers: renderers.header, + onRow, + rowData, + rowIndex + }) + ) + ].concat(children) + ); + } +} +Header.propTypes = tableHeaderTypes; +Header.contextTypes = tableHeaderContextTypes; + +export default Header; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/index.js b/packages/patternfly-4/react-table/src/components/Table/base/index.js new file mode 100644 index 00000000000..31f9e2760e6 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/index.js @@ -0,0 +1,9 @@ +export { default as Provider } from './provider'; +export { default as Header } from './header'; +export { default as Body } from './body'; +export { default as BodyRow } from './body-row'; +export { default as evaluateFormatters } from './evaluate-formatters'; +export { default as evaluateTransforms } from './evaluate-transforms'; +export { default as mergeProps } from './merge-props'; +export { default as columnsAreEqual } from './columns-are-equal'; +export { default as resolveRowKey } from './resolve-row-key'; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/merge-props.js b/packages/patternfly-4/react-table/src/components/Table/base/merge-props.js new file mode 100644 index 00000000000..e7a58581138 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/merge-props.js @@ -0,0 +1,36 @@ +/** + * merge-props.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import { mergeWith } from 'lodash-es'; +import classNames from 'classnames'; + +function mergePropPair(...props) { + const firstProps = props[0]; + const restProps = props.slice(1); + + if (!restProps.length) { + return mergeWith({}, firstProps); + } + + // Avoid mutating the first prop collection + return mergeWith(mergeWith({}, firstProps), ...restProps, (a, b, key) => { + if (key === 'children') { + // Children have to be merged in reverse order for Reactabular + // logic to work. + return { ...b, ...a }; + } + + if (key === 'className') { + // Process class names through classNames to merge properly + // as a string. + return classNames(a, b); + } + + return undefined; + }); +} + +export default mergePropPair; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/provider.js b/packages/patternfly-4/react-table/src/components/Table/base/provider.js new file mode 100644 index 00000000000..499bba82518 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/provider.js @@ -0,0 +1,56 @@ +/** + * provider.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { tableTypes, tableDefaults, tableContextTypes } from './types'; + +const componentDefaults = tableDefaults.renderers; + +export default class Provider extends React.Component { + getChildContext() { + const { columns, components, renderers } = this.props; + let finalRenderers = renderers; + + // XXXXX: Drop in the next major version + if (components) { + // eslint-disable-next-line no-console + console.warn( + '`components` have been deprecated in favor of `renderers` and will be removed in the next major version, please rename!' + ); + + finalRenderers = components; + } + + return { + columns, + renderers: { + table: finalRenderers.table || componentDefaults.table, + header: { ...componentDefaults.header, ...finalRenderers.header }, + body: { ...componentDefaults.body, ...finalRenderers.body } + } + }; + } + render() { + const { + columns, // eslint-disable-line no-unused-vars + renderers, + components, // XXXXX: Drop in the next major version + children, + ...props + } = this.props; + + return React.createElement(renderers.table || tableDefaults.renderers.table, props, children); + } +} +Provider.propTypes = { + ...tableTypes, + children: PropTypes.any +}; +Provider.defaultProps = { + ...tableDefaults +}; +Provider.childContextTypes = tableContextTypes; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/resolve-row-key.js b/packages/patternfly-4/react-table/src/components/Table/base/resolve-row-key.js new file mode 100644 index 00000000000..ad2d9d74c4a --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/resolve-row-key.js @@ -0,0 +1,31 @@ +/** + * resolve-row-key.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import { isArray } from 'lodash-es'; + +function resolveRowKey({ rowData, rowIndex, rowKey }) { + if (typeof rowKey === 'function') { + return `${rowKey({ rowData, rowIndex })}-row`; + } else if (process.env.NODE_ENV !== 'production') { + // Arrays cannot have rowKeys by definition so we have to go by index there. + if (!isArray(rowData) && rowData[rowKey] === undefined) { + console.warn( + // eslint-disable-line no-console + 'Table.Body - Missing valid rowKey!', + rowData, + rowKey + ); + } + } + + if (rowData[rowKey] === 0) { + return `${rowData[rowKey]}-row`; + } + + return `${rowData[rowKey] || rowIndex}-row`; +} + +export default resolveRowKey; diff --git a/packages/patternfly-4/react-table/src/components/Table/base/types.js b/packages/patternfly-4/react-table/src/components/Table/base/types.js new file mode 100644 index 00000000000..20293559f6c --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/base/types.js @@ -0,0 +1,107 @@ +/** + * types.js + * + * Forked from reactabular-table version 8.14.0 + * https://github.com/reactabular/reactabular/tree/v8.14.0/packages/reactabular-table/src + * */ +import PropTypes from 'prop-types'; + +const arrayOfObjectColumns = PropTypes.arrayOf( + PropTypes.shape({ + header: PropTypes.shape({ + label: PropTypes.string, + transforms: PropTypes.arrayOf(PropTypes.func), + formatters: PropTypes.arrayOf(PropTypes.func), + props: PropTypes.object + }), + cell: PropTypes.shape({ + property: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + transforms: PropTypes.arrayOf(PropTypes.func), + formatters: PropTypes.arrayOf(PropTypes.func), + props: PropTypes.object + }) + }) +); +const arrayOfArrayColumns = PropTypes.arrayOf(PropTypes.array); +const rowsType = PropTypes.oneOfType([arrayOfObjectColumns, arrayOfArrayColumns]); +const rowKeyType = PropTypes.oneOfType([PropTypes.func, PropTypes.string]); +const rowDataType = PropTypes.oneOfType([PropTypes.array, PropTypes.object]); +const tableTypes = { + columns: PropTypes.array.isRequired, + renderers: PropTypes.object, + components: PropTypes.object // XXXXX: Deprecated in favor of renderers, remove in the next major! +}; +const tableContextTypes = { + columns: PropTypes.array.isRequired, + renderers: PropTypes.object +}; +const tableBodyDefaults = { + onRow: () => {} +}; +const tableBodyTypes = { + onRow: PropTypes.func, + rows: rowsType.isRequired, + rowKey: rowKeyType +}; +const tableBodyContextTypes = { + columns: PropTypes.array.isRequired, + renderers: PropTypes.object +}; +const tableBodyRowDefaults = { + onRow: () => ({}) +}; +const tableBodyRowTypes = { + columns: PropTypes.array.isRequired, + renderers: PropTypes.object, + onRow: PropTypes.func, + rowIndex: PropTypes.number.isRequired, + rowData: rowDataType.isRequired, + rowKey: PropTypes.string.isRequired +}; +const tableHeaderTypes = { + headerRows: PropTypes.arrayOf(arrayOfObjectColumns), + children: PropTypes.any +}; +const tableHeaderContextTypes = { + columns: PropTypes.array.isRequired, + renderers: PropTypes.object +}; +const tableHeaderRowDefaults = { + onRow: () => ({}) +}; +const tableHeaderRowTypes = { + renderers: PropTypes.object, + onRow: PropTypes.func, + rowIndex: PropTypes.number.isRequired, + rowData: rowDataType.isRequired +}; +const tableDefaults = { + renderers: { + table: 'table', + header: { + wrapper: 'thead', + row: 'tr', + cell: 'th' + }, + body: { + wrapper: 'tbody', + row: 'tr', + cell: 'td' + } + } +}; + +export { + tableTypes, + tableContextTypes, + tableBodyTypes, + tableBodyDefaults, + tableBodyContextTypes, + tableBodyRowTypes, + tableBodyRowDefaults, + tableHeaderTypes, + tableHeaderContextTypes, + tableHeaderRowTypes, + tableHeaderRowDefaults, + tableDefaults +}; diff --git a/packages/patternfly-4/react-virtualized-extension/.babelrc b/packages/patternfly-4/react-virtualized-extension/.babelrc new file mode 100644 index 00000000000..9779cbaf914 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/.babelrc @@ -0,0 +1,29 @@ +{ + "presets": ["../.babelrc.js"], + "env": { + "production:esm": { + "plugins": [ + [ + "@patternfly/react-styles/babel", + { + "srcDir": "./src", + "outDir": "./dist/esm", + "useModules": true + } + ] + ] + }, + "production:cjs": { + "plugins": [ + [ + "@patternfly/react-styles/babel", + { + "srcDir": "./src", + "outDir": "./dist/js", + "useModules": false + } + ] + ] + } + } +} diff --git a/packages/patternfly-4/react-virtualized-extension/.npmignore b/packages/patternfly-4/react-virtualized-extension/.npmignore new file mode 100644 index 00000000000..378eac25d31 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/.npmignore @@ -0,0 +1 @@ +build diff --git a/packages/patternfly-4/react-virtualized-extension/README.md b/packages/patternfly-4/react-virtualized-extension/README.md new file mode 100644 index 00000000000..ba01cc213be --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/README.md @@ -0,0 +1,5 @@ +# react-virtualized-extension + +This package contains virtualization extensions for tables and lists. + +This package is currently an extension. Extension components do not undergo the same rigorous design or coding review process as core PatternFly components. If enough members of the community find them useful, we will work to move them into our core PatternFly system by starting the design process for the idea. diff --git a/packages/patternfly-4/react-virtualized-extension/build/copyStyles.js b/packages/patternfly-4/react-virtualized-extension/build/copyStyles.js new file mode 100644 index 00000000000..3c501ed0ad6 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/build/copyStyles.js @@ -0,0 +1,42 @@ +/* eslint-disable no-case-declarations */ +const { copySync, readFileSync, writeFileSync } = require('fs-extra'); +const { resolve, dirname, join } = require('path'); +const { parse: parseCSS, stringify: stringifyCSS } = require('css'); + +const baseCSSFilename = 'patternfly-base.css'; +const stylesDir = resolve(__dirname, '../dist/styles'); +const pfDir = dirname(require.resolve(`@patternfly/patternfly/${baseCSSFilename}`)); + +const css = readFileSync(join(pfDir, baseCSSFilename), 'utf8'); +const ast = parseCSS(css); + +const unusedSelectorRegEx = /(\.fas?|\.sr-only)/; +const unusedKeyFramesRegEx = /fa-/; +const unusedFontFamilyRegEx = /Font Awesome 5 Free/; +const ununsedFontFilesRegExt = /(fa-|\.html$|\.css$)/; + +// Core provides font awesome fonts and utlities. React does not use these +ast.stylesheet.rules = ast.stylesheet.rules.filter(rule => { + switch (rule.type) { + case 'rule': + return !rule.selectors.some(sel => unusedSelectorRegEx.test(sel)); + case 'keyframes': + return !unusedKeyFramesRegEx.test(rule.name); + case 'charset': + case 'comment': + return false; + case 'font-face': + const fontFamilyDecl = rule.declarations.find(decl => decl.property === 'font-family'); + return !unusedFontFamilyRegEx.test(fontFamilyDecl.value); + default: + return true; + } +}); + +copySync(join(pfDir, 'assets/images'), join(stylesDir, 'assets/images')); +copySync(join(pfDir, 'assets/fonts'), join(stylesDir, 'assets/fonts'), { + filter(src) { + return !ununsedFontFilesRegExt.test(src); + } +}); +writeFileSync(join(stylesDir, 'base.css'), stringifyCSS(ast)); diff --git a/packages/patternfly-4/react-virtualized-extension/build/snapshot-serializer.js b/packages/patternfly-4/react-virtualized-extension/build/snapshot-serializer.js new file mode 100644 index 00000000000..0edb3cb5b4c --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/build/snapshot-serializer.js @@ -0,0 +1,8 @@ +const fs = require('fs'); +const { createSerializer } = require('@patternfly/react-styles/snapshot-serializer'); + +const pf4CSS = fs.readFileSync(require.resolve('@patternfly/patternfly/patternfly-base.css'), 'utf8'); + +module.exports = createSerializer({ + globalCSS: pf4CSS.match(/:root\W?\{(.|\n)*?\}/)[0] +}); diff --git a/packages/patternfly-4/react-virtualized-extension/package.json b/packages/patternfly-4/react-virtualized-extension/package.json new file mode 100644 index 00000000000..8cc8de4868c --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/package.json @@ -0,0 +1,57 @@ +{ + "name": "@patternfly/react-virtualized-extension", + "version": "2.1.0", + "description": "This library provides efficient rendering extensions for PatternFly 4 React tables and lists.", + "main": "dist/js/index.js", + "module": "dist/esm/index.js", + "types": "dist/js/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public", + "tag": "prerelease" + }, + "repository": { + "type": "git", + "url": "https://github.com/patternfly/patternfly-react.git" + }, + "keywords": [ + "react", + "patternfly", + "table", + "reacttabular" + ], + "author": "Red Hat", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/patternfly/patternfly-react/issues" + }, + "homepage": "https://github.com/patternfly/patternfly-react/tree/master/packages/patternfly-4/", + "dependencies": { + "@patternfly/patternfly": "2.6.5", + "@patternfly/react-core": "^3.18.1", + "@patternfly/react-icons": "^3.9.2", + "@patternfly/react-styles": "^3.2.2", + "exenv": "^1.2.2" + }, + "peerDependencies": { + "@patternfly/react-table": "^2.6.1", + "lodash-es": "4.x", + "prop-types": "^15.6.1", + "react": "^16.4.0", + "react-dom": "^15.6.2 || ^16.4.0" + }, + "scripts": { + "build": "yarn build:babel && yarn build:ts", + "build:babel": "concurrently \"yarn build:babel:cjs\" \"yarn build:babel:esm\"", + "build:babel:cjs": "cross-env BABEL_ENV=production:cjs babel src --out-dir dist/js", + "build:babel:esm": "cross-env BABEL_ENV=production:esm babel src --out-dir dist/esm", + "build:ts": "node ./scripts/copyTS.js", + "postbuild": "node ./build/copyStyles.js" + }, + "devDependencies": { + "css": "^2.2.3", + "fs-extra": "^6.0.1", + "glob": "^7.1.2", + "uuid": "^3.3.2" + } +} diff --git a/packages/patternfly-4/react-virtualized-extension/scripts/copyTS.js b/packages/patternfly-4/react-virtualized-extension/scripts/copyTS.js new file mode 100644 index 00000000000..569890d173f --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/scripts/copyTS.js @@ -0,0 +1,15 @@ +const path = require('path'); +const glob = require('glob'); +const fse = require('fs-extra'); + +const srcDir = path.join('./src'); +const distDir = path.join('./dist/js'); + +const files = glob.sync('**/*.d.ts', { + cwd: srcDir +}); +files.forEach(file => { + const from = path.join(srcDir, file); + const to = path.join(distDir, file); + fse.copySync(from, to); +}); diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Body.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Body.js new file mode 100644 index 00000000000..0942fd8dead --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Body.js @@ -0,0 +1,303 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { TableBody, TableContext } from '@patternfly/react-table'; +import calculateAverageHeight from './utils/calculateAverageHeight'; +import calculateRows from './utils/calculateRows'; +import createDetectElementResize from './utils/detectElementResize'; + +const initialContext = { + amountOfRowsToRender: 0, + startIndex: 0, // Index where to start rendering + startHeight: 0, // Heights for extra rows to mimic scrolling + endHeight: 0, + showExtraRow: false // Show extra row (even/odd issue) +}; +export const VirtualizedBodyContext = React.createContext(initialContext); + +class Body extends React.Component { + state = initialContext; + measuredRows = {}; // row key -> measurement + tbodyRef = null; // tbody ref used for gathering scroll position + initialMeasurement = true; + scrollTop = 0; + timeoutId = 0; + + constructor(props) { + super(props); + this.onScroll = this.onScroll.bind(this); + this.onResize = this.onResize.bind(this); + } + + setTbodyRef = element => { + this.tbodyRef = element; + }; + + scrollTo = index => { + const { rows, rowKey } = this.props; + const startIndex = parseInt(index, 10); + + if (startIndex >= 0) { + const startHeight = calculateAverageHeight(this.measuredRows) * startIndex; + + this.scrollTop = startHeight; + this.tbodyRef.scrollTop = startHeight; + + this.setState(this.calculateRows()); + } + }; + + checkMeasurements = prevProps => { + // If there are no valid measurements or the rows have changed, + // calculate some after waiting a while. Without this some styling solutions + // won't work as you might expect given they can take a while to set container height. + if (this.initialMeasurement || (prevProps && prevProps.rows !== this.props.rows)) { + // If the rows have changed, but the user has not scrolled, maintain the existing + // scroll position + if (this.tbodyRef) { + this.tbodyRef.scrollTop = this.scrollTop; + } + this.timeoutId = setTimeout(() => { + const rows = this.calculateRows(); + + if (!rows) { + // Refresh the rows to trigger measurement. + this.forceUpdate(); + + return; + } + + this.setState(rows, () => { + this.initialMeasurement = false; + }); + }, 100); + } + }; + + getHeight = () => { + const { container, height, style } = this.props; + if (container && container()) { + return container().clientHeight; + } + // If `props.height` is not defined, we use `props.style.maxHeight` instead. + return height || style.maxHeight; + }; + + // Attach information about measuring status. This way we can implement + // proper shouldComponentUpdate + rowsToRender = (rows, startIndex, amountOfRowsToRender, rowKey) => { + const renderedRows = rows.slice(startIndex, startIndex + amountOfRowsToRender).map((rowData, rowIndex) => { + const ariaRowIndex = startIndex + rowIndex + 1; // aria-rowindex should be 1-based, not 0-based. + return { + ...rowData, + 'aria-rowindex': ariaRowIndex, + _measured: !!this.measuredRows[ariaRowIndex] + }; + }); + return renderedRows; + }; + + getBodyOffset = container => + // this is a bug in reactabular-virtualized, return this.tbodyRef.parentElement.offsetTop + this.tbodyRef.offsetTop; + // simply returning the tbodyRef.offsetTop does not account for cases that other parent elements set position:relative + // could be the offset parent. We want the offset from tbody to the passed container element. This can change as the + // user scrolls so it should only be calculated initially or on resize after scroll position is reset. + this.tbodyRef.getBoundingClientRect().top - container.getBoundingClientRect().top; + + registerContainer = () => { + setTimeout(() => { + const element = this.props.container(); + if (element) { + element && element.addEventListener('scroll', this.onScroll); + this._detectElementResize = createDetectElementResize(); + this._detectElementResize.addResizeListener(element, this.onResize); + this.setContainerOffset(); + } + }, 0); + }; + + unregisterContainer = () => { + const element = this.props.container(); + if (element && element.__resizeListeners__) { + element.removeEventListener('scroll', this.onScroll); + } + }; + + setContainerOffset = () => { + const element = this.props.container && this.props.container(); + if (element) { + this.containerOffset = this.getBodyOffset(element); + } + }; + + calculateRows = () => { + const { rows, rowKey } = this.props; + return calculateRows({ + scrollTop: this.scrollTop, + measuredRows: this.measuredRows, + height: this.getHeight(), + rowKey, + rows + }); + }; + + componentDidMount() { + this.checkMeasurements(); + if (this.props.container) { + this.registerContainer(); + } else { + this._detectElementResize = createDetectElementResize(); + this._detectElementResize.addResizeListener(this.tbodyRef, this.onResize); + } + } + + componentDidUpdate(prevProps) { + this.checkMeasurements(prevProps); + } + + componentWillUnmount() { + if (this.tbodyRef && this.tbodyRef.__resizeListeners__) { + this._detectElementResize.removeResizeListener(this.tbodyRef, this.onResize); + } + if (this.props.container) { + this.unregisterContainer(); + } + clearTimeout(this.timeoutId); + } + + onResize() { + // if the containing element resizes reset all measurements & `measuredRows` + this.initialMeasurement = true; + this.scrollTop = 0; + this.setState(initialContext); + this.measuredRows = {}; + this.setContainerOffset(); + this.checkMeasurements(); + } + + onScroll(e) { + const { onScroll, container } = this.props; + onScroll && onScroll(e); + + const { + target: { scrollTop } + } = e; + + // Y didn't change, bail to avoid rendering rows + if (this.scrollTop === scrollTop) { + return; + } + this.scrollTop = container ? scrollTop - this.containerOffset : scrollTop; + this.setState(this.calculateRows()); + } + + render() { + const { onRow, rows, onScroll, container, rowKey, ...props } = this.props; + const { startIndex, amountOfRowsToRender, startHeight, endHeight, showExtraRow } = this.state; + const height = this.getHeight(); + + const rowsToRender = this.rowsToRender(rows, startIndex, amountOfRowsToRender, rowKey); + if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined' && window.LOG_VIRTUALIZED) { + console.log( + // eslint-disable-line no-console + 'rendering', + rowsToRender.length, + '/', + rows.length, + 'rows to render', + rowsToRender, + 'start index', + startIndex, + 'amount of rows to render', + amountOfRowsToRender + ); + } + + const style = { height }; + + const tableBodyProps = { + ...props, + height, + style, + onRow: (row, extra) => ({ + // Pass index so that row heights can be tracked properly + 'data-id': row.id || row['aria-rowindex'], + 'aria-rowindex': row['aria-rowindex'], + ...(onRow ? onRow(row, extra) : {}) + }), + rows: rowsToRender + }; + + if (!container) { + // do not listen to tbody onScroll if we are using window scroller + tableBodyProps.onScroll = this.onScroll; + // if we do not have a parent container to scroll, set the body to scroll + tableBodyProps.style.display = 'block'; + tableBodyProps.style.overflow = 'auto'; + } + + return ( + { + this.measuredRows[oneRowKey] = rowHeight; + }, + // Capture height data only during the initial measurement or during resize + initialMeasurement: this.initialMeasurement + }} + > + + + ); + } +} +Body.propTypes = { + ...TableBody.propTypes, + height: heightPropCheck, + container: PropTypes.func +}; +Body.defaultProps = { + height: undefined, + container: undefined +}; + +const VirtualizedBody = ({ tableBody, ...props }) => ( + + {({ headerData, rows }) => } + +); + +VirtualizedBody.defaultProps = TableBody.defaultProps; +VirtualizedBody.propTypes = { + /** Additional classes for table body. */ + className: PropTypes.string, + /** Specify key which should be used for labeling each row. */ + rowKey: PropTypes.string, + /** Function that is fired when user clicks on row. */ + onRowClick: PropTypes.func, + /** the height of the body or window container */ + height: heightPropCheck, + /** a callback return the container ref */ + container: PropTypes.func, + /** a react ref that can be used by the consumer to scroll to a given index */ + tableBody: PropTypes.object +}; + +export function heightPropCheck(props, propName, componentName) { + if ( + typeof props[propName] !== 'number' && + (!props.style || typeof props.style.maxHeight !== 'number') && + (!props.container || typeof props.container !== 'function') + ) { + return new Error( + `height or style.maxHeight of type 'number' or container of type 'function' is marked as required in ${componentName}` + ); + } + + return undefined; +} + +export default VirtualizedBody; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/BodyWrapper.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/BodyWrapper.js new file mode 100644 index 00000000000..7b0fbfaf41b --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/BodyWrapper.js @@ -0,0 +1,86 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { VirtualizedBodyContext } from './Body'; +import { BodyWrapper as ReactTableBodyWrapper } from '@patternfly/react-table'; +import { bodyWrapperContextTypes, bodyWrapperTypes } from './types'; + +import { virtualizedCss } from './css/virtualized-css'; + +virtualizedCss.inject(); + +class BodyWrapper extends Component { + tr = props => React.createElement('tr', props); + render() { + const { children, tbodyRef, startHeight, endHeight, showExtraRow, mappedRows, ...props } = this.props; + const startRow = this.tr({ + key: 'start-row', + style: { + height: startHeight + }, + 'aria-hidden': true, + className: 'pf-virtualized-spacer' + }); + const endRow = this.tr({ + key: 'end-row', + style: { + height: endHeight + }, + 'aria-hidden': true, + className: 'pf-virtualized-spacer' + }); + // Extra row to keep onRow indexing stable instead of even/odd. This is important + // for styling. + const rows = [startRow].concat(children).concat(endRow); + + if (showExtraRow) { + rows.unshift( + this.tr({ + key: 'extra-row', + style: { + height: 0 + }, + 'aria-hidden': true, + className: 'pf-virtualized-spacer' + }) + ); + } + + return ( + + {rows} + + ); + } +} +BodyWrapper.contextTypes = bodyWrapperContextTypes; +BodyWrapper.propTypes = { + ...bodyWrapperTypes, + ...ReactTableBodyWrapper.propTypes +}; + +const propTypes = { + rows: PropTypes.array, + tbodyRef: PropTypes.func +}; +const defaultProps = { + rows: [], + tbodyRef: null +}; + +const VirtualizedBodyWrapper = ({ ...props }) => ( + + {({ tbodyRef, startHeight, endHeight, showExtraRow }) => ( + + )} + +); +VirtualizedBodyWrapper.propTypes = propTypes; +VirtualizedBodyWrapper.defaultProps = defaultProps; + +export default VirtualizedBodyWrapper; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/RowWrapper.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/RowWrapper.js new file mode 100644 index 00000000000..3830525fd1a --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/RowWrapper.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { RowWrapper } from '@patternfly/react-table'; +import { VirtualizedBodyContext } from './Body'; + +class VirtualizedRowWrapper extends React.Component { + trRef = null; + + setTrRef = element => { + this.trRef = element; + }; + + updateRowHeight = () => { + if (this.trRef) { + const { updateHeight, rowProps } = this.props; + updateHeight(rowProps['aria-rowindex'], this.getAbsoluteHeight(this.trRef)); + } + }; + + // offsetHeight does not include margins, so we use this helper for better accuracy + getAbsoluteHeight = el => { + const styles = window.getComputedStyle(el); + const margin = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom); + return Math.ceil(el.offsetHeight + margin); + }; + + componentDidMount() { + this.updateRowHeight(); + } + componentDidUpdate() { + // update height every update since we have flex css that can change row heights after resize rendering + this.updateRowHeight(); + } + + render() { + const { updateHeight, initialMeasurement, row, rowProps, ...props } = this.props; + return ( + + ); + } +} +VirtualizedRowWrapper.propTypes = { + ...RowWrapper.propTypes, + rowProps: PropTypes.shape({ + 'data-id': PropTypes.string.isRequired, + 'aria-rowindex': PropTypes.number.isRequired + }).isRequired, + updateHeight: PropTypes.func.isRequired, + initialMeasurement: PropTypes.bool.isRequired +}; + +const VirtualizedRowWrapperWithContext = props => ( + + {({ updateHeight, initialMeasurement }) => ( + + )} + +); + +export default VirtualizedRowWrapperWithContext; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Virtualized.md b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Virtualized.md new file mode 100644 index 00000000000..d397d5ea3a4 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Virtualized.md @@ -0,0 +1,292 @@ +--- +title: 'Table' +section: 'Virtual Scroll' +--- + +import React from 'react'; +import { Table, TableHeader } from '@patternfly/react-table'; +import { +VirtualizedBody, +VirtualizedBodyWrapper, +VirtualizedRowWrapper +} from '@patternfly/react-virtualized-extension'; + +import UUID from 'uuid/v1'; + +## Simple Example + +```js +import React from 'react'; +import { Table, TableHeader } from '@patternfly/react-table'; +import { + VirtualizedBody, + VirtualizedBodyWrapper, + VirtualizedRowWrapper +} from '@patternfly/react-virtualized-extension'; + +import UUID from 'uuid/v1'; + +class VirtualizedExample extends React.Component { + constructor(props) { + super(props); + const rows = []; + for (let i = 0; i < 100; i++) { + rows.push({ + id: UUID(), + cells: [`one-${i}`, `two-${i}`, `three-${i}`, `four-${i}`, `five-${i}`] + }); + } + this.state = { + columns: [ + { title: 'Repositories' }, + { title: 'Branches' }, + { title: 'Pull requests' }, + { title: 'Workspaces' }, + { title: 'Last Commit' } + ], + rows + }; + } + + render() { + const { columns, rows } = this.state; + + return ( + + + +
+ ); + } +} + +export default VirtualizedExample; +``` + +## Sortable Example + +```js +import React from 'react'; +import { Table, TableHeader, sortable, SortByDirection } from '@patternfly/react-table'; +import { + VirtualizedBody, + VirtualizedBodyWrapper, + VirtualizedRowWrapper +} from '@patternfly/react-virtualized-extension'; + +import UUID from 'uuid/v1'; + +class SortableExample extends React.Component { + constructor(props) { + super(props); + + this.tableBody = React.createRef(); + + const rows = []; + for (let i = 0; i < 100; i++) { + rows.push({ + id: UUID(), + cells: [`one-${i}`, `two-${i}`, `three-${i}`, `four-${i}`, `five-${i}`] + }); + } + this.state = { + columns: [ + { title: 'Repositories', transforms: [sortable] }, + { title: 'Branches' }, + { title: 'Pull requests', transforms: [sortable] }, + { title: 'Workspaces' }, + { title: 'Last Commit' } + ], + rows, + sortBy: {} + }; + this.onSort = this.onSort.bind(this); + } + + onSort(_event, index, direction) { + const sortedRows = this.state.rows.sort((a, b) => + a.cells[index] < b.cells[index] ? -1 : a.cells[index] > b.cells[index] ? 1 : 0 + ); + this.tableBody.current.scrollTo(0); + this.setState({ + sortBy: { + index, + direction + }, + rows: direction === SortByDirection.asc ? sortedRows : sortedRows.reverse() + }); + } + + render() { + const { columns, rows, sortBy } = this.state; + + return ( + + + +
+ ); + } +} + +export default SortableExample; +``` + +## Selectable Example + +```js +import React from 'react'; +import { Table, TableHeader, headerCol } from '@patternfly/react-table'; +import { + VirtualizedBody, + VirtualizedBodyWrapper, + VirtualizedRowWrapper +} from '@patternfly/react-virtualized-extension'; + +import UUID from 'uuid/v1'; + +class SelectableExample extends React.Component { + constructor(props) { + super(props); + const rows = []; + for (let i = 0; i < 100; i++) { + rows.push({ + id: UUID(), + cells: [`one-${i}`, `two-${i}`, `three-${i}`, `four-${i}`, `five-${i}`] + }); + } + this.state = { + columns: [ + { title: 'Repositories', cellTransforms: [headerCol()] }, + { title: 'Branches' }, + { title: 'Pull requests' }, + { title: 'Workspaces' }, + { title: 'Last Commit' } + ], + rows + }; + this.onSelect = this.onSelect.bind(this); + } + + onSelect(event, isSelected, virtualRowIndex, rowData) { + let rows; + if (virtualRowIndex === -1) { + rows = this.state.rows.map(oneRow => { + oneRow.selected = isSelected; + return oneRow; + }); + } else { + rows = [...this.state.rows]; + const rowIndex = rows.findIndex(r => r.id === rowData.id); + rows[rowIndex].selected = isSelected; + } + this.setState({ + rows + }); + } + + render() { + const { columns, rows } = this.state; + + return ( + + + +
+ ); + } +} + +export default SelectableExample; +``` + +## Dynamic Height Example + +```js +import React from 'react'; +import { Table, TableHeader } from '@patternfly/react-table'; +import { + VirtualizedBody, + VirtualizedBodyWrapper, + VirtualizedRowWrapper +} from '@patternfly/react-virtualized-extension'; + +import UUID from 'uuid/v1'; + +class DynamicHeightExample extends React.Component { + constructor(props) { + super(props); + const rows = []; + for (let i = 0; i < 100000; i++) { + const cells = []; + const num = Math.floor(Math.random() * Math.floor(9)) + 1; + for (let j = 0; j < 5; j++) { + const cellValue = i.toString() + ' Arma virumque cano Troiae qui primus ab oris. '.repeat(num); + cells.push(cellValue); + } + rows.push({ + id: UUID(), + cells + }); + } + this.state = { + columns: [ + { title: 'Repositories' }, + { title: 'Branches' }, + { title: 'Pull requests' }, + { title: 'Workspaces' }, + { title: 'Last Commit' } + ], + rows + }; + } + + render() { + const { columns, rows } = this.state; + + return ( + + + +
+ ); + } +} + +export default DynamicHeightExample; +``` diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.js new file mode 100644 index 00000000000..f4d8051d489 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.js @@ -0,0 +1,262 @@ +/** + * WindowScroller.js + * https://github.com/bvaughn/react-virtualized/blob/9.21.0/source/WindowScroller/WindowScroller.js + * Brian Vaughn + * + * Forked from version 9.21.0; includes the following modifications: + * 1) Allow scrollElement to be queryable as a string using document.querySelector or passed as an element + * */ + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import { canUseDOM } from 'exenv'; +import { registerScrollListener, unregisterScrollListener } from './utils/onScroll'; +import { getDimensions, getPositionOffset, getScrollOffset } from './utils/dimensions'; +import createDetectElementResize from './utils/detectElementResize'; + +/** + * Specifies the number of miliseconds during which to disable pointer events while a scroll is in progress. + * This improves performance and makes scrolling smoother. + */ +export const IS_SCROLLING_TIMEOUT = 150; + +const getWindow = () => (typeof window !== 'undefined' ? window : undefined); + +class WindowScroller extends React.PureComponent { + static defaultProps = { + onResize: () => {}, + onScroll: () => {}, + scrollingResetTimeInterval: IS_SCROLLING_TIMEOUT, + scrollElement: getWindow(), + serverHeight: 0, + serverWidth: 0 + }; + + _window = getWindow(); + _isMounted = false; + _positionFromTop = 0; + _positionFromLeft = 0; + _detectElementResize = { + addResizeListener: () => null, + removeResizeListener: () => null + }; + _child = null; + + constructor(props) { + super(props); + this.state = { + ...getDimensions(this._getScrollElement(), this.props), + isScrolling: false, + scrollLeft: 0, + scrollTop: 0 + }; + this.onResize = this.onResize.bind(this); + this.updatePosition = this.updatePosition.bind(this); + } + + onResize() { + this.updatePosition(); + } + + updatePosition(scrollable) { + if (!canUseDOM) { + return null; + } + + const { onResize } = this.props; + const { height, width } = this.state; + const scrollElement = this._getScrollElement(); + + const thisNode = this._child || ReactDOM.findDOMNode(this); + if (thisNode instanceof Element && scrollElement) { + const offset = getPositionOffset(thisNode, scrollElement); + this._positionFromTop = offset.top; + this._positionFromLeft = offset.left; + } + + const dimensions = getDimensions(scrollElement, this.props); + if (height !== dimensions.height || width !== dimensions.width) { + this.setState({ + height: dimensions.height, + width: dimensions.width + }); + onResize({ + height: dimensions.height, + width: dimensions.width + }); + } + } + + componentDidMount() { + const scrollElement = this._getScrollElement(); + this._detectElementResize = createDetectElementResize(); + + this.updatePosition(scrollElement); + + if (scrollElement) { + registerScrollListener(this, scrollElement); + this._registerResizeListener(scrollElement); + } + + this._isMounted = true; + } + + componentDidUpdate(prevProps, prevState) { + if (!canUseDOM) { + return; + } + const prevScrollElement = document.getElementById(prevProps.scrollElement); + const scrollElement = this._getScrollElement(); + + if (prevScrollElement !== scrollElement && prevScrollElement != null && scrollElement != null) { + this.updatePosition(scrollElement); + + unregisterScrollListener(this, prevScrollElement); + registerScrollListener(this, scrollElement); + + this._unregisterResizeListener(prevScrollElement); + this._registerResizeListener(scrollElement); + } + } + + componentWillUnmount() { + const scrollElement = this._getScrollElement(); + if (scrollElement) { + unregisterScrollListener(this, scrollElement); + this._unregisterResizeListener(scrollElement); + } + + this._isMounted = false; + } + + render() { + const { children } = this.props; + const { isScrolling, scrollTop, scrollLeft, height, width } = this.state; + + return children({ + onChildScroll: this._onChildScroll, + registerChild: this._registerChild, + height, + isScrolling, + scrollLeft, + scrollTop, + width + }); + } + + _getScrollElement() { + if (!canUseDOM) { + return null; + } + + const { scrollElement } = this.props; + if (typeof scrollElement === 'string') { + return document.querySelector(scrollElement); + } + // scrollElement defaults to Window + return scrollElement; + } + + _registerChild = element => { + if (element && !(element instanceof Element)) { + console.warn('WindowScroller registerChild expects to be passed Element or null'); + } + this._child = element; + this.updatePosition(); + }; + + _onChildScroll = ({ scrollTop }) => { + if (this.state.scrollTop === scrollTop) { + return; + } + const scrollElement = this._getScrollElement(); + if (scrollElement) { + if (typeof scrollElement.scrollTo === 'function') { + scrollElement.scrollTo(0, scrollTop + this._positionFromTop); + } else { + scrollElement.scrollTop = scrollTop + this._positionFromTop; + } + } + }; + + _registerResizeListener = element => { + if (element === window) { + window.addEventListener('resize', this.onResize, false); + } else { + this._detectElementResize.addResizeListener(element, this.onResize); + } + }; + + _unregisterResizeListener = element => { + if (element === window) { + window.removeEventListener('resize', this.onResize, false); + } else if (element && element.__resizeListeners__) { + this._detectElementResize.removeResizeListener(element, this.onResize); + } + }; + + // Referenced by utils/onScroll + __handleWindowScrollEvent = () => { + if (!this._isMounted) { + return; + } + + const { onScroll } = this.props; + const scrollElement = this._getScrollElement(); + + if (scrollElement) { + const scrollOffset = getScrollOffset(scrollElement); + const scrollLeft = Math.max(0, scrollOffset.left - this._positionFromLeft); + const scrollTop = Math.max(0, scrollOffset.top - this._positionFromTop); + + this.setState({ + isScrolling: true, + scrollLeft, + scrollTop + }); + + onScroll({ + scrollLeft, + scrollTop + }); + } + }; + + // Referenced by utils/onScroll + __resetIsScrolling = () => { + this.setState({ + isScrolling: false + }); + }; +} + +WindowScroller.propTypes = { + /** + * Function responsible for rendering children. + * This function should implement the following signature: + * ({ height, isScrolling, scrollLeft, scrollTop, width }) => PropTypes.element + */ + children: PropTypes.func.isRequired, + + /** Callback to be invoked on-resize: ({ height, width }) */ + onResize: PropTypes.func, + + /** Callback to be invoked on-scroll: ({ scrollLeft, scrollTop }) */ + onScroll: PropTypes.func, + + /** Query string for element to attach scroll event listeners. Defaults to window if no element query string provided. */ + scrollElement: PropTypes.string, + /** + * Wait this amount of time after the last scroll event before resetting child `pointer-events`. + */ + scrollingResetTimeInterval: PropTypes.number, + + /** Height used for server-side rendering */ + serverHeight: PropTypes.number, + + /** Width used for server-side rendering */ + serverWidth: PropTypes.number +}; + +export default WindowScroller; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.md b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.md new file mode 100644 index 00000000000..56543f169b6 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.md @@ -0,0 +1,93 @@ +--- +title: 'Window Scroller' +section: 'Virtual Scroll' +--- + +import React from 'react'; +import { Table, TableHeader } from '@patternfly/react-table'; +import { +VirtualizedBody, +VirtualizedBodyWrapper, +VirtualizedRowWrapper, +WindowScroller +} from '@patternfly/react-virtualized-extension'; +import UUID from 'uuid/v1'; + +## Window Scroller Example + +```js +import React from 'react'; +import { Table, TableHeader } from '@patternfly/react-table'; +import { + VirtualizedBody, + VirtualizedBodyWrapper, + VirtualizedRowWrapper, + WindowScroller +} from '@patternfly/react-virtualized-extension'; + +class WindowScrollerExample extends React.Component { + constructor(props) { + super(props); + this.container = null; + this.setContainer = element => { + this.container = element; + }; + const rows = []; + for (let i = 0; i < 100000; i++) { + rows.push({ + id: UUID(), + cells: [`one-${i}`, `two-${i}`, `three-${i}`, `four-${i}`, `five-${i}`] + }); + } + this.state = { + columns: [ + { title: 'Repositories' }, + { title: 'Branches' }, + { title: 'Pull requests' }, + { title: 'Workspaces' }, + { title: 'Last Commit' } + ], + rows + }; + } + + render() { + const { columns, rows } = this.state; + const defaultHeight = 400; + + return ( +
+
+ + {({ height, isScrolling, registerChild, onChildScroll, scrollTop }) => ( + + + this.container} rowKey="id" /> +
+ )} +
+
+
+ ); + } +} + +export default WindowScrollerExample; +``` diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/css/virtualized-css.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/css/virtualized-css.js new file mode 100644 index 00000000000..bf30375f95d --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/css/virtualized-css.js @@ -0,0 +1,53 @@ +import { StyleSheet } from '@patternfly/react-styles'; + +export const virtualizedCss = StyleSheet.parse(` + /* virtualized tables use aria-hidden tr's to offset scrolled rows - + do not add extra spacing to these elements as offset height is important + */ + .pf-virtualized-spacer { + padding: 0 !important; + margin: 0 !important; + border: 0 !important; + } + + /* Based on the following css from reactabular-virtualized: + https://reactabular.js.org/#/features/virtualization?a=using-relative-column-widths + */ + .pf-c-virtualized.pf-c-table { + display: flex; + flex-flow: column; + } + + .pf-c-virtualized.pf-c-table thead, + .pf-c-virtualized.pf-c-table tbody tr { + display: table; + table-layout: fixed; + } + + .pf-c-virtualized.pf-c-table thead { + /* flex: 0 0 auto; */ + width: 100%; + } + + .pf-c-virtualized.pf-c-table thead tr { + /* 0.9em approximates scrollbar width */ + /* width: calc(100% - 0.9em); */ + width: 100%; + } + + .pf-c-virtualized.pf-c-table tbody { + display: block; + /* flex: 1 1 auto; */ + overflow-y: scroll; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + } + + .pf-c-virtualized.pf-c-table tbody tr { + width: 100%; + } + .pf-c-virtualized.pf-c-table th, + .pf-c-virtualized.pf-c-table td { + width: 20%; + } +`); diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/index.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/index.js new file mode 100644 index 00000000000..474ca69c885 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/index.js @@ -0,0 +1,4 @@ +export { default as VirtualizedBody, VirtualizedBodyContext } from './Body'; +export { default as VirtualizedBodyWrapper } from './BodyWrapper'; +export { default as VirtualizedRowWrapper } from './RowWrapper'; +export { default as WindowScroller } from './WindowScroller'; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/types.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/types.js new file mode 100644 index 00000000000..38e92301118 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/types.js @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; + +const bodyRowContextTypes = { + initialMeasurement: PropTypes.bool, + updateHeight: PropTypes.func +}; + +const bodyWrapperContextTypes = { + startHeight: PropTypes.number, + endHeight: PropTypes.number, + showExtraRow: PropTypes.bool +}; +const bodyWrapperTypes = { + children: PropTypes.any +}; +const bodyChildContextTypes = { + ...bodyRowContextTypes, + ...bodyWrapperContextTypes +}; + +export { bodyChildContextTypes, bodyRowContextTypes, bodyWrapperContextTypes, bodyWrapperTypes }; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/animationFrame.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/animationFrame.js new file mode 100644 index 00000000000..3282efb3120 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/animationFrame.js @@ -0,0 +1,41 @@ +/** + * animationFrame.js + * https://github.com/bvaughn/react-virtualized/blob/9.21.0/source/utils/animationFrame.js + * Brian Vaughn + * + * Forked from version 9.21.0 + * */ + +// Properly handle server-side rendering. +let win; +if (typeof window !== 'undefined') { + win = window; + // eslint-disable-next-line no-restricted-globals +} else if (typeof self !== 'undefined') { + // eslint-disable-next-line no-restricted-globals + win = self; +} else { + win = {}; +} + +// requestAnimationFrame() shim by Paul Irish +// http://paulirish.com/2011/requestanimationframe-for-smart-animating/ +export const raf = + win.requestAnimationFrame || + win.webkitRequestAnimationFrame || + win.mozRequestAnimationFrame || + win.oRequestAnimationFrame || + win.msRequestAnimationFrame || + function raf(callback) { + return win.setTimeout(callback, 1000 / 60); + }; + +export const caf = + win.cancelAnimationFrame || + win.webkitCancelAnimationFrame || + win.mozCancelAnimationFrame || + win.oCancelAnimationFrame || + win.msCancelAnimationFrame || + function cT(id) { + win.clearTimeout(id); + }; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateAverageHeight.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateAverageHeight.js new file mode 100644 index 00000000000..cf3625b0ac6 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateAverageHeight.js @@ -0,0 +1,18 @@ +/** + * calculateAverageHeight.js + * https://github.com/reactabular/reactabular/blob/v8.17.0/packages/reactabular-virtualized/src/calculate-average-height.js + * + * Forked from version 8.17.0 + * + * Changes: + * - Use arrow-rowindex based measured amounts for simplicity + * - prevent divide by zero exception + * */ +const calculateAverageHeight = measuredRows => { + const measuredAmounts = Object.keys(measuredRows).map(key => measuredRows[key]); + const amountOfMeasuredRows = measuredAmounts.length; + // prevent divide by zero exception + return Math.max(measuredAmounts.reduce((a, b) => a + b, 0) / amountOfMeasuredRows, 1); +}; + +export default calculateAverageHeight; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateRows.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateRows.js new file mode 100644 index 00000000000..92c3d9cb301 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateRows.js @@ -0,0 +1,103 @@ +/** + * calculateRows.js + * https://github.com/reactabular/reactabular/blob/v8.17.0/packages/reactabular-virtualized/src/calculate-rows.js + * + * Forked from version 8.17.0; includes the following modifications: + * 1) Calculate actual row heights in determining startIndex. This allows dynamic row heights. + * */ +// import calculateAverageHeight from './calculateAverageHeight'; + +const calculateRows = ({ measuredRows, height, rowKey, rows, scrollTop = 0 }) => { + // default overscan to 10 for now, could be accepted as a prop in the future + const overscan = 20; + + // averageHeight of measuredRows can still be used to closely approximate amount of rows to render + const measuredAmounts = Object.keys(measuredRows).map(key => measuredRows[key]); + const amountOfMeasuredRows = measuredAmounts.length; + const totalMeasuredHeight = measuredAmounts.reduce((a, b) => a + b, 0); + + // if we have no rows, use a small row height so we get a good sample in future loops + const averageHeight = amountOfMeasuredRows > 0 ? totalMeasuredHeight / amountOfMeasuredRows : 50; + + let startIndex = 0; + let startHeight = 0; + let accruedHeight = 0; + let i = 0; + + while (accruedHeight < scrollTop) { + // measuredRows use aria-rowindex as identifiers which is 1 based + if (measuredRows.hasOwnProperty(i + 1)) { + accruedHeight += measuredRows[i + 1]; + } else { + accruedHeight += averageHeight; + } + if (scrollTop <= accruedHeight) { + startIndex = i; + break; + } else if (i + overscan > rows.length) { + // stop accruing after we reach i + overscan (the end) + startHeight = accruedHeight; + startIndex = i; + break; + } + // accrue and continue + startHeight = accruedHeight; + i += 1; + } + + const amountOfRowsToRender = Math.ceil(height / averageHeight) + overscan; + const rowsToRender = rows.slice(startIndex, Math.max(startIndex + amountOfRowsToRender, 0)); + + if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined' && window.LOG_VIRTUALIZED) { + console.log( + // eslint-disable-line no-console + 'update rows to render', + 'scroll top', + scrollTop, + 'measured rows', + measuredRows, + 'amount of rows to render', + amountOfRowsToRender, + 'rows to render', + rowsToRender, + 'start index', + startIndex + ); + } + + // Escape if there are no rows to render for some reason + if (!rowsToRender.length) { + return null; + } + + // Calculate the padding of the last row so we can match whole height. This + // won't be totally accurate if row heights differ but should get close + // enough in most cases. + const endHeight = Math.max((rows.length - amountOfRowsToRender) * averageHeight - startHeight, 0); + + if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined' && window.LOG_VIRTUALIZED) { + console.log( + // eslint-disable-line no-console + 'average height', + averageHeight, + 'body height', + height, + 'scroll top', + scrollTop, + 'start height', + startHeight, + 'end height', + endHeight + ); + } + + return { + amountOfRowsToRender, + startIndex, + showExtraRow: !(startIndex % 2), + startHeight, + endHeight + }; +}; + +export default calculateRows; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/detectElementResize.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/detectElementResize.js new file mode 100644 index 00000000000..75551b0b7fe --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/detectElementResize.js @@ -0,0 +1,225 @@ +/* eslint-disable */ +/** + * https://github.com/bvaughn/react-virtualized/blob/9.21.0/source/vendor/detectElementResize.js + * + * Detect Element Resize. + * https://github.com/sdecima/javascript-detect-element-resize + * Sebastian Decima + * + * Forked from version 0.5.3; includes the following modifications: + * 1) Guard against unsafe 'window' and 'document' references (to support SSR). + * 2) Defer initialization code via a top-level function wrapper (to support SSR). + * 3) Avoid unnecessary reflows by not measuring size for scroll events bubbling from children. + * 4) Add nonce for style element. + * */ + +export default function createDetectElementResize(nonce) { + // Check `document` and `window` in case of server-side rendering + var _window; + if (typeof window !== 'undefined') { + _window = window; + } else if (typeof self !== 'undefined') { + _window = self; + } else { + _window = global; + } + + var attachEvent = typeof document !== 'undefined' && document.attachEvent; + + if (!attachEvent) { + var requestFrame = (function() { + var raf = + _window.requestAnimationFrame || + _window.mozRequestAnimationFrame || + _window.webkitRequestAnimationFrame || + function(fn) { + return _window.setTimeout(fn, 20); + }; + return function(fn) { + return raf(fn); + }; + })(); + + var cancelFrame = (function() { + var cancel = + _window.cancelAnimationFrame || + _window.mozCancelAnimationFrame || + _window.webkitCancelAnimationFrame || + _window.clearTimeout; + return function(id) { + return cancel(id); + }; + })(); + + var resetTriggers = function(element) { + var triggers = element.__resizeTriggers__, + expand = triggers.firstElementChild, + contract = triggers.lastElementChild, + expandChild = expand.firstElementChild; + contract.scrollLeft = contract.scrollWidth; + contract.scrollTop = contract.scrollHeight; + expandChild.style.width = expand.offsetWidth + 1 + 'px'; + expandChild.style.height = expand.offsetHeight + 1 + 'px'; + expand.scrollLeft = expand.scrollWidth; + expand.scrollTop = expand.scrollHeight; + }; + + var checkTriggers = function(element) { + return ( + element.offsetWidth != element.__resizeLast__.width || element.offsetHeight != element.__resizeLast__.height + ); + }; + + var scrollListener = function(e) { + // Don't measure (which forces) reflow for scrolls that happen inside of children! + if ( + e.target.className && + typeof e.target.className.indexOf === 'function' && + e.target.className.indexOf('contract-trigger') < 0 && + e.target.className.indexOf('expand-trigger') < 0 + ) { + return; + } + + var element = this; + resetTriggers(this); + if (this.__resizeRAF__) { + cancelFrame(this.__resizeRAF__); + } + this.__resizeRAF__ = requestFrame(function() { + if (checkTriggers(element)) { + element.__resizeLast__.width = element.offsetWidth; + element.__resizeLast__.height = element.offsetHeight; + element.__resizeListeners__.forEach(function(fn) { + fn.call(element, e); + }); + } + }); + }; + + /* Detect CSS Animations support to detect element display/re-attach */ + var animation = false, + keyframeprefix = '', + animationstartevent = 'animationstart', + domPrefixes = 'Webkit Moz O ms'.split(' '), + startEvents = 'webkitAnimationStart animationstart oAnimationStart MSAnimationStart'.split(' '), + pfx = ''; + { + var elm = document.createElement('fakeelement'); + if (elm.style.animationName !== undefined) { + animation = true; + } + + if (animation === false) { + for (var i = 0; i < domPrefixes.length; i++) { + if (elm.style[domPrefixes[i] + 'AnimationName'] !== undefined) { + pfx = domPrefixes[i]; + keyframeprefix = '-' + pfx.toLowerCase() + '-'; + animationstartevent = startEvents[i]; + animation = true; + break; + } + } + } + } + + var animationName = 'resizeanim'; + var animationKeyframes = + '@' + keyframeprefix + 'keyframes ' + animationName + ' { from { opacity: 0; } to { opacity: 0; } } '; + var animationStyle = keyframeprefix + 'animation: 1ms ' + animationName + '; '; + } + + var createStyles = function(doc) { + if (!doc.getElementById('detectElementResize')) { + //opacity:0 works around a chrome bug https://code.google.com/p/chromium/issues/detail?id=286360 + var css = + (animationKeyframes ? animationKeyframes : '') + + '.resize-triggers { ' + + (animationStyle ? animationStyle : '') + + 'visibility: hidden; opacity: 0; } ' + + '.resize-triggers, .resize-triggers > div, .contract-trigger:before { content: " "; display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; z-index: -1; } .resize-triggers > div { background: #eee; overflow: auto; } .contract-trigger:before { width: 200%; height: 200%; }', + head = doc.head || doc.getElementsByTagName('head')[0], + style = doc.createElement('style'); + + style.id = 'detectElementResize'; + style.type = 'text/css'; + + if (nonce != null) { + style.setAttribute('nonce', nonce); + } + + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(doc.createTextNode(css)); + } + + head.appendChild(style); + } + }; + + var addResizeListener = function(element, fn) { + if (attachEvent) { + element.attachEvent('onresize', fn); + } else { + if (!element.__resizeTriggers__) { + var doc = element.ownerDocument; + var elementStyle = _window.getComputedStyle(element); + if (elementStyle && elementStyle.position == 'static') { + element.style.position = 'relative'; + } + createStyles(doc); + element.__resizeLast__ = {}; + element.__resizeListeners__ = []; + (element.__resizeTriggers__ = doc.createElement('div')).className = 'resize-triggers'; + element.__resizeTriggers__.innerHTML = + '
' + '
'; + element.appendChild(element.__resizeTriggers__); + resetTriggers(element); + element.addEventListener('scroll', scrollListener, true); + + /* Listen for a css animation to detect element display/re-attach */ + if (animationstartevent) { + element.__resizeTriggers__.__animationListener__ = function animationListener(e) { + if (e.animationName == animationName) { + resetTriggers(element); + } + }; + element.__resizeTriggers__.addEventListener( + animationstartevent, + element.__resizeTriggers__.__animationListener__ + ); + } + } + element.__resizeListeners__.push(fn); + } + }; + + var removeResizeListener = function(element, fn) { + if (attachEvent) { + element.detachEvent('onresize', fn); + } else { + element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1); + if (!element.__resizeListeners__.length) { + element.removeEventListener('scroll', scrollListener, true); + if (element.__resizeTriggers__.__animationListener__) { + element.__resizeTriggers__.removeEventListener( + animationstartevent, + element.__resizeTriggers__.__animationListener__ + ); + element.__resizeTriggers__.__animationListener__ = null; + } + try { + element.__resizeTriggers__ = !element.removeChild(element.__resizeTriggers__); + } catch (e) { + // Preact compat; see developit/preact-compat/issues/228 + } + } + } + }; + + return { + addResizeListener, + removeResizeListener + }; +} diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/dimensions.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/dimensions.js new file mode 100644 index 00000000000..bc5703c0eea --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/dimensions.js @@ -0,0 +1,69 @@ +/** + * dimensions.js + * https://github.com/bvaughn/react-virtualized/blob/9.21.0/source/WindowScroller/utils/dimensions.js + * Brian Vaughn + * + * Forked from version 9.21.0 + * */ + +const isWindow = element => element === window; + +const getBoundingBox = element => element.getBoundingClientRect(); + +export function getDimensions(scrollElement, props) { + if (!scrollElement) { + return { + height: props.serverHeight, + width: props.serverWidth + }; + } else if (isWindow(scrollElement)) { + const { innerHeight, innerWidth } = window; + return { + height: typeof innerHeight === 'number' ? innerHeight : 0, + width: typeof innerWidth === 'number' ? innerWidth : 0 + }; + } + return getBoundingBox(scrollElement); +} + +/** + * Gets the vertical and horizontal position of an element within its scroll container. + * Elements that have been “scrolled past” return negative values. + * Handles edge-case where a user is navigating back (history) from an already-scrolled page. + * In this case the body’s top or left position will be a negative number and this element’s top or left will be increased (by that amount). + */ +export function getPositionOffset(element, container) { + if (isWindow(container) && document.documentElement) { + const containerElement = document.documentElement; + const elementRect = getBoundingBox(element); + const containerRect = getBoundingBox(containerElement); + return { + top: elementRect.top - containerRect.top, + left: elementRect.left - containerRect.left + }; + } + const scrollOffset = getScrollOffset(container); + const elementRect = getBoundingBox(element); + const containerRect = getBoundingBox(container); + return { + top: elementRect.top + scrollOffset.top - containerRect.top, + left: elementRect.left + scrollOffset.left - containerRect.left + }; +} + +/** + * Gets the vertical and horizontal scroll amount of the element, accounting for IE compatibility + * and API differences between `window` and other DOM elements. + */ +export function getScrollOffset(element) { + if (isWindow(element) && document.documentElement) { + return { + top: 'scrollY' in window ? window.scrollY : document.documentElement.scrollTop, + left: 'scrollX' in window ? window.scrollX : document.documentElement.scrollLeft + }; + } + return { + top: element.scrollTop, + left: element.scrollLeft + }; +} diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/onScroll.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/onScroll.js new file mode 100644 index 00000000000..9e5173bda6d --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/onScroll.js @@ -0,0 +1,75 @@ +/** + * onScroll.js + * https://github.com/bvaughn/react-virtualized/blob/9.21.0/source/WindowScroller/utils/onScroll.js + * Brian Vaughn + * + * Forked from version 9.21.0 + * */ + +import { requestAnimationTimeout, cancelAnimationTimeout } from './requestAnimationTimeout'; + +let mountedInstances = []; +let originalBodyPointerEvents = null; +let disablePointerEventsTimeoutId = null; + +function enablePointerEventsIfDisabled() { + if (disablePointerEventsTimeoutId) { + disablePointerEventsTimeoutId = null; + + if (document.body && originalBodyPointerEvents != null) { + document.body.style.pointerEvents = originalBodyPointerEvents; + } + + originalBodyPointerEvents = null; + } +} + +function enablePointerEventsAfterDelayCallback() { + enablePointerEventsIfDisabled(); + mountedInstances.forEach(instance => instance.__resetIsScrolling()); +} + +function enablePointerEventsAfterDelay() { + if (disablePointerEventsTimeoutId) { + cancelAnimationTimeout(disablePointerEventsTimeoutId); + } + + let maximumTimeout = 0; + mountedInstances.forEach(instance => { + maximumTimeout = Math.max(maximumTimeout, instance.props.scrollingResetTimeInterval); + }); + + disablePointerEventsTimeoutId = requestAnimationTimeout(enablePointerEventsAfterDelayCallback, maximumTimeout); +} + +function onScrollWindow(event) { + if (event.currentTarget === window && originalBodyPointerEvents == null && document.body) { + originalBodyPointerEvents = document.body.style.pointerEvents; + + document.body.style.pointerEvents = 'none'; + } + enablePointerEventsAfterDelay(); + mountedInstances.forEach(instance => { + if (instance.props.scrollElement === event.currentTarget) { + instance.__handleWindowScrollEvent(); + } + }); +} + +export function registerScrollListener(component, element) { + if (!mountedInstances.some(instance => instance.props.scrollElement === element)) { + element.addEventListener('scroll', onScrollWindow); + } + mountedInstances.push(component); +} + +export function unregisterScrollListener(component, element) { + mountedInstances = mountedInstances.filter(instance => instance !== component); + if (!mountedInstances.length) { + element.removeEventListener('scroll', onScrollWindow); + if (disablePointerEventsTimeoutId) { + cancelAnimationTimeout(disablePointerEventsTimeoutId); + enablePointerEventsIfDisabled(); + } + } +} diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/requestAnimationTimeout.js b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/requestAnimationTimeout.js new file mode 100644 index 00000000000..79a44fecd44 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/requestAnimationTimeout.js @@ -0,0 +1,39 @@ +/** + * requestAnimationTimeout.js + * https://github.com/bvaughn/react-virtualized/blob/9.21.0/source/utils/requestAnimationTimeout.js + * Brian Vaughn + * + * Forked from version 9.21.0 + * */ + +import { caf, raf } from './animationFrame'; + +export const cancelAnimationTimeout = frame => caf(frame.id); + +/** + * Recursively calls requestAnimationFrame until a specified delay has been met or exceeded. + * When the delay time has been reached the function you're timing out will be called. + * + * Credit: Joe Lambert (https://gist.github.com/joelambert/1002116#file-requesttimeout-js) + */ +export const requestAnimationTimeout = (callback, delay) => { + let start; + // wait for end of processing current event handler, because event handler may be long + Promise.resolve().then(() => { + start = Date.now(); + }); + + const timeout = () => { + if (Date.now() - start >= delay) { + callback.call(); + } else { + frame.id = raf(timeout); + } + }; + + const frame = { + id: raf(timeout) + }; + + return frame; +}; diff --git a/packages/patternfly-4/react-virtualized-extension/src/components/index.js b/packages/patternfly-4/react-virtualized-extension/src/components/index.js new file mode 100644 index 00000000000..1dfee408f4a --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/index.js @@ -0,0 +1 @@ +export * from './Virtualized'; diff --git a/packages/patternfly-4/react-virtualized-extension/src/index.js b/packages/patternfly-4/react-virtualized-extension/src/index.js new file mode 100644 index 00000000000..07635cbbc8e --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/index.js @@ -0,0 +1 @@ +export * from './components';