From b240e6583d66e6a73a1e26e7467e3b326b17d707 Mon Sep 17 00:00:00 2001 From: Patrick Riley Date: Tue, 16 Apr 2019 16:30:09 -0400 Subject: [PATCH] feat(virtualized): add virtualized table extensions --- jest.config.js | 61 ++-- .../patternfly-4/react-docs/gatsby-node.js | 46 +-- .../react-table/src/components/Table/Body.js | 19 +- .../src/components/Table/BodyWrapper.js | 19 +- .../Table/__snapshots__/Table.test.js.snap | 18 ++ .../react-virtualized-extension/.babelrc | 29 ++ .../react-virtualized-extension/.npmignore | 1 + .../react-virtualized-extension/README.md | 5 + .../build/copyStyles.js | 42 +++ .../build/snapshot-serializer.js | 8 + .../react-virtualized-extension/package.json | 58 ++++ .../scripts/copyTS.js | 15 + .../src/components/Virtualized/Body.js | 294 ++++++++++++++++++ .../src/components/Virtualized/BodyWrapper.js | 86 +++++ .../src/components/Virtualized/RowWrapper.js | 78 +++++ .../src/components/Virtualized/Virtualized.md | 292 +++++++++++++++++ .../components/Virtualized/WindowScroller.js | 262 ++++++++++++++++ .../components/Virtualized/WindowScroller.md | 93 ++++++ .../Virtualized/css/virtualized-css.js | 53 ++++ .../src/components/Virtualized/index.js | 4 + .../src/components/Virtualized/types.js | 21 ++ .../Virtualized/utils/animationFrame.js | 41 +++ .../utils/calculateAverageHeight.js | 18 ++ .../Virtualized/utils/calculateRows.js | 100 ++++++ .../Virtualized/utils/detectElementResize.js | 225 ++++++++++++++ .../Virtualized/utils/dimensions.js | 69 ++++ .../components/Virtualized/utils/onScroll.js | 75 +++++ .../utils/requestAnimationTimeout.js | 39 +++ .../src/components/index.js | 1 + .../react-virtualized-extension/src/index.js | 1 + 30 files changed, 2004 insertions(+), 69 deletions(-) create mode 100644 packages/patternfly-4/react-virtualized-extension/.babelrc create mode 100644 packages/patternfly-4/react-virtualized-extension/.npmignore create mode 100644 packages/patternfly-4/react-virtualized-extension/README.md create mode 100644 packages/patternfly-4/react-virtualized-extension/build/copyStyles.js create mode 100644 packages/patternfly-4/react-virtualized-extension/build/snapshot-serializer.js create mode 100644 packages/patternfly-4/react-virtualized-extension/package.json create mode 100644 packages/patternfly-4/react-virtualized-extension/scripts/copyTS.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Body.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/BodyWrapper.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/RowWrapper.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Virtualized.md create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/WindowScroller.md create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/css/virtualized-css.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/index.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/types.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/animationFrame.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateAverageHeight.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateRows.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/detectElementResize.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/dimensions.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/onScroll.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/requestAnimationTimeout.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/components/index.js create mode 100644 packages/patternfly-4/react-virtualized-extension/src/index.js diff --git a/jest.config.js b/jest.config.js index cbf2f177de7..756665b1601 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)'], // 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 3440f9d6534..3d098b0af37 100644 --- a/packages/patternfly-4/react-docs/gatsby-node.js +++ b/packages/patternfly-4/react-docs/gatsby-node.js @@ -1,5 +1,5 @@ -const navHelpers = require("./src/helpers/navHelpers"); -const path = require("path"); +const navHelpers = require('./src/helpers/navHelpers'); +const path = require('path'); // Add map PR-related environment variables to gatsby nodes exports.sourceNodes = ({ actions, createNodeId, createContentDigest }) => { @@ -8,33 +8,33 @@ exports.sourceNodes = ({ actions, createNodeId, createContentDigest }) => { // Docs https://www.gatsbyjs.org/docs/actions/#createNode actions.createNode({ name: 'PR_INFO', - num: num ? num : '', - url: url ? url : '', + num: num || '', + url: url || '', id: createNodeId(`PR_INFO`), parent: null, children: [], internal: { contentDigest: createContentDigest({ a: 'PR_INFO' }), - type: `EnvVars`, - }, + type: `EnvVars` + } }); -}; +} // Create pages for markdown files exports.createPages = ({ graphql, actions }) => { const mdx = graphql(` - { - allMdx { - nodes { - fileAbsolutePath - frontmatter { - title - section - fullscreen + { + allMdx { + nodes { + fileAbsolutePath + frontmatter { + title + section + fullscreen + } } } } - } `); return mdx.then(({ data }) => { @@ -54,7 +54,7 @@ exports.createPages = ({ graphql, actions }) => { component: path.resolve('./src/templates/mdxFullscreenTemplate.js'), context: { title: node.frontmatter.title, - fileAbsolutePath: node.fileAbsolutePath, // Helps us get the markdown + fileAbsolutePath: node.fileAbsolutePath // Helps us get the markdown } }); } else { @@ -72,9 +72,8 @@ exports.createPages = ({ graphql, actions }) => { }); } }); - }); -}; - + }) +} exports.onCreateWebpackConfig = ({ stage, actions }) => { actions.setWebpackConfig({ @@ -90,6 +89,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'), @@ -97,6 +97,6 @@ exports.onCreateWebpackConfig = ({ stage, actions }) => { // Hack to work downstream in https://github.com/patternfly/patternfly-org '@content': path.resolve(__dirname, 'src/components/componentDocs'), } - }, - }) -}; + } + }); +} 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..07799ea13f2 100644 --- a/packages/patternfly-4/react-table/src/components/Table/Body.js +++ b/packages/patternfly-4/react-table/src/components/Table/Body.js @@ -10,13 +10,16 @@ const propTypes = { /** 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 + onRowClick: PropTypes.func, + /** Virtualized rows (optional provided in place of rows) */ + rowsToRender: PropTypes.array }; const defaultProps = { rowKey: 'id', className: '', - onRowClick: () => undefined + onRowClick: () => undefined, + rowsToRender: undefined }; const flagVisibility = rows => { @@ -29,10 +32,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', @@ -78,7 +85,7 @@ class ContextBody extends React.Component { }; render() { - const { className, headerData, rows, rowKey, children, onRowClick, ...props } = this.props; + const { className, headerData, rows, rowKey, rowsToRender, children, onRowClick, ...props } = this.props; let mappedRows; if (headerData.length > 0) { @@ -113,7 +120,7 @@ class ContextBody extends React.Component { const TableBody = props => ( - {({ headerData, rows }) => } + {({ headerData, rows }) => } ); 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/__snapshots__/Table.test.js.snap b/packages/patternfly-4/react-table/src/components/Table/__snapshots__/Table.test.js.snap index f1ecc7a7739..2a37083125c 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} > { + 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..2fa04ad50ff --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/package.json @@ -0,0 +1,58 @@ +{ + "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.16.10", + "@patternfly/react-icons": "^3.8.1", + "@patternfly/react-styles": "^3.2.0", + "exenv": "^1.2.2", + "reactabular-table": "^8.14.0" + }, + "peerDependencies": { + "@patternfly/react-table": "^2.5.11", + "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..31d438a8f39 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/Body.js @@ -0,0 +1,294 @@ +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: 3, // First few rows for initial measurement + 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(); + element && element.addEventListener('scroll', this.onScroll); + this._detectElementResize = createDetectElementResize(); + this._detectElementResize.addResizeListener(element, this.onResize); + this.setContainerOffset(); + }, 0); + }; + + 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.tbodyRef && this._detectElementResize.removeResizeListener(this.tbodyRef, this.onResize); + } + 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 }; + if (!container) { + // if we do not have a parent container to scroll, set the body to scroll + style.display = 'block'; + style.overflow = 'auto'; + } + 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) : {}) + }), + rowsToRender + }; + + // do not listen to tbody onScroll if we are using window scroller + if (!container) { + tableBodyProps.onScroll = this.onScroll; + } + + 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, + /** Virtualized rows (optional provided in place of rows) */ + rowsToRender: PropTypes.array, + /** 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..887ec5bfcb2 --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/RowWrapper.js @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEqual } from 'lodash-es'; +import { columnsAreEqual } from 'reactabular-table'; +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); + }; + + static shouldComponentUpdate(nextProps) { + const { columns, rowData } = this.props; + // Update only if a row has not been measured and either + // columns or rowData hasn't changed + if (nextProps.rowData._measured) { + return !(columnsAreEqual(columns, nextProps.columns) && isEqual(rowData, nextProps.rowData)); + } + return true; + } + + 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..8269ae45b24 --- /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 < 100; 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..4682d4ea4ac --- /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 < 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; + 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..0d8809a9df3 --- /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 / amountOfMeasuredRows, 0), 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..7af90b722ac --- /dev/null +++ b/packages/patternfly-4/react-virtualized-extension/src/components/Virtualized/utils/calculateRows.js @@ -0,0 +1,100 @@ +/** + * 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 }) => { + // used exact measured row heights for determining `startIndex` for smooth scroll + // average heights are not accurate when there is lots of variation in row heights + let startIndex = 0; + let startHeight = 0; + let accruedHeight = 0; + + //default overscan to 10 for now, could be accepted as a prop in the future + const overscan = 10; + + for (let i = 0; i < Object.keys(measuredRows).length; i++) { + // measuredRows use aria-rowindex as identifiers which is 1 based + accruedHeight += measuredRows[i + 1]; + + if (scrollTop < accruedHeight) { + startIndex = i; + break; + } + else if(i + overscan > Object.keys(measuredRows).length){ + // stop accruing after we reach i + overscan + startHeight = accruedHeight; + startIndex = i; + break; + } + else { + // accrue and continue + startHeight = accruedHeight; + } + } + + // averageHeight of measuredRows can still be used to closely approximate amount of rows to render + // if this causes issues w/ row visibility, exact heights can still be used + const averageHeight = calculateAverageHeight(measuredRows); + const amountOfRowsToRender = Math.ceil(height / averageHeight) + overscan; + + // const zeroedIndex = startIndex; + 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: 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';