import React, {
Children,
forwardRef,
HTMLAttributes,
ReactNode,
useCallback,
useRef,
useState,
} from "react";
import cn from "classnames";
import applyRef from "../applyRef";
import bem from "../bem";
import useResizeObserver from "../sizing/useResizeObserver";
import GridListCell from "./GridListCell";
import getScrollbarSize from "./scrollbarSize";
/**
* This is the css variable that is used store the current size of each cell.
*/
export const CELL_SIZE_VAR = "--rmd-cell-size";
/**
* This is the css variable that is used store the current margin of each cell.
*/
export const CELL_MARGIN_VAR = "--rmd-cell-margin";
export interface GridListSize {
/**
* The current number of columns in the `GridList`.
*/
columns: number;
/**
* The current width of each cell within the grid.
*/
cellWidth: number;
}
/**
* The children render function that will be provided the current grid list size
* object and should return renderable elements.
*
* Note: The first time this is called, the `columns` and `cellWidth` will be
* the `defaultSize`. Once the `GridList` has been fully mounted in the DOM, it
* will begin the sizing calculations and update with the "real" values. This
* doesn't cause any problems if you are only rendering client side, but it
* might mess up server-side rendering, so it is recommended to update the
* `defaultSize` when server-side rendering if this can be "known" service-side
* in your app.
*/
export type RenderGridListChildren = (size: GridListSize) => ReactNode;
export interface GridListProps extends HTMLAttributes {
/**
* An optional margin to apply to each cell as the `CELL_MARGIN_VAR` css
* variable only when it is defined. This has to be a number string with a
* `px`, `em`, `rem` or `%` suffix or else the grid will break.
*/
cellMargin?: string;
/**
* The max size that each cell can be.
*/
maxCellSize?: number;
/**
* Since the `GridList` requires being fully rendered in the DOM to be able to
* correctly calculate the number of `columns` and `cellWidth`, this _might_
* cause problems when server-side rendering when using the children renderer
* to create a grid list dynamically based on the number of columns. If the
* number of columns and default `cellWidth` can be guessed server-side, you
* should provide this prop. Otherwise it will be: `{ cellSize; maxCellSize,
* columns: -1 }`
*/
defaultSize?: GridListSize | (() => GridListSize);
/**
* This is _normally_ the amount of padding on the grid list item itself to
* subtract from the `offsetWidth` since `padding`, `border`, and vertical
* scrollbars will be included. If you add a border or change the padding or
* add borders to this component, you'll need to update the `containerPadding`
* to be the new number.
*/
containerPadding?: number;
/**
* Boolean if the current scrollbar width should no longer be subtracted from
* the total width of the grid list. This should only be disabled if your
* `containerPadding` is updated to include scrollbar width as well since
* it'll mess up the grid on OSes that display scrollbars.
*/
disableScrollbarWidth?: boolean;
/**
* Boolean if the resize observer should stop tracking width changes within
* the `GridList`. This should normally stay as `false` since tracking width
* changes will allow for dynamic content being added to the list to not mess
* up the grid calculation when the user is on an OS that shows scrollbars.
*/
disableHeightObserver?: boolean;
/**
* Boolean if the resize observer should stop tracking width changes within
* the `GridList`. This should normally stay as `false` since tracking width
* changes will allow for dynamic content being added to the list to not mess
* up the grid calculation when the user is on an OS that shows scrollbars.
*/
disableWidthObserver?: boolean;
/**
* The children to display within the grid list. This can either be a callback
* function that will provide the current calculated width for each cell that
* should return renderable elements or any renderable elements that are sized
* with the `--rmd-cell-width` css variable.
*/
children: ReactNode | RenderGridListChildren;
/**
* Boolean if the current cell sizing should automatically be cloned into each
* child. This will only work if the `children` is renderable element or a
* list of renderable elements that accept the `style` and `className` props.
*/
clone?: boolean;
/**
* Boolean if each child within the `GridList` should be wrapped with the
* `GridListCell` component. This will only work if the `children` is not a
* `function`.
*/
wrapOnly?: boolean;
}
type CSSProperties = React.CSSProperties & {
[CELL_SIZE_VAR]: string;
[CELL_MARGIN_VAR]?: string;
};
const block = bem("rmd-grid-list");
const isRenderFunction = (
children: GridListProps["children"]
): children is RenderGridListChildren => typeof children === "function";
/**
* The `GridList` component is a different way to render a list of data where
* the number of columns is dynamic and based on the max-width for each cell.
* Instead of setting a percentage width to each cell based on the number of
* columns, this will dynamically add columns to fill up the remaining space and
* have each cell grow up to a set max-width. A really good use-case for this is
* displaying a list of images or thumbnails and allowing the user to see a full
* screen preview once selected/clicked.
*/
const GridList = forwardRef(function GridList(
{
style,
className,
children,
clone = false,
wrapOnly = false,
cellMargin,
defaultSize,
maxCellSize = 150,
containerPadding = 16,
disableHeightObserver = false,
disableWidthObserver = false,
...props
},
forwardedRef
) {
const [gridSize, setGridSize] = useState(
defaultSize || { columns: -1, cellWidth: maxCellSize }
);
const ref = useRef(null);
const recalculate = useCallback(() => {
if (!ref.current) {
return;
}
// need to use rect instead of offsetWidth since we need decimal precision
// for the width since offsetWidth is basically Math.ceil(width). the
// calculations for max columns will be off on high-pixel-density monitors
// or some zoom levels.
let { width } = ref.current.getBoundingClientRect();
width -= containerPadding;
// just need to see if there is a scrollbar visible and subtract that width.
// don't need decimal precision here since both values will be rounded
if (ref.current.offsetHeight < ref.current.scrollHeight) {
width -= getScrollbarSize("width");
}
const columns = Math.ceil(width / maxCellSize);
setGridSize({ cellWidth: width / columns, columns });
}, [maxCellSize, containerPadding]);
const refHandler = useCallback(
(instance: HTMLDivElement | null) => {
applyRef(instance, forwardedRef);
ref.current = instance;
if (instance) {
recalculate();
}
},
[forwardedRef, recalculate]
);
useResizeObserver({
disableHeight: disableHeightObserver,
disableWidth: disableWidthObserver,
onResize: recalculate,
target: ref,
});
const mergedStyle: CSSProperties = {
...style,
[CELL_SIZE_VAR]: `${gridSize.cellWidth}px`,
[CELL_MARGIN_VAR]: cellMargin || undefined,
};
let content: ReactNode = null;
if (isRenderFunction(children)) {
content = children(gridSize);
} else if (clone || wrapOnly) {
content = Children.map(
children,
(child) => child && {child}
);
} else {
content = children;
}
return (
{content}
);
});
if (process.env.NODE_ENV !== "production") {
try {
const PropTypes = require("prop-types");
GridList.propTypes = {
style: PropTypes.object,
clone: PropTypes.bool,
wrapOnly: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
cellMargin: PropTypes.string,
maxCellSize: PropTypes.number,
defaultSize: PropTypes.oneOfType([
PropTypes.shape({
columns: PropTypes.number.isRequired,
cellWidth: PropTypes.number.isRequired,
}),
PropTypes.func,
]),
containerPadding: PropTypes.number,
disableHeightObserver: PropTypes.bool,
disableWidthObserver: PropTypes.bool,
};
} catch (e) {}
}
export default GridList;