UNPKG

@appnest/masonry-layout

Version:

An efficient and fast web component that gives you a beautiful masonry layout

280 lines (276 loc) 10.7 kB
import { COL_COUNT_CSS_VAR_NAME, debounce, DEFAULT_COLS, DEFAULT_DEBOUNCE_MS, DEFAULT_GAP_PX, DEFAULT_MAX_COL_WIDTH, ELEMENT_NODE_TYPE, findSmallestColIndex, GAP_CSS_VAR_NAME, getColCount, getNumberAttribute } from "./masonry-helpers"; /** * Template for the masonry layout. * Max width of each column is computed as the width in percentage of * the column minus the total with of the gaps divided between each column. */ const $template = document.createElement("template"); $template.innerHTML = ` <style> :host { display: flex; align-items: flex-start; justify-content: stretch; } .column { max-width: calc((100% / var(${COL_COUNT_CSS_VAR_NAME}, 1) - ((var(${GAP_CSS_VAR_NAME}, ${DEFAULT_GAP_PX}px) * (var(${COL_COUNT_CSS_VAR_NAME}, 1) - 1) / var(${COL_COUNT_CSS_VAR_NAME}, 1))))); width: 100%; flex: 1; display: flex; flex-direction: column; } .column:not(:last-child) { margin-inline-end: var(${GAP_CSS_VAR_NAME}, ${DEFAULT_GAP_PX}px); } .column ::slotted(*) { margin-block-end: var(${GAP_CSS_VAR_NAME}, ${DEFAULT_GAP_PX}px); box-sizing: border-box; width: 100%; } /* Hide the items that has not yet found the correct slot */ #unset-items { opacity: 0; position: absolute; pointer-events: none; } </style> <div id="unset-items"> <slot></slot> </div> `; /** * Masonry layout web component. It places the slotted elements in the optimal position based * on the available vertical space, just like mason fitting stones in a wall. * @example <masonry-layout><div class="item"></div><div class="item"></div></masonry-layout> * @csspart column - Each column of the masonry layout. * @csspart column-index - The specific column at the given index (eg. column-0 would target the first column and so on) * @slot - Items that should be distributed in the layout. */ export class MasonryLayout extends HTMLElement { // The observed attributes. // Whenever one of these changes we need to update the layout. static get observedAttributes() { return ["maxcolwidth", "gap", "cols"]; } /** * The maximum width of each column if cols are set to auto. * @attr maxcolwidth * @param v */ set maxColWidth(v) { this.setAttribute("maxcolwidth", v.toString()); } get maxColWidth() { return getNumberAttribute(this, "maxcolwidth", DEFAULT_MAX_COL_WIDTH); } /** * The amount of columns. * @attr cols * @param v */ set cols(v) { this.setAttribute("cols", v.toString()); } get cols() { return getNumberAttribute(this, "cols", DEFAULT_COLS); } /** * The gap in pixels between the columns. * @attr gap * @param v */ set gap(v) { this.setAttribute("gap", v.toString()); } get gap() { return getNumberAttribute(this, "gap", DEFAULT_GAP_PX); } /** * The ms of debounce when the element resizes. * @attr debounce * @param v */ set debounce(v) { this.setAttribute("debounce", v.toString()); } get debounce() { return getNumberAttribute(this, "debounce", DEFAULT_DEBOUNCE_MS); } /** * The column elements. */ get $columns() { return Array.from(this.shadowRoot.querySelectorAll(`.column`)); } /** * Attach the shadow DOM. */ constructor() { super(); // Unique debounce ID so different masonry layouts on one page won't affect eachother this.debounceId = `layout_${Math.random()}`; // Resize observer that layouts when necessary this.ro = undefined; // The current request animation frame callback this.currentRequestAnimationFrameCallback = undefined; const shadow = this.attachShadow({ mode: "open" }); shadow.appendChild($template.content.cloneNode(true)); this.onSlotChange = this.onSlotChange.bind(this); this.onResize = this.onResize.bind(this); this.layout = this.layout.bind(this); this.$unsetElementsSlot = this.shadowRoot.querySelector("#unset-items > slot"); } /** * Hook up event listeners when added to the DOM. */ connectedCallback() { this.$unsetElementsSlot.addEventListener("slotchange", this.onSlotChange); // Attach resize observer so we can relayout eachtime the size changes if ("ResizeObserver" in window) { this.ro = new ResizeObserver(this.onResize); this.ro.observe(this); } else { window.addEventListener("resize", this.onResize); } } /** * Remove event listeners when removed from the DOM. */ disconnectedCallback() { this.$unsetElementsSlot.removeEventListener("slotchange", this.onSlotChange); window.removeEventListener("resize", this.onResize); if (this.ro != null) { this.ro.unobserve(this); } } /** * Updates the layout when one of the observed attributes changes. */ attributeChangedCallback(name) { switch (name) { case "gap": this.style.setProperty(GAP_CSS_VAR_NAME, `${this.gap}px`); break; } // Recalculate the layout this.scheduleLayout(); } /** * */ onSlotChange() { // Grab unset elements const $unsetElements = (this.$unsetElementsSlot.assignedNodes() || []) .filter(node => node.nodeType === ELEMENT_NODE_TYPE); // If there are more items not yet set layout straight awy to avoid the item being delayed in its render. if ($unsetElements.length > 0) { this.layout(); } } /** * Each time the element resizes we need to schedule a layout * if the amount available columns has has changed. * @param entries */ onResize(entries) { // Grab the width of the element. If it isn't provided by the resize observer entry // we compute it ourselves by looking at the offset width of the element. const { width } = entries != null && Array.isArray(entries) && entries.length > 0 ? entries[0].contentRect : { width: this.offsetWidth }; // Get the amount of columns we should have const colCount = getColCount(width, this.cols, this.maxColWidth); // Compare the amount of columns we should have to the current amount of columns. // Schedule a layout if they are no longer the same. if (colCount !== this.$columns.length) { this.scheduleLayout(); } } /** * Render X amount of columns. * @param colCount */ renderCols(colCount) { // Get the current columns const $columns = this.$columns; // If the amount of columns is correct we don't have to add new columns. if ($columns.length === colCount) { return; } // Remove all of the current columns for (const $column of $columns) { $column.parentNode && $column.parentNode.removeChild($column); } // Add some new columns for (let i = 0; i < colCount; i++) { // Create a column element const $column = document.createElement(`div`); $column.classList.add(`column`); $column.setAttribute(`part`, `column column-${i}`); // Add a slot with the name set to the index of the column const $slot = document.createElement(`slot`); $slot.setAttribute(`name`, i.toString()); // Append the slot to the column an the column to the shadow root. $column.appendChild($slot); this.shadowRoot.appendChild($column); } // Set the column count so we can compute the correct width of the columns this.style.setProperty(COL_COUNT_CSS_VAR_NAME, colCount.toString()); } /** * Schedules a layout. * @param ms */ scheduleLayout(ms = this.debounce) { debounce(this.layout, ms, this.debounceId); } /** * Layouts the elements. */ layout() { // Cancel the current animation frame callback if (this.currentRequestAnimationFrameCallback != null) { window.cancelAnimationFrame(this.currentRequestAnimationFrameCallback); } // Layout in the next animationframe this.currentRequestAnimationFrameCallback = requestAnimationFrame(() => { // console.time("layout"); // Compute relevant values we are going to use for layouting the elements. const gap = this.gap; const $elements = Array.from(this.children) .filter(node => node.nodeType === ELEMENT_NODE_TYPE); const colCount = getColCount(this.offsetWidth, this.cols, this.maxColWidth); // Have an array that keeps track of the highest col height. const colHeights = Array(colCount).fill(0); // Instead of interleaving reads and writes we create an array for all writes so we can batch them at once. const writes = []; // Go through all elements and figure out what column (aka slot) they should be put in. // We only do reads in this for loop and postpone the writes for (const $elem of $elements) { // Read the height of the element const height = $elem.getBoundingClientRect().height; // Find the currently smallest column let smallestColIndex = findSmallestColIndex(colHeights); // Add the height of the item and the gap to the column heights. // It is very important we add the gap since the more elements we have, // the bigger the role the margins play when computing the actual height of the columns. colHeights[smallestColIndex] += height + gap; // Set the slot on the element to get the element to the correct column. // Only do it if the slot has actually changed. const newSlot = smallestColIndex.toString(); if ($elem.slot !== newSlot) { writes.push(() => ($elem.slot = newSlot)); } } // Batch all the writes at once for (const write of writes) { write(); } // Render the columns this.renderCols(colCount); // console.timeEnd("layout"); }); } } customElements.define("masonry-layout", MasonryLayout); //# sourceMappingURL=masonry-layout.js.map