diff --git a/src/Table/README.mdx b/src/Table/README.mdx new file mode 100644 index 00000000..a7d8418a --- /dev/null +++ b/src/Table/README.mdx @@ -0,0 +1,23 @@ +--- +name: Table +menu: Components +--- + +import {Playground, Props} from 'docz'; +import Table, {Column} from './'; + +# Table + +More detailed description to follow. + +## Examples + + + +## Table props + + + +## Column props + + diff --git a/src/Table/getColumnConfigFromChildren.js b/src/Table/getColumnConfigFromChildren.js new file mode 100644 index 00000000..a40ce026 --- /dev/null +++ b/src/Table/getColumnConfigFromChildren.js @@ -0,0 +1,27 @@ +import {Children} from 'react'; + +/** + * Get the table's column configuration via JSX from + * its `children` prop, instead of as a JS object via + * the `columns` prop + * + * @param {Array} children - Table column configuration, + * must be made of Column components as exported by the + * Table component + * + * @returns {object} Table config as object + */ + +function getColumnConfigFromChildren(children) { + return Children.map(children, child => { + if (child && child.type.displayName === 'Column') { + return child.props; + } else if (child) { + console.warn( + 'The Table component only accepts children that are instances of the Column component.' + ); + } + }).filter(column => Boolean(column)); +} + +export default getColumnConfigFromChildren; diff --git a/src/Table/getColumnWidths.js b/src/Table/getColumnWidths.js new file mode 100644 index 00000000..e09f2bf3 --- /dev/null +++ b/src/Table/getColumnWidths.js @@ -0,0 +1,71 @@ +import {percentageToFraction} from '../utils/units'; + +/** + * @param {string|number|undefined} width - Predefined width + * of a column, either absolute (number) or a percentage (string) + * @param {number} availableWidth - Total width of the table + * + * @returns {number} Column width in pixels + */ + +export function getSingleColumnWidth(width, availableWidth) { + if (!width) return null; + if (typeof width === 'string') { + const fraction = percentageToFraction(width); + return Math.round(availableWidth * fraction); + } else return width; +} + +/** + * The table uses fixed positioning, which means we can calculate + * the width of each column knowing only the table's configuration + * and the total width that the table takes up on the page. + * + * @param {Array} columns - Table column configuration + * @param {number} totalWidth - Total table width + * + * @returns {Array} column widths, containing one object per column + * with the fields `name`, `width`, and `shouldHide` + * + * `shouldHide` is determined based on the column's `minWidth` prop. + */ + +function getColumnWidths(columns, totalWidth) { + if (columns.length === 0 || !totalWidth || totalWidth < 0) { + return null; + } + // Get the width of any columns that have a predefined width (either % or px) + const predefinedColumnWidths = columns.map(column => + getSingleColumnWidth(column.width, totalWidth) + ); + // Calculate the space remaining after subtracting the defined widths + const remainingSpace = predefinedColumnWidths.reduce( + (remaining, current) => remaining - (current || 0), + totalWidth + ); + const remainingColumns = predefinedColumnWidths.filter(width => !width); + // Calculate the widths of any remaining columns by evenly + // distributing the remaining space between them + const columnWidths = predefinedColumnWidths.map(width => { + if (width) return width; + return Math.round(remainingSpace / remainingColumns.length); + }); + + // Build the output object including the information whether the + // column should be hidden given its size + const columnWidthsAndHiddenStatus = columns.map((column, index) => { + const shouldHide = column.minWidth + ? columnWidths[index] < column.minWidth + : false; + + return { + name: column.name, + width: columnWidths[index], + shouldHide, + }; + }); + + return columnWidthsAndHiddenStatus; +} + +export default getColumnWidths; diff --git a/src/Table/getColumnsToHide.js b/src/Table/getColumnsToHide.js new file mode 100644 index 00000000..99d85150 --- /dev/null +++ b/src/Table/getColumnsToHide.js @@ -0,0 +1,114 @@ +import getColumnWidths from './getColumnWidths'; + +/** + * Calculates which columns should be hidden at the width + * available to the table. It tries to fit as many columns + * as possible given the constraints defined by the column + * configuration (width & minWidth) + * + * @param {Array} columns - Table column configuration + * @param {number} totalWidth - Total table width + * + * @returns {Array} Names of columns that should be hidden at the given width + */ + +function getColumnsToHide(columns, totalWidth) { + if (!totalWidth) return []; + + // Calculate initial column widths if all columns are visible + const columnWidths = getColumnWidths(columns, totalWidth); + + // Get the initial candidates for hiding (i.e. columns whose + // calculated width is below their defined `minWidth`) + const hidingCandidates = columnWidths.filter(column => column.shouldHide); + + // If there's just one candidate, happy days: return it + if (hidingCandidates.length === 1) { + return [hidingCandidates[0].name]; + } + // If there are more, we must determine which ones _could_ fit + // if only _some_ of the candidates were removed, in order to + // fit the maximum number of columns + else if (hidingCandidates.length > 1) { + // We'll assign a score to each candidate based on how + // many of the other candidates could be fit if it was hidden + const candidateScores = hidingCandidates.map(({name}) => { + // What are our widths if we hide this candidate? + const widthsWithoutCandidate = getColumnWidths( + columns.filter(c => c.name !== name), + totalWidth + ); + + // Get all candidates excluding the current one to + // check if it could be made visible now + const otherCandidates = hidingCandidates + .filter(candidate => candidate.name !== name) + .map(candidate => candidate.name); + + let score = 0; + otherCandidates.forEach(otherCandidateName => { + const otherCandidate = widthsWithoutCandidate.find( + item => item.name === otherCandidateName + ); + // Increase the score of this 'other candidate' if hiding + // the 'current candidate' has given it enough space + // to be made visible again + if (otherCandidate.shouldHide === false) { + score += 1; + } + }); + return { + name, + score, + }; + }); + + // Once we know the scores, we can group the candidates into 'final + // candidates' with a high score which should be hidden immediately + // (because hiding them creates a lot of room for other candidates) + // and those where hiding them doesn't change much, in which case + // we'll give them another pass, this time with the final + // candidates already out of the picture. + const finalCandidates = []; + const remainingCandidates = []; + // Set the threshold at the lowest score found. + // Anything higher we consider a final candidate + const lowestScore = Math.min( + ...candidateScores.map(item => item.score) + ); + + candidateScores.forEach(item => { + if (item.score > lowestScore) { + finalCandidates.push(item.name); + } else { + remainingCandidates.push(item.name); + } + }); + + // If there are no candidates with a higher score than others, + // we simply sacrifice and hide the rightmost candidate. + // Gotta do _something_ to make those columns fit, otherwise we'd + // enter an infinite loop! + if (!finalCandidates.length) { + finalCandidates.push(remainingCandidates.pop()); + } + + // As long as there are remaining candidates, we recursively call + // this function with high-scoring candidates removed, until we can + // fit the highest number of remaining columns. + if (remainingCandidates.length) { + const columnsWithoutFinalCandidates = columns.filter( + column => !finalCandidates.includes(column.name) + ); + return [ + ...finalCandidates, + ...getColumnsToHide(columnsWithoutFinalCandidates, totalWidth), + ]; + } else return finalCandidates; + } + + // Return an empty array if there's nothing to hide + return []; +} + +export default getColumnsToHide; diff --git a/src/Table/index.js b/src/Table/index.js new file mode 100644 index 00000000..a3b9d011 --- /dev/null +++ b/src/Table/index.js @@ -0,0 +1,182 @@ +import React, {useRef} from 'react'; +import PropTypes from 'prop-types'; +import styled, {css} from 'styled-components'; +import {useSize} from 'react-hook-size'; + +import {pxToRem} from 'base5-ui/utils/units'; +import {alpha} from 'base5-ui/utils/colors'; +import {borderValue} from 'base5-ui/mixins'; +import {positionProps, marginProps} from 'base5-ui/styleProps'; +import {getSpacing} from 'base5-ui/utils/spacing'; + +import Box from 'base5-ui/Box'; +import Text from 'base5-ui/Text'; + +import getColumnsToHide from './getColumnsToHide'; +import getColumnConfigFromChildren from './getColumnConfigFromChildren'; + +const StyledTable = styled.table` + position: relative; + ${positionProps} + display: table; + table-layout: fixed; + border-spacing: 0; + + ${marginProps} + + width: 100%; + + tr:hover { + background-color: ${p => alpha(p.theme.shade, p.theme.shadeStrength)}; + } + + td, + th { + font-weight: inherit; + text-align: left; + height: ${p => pxToRem(p.rowMinHeight)}; + padding: 0 ${p => p.theme.globals.spacing.xs}; + } + + thead th { + position: sticky; + top: 0; + z-index: ${p => p.theme.globals.z.raised}; + background-color: ${p => p.theme.background}; + border-bottom: ${p => borderValue(p.theme)}; + } + + ${p => + p.pl && + css` + th:first-child, + td:first-child { + padding-left: ${getSpacing(p.pl, p.theme)}; + } + `} + + ${p => + p.pr && + css` + th:last-child, + td:last-child { + padding-right: ${getSpacing(p.pr, p.theme)}; + } + `} +`; + +const defaultHeaderRenderer = column => {column.name}; + +// Uses either the column's name or its cellRenderer +// to access a cell's data. If no cellRenderer is proviced, +// the name of the column is used. +function getCellContent(item, key) { + if (typeof key === 'string') { + return item[key] || item[key.toLowerCase()] || 'No entry found'; + } + return key(item); +} + +function Table({ + children, + columns: columnsProp, + data, + headerRenderer = defaultHeaderRenderer, + rowMinHeight, + ...otherProps +}) { + const ref = useRef(); + const {width: tableWidth} = useSize(ref); + const columns = children + ? getColumnConfigFromChildren(children) + : columnsProp; + + const hiddenColumns = getColumnsToHide(columns, tableWidth); + + return ( +
+ + + + {columns.map(column => { + return ( + + {headerRenderer(column)} + + ); + })} + + + + {data.map(item => ( + + {columns.map(column => { + const elementToRender = column.isHeading + ? 'th' + : 'td'; + const isHidden = hiddenColumns.includes( + column.name + ); + return ( + + ); + })} + + ))} + + +
+ ); +} + +Table.defaultProps = { + rowMinHeight: 45, +}; + +Table.propTypes = { + rowMinHeight: PropTypes.number, + data: PropTypes.array.isRequired, + columns: PropTypes.arrayOf(PropTypes.shape(columnPropsShape)), +}; + +const columnPropsShape = { + isHeading: PropTypes.bool, + name: PropTypes.string.isRequired, + cellRenderer: PropTypes.func, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + minWidth: PropTypes.number, + allowLineBreaks: PropTypes.bool, +}; + +function Column() { + return null; +} +Column.displayName = 'Column'; +Column.propTypes = columnPropsShape; + +export {Column}; +export default Table; diff --git a/src/index.js b/src/index.js index 8f5d7102..5ee6f621 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ export {default as Portal} from './Portal'; export {default as SimpleChart} from './charts/SimpleChart'; export {default as SimpleGauge} from './charts/SimpleGauge'; export {default as Switch} from './Switch'; +export {default as Table} from './Table'; export {default as Text} from './Text'; export {default as TextLink} from './TextLink'; export {default as ThemeSection} from './ThemeSection'; diff --git a/src/utils/units.js b/src/utils/units.js index afc980c8..b89b4419 100644 --- a/src/utils/units.js +++ b/src/utils/units.js @@ -13,4 +13,18 @@ const pxToRem = px => pxToRelative(px, rootFontSize, 'rem'); const pxToEm = (px, base = rootFontSize) => pxToRelative(px, base, 'em'); -export {rootFontSize, remToPx, pxToRem, pxToEm}; +/** + * Turns a percentage string, e.g. '57%', into a fraction (0.57) + * + * @param {string} percentage - Percentage value ending with '%' + * + * @returns {number} Percentage as fraction (0..1) + */ + +function percentageToFraction(percentage) { + if (!percentage || typeof percentage !== 'string') return null; + + return Number(percentage.slice(0, -1)) / 100; +} + +export {rootFontSize, remToPx, pxToRem, pxToEm, percentageToFraction};