Created
February 6, 2025 17:12
-
-
Save liampmccabe/ab40c43cb36284c0cdca05e5bd495978 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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