import { border, boxShadow, CactusTheme, color, ColorStyle, mediaGTE, radius, textStyle, } from '@repay/cactus-theme' import PropTypes from 'prop-types' import React, { createContext, useContext, useLayoutEffect } from 'react' import styled, { css, DefaultTheme, FlattenInterpolation, ThemeProps } from 'styled-components' import { margin, MarginProps, width, WidthProps } from 'styled-system' import { extractMargins } from '../helpers/omit' import { useMergedRefs } from '../helpers/react' import variant from '../helpers/variant' import { ScreenSizeContext, Size, SIZES } from '../ScreenSizeProvider/ScreenSizeProvider' type CellAlignment = 'center' | 'right' | 'left' type CellType = 'th' | 'td' type BorderCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' export type FocusOption = boolean | 'mouse-only' export type TableVariant = 'table' | 'card' | 'mini' export type StickyColAlignment = 'right' | 'none' interface TableContextProps { cellType?: CellType inHeader: boolean headers?: React.ReactNode[] cellIndex: number variant: TableVariant dividers?: boolean rowFocus: FocusOption } interface TableProps extends MarginProps, React.TableHTMLAttributes { fullWidth?: boolean cardBreakpoint?: Size variant?: TableVariant as?: React.ElementType dividers?: boolean sticky?: StickyColAlignment rowFocus?: FocusOption rowHover?: boolean } interface TableHeaderProps extends React.TableHTMLAttributes { variant?: TableVariant dividers?: boolean sticky?: StickyColAlignment } export interface TableCellProps extends WidthProps, Omit< React.TdHTMLAttributes & React.ThHTMLAttributes, 'width' > { variant?: TableVariant align?: CellAlignment as?: CellType } type TableRowProps = React.TableHTMLAttributes type TableBodyProps = React.HTMLAttributes const DEFAULT_CONTEXT: TableContextProps = { inHeader: false, cellIndex: 0, variant: 'table', dividers: false, rowFocus: true, } const TableContext = createContext(DEFAULT_CONTEXT) const Wrapper = styled.div` max-width: 100%; overflow-x: auto; margin: 0 16px; ${margin} ${(p) => (p.fullWidth ? 'width: 100%;' : '')}; ` interface heightInfoProps { max: number cells: any[] } export const Table = React.forwardRef((initProps, ref) => { const { children, cardBreakpoint, sticky = 'none', rowFocus = true, ...props } = initProps const size = useContext(ScreenSizeContext) const context: TableContextProps = { ...DEFAULT_CONTEXT, headers: [], rowFocus } const tableRef = React.useRef(null) const mergedRef = useMergedRefs(ref, tableRef) const marginProps = extractMargins(props) useLayoutEffect(() => { const heightInfo: heightInfoProps[] = [] let height = 0 if (context.variant === 'card') { const cells = tableRef.current?.querySelectorAll(`${StyledCell}`) if (cells && cells.length > 0) { cells.forEach((cell) => { const cellIndex = (cell as HTMLTableCellElement).cellIndex const info = heightInfo[cellIndex] || (heightInfo[cellIndex] = { max: 0, cells: [] }) const [first, second] = cell.childNodes as any const rect1 = first?.getBoundingClientRect?.() const rect2 = second?.getBoundingClientRect?.() if (rect1) { if (!rect2 || rect1.width + rect2.width < cell.clientWidth - 16) { height = rect1.height } else { height = rect1.height + rect2.height + 16 } info.max = Math.max(info.max, height) info.cells.push({ height, cell }) } }) // We queue the updates then make them all at once to avoid layout thrashing. for (const info of heightInfo) { for (const cellInfo of info.cells) { cellInfo.cell.style.minHeight = `${info.max}px` } } } } }) if (props.variant) { context.variant = props.variant } else if (cardBreakpoint && size <= SIZES[cardBreakpoint]) { context.variant = 'card' } if (props.dividers) { context.dividers = props.dividers } props.variant = context.variant return ( {props.as ? ( {children} ) : ( {children} )} ) }) export const TableCell = React.forwardRef( ({ children, ...props }, ref): React.ReactElement => { const context = useContext(TableContext) if (context.cellType && !props.as) { props.as = context.cellType } const colSpan = parseInt((props.colSpan as any) || '1') if (context.inHeader && context.headers) { context.headers[context.cellIndex] = children context.cellIndex += colSpan } else if (context.variant === 'card') { const headerContent = context.headers && context.headers[context.cellIndex] context.cellIndex += colSpan props.variant = 'card' return ( {headerContent && {headerContent}} {children} ) } props.variant = context.variant return ( {children} ) } ) export const TableHeader = React.forwardRef( ({ children, ...props }, ref): React.ReactElement => { const context = useContext(TableContext) return ( {children} ) } ) export const TableRow: React.FC = ({ children, ...props }): React.ReactElement => { const context = useContext(TableContext) const tabIndex = context.rowFocus === 'mouse-only' ? -1 : context.rowFocus ? 0 : undefined return ( {children} ) } export const TableBody = 'tbody' type ForwardRefComponent = React.ForwardRefExoticComponent< React.PropsWithoutRef

& React.RefAttributes > type TableComponentType = ForwardRefComponent & { Header: ForwardRefComponent Cell: ForwardRefComponent Row: React.FC Body: React.ElementType } const DefaultTable = Table as TableComponentType DefaultTable.Header = TableHeader DefaultTable.Cell = TableCell DefaultTable.Row = TableRow DefaultTable.Body = TableBody TableCell.propTypes = { align: PropTypes.oneOf(['center', 'right', 'left']), as: PropTypes.oneOf(['th', 'td']), } Table.displayName = 'Table' Table.propTypes = { fullWidth: PropTypes.bool, // @ts-ignore rowFocus: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['mouse-only'])]), rowHover: PropTypes.bool, cardBreakpoint: PropTypes.oneOf(['tiny', 'small', 'medium', 'large', 'extraLarge']), variant: PropTypes.oneOf(['table', 'card', 'mini']), } Table.defaultProps = { cardBreakpoint: 'tiny', rowFocus: true, rowHover: true, } export default DefaultTable const getShape = (location: BorderCorner): FlattenInterpolation> => css`border-${location}-radius: ${radius(8)};` const HeaderBox = styled.div.attrs({ 'aria-hidden': 'true' })` text-transform: uppercase; font-weight: 600; text-align: left; max-width: 100%; flex-grow: 1; margin-right: 8px; &:empty { display: none; } ` const ContentBox = styled.div` flex-grow: 1; text-align: right; overflow-wrap: break-word; word-wrap: break-word; min-width: 20%; &:only-child, ${HeaderBox}:empty + & { text-align: center; } ${HeaderBox}:not(:empty) + & { align-self: flex-end; } ` const StyledCell = styled.td( variant({ table: css` text-align: ${(p) => p.align || 'left'}; padding: 16px; ${(p) => p.width ? width : css` min-width: calc(160px * 0.7125); ${mediaGTE('large')} { min-width: calc(160px * 0.875); } ${mediaGTE('extraLarge')} { min-width: 160px; } `}; `, mini: css` text-align: ${(p) => p.align || 'left'}; padding: 8px; ${(p) => p.width && width}; `, card: css` && { display: flex; width: 240px; flex-flow: row wrap; justify-content: space-between; align-items: flex-start; box-sizing: border-box; padding: 8px 16px; :nth-of-type(even) { background-color: ${color('lightContrast')}; } :last-child { border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; } } `, }) ) const headerVariants = variant({ card: css` tr { position: absolute; top: auto; left: -10000px; } `, }) const StyledHeader = styled.thead` // Lots of specificity to override the first/last row border colors. &&&&& th, &&&&& td { text-transform: uppercase; border: ${border('base')}; border-right: ${(p) => (p.dividers ? border(p.theme, 'mediumContrast') : '')}; ${(p): ColorStyle => p.theme.colorStyles.base}; } ${headerVariants} ` const getCTABorder = (p: ThemeProps, focus?: boolean): ReturnType => { return css` th, td { border-left-color: ${p.theme.colors.callToAction}; border-top-color: ${p.theme.colors.callToAction}; border-bottom-color: ${p.theme.colors.callToAction}; background-color: ${focus && p.theme.colors.lightCallToAction}; } td:last-child { border-color: ${p.theme.colors.callToAction}; } ` } const table = css` display: table; ${(p) => textStyle(p.theme, p.variant === 'mini' ? 'small' : 'body')}; border-spacing: 0; td, th { border-right: ${(p) => (p.dividers ? border(p.theme, 'lightContrast') : '')}; background-color: ${color('white')}; border-top: ${border('transparent')}; border-bottom: ${border('transparent')}; :first-child { border-left: ${border('lightContrast')}; } :last-child { border-right: ${border('lightContrast')}; :focus-within { z-index: 1; } ${(p) => p.sticky === 'right' ? `position: sticky; right: 0; ${boxShadow(p.theme, 0)}` : ''}; } } tr:nth-of-type(even) { td, th { background-color: ${color('lightContrast')}; } td:not(:last-child) { border-right: ${(p) => (p.dividers ? border(p.theme, 'white') : '')}; } } &&& tr:hover { ${(p) => p.rowHover && getCTABorder(p)} } &&& tr:focus { outline: 0; ${(p) => getCTABorder(p, true)} } // first row & > tr:first-of-type, tbody > tr:first-child, thead > tr:first-child { th, td { border-top-color: ${color('lightContrast')}; :first-child { ${getShape('top-left')}; } :last-child { ${getShape('top-right')}; } } } // first row false positives && > thead + tr:first-of-type, && > thead + tbody > tr:first-child { th, td { border-top-color: transparent; border-top-left-radius: 0; border-top-right-radius: 0; } } // last row &, tbody:last-child, thead:last-child, tfoot:last-child { & > tr:last-child { th, td { border-bottom-color: ${color('lightContrast')}; :first-child { ${getShape('bottom-left')}; } :last-child { ${getShape('bottom-right')}; } } } } ` const card = css` ${textStyle('tiny')}; overflow-wrap: break-word; &, thead, tbody, tfoot { display: inline-flex; flex-flow: row nowrap; justify-content: flex-start; } tr { display: flex; flex-flow: column nowrap; min-width: 240px; max-width: 360px; ${boxShadow(1)} background-color: ${color('white')}; border: ${border('lightContrast')}; border-radius: ${radius(8)}; margin: 4px; outline: 0; :focus { border-color: ${color('callToAction')}; } } th, td { display: block; text-align: center; max-width: 100%; padding: 8px 16px; :nth-of-type(even) { background-color: ${color('lightContrast')}; } :last-child { border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; } } ` const StyledTable = styled.table` ${(p) => (p.fullWidth ? 'min-width: 100%' : '')}; color: ${color('darkestContrast')}; th { font-weight: 600; } ${variant({ table, card, mini: table })}; `