UNPKG

grid-rows-masonry

Version:

A ponyfill for CSS Grid masonry layout in plain JavaScript and React.

106 lines (105 loc) 4.75 kB
export class Masonry { grid; resizeObserver; mutationObserver; lastUpdateTimestamp = 0; constructor(grid) { this.grid = grid; this.resizeObserver = new ResizeObserver(() => { requestAnimationFrame(this.updateLayout); }); this.mutationObserver = new MutationObserver((records) => { requestAnimationFrame(this.updateLayout); records.forEach((record) => { record.addedNodes.forEach((node) => { this.resizeObserver.observe(node); }); }); }); this.resizeObserver.observe(this.grid); this.getChildren().forEach((child) => { this.resizeObserver.observe(child); }); this.mutationObserver.observe(this.grid, { childList: true, }); } getChildren = () => { return Array.from(this.grid.children).filter((child) => child instanceof HTMLElement); }; clearStyles = () => { this.getChildren().forEach((child) => { child.style.marginTop = ""; child.style.gridColumnStart = ""; }); }; updateLayout = (timestamp) => { if (timestamp === this.lastUpdateTimestamp) { // Skip update if update already ran in this animation frame return; } this.lastUpdateTimestamp = timestamp; this.clearStyles(); const gridStyle = window.getComputedStyle(this.grid); if (gridStyle.display !== "grid") { // display is not grid, so no need for masonry console.warn("Display must be grid for masonry layout to work"); return; } if (gridStyle.gridTemplateRows === "masonry") { // `masonry` is already supported, so no need for the ponyfill console.warn("Masonry layout is already supported natively"); return; } const parentPaddingTop = parseFloat(gridStyle.paddingTop) || 0; const numColumns = gridStyle.gridTemplateColumns.split(" ").length; if (numColumns <= 1) { // no need for masonry if there's only one column return; } const rowGap = parseFloat(gridStyle.rowGap) || 0; const rows = []; const columnHeights = new Array(numColumns).fill(0); this.getChildren() .filter((child) => !!child.offsetParent) .forEach((child, index) => { const rowIndex = Math.floor(index / numColumns); rows[rowIndex] ??= []; rows[rowIndex].push(child); }); rows.forEach((row, rowIndex) => { row.forEach((child) => { // include the row gap for all but the first row const gap = rowIndex > 0 ? rowGap : 0; // find the index of the column with the smallest height const targetColumnIndex = columnHeights.indexOf(Math.min(...columnHeights)); // reorder the columns before setting the margin child.style.gridColumnStart = `${targetColumnIndex + 1}`; const childStyle = window.getComputedStyle(child); const childMarginTop = parseFloat(childStyle.marginTop) || 0; const childMarginBottom = parseFloat(childStyle.marginBottom) || 0; // Calculate the offset top for the child by subtracting the parent top and padding from the child top and margin const { top, height: childHeight } = child.getBoundingClientRect(); const offsetTop = top - childMarginTop - parentPaddingTop - this.grid.getBoundingClientRect().top; // Set the child's top margin to the difference between the current column height and // the child's offset, plus the child's inherit top margin and the row gap. // Round the margin to the nearest pixel to avoid unnecessary layout thrashing // @todo this could be optimized to round to a multiple of the screen's pixel ratio const marginTop = Math.round(columnHeights[targetColumnIndex] - offsetTop + childMarginTop + gap); child.style.marginTop = `${marginTop}px`; // Add the height of this child element, its margin, and the row gap to the column height columnHeights[targetColumnIndex] += childMarginTop + childHeight + childMarginBottom + gap; }); }); }; destroy() { this.clearStyles(); this.resizeObserver.disconnect(); this.mutationObserver.disconnect(); } } export const GridRowsMasonry = Masonry;