Last active
February 4, 2025 14:52
-
-
Save liampmccabe/96289146dd2c722f673d0dd5c7e8ffee 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: {}, // New option for category mapping | |
| 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 layout = []; | |
| const numColumns = getColumnCount(); | |
| const columnWidth = 100 / numColumns; | |
| const columnHeights = Array(numColumns).fill(0); | |
| let expandedIndex = -1; | |
| if (state.expandedId !== null) { | |
| expandedIndex = Number(state.expandedId); | |
| } | |
| // Only layout visible items | |
| const visibleItems = state.items.filter(item => item.style.display !== 'none'); | |
| state.visibleItems = visibleItems; | |
| // First pass: Layout items up to expanded item | |
| visibleItems.forEach((item, index) => { | |
| const actualIndex = Number(item.getAttribute('data-grid-id')); | |
| if (actualIndex === expandedIndex) return; | |
| if (expandedIndex !== -1 && index > expandedIndex) return; | |
| // Get the actual height of the item | |
| const itemHeight = item.offsetHeight; | |
| const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights)); | |
| const leftPosition = shortestColumn * columnWidth; | |
| layout.push({ | |
| element: item, | |
| index: actualIndex, | |
| top: columnHeights[shortestColumn], | |
| left: leftPosition, | |
| isExpanded: false, | |
| width: columnWidth, | |
| height: itemHeight | |
| }); | |
| columnHeights[shortestColumn] += itemHeight + this.options.gap; | |
| }); | |
| // Insert expanded item if exists and is visible | |
| if (expandedIndex !== -1 && state.items[expandedIndex].style.display !== 'none') { | |
| const expandedItem = state.items[expandedIndex]; | |
| const baseHeight = Math.max(...columnHeights); | |
| // Use actual height of expanded item if available | |
| const expandedHeight = expandedItem.classList.contains(this.options.activeClass) | |
| ? expandedItem.scrollHeight | |
| : this.options.expandedHeight; | |
| layout.push({ | |
| element: expandedItem, | |
| index: expandedIndex, | |
| top: baseHeight + this.options.expandedGap, | |
| left: 0, | |
| isExpanded: true, | |
| width: 100, | |
| height: expandedHeight | |
| }); | |
| // Update all column heights to account for expanded item | |
| const totalExpandedHeight = baseHeight + expandedHeight + (this.options.expandedGap * 2); | |
| columnHeights.fill(totalExpandedHeight); | |
| // Second pass: Layout remaining visible items | |
| const remainingItems = visibleItems.filter(item => | |
| Number(item.getAttribute('data-grid-id')) > expandedIndex | |
| ); | |
| remainingItems.forEach(item => { | |
| const actualIndex = Number(item.getAttribute('data-grid-id')); | |
| const itemHeight = item.offsetHeight; | |
| const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights)); | |
| const leftPosition = shortestColumn * columnWidth; | |
| layout.push({ | |
| element: item, | |
| index: actualIndex, | |
| top: columnHeights[shortestColumn], | |
| left: leftPosition, | |
| isExpanded: false, | |
| width: columnWidth, | |
| height: itemHeight | |
| }); | |
| columnHeights[shortestColumn] += itemHeight + this.options.gap; | |
| }); | |
| } | |
| return layout.sort((a, b) => a.index - b.index); | |
| }; | |
| // Public layout method | |
| this.layout = function() { | |
| const layout = calculateLayout.call(this); | |
| const containerHeight = Math.max(...layout.map(item => item.top + item.height), 0); | |
| this.options.container.style.height = `${containerHeight}px`; | |
| // Update item positions and states | |
| layout.forEach(item => { | |
| const element = item.element; | |
| element.style.top = `${item.top}px`; | |
| element.style.left = `calc(${item.left}% + ${item.left > 0 ? this.options.gap/2 : 0}px)`; | |
| element.style.width = item.isExpanded ? '100%' : `calc(${item.width}% - ${this.options.gap/2}px)`; | |
| element.style.zIndex = item.isExpanded ? '10' : '1'; | |
| 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