diff --git a/src/Table/README.mdx b/src/Table/README.mdx index f81bb3f5..00bf3f1c 100644 --- a/src/Table/README.mdx +++ b/src/Table/README.mdx @@ -14,7 +14,7 @@ A table component with a sticky header row. - By default, all columns have an equal width - Control the width of columns by specifying a fixed or percentage width -- You can define a `minWidth` for the table or individual columns – if they grow smaller than the defined size, a list-like **mobile view** will be enabled +- You can define a breakpoint under which a list-like **mobile view** will be enabled (by default this is "xs") - Columns can be defined in JSX using the `Column` component, or using the `columns` prop ## Examples @@ -93,13 +93,13 @@ You can also define your columns as an array of objects, if you prefer: ### Responsiveness examples -- **Mobile view:** By default, the table changes to a more list-like view as soon as it has less than 500 pixels of horizontal space available. Use the `` prop to set a custom pixel threshold under which you want the mobile view to become active. You can also define a `minWidth` for individual columns. As soon as any one column gets smaller than the size specified, the mobile view will kick in, making sure that all data stays readable. -- **Hiding columns:** If you'd rather hide columns than trigger the mobile view, add the `canHideColumns` prop to `
`. In this mode, columns smaller than their specified `minWidth` will be hidden (unless the mobile view is active). +- **Mobile view:** The table changes to a more list-like view when the screen width gets lower than the breakpoint defined using the `mobileViewBreakpoint` prop. +- **Hiding columns:** When the mobile view is not active, you can hide columns below a certain breakpoint using the `hideBelowBreakpoint` prop. Columns hidden this way will become visible again in the mobile view. The table below uses both modes – on medium sized screens, columns that don't fit are hidden, while the mobile view is active on very small screens. -
+
{item.name}} /> - - - + + +
diff --git a/src/Table/getColumnWidths.js b/src/Table/getColumnWidths.js deleted file mode 100644 index e09f2bf3..00000000 --- a/src/Table/getColumnWidths.js +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 99d85150..00000000 --- a/src/Table/getColumnsToHide.js +++ /dev/null @@ -1,114 +0,0 @@ -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 index e3b0479e..1de341e4 100644 --- a/src/Table/index.js +++ b/src/Table/index.js @@ -1,7 +1,6 @@ -import React, {useRef} from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import styled, {css} from 'styled-components'; -import {useSize} from 'react-hook-size'; import {pxToRem} from '../utils/units'; import {alpha, mix} from '../utils/colors'; @@ -11,9 +10,12 @@ import {getSpacing} from '../utils/spacing'; import Text from '../Text'; -import getColumnsToHide from './getColumnsToHide'; import getColumnConfigFromChildren from './getColumnConfigFromChildren'; +function getBreakpoint(key) { + return p => p.theme.globals.breakpoints[p[key]]; +} + const StyledTable = styled.table` position: relative; ${positionProps} @@ -26,20 +28,6 @@ const StyledTable = styled.table` width: 100%; - ${p => - !p.isMobileView && - css` - /* Highlight table row on hover and focus within */ - tr:hover { - background-color: ${p => - alpha(p.theme.shade, Number(p.theme.shadeStrength) / 2)}; - } - tr:focus-within { - background-color: ${p => - alpha(p.theme.shade, Number(p.theme.shadeStrength) / 2)}; - } - `} - td, th { font-weight: inherit; @@ -83,94 +71,114 @@ const StyledTable = styled.table` } } - ${p => - p.pl && - !p.isMobileView && - css` - th:first-child, - td:first-child { - padding-left: ${getSpacing(p.pl, p.theme)}; - } - `} - - ${p => - p.pr && - !p.isMobileView && - css` - th:last-child, - td:last-child { - padding-right: ${getSpacing(p.pr, p.theme)}; - } - `} - - ${p => - p.isMobileView && - css` - /* Hide the column headers. We'll add them back - * in inside of each (non-header) cell. */ - thead { - display: none; - } + /* Non-mobile-view styles only */ + @media (min-width: ${getBreakpoint('mobileViewBreakpoint')}) { + /* Highlight table row on hover and focus within */ + tr:hover { + background-color: ${p => + alpha(p.theme.shade, Number(p.theme.shadeStrength) / 2)}; + } + tr:focus-within { + background-color: ${p => + alpha(p.theme.shade, Number(p.theme.shadeStrength) / 2)}; + } - /* Remove table layout. */ - table, - tbody, - th, - td { - display: block; - } + ${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)}; + } + `} + } - tr { - /* Using flex allows us to modify the order of children - * so we can display the row's header at the top */ - display: flex; - flex-direction: column; - - /* Add some padding for nicer spacing in the content area */ - padding-bottom: ${p => p.theme.globals.spacing.xs}; - background-color: ${p => - mix(p.theme.shade)( - p.theme.background, - p.theme.shadeStrength - )}; - border-top: ${p => borderValue(p.theme)}; - } + /* Mobile-view styles */ + @media (max-width: ${getBreakpoint('mobileViewBreakpoint')}) { + /* Hide column headers. We'll add them back + * inside of each cell (except the row header). */ + thead { + display: none; + } - th { - display: flex; - align-items: center; - font-weight: bold; - background-color: ${p => p.theme.background}; - /* Make sure to display the header at the top */ - order: -1; - /* Visually, this is the top spacing of the content area */ - margin-bottom: ${p => p.theme.globals.spacing.xs}; - } + /* Remove table layout. */ + table, + tbody, + th, + td { + display: block; + } - td { - /* Don't use the specified row height in mobile view */ - height: auto; - display: flex; - font-weight: bold; - - /* Add columns headers as inline labels - * The parent's display: flex ensures clean - * content line breaks */ - &::before { - content: attr(data-columnheader) ': '; - margin-right: ${p => p.theme.globals.spacing.xs}; - font-weight: normal; - white-space: nowrap; - } + tr { + /* Using flex allows us to modify the order of children + * so we can display the row's header at the top */ + display: flex; + flex-direction: column; + + /* Add some padding for nicer spacing in the content area */ + padding-bottom: ${p => p.theme.globals.spacing.xs}; + background-color: ${p => + mix(p.theme.shade)(p.theme.background, p.theme.shadeStrength)}; + border-top: ${p => borderValue(p.theme)}; + } + + th { + /* Make sure to display the header at the top */ + order: -1; + + /* Flex to vertically align content in cell */ + display: flex; + align-items: center; + font-weight: bold; + background-color: ${p => p.theme.background}; + /* Visually, this margin is added to the top padding + * of the content area */ + margin-bottom: ${p => p.theme.globals.spacing.xs}; + } + + td { + /* Override the specified minimum row height */ + height: auto; + display: flex; + font-weight: bold; + + /* Add columns headers as inline labels + * The parent td's display: flex ensures clean + * content line breaks */ + &::before { + content: attr(data-columnheader) ': '; + margin-right: ${p => p.theme.globals.spacing.xs}; + font-weight: normal; + white-space: nowrap; } - `} + } + } `; -const defaultHeaderRenderer = column => {column.name}; +const Cell = styled.td` + @media (max-width: ${getBreakpoint('hideBelowBreakpoint')}) { + display: none; + } +`; + +/** + * 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. + * + * @param {object} item + * @param {string|Function} key + */ -// 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'; @@ -178,109 +186,105 @@ function getCellContent(item, key) { return key(item); } +const defaultHeaderRenderer = column => {column.name}; + // Note about roles: The table is marked up using roles // that are seemingly redundant. This is done so that the // styles of the mobile view don't remove the table's semantics, // which they'd otherwise do. Explicit roles ensure that // semantics aren't affected by the styles. -function Table({ - children, - columns: columnsProp, - data, - headerRenderer, - stickyHeaderOffset, - canHideColumns, - minWidth, - rowMinHeight, - shadedHeader, - ...otherProps -}) { - const ref = useRef(); - const {width} = useSize(ref); +function Table(props) { + const { + children, + columns: columnsProp, + data, + headerRenderer, + stickyHeaderOffset, + mobileViewBreakpoint, + rowMinHeight, + shadedHeader, + ...otherProps + } = props; + const columns = children ? getColumnConfigFromChildren(children) : columnsProp; - const hiddenColumns = getColumnsToHide(columns, width); - const isMobileView = - width < minWidth || (!canHideColumns && hiddenColumns.length > 0); - return ( -
- - {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} - - + + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} + + + {columns.map(column => { + const { + hideBelowBreakpoint, + name, + subtitle, + width, + } = column; + return ( + + {headerRenderer(column)} + {subtitle && ( + + {subtitle} + + )} + + ); + })} + + + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} + + {data.map(item => ( + {columns.map(column => { - const {name, subtitle, width} = column; + const { + cellRenderer, + isHeading, + hideBelowBreakpoint, + name, + } = column; + return ( - - {headerRenderer(column)} - {subtitle && ( - - {subtitle} - - )} - + {getCellContent(item, cellRenderer || name)} + ); })} - - {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} - - {data.map(item => ( - - {columns.map(column => { - const {cellRenderer, isHeading, name} = column; - - const Element = isHeading ? 'th' : 'td'; - const isHidden = - !isMobileView && - hiddenColumns.includes(name); - - return ( - - ); - })} - - ))} - - -
+ ))} + + ); } Table.defaultProps = { - minWidth: 500, headerRenderer: defaultHeaderRenderer, + mobileViewBreakpoint: 'xs', stickyHeaderOffset: 0, rowMinHeight: 45, }; @@ -294,9 +298,10 @@ Table.propTypes = { */ stickyHeaderOffset: PropTypes.number, /** - * Specify below which width the mobile view should kick in + * Specify below which breakpoint (from `theme.globals.breakpoints`) + * the mobile view should kick in. */ - minWidth: PropTypes.number, + mobileViewBreakpoint: PropTypes.number, pl: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), pr: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), rowMinHeight: PropTypes.number, @@ -306,7 +311,7 @@ Table.propTypes = { const columnPropsShape = { cellRenderer: PropTypes.func, isHeading: PropTypes.bool, - minWidth: PropTypes.number, + hideBelowBreakpoint: PropTypes.string, name: PropTypes.string.isRequired, subtitle: PropTypes.string, width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), diff --git a/src/theme/sections.js b/src/theme/sections.js index 6c54724b..4586b956 100644 --- a/src/theme/sections.js +++ b/src/theme/sections.js @@ -8,7 +8,7 @@ const sections = { shade: colors.black, highlight: colors.pink, textDimStrength: 0.75, - shadeStrength: 0.08, + shadeStrength: 0.05, lineStrength: 0.2, }, }; diff --git a/src/utils/units.js b/src/utils/units.js index b89b4419..afc980c8 100644 --- a/src/utils/units.js +++ b/src/utils/units.js @@ -13,18 +13,4 @@ const pxToRem = px => pxToRelative(px, rootFontSize, 'rem'); const pxToEm = (px, base = rootFontSize) => pxToRelative(px, base, 'em'); -/** - * 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}; +export {rootFontSize, remToPx, pxToRem, pxToEm};