grid-rows-masonry
Version:
A ponyfill for CSS Grid masonry layout in plain JavaScript and React.
106 lines (105 loc) • 4.75 kB
JavaScript
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;