import * as React from 'react' import {cloneElement, createContext, CSSProperties, useCallback, useContext, useLayoutEffect, useState} from 'react' import {Dimensions} from "./dimensions"; import { cellDimensionFor, emptyGridTrackTemplate, gridLineNamesFor, GridTrackTemplate, gridTrackTemplateBuilder, trackIndexFor, withFraction, withGridTrack } from "./gridTemplates"; import {emptyGridTemplateAreas, GridArea, GridTemplateAreas, isGridTemplateAreasNonEmpty} from "./gridTemplateAreas"; import {GridErrorBoundary} from "./GridErrorBoundary"; interface UseGridValues { width: number height: number gridTemplateRows: GridTrackTemplate gridTemplateColumns: GridTrackTemplate gridTemplateAreas: GridTemplateAreas rowGap: number columnGap: number showGrid: boolean } const initialGridValues: UseGridValues = { width: 10, height: 10, gridTemplateRows: emptyGridTrackTemplate(), gridTemplateColumns: emptyGridTrackTemplate(), gridTemplateAreas: emptyGridTemplateAreas(), rowGap: 0, columnGap: 0, showGrid: false } const GridContext = createContext(initialGridValues) interface GridDimensions { numRows: number numColumns: number } interface Props { // supplies the dimensions of the (parent) container whose dimensions // this grid uses. dimensionsSupplier: () => Dimensions gridTemplateRows?: GridTrackTemplate gridTemplateColumns?: GridTrackTemplate gridTemplateAreas?: GridTemplateAreas // the number pixels between rows in the grid rowGap?: number // the number pixels between columns in the grid columnGap?: number showGrid?: boolean // additional styles styles?: CSSProperties // the children grid-cells children: JSX.Element | Array } /** * Grid layout for use when the grid cells need to pass one their size (in pixels) to their * children. This is useful when the child uses a canvas or svg element that needs to have its * explicit size set. * * The grid expects a size (width, height) in pixels, which may be supplied by the `DimensionProvider`, * and the number rows and columns that comprise the grid. The children of the must be * elements. * * For single elements requiring a size in pixels, use the raw `DimensionProvider`. * * Uses CSS grid underneath. * @param props The properties defining the grid's dimensions and children * @return A JSX element representing the grid * @constructor */ export function Grid(props: Props): JSX.Element { const { dimensionsSupplier, gridTemplateRows, gridTemplateColumns, gridTemplateAreas = emptyGridTemplateAreas(), rowGap = 0, columnGap = 0, showGrid = false, styles, children } = props const {width, height} = dimensionsSupplier() const calcGridDimensions = useCallback( (): GridDimensions => { if (!Array.isArray(children)) { return { numRows: trackIndexFor((children.props as CellProps).row || 0, gridTemplateRows || emptyGridTrackTemplate()), numColumns: trackIndexFor((children.props as CellProps).column || 0, gridTemplateColumns || emptyGridTrackTemplate()) } } return children .map(child => { const props = (child.props as CellProps) return {numRows: props.row, numColumns: props.column} as GridDimensions }) .reduce((dim1, dim2) => ({ numRows: Math.max(dim1.numRows, dim2.numRows), numColumns: Math.max(dim1.numColumns, dim2.numColumns) })) }, [children, gridTemplateColumns, gridTemplateRows] ) const [gridDimensions, setGridDimensions] = useState(calcGridDimensions()) useLayoutEffect( () => { setGridDimensions(calcGridDimensions()) }, [calcGridDimensions] ) if (width === undefined || height === undefined) { return <> } const {numRows, numColumns} = gridDimensions if (numRows <= 0 && gridTemplateRows === undefined) { throw new Error(` rows defined by the children must be 1 or larger, or the grid-template-rows property must be set; specified rows: ${numRows}`) } if (numColumns <= 0 && gridTemplateColumns === undefined) { throw new Error(` columns defined by the children must be 1 or larger, or the grid-template-columns property must be set; specified columns: ${numColumns}`) } // use the specified grid templates, or use the default values calculated from the // children's (row, column) properties // todo move this into a useMemo so that they are only recalculated if the values change const templateRows = gridTemplateRows || gridTrackTemplateBuilder() .repeatFor(numRows, withGridTrack(withFraction(1))) .build() const templateColumns = gridTemplateColumns || gridTrackTemplateBuilder() .repeatFor(numColumns, withGridTrack(withFraction(1))) .build() /** * Attempts to calculate a react key for each grid-cell based on the properties set on the child. * @param cellProps The properties of the child grid-cell * @return A unique key for the grid-cell, or undefined and an error if no key could be calculated * or found. */ function cellKeyGenerator(cellProps: CellProps): string | undefined { if (cellProps.cellKey) { return `grid-cell-key-${cellProps.cellKey}` } if (cellProps.gridAreaName) { return `grid-cell-area-${cellProps.gridAreaName}` } if (cellProps.row !== undefined && cellProps.column !== undefined) { return `grid-cell-${cellProps.row}-${cellProps.column}` } throw new Error( 'Each child should have a unique react key. If you have not specified a (row, column) coordinate' + ' or a grid-area name, then you ought to set the cellKey property for each ' ) } /** * Clones the children (or child) and adds the height, width, numRows, and numColumns props. * @param children An array of `GridCell` or a single JSX element * @return The enriched children */ function enrich(children: JSX.Element | Array): JSX.Element | Array { const childElements = Array.isArray(children) ? children : [children]; const invalidChildren = childElements.filter(child => !(child.type.name === GridItem.name || child.type.name === GridCell.name)); if (invalidChildren.length > 0) { throw new Error( " allows only as children; " + `invalid children: ${invalidChildren.map(child => typeof child.type).join(", ")}` ) } // for now display use of the deprecated // noinspection JSDeprecatedSymbols const deprecatedChildren = childElements .filter(child => child.type.name === GridCell.name) .map(child => child.props.gridAreaName ? child.props.gridAreaName : `(${child.props.row}, ${child.props.column})`) .join(", ") if (deprecatedChildren.length > 0) { console.warn(` is deprecated. Please use : [${deprecatedChildren}]`) } // end of deprecated code guard return childElements.map(child => cloneElement( child, { key: cellKeyGenerator(child.props), height, width, numRows, numColumns } )) } return (
{enrich(children)}
) } interface UseGridCellValues extends GridArea { width: number height: number } const initialCellValues: UseGridCellValues = { width: 10, height: 10, row: 1, column: 1, rowsSpanned: 1, columnsSpanned: 1 } const GridCellContext = createContext(initialCellValues) interface CellProps { cellKey?: string column?: number | string columnsSpanned?: number row?: number | string rowsSpanned?: number gridAreaName?: string isVisible?: boolean // additional styles styles?: CSSProperties children: JSX.Element } /** * @deprecated use {@link GridItem} instead * A cell in the whose location and size are controlled by the parent. The parent * component provides the width and height of the entire area it manages. The uses the width * and height from the parent, its grid location and the row and column spans, to determine the width and * height of the . It passes the calculated width and height to it child. * * Uses CSS grid underneath. * @param props The properties defining the location and row and column spans and the width and height * managed by the parent * @return A sized * @constructor */ export function GridCell(props: CellProps): JSX.Element { return GridItem(props) } /** * A cell in the whose location and size are controlled by the parent. The parent * component provides the width and height of the entire area it manages. The uses the width * and height from the parent, its grid location and the row and column spans, to determine the width and * height of the . It passes the calculated width and height to it child. * * Uses CSS grid underneath. * @param props The properties defining the location and row and column spans and the width and height * managed by the parent * @return A sized * @constructor */ export function GridItem(props: CellProps): JSX.Element { const { column, columnsSpanned, row, rowsSpanned, gridAreaName, isVisible = true, styles, children, } = props const { width, height, gridTemplateRows, gridTemplateColumns, gridTemplateAreas, rowGap, columnGap, showGrid } = useContext(GridContext) if (!isVisible) return <> // when the grid areas template is not empty and the gridArea was specified, then attempt to find the // coordinates (row, column) and the spans that define the cell const gridArea = gridCellPlacementFrom(gridTemplateAreas, gridAreaName) if (gridArea === undefined && (column === undefined || row === undefined)) { throw new Error( 'Unable to determine the placement for the grid-cell. The placement of a must be defined by a ' + 'grid-area name, or by specifying (row, column) coordinates as properties of the . ' + `gridAreaName: ${gridAreaName ? gridAreaName : '[undefined]'}; valid grid-area-names: [${Array.from(gridTemplateAreas.gridAreas.keys()).join(", ")}]; ` + `row: ${row !== undefined ? row : '[undefined]'}; column: ${column !== undefined ? column : '[undefined]'};` ) } const spannedColumns = gridArea ? (gridArea.columnsSpanned || 1) : (columnsSpanned || 1) const spannedRows = gridArea ? gridArea.rowsSpanned || 1 : (rowsSpanned || 1) // find the column and row indexes (the row or column could have been specified as a grid-line name const columnIndex = gridArea ? gridArea.column : trackIndexFor(column || 0, gridTemplateColumns) if (columnIndex === 0 && typeof column === 'string') { const lineNames = gridLineNamesFor(gridTemplateColumns).join(", ") throw new Error( ` line-name for specified column identifier not found in any tracks; column: "${column}"; line-names: [${lineNames}]` ) } const rowIndex = gridArea ? gridArea.row : trackIndexFor(row || 0, gridTemplateRows) if (rowIndex === 0 && typeof row === 'string') { const lineNames = gridLineNamesFor(gridTemplateRows).join(", ") throw new Error( ` line-name for specified row identifier not found in any tracks; row: "${row}"; line-names: [${lineNames}]` ) } // ensure that the row index and column index are within valid bounds const numRows = gridTemplateRows.trackList.length const numColumns = gridTemplateColumns.trackList.length if (rowIndex < 1 || rowIndex > numRows) { throw new Error( `Unable to place because row is out of range (1 ≤ row ≤ ${numRows}); ` + `row-index: ${rowIndex}; column-index: ${columnIndex}` ) } if (spannedRows < 1 || rowIndex + spannedRows - 1 > numRows) { throw new Error( `Unable to place because the row-index plus the spanned-rows cannot exceed the number of rows ` + `(1 ≤ spanned-rows ≤ ${rowIndex + spannedRows - 1}); row-index: ${rowIndex}; column-index: ${columnIndex}; ` + `spanned-rows: ${spannedRows}; num-rows: ${numRows}` ) } if (columnIndex < 1 || columnIndex > numColumns) { throw new Error( `Unable to place because column is out of range (1 ≤ column ≤ ${numColumns}); ` + `row-index: ${rowIndex}; column-index: ${columnIndex}` ) } if (spannedColumns < 1 || columnIndex + spannedColumns - 1 > numColumns) { throw new Error( `Unable to place because the column-index plus the spanned-columns cannot exceed the number of columns ` + `(1 ≤ spanned-columns ≤ ${columnIndex + spannedColumns - 1}); row-index: ${rowIndex}; column-index: ${columnIndex}; ` + `spannedColumns: ${spannedColumns}; numColumns: ${numColumns}` ) } // update the style when in debug mode const debug: CSSProperties = showGrid ? {borderStyle: 'dashed', borderWidth: 1, borderColor: 'lightgrey'} : {} const cellWidth = cellDimensionFor(width, columnIndex, columnGap, spannedColumns, gridTemplateColumns) const cellHeight = cellDimensionFor(height, rowIndex, rowGap, spannedRows, gridTemplateRows) const style = gridArea ? { gridArea: gridAreaName } : { gridColumnStart: column, gridColumnEnd: Math.min(columnIndex + spannedColumns, numColumns + 1), gridRowStart: row, gridRowEnd: Math.min(rowIndex + spannedRows, numRows + 1), } return ( console.error(error)}>
{cloneElement(children, {width: cellWidth, height: cellHeight})}
) } function gridCellPlacementFrom(template: GridTemplateAreas, gridAreaName?: string): GridArea | undefined { // when the grid areas template is not empty and the gridArea was specified, then attempt to find the // coordinates (row, column) and the spans that define the cell if (isGridTemplateAreasNonEmpty(template) && gridAreaName !== undefined && gridAreaName.length > 0) { const gridArea = template.gridAreas.get(gridAreaName) if (gridArea !== undefined) { return {...gridArea} } } return undefined } /** * React hook used to provide information about the grid cell. * @return The width and height of the grid cell and information about the cell's location and spanning */ export function useGridCell(): UseGridCellValues { const context = useContext(GridCellContext) const {width, height, row, column} = context if (width === undefined || height === undefined || row === undefined || column === undefined) { throw new Error("useGridCell can only be used when the parent is a ") } return context } /** * React hook used to provide information about the grid cell. * @return The width and height of the grid cell and information about the cell's location and spanning */ export function useGridCellHeight(): number { const context = useContext(GridCellContext) const {width, height, row, column} = context if (width === undefined || height === undefined || row === undefined || column === undefined) { throw new Error("useGridCellHeight can only be used when the parent is a ") } return height } /** * React hook used to provide information about the grid cell. * @return The width and height of the grid cell and information about the cell's location and spanning */ export function useGridCellWidth(): number { const context = useContext(GridCellContext) const {width, height, row, column} = context if (width === undefined || height === undefined || row === undefined || column === undefined) { throw new Error("useGridCellWidth can only be used when the parent is a ") } return width }