Last active
February 4, 2025 15:31
-
-
Save liampmccabe/81d72b10ff258da541fd6786065ebe81 to your computer and use it in GitHub Desktop.
This file contains hidden or 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 columnWidth = 100 / numColumns; | |
| const expandedIndex = state.expandedId !== null ? Number(state.expandedId) : -1; | |
| // Calculate row height based on the first visible item | |
| const visibleItems = state.items.filter(item => item.style.display !== 'none'); | |
| state.visibleItems = visibleItems; | |
| if (visibleItems.length === 0) return []; | |
| // 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); | |
| const itemHeight = item.offsetHeight; | |
| if (!rowHeights[row] || itemHeight > rowHeights[row]) { | |
| rowHeights[row] = itemHeight; | |
| } | |
| }); | |
| // Calculate cumulative row positions | |
| const rowPositions = {}; | |
| let currentPosition = 0; | |
| Object.keys(rowHeights).sort((a, b) => Number(a) - Number(b)).forEach(row => { | |
| rowPositions[row] = currentPosition; | |
| currentPosition += rowHeights[row] + gap; | |
| }); | |
| // Calculate row positions first | |
| const rowTops = {}; | |
| let currentTop = 0; | |
| Object.keys(rowHeights) | |
| .sort((a, b) => Number(a) - Number(b)) | |
| .forEach(row => { | |
| rowTops[row] = currentTop; | |
| currentTop += rowHeights[row] + gap; | |
| }); | |
| // First pass: Layout all items in their normal positions | |
| visibleItems.forEach((item, index) => { | |
| const actualIndex = Number(item.getAttribute('data-grid-id')); | |
| if (actualIndex === expandedIndex) return; | |
| // Calculate width accounting for gaps | |
| 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, | |
| row: row | |
| }); | |
| }); | |
| // Handle expanded item if it exists | |
| if (expandedIndex !== -1) { | |
| const expandedItem = visibleItems.find(item => | |
| Number(item.getAttribute('data-grid-id')) === expandedIndex | |
| ); | |
| if (expandedItem) { | |
| const expandedRow = Math.floor(expandedIndex / numColumns); | |
| // Calculate where the expanded item should be inserted | |
| const baseTop = rowTops[expandedRow]; | |
| // Create expanded item layout | |
| const expandedItemLayout = { | |
| element: expandedItem, | |
| index: expandedIndex, | |
| top: baseTop + rowHeights[expandedRow] + this.options.expandedGap, | |
| left: 0, | |
| width: 100, | |
| height: this.options.expandedHeight, | |
| isExpanded: true, | |
| row: expandedRow | |
| }; | |
| // Add expanded item to layout | |
| layout.push(expandedItemLayout); | |
| // Calculate where items after the expanded row should start | |
| const expandedBottom = expandedItemLayout.top + expandedItemLayout.height + this.options.expandedGap; | |
| // Calculate new row positions after expanded item | |
| let newTop = expandedBottom; | |
| const affectedRows = Object.keys(rowTops) | |
| .map(Number) | |
| .filter(row => row > expandedRow) | |
| .sort((a, b) => a - b); | |
| // Update row tops for affected rows | |
| affectedRows.forEach(row => { | |
| rowTops[row] = newTop; | |
| newTop += rowHeights[row] + gap; | |
| }); | |
| // Update positions for items in affected rows | |
| layout.forEach(item => { | |
| if (item.row > expandedRow && !item.isExpanded) { | |
| item.top = rowTops[item.row]; | |
| } | |
| }); | |
| } | |
| } | |
| return layout.sort((a, b) => a.index - b.index); | |
| }; | |
| // Public layout method | |
| this.layout = function() { | |
| const layout = calculateLayout.call(this); | |
| if (!layout.length) return this; | |
| const containerHeight = Math.max(...layout.map(item => item.top + item.height)); | |
| this.options.container.style.height = `${containerHeight}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'; | |
| // Enable transitions before changing properties | |
| requestAnimationFrame(() => { | |
| element.style.transition = 'top 0.3s ease-in-out, left 0.3s ease-in-out, width 0.3s ease-in-out, height 0.3s ease-in-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 = 300; | |
| 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)); | |
| // Get and setup items | |
| state.items = Array.from(this.options.container.querySelectorAll(this.options.itemSelector)); | |
| state.visibleItems = state.items; | |
| 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 = '1'; | |
| // 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(); | |
| const wasMobile = state.isMobile; | |
| 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