UNPKG

@shopify/polaris

Version:

Shopify’s admin product component library

706 lines (702 loc) • 26.7 kB
import React, { PureComponent, createRef } from 'react'; import isEqual from 'react-fast-compare'; import { debounce } from '../../utilities/debounce.js'; import { classNames } from '../../utilities/css.js'; import { headerCell } from '../shared.js'; import { measureColumn, getPrevAndCurrentColumns } from './utilities.js'; import styles from './DataTable.css.js'; import { Cell } from './components/Cell/Cell.js'; import { Pagination } from '../Pagination/Pagination.js'; import { AfterInitialMount } from '../AfterInitialMount/AfterInitialMount.js'; import { Sticky } from '../Sticky/Sticky.js'; import { Navigation } from './components/Navigation/Navigation.js'; import { useI18n } from '../../utilities/i18n/hooks.js'; import { EventListener } from '../EventListener/EventListener.js'; const getRowClientHeights = rows => { const heights = []; if (!rows) { return heights; } rows.forEach(row => { heights.push(row.clientHeight); }); return heights; }; class DataTableInner extends PureComponent { constructor(...args) { super(...args); this.state = { condensed: false, columnVisibilityData: [], isScrolledFarthestLeft: true, isScrolledFarthestRight: false, rowHovered: undefined }; this.dataTable = /*#__PURE__*/createRef(); this.scrollContainer = /*#__PURE__*/createRef(); this.table = /*#__PURE__*/createRef(); this.stickyTable = /*#__PURE__*/createRef(); this.stickyNav = null; this.headerNav = null; this.tableHeadings = []; this.stickyHeadings = []; this.tableHeadingWidths = []; this.stickyHeaderActive = false; this.scrollStopTimer = null; this.handleResize = debounce(() => { const { table: { current: table }, scrollContainer: { current: scrollContainer } } = this; let condensed = false; if (table && scrollContainer) { // safari sometimes incorrectly sets the scrollwidth too large by 1px condensed = table.scrollWidth > scrollContainer.clientWidth + 1; } this.setState({ condensed, ...this.calculateColumnVisibilityData(condensed) }); }); this.setCellRef = ({ ref, index, inStickyHeader }) => { if (ref == null) { return; } if (inStickyHeader) { this.stickyHeadings[index] = ref; const button = ref.querySelector('button'); if (button == null) { return; } button.addEventListener('focus', this.handleHeaderButtonFocus); } else { this.tableHeadings[index] = ref; this.tableHeadingWidths[index] = ref.clientWidth; } }; this.changeHeadingFocus = () => { const { tableHeadings, stickyHeadings, stickyNav, headerNav } = this; const stickyFocusedItemIndex = stickyHeadings.findIndex(item => item === document.activeElement?.parentElement); const tableFocusedItemIndex = tableHeadings.findIndex(item => item === document.activeElement?.parentElement); const arrowsInStickyNav = stickyNav?.querySelectorAll('button'); const arrowsInHeaderNav = headerNav?.querySelectorAll('button'); let stickyFocusedNavIndex = -1; arrowsInStickyNav?.forEach((item, index) => { if (item === document.activeElement) { stickyFocusedNavIndex = index; } }); let headerFocusedNavIndex = -1; arrowsInHeaderNav?.forEach((item, index) => { if (item === document.activeElement) { headerFocusedNavIndex = index; } }); if (stickyFocusedItemIndex < 0 && tableFocusedItemIndex < 0 && stickyFocusedNavIndex < 0 && headerFocusedNavIndex < 0) { return null; } let button; if (stickyFocusedItemIndex >= 0) { button = tableHeadings[stickyFocusedItemIndex].querySelector('button'); } else if (tableFocusedItemIndex >= 0) { button = stickyHeadings[tableFocusedItemIndex].querySelector('button'); } if (stickyFocusedNavIndex >= 0) { button = arrowsInHeaderNav?.[stickyFocusedNavIndex]; } else if (headerFocusedNavIndex >= 0) { button = arrowsInStickyNav?.[headerFocusedNavIndex]; } if (button == null) { return null; } button.style.visibility = 'visible'; button.focus(); button.style.removeProperty('visibility'); }; this.calculateColumnVisibilityData = condensed => { const fixedFirstColumns = this.fixedFirstColumns(); const { table: { current: table }, scrollContainer: { current: scrollContainer }, dataTable: { current: dataTable } } = this; const { stickyHeader } = this.props; if ((stickyHeader || condensed) && table && scrollContainer && dataTable) { const headerCells = table.querySelectorAll(headerCell.selector); const rightMostHeader = headerCells[fixedFirstColumns - 1]; const nthColumnWidth = fixedFirstColumns ? rightMostHeader.offsetLeft + rightMostHeader.offsetWidth : 0; if (headerCells.length > 0) { const firstVisibleColumnIndex = headerCells.length - 1; const tableLeftVisibleEdge = scrollContainer.scrollLeft + nthColumnWidth; const tableRightVisibleEdge = scrollContainer.scrollLeft + dataTable.offsetWidth; const tableData = { firstVisibleColumnIndex, tableLeftVisibleEdge, tableRightVisibleEdge }; const columnVisibilityData = [...headerCells].map(measureColumn(tableData)); const lastColumn = columnVisibilityData[columnVisibilityData.length - 1]; const isScrolledFarthestLeft = fixedFirstColumns ? tableLeftVisibleEdge === nthColumnWidth : tableLeftVisibleEdge === 0; return { columnVisibilityData, ...getPrevAndCurrentColumns(tableData, columnVisibilityData), isScrolledFarthestLeft, isScrolledFarthestRight: lastColumn.rightEdge <= tableRightVisibleEdge }; } } return { columnVisibilityData: [], previousColumn: undefined, currentColumn: undefined }; }; this.handleHeaderButtonFocus = event => { const fixedFirstColumns = this.fixedFirstColumns(); if (this.scrollContainer.current == null || event.target == null || this.state.columnVisibilityData.length === 0) { return; } const target = event.target; const currentCell = target.parentNode; const tableScrollLeft = this.scrollContainer.current.scrollLeft; const tableViewableWidth = this.scrollContainer.current.offsetWidth; const tableRightEdge = tableScrollLeft + tableViewableWidth; const nthColumnWidth = this.state.columnVisibilityData.length > 0 ? this.state.columnVisibilityData[fixedFirstColumns]?.rightEdge : 0; const currentColumnLeftEdge = currentCell.offsetLeft; const currentColumnRightEdge = currentCell.offsetLeft + currentCell.offsetWidth; if (tableScrollLeft > currentColumnLeftEdge - nthColumnWidth) { this.scrollContainer.current.scrollLeft = currentColumnLeftEdge - nthColumnWidth; } if (currentColumnRightEdge > tableRightEdge) { this.scrollContainer.current.scrollLeft = currentColumnRightEdge - tableViewableWidth; } }; this.stickyHeaderScrolling = () => { const { current: stickyTable } = this.stickyTable; const { current: scrollContainer } = this.scrollContainer; if (stickyTable == null || scrollContainer == null) { return; } stickyTable.scrollLeft = scrollContainer.scrollLeft; }; this.scrollListener = () => { if (this.scrollStopTimer) { clearTimeout(this.scrollStopTimer); } this.scrollStopTimer = setTimeout(() => { this.setState(prevState => ({ ...this.calculateColumnVisibilityData(prevState.condensed) })); }, 100); this.setState({ isScrolledFarthestLeft: this.scrollContainer.current?.scrollLeft === 0 }); if (this.props.stickyHeader && this.stickyHeaderActive) { this.stickyHeaderScrolling(); } }; this.handleHover = row => () => { this.setState({ rowHovered: row }); }; this.handleFocus = event => { const fixedFirstColumns = this.fixedFirstColumns(); if (this.scrollContainer.current == null || event.target == null) { return; } const currentCell = event.target.parentNode; const fixedNthColumn = this.props; const nthColumnWidth = fixedNthColumn ? this.state.columnVisibilityData[fixedFirstColumns]?.rightEdge : 0; const currentColumnLeftEdge = currentCell.offsetLeft; const desiredScrollLeft = currentColumnLeftEdge - nthColumnWidth; if (this.scrollContainer.current.scrollLeft > desiredScrollLeft) { this.scrollContainer.current.scrollLeft = desiredScrollLeft; } // focus fixed first column if present }; this.navigateTable = direction => { const fixedFirstColumns = this.fixedFirstColumns(); const { currentColumn, previousColumn } = this.state; const nthColumnWidth = this.state.columnVisibilityData[fixedFirstColumns - 1]?.rightEdge; if (!currentColumn || !previousColumn) { return; } let prevWidths = 0; for (let index = 0; index < currentColumn.index; index++) { prevWidths += this.state.columnVisibilityData[index].width; } const { current: scrollContainer } = this.scrollContainer; const handleScroll = () => { let newScrollLeft = 0; if (fixedFirstColumns) { newScrollLeft = direction === 'right' ? prevWidths - nthColumnWidth + currentColumn.width : prevWidths - previousColumn.width - nthColumnWidth; } else { newScrollLeft = direction === 'right' ? currentColumn.rightEdge : previousColumn.leftEdge; } if (scrollContainer) { scrollContainer.scrollLeft = newScrollLeft; requestAnimationFrame(() => { this.setState(prevState => ({ ...this.calculateColumnVisibilityData(prevState.condensed) })); }); } }; return handleScroll; }; // eslint-disable-next-line @shopify/react-no-multiple-render-methods this.renderHeading = ({ heading, headingIndex, inFixedNthColumn, inStickyHeader }) => { const { sortable, truncate = false, columnContentTypes, defaultSortDirection, initialSortColumnIndex = 0, verticalAlign, firstColumnMinWidth } = this.props; const fixedFirstColumns = this.fixedFirstColumns(); const { sortDirection = defaultSortDirection, sortedColumnIndex = initialSortColumnIndex, isScrolledFarthestLeft } = this.state; let sortableHeadingProps; const headingCellId = `heading-cell-${headingIndex}`; const stickyHeaderId = `stickyheader-${headingIndex}`; const id = inStickyHeader ? stickyHeaderId : headingCellId; if (sortable) { const isSortable = sortable[headingIndex]; const isSorted = isSortable && sortedColumnIndex === headingIndex; const direction = isSorted ? sortDirection : 'none'; sortableHeadingProps = { defaultSortDirection, sorted: isSorted, sortable: isSortable, sortDirection: direction, onSort: this.defaultOnSort(headingIndex), fixedNthColumn: fixedFirstColumns, inFixedNthColumn: fixedFirstColumns }; } const stickyCellWidth = inStickyHeader ? this.tableHeadingWidths[headingIndex] : undefined; const fixedCellVisible = !isScrolledFarthestLeft; const cellProps = { header: true, stickyHeadingCell: inStickyHeader, content: heading, contentType: columnContentTypes[headingIndex], nthColumn: headingIndex < fixedFirstColumns, fixedFirstColumns, truncate, headingIndex, ...sortableHeadingProps, verticalAlign, handleFocus: this.handleFocus, stickyCellWidth, fixedCellVisible, firstColumnMinWidth }; if (inFixedNthColumn && inStickyHeader) { // need two cells for fixed first column (actual cell and the overlapping one) // the sticky cell is second so that the index is associated with the sticky // cell and not the underlying one. This helps `changeHeadingFocus` to put // focus on the right cell when switching from sticky to non-sticky headers return [/*#__PURE__*/React.createElement(Cell, Object.assign({ key: id }, cellProps, { setRef: ref => { this.setCellRef({ ref, index: headingIndex, inStickyHeader }); }, inFixedNthColumn: false })), /*#__PURE__*/React.createElement(Cell, Object.assign({ key: `${id}-sticky` }, cellProps, { setRef: ref => { this.setCellRef({ ref, index: headingIndex, inStickyHeader }); }, inFixedNthColumn: Boolean(fixedFirstColumns), lastFixedFirstColumn: headingIndex === fixedFirstColumns - 1, style: { left: this.state.columnVisibilityData[headingIndex]?.leftEdge } }))]; } return /*#__PURE__*/React.createElement(Cell, Object.assign({ key: id }, cellProps, { setRef: ref => { this.setCellRef({ ref, index: headingIndex, inStickyHeader }); }, lastFixedFirstColumn: headingIndex === fixedFirstColumns - 1, inFixedNthColumn: inFixedNthColumn })); }; this.totalsRowHeading = () => { const { i18n, totals, totalsName } = this.props; const totalsLabel = totalsName ? totalsName : { singular: i18n.translate('Polaris.DataTable.totalRowHeading'), plural: i18n.translate('Polaris.DataTable.totalsRowHeading') }; return totals && totals.filter(total => total !== '').length > 1 ? totalsLabel.plural : totalsLabel.singular; }; // eslint-disable-next-line @shopify/react-no-multiple-render-methods this.renderTotals = ({ total, index }) => { const fixedFirstColumns = this.fixedFirstColumns(); const id = `totals-cell-${index}`; const { truncate = false, verticalAlign, columnContentTypes } = this.props; let content; let contentType; if (index === 0) { content = this.totalsRowHeading(); } if (total !== '' && index > 0) { contentType = columnContentTypes[index]; content = total; } const totalInFooter = this.props.showTotalsInFooter; return /*#__PURE__*/React.createElement(Cell, { total: true, totalInFooter: totalInFooter, nthColumn: index <= fixedFirstColumns - 1, firstColumn: index === 0, key: id, content: content, contentType: contentType, truncate: truncate, verticalAlign: verticalAlign }); }; this.getColSpan = (rowLength, headingsLength, contentTypesLength, cellIndex) => { // We decided that it shouldn't be possible to have fixed "n" columns and content that spans multiple columns const fixedFirstColumns = this.fixedFirstColumns(); if (fixedFirstColumns) { return 1; } const rowLen = rowLength ? rowLength : 1; const colLen = headingsLength ? headingsLength : contentTypesLength; const colSpan = Math.floor(colLen / rowLen); const remainder = colLen % rowLen; return cellIndex === 0 ? colSpan + remainder : colSpan; }; this.defaultRenderRow = ({ row, index, inFixedNthColumn, rowHeights }) => { const { columnContentTypes, truncate = false, verticalAlign, hoverable = true, headings } = this.props; const { condensed } = this.state; const fixedFirstColumns = this.fixedFirstColumns(); const className = classNames(styles.TableRow, hoverable && styles.hoverable); return /*#__PURE__*/React.createElement("tr", { key: `row-${index}`, className: className, onMouseEnter: this.handleHover(index), onMouseLeave: this.handleHover() }, row.map((content, cellIndex) => { const hovered = index === this.state.rowHovered; const id = `cell-${cellIndex}-row-${index}`; const colSpan = this.getColSpan(row.length, headings.length, columnContentTypes.length, cellIndex); return /*#__PURE__*/React.createElement(Cell, { key: id, content: content, contentType: columnContentTypes[cellIndex], nthColumn: cellIndex <= fixedFirstColumns - 1, firstColumn: cellIndex === 0, truncate: truncate, verticalAlign: verticalAlign, colSpan: colSpan, hovered: hovered, style: rowHeights ? { height: `${rowHeights[index]}px` } : {}, inFixedNthColumn: condensed && inFixedNthColumn }); })); }; this.defaultOnSort = headingIndex => { const { onSort, defaultSortDirection = 'ascending', initialSortColumnIndex } = this.props; const { sortDirection = defaultSortDirection, sortedColumnIndex = initialSortColumnIndex } = this.state; let newSortDirection = defaultSortDirection; if (sortedColumnIndex === headingIndex) { newSortDirection = sortDirection === 'ascending' ? 'descending' : 'ascending'; } const handleSort = () => { this.setState({ sortDirection: newSortDirection, sortedColumnIndex: headingIndex }, () => { if (onSort) { onSort(headingIndex, newSortDirection); } }); }; return handleSort; }; } componentDidMount() { // We need to defer the calculation in development so the styles have time to be injected. if (process.env.NODE_ENV === 'development') { setTimeout(() => { this.handleResize(); }, 10); } else { this.handleResize(); } } componentDidUpdate(prevProps) { if (isEqual(prevProps, this.props)) { return; } this.handleResize(); } componentWillUnmount() { this.handleResize.cancel(); } render() { const { headings, totals, showTotalsInFooter, rows, footerContent, hideScrollIndicator = false, increasedTableDensity = false, hasZebraStripingOnData = false, stickyHeader = false, hasFixedFirstColumn: fixedFirstColumn = false, pagination } = this.props; const { condensed, columnVisibilityData, isScrolledFarthestLeft, isScrolledFarthestRight } = this.state; if (fixedFirstColumn && process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn('Deprecation: The `hasFixedFirstColumn` prop on the `DataTable` has been deprecated. Use fixedFirstColumns={n} instead.'); } const fixedFirstColumns = this.fixedFirstColumns(); const rowCountIsEven = rows.length % 2 === 0; const className = classNames(styles.DataTable, condensed && styles.condensed, totals && styles.ShowTotals, showTotalsInFooter && styles.ShowTotalsInFooter, hasZebraStripingOnData && styles.ZebraStripingOnData, hasZebraStripingOnData && rowCountIsEven && styles.RowCountIsEven); const wrapperClassName = classNames(styles.TableWrapper, condensed && styles.condensed, increasedTableDensity && styles.IncreasedTableDensity, stickyHeader && styles.StickyHeaderEnabled); const headingMarkup = /*#__PURE__*/React.createElement("tr", null, headings.map((heading, index) => this.renderHeading({ heading, headingIndex: index, inFixedNthColumn: false, inStickyHeader: false }))); const totalsMarkup = totals ? /*#__PURE__*/React.createElement("tr", null, totals.map((total, index) => this.renderTotals({ total, index }))) : null; const nthColumns = rows.map(row => row.slice(0, fixedFirstColumns)); const nthHeadings = headings.slice(0, fixedFirstColumns); const nthTotals = totals?.slice(0, fixedFirstColumns); const tableHeaderRows = this.table.current?.children[0].childNodes; const tableBodyRows = this.table.current?.children[1].childNodes; const headerRowHeights = getRowClientHeights(tableHeaderRows); const bodyRowHeights = getRowClientHeights(tableBodyRows); const fixedNthColumnMarkup = condensed && fixedFirstColumns !== 0 && /*#__PURE__*/React.createElement("table", { className: classNames(styles.FixedFirstColumn, !isScrolledFarthestLeft && styles.separate), style: { width: `${columnVisibilityData[fixedFirstColumns - 1]?.rightEdge}px` } }, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", { style: { height: `${headerRowHeights[0]}px` } }, nthHeadings.map((heading, index) => this.renderHeading({ heading, headingIndex: index, inFixedNthColumn: true, inStickyHeader: false }))), totals && !showTotalsInFooter && /*#__PURE__*/React.createElement("tr", { style: { height: `${headerRowHeights[1]}px` } }, nthTotals?.map((total, index) => this.renderTotals({ total, index })))), /*#__PURE__*/React.createElement("tbody", null, nthColumns.map((row, index) => this.defaultRenderRow({ row, index, inFixedNthColumn: true, rowHeights: bodyRowHeights }))), totals && showTotalsInFooter && /*#__PURE__*/React.createElement("tfoot", null, /*#__PURE__*/React.createElement("tr", null, nthTotals?.map((total, index) => this.renderTotals({ total, index }))))); const bodyMarkup = rows.map((row, index) => this.defaultRenderRow({ row, index, inFixedNthColumn: false })); const footerMarkup = footerContent ? /*#__PURE__*/React.createElement("div", { className: styles.Footer }, footerContent) : null; const paginationMarkup = pagination ? /*#__PURE__*/React.createElement(Pagination, Object.assign({ type: "table" }, pagination)) : null; const headerTotalsMarkup = !showTotalsInFooter ? totalsMarkup : null; const footerTotalsMarkup = showTotalsInFooter ? /*#__PURE__*/React.createElement("tfoot", null, totalsMarkup) : null; const navigationMarkup = location => hideScrollIndicator ? null : /*#__PURE__*/React.createElement(Navigation, { columnVisibilityData: columnVisibilityData, isScrolledFarthestLeft: isScrolledFarthestLeft, isScrolledFarthestRight: isScrolledFarthestRight, navigateTableLeft: this.navigateTable('left'), navigateTableRight: this.navigateTable('right'), fixedFirstColumns: fixedFirstColumns, setRef: ref => { if (location === 'header') { this.headerNav = ref; } else if (location === 'sticky') { this.stickyNav = ref; } } }); const stickyHeaderMarkup = stickyHeader ? /*#__PURE__*/React.createElement(AfterInitialMount, null, /*#__PURE__*/React.createElement("div", { className: styles.StickyHeaderWrapper, role: "presentation" }, /*#__PURE__*/React.createElement(Sticky, { boundingElement: this.dataTable.current, onStickyChange: isSticky => { this.changeHeadingFocus(); this.stickyHeaderActive = isSticky; } }, isSticky => { const stickyHeaderInnerClassNames = classNames(styles.StickyHeaderInner, isSticky && styles['StickyHeaderInner-isSticky']); const stickyHeaderTableClassNames = classNames(styles.StickyHeaderTable, !isScrolledFarthestLeft && styles.separate); return /*#__PURE__*/React.createElement("div", { className: stickyHeaderInnerClassNames }, /*#__PURE__*/React.createElement("div", null, navigationMarkup('sticky')), /*#__PURE__*/React.createElement("table", { className: stickyHeaderTableClassNames, ref: this.stickyTable }, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", { className: styles.StickyTableHeadingsRow }, headings.map((heading, index) => { return this.renderHeading({ heading, headingIndex: index, inFixedNthColumn: Boolean(index <= fixedFirstColumns - 1 && fixedFirstColumns), inStickyHeader: true }); }))))); }))) : null; return /*#__PURE__*/React.createElement("div", { className: wrapperClassName, ref: this.dataTable }, stickyHeaderMarkup, navigationMarkup('header'), /*#__PURE__*/React.createElement("div", { className: className }, /*#__PURE__*/React.createElement("div", { className: styles.ScrollContainer, ref: this.scrollContainer }, /*#__PURE__*/React.createElement(EventListener, { event: "resize", handler: this.handleResize }), /*#__PURE__*/React.createElement(EventListener, { capture: true, passive: true, event: "scroll", handler: this.scrollListener }), fixedNthColumnMarkup, /*#__PURE__*/React.createElement("table", { className: styles.Table, ref: this.table }, /*#__PURE__*/React.createElement("thead", null, headingMarkup, headerTotalsMarkup), /*#__PURE__*/React.createElement("tbody", null, bodyMarkup), footerTotalsMarkup)), paginationMarkup, footerMarkup)); } fixedFirstColumns() { const { hasFixedFirstColumn, fixedFirstColumns = 0, headings } = this.props; const numberOfFixedFirstColumns = hasFixedFirstColumn && !fixedFirstColumns ? 1 : fixedFirstColumns; if (numberOfFixedFirstColumns >= headings.length) { return 0; } return numberOfFixedFirstColumns; } } function DataTable(props) { const i18n = useI18n(); return /*#__PURE__*/React.createElement(DataTableInner, Object.assign({}, props, { i18n: i18n })); } export { DataTable };