Skip to content

Instantly share code, notes, and snippets.

@liampmccabe
Created February 6, 2025 17:12
Show Gist options
  • Save liampmccabe/ab40c43cb36284c0cdca05e5bd495978 to your computer and use it in GitHub Desktop.
Save liampmccabe/ab40c43cb36284c0cdca05e5bd495978 to your computer and use it in GitHub Desktop.
function MasonryGrid(userOptions = {}) {
if (!(this instanceof MasonryGrid)) {
throw new Error('MasonryGrid must be called with new');
}
// Private state using WeakMap to maintain encapsulation
const privateState = new WeakMap();
privateState.set(this, {
expandedId: null,
isMobile: window.innerWidth < 768,
items: [],
visibleItems: [],
resizeObserver: null,
debounceTimeout: null,
categoryMap: null
});
// Webflow breakpoint widths
const BREAKPOINT_WIDTHS = {
mobilePortrait: 478, // < 479px
mobileLandscape: 767, // < 768px
tablet: 991, // < 992px
desktop: 1279, // < 1280px
desktopWide: Infinity // >= 1280px
};
// Options
this.options = {
container: null,
itemSelector: '.grid-item',
gap: 16,
expandedGap: 32,
expandedHeight: 480,
breakpoints: {
mobilePortrait: 1,
mobileLandscape: 1,
tablet: 2,
desktop: 2,
desktopWide: 2
},
categoryMap: {},
activeFilters: [],
activeClass: 'is-expanded',
clickToToggle: true,
onItemClick: null,
...userOptions
};
// Get private state helper
const getState = () => privateState.get(this);
// Debounce helper
const debounce = (func, wait) => {
const state = getState();
return (...args) => {
clearTimeout(state.debounceTimeout);
state.debounceTimeout = setTimeout(() => func.apply(this, args), wait);
};
};
// Get current column count based on breakpoints
const getColumnCount = () => {
const viewportWidth = window.innerWidth;
const { breakpoints } = this.options;
if (viewportWidth <= BREAKPOINT_WIDTHS.mobilePortrait) return breakpoints.mobilePortrait;
if (viewportWidth <= BREAKPOINT_WIDTHS.mobileLandscape) return breakpoints.mobileLandscape;
if (viewportWidth <= BREAKPOINT_WIDTHS.tablet) return breakpoints.tablet;
if (viewportWidth <= BREAKPOINT_WIDTHS.desktop) return breakpoints.desktop;
return breakpoints.desktopWide;
};
// Layout calculation
const calculateLayout = () => {
const state = getState();
const numColumns = getColumnCount();
const { container, gap } = this.options;
const layout = [];
const expandedIndex = state.expandedId !== null ? Number(state.expandedId) : -1;
// Get visible items
const visibleItems = state.items.filter(item => item.style.display !== 'none');
state.visibleItems = visibleItems;
if (visibleItems.length === 0) return [];
if (numColumns === 1) {
// Single column layout - simple stacking
let currentTop = 0;
visibleItems.forEach((item, index) => {
const actualIndex = Number(item.getAttribute('data-grid-id'));
// Force a reflow to get the actual height
// item.style.position = 'static';
const itemHeight = item.offsetHeight;
// item.style.position = '';
// If this is the expanded item, get its expanded height
if (actualIndex === expandedIndex) {
item.style.width = container.offsetWidth + 'px';
const expandedHeight = Array.from(item.children)
.reduce((total, child) => total + child.offsetHeight, 0);
const mobileExpandedGap = this.options.expandedGap / 2;
// Add gap before expanded item
currentTop += mobileExpandedGap;
layout.push({
element: item,
index: actualIndex,
top: currentTop,
left: 0,
width: 100,
height: expandedHeight,
isExpanded: true
});
// Add expanded height plus gaps
currentTop += expandedHeight + mobileExpandedGap + gap;
} else {
layout.push({
element: item,
index: actualIndex,
top: currentTop,
left: 0,
width: 100,
height: itemHeight,
isExpanded: false
});
currentTop += itemHeight + gap;
}
});
} else {
// Multi-column layout - calculate row heights first
const rowHeights = {};
visibleItems.forEach((item, index) => {
const actualIndex = Number(item.getAttribute('data-grid-id'));
if (actualIndex === expandedIndex) return;
const row = Math.floor(index / numColumns);
// item.style.position = 'static';
const itemHeight = item.offsetHeight;
// console.log('itemHeight', index, itemHeight)
// item.style.position = '';
if (!rowHeights[row] || itemHeight > rowHeights[row]) {
rowHeights[row] = itemHeight;
}
});
// console.log(rowHeights)
// Calculate row positions
const rowTops = {};
let currentTop = 0;
Object.keys(rowHeights)
.sort((a, b) => Number(a) - Number(b))
.forEach(row => {
rowTops[row] = currentTop;
currentTop += rowHeights[row] + gap;
});
// console.log(rowHeights)
// console.log(rowTops)
// Layout items in grid
visibleItems.forEach((item, index) => {
const actualIndex = Number(item.getAttribute('data-grid-id'));
if (actualIndex === expandedIndex) return;
const totalGapWidth = gap * (numColumns - 1);
const adjustedColumnWidth = (100 - (totalGapWidth / container.offsetWidth * 100)) / numColumns;
const row = Math.floor(index / numColumns);
const col = index % numColumns;
layout.push({
element: item,
index: actualIndex,
top: rowTops[row],
left: col * (adjustedColumnWidth + (gap / container.offsetWidth * 100)),
width: adjustedColumnWidth,
height: rowHeights[row],
isExpanded: false
});
});
// Handle expanded item for multi-column
if (expandedIndex !== -1) {
const expandedItem = visibleItems.find(item =>
Number(item.getAttribute('data-grid-id')) === expandedIndex
);
if (expandedItem) {
const expandedRow = Math.floor(expandedIndex / numColumns);
const baseTop = rowTops[expandedRow];
expandedItem.style.width = container.offsetWidth + 'px';
const expandedHeight = Array.from(expandedItem.children)
.reduce((total, child) => total + child.offsetHeight, 0);
// First, adjust the expanded item's position to include gap above
const expandedItemLayout = {
element: expandedItem,
index: expandedIndex,
top: baseTop + rowHeights[expandedRow] + this.options.expandedGap,
left: 0,
width: 100,
height: expandedHeight,
isExpanded: true
};
layout.push(expandedItemLayout);
// Calculate the space needed for the expanded item including gaps
const expandedTotalHeight = expandedHeight + (this.options.expandedGap * 2); // Gap above and below
const expandedBottom = expandedItemLayout.top + expandedHeight + this.options.expandedGap;
// Adjust positions of items after the expanded item
const itemsAfter = layout.filter(item =>
!item.isExpanded && Math.floor(item.index / numColumns) > expandedRow
);
itemsAfter.forEach(item => {
item.top += expandedTotalHeight;
});
}
}
}
return layout.sort((a, b) => a.index - b.index);
};
// Public layout method
this.layout = function() {
// Wait for all images to load before calculating layout
return waitForImages(state.visibleItems).then(() => {
let layout = calculateLayout.call(this);
if (!layout.length) return this;
// Show items once layout is calculated
// state.visibleItems.forEach(item => {
// item.style.opacity = '1';
// });
const numColumns = getColumnCount();
// Find the bottom-most point of any item
let maxBottom = Math.max(...layout.map(item => item.top + item.height));
// Add expanded gap if the bottom-most item is expanded
const bottomMostItem = layout.find(item => item.top + item.height === maxBottom);
if (bottomMostItem && bottomMostItem.isExpanded) {
maxBottom += numColumns === 1 ? this.options.expandedGap / 2 : this.options.expandedGap;
}
this.options.container.style.height = `${maxBottom}px`;
// Update positions with transitions
layout.forEach(item => {
const element = item.element;
// Set initial position and state
element.style.position = 'absolute';
element.style.zIndex = item.isExpanded ? '10' : '1';
const totalGapWidth = this.options.gap * (numColumns - 1);
const adjustedColumnWidth = (100 - (totalGapWidth / this.options.container.offsetWidth * 100)) / numColumns;
element.style.width = `${adjustedColumnWidth}%`;
// Enable transitions before changing properties
requestAnimationFrame(() => {
element.style.transition = 'top 0.3s ease-out, left 0.3s ease-out, width 0.2s ease-out, height 0.1s ease-out';
element.style.top = `${item.top}px`;
element.style.left = `${item.left}%`;
element.style.width = item.isExpanded ? '100%' : `${item.width}%`;
element.style.height = item.isExpanded ? `${item.height}px` : 'auto';
if (item.isExpanded) {
element.classList.add(this.options.activeClass);
} else {
element.classList.remove(this.options.activeClass);
}
});
});
return this;
});
};
// Close expanded item
this.close = function() {
const state = getState();
if (state.expandedId !== null) {
const expandedItem = state.items[state.expandedId];
expandedItem.classList.remove(this.options.activeClass);
state.expandedId = null;
this.layout();
}
return this;
};
// Toggle item expansion
this.toggleExpand = function(index) {
const state = getState();
const TRANSITION_DURATION = 150;
if (state.expandedId === index) {
// Closing expanded item
this.close();
} else {
// Expanding new item
const previousExpandedId = state.expandedId;
state.expandedId = index;
// First, collapse any previously expanded item
if (previousExpandedId !== null) {
const previousItem = state.items[previousExpandedId];
previousItem.classList.remove(this.options.activeClass);
}
const expandedItem = state.items[index];
// Get expanded height before adding class
const expandedHeight = expandedItem.scrollHeight;
this.options.expandedHeight = expandedHeight;
// Add expanded class and immediately calculate layout
expandedItem.classList.add(this.options.activeClass);
this.layout();
// After transition, ensure proper scroll position
setTimeout(() => {
// Calculate scroll position
const itemRect = expandedItem.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
// Calculate target scroll position (center item in viewport)
let targetScroll = scrollTop + itemRect.top - (windowHeight - itemRect.height) / 2;
// Ensure we don't scroll past the top of the page
targetScroll = Math.max(0, targetScroll);
// Smooth scroll to expanded item
window.scrollTo({
top: targetScroll,
behavior: 'smooth'
});
}, TRANSITION_DURATION);
}
return this;
};
// Navigate to previous item (with loop)
this.previous = function() {
const state = getState();
const visibleIndices = state.visibleItems.map(item =>
parseInt(item.getAttribute('data-grid-id'))
);
if (!visibleIndices.length) return this;
if (state.expandedId === null) {
// If nothing is expanded, start from the last item
this.toggleExpand(visibleIndices[visibleIndices.length - 1]);
} else {
const currentIndex = visibleIndices.indexOf(state.expandedId);
// Loop to the end if at the beginning
const prevIndex = currentIndex <= 0
? visibleIndices[visibleIndices.length - 1]
: visibleIndices[currentIndex - 1];
this.toggleExpand(prevIndex);
}
return this;
};
// Navigate to next item (with loop)
this.next = function() {
const state = getState();
const visibleIndices = state.visibleItems.map(item =>
parseInt(item.getAttribute('data-grid-id'))
);
if (!visibleIndices.length) return this;
if (state.expandedId === null) {
// If nothing is expanded, start from the first item
this.toggleExpand(visibleIndices[0]);
} else {
const currentIndex = visibleIndices.indexOf(state.expandedId);
// Loop to the beginning if at the end
const nextIndex = currentIndex >= visibleIndices.length - 1
? visibleIndices[0]
: visibleIndices[currentIndex + 1];
this.toggleExpand(nextIndex);
}
return this;
};
// Filter functionality
this.filter = function(categories) {
const state = getState();
const FADE_DURATION = 300; // Match with transition duration
// Convert single category to array
const categoryArray = Array.isArray(categories) ? categories : [categories];
// Track which items need to change visibility
const itemStates = state.items.map(item => {
const projectId = item.getAttribute('data-project-id');
const projectData = this.options.categoryMap.items.find(p => p.id === projectId);
const wasVisible = item.style.opacity !== '0';
const shouldShow = categoryArray.length === 0 ||
categoryArray.includes('all') ||
projectData?.categories.some(cat => categoryArray.includes(cat)) ||
false;
return { item, wasVisible, shouldShow };
});
// First, start fade out animations for items that need to be hidden
itemStates.forEach(({ item, wasVisible, shouldShow }) => {
if (wasVisible && !shouldShow) {
item.style.opacity = '0';
item.style.visibility = 'hidden';
}
});
// After fade out completes, update display property and start fade in animations
setTimeout(() => {
itemStates.forEach(({ item, wasVisible, shouldShow }) => {
if (shouldShow) {
item.style.display = '';
item.style.visibility = 'visible';
// Small delay to ensure display is processed
setTimeout(() => {
item.style.opacity = '1';
}, 20);
} else if (!shouldShow) {
item.style.display = 'none';
}
});
// Update visible items array
state.visibleItems = state.items.filter((_, i) => itemStates[i].shouldShow);
// Close expanded item when filtering
if (state.expandedId !== null) {
const expandedItem = state.items[state.expandedId];
expandedItem.classList.remove(this.options.activeClass);
state.expandedId = null;
}
// Recalculate layout
this.layout();
}, FADE_DURATION);
return this;
};
// Get unique categories from the mapping
this.getCategories = function() {
return this.options.categoryMap.categories || ['all'];
};
// Check if navigation is possible
this.canNavigate = function() {
const state = getState();
if (state.expandedId === null) return { prev: false, next: false };
const visibleIndices = state.visibleItems.map(item =>
parseInt(item.getAttribute('data-grid-id'))
);
const currentIndex = visibleIndices.indexOf(state.expandedId);
return {
prev: currentIndex > 0,
next: currentIndex < visibleIndices.length - 1
};
};
// Get current state
this.getCurrentState = function() {
return getState();
};
// Initialize
if (!this.options.container) {
throw new Error('Container element is required');
}
// Set up container
// this.options.container.style.position = 'relative';
const state = getState();
// Initialize ResizeObserver
state.resizeObserver = new ResizeObserver(debounce(() => {
this.layout();
}, 16));
// Helper function to wait for images to load
const waitForImages = (items) => {
const promises = [];
items.forEach(item => {
const images = item.getElementsByTagName('img');
Array.from(images).forEach(img => {
if (!img.complete) {
const promise = new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve; // Handle errors too
});
promises.push(promise);
}
});
});
return Promise.all(promises);
};
// Get and setup items
state.items = Array.from(this.options.container.querySelectorAll(this.options.itemSelector));
state.visibleItems = state.items;
// Set up items and wait for images
state.items.forEach((item, index) => {
item.setAttribute('data-grid-id', index);
// item.style.position = 'absolute';
// item.style.transition = 'all 0.3s ease, opacity 0.3s ease';
// item.style.opacity = '0'; // Start hidden
// Observe each item for size changes
state.resizeObserver.observe(item);
// Add click handler for both toggle and custom click behavior
item.addEventListener('click', () => {
if (this.options.clickToToggle) {
this.toggleExpand(index);
} else if (this.options.onItemClick) {
this.options.onItemClick(index);
}
});
});
// Initialize layout
this.layout();
// Handle window resize
window.addEventListener('resize', debounce(() => {
const state = getState();
state.isMobile = window.innerWidth < 768;
// Always recalculate layout on resize
this.layout();
}, 16));
// Destroy method
this.destroy = () => {
const state = getState();
// Clean up ResizeObserver
if (state.resizeObserver) {
state.items.forEach(item => {
state.resizeObserver.unobserve(item);
});
state.resizeObserver.disconnect();
}
// Clean up event listeners and styles
window.removeEventListener('resize', this.layout);
state.items.forEach(item => {
item.style = '';
item.removeAttribute('data-grid-id');
item.removeEventListener('click', () => {
if (this.options.clickToToggle) {
this.toggleExpand(index);
} else if (this.options.onItemClick) {
this.options.onItemClick(index);
}
});
});
return this;
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment