import { CactusTheme } from '@repay/cactus-theme' import { Property } from 'csstype' import PropTypes from 'prop-types' import React from 'react' import styled, { StyledComponentBase } from 'styled-components' import * as SS from 'styled-system' import Flex, { FlexBoxProps } from '../Flex/Flex' import { isIE } from '../helpers/constants' import { getOmittableProps } from '../helpers/omit' import { useMergedRefs } from '../helpers/react' import { FlexItemProps } from '../helpers/styled' import { screenSizes, useScreenSize } from '../ScreenSizeProvider/ScreenSizeProvider' const DEFAULT_GAP = 4 const PSEUDO_FLEX_COLS = 12 const COLUMN_NUMS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const // Typescript (rightly) dislikes IE property names, so... const css = { grid: '-ms-grid', gridTemplateRows: '-ms-grid-rows' as 'gridTemplateRows', gridTemplateColumns: '-ms-grid-columns' as 'gridTemplateColumns', gridRowStart: '-ms-grid-row' as 'gridRowStart', gridColumnStart: '-ms-grid-column' as 'gridColumnStart', // ...not really true, but as close as we can get. gridRowEnd: '-ms-grid-row-span' as 'gridRowEnd', gridColumnEnd: '-ms-grid-column-span' as 'gridColumnEnd', justifySelf: '-ms-grid-column-align' as 'justifySelf', alignSelf: '-ms-grid-row-align' as 'alignSelf', } if (!isIE) { Object.keys(css).forEach((key) => ((css as any)[key] = key)) } type GridProperty = keyof typeof css type ScreenSize = typeof screenSizes[number] type ColumnNum = typeof COLUMN_NUMS[number] type PseudoFlexProps = { [K in ScreenSize]?: ColumnNum } interface GridBoxProps extends FlexBoxProps { rows?: SS.ResponsiveValue> cols?: SS.ResponsiveValue> columns?: SS.ResponsiveValue> // Backwards compat; I recommend using `justifyItems` for clarity. justify?: SS.ResponsiveValue justifyItems?: SS.ResponsiveValue autoFlow?: SS.ResponsiveValue autoRows?: SS.ResponsiveValue> autoCols?: SS.ResponsiveValue> autoColumns?: SS.ResponsiveValue> gridAreas?: { [key: string]: SS.ResponsiveValue } // I'm supporting these for completeness, but their syntax isn't very React-friendly. grid?: SS.ResponsiveValue gridTemplate?: SS.ResponsiveValue gridTemplateAreas?: SS.ResponsiveValue } interface GridItemProps extends FlexItemProps, PseudoFlexProps { row?: SS.ResponsiveValue rowStart?: SS.ResponsiveValue rowEnd?: SS.ResponsiveValue col?: SS.ResponsiveValue column?: SS.ResponsiveValue colStart?: SS.ResponsiveValue columnStart?: SS.ResponsiveValue colEnd?: SS.ResponsiveValue columnEnd?: SS.ResponsiveValue gridArea?: SS.ResponsiveValue justifySelf?: SS.ResponsiveValue } interface GridComponent extends StyledComponentBase<'div', CactusTheme, GridBoxProps> { Item: StyledComponentBase<'div', CactusTheme, GridItemProps> supportsGrid: boolean supportsGap: boolean } const gridItemStyles = (function () { let itemStyleConfig: SS.Config if (isIE) { const delimiter = /\s*\/\s*/ const spanRegex = /span/i const getStartSpan = ( startProp: GridProperty, spanProp: GridProperty, start: string, end?: string ) => { const styles: { [K in GridProperty]?: string } = {} if (end) { if (spanRegex.test(end)) { styles[spanProp] = String(parseInt(end.replace(spanRegex, ''))) } else { styles[spanProp] = String(parseInt(end) - parseInt(start)) } } styles[startProp] = start return styles } const getCombinedStartEndParser = (startProp: GridProperty, spanProp: GridProperty): SS.ConfigFunction => (spec) => { if (spec) { if (typeof spec === 'number') { return getStartSpan(startProp, spanProp, String(spec)) } else if (typeof spec === 'string') { const [start, end] = spec.split(delimiter) return getStartSpan(startProp, spanProp, start, end) } } } const getStartParser = (startProp: GridProperty): SS.ConfigFunction => (start) => { if (start) { return { [startProp]: String(start) } } } const getEndParser = (spanProp: GridProperty): SS.ConfigFunction => (span, _, props) => { if (span) { let end = span if (typeof span === 'string') { if (spanRegex.test(span)) { span = span.replace(spanRegex, '').trim() } else { end = parseInt(span) } } if (typeof end === 'number') { const start = spanProp === css.gridRowEnd ? props.rowStart : props.colStart || props.columnStart span = end - (parseInt(start) || 1) } return { [spanProp]: String(span) } } } itemStyleConfig = { row: getCombinedStartEndParser(css.gridRowStart, css.gridRowEnd), rowStart: getStartParser(css.gridRowStart), rowEnd: getEndParser(css.gridRowEnd), col: getCombinedStartEndParser(css.gridColumnStart, css.gridColumnEnd), colStart: getStartParser(css.gridColumnStart), colEnd: getEndParser(css.gridColumnEnd), gridArea: (areaSpec: unknown) => { if (areaSpec && typeof areaSpec === 'string') { const [rowStart, colStart, rowEnd, colEnd] = areaSpec.split(delimiter) const rowStyles = getStartSpan(css.gridRowStart, css.gridRowEnd, rowStart, rowEnd) const colStyles = getStartSpan(css.gridColumnStart, css.gridColumnEnd, colStart, colEnd) return Object.assign(rowStyles, colStyles) } }, justifySelf: { property: css.justifySelf }, alignSelf: { property: css.alignSelf }, } } else { // Stringify to ensure grid lines won't be treated like CSS values. const transform = (val: unknown) => (typeof val === 'number' ? String(val) : val) itemStyleConfig = { row: { property: 'gridRow', transform }, rowStart: { property: 'gridRowStart', transform }, rowEnd: { property: 'gridRowEnd', transform }, col: { property: 'gridColumn', transform }, colStart: { property: 'gridColumnStart', transform }, colEnd: { property: 'gridColumnEnd', transform }, gridArea: { property: 'gridArea', transform }, justifySelf: true, } } itemStyleConfig.column = itemStyleConfig.col itemStyleConfig.columnStart = itemStyleConfig.colStart itemStyleConfig.columnEnd = itemStyleConfig.colEnd return SS.system(itemStyleConfig) })() const pseudoFlexStyles = (function (): SS.styleFn { const parser: SS.styleFn = (props: GridItemProps & { theme: CactusTheme }) => { const spans = screenSizes.map((size) => props[size] && `span ${props[size]}`) return gridItemStyles({ colEnd: spans, theme: props.theme }) } parser.propNames = [...screenSizes] return parser })() const itemStyleProps = getOmittableProps(gridItemStyles, pseudoFlexStyles) export const GridItem = styled(Flex.Item).withConfig({ shouldForwardProp: (p: any) => !itemStyleProps.has(p), })` ${pseudoFlexStyles} && { ${gridItemStyles} } ` as GridComponent['Item'] GridItem.displayName = 'Grid.Item' const ColumnPropType = PropTypes.oneOf(COLUMN_NUMS) GridItem.propTypes = { tiny: ColumnPropType, small: ColumnPropType, medium: ColumnPropType, large: ColumnPropType, extraLarge: ColumnPropType, } export const Grid: GridComponent = (function () { const repeat = (count: number, text: string) => { if (!isIE) return `repeat(${count}, ${text})` const result: string[] = [] for (let i = 0; i < count; i++) result.push(text) return result.join(' ') } // Shortcut, e.g. so you can just say `cols={4}` and it'll add 4 equal columns. const templateTransform = (defaultSize: string) => (val: unknown) => typeof val === 'number' ? repeat(val, defaultSize) : val const gridStyleConfig: SS.Config = { rows: { property: css.gridTemplateRows, transform: templateTransform('min-content') }, cols: { property: css.gridTemplateColumns, transform: templateTransform('1fr') }, justifyItems: { property: 'justifyItems' }, autoFlow: { property: 'gridAutoFlow' }, autoRows: { property: 'gridAutoRows' }, autoCols: { property: 'gridAutoColumns' }, grid: true, gridTemplate: true, gridTemplateAreas: true, } gridStyleConfig.justify = gridStyleConfig.justifyItems gridStyleConfig.columns = gridStyleConfig.cols gridStyleConfig.autoColumns = gridStyleConfig.autoCols let gridTag: any let justifyStyles: SS.styleFn if (isIE) { // Technically there are other indicator props, but none are supported in any // way by IE, so if going for IE-compatible they won't be using those anyway. const isPseudoFlexMode = (props: GridBoxProps) => ({ isPseudoFlex: !(props.rows || props.cols || props.columns || props.gridAreas), }) const gridBase = React.forwardRef( ({ isPseudoFlex, ...props }, inRef) => { // Make sure we re-run the effect when screen size changes. useScreenSize() const ref = useMergedRefs(inRef) React.useLayoutEffect(() => { // IE doesn't auto-fill grids, so we have to do it ourselves. if (ref.current && isPseudoFlex) { let column = 0 // The math's a bit easier starting from zero. let row = 1 const gridItems = ref.current.children for (let i = 0; i < gridItems.length; i++) { const item = gridItems[i] as HTMLElement const span = parseInt(getComputedStyle(item)[css.gridColumnEnd]) if (span + column > PSEUDO_FLEX_COLS) { row += 1 column = 0 } item.style[css.gridRowStart] = String(row) item.style[css.gridColumnStart] = String(column + 1) column += span } ref.current.style[css.gridTemplateRows] = repeat(row, 'min-content') } }) return } ) gridTag = styled(gridBase).attrs(isPseudoFlexMode) const applyJustifyToChildren = (value: unknown) => { if (value) return { '& > *': { [css.justifySelf]: value } } } // Keeping this separate in case we implement inline styles, see CACTUS-975. justifyStyles = SS.system({ justify: applyJustifyToChildren, justifyItems: applyJustifyToChildren, alignItems: (value: unknown) => { // Note the key is slightly different to prevent overwriting // if both `alignItems` & `justifyItems` are passed. if (value) return { '&>*': { [css.alignSelf]: value } } }, }) } else { gridTag = styled(Flex) // Could be a constant, but it makes things more symmetrical with the IE code. justifyStyles = () => ({ justifyItems: 'normal' }) } const pseudoFlexColumns = repeat(PSEUDO_FLEX_COLS, 'minmax(1px, 1fr)') const systemStyles = SS.system(gridStyleConfig) const gridStyles = (props: GridBoxProps & { theme: CactusTheme }) => { const styles = justifyStyles(props) styles.width = '100%' styles.display = css.grid styles[css.gridTemplateColumns] = pseudoFlexColumns styles['&&'] = systemStyles(props) const { gridAreas } = props if (gridAreas) { for (const className of Object.keys(gridAreas)) { styles[`.${className}`] = gridItemStyles({ gridArea: gridAreas[className], theme: props.theme, }) } } return styles } const styleProps = getOmittableProps(systemStyles, 'gridAreas') return gridTag.withConfig({ displayName: 'Grid', shouldForwardProp: (p: any) => !styleProps.has(p), })(gridStyles) })() Grid.defaultProps = { gap: DEFAULT_GAP } Grid.Item = GridItem Grid.supportsGap = Flex.supportsGap Grid.supportsGrid = typeof CSS !== 'undefined' && CSS.supports?.('display', 'grid') export default Grid