文件操作 - frontend.js
返回文件管理
返回主菜单
删除本文件
文件: /storage/v12552/stanleysteamer/public_html/wp-content/themes/bricks/assets/js/frontend.js
编辑文件内容
/** * Scroll into view via IntersectionObserver * * Fallback for IE9+ included. */ class BricksIntersect { constructor(options = {}) { let element = options.element || false let callback = options.callback || false let runOnce = options.hasOwnProperty('once') ? options.once : true let trigger = options.hasOwnProperty('trigger') ? options.trigger : false // Create Intersection Observer if ('IntersectionObserver' in window) { let enableLeaveViewTrigger = false let observerInstance = new IntersectionObserver( (entries, observer) => { entries.forEach((entry) => { // Check if element is intersecting based on trigger type let bricksIsIntersecting = trigger === 'leaveView' ? !entry.isIntersecting && enableLeaveViewTrigger : entry.isIntersecting if (trigger === 'leaveView' && entry.isIntersecting) { // Trigger is 'leaveView' & element is intersecting: Enable the trigger (@since 1.10) // This is to prevent the callback from running when the element is not visible on page load enableLeaveViewTrigger = true } if (bricksIsIntersecting) { // Run callback function if (element && callback) { callback(entry.target) } // Run only once: Stop observing element if (runOnce) { observer.unobserve(entry.target) } } }) }, { threshold: options.threshold || 0, root: options.root || null, rootMargin: options?.rootMargin || '0px' } ) // Start observer if (element instanceof Element) { observerInstance.observe(element) } } // Fallback: Internet Explorer 9+ else { let active = false let ieIntersectObserver = () => { if (active === false) { active = true if ( element.getBoundingClientRect().top <= window.innerHeight && element.getBoundingClientRect().bottom >= 0 && window.getComputedStyle(element).display !== 'none' ) { // Run callback function if (element && callback) { callback(element) } } active = false } } // Init IE intersect observer fallback function ieIntersectObserver() document.addEventListener('scroll', ieIntersectObserver) window.addEventListener('resize', ieIntersectObserver) window.addEventListener('orientationchange', ieIntersectObserver) } } } /** * Check if element is in the viewport * * @since 1.5 * * @param {Element} element * @returns {boolean} */ function BricksIsInViewport(element) { const rect = element.getBoundingClientRect() return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ) } /** * Convert foundNodeList to array (as IE does not support forEach loop on NodeList) * * @param {Element} parentNode Node to search within. * @param {array, string} selector CSS selector(s) to search for. * * @returns {array} */ function bricksQuerySelectorAll(parentNode, selector) { // Multiple selectors if (Array.isArray(selector)) { let nodes = [] selector.forEach((sel) => { nodes = nodes.concat(Array.prototype.slice.apply(parentNode.querySelectorAll(sel))) }) return nodes } // One selector (string) return Array.prototype.slice.apply(parentNode.querySelectorAll(selector)) } /** * Bricks Utilities functions * * @since 1.8 */ const bricksUtils = { /** * Subscribe to multiple events * @param {*} object Example: document, window, element * @param {*} eventNames Array of event names * @param {*} callback */ subscribeEvents: (object, eventNames, callback) => { eventNames.forEach((eventName) => { object.addEventListener(eventName, (event) => { callback(event) }) }) }, /** * Subscribe to multiple jQuery events * * @param {*} object * @param {*} eventNames * @param {*} callback * * @since 1.9.2 */ subscribejQueryEvents: (object, eventNames, callback) => { if (typeof jQuery === 'undefined') { return } eventNames.forEach((eventName) => { jQuery(object).on(eventName, (event) => { callback(event) }) }) }, /** * Check if interaction should run bricksInteractionCallbackExecution based on runOnce setting * * Only use this if listening to an event that cannot simply use once: true as requires some checking * * (e.g, 'bricks/ajax/start', 'bricks/form/error') * * @since 1.9.2 */ maybeRunOnceInteractions: (sourceEl, interaction) => { // Get interaction from window.bricksData.interactions let interactionIndex = window.bricksData.interactions.findIndex((interactionPool) => { return interactionPool === interaction }) // If interactionIndex is not found, return if (interactionIndex === -1) { return } // Remove interaction from window.bricksData.interactions after running the callback if (interaction?.runOnce) { window.bricksData.interactions.splice(interactionIndex, 1) } // STEP: Execute callback bricksInteractionCallbackExecution(sourceEl, interaction) }, /** * Resolve the selector ID used to find query trail nodes in the DOM. * * @param {string} queryId Query element ID * @param {string|boolean} componentId Component root ID * @returns {string|boolean} * * @since 2.3.7 (#86c9z8rwk) */ getQueryTrailSelectorId: (queryId, componentId = false) => { if (componentId) { return componentId } return queryId && queryId.includes('-') ? queryId.split('-')[0] : queryId }, /** * Conditionally hide or show load more buttons based on queryLoopInstance status * * @param {*} queryId || 'all' * @since 1.9.5 */ hideOrShowLoadMoreButtons: (queryId) => { // Get queryLoopInstance from window.bricksData.queryLoopInstances const queryLoopInstance = window.bricksData.queryLoopInstances[queryId] || false // Exit if no queryLoopInstance or queryId is 'all' if (!queryLoopInstance && queryId !== 'all') { return } if (queryId === 'all') { // Loop through all queryLoopInstances for (const queryId in window.bricksData.queryLoopInstances) { if (window.bricksData.queryLoopInstances.hasOwnProperty(queryId)) { bricksUtils.hideOrShowLoadMoreButtons(queryId) // Recursive } } } // Get all interactions where action is 'loadMore' and loadMoreQuery is the same as queryId const loadMoreInteractions = window.bricksData.interactions.filter((interaction) => { return interaction.action === 'loadMore' && interaction.loadMoreQuery === queryId }) // Loop through all loadMoreInteractions if (loadMoreInteractions.length) { let shouldShow = parseInt(queryLoopInstance.page) < parseInt(queryLoopInstance.maxPages) loadMoreInteractions.forEach((interaction) => { if (shouldShow) { interaction.el.classList.remove('brx-load-more-hidden') } else { interaction.el.classList.add('brx-load-more-hidden') } }) } }, updateIsotopeInstance: (elementId) => { // Find the isotope instance from window.bricksData.isotopeInstances const isotopeInstance = window.bricksData.isotopeInstances[elementId] || false // Exit if no isotopeInstance if (!isotopeInstance) { return } /** * Enhancement for masonry-element, with smoother animation * * @since 1.11.1 */ if (isotopeInstance.elementName === 'masonry-element') { // Some items already removed and not exist in the DOM, must remove them from the isotope instance let removeItems = isotopeInstance.instance.items.filter((item) => !item.element.isConnected) if (removeItems.length > 0) { isotopeInstance.instance.remove(removeItems) } // Maybe triggered by Bricks Lazy Load (@since 1.12) isotopeInstance.instance.layout() // Add new items to the isotope instance let masonryItems = isotopeInstance.instance.element.querySelectorAll( ':scope > *:not(.bricks-isotope-sizer):not(.bricks-gutter-sizer)' ) if (masonryItems.length > 0) { // Check if the new items are already in the isotope instance masonryItems.forEach((newItem) => { // Append new items to the isotope instance if not exists if (!isotopeInstance.instance.items.find((item) => item.element === newItem)) { isotopeInstance.instance.appended(newItem) } }) } // Handle images loaded in the new items (@since 1.12) bricksUtils.updateIsotopeOnImageLoad(elementId) return } /** * Update the isotope elements, maybe newly added via AJAX or deleted * 1. Reload all items * 2. Recalculate and update the layout (@since 1.10) * 3. Arrange items according to current sorting/filtering * 4. Update isotope on image load (#86c44c5a0; @since 2.0) */ isotopeInstance.instance?.reloadItems() isotopeInstance.instance?.layout() isotopeInstance.instance?.arrange() bricksUtils.updateIsotopeOnImageLoad(elementId) }, /** * Helper function to trigger Isotope layout update based on image load progress * * @since 1.12 */ updateIsotopeOnImageLoad: (elementId) => { if (!document.body.classList.contains('bricks-is-frontend')) { return } // Find the isotope instance from window.bricksData.isotopeInstances const isotopeInstance = window.bricksData.isotopeInstances[elementId] || false // Exit if no isotopeInstance if (!isotopeInstance) { return } /** * Handle native browser loading="lazy" attribute * * @since 2.0: Only track images that are not loaded yet. * Otherwise the more images added to the instance.element, * the bigger the trigger and visitor might see many unstyle images (Infinite Scroll). */ const unloadedImages = Array.from( isotopeInstance.instance.element.querySelectorAll('img') ).filter((img) => { return !img.complete || img.naturalHeight === 0 }) if (unloadedImages.length === 0) { // Trigger once if no images to load isotopeInstance.instance.layout() return } let loadedCount = 0 let layoutScheduled = false /** * Schedule layout update using requestAnimationFrame * Without requestAnimationFrame, multiple images loading simultaneously could trigger excessive layout calculations, causing janky animations and poor performance. * @since 2.0 */ const scheduleLayout = () => { if (!layoutScheduled) { layoutScheduled = true requestAnimationFrame(() => { isotopeInstance.instance.layout() layoutScheduled = false }) } } /** * Calculate N images to wait for before forcing an update * To prevents layout instability while balancing performance vs responsiveness. * * Formula breakdown: * - Divide total unloaded images by 5 (aiming for ~20% intervals) * - Round up to ensure we get a whole number (Math.ceil) * - Ensure minimum of 1 image (never wait for 0 images) * - Cap maximum at 3 images (prevent waiting too long for large galleries) * * Examples: * - 2 images → forceUpdateEvery = 1 (50% intervals) * - 10 images → forceUpdateEvery = 2 (20% intervals) * - > 10 images → forceUpdateEvery = 3 (12% intervals, capped) * */ const forceUpdateEvery = Math.min(3, Math.max(1, Math.ceil(unloadedImages.length / 5))) const maxWaitTime = 500 // 500ms let lastForceUpdate = Date.now() unloadedImages.forEach((img) => { // Force update every N images, or every 500ms, or on last image load const handleImageLoad = () => { loadedCount++ const now = Date.now() // Force update conditions const shouldForceUpdate = loadedCount % forceUpdateEvery === 0 || // Every N images now - lastForceUpdate > maxWaitTime || // Or every 500ms loadedCount === unloadedImages.length // Or last image if (shouldForceUpdate) { // Immediate update isotopeInstance.instance.layout() lastForceUpdate = now } else { // Batched update scheduleLayout() } // Cleanup event listeners img.removeEventListener('load', handleImageLoad) img.removeEventListener('error', handleImageLoad) } if (img.complete && img.naturalHeight !== 0) { // This still needed even if unloadedImages is filtered, because the image might be loaded before reaching this point) loadedCount++ scheduleLayout() } else { img.addEventListener('load', handleImageLoad) img.addEventListener('error', handleImageLoad) } }) }, /** * Update offcanvas toggle aria-label based on state * * @since 2.3.6 */ setOffcanvasToggleAriaLabel: (toggle, isExpanded) => { if (!toggle) { return } const openLabel = toggle.dataset?.ariaLabelOpen || toggle.getAttribute('aria-label') const closeLabel = toggle.dataset?.ariaLabelClose || window.bricksData?.i18n?.close if (!openLabel || !closeLabel) { return } if (!toggle.dataset.ariaLabelOpen) { toggle.dataset.ariaLabelOpen = openLabel } toggle.setAttribute('aria-label', isExpanded ? closeLabel : openLabel) }, /** * Helper function for toggle element and toggle offcanvas interaction * * @since 1.11 */ toggleAction: (toggle, customOptions = {}) => { // Toggle selector, attribute, and value let toggleSelector = toggle.dataset?.selector || '.brxe-offcanvas' let toggleAttribute = toggle.dataset?.attribute || 'class' let toggleValue = toggle.dataset?.value || 'brx-open' if (customOptions) { toggleSelector = customOptions?.selector || toggleSelector toggleAttribute = customOptions?.attribute || toggleAttribute toggleValue = customOptions?.value || toggleValue } let toggleElement = toggleSelector ? document.querySelector(toggleSelector) : false // Element: nav-nested if (!toggleElement) { toggleElement = toggle.closest('.brxe-nav-nested') } // Element: offcanvas if (!toggleElement) { toggleElement = toggle.closest('.brxe-offcanvas') } if (!toggleElement) { return } // Re-calculcate mega menu position & width to prevent scrollbars if (document.querySelector('.brx-has-megamenu')) { // If not close toggle inside offcanvas with offset effect // Changed from event.target to the element node, should have same result (@since 1.11) if (!toggle.closest('[data-effect="offset"]')) { bricksSubmenuPosition(0) } } // STEP: Check if the target element is expanded (@since 1.11) let expanded = false if (toggleAttribute === 'class') { expanded = toggleElement.classList.contains(toggleValue) } else { expanded = toggleElement.getAttribute(toggleAttribute) === toggleValue } // STEP: Toggle 'aria-expanded' & .is-active on the toggle toggle.setAttribute('aria-expanded', !expanded) if (expanded) { // Closing now toggle.classList.remove('is-active') } else { // Opening now toggle.classList.add('is-active') // STEP: Set data-toggle-script-id as selector to focus back to toggle when closing via ESC key // toggleScriptId should follow the last triggered id (@since 1.11) if (toggle.dataset?.scriptId || toggle.dataset?.interactionId) { toggleElement.dataset.toggleScriptId = toggle.dataset?.scriptId || toggle.dataset?.interactionId } } if (toggleElement.classList.contains('brxe-offcanvas')) { bricksUtils.setOffcanvasToggleAriaLabel(toggle, !expanded) } // STEP: Toggle class OR other attribute let toggleElementCurrentState = 'off' // @since 2.0 if (toggleAttribute === 'class') { // Close .brx-open after 200ms to prevent mobile menu styles from unsetting while mobile menu fades out if ( toggle.closest('.brxe-nav-nested') && toggleValue === 'brx-open' && toggleElement.classList.contains('brx-open') ) { toggleElementCurrentState = 'on' toggleElement.classList.add('brx-closing') setTimeout(() => { toggleElement.classList.remove('brx-closing') toggleElement.classList.remove('brx-open') }, 200) } else { // Check the current state of the toggle element if (toggleElement.classList.contains(toggleValue)) { toggleElementCurrentState = 'on' } else { toggleElementCurrentState = 'off' } toggleElement.classList.toggle(toggleValue) } } else { if (toggleElement.getAttribute(toggleAttribute)) { toggleElementCurrentState = 'on' toggleElement.removeAttribute(toggleAttribute) } else { toggleElementCurrentState = 'off' toggleElement.setAttribute(toggleAttribute, toggleValue) } } let disableAutoFocus = false // Check for Offcanvas element disableAutoFocus attribute (@since 1.10.2) if (toggleElement.classList.contains('brxe-offcanvas')) { disableAutoFocus = toggleElement.dataset?.noAutoFocus === 'true' || false toggleElement.classList.remove('brx-closing') // We can remove the closing class here (@since 2.0) } // Nestable nav: disable auto focus as there is another logic in bricksNavNested(), or it will be flickering if (toggleElement.classList.contains('brxe-nav-nested')) { disableAutoFocus = true } // Only auto focus if the target toggle is going to be opened (@since 2.0) if (!disableAutoFocus && toggleElementCurrentState === 'off') { bricksFocusOnFirstFocusableElement(toggleElement) } }, /** * Helper debounce function, to prevent multiple calls at once * NOTE: Previoulsy defined in filters.js, but that file is not loaded on all pages. * * @param {*} func Function to run, when debounce is over * @param {*} wait Time to wait before running the function * @param {*} immediate Run the function immediately * @returns {function} Debounced function * * @since 1.12 */ debounce: (func, wait, immediate) => { let timeout return function () { let context = this, args = arguments let later = function () { timeout = null if (!immediate) func.apply(context, args) } let callNow = immediate && !timeout clearTimeout(timeout) timeout = setTimeout(later, wait) if (callNow) func.apply(context, args) } }, /** * Return true if the xhr is still running and abort it * This is to prevent multiple xhr requests running at the same time * * @param {string} queryId * @returns boolean * * @since 1.12 */ maybeAbortXhr: (queryId) => { // Get queryLoopInstance from window.bricksData.queryLoopInstances const queryLoopInstance = window.bricksData.queryLoopInstances[queryId] || false // Exit if no queryLoopInstance if (!queryLoopInstance) { return queryLoopInstance.xhrAborted } // Abort the XHR request if it is still running if (queryLoopInstance.xhr) { queryLoopInstance.xhr.abort() queryLoopInstance.xhrAborted = true } return queryLoopInstance.xhrAborted }, // Get page number from URL (@since 1.11) getPageNumberFromUrl: (href) => { // Set clickedPageNumber to 1 let pageNumber = 1 // Get the page number from href /page/3/, ?paged=2 const url = new URL(href) // Check if the href has page query if (url.searchParams.has('paged')) { pageNumber = parseInt(url.searchParams.get('paged')) } else if (href === window.bricksData?.baseUrl) { // Example: Date archive page, 1st page is same as baseUrl (#86c4bj3zz @since 2.0.2) pageNumber = 1 } else { // Check if the href has page path const pagePath = url.pathname.split('/') // Remove all empty string from pagePath const pagePathFiltered = pagePath.filter((path) => { return path !== '' }) // Get the last item from pagePathFiltered pageNumber = pagePathFiltered[pagePathFiltered.length - 1] // If clickedPageNumber is NaN, set it to 1 if (isNaN(pageNumber)) { pageNumber = 1 } } // Convert pageNumber to integer pageNumber = parseInt(pageNumber) return pageNumber }, /** * Updated query_results_count, query_results_count_filter in the DOM * * @param {string} queryId * @param {string} type 'query' or 'dom' * @param {*} data * * @since 1.12.2 */ updateQueryResultStats: (queryId, type, data) => { const foundStats = { count: 0, start: 0, end: 0 } if (type === 'dom') { // STEP: Find any query results count, start, and end in the DOM // Get from data-brx-qr-count (query_results_count_filter dynamic tag) const domResultsCount = data.querySelector(`span[data-brx-qr-count="${queryId}"]`) if (domResultsCount) { foundStats.count = domResultsCount.innerHTML } // Get from query results summary element in the DOM that is targetting the queryId const queryResultsSummaryElement = data.querySelector( `.brxe-query-results-summary[data-brx-qr-stats="${queryId}"]` ) if (queryResultsSummaryElement) { // Extract the start, end and total from data-brx-qr-stats-data const statsData = queryResultsSummaryElement.dataset?.brxQrStatsData || false if (statsData) { const statsDataObj = JSON.parse(statsData) if (statsDataObj?.start !== undefined) { foundStats.start = statsDataObj.start } if (statsDataObj?.end !== undefined) { foundStats.end = statsDataObj.end } if (statsDataObj?.count !== undefined) { foundStats.count = statsDataObj.count } } } } else { // 'query' type if (data?.count !== undefined) { foundStats.count = data.count } if (data?.start !== undefined) { foundStats.start = data.start } if (data?.end !== undefined) { foundStats.end = data.end } } // Count / Total found if (foundStats?.count !== undefined) { const qrTotal = parseInt(foundStats.count || 0) const posStart = parseInt(foundStats.start || 0) const posEnd = parseInt(foundStats.end || 0) /** * STEP: Replace any existing span[data-brx-qr-count] innerHTML with the updated count * * {query_results_count} DD */ const queryResultsCounts = document.querySelectorAll(`span[data-brx-qr-count="${queryId}"]`) queryResultsCounts.forEach((count) => { count.innerHTML = qrTotal }) /** * STEP: Handle query results summary element in the DOM that is targetting the queryId * * @since 1.12.2 */ const queryResultsSummaryElement = document.querySelectorAll( `.brxe-query-results-summary[data-brx-qr-stats="${queryId}"]` ) queryResultsSummaryElement.forEach((stats) => { // Extract statsFormat, oneResultText, noResultsText from data-brx-qr-stats-data const statsData = stats.dataset?.brxQrStatsData || false if (statsData) { const statsDataObj = JSON.parse(statsData) const statsFormat = statsDataObj?.statsFormat || false const oneResultText = statsDataObj?.oneResultText || false const noResultsText = statsDataObj?.noResultsText || false if (qrTotal < 1) { // Use noResultsText if no results found stats.innerHTML = noResultsText } else if (qrTotal === 1) { // Use oneResultText if only 1 result found stats.innerHTML = oneResultText } else { // Use statsFormat if more than 1 result found // Replace %start%, %end%, %total% with the actual values let statsText = statsFormat .replace('%start%', posStart) // Replace %start% with the actual start position .replace('%end%', posEnd) // Replace %end% with the actual end position .replace('%total%', qrTotal) // Replace %total% with the actual total count stats.innerHTML = statsText } } }) } }, /** * Use by Bricks Interaction to toggle infoBox on Google Map * @since 2.0 */ toggleMapInfoBox: (config) => { // Ensure google is loaded if (!window.google || !window.google.maps) return const { el: sourceEl, action } = config if (!sourceEl) return const addressId = sourceEl.dataset?.brxInfoboxOpen || false const mapId = sourceEl.dataset?.brxInfoboxMapId || false if (!addressId || !mapId) return const googleMapInstance = window.bricksData.googleMapInstances[mapId] if (!googleMapInstance) return const location = googleMapInstance.locations.find((loc) => loc.id === addressId) if (!location || !location.marker) return // Reach here, we have the location and marker const { infoBox } = location if (action === 'openAddress') { // Show infoBox if it is not open if (!infoBox || !infoBox?.div_) { google.maps.event.trigger(location.marker, 'click') } } else { // Hide infoBox if it is open if (infoBox && infoBox?.div_) { google.maps.event.trigger(infoBox, 'closeclick') infoBox.close() } } }, /** * Close all submenus * Previously located in bricksSubmenuListener() * @since 1.11 * * @since 2.0 */ closeAllSubmenus: (element) => { // STEP: Hide closest submenu & focus on parent let openSubmenu = element.closest('.open') let multilevel = element.closest('.brx-has-multilevel') if (openSubmenu && !multilevel) { let toggle = openSubmenu.querySelector('.brx-submenu-toggle button[aria-expanded]') if (toggle) { bricksSubmenuToggle(toggle, 'remove') // Focus on parent if (toggle) { toggle.focus() } } } // STEP: Close all open submenus (multilevel) else { let openSubmenuToggles = bricksQuerySelectorAll( document, '.brx-submenu-toggle > button[aria-expanded="true"]' ) openSubmenuToggles.forEach((toggle) => { if (toggle) { bricksSubmenuToggle(toggle, 'remove') } }) } }, /** * Rebuild Splide instance when slides DOM changed * #86bz8vbac * @since 2.2 */ rebuildSplide: (splideId) => { // Get splide instance from window.bricksData.splideInstances const splideInstance = window.bricksData.splideInstances[splideId] || false // Exit if no splideInstance if (!splideInstance) { return } // Destroy the existing instance splideInstance.destroy() // Delete from window.bricksData.splideInstances delete window.bricksData.splideInstances[splideId] // Delete from bricksSplideFn._initializedElements to allow re-initialization if (window.bricksSplideFn && window.bricksSplideFn._initializedElements) { // Find the elment in the initializedElements set and delete it const element = splideInstance.root if (window.bricksSplideFn._initializedElements.has(element)) { window.bricksSplideFn._initializedElements.delete(element) } } // Re-initialize the splide instance if (window.bricksSplideFn) { window.bricksSplideFn.run() } }, // Helper function to get photoswipe id from lightbox element (#86c9qjvd8; @since 2.3.5) photoswipeGetId: (lightboxElement) => { return ( lightboxElement.getAttribute('data-pswp-id') || lightboxElement.getAttribute('data-lightbox-id') ) }, // Helper function to destroy photoswipe instance and remove reference from the lightbox element (#86c9qjvd8; @since 2.3.5) photoswipeDestroy: (lightboxElement) => { if (!lightboxElement?.bricksPhotoswipe) { return } lightboxElement.bricksPhotoswipe.destroy() delete lightboxElement.bricksPhotoswipe }, imageGalleryGetLoadMoreSettings: (galleryEl) => { if (!galleryEl?.dataset?.brxLoadMoreSettings) { return {} } try { return JSON.parse(galleryEl.dataset.brxLoadMoreSettings) } catch (e) { return {} } }, imageGalleryResolveLoadMoreValue: (loadMoreSettings = {}, settingKey = 'initial') => { const fallback = Number.parseInt(loadMoreSettings?.[settingKey] ?? '0', 10) const responsiveValues = loadMoreSettings?.responsive?.[settingKey] || {} const breakpoints = Array.isArray(loadMoreSettings?.breakpoints) ? loadMoreSettings.breakpoints : [] if (!breakpoints.length || !responsiveValues || typeof responsiveValues !== 'object') { return Number.isNaN(fallback) ? 0 : Math.max(0, fallback) } const baseBreakpoint = breakpoints.find((breakpoint) => breakpoint?.base === true) || breakpoints.find((breakpoint) => breakpoint?.key === 'desktop') if (!baseBreakpoint) { return Number.isNaN(fallback) ? 0 : Math.max(0, fallback) } const baseWidth = Number.parseInt(baseBreakpoint?.width ?? '0', 10) const orderedBreakpoints = [ baseBreakpoint, ...breakpoints .filter((breakpoint) => { const width = Number.parseInt(breakpoint?.width ?? '0', 10) return breakpoint?.base !== true && width > baseWidth }) .sort((a, b) => { return Number.parseInt(a?.width ?? '0', 10) - Number.parseInt(b?.width ?? '0', 10) }), ...breakpoints .filter((breakpoint) => { const width = Number.parseInt(breakpoint?.width ?? '0', 10) return breakpoint?.base !== true && width < baseWidth }) .sort((a, b) => { return Number.parseInt(b?.width ?? '0', 10) - Number.parseInt(a?.width ?? '0', 10) }) ] let resolvedValue = Number.isNaN(fallback) ? 0 : Math.max(0, fallback) orderedBreakpoints.forEach((breakpoint) => { const width = Number.parseInt(breakpoint?.width ?? '0', 10) let applies = breakpoint?.base === true if (!applies && !Number.isNaN(width)) { if (width > baseWidth) { applies = window.innerWidth >= width } else if (width < baseWidth) { applies = window.innerWidth <= width } } if (!applies || !Object.prototype.hasOwnProperty.call(responsiveValues, breakpoint.key)) { return } const value = Number.parseInt(responsiveValues[breakpoint.key], 10) if (!Number.isNaN(value)) { resolvedValue = Math.max(0, value) } }) return resolvedValue }, imageGalleryGetResolvedLoadMoreSettings: (galleryEl) => { const loadMoreSettings = bricksUtils.imageGalleryGetLoadMoreSettings(galleryEl) const storedInitial = galleryEl?.dataset?.brxLoadMoreResolvedInitial const resolvedStep = bricksUtils.imageGalleryResolveLoadMoreValue(loadMoreSettings, 'step') if (storedInitial !== undefined) { if (galleryEl?.dataset) { galleryEl.dataset.brxLoadMoreResolvedStep = `${resolvedStep}` } return { ...loadMoreSettings, initial: Number.parseInt(storedInitial, 10) || 0, step: resolvedStep } } const resolvedSettings = { ...loadMoreSettings, initial: bricksUtils.imageGalleryResolveLoadMoreValue(loadMoreSettings, 'initial'), step: resolvedStep } if (galleryEl?.dataset) { galleryEl.dataset.brxLoadMoreResolvedInitial = `${resolvedSettings.initial}` galleryEl.dataset.brxLoadMoreResolvedStep = `${resolvedSettings.step}` } return resolvedSettings }, imageGalleryAppendDeferredItems: (targetGallery, revealCount = 0, options = {}) => { const template = targetGallery?.querySelector(':scope > .brx-gallery-load-more-template') if (!template || !template.content || revealCount <= 0) { return [] } const remainingItems = Array.from(template.content.children).filter( (node) => node.matches && node.matches('li.bricks-layout-item') ) const itemsToAppend = remainingItems.slice(0, revealCount) if (!itemsToAppend.length) { return [] } const fragment = document.createDocumentFragment() const sizer = targetGallery.querySelector(':scope > .bricks-isotope-sizer') const trail = targetGallery.querySelector(':scope > .brx-gallery-load-more-trail') const insertBeforeNode = sizer || trail || template itemsToAppend.forEach((item) => { if (options?.animate) { item.classList.add('brx-gallery-item-reveal') } fragment.appendChild(item) }) targetGallery.insertBefore(fragment, insertBeforeNode) if (options?.animate) { itemsToAppend.forEach((item) => { setTimeout(() => { item.classList.remove('brx-gallery-item-reveal') }, 300) }) } return itemsToAppend }, imageGalleryLoadMoreSync: (target = 'all') => { let galleries = [] if (target === 'all') { galleries = bricksQuerySelectorAll( document, '.brxe-image-gallery[data-brx-load-more="gallery"]' ) } else if (target?.matches?.('.brxe-image-gallery[data-brx-load-more="gallery"]')) { galleries = [target] } else if (typeof target === 'string') { galleries = bricksQuerySelectorAll( document, `.brxe-image-gallery[data-brx-load-more="gallery"][data-script-id="${target}"]` ) } galleries.forEach((gallery) => { const template = gallery.querySelector(':scope > .brx-gallery-load-more-template') if (!template || !template.content) { return } const loadMoreSettings = bricksUtils.imageGalleryGetResolvedLoadMoreSettings(gallery) const visibleItems = Array.from(gallery.children).filter((node) => { return node.matches && node.matches('li.bricks-layout-item') }) const deferredItems = Array.from(template.content.children).filter((node) => { return node.matches && node.matches('li.bricks-layout-item') }) const totalItems = Number.parseInt( loadMoreSettings?.total ?? `${visibleItems.length + deferredItems.length}`, 10 ) const targetInitial = Number.parseInt(loadMoreSettings?.initial ?? '0', 10) const normalizedTarget = targetInitial > 0 && targetInitial < totalItems ? Math.min(targetInitial, totalItems) : totalItems let hasChanges = false if (visibleItems.length > normalizedTarget) { const itemsToDefer = visibleItems.slice(normalizedTarget) if (itemsToDefer.length) { const fragment = document.createDocumentFragment() itemsToDefer.forEach((item) => { fragment.appendChild(item) }) template.content.insertBefore(fragment, template.content.firstChild) hasChanges = true } } else if (visibleItems.length < normalizedTarget) { const appendedItems = bricksUtils.imageGalleryAppendDeferredItems( gallery, normalizedTarget - visibleItems.length ) hasChanges = appendedItems.length > 0 } if (hasChanges) { bricksLazyLoad() if (gallery.classList.contains('isotope')) { const elementId = gallery.dataset?.scriptId if (elementId) { bricksUtils.updateIsotopeInstance(elementId) } } if (gallery.classList.contains('bricks-lightbox')) { bricksPhotoswipe() } if (loadMoreSettings?.infinite) { delete gallery.dataset.brxGalleryInfiniteInit if (typeof bricksGalleryInfiniteScrollFn !== 'undefined') { bricksGalleryInfiniteScrollFn.run({ forceReinit: (element) => element === gallery }) } } } const galleryId = gallery.dataset?.scriptId || 'all' bricksUtils.imageGalleryLoadMoreButtonVisibility(galleryId) }) }, /** * Show/hide image gallery "Load more" buttons based on the presence of deferred items in the target gallery * * If the target gallery is not exist, hide "Load more" button. * * @since 2.3.1 */ imageGalleryLoadMoreButtonVisibility: (galleryId = 'all') => { const interactions = Array.isArray(window.bricksData?.interactions) ? window.bricksData.interactions : [] if (!interactions.length) { return } const galleryHasMore = (galleryEl) => { if ( !galleryEl || !galleryEl.matches || !galleryEl.matches('.brxe-image-gallery[data-brx-load-more="gallery"]') ) { return false } const template = galleryEl.querySelector(':scope > .brx-gallery-load-more-template') // No template => no deferred items => no more items if (!template || !template.content) { return false } return !!template.content.querySelector('li.bricks-layout-item') } interactions.forEach((interaction) => { if ( interaction?.action !== 'loadMoreGallery' || !interaction?.loadMoreTargetSelector || !interaction?.el ) { return } let targets = [] try { targets = bricksQuerySelectorAll(document, interaction.loadMoreTargetSelector) } catch (e) { return } targets = targets.filter((el) => { return el.matches && el.matches('.brxe-image-gallery[data-brx-load-more="gallery"]') }) // No gallery target found for this selector: hide trigger if (!targets.length) { interaction.el.classList.add('brx-load-more-hidden') return } // If galleryId is provided, only update interactions that target that gallery if (galleryId !== 'all') { const hasTargetGallery = targets.some((el) => el.dataset?.scriptId === galleryId) if (!hasTargetGallery) { return } } // Keep trigger visible if at least one target gallery still has deferred items const shouldShow = targets.some((galleryEl) => galleryHasMore(galleryEl)) if (shouldShow) { interaction.el.classList.remove('brx-load-more-hidden') } else { interaction.el.classList.add('brx-load-more-hidden') } }) }, /** * Reveal more items in the gallery based on the load more settings and options * * Considered: Lazyload, Isotope, Lightbox * * @since 2.3.1 */ imageGalleryLoadMore: (targetGallery, options = {}) => { if (!targetGallery || !targetGallery.matches('.brxe-image-gallery')) { return false } const template = targetGallery.querySelector(':scope > .brx-gallery-load-more-template') if (!template || !template.content) { return false } const loadMoreSettings = bricksUtils.imageGalleryGetResolvedLoadMoreSettings(targetGallery) // Ensure the step is a positive integer, if not set to 0 to reveal all remaining items const step = Number.parseInt(loadMoreSettings?.step || '0', 10) const remainingItems = Array.from(template.content.children).filter( (node) => node.matches && node.matches('li.bricks-layout-item') ) const revealCount = step > 0 ? Math.min(step, remainingItems.length) : remainingItems.length if (!revealCount) { return false } const appendedItems = bricksUtils.imageGalleryAppendDeferredItems(targetGallery, revealCount, { animate: true }) const trail = targetGallery.querySelector(':scope > .brx-gallery-load-more-trail') // Re-run dependent frontend logic bricksLazyLoad() if (targetGallery.classList.contains('isotope')) { const elementId = targetGallery.dataset.scriptId if (elementId) { bricksUtils.updateIsotopeOnImageLoad(elementId) } } if (targetGallery.classList.contains('bricks-lightbox')) { bricksPhotoswipe() } const hasMore = template.content.querySelector('li.bricks-layout-item') !== null if (!hasMore && trail) { trail.remove() } // Sync all loadMore triggers targeting this gallery const galleryId = targetGallery.dataset?.scriptId || 'all' bricksUtils.imageGalleryLoadMoreButtonVisibility(galleryId) return hasMore } } /** * BricksFunction class * * @since 1.8 */ class BricksFunction { // Store custom functions on class init _customRun = null _customEachElement = null _customListenerHandler = null _customAddEventListeners = null // Store default settings _settings = {} // Store initialized elements _initializedElements = new Set() constructor(options) { // Default settings const defaultSettings = { parentNode: document, selector: '', subscribeEvents: [ 'bricks/ajax/pagination/completed', 'bricks/ajax/load_page/completed', 'bricks/ajax/popup/loaded', 'bricks/ajax/query_result/displayed' ], subscribejQueryEvents: [], forceReinit: false, frontEndOnly: false, windowVariableCheck: [], additionalActions: [] } // Merge options with default settings when init the class Object.assign(defaultSettings, options) // Set default settings as class properties this._settings = defaultSettings // Assign custom functions if any (these functions are overrideable on class init) this._customRun = options?.run ?? null this._customEachElement = options?.eachElement ?? null this._customListenerHandler = options?.listenerHandler ?? null this._customAddEventListeners = options?.addEventListeners ?? null // Bind functions to class this.cleanUpInitElements = this.cleanUpInitElements.bind(this) this.run = this.run.bind(this) this.eachElement = this.eachElement.bind(this) this.listenerHandler = this.listenerHandler.bind(this) this.addEventListeners = this.addEventListeners.bind(this) document.addEventListener('DOMContentLoaded', () => { // Add event listeners (only add once) this.addEventListeners() // Run additional actions: Not define as a function to avoid overriding (no functionCanRun check here) if (this._settings.additionalActions.length) { for (const action of this._settings.additionalActions) { // Check if action is a function if (typeof action === 'function') { action.call(this) } } } }) /** * Save the class instance in the window.bricksFunctions object * * For bricksRunAllFunctions() to run all functions * * @since 1.11 */ if (!window.bricksFunctions) { window.bricksFunctions = [] } window.bricksFunctions.push(this) } /** * Helper: Based on window variable and frontEndOnly setting, check if function can run */ functionCanRun() { // Check: frontEndOnly is set and we are not in the front end if (this._settings.frontEndOnly) { // Can't use bricksIsFrontend here as this function is called before 'bricksIsFrontend' is set (and this is inside a class) if (!document.body.classList.contains('bricks-is-frontend')) { return false } } // Check: Does required window variables exist if (this._settings.windowVariableCheck.length) { for (const variable of this._settings.windowVariableCheck) { /** * Support checking different variable types: * - Splide * - bricksWooCommerce.useQtyInLoop * - abc.cde.efg * * If the variable found but is false, consider it as not exist * * @since 1.9.2 */ const variableParts = variable.split('.') let variableValue = window for (const variablePart of variableParts) { if (variableValue.hasOwnProperty(variablePart)) { variableValue = variableValue[variablePart] } else { variableValue = false break } } if (!variableValue) { return false } } } return true } /** * Helper: Clean up initialized elements set: Remove elements that are no longer in the DOM */ cleanUpInitElements() { // Remove elements from _initializedElements if they are no longer in the DOM for (const element of this._initializedElements) { if (!element.isConnected) { this._initializedElements.delete(element) } } } /** * Run logic on each element */ eachElement(element) { // Execute custom _customEachElement function if defined in constructor if (this._customEachElement && typeof this._customEachElement === 'function') { this._customEachElement.call(this, element) return } // Default customEachElement function: Do nothing } /** * Entry point: * Using functionCanRun as a guard, clean up initialized elements. * By default, find all elements based on parent node and selector, and run the eachElement function on each element. */ run(customSettings) { if (!this.functionCanRun()) { return } // Must run cleanUpInitElements before custom run function this.cleanUpInitElements() // Execute custom run function if defined in constructor if (this._customRun && typeof this._customRun === 'function') { this._customRun.call(this, customSettings) return } // Default run function // Clone settings (to avoid modifying them) const currentSettings = Object.assign({}, this._settings) // Set custom settings to current settings if (customSettings) { Object.keys(customSettings).forEach((key) => { if (currentSettings.hasOwnProperty(key)) { currentSettings[key] = customSettings[key] } }) } const elementInstances = bricksQuerySelectorAll( currentSettings.parentNode, currentSettings.selector ) // Exit if no element found if (!elementInstances.length) { return } elementInstances.forEach((element, index) => { // Store the element in the _initializedElements set // forceReinit, ignore the set and run the eachElement function if (currentSettings.forceReinit) { // If forceReinit is a callback, run it const reinit = typeof currentSettings.forceReinit === 'function' ? currentSettings.forceReinit.call(this, element, index) : currentSettings.forceReinit if (reinit) { this.eachElement(element, index) // Continue to next element return } } // Check if the element is already initialized if (!this._initializedElements.has(element)) { // Add element to initialized elements set this._initializedElements.add(element) // Run eachElement function this.eachElement(element, index) } else { // Maybe the element inside the set is not the same as the current element, so we need to check // Get the element from the set const elementFromSet = Array.from(this._initializedElements).find((el) => el === element) // If it is not connected, remove it from the set and run the eachElement function if (!elementFromSet.isConnected) { this._initializedElements.delete(elementFromSet) // Add element to initialized elements set this._initializedElements.add(element, index) this.eachElement(element, index) } } }) } /** * Once subscribed to events, run the listenerHandler function * By default, we will change the parent node based on the event type, and execute the run function again */ listenerHandler(event) { // Execute custom listenerHandler function if defined in constructor if (this._customListenerHandler && typeof this._customListenerHandler === 'function') { this._customListenerHandler.call(this, event) return } // Default listenerHandler function if (event?.type) { switch (event.type) { // Can add more cases here if needed for different events // Maybe can change the parent node or selector based on the event type default: this.run() break } } } /** * By default, subscribe to events defined in the settings, and set listenerHandler as the callback * Using functionCanRun as a guard */ addEventListeners() { if (!this.functionCanRun()) { return } // Execute custom addEventListeners function if defined in constructor if (this._customAddEventListeners && typeof this._customAddEventListeners === 'function') { this._customAddEventListeners.call(this) return } // Default addEventListeners function if (this._settings.subscribeEvents.length) { bricksUtils.subscribeEvents(document, this._settings.subscribeEvents, this.listenerHandler) } // jQuery events (@since 1.9.2) if (this._settings.subscribejQueryEvents.length) { bricksUtils.subscribejQueryEvents( document, this._settings.subscribejQueryEvents, this.listenerHandler ) } } } /** * Block Editor Integration: Simple re-initialization system * * @since 2.1 */ // Simple function to re-run all registered BricksFunction instances function reinitBricksFunctions() { if (window.bricksFunctionInstances) { window.bricksFunctionInstances.forEach((instance) => { if (instance && typeof instance.run === 'function') { instance.run() } }) } } /** * BricksFunction registration system for block editor compatibility * * @since 2.1 */ if (typeof window !== 'undefined') { // Store all BricksFunction instances for re-initialization window.bricksFunctionInstances = window.bricksFunctionInstances || [] // Store the original BricksFunction class const OriginalBricksFunction = BricksFunction // Override BricksFunction constructor to register instances for block editor window.BricksFunction = class extends OriginalBricksFunction { constructor(options) { super(options) // Register this instance for block editor re-initialization window.bricksFunctionInstances.push(this) } } // Preserve the original class properties and methods Object.setPrototypeOf(window.BricksFunction.prototype, OriginalBricksFunction.prototype) Object.setPrototypeOf(window.BricksFunction, OriginalBricksFunction) // Make the enhanced class globally available if (typeof BricksFunction !== 'undefined') { BricksFunction = window.BricksFunction } } /** * Block Editor Integration: MutationObserver on editor container * * @since 2.1 */ function bricksBlockEditorIntegration() { if (!document.body.classList.contains('block-editor-page')) { return } const waitForEditor = () => { const editorContainer = document.querySelector('.wp-block-post-content') if (editorContainer) { // Set up MutationObserver on the editor container const observer = new MutationObserver(() => { clearTimeout(window.bricksReinitTimeout) window.bricksReinitTimeout = setTimeout(reinitBricksFunctions, 300) }) observer.observe(editorContainer, { childList: true, subtree: true }) // Also run once immediately setTimeout(reinitBricksFunctions, 200) } else { // Retry if editor not ready yet setTimeout(waitForEditor, 100) } } // Start waiting for editor if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', waitForEditor) } else { waitForEditor() } } /** * Frontend: Lazy load when target element enters viewport * * Video lazy load via bricksBackgroundVideoInit() * * https://developers.google.com/web/fundamentals/performance/lazy-loading-guidance/images-and-video/ */ const bricksLazyLoadFn = new BricksFunction({ parentNode: document, selector: '.bricks-lazy-hidden', subscribejQueryEvents: ['updated_cart_totals'], eachElement: (el) => { // Lazy Load function let lazyLoad = (el) => { // Replace element attributes by setting 'src' with 'data-src' // Show base64 preloader SVG el.classList.add('wait') // Image if (el.dataset.src) { el.src = el.dataset.src delete el.dataset.src // Once image loaded, trigger isotope layout (@since 1.12) if (el.closest('.bricks-masonry')) { let isotopeId = el.closest('.bricks-masonry').getAttribute('data-script-id') ?? false if (isotopeId) { el.addEventListener('load', () => { bricksUtils.updateIsotopeInstance(isotopeId) }) } } } // Image (data-sizes @since 1.5.1 due to W3 Validator error) if (el.dataset.sizes) { el.sizes = el.dataset.sizes delete el.dataset.sizes } if (el.dataset.srcset) { el.srcset = el.dataset.srcset delete el.dataset.srcset } // Background image (e.g. slider) if (el.dataset.style) { let style = el.getAttribute('style') || '' style += el.dataset.style el.setAttribute('style', style) // Keep 'data-style' attribute for when splide.js re-initializes on window resize, etc. if (!el.classList.contains('splide__slide')) { delete el.dataset.style } } el.classList.remove('bricks-lazy-hidden') el.classList.remove('wait') if (el.classList.contains('bricks-lazy-load-isotope')) { bricksIsotope() } } // Lazy load offet: 300px default (customisable via Bricks setting 'offsetLazyLoad') const rootMargin = window.bricksData.offsetLazyLoad || 300 new BricksIntersect({ element: el, callback: (el) => { lazyLoad(el) }, rootMargin: `${rootMargin}px` }) }, listenerHandler: (event) => { // No need to change parentNode, but need some delay to allow for new elements to be added to the DOM (e.g. swiper, carousel, testimonial, etc.) setTimeout(() => { bricksLazyLoadFn.run() }, 100) } }) function bricksLazyLoad() { bricksLazyLoadFn.run() } /** * Animate.css element animation */ const bricksAnimationFn = new BricksFunction({ parentNode: document, selector: '.brx-animated', removeAfterMs: 3000, // removeAfterMs not used anymore (@since 1.8) eachElement: (el) => { new BricksIntersect({ element: el, callback: (el) => { let animation = el.dataset.animation if (animation) { // Start animation el.classList.add(`brx-animate-${animation}`) // Remove attribute to prevent hiding element after "in" animations (see _animate.scss) el.removeAttribute('data-animation') // Remove animation class on 'animationend' event instead of setTimeout below (@since 1.8) el.addEventListener( 'animationend', () => { el.classList.remove(`brx-animate-${animation}`) // If this is .brx-popup-content, and animation includes 'Out', execute bricksClosePopup() after animation if (el.classList.contains('brx-popup-content') && animation.includes('Out')) { const popupNode = el.closest('.brx-popup') if (popupNode) { bricksClosePopup(popupNode) } } // animationId = data-animation-id const animationId = el.dataset?.animationId // Remove data-animation-id and style: animation-duration to not affect next animation (@since 2.0) el.style.animationDuration = '' if (animationId) { // @since 1.8.4 - Trigger custom event for bricks/animation/end/{animationId}, provide element const bricksAnimationEvent = new CustomEvent( `bricks/animation/end/${animationId}`, { detail: { el } } ) document.dispatchEvent(bricksAnimationEvent) } }, { once: true } ) } } }) }, run: (customSettings) => { const self = bricksAnimationFn // Use customSettings.elementsToAnimate if defined const elementsToAnimate = customSettings?.elementsToAnimate || bricksQuerySelectorAll(self._settings.parentNode, self._settings.selector) // Use customSettings.removeAfterMs if defined self.removeAfterMs = customSettings?.removeAfterMs || self.removeAfterMs elementsToAnimate.forEach((el) => { self.eachElement(el) }) } }) function bricksAnimation() { bricksAnimationFn.run() } /** * Motion.js parallax (element + background) * * Separate from legacy background-attachment: fixed behavior. * * @since 2.3.1 */ const bricksMotionParallax = (() => { const motionParallaxData = new WeakMap() const activeParallaxElements = new Set() const parallaxSubscribers = new Set() let rafId = null let isFrameScheduled = false let areParallaxListenersBound = false /** * Run all active parallax updates in a shared RAF tick. */ function runParallaxFrame() { isFrameScheduled = false rafId = null parallaxSubscribers.forEach((update) => { update() }) } /** * Schedule a shared RAF update for all active parallax elements. */ function scheduleParallaxFrame() { if (isFrameScheduled) return isFrameScheduled = true rafId = window.requestAnimationFrame(runParallaxFrame) } /** * Toggle shared event listeners based on whether parallax instances exist. */ function syncParallaxListeners() { const shouldBind = parallaxSubscribers.size > 0 if (shouldBind && !areParallaxListenersBound) { window.addEventListener('scroll', scheduleParallaxFrame, { passive: true }) window.addEventListener('resize', scheduleParallaxFrame, { passive: true }) window.addEventListener('orientationchange', scheduleParallaxFrame, { passive: true }) areParallaxListenersBound = true } if (!shouldBind && areParallaxListenersBound) { window.removeEventListener('scroll', scheduleParallaxFrame) window.removeEventListener('resize', scheduleParallaxFrame) window.removeEventListener('orientationchange', scheduleParallaxFrame) areParallaxListenersBound = false if (rafId) { window.cancelAnimationFrame(rafId) rafId = null isFrameScheduled = false } } } /** * Parse a CSS custom property percentage value (e.g. "50" from --brx-parallax-speed-x) * and return it as a decimal (0.5). * * @param {string} value * @param {number} fallback * @returns {number} */ function parseSpeedPercentage(value, fallback = 0) { const parsed = parseFloat(value) return Number.isFinite(parsed) ? parsed / 100 : fallback } /** * Normalize background position keywords to percentages. * * @param {string} value * @param {string} fallback * @returns {string} */ function normalizeBackgroundPosition(value, fallback = '50%') { if (!value) return fallback const map = { left: '0%', right: '100%', top: '0%', bottom: '100%', center: '50%' } return map[value.trim().toLowerCase()] || value } /** * Destroy parallax instance for an element: stop scroll observers, reset styles. * * @param {HTMLElement} element */ function destroyElement(element) { const data = motionParallaxData.get(element) activeParallaxElements.delete(element) if (!data) return data.cleanupFns.forEach((fn) => { if (typeof fn === 'function') fn() }) // Reset element parallax styles if (data.hasElementParallax) { element.style.translate = data.originalInlineTranslate || '' } // Reset background parallax styles if (data.hasBackgroundParallax) { element.style.backgroundPosition = data.originalInlineBackgroundPosition ?? '' } motionParallaxData.delete(element) } /** * Clean up parallax instances for elements that were detached before teardown could run. */ function sweepDisconnectedElements() { activeParallaxElements.forEach((element) => { if (!element.isConnected) { destroyElement(element) } }) } /** * Create a parallax instance using a shared scroll/RAF scheduler. * * @param {HTMLElement} element * @param {Object} options * @returns {Object} Instance data to store in WeakMap */ function createParallaxInstance(element, options) { const { hasElementParallax, hasBackgroundParallax, progressStart, originalInlineTranslate, originalInlineBackgroundPosition } = options const cleanupFns = [] // Cache prefers-reduced-motion query (avoid creating new MediaQueryList per frame) const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)') // Cache background position from computed style (must read BEFORE we modify it) let bgPosX = '50%' let bgPosY = '50%' if (hasBackgroundParallax) { // Reset to original so we read the authored position, not our modified one element.style.backgroundPosition = originalInlineBackgroundPosition || '' const computedStyle = window.getComputedStyle(element) bgPosX = normalizeBackgroundPosition(computedStyle.backgroundPositionX, '50%') bgPosY = normalizeBackgroundPosition(computedStyle.backgroundPositionY, '50%') } // Cache speed values from CSS custom properties (they only change on breakpoint/resize) let speedX = 0 let speedY = 0 let bgSpeed = 0 const readSpeedValues = () => { const style = window.getComputedStyle(element) if (hasElementParallax) { speedX = parseSpeedPercentage( style.getPropertyValue('--brx-motion-parallax-speed-x')?.trim() ) speedY = parseSpeedPercentage( style.getPropertyValue('--brx-motion-parallax-speed-y')?.trim() ) } if (hasBackgroundParallax) { bgSpeed = parseSpeedPercentage( style.getPropertyValue('--brx-motion-background-speed')?.trim() ) } } readSpeedValues() // Cache element position for manual progress computation. // This ensures elements above the fold start at progress=0 (no offset at scroll=0). let initRect = element.getBoundingClientRect() let initScrollY = window.scrollY || window.pageYOffset || 0 let elementTop = initScrollY + initRect.top let elementHeight = initRect.height // Re-read speed values and element position on resize (breakpoint changes) const onResize = () => { readSpeedValues() element.style.translate = '' initRect = element.getBoundingClientRect() initScrollY = window.scrollY || window.pageYOffset || 0 elementTop = initScrollY + initRect.top elementHeight = initRect.height scheduleParallaxFrame() } window.addEventListener('resize', onResize) // Add other event listeners for custom bricks events, that may change element's position (e.g. tabs, accordion, ajax content load, etc.) document.addEventListener('load', onResize) document.addEventListener('bricks/tabs/changed', onResize) document.addEventListener('bricks/accordion/open', onResize) document.addEventListener('bricks/accordion/close', onResize) document.addEventListener('bricks/ajax/nodes_added', onResize) document.addEventListener('bricks/ajax/load_page/completed', onResize) document.addEventListener('bricks/ajax/query_result/displayed', onResize) document.addEventListener('bricks/ajax/pagination/completed', onResize) // Cleanup function to remove event listeners and stop scroll listener cleanupFns.push(() => { window.removeEventListener('resize', onResize) document.removeEventListener('load', onResize) document.removeEventListener('bricks/tabs/changed', onResize) document.removeEventListener('bricks/accordion/open', onResize) document.removeEventListener('bricks/accordion/close', onResize) document.removeEventListener('bricks/ajax/nodes_added', onResize) document.removeEventListener('bricks/ajax/load_page/completed', onResize) document.removeEventListener('bricks/ajax/query_result/displayed', onResize) document.removeEventListener('bricks/ajax/pagination/completed', onResize) }) const updateParallax = () => { // Respect prefers-reduced-motion if (reducedMotionQuery.matches) { if (hasElementParallax) { element.style.translate = '0px 0px' } if (hasBackgroundParallax) { element.style.backgroundPosition = `${bgPosX} ${bgPosY}` } return } // Compute progress manually from scrollY and cached element position. // For above-fold elements at scroll=0, start is clamped to 0 so progress=0. const viewportHeight = window.innerHeight || document.documentElement.clientHeight const scrollY = window.scrollY || window.pageYOffset || 0 const start = Math.max(elementTop - viewportHeight, 0) const range = viewportHeight + elementHeight || 1 const progress = Math.min(Math.max((scrollY - start) / range, 0), 1) // Start once the configured visibility threshold is reached (0-1). // Normalize to keep the max parallax intensity consistent with previous behavior. const progressStartOffset = Math.min(Math.max(progressStart, 0), 1) const progressRange = Math.max(1 - progressStartOffset, 0.0001) const p = (Math.max(progress - progressStartOffset, 0) / progressRange) * 0.5 if (hasElementParallax) { const viewportWidth = window.innerWidth || document.documentElement.clientWidth const xOffset = p * speedX * viewportWidth const yOffset = p * speedY * viewportHeight element.style.translate = `${xOffset.toFixed(2)}px ${yOffset.toFixed(2)}px` } if (hasBackgroundParallax) { const bgOffset = p * bgSpeed * viewportHeight element.style.backgroundPosition = `${bgPosX} calc(${bgPosY} + ${bgOffset.toFixed(2)}px)` } } parallaxSubscribers.add(updateParallax) syncParallaxListeners() cleanupFns.push(() => { parallaxSubscribers.delete(updateParallax) syncParallaxListeners() }) updateParallax() return { cleanupFns, hasElementParallax, hasBackgroundParallax, originalInlineTranslate, originalInlineBackgroundPosition } } // BricksFunction registration const motionParallaxFn = new BricksFunction({ parentNode: document, selector: '[data-brx-motion-parallax]', forceReinit: true, eachElement: (element) => { // Clean up any existing instance destroyElement(element) // Parse settings from data attribute let settings try { settings = JSON.parse(element.dataset?.brxMotionParallax) } catch (e) { return } if (!settings) return const hasElementParallax = settings?.element === true const hasBackgroundParallax = settings?.background === true const progressStartRaw = settings?.startVisiblePercent const parsedProgressStart = parseFloat(progressStartRaw) const progressStartPercent = Number.isFinite(parsedProgressStart) ? Math.min(Math.max(parsedProgressStart, 0), 100) : 0 const progressStart = progressStartPercent / 100 if (!hasElementParallax && !hasBackgroundParallax) return // Store original inline styles for cleanup const originalInlineTranslate = element.style.translate || '' const originalInlineBackgroundPosition = element.style.backgroundPosition // Create parallax instance const data = createParallaxInstance(element, { hasElementParallax, hasBackgroundParallax, progressStart, originalInlineTranslate, originalInlineBackgroundPosition }) motionParallaxData.set(element, data) activeParallaxElements.add(element) } }) return (customSettings) => { sweepDisconnectedElements() scheduleParallaxFrame() motionParallaxFn.run(customSettings) } })() /** * Populate the queries instances variable to be used for infinite scroll and load more * * @since 1.6 */ const bricksInitQueryLoopInstancesFn = new BricksFunction({ parentNode: document, selector: '.brx-query-trail', subscribeEvents: ['bricks/ajax/load_page/completed'], eachElement: (el) => { const observerMargin = el.dataset?.observerMargin || '1px' // 0px doesn't trigger properly every time const observerDelay = el.dataset?.observerDelay || 0 // Infinite scroll delay in milliseconds (@since 1.12) const componentId = el.dataset?.queryComponentId || false const queryElementId = el.dataset?.queryElementId const queryVars = el.dataset?.queryVars const originalQueryVars = el.dataset?.originalQueryVars || '[]' // DD parsed and before merge with Query Filter's query var (@since 1.11.1) const isPostsElement = el.classList.contains('bricks-isotope-sizer') const isInfiniteScroll = el.classList.contains('brx-infinite-scroll') const ajaxLoader = el.dataset?.brxAjaxLoader const isLiveSearch = el.dataset?.brxLiveSearch const disableUrlParams = el.dataset?.brxDisableUrlParams // Find the <[data-brx-loop-start]> element (@since 1.12.3) const loopMarker = document.querySelector(`[data-brx-loop-start="${queryElementId}"]`) if (!loopMarker) { // Query Loop Marker not found, this query didn't define no results message el.insertAdjacentHTML('beforebegin', `<!--brx-loop-start-${queryElementId}-->`) } else { // Create comment and insert right before the loopMarker loopMarker?.insertAdjacentHTML('beforebegin', `<!--brx-loop-start-${queryElementId}-->`) loopMarker.removeAttribute('data-brx-loop-start') } // STEP: Store results container let resultsContainer = loopMarker?.parentNode || el.parentNode window.bricksData.queryLoopInstances[queryElementId] = { componentId: componentId, start: el.dataset.start, end: el.dataset.end, page: el.dataset.page, maxPages: el.dataset.maxPages, queryVars, originalQueryVars, observerMargin, observerDelay, // @since 1.12 infiniteScroll: isInfiniteScroll, isPostsElement: isPostsElement, ajaxLoader, isLiveSearch, disableUrlParams, // @since 2.0 resultsContainer, xhr: null, // Store the xhr object for each query instance (@since 1.12) xhrAborted: false // Store the xhr abort status (@since 1.12) } /** * Posts element: Query trail is the isotope sizer * For the Query Loop the trail is the last loop element. * * @since 1.7.1: Exclude popup elements */ const selectorId = bricksUtils.getQueryTrailSelectorId(queryElementId, componentId) let queryTrail = isPostsElement ? el.previousElementSibling : Array.from(resultsContainer.querySelectorAll(`.brxe-${selectorId}:not(.brx-popup)`)).pop() // Cleanup unnecessary data attributes (@since 1.12.3) el.removeAttribute('data-query-vars') el.removeAttribute('data-original-query-vars') const isSplideSlide = el.closest('.brxe-slider-nested.splide') && el.parentNode?.classList.contains('splide__list') if (isSplideSlide) { // Listen to these events to re-initialize the Splide instance after new query results are loaded via AJAX (#86bz8vbac @since 2.2) let splideId = el.closest('.brxe-slider-nested.splide').dataset?.scriptId || false if (splideId) { const splideEvents = [ 'bricks/ajax/pagination/completed', 'bricks/ajax/load_page/completed', 'bricks/ajax/query_result/displayed' ] splideEvents.forEach((eventName) => { document.addEventListener(eventName, () => { // Run rebuildSplide for the splideId bricksUtils.rebuildSplide(splideId) }) }) } } // Remove the trail in case it is not a Posts element if (!isPostsElement) { el.remove() } if (queryTrail && isInfiniteScroll) { queryTrail.dataset.queryElementId = queryElementId new BricksIntersect({ element: queryTrail, callback: (el) => bricksQueryLoadPage(el), once: 1, rootMargin: observerMargin }) } } }) function bricksInitQueryLoopInstances() { bricksInitQueryLoopInstancesFn.run() } /** * AJAX loader - Add and remove ajax loader on ajax start and end * * @since 1.9 */ function bricksAjaxLoader() { const getLoaderHTML = (animation) => { let html = '' switch (animation) { case 'default': html = '<div class="brx-loading-default"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>' break case 'ellipsis': html = '<div class="brx-loading-ellipsis"><div></div><div></div><div></div><div></div></div>' break case 'ring': html = '<div class="brx-loading-ring"><div></div><div></div><div></div><div></div></div>' break case 'dual-ring': html = '<div class="brx-loading-dual-ring"></div>' break case 'facebook': html = '<div class="brx-loading-facebook"><div></div><div></div><div></div></div>' break case 'roller': html = '<div class="brx-loading-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>' break case 'ripple': html = '<div class="brx-loading-ripple"><div></div><div></div></div>' break case 'spinner': html = '<div class="brx-loading-spinner"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>' break } return html } // Add ajax loader document.addEventListener('bricks/ajax/start', (event) => { // Get the queryId from the event const queryId = event.detail.queryId || false if (!queryId) { return } // Find the queryLoopInstance from bricksData.queryLoopInstances const queryLoopConfig = window.bricksData.queryLoopInstances[queryId] || false // Exit if no queryLoopConfig if (!queryLoopConfig) { return } const ajaxLoader = JSON.parse(queryLoopConfig?.ajaxLoader || false) const loaderAnimation = ajaxLoader?.animation || false // Exit if no loader animation if (!loaderAnimation) { return } // STEP: Determine where to insert the AJAX loader HTML let targetElement = ajaxLoader?.selector ? document.querySelector(ajaxLoader.selector) : false // Fallback/default: Get the last element of the query trail if (!targetElement) { const selectorId = bricksUtils.getQueryTrailSelectorId( queryId, queryLoopConfig?.componentId || false ) // No result will cause the loader unable to detect the last element (@since 1.9.6) targetElement = Array.from( queryLoopConfig.resultsContainer?.querySelectorAll(`.brxe-${selectorId}:not(.brx-popup)`) ).pop() ?? queryLoopConfig.resultsContainer } // STEP: Insert the AJAX loader HTML if (targetElement) { let nodeName = targetElement.nodeName.toLowerCase() let loadingHTML = document.createElement(nodeName) loadingHTML.classList.add('brx-loading-animation') loadingHTML.dataset.ajaxLoaderQueryId = queryId // data attribute identifies the queryId // Add custom color if (ajaxLoader?.color) { loadingHTML.style.setProperty('--brx-loader-color', ajaxLoader.color) } // Add custom size if (ajaxLoader?.scale) { loadingHTML.style.transform = `scale(${ajaxLoader.scale})` } loadingHTML.innerHTML = getLoaderHTML(loaderAnimation) // Custom selector: Insert at the end of selector node, otherwise insert after last query trail element targetElement.insertAdjacentElement( ajaxLoader?.selector ? 'beforeend' : 'afterend', loadingHTML ) } }) // STEP: Remove all AJAX loaders document.addEventListener('bricks/ajax/end', (event) => { // Use querySelectorAll to remove all loaders related to the queryId const loadingAnimations = event?.detail?.queryId ? document.querySelectorAll( `.brx-loading-animation[data-ajax-loader-query-id="${event.detail.queryId}"]` ) : [] // Remove all loaders loadingAnimations.forEach((el) => el.remove()) }) // Add AJAX loader - Popup (@since 1.9.4) document.addEventListener('bricks/ajax/popup/start', (event) => { // Get the popupId from the event const popupElement = event.detail.popupElement || false if (!popupElement) { return } const ajaxLoader = JSON.parse(popupElement.dataset?.brxAjaxLoader || false) const loaderAnimation = ajaxLoader?.animation || false // Exit if no loader animation if (!loaderAnimation) { return } // STEP: Determine where to insert the AJAX loader HTML let targetElement = ajaxLoader?.selector ? document.querySelector(ajaxLoader.selector) : false // Fallback/default: Get the .brx-popup-content element if (!targetElement) { targetElement = popupElement.querySelector('.brx-popup-content') } // STEP: Insert the AJAX loader HTML if (targetElement) { let nodeName = targetElement.nodeName.toLowerCase() let loadingHTML = document.createElement(nodeName) loadingHTML.classList.add('brx-loading-animation') loadingHTML.dataset.ajaxLoaderPopupId = popupElement.dataset.popupId // data attribute identifies the popupId // Add custom color if (ajaxLoader?.color) { loadingHTML.style.setProperty('--brx-loader-color', ajaxLoader.color) } // Add custom size if (ajaxLoader?.scale) { loadingHTML.style.transform = `scale(${ajaxLoader.scale})` } loadingHTML.innerHTML = getLoaderHTML(loaderAnimation) // Insert inside the targetElement targetElement.insertAdjacentElement('afterbegin', loadingHTML) } }) // STEP: Remove all AJAX loaders - popup (@since 1.9.4) document.addEventListener('bricks/ajax/popup/end', (event) => { // Use querySelectorAll to remove all loaders related to the popupId const loadingAnimations = event?.detail?.popupId ? document.querySelectorAll( `.brx-loading-animation[data-ajax-loader-popup-id="${event.detail.popupId}"]` ) : [] // Remove all loaders loadingAnimations.forEach((el) => el.remove()) }) } /** * Bricks query load page elements * * @param {HTMLElement} el * @param {boolean} noDelay (@since 1.12.2) * @param {boolean} nonceRefreshed (@since 1.11) * * @since 1.5 */ function bricksQueryLoadPage(el, noDelay = false, nonceRefreshed = false) { return new Promise(function (resolve, reject) { const queryElementId = el.dataset.queryElementId const queryInfo = window.bricksData.queryLoopInstances?.[queryElementId] if (!queryInfo || (queryInfo?.isLoading && !nonceRefreshed)) { return } const componentId = queryInfo?.componentId || false let page = parseInt(queryInfo.page || 1) + 1 const maxPages = parseInt(queryInfo.maxPages || 1) if (page > maxPages) { // Don't remove instance as we still need it when filter or show/hide load more buttons resolve({ page, maxPages }) return } let url = window.bricksData.restApiUrl.concat('load_query_page') let queryData = { postId: window.bricksData.postId, queryElementId: queryElementId, componentId: componentId, queryVars: queryInfo.queryVars, page: page, nonce: window.bricksData.nonce, lang: window.bricksData.language || false, mainQueryId: window.bricksData.mainQueryId || false, // Record the main query ID (@since 2.0) activeSearchTemplate: window.bricksData.activeSearchTemplate || false, // Current active search template ID (@since 2.2) urlParams: bricksGetCurrentUrlParams() // #86c21d4m0; @since 2.3.8 } // Check if useQueryFilter is ON if ( window.bricksData.useQueryFilter === '1' && typeof window.bricksUtils.getFiltersForQuery === 'function' && typeof window.bricksUtils.getSelectedFiltersForQuery === 'function' ) { let filterIds = [] // Find all filters with the same targetQueryId, bricksData.filterInstances is Object if ( window.bricksData.filterInstances && Object.keys(window.bricksData.filterInstances).length > 0 ) { const filterIntances = Object.values(window.bricksData.filterInstances).filter((filter) => { return filter.targetQueryId === queryElementId }) filterIds = filterIntances.map((filter) => filter.filterId) } if (filterIds.length) { // Build allFilters array, no key is needed, just need each filter's ID let allFilters = window.bricksUtils.getFiltersForQuery(queryElementId, 'filterId') let selectedFilters = window.bricksUtils.getSelectedFiltersForQuery(queryElementId) // Get active filters tags for the query (@since 2.0) let afTags = window.bricksUtils.getDynamicTagsForParse(queryElementId) let originalQueryVars = queryInfo?.originalQueryVars === '[]' ? queryInfo?.queryVars : queryInfo?.originalQueryVars // Set queryData queryData = { postId: window.bricksData.postId, queryElementId: queryElementId, originalQueryVars: originalQueryVars, // for query filters retrieve original DD parsed query vars (@since 1.11.1) pageFilters: window.bricksData.pageFilters || false, filters: allFilters, // for dynamic filter update infinitePage: page, // Set the latest page number selectedFilters: selectedFilters, // for active filter update (@since 1.11) afTags: afTags, // for active filters tags update (@since 2.0) nonce: window.bricksData.nonce, baseUrl: window.bricksData.baseUrl, lang: window.bricksData.language || false, mainQueryId: window.bricksData.mainQueryId || false, // Record the main query ID (@since 2.0) activeSearchTemplate: window.bricksData.activeSearchTemplate || false, // Current active search template ID (@since 2.2) urlParams: bricksGetCurrentUrlParams() // #86c21d4m0; @since 2.3.8 } // Change the url to use the filter endpoint url = window.bricksData.restApiUrl.concat('query_result') } } // Set isLoading flag window.bricksData.queryLoopInstances[queryElementId].isLoading = 1 // AJAX start event - AJAX loader purposes (@since 1.9) if (!nonceRefreshed) { document.dispatchEvent( new CustomEvent('bricks/ajax/start', { detail: { queryId: queryElementId } }) ) } // Add Get lang parameter for WPML if current url has lang parameter (@since 1.9.9) if ( window.bricksData.multilangPlugin === 'wpml' && (window.location.search.includes('lang=') || window.bricksData.wpmlUrlFormat != 3) ) { // use window.bricksData.language to get the current language url = url.concat('?lang=' + window.bricksData.language) } let xhr = new XMLHttpRequest() xhr.open('POST', url, true) xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8') xhr.setRequestHeader('X-WP-Nonce', window.bricksData.wpRestNonce) // Successful response xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { let status = xhr.status let res try { res = JSON.parse(xhr.response) } catch (e) { // If response is not JSON, set res to null res = null } // Success if (status === 0 || (status >= 200 && status < 400)) { let html = res?.html || false const styles = res?.styles || false // Popups HTML (@since 1.7.1) const popups = res?.popups || false const updatedQuery = res?.updated_query || false // (@since 1.12.2) if (html) { // Remove <!--brx-loop-start-QUERYID--> comment from html string or it will be double (@since 1.12.2) html = html.replace(/<!--brx-loop-start-.*?-->/g, '') el.insertAdjacentHTML('afterend', html) } if (popups) { // Add popups HTML at the end of the body (@since 1.7.1) document.body.insertAdjacentHTML('beforeend', popups) } // Emit bricks/ajax/nodes_added (@since 1.11.1), move after popups added document.dispatchEvent( new CustomEvent('bricks/ajax/nodes_added', { detail: { queryId: queryElementId } }) ) if (styles) { // Add the page styles at the end of body document.body.insertAdjacentHTML('beforeend', styles) } // (@since 1.12.2) if (updatedQuery) { // Load more action, the start should use the original start value updatedQuery.start = parseInt(queryInfo.start) bricksUtils.updateQueryResultStats(queryElementId, 'query', updatedQuery) } // Update Page on query info window.bricksData.queryLoopInstances[queryElementId].page = page // Update queryLoopInstances.maxPages if updatedQuery.max_num_pages exists, sometimes it might be changed (@since 2.1) if (updatedQuery?.max_num_pages !== undefined) { window.bricksData.queryLoopInstances[queryElementId].maxPages = parseInt( updatedQuery.max_num_pages ) } } else if (status === 403 && res?.code === 'rest_cookie_invalid_nonce' && !nonceRefreshed) { // Nonce might be invalid, try to regenerate and retry bricksRegenerateNonceAndRetryQueryLoadPage(el).then(resolve).catch(reject) // Exit early to prevent further processing return } else { console.error(`Request failed with status ${status}`) } // These actions should happen regardless of success or failure // Reset isLoading flag window.bricksData.queryLoopInstances[queryElementId].isLoading = 0 resolve({ page, maxPages }) // STEP: Show or hide "Load more" buttons (@since 1.9.4) bricksUtils.hideOrShowLoadMoreButtons(queryElementId) // Ajax end event - ajax loader purposes (@since 1.9) document.dispatchEvent( new CustomEvent('bricks/ajax/end', { detail: { queryId: queryElementId } }) ) setTimeout(() => { const selectorId = bricksUtils.getQueryTrailSelectorId(queryElementId, componentId) // Set the new query trail let newQueryTrail = Array.from( queryInfo.resultsContainer?.querySelectorAll(`.brxe-${selectorId}:not(.brx-popup)`) || [] ).pop() // Emit event document.dispatchEvent( new CustomEvent('bricks/ajax/load_page/completed', { detail: { queryTrailElement: newQueryTrail, queryId: queryElementId } }) ) if (!newQueryTrail) { return } // Is infinite scroll? if (queryInfo.infiniteScroll) { newQueryTrail.dataset.queryElementId = queryElementId // Check if the query trail is still visible, if yes, triggers the next page if (BricksIsInViewport(newQueryTrail)) { bricksQueryLoadPage(newQueryTrail) } // Add a new observer else { new BricksIntersect({ element: newQueryTrail, callback: (el) => bricksQueryLoadPage(el), once: true, rootMargin: queryInfo.observerMargin }) } } }, 250) } } /** * Infinite scroll delay the AJAX request to support "fake" delay (@since 1.12) * noDelay = true: when loadMore interaction, no delay (@since 1.12.2) * noDelay = true: when regenerating nonce, no delay (@since 1.12.2) */ if (noDelay) { xhr.send(JSON.stringify(queryData)) } else { setTimeout(() => { xhr.send(JSON.stringify(queryData)) }, parseInt(queryInfo.observerDelay)) } }) } /** * Regenerate nonce and retry query load page * * @param {*} el * @since 1.11 */ function bricksRegenerateNonceAndRetryQueryLoadPage(el) { return new Promise((resolve, reject) => { let xhrNonce = new XMLHttpRequest() xhrNonce.open('POST', window.bricksData.ajaxUrl, true) xhrNonce.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') xhrNonce.onreadystatechange = function () { if (xhrNonce.readyState === XMLHttpRequest.DONE) { if (xhrNonce.status >= 200 && xhrNonce.status < 400) { let response try { response = JSON.parse(xhrNonce.responseText) } catch (e) { reject('Invalid response from server when regenerating nonce') return } if (response.success && response.data) { window.bricksData.nonce = response.data.bricks_nonce window.bricksData.wpRestNonce = response.data.rest_nonce bricksQueryLoadPage(el, true, true).then(resolve).catch(reject) } else { reject('Failed to regenerate nonces: Invalid response structure') } } else { reject('Failed to regenerate nonce') } } } xhrNonce.send('action=bricks_regenerate_query_nonce') }) } /** * Bricks query pagination elements (AJAX) * * @since 1.5 */ const bricksQueryPaginationFn = new BricksFunction({ parentNode: document, selector: '.brx-ajax-pagination a', subscribeEvents: ['bricks/ajax/pagination/completed', 'bricks/ajax/query_result/displayed'], eachElement: (el) => { /** * Check if we should use AJAX pagination logic or Filter logic * * Must run this check inside click event as filterInstance is not available on page load. * bricksQueryPaginationFn is earlier than bricksPaginationFilterFn. * * @param {*} el * @returns boolean */ const isAjaxPagination = (el) => { let isAjax = true if ( !window.bricksData.useQueryFilter || typeof window.bricksUtils.getFiltersForQuery !== 'function' ) { return isAjax } const paginationElement = el.closest('.brx-ajax-pagination') const queryId = paginationElement?.dataset?.queryElementId || false if (queryId) { let allFilters = bricksUtils.getFiltersForQuery(queryId) // Exclude all pagination filters allFilters = allFilters.filter((filter) => { return filter.filterType !== 'pagination' }) if (allFilters.length > 0) { isAjax = false } } return isAjax } // STEP: Add event listener for normal AJAX pagination if (!el.dataset?.ajaxPagination) { el.dataset.ajaxPagination = 1 el.addEventListener('click', function (e) { const targetEl = e.currentTarget const href = targetEl.getAttribute('href') const targetPaginationEl = targetEl.closest('.brx-ajax-pagination') const queryId = targetPaginationEl?.dataset?.queryElementId // Check if we shoud use AJAX pagination logic (@since 1.10) if (!isAjaxPagination(targetPaginationEl)) { return } let clickedPageNumber = parseInt(bricksUtils.getPageNumberFromUrl(href)) // Skip, if clickedPageNumber is less than 1 if (clickedPageNumber < 1) { return } e.preventDefault() // Refactor to use bricksAjaxPagination (@since 1.12.2) bricksAjaxPagination(targetEl, queryId, clickedPageNumber) }) } } }) /** * AJAX pagination * - New logic to use load_query_page endpoint like Infinite Scroll * - Previously use GET request to clicked page URL but encountered issues like unable to retrieve the correct selectors if using components because many same selectors in same page * * @since 1.12.2 */ function bricksAjaxPagination(targetEl, queryId, clickedPageNumber, nonceRefreshed = false) { return new Promise((resolve, reject) => { if (!queryId) { reject('Query ID is missing') return } const href = targetEl.getAttribute('href') const queryInstance = window.bricksData.queryLoopInstances[queryId] || false const targetPaginationEl = targetEl.closest('.brx-ajax-pagination') const queryComponentId = queryInstance?.componentId || false const resultsContainer = queryInstance?.resultsContainer || false if (!queryInstance || !resultsContainer) { reject('Query instance not found') return } let url = window.bricksData.restApiUrl.concat('load_query_page') let queryData = { postId: window.bricksData.postId, queryElementId: queryId, componentId: queryInstance.componentId || false, queryVars: queryInstance.queryVars, page: clickedPageNumber, nonce: window.bricksData.nonce, paginationId: targetPaginationEl.dataset.paginationId, baseUrl: window.bricksData.baseUrl, lang: window.bricksData.language || false, mainQueryId: window.bricksData.mainQueryId || false, // Record the main query ID (@since 2.0) activeSearchTemplate: window.bricksData.activeSearchTemplate || false, // Current active search template ID (@since 2.2) urlParams: bricksGetCurrentUrlParams() // #86c21d4m0; @since 2.3.8 } // AJAX start event - AJAX loader purposes (@since 1.9) if (!nonceRefreshed) { document.dispatchEvent(new CustomEvent('bricks/ajax/start', { detail: { queryId } })) } // Add Get lang parameter for WPML if current url has lang parameter (@since 1.9.9) if ( window.bricksData.multilangPlugin === 'wpml' && (window.location.search.includes('lang=') || window.bricksData.wpmlUrlFormat != 3) ) { // use window.bricksData.language to get the current language url = url.concat('?lang=' + window.bricksData.language) } let xhr = new XMLHttpRequest() xhr.open('POST', url, true) xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8') xhr.setRequestHeader('X-WP-Nonce', window.bricksData.wpRestNonce) xhr.onreadystatechange = function () { if (this.readyState === XMLHttpRequest.DONE) { let status = this.status let res try { res = JSON.parse(xhr.response) } catch (e) { // If response is not JSON, set res to null res = null } // Success if (status === 0 || (status >= 200 && status < 400)) { const html = res?.html || false const styles = res?.styles || false const popups = res?.popups || false const pagination = res?.pagination || false const updatedQuery = res?.updated_query || false // selectorId to be used for looping elements, use queryComponentId if available (@since 1.12.2) const selectorId = bricksUtils.getQueryTrailSelectorId(queryId, queryComponentId) // Keep a copy of the bricks-gutter-sizer from the results container (posts element) const gutterSizer = resultsContainer.querySelector('.bricks-gutter-sizer') const isotopSizer = resultsContainer.querySelector('.bricks-isotope-sizer') const actualLoopDOM = resultsContainer.querySelectorAll( `.brxe-${selectorId}, .bricks-posts-nothing-found` ) // Find the HTML comment <!--brx-loop-start-QUERYID--> const loopStartComment = document .createNodeIterator(resultsContainer, NodeFilter.SHOW_COMMENT, { acceptNode: function (node) { return node.nodeValue === `brx-loop-start-${queryId}` } }) .nextNode() const hasOldResults = actualLoopDOM.length > 0 || loopStartComment if (hasOldResults) { /** * - Remove all brxe-<targetQueryId> nodes, and .bricks-posts-nothing-found div from the results container */ resultsContainer .querySelectorAll(`.brxe-${selectorId}, .bricks-posts-nothing-found`) .forEach((el) => el.remove()) } if (html) { // Replace the old page elements // Add new HTML inside the queryParentElement if (hasOldResults) { // Find the HTML comment <!--brx-loop-start-QUERYID--> and insert the HTML string right after it if (loopStartComment) { // Check if the first HTML tag is a <td> or <tr> const firstTag = html.match(/<\s*([a-z0-9]+)([^>]+)?>/i) let tempDiv = null // Special case for <td> and <tr> tags if (firstTag && (firstTag[1] === 'td' || firstTag[1] === 'tr')) { tempDiv = document.createElement('tbody') } else { tempDiv = document.createElement('div') } // Insert the HTML string inside the temp div (Browser will parse the HTML string to DOM nodes and auto correct any invalid HTML tags) tempDiv.innerHTML = html // Get the child nodes of the temp div let newNodes = Array.from(tempDiv.childNodes) //reverse the array to insert the nodes in the correct order newNodes.reverse() newNodes.forEach((node) => { if (loopStartComment.nextSibling) { loopStartComment.parentNode?.insertBefore(node, loopStartComment.nextSibling) } else { loopStartComment.parentNode?.appendChild(node) } }) // Remove the temp div tempDiv.remove() } } else { resultsContainer.insertAdjacentHTML('beforeend', html) } } // Restore the bricks-gutter-sizer if (gutterSizer) { resultsContainer.appendChild(gutterSizer) } // Restore the bricks-isotope-sizer if (isotopSizer) { resultsContainer.appendChild(isotopSizer) } // Remove old query looping popup elements (use queryId even if componentId is available) const oldLoopPopupNodes = document.querySelectorAll( `.brx-popup[data-popup-loop="${queryId}"]` ) oldLoopPopupNodes.forEach((el) => el.remove()) if (popups) { // Add popups HTML at the end of the body (@since 1.7.1) document.body.insertAdjacentHTML('beforeend', popups) } // Update pagination if available if (pagination) { const parser = new DOMParser() const doc = parser.parseFromString(pagination, 'text/html') const newPagination = doc.querySelector('.bricks-pagination') if (newPagination) { targetPaginationEl.innerHTML = '' targetPaginationEl.appendChild(newPagination) } } // Emit bricks/ajax/nodes_added (@since 1.11.1), move after popups and pagination added document.dispatchEvent( new CustomEvent('bricks/ajax/nodes_added', { detail: { queryId: queryId } }) ) if (styles) { // Create a style element if not exists let styleElement = document.querySelector(`#brx-query-styles-${queryId}`) if (!styleElement) { styleElement = document.createElement('style') styleElement.id = `brx-query-styles-${queryId}` // Add style element to footer document.body.appendChild(styleElement) } // Add styles to the style element styleElement.innerHTML = styles } if (updatedQuery) { // AJAX pagination, Update the query start value to make it compatible with infinite scroll or loadMore (@since 1.12.3) if (updatedQuery?.start !== undefined) { window.bricksData.queryLoopInstances[queryId].start = parseInt(updatedQuery.start) } // Update Query Result Stats (@since 1.12.2) bricksUtils.updateQueryResultStats(queryId, 'query', updatedQuery) } // Update Page on query info window.bricksData.queryLoopInstances[queryId].page = clickedPageNumber // Update the history window.history.pushState({}, '', href) } else if (status === 403 && res?.code === 'rest_cookie_invalid_nonce' && !nonceRefreshed) { // Nonce might be invalid, try to regenerate and retry bricksRegenerateNonceAndRetryAjaxPagination(targetEl, queryId, clickedPageNumber) .then(resolve) .catch(reject) // Exit early to prevent further processing return } else { console.error(`Request failed with status ${status}`) } resolve() // AJAX end event - AJAX loader purposes (@since 1.9) document.dispatchEvent(new CustomEvent('bricks/ajax/end', { detail: { queryId } })) // @since 1.8 - Emit event document.dispatchEvent( new CustomEvent('bricks/ajax/pagination/completed', { detail: { queryId } }) ) // STEP: Show or hide "Load more" buttons bricksUtils.hideOrShowLoadMoreButtons(queryId) } } xhr.send(JSON.stringify(queryData)) }) } function bricksRegenerateNonceAndRetryAjaxPagination(targetEl, queryId, clickedPageNumber) { return new Promise((resolve, reject) => { let xhrNonce = new XMLHttpRequest() xhrNonce.open('POST', window.bricksData.ajaxUrl, true) xhrNonce.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') xhrNonce.onreadystatechange = function () { if (xhrNonce.readyState === XMLHttpRequest.DONE) { if (xhrNonce.status >= 200 && xhrNonce.status < 400) { let response try { response = JSON.parse(xhrNonce.responseText) } catch (e) { reject('Invalid response from server when regenerating nonce') return } if (response.success && response.data) { window.bricksData.nonce = response.data.bricks_nonce window.bricksData.wpRestNonce = response.data.rest_nonce bricksAjaxPagination(targetEl, queryId, clickedPageNumber, true) .then(resolve) .catch(reject) } else { reject('Failed to regenerate nonces: Invalid response structure') } } else { reject('Failed to regenerate nonce') } } } xhrNonce.send('action=bricks_regenerate_query_nonce') }) } function bricksQueryPagination() { bricksQueryPaginationFn.run() } function bricksStickyHeader() { let stickyHeaderEl = document.querySelector('#brx-header.brx-sticky') if (!stickyHeaderEl) { return } let logo = document.querySelector('.bricks-site-logo') let logoDefault let logoInverse let lastScrolled = -1 // -1 to make sure that the first time bricksStickyHeaderOnScroll() runs it doesn't slide up let headerSlideUpAfter = stickyHeaderEl.hasAttribute('data-slide-up-after') ? stickyHeaderEl.getAttribute('data-slide-up-after') : 0 if (logo) { logoDefault = logo.getAttribute('data-bricks-logo') logoInverse = logo.getAttribute('data-bricks-logo-inverse') } const bricksStickyHeaderOnScroll = () => { // Return: body has .no-scroll class (@since 1.11) if (document.body.classList.contains('no-scroll')) { return } let scrolled = window.scrollY // @since 1.11 (use scrollY instead of depricated pageYOffset) if (scrolled > 0) { stickyHeaderEl.classList.add('scrolling') if (logo && logoInverse && logo.src !== logoInverse) { logo.src = logoInverse logo.srcset = '' } } else { stickyHeaderEl.classList.remove('scrolling') if (logo && logoDefault && logo.src !== logoDefault) { logo.src = logoDefault } } /** * Slide up * * @since 1.9 - Add .sliding to prevent horizontal scrollbar (e.g. Offcanvas mini cart in header) */ if (headerSlideUpAfter && !document.querySelector('.bricks-search-overlay.show')) { if (scrolled > lastScrolled && lastScrolled >= 0) { // Scolling down if (scrolled > headerSlideUpAfter) { // If it's not .slide-up and we slide down, add .sliding class to prevent horizontal scrollbar (@since 1.11) if (!stickyHeaderEl.classList.contains('slide-up')) { stickyHeaderEl.classList.add('sliding') } stickyHeaderEl.classList.add('slide-up') } } else { // If it's .slide-up and we slide up, add .sliding class to prevent horizontal scrollbar (@since 1.11) if (stickyHeaderEl.classList.contains('slide-up')) { stickyHeaderEl.classList.add('sliding') } // Scrolling up stickyHeaderEl.classList.remove('slide-up') } } lastScrolled = scrolled } // Transform completed: Remove .sliding class (@since 1.11) stickyHeaderEl.addEventListener('transitionend', (e) => { if (e.propertyName === 'transform') { stickyHeaderEl.classList.remove('sliding') } }) // Set sticky header logo inverse & slide up window.addEventListener('scroll', bricksStickyHeaderOnScroll) // Run it once on page load to set the .scrolling class if page is aready scrolled down bricksStickyHeaderOnScroll() } /** * Frontend: One Page Navigation (in builder via dynamic Vue component) */ function bricksOnePageNavigation() { let onePageNavigationWrapper = document.getElementById('bricks-one-page-navigation') if (!bricksIsFrontend || !onePageNavigationWrapper) { return } let rootElements = bricksQuerySelectorAll(document, '#brx-content > *') let elementIds = [] let elementId = '' let onePageLink = '' let onePageItem = '' if (!rootElements) { return } rootElements.forEach((element) => { elementId = element.getAttribute('id') if (!elementId) { return } elementIds.push(elementId) onePageItem = document.createElement('li') onePageLink = document.createElement('a') onePageLink.classList.add(`bricks-one-page-${elementId}`) onePageLink.setAttribute('href', `#${elementId}`) onePageItem.appendChild(onePageLink) onePageNavigationWrapper.appendChild(onePageItem) }) function onePageScroll() { let scrolled = window.scrollY elementIds.forEach((elementId) => { let element = document.getElementById(elementId) let elementTop = element.offsetTop let elementBottom = elementTop + element.offsetHeight if (scrolled >= elementTop - 1 && scrolled < elementBottom - 1) { document.querySelector(`.bricks-one-page-${elementId}`).classList.add('active') } else { document.querySelector(`.bricks-one-page-${elementId}`).classList.remove('active') } }) } // Add load, resize, scroll event listeners window.addEventListener('load', onePageScroll) window.addEventListener('resize', onePageScroll) document.addEventListener('scroll', onePageScroll) } /** * Search element: Toggle overlay search */ function bricksSearchToggle() { let searchElements = bricksQuerySelectorAll(document, '.brxe-search') searchElements.forEach((searchElement) => { let toggle = searchElement.querySelector('.toggle') let overlay = searchElement.querySelector('.bricks-search-overlay') if (!toggle || !overlay) { return } let searchInputOrIcon = overlay.previousElementSibling document.addEventListener('keyup', (e) => { if (e.key === 'Escape') { // Close search overlay on ESC key if visible (offsetParent not working on fixed positioned node) let overlayStyles = window.getComputedStyle(overlay) if (overlayStyles.visibility === 'visible') { overlay.classList.remove('show') searchInputOrIcon.focus() searchInputOrIcon.setAttribute('aria-expanded', false) } } }) toggle.addEventListener('click', () => { overlay.classList.toggle('show') toggle.setAttribute('aria-expanded', toggle.getAttribute('aria-expanded') === 'false') setTimeout(() => { searchElement.querySelector('input[type=search]').focus() }, 200) }) overlay.querySelector('.close').addEventListener('click', () => { overlay.classList.remove('show') searchInputOrIcon.focus() searchInputOrIcon.setAttribute('aria-expanded', false) }) }) } /** * Dismiss alert element */ const bricksAlertDismissFn = new BricksFunction({ parentNode: document, selector: '.brxe-alert svg', eachElement: (dismissable) => { dismissable.addEventListener('click', () => { let alertEl = dismissable.closest('.brxe-alert') alertEl.remove() }) } }) function bricksAlertDismiss() { bricksAlertDismissFn.run() } /** * Element: Tabs */ const bricksTabsFn = new BricksFunction({ parentNode: document, selector: '.brxe-tabs, .brxe-tabs-nested', forceReinit: (element, index) => { // Builder: Force reinit for adding new tab title/pane in the builder return !bricksIsFrontend }, eachElement: (tabElement) => { let hash = window.location.hash const openTabsOnEvent = tabElement.getAttribute('data-open-on') || 'click' // Get first .tab-title & all it's siblings (allows for custom tab title element structure) let firstTitle = tabElement.querySelector('.tab-title') let titles = firstTitle ? Array.from(firstTitle.parentNode.children).filter((el) => el.classList.contains('tab-title') ) : [] // Get first .tab-pane & all it's siblings (allows for custom tab pane element structure) let firstPane = tabElement.querySelector('.tab-pane') let panes = firstPane ? Array.from(firstPane.parentNode.children).filter((el) => el.classList.contains('tab-pane')) : [] if (!titles.length || !panes.length) { return } titles.forEach((title, index) => { // Create tab title click listener title.addEventListener(openTabsOnEvent, () => { let activeTitle = null let activePane = null titles.forEach((t, i) => { // Add .brx-open to tab title if (i === index) { t.classList.add('brx-open') t.setAttribute('aria-selected', 'true') t.setAttribute('tabindex', '0') activeTitle = title } // Remove .brx-open from other title else { // Remove .brx-open from other title and set ARIA attributes t.classList.remove('brx-open') t.setAttribute('aria-selected', 'false') t.setAttribute('tabindex', '-1') } }) panes.forEach((pane, i) => { // Add .brx-open to tab content if (i === index) { pane.classList.add('brx-open') activePane = pane } // Remove .brx-open from other content else { pane.classList.remove('brx-open') } }) /** * Add or remove URL hash * * @since 1.9.3: use replaceState to prevent browser from storing history with each accordion item click * * @since 1.8.6 */ let anchorId = title?.id && !title.id.startsWith('brxe-') && !title.id.startsWith('brx-') ? `#${title.id}` : '' if (anchorId) { history.replaceState(null, null, anchorId) } else { history.replaceState(null, null, ' ') } // Trigger custom event "bricks/tabs/changed" (#86bwtqc4j) document.dispatchEvent( new CustomEvent('bricks/tabs/changed', { detail: { elementId: tabElement.getAttribute('data-script-id'), activeIndex: index, activeTitle, activePane } }) ) }) // Keydown listener for arrow keys (@since 1.11) title.addEventListener('keydown', (event) => { let newIndex if (event.key === 'ArrowRight') { newIndex = index + 1 === titles.length ? 0 : index + 1 } else if (event.key === 'ArrowLeft') { newIndex = index - 1 < 0 ? titles.length - 1 : index - 1 } else if (event.key === 'Home') { event.preventDefault() // Prevent page scroll newIndex = 0 } else if (event.key === 'End') { event.preventDefault() // Prevent page scroll newIndex = titles.length - 1 } else { return } titles[newIndex].focus() titles[newIndex].click() }) }) // STEP: Open tab title and content based on URL hash or fallback to first tab let activeIndex = tabElement.getAttribute('data-open-tab') || 0 // URL hash found if (hash) { // Get the index of the tab title that matches the hash let tempIndex = titles.findIndex( (title) => title?.id && !title.id.startsWith('brxe-') && title.id === hash.replace('#', '') ) activeIndex = tempIndex !== -1 ? tempIndex : 0 } // Set title to open (fallback: first item) if (titles[activeIndex]) { titles[activeIndex].classList.add('brx-open') } else { titles[0].classList.add('brx-open') } // Set content to open (fallback: first item) if (panes[activeIndex]) { panes[activeIndex].classList.add('brx-open') } else { panes[0].classList.add('brx-open') } // Set initial aria attributes after brx-open class is added (@since 1.11.1) titles.forEach((title) => { if (title.classList.contains('brx-open')) { title.setAttribute('aria-selected', 'true') title.setAttribute('tabindex', '0') } else { title.setAttribute('aria-selected', 'false') title.setAttribute('tabindex', '-1') } }) // Accordion layout on mobile (@since 2.1) bricksTabsAccordionLayoutOnMobile() } }) /** * Tabs: Accordion layout on mobile * * @since 2.1 */ function bricksTabsAccordionLayoutOnMobile() { function attachEventsToTitles(titles, panes) { titles.forEach((title, index) => { // Create tab title click listener title.addEventListener('click', () => { titles.forEach((t, i) => { // Add .brx-open to tab title if (i === index) { title.classList.add('brx-open') // Update aria-expanded for accordion mode if (title.classList.contains('accordion-title')) { t.setAttribute('aria-expanded', 'true') } } // Remove .brx-open from other title else { t.classList.remove('brx-open') // Update aria-expanded for accordion mode if (t.classList.contains('accordion-title')) { t.setAttribute('aria-expanded', 'false') } } }) panes.forEach((pane, i) => { // Add .brx-open to tab content if (i === index) { pane.classList.add('brx-open') } // Remove .brx-open from other conten else { pane.classList.remove('brx-open') } }) // Add or remove URL hash (@since 1.8.6) let anchorId = title?.id && !title.id.startsWith('brxe-') ? `#${title.id}` : '' if (anchorId) { history.pushState('', document.title, window.location.pathname + anchorId) } else { history.pushState('', document.title, window.location.pathname + window.location.search) } }) // Add keyboard open on click support for accordion mode if (title.classList.contains('accordion-title')) { title.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() // Use the same logic as click - close others, open current titles.forEach((t, i) => { if (i === index) { t.classList.add('brx-open') t.setAttribute('aria-expanded', 'true') } else { t.classList.remove('brx-open') t.setAttribute('aria-expanded', 'false') } }) panes.forEach((pane, i) => { if (i === index) { pane.classList.add('brx-open') } else { pane.classList.remove('brx-open') } }) } }) } }) } function detachEventsFromTitles(titles) { titles.forEach((title) => { // Remove all listeners by cloning the node const newTitle = title.cloneNode(true) title.parentNode.replaceChild(newTitle, title) }) } const tabElements = bricksQuerySelectorAll(document, '.brxe-tabs, .brxe-tabs-nested') tabElements.forEach((tabElement) => { const breakpoint = parseInt(tabElement.getAttribute('data-accordion-breakpoint'), 10) const tabMenu = tabElement.querySelector('.tab-menu') // If accordion breakpoint is set and viewport is smaller than the breakpoint if (breakpoint && window.innerWidth < breakpoint) { // STEP: Mode: "Accordion" // Hide the entire tab menu if (tabMenu) { tabMenu.style.display = 'none' } tabElement .querySelectorAll('.tab-title:not([data-brx-tab-mode="accordion"])') .forEach((el, index) => { const accordionTitle = tabElement.querySelectorAll('[data-brx-tab-mode="accordion"]')[ index ] // Check if title has been transferred if (!el.hasAttribute('data-transferred')) { const titleSpan = el.querySelector('span') if (titleSpan) { accordionTitle.insertBefore(titleSpan.cloneNode(true), accordionTitle.firstChild) titleSpan.remove() // Set .brx-open if (el.classList.contains('brx-open')) { accordionTitle.classList.add('brx-open') } else { accordionTitle.classList.remove('brx-open') } } el.setAttribute('data-transferred', 'true') } }) tabElement .querySelectorAll('[data-brx-tab-mode="accordion"]') .forEach((el) => (el.style.display = 'block')) // Remove tabindex from tab panels in accordion mode tabElement.querySelectorAll('.tab-pane').forEach((pane) => { pane.removeAttribute('tabindex') }) // Attach events to accordion titles const accordionTitles = tabElement.querySelectorAll('[data-brx-tab-mode="accordion"]') const firstPane = tabElement.querySelector('.tab-pane') const panes = firstPane ? Array.from(firstPane.parentNode.children).filter((el) => el.classList.contains('tab-pane') ) : [] attachEventsToTitles(accordionTitles, panes) } else { // STEP: Mode "Tabs" // Revert the tab menu to its original display value if (tabMenu) { tabMenu.style.display = '' } tabElement.querySelectorAll('[data-brx-tab-mode="accordion"]').forEach((el, index) => { const tabTitle = tabElement.querySelectorAll( '.tab-title:not([data-brx-tab-mode="accordion"])' )[index] // Check if title needs to be reverted if (tabTitle.hasAttribute('data-transferred')) { const titleSpan = el.querySelector('span') if (titleSpan) { tabTitle.insertBefore(titleSpan.cloneNode(true), tabTitle.firstChild) titleSpan.remove() // Set .brx-open if (el.classList.contains('brx-open')) { tabTitle.classList.add('brx-open') } else { tabTitle.classList.remove('brx-open') } } tabTitle.removeAttribute('data-transferred') } tabTitle.style.display = 'block' }) tabElement .querySelectorAll('[data-brx-tab-mode="accordion"]') .forEach((el) => (el.style.display = 'none')) // Restore tabindex to tab panels in tabs mode (e.g. after window resize from accordion mode to tabs mode) tabElement.querySelectorAll('.tab-pane').forEach((pane) => { pane.setAttribute('tabindex', '0') }) // Detach events from accordion titles only const accordionTitles = tabElement.querySelectorAll('[data-brx-tab-mode="accordion"]') detachEventsFromTitles(accordionTitles) } }) } function bricksTabs() { bricksTabsFn.run() } /** * Element - Video: Play video on overlay, icon click or thumbnail preview click */ const bricksVideoOverlayClickDetectorFn = new BricksFunction({ parentNode: document, selector: '.bricks-video-overlay, .bricks-video-overlay-icon, .bricks-video-preview-image', frontEndOnly: true, eachElement: (overlay) => { const onOverlayAction = (e) => { let videoWrapper = e.target.closest('.brxe-video') if (!videoWrapper) { return } // STEP: Convert thumbnail preview into iframe // Get thumbnail preview element const thumbnailPreviewElement = videoWrapper.querySelector('.bricks-video-preview-image') if (thumbnailPreviewElement) { // Convert thumbnail preview into iframe together with all attributes (youtube/vimeo) const iframeElement = document.createElement('iframe') const attributes = [...thumbnailPreviewElement.attributes] attributes.forEach((attr) => { // Skip the class attribute and style attribute if (attr.name === 'class' || attr.name === 'style') { return } // Change the data-src attribute to src if (attr.name === 'data-iframe-src') { iframeElement.setAttribute('src', attr.value) return } // Add all other attributes to the iframe element iframeElement.setAttribute(attr.name, attr.value) }) iframeElement.dataset.bricksVideoPreviewIframe = 'true' thumbnailPreviewElement.replaceWith(iframeElement) } // STEP: Start iframe/video // Get iframe element (video type: YouTube, Vimeo) const iframeElement = videoWrapper.querySelector('iframe') if (iframeElement && iframeElement.getAttribute('src')) { iframeElement.src += '&autoplay=1' } // If there is iframe, remove tabindex to allow keyboard navigation (@since 1.11) if (iframeElement) { iframeElement.removeAttribute('tabindex') } // Get <video> element (video type: media, file URL) const videoElement = videoWrapper.querySelector('video') if (videoElement) { videoElement.play() // remove tabindex to allow keyboard navigation (@since 1.11) videoElement.removeAttribute('tabindex') // Focus on the video element in next tick to prevent the browser from scrolling to the video element (@since 1.11) // This is to ensure that tabindex is removed before focusing on the video element setTimeout(() => { videoElement.focus() }, 0) } } overlay.addEventListener('click', onOverlayAction) // Add keydown event listener for accessibility (Space key and Enter key) (@since 1.11) overlay.addEventListener('keydown', (event) => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault() onOverlayAction(event) } }) } }) function bricksVideoOverlayClickDetector() { bricksVideoOverlayClickDetectorFn.run() } /** * Background video (supported: YouTube and file URLs) * Also allows Vimeo or YouTube video ID, instead of full link (@since 1.12.2) */ const bricksBackgroundVideoInitFn = new BricksFunction({ parentNode: document, selector: '.bricks-background-video-wrapper', forceReinit: (element, index) => { // Builder: Force reinit as the URL parameter is not yet set (@since 1.8) return !bricksIsFrontend }, eachElement: (videoWrapper) => { if (videoWrapper.classList.contains('loaded')) { return } let videoId let videoUrl = videoWrapper.getAttribute('data-background-video-url') // STEP: Convert Vimeo or YouTube video ID into full URL (@since 1.12.2) if (videoUrl && !videoUrl.includes('http')) { // Patterns const youtubeIdPattern = /^[a-zA-Z0-9_-]{11}$/ const vimeoIdPattern = /^[0-9]{6,10}$/ // YouTube ID if (youtubeIdPattern.test(videoUrl)) { videoUrl = `https://www.youtube.com/watch?v=${videoUrl}` } // Vimeo ID else if (vimeoIdPattern.test(videoUrl)) { videoUrl = `https://vimeo.com/${videoUrl}` } } // Return: No videoUrl provided if (!videoUrl) { return } /** * STEP: Start playing video on breakpoint and up * * Setting 'videoPlayBreakpoint' stored in data attribute 'data-background-video-show-at-breakpoint'. * * @since 1.8.5 */ let videoPlayBreakpoint = parseInt( videoWrapper.getAttribute('data-background-video-show-at-breakpoint') ) // Return: Viewport width is smaller than breakpoint width if (videoPlayBreakpoint && window.innerWidth < videoPlayBreakpoint) { return } let videoScale = videoWrapper.getAttribute('data-background-video-scale') let videoAspectRatio = videoWrapper.getAttribute('data-background-video-ratio') || '16:9' let videoAspectRatioX = parseInt(videoAspectRatio.split(':')[0] || 16) let videoAspectRatioY = parseInt(videoAspectRatio.split(':')[1] || 9) let startTime = parseInt(videoWrapper.getAttribute('data-background-video-start')) || 0 let endTime = parseInt(videoWrapper.getAttribute('data-background-video-end')) || 0 let videoLoop = videoWrapper.getAttribute('data-background-video-loop') == 1 // Poster settings (@since 1.11) const posterImageCustom = videoWrapper.getAttribute('data-background-video-poster') const posterImageYouTubeSize = videoWrapper.getAttribute('data-background-video-poster-yt-size') // End time must be greater than start time: If not, don't use it if (endTime < startTime) { endTime = 0 } let isIframe = false // YouTube and Vimeo iframe embed let isYoutube = false let isVimeo = false /** * YouTube embed * * NOTE: Error "Failed to execute 'postMessage' on 'DOMWindow'" when origin is not HTTPS * * Adding 'host' or 'origin' does not fix this error. * * @since 1.12.3: Supports short YouTube url (e.g. https://youtu.be/VIDEO_ID) * @since 2.0: Support Youtube shorts and live videos and a change to the way video ID is extracted, now using a regex pattern */ if (videoUrl.indexOf('youtube.com') !== -1 || videoUrl.indexOf('youtu.be') !== -1) { isIframe = true isYoutube = true const videoData = bricksGetYouTubeVideoLinkData(videoUrl) videoId = videoData.id videoUrl = videoData.url } /** * Vimeo embed * * @since 2.0: VideoUrl should not include '/progressive_redirect/' as that is direct link to file * * https://help.vimeo.com/hc/en-us/articles/360001494447-Using-Player-Parameters */ if (videoUrl.indexOf('vimeo.com') !== -1 && videoUrl.indexOf('/progressive_redirect/') === -1) { isIframe = true isVimeo = true // Transform Vimeo video URL into valid embed URL if (videoUrl.indexOf('player.vimeo.com/video') === -1) { videoUrl = videoUrl.replace('vimeo.com', 'player.vimeo.com/video') } } // Returns poster image element (@since 1.11) const getPosterImageElement = () => { const posterImage = document.createElement('img') posterImage.classList.add('bricks-video-poster-image') let hasPosterImage = false // YouTube support custom image or auto image if (isYoutube) { // First we try to set default image if (posterImageYouTubeSize) { posterImage.src = `https://img.youtube.com/vi/${videoId}/${posterImageYouTubeSize}.jpg` hasPosterImage = true } // If auto image is not set, we try custom image else if (posterImageCustom) { posterImage.src = posterImageCustom hasPosterImage = true } } // Vimeo support custom image only if (isVimeo && posterImageCustom) { posterImage.src = posterImageCustom hasPosterImage = true } // Return false if no poster image is set if (!hasPosterImage) { return false } return posterImage } const removePosterImageElement = () => { const posterImageElement = videoWrapper.querySelector('.bricks-video-poster-image') if (posterImageElement) { posterImageElement.remove() } } let videoElement // STEP: YouTuvbe and Vimeo <iframe> embed if (isIframe) { if (isYoutube) { // Check if YouTube API script is already added if (!document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) { // Create script tag for YouTube IFrame API let tag = document.createElement('script') // Builder: Compatible with Cloudflare Rocket Loader (@since 2.0) if (!bricksIsFrontend && window.bricksData?.builderCloudflareRocketLoader) { tag.setAttribute('data-cfasync', 'false') } // Set source to YouTube IFrame API URL tag.src = 'https://www.youtube.com/iframe_api' // Find the first script tag on your page let firstScriptTag = document.getElementsByTagName('script')[0] // Insert new script tag before the first script tag firstScriptTag.parentNode.insertBefore(tag, firstScriptTag) } videoElement = document.createElement('div') // Remove <video> element (present in the DOM due to Chrome compatibility) if (bricksIsFrontend && videoWrapper.querySelector('video')) { videoWrapper.removeChild(videoWrapper.querySelector('video')) } // Append videoElement to the videoWrapper before initializing the player videoWrapper.appendChild(videoElement) // append image to videoWrapper, if exists (@since 1.11) const posterImageElement = getPosterImageElement() if (posterImageElement) { videoWrapper.appendChild(posterImageElement) } // Wait for YouTube IFrame Player API to load let playerCheckInterval = setInterval(function () { if (window.YT && YT.Player) { clearInterval(playerCheckInterval) let player = new YT.Player(videoElement, { width: '640', height: '360', videoId: videoId, playerVars: { autoplay: 1, controls: 0, start: startTime || undefined, // end: endTime || undefined, // Check endTime manually below to pause video instead of stopping it mute: 1, rel: 0, showinfo: 0, modestbranding: 1, cc_load_policy: 0, iv_load_policy: 3, autohide: 0, loop: 0, // Handle loop manually below according to startTime & endTime playlist: videoId, enablejsapi: 1 }, events: { onReady: function (event) { // Check every second if video endTime is reached if (endTime) { let endTimeCheckInterval = setInterval(function () { if (player.getCurrentTime() >= endTime) { // Loop or pause video if (videoLoop) { player.seekTo(startTime || 0, true) } else { player.pauseVideo() clearInterval(endTimeCheckInterval) } } }, 1000) } }, onStateChange: function (event) { if (videoLoop) { // Video ended naturally: Restart at start time if (event.data == YT.PlayerState.ENDED) { player.seekTo(startTime || 0, true) } } if (event.data == YT.PlayerState.PLAYING) { // Remove poster image (@since 1.11) removePosterImageElement() } } } }) } }, 100) } if (isVimeo) { // Check if Vimeo Player API script is already added if (!document.querySelector('script[src="https://player.vimeo.com/api/player.js"]')) { // STEP: Create script tag for Vimeo Player API let tag = document.createElement('script') // Builder: Compatible with Cloudflare Rocket Loader (@since 2.0) if (!bricksIsFrontend && window.bricksData?.builderCloudflareRocketLoader) { tag.setAttribute('data-cfasync', 'false') } // Set source to Vimeo Player API URL tag.src = 'https://player.vimeo.com/api/player.js' // Find the first script tag on page let firstScriptTag = document.getElementsByTagName('script')[0] // Insert new script tag before the first script tag firstScriptTag.parentNode.insertBefore(tag, firstScriptTag) } // Remove <video> element (present in the DOM due to Chrome compatibility) if (bricksIsFrontend && videoWrapper.querySelector('video')) { videoWrapper.removeChild(videoWrapper.querySelector('video')) } // Create a div for the Vimeo player videoElement = document.createElement('div') // append image to videoWrapper, if exists (@since 1.11) const posterImageElement = getPosterImageElement() if (posterImageElement) { videoWrapper.appendChild(posterImageElement) } // Append videoElement to the videoWrapper before initializing the player videoWrapper.appendChild(videoElement) // Extract Vimeo video ID const vimeoVideoId = videoUrl.split('/').pop() // Wait for Vimeo Player API to load let playerCheckInterval = setInterval(function () { if (window.Vimeo && Vimeo.Player) { clearInterval(playerCheckInterval) // STEP: Initialize new Vimeo Player let player = new Vimeo.Player(videoElement, { id: vimeoVideoId, width: 640, autoplay: true, controls: false, background: true, loop: videoLoop && !startTime // Handle loop manually if startTime set (as loop set to true always starts video at 0 sec) }) if (posterImageElement) { player.on('play', function () { // Remove poster image(@since 1.11) removePosterImageElement() }) } // Player is loaded: Start the video at the startTime if (startTime) { player.on('loaded', function () { player.setCurrentTime(startTime) }) } // EndTime reached: Pause or loop video if (endTime) { player.on('timeupdate', function (data) { if (data.seconds >= endTime) { if (videoLoop) { player.setCurrentTime(startTime || 0) player.play() } else { player.pause() } } }) } // End of video reached player.on('ended', () => { // Restart video at startTime if (videoLoop) { player.setCurrentTime(startTime || 0).then(function (seconds) { player.play() }) } }) } }, 100) } } // STEP: Get the <video> element (present in the DOM due to Chrome compatibility) else { videoElement = videoWrapper.querySelector('video') if (videoElement) { let elementId = videoElement.closest('[data-script-id]')?.getAttribute('data-script-id') // Play once: Remove 'loop' attribute if (!videoLoop) { videoElement.removeAttribute('loop') } else if (!videoElement.hasAttribute('loop')) { videoElement.setAttribute('loop', '') } // Re-init startTime in builder if (!bricksIsFrontend) { videoElement.currentTime = startTime || 0 } if (!window.bricksData.videoInstances?.[elementId]) { window.bricksData.videoInstances[elementId] = {} } // Store on window to update in builder :) window.bricksData.videoInstances[elementId].startTime = startTime window.bricksData.videoInstances[elementId].endTime = endTime window.bricksData.videoInstances[elementId].videoLoop = videoLoop // Set custom start time & play video let loadedmetadata = function () { if (window.bricksData.videoInstances[elementId].startTime) { this.currentTime = window.bricksData.videoInstances[elementId].startTime this.play() } } // Current playback time is greater than or equal to end time OR video duration let timeupdate = function () { // NOTE: media controller position changes ever 15 to 250ms (we use 250ms) if ( this.currentTime >= (window.bricksData.videoInstances[elementId].endTime || this.duration) - 0.25 ) { // Loop disabled: Pause video if (window.bricksData.videoInstances[elementId].videoLoop) { // Reset to start time this.currentTime = window.bricksData.videoInstances[elementId].startTime // Video is not playing: Play it if (videoElement.paused) { this.play() } } else { this.pause() } } } // Set custom start time & play video let ended = function () { if ( window.bricksData.videoInstances[elementId].videoLoop && window.bricksData.videoInstances[elementId].startTime ) { this.currentTime = window.bricksData.videoInstances[elementId].startTime this.play() } } /** * Custom start and/or end time set * * Add event listeners to the video element (if not already added; check via .listening class) */ if (!videoElement.classList.contains('listening') && (startTime || endTime)) { videoElement.classList.add('listening') videoElement.addEventListener('loadedmetadata', loadedmetadata) videoElement.addEventListener('timeupdate', timeupdate) videoElement.addEventListener('ended', ended) } } } if (videoScale) { videoElement.style.transform = `translate(-50%, -50%) scale(${videoScale})` } // STEP: Lazy load video (frontend only) if (bricksIsFrontend) { if (videoWrapper.classList.contains('bricks-lazy-video')) { new BricksIntersect({ element: videoWrapper, callback: (el) => { el.classList.remove('bricks-lazy-video') if (isIframe) { el.appendChild(videoElement) } else { videoElement.src = videoUrl } } }) } } else { if (isIframe) { videoWrapper.appendChild(videoElement) } else { videoElement.src = videoUrl } } videoWrapper.classList.add('loaded') let resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { let videoWidth if (entry.contentBoxSize) { // Firefox implements `contentBoxSize` as a single content rect, rather than an array let contentBoxSize = Array.isArray(entry.contentBoxSize) ? entry.contentBoxSize[0] : entry.contentBoxSize videoWidth = contentBoxSize.inlineSize } else { videoWidth = entry.contentRect.width } let elementHeight = videoWrapper.clientHeight let videoHeight = (videoWidth * videoAspectRatioY) / videoAspectRatioX if (videoHeight < elementHeight) { videoHeight = elementHeight videoWidth = (elementHeight * videoAspectRatioX) / videoAspectRatioY } videoElement.style.width = `${videoWidth}px` videoElement.style.height = `${videoHeight}px` } }) resizeObserver.observe(videoWrapper) } }) function bricksBackgroundVideoInit() { bricksBackgroundVideoInitFn.run() } /** * Photoswipe 5 lightbox * * For accessibility reasons the <a> is required by default: https://photoswipe.com/getting-started/#required-html-markup * If you want to use different markup there is a domItemData filter: https://photoswipe.com/data-sources/#custom-html-markup * * @since 1.8 */ const bricksPhotoswipeFn = new BricksFunction({ parentNode: document, selector: '.bricks-lightbox', windowVariableCheck: ['PhotoSwipeLightbox'], // PhotoSwipe captures grouped children on init, so same-ID groups need fresh listeners after AJAX. forceReinit: (lightboxElement) => { return !!bricksUtils.photoswipeGetId(lightboxElement) }, eachElement: (lightboxElement) => { bricksUtils.photoswipeDestroy(lightboxElement) let gallery = lightboxElement let children = lightboxElement.tagName === 'A' ? '' : 'a' let lightboxId = bricksUtils.photoswipeGetId(lightboxElement) // Also supports Image Gallery // Can be set to 'none' to avoid jumpy animation between different aspect ratios (@since 1.8.4) let animationType = lightboxElement.getAttribute('data-animation-type') || 'zoom' // Get all lightbox elements with the same ID (@since 1.7.2) if (lightboxId) { children = bricksQuerySelectorAll(document, `[data-pswp-id="${lightboxId}"]`) } // https://photoswipe.com/styling/ let closeSVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' let options = { mainClass: 'brx', // To distinguish from Photoswipe 4 (used on single product page by Woo core) gallery: gallery, counter: !gallery.classList.contains('brxe-carousel'), // Hide wrong carousel count for carousel loop (due to swiperJS-generated slide duplicates) children: children, pswpModule: PhotoSwipe5, // NOTE: Rename class from 'PhotoSwipe' to 'PhotoSwipe5' to avoid conflict with Photoswipe 4 that WooCommerce core uses! closeSVG: closeSVG, showHideAnimationType: animationType } // Element, that contains thumbnail settings, in case if we have multiple galleries connected (@since 1.12) let thumbnailElement = null let paddingElement = null let captionElement = null // Get frist element with "thumbnail" settings (@since 1.12) if (lightboxId) { // check "children" elements, if they or their ancestors have thumbnails children.forEach((child) => { // Thumbnails if (child.classList.contains('has-lightbox-thumbnails')) { thumbnailElement = child // update thumbnailsElement } else { let parent = child.closest('.has-lightbox-thumbnails') if (parent) { thumbnailElement = parent // update thumbnailsElement } } // Padding if (child.getAttribute('data-lightbox-padding')) { paddingElement = child } else { let parent = child.closest('[data-lightbox-padding]') if (parent) { paddingElement = parent } } // Caption if (child.classList.contains('has-lightbox-caption')) { captionElement = child } else { let parent = child.closest('.has-lightbox-caption') if (parent) { captionElement = parent } } }) } // If we don't have lightboxId, check the current element else if (lightboxElement.classList.contains('has-lightbox-thumbnails')) { thumbnailElement = lightboxElement } // Lightbox padding (@since 1.10) // Check elements in the following order: thumbnailElement, paddingElement, lightboxElement let padding = (thumbnailElement || paddingElement || lightboxElement).getAttribute( 'data-lightbox-padding' ) if (padding) { options.padding = JSON.parse(padding) } // Image click action (@since 1.10) let imageClickAction = lightboxElement.getAttribute('data-lightbox-image-click') if (imageClickAction) { options.imageClickAction = imageClickAction } const lightbox = new PhotoSwipeLightbox(options) const hasMismatchedLightboxSource = (itemData) => { return !!itemData?.src && !!itemData?.msrc && itemData.src !== itemData.msrc } const getThumbnailNavHeight = (pswp) => { if (!pswp?.element) { return 0 } const thumbnailNavWrapper = pswp.element.querySelector('.pswp__thumbnail-nav-wrapper') const thumbnailNavOffset = parseFloat( getComputedStyle(pswp.element).getPropertyValue('--pswp-thumbnail-nav-offset') ) || 0 return thumbnailNavWrapper ? thumbnailNavWrapper.offsetHeight + thumbnailNavOffset : 0 } // Prevent a visible source swap when the clicked image differs from the lightbox source. lightbox.addFilter('placeholderSrc', (placeholderSrc, content) => { return placeholderSrc && hasMismatchedLightboxSource(content?.data) ? content.data.src : placeholderSrc }) // Crossfade back to the page thumbnail instead of revealing a final-frame source swap. lightbox.on('closingAnimationStart', () => { const currentSlide = lightbox.pswp?.currSlide const closeDuration = lightbox.pswp?.options?.hideAnimationDuration if (!hasMismatchedLightboxSource(currentSlide?.data) || !closeDuration) { return } const easing = lightbox.pswp?.options?.easing || 'cubic-bezier(.4,0,.22,1)' const fadeElements = [ currentSlide?.content?.element, currentSlide?.content?.placeholder?.element ].filter(Boolean) fadeElements.forEach((element) => { element.style.transition = `opacity ${closeDuration}ms ${easing}` element.style.opacity = '0' }) }) // Remove content placeholder if animation type is 'none' to avoid weird animation (#86bwb5vtj; @since 2.0) if (animationType === 'none') { lightbox.addFilter('useContentPlaceholder', (useContentPlaceholder, content) => { return false }) } /** * Lightbox caption * * Carousel, Image, Image Gallery. * * https://github.com/dimsemenov/photoswipe-dynamic-caption-plugin * * @since 1.10 */ if ( typeof PhotoSwipeDynamicCaption !== 'undefined' && // Check elements in the following order: captionElement, lightboxElement (@since 1.12) (captionElement || lightboxElement).classList.contains('has-lightbox-caption') ) { let lightboxCaption = new PhotoSwipeDynamicCaption(lightbox, { type: 'below', // auto, below, aside captionContent: (slide) => { return slide.data.element.getAttribute('data-lightbox-caption') } }) lightbox.on('init', () => { lightbox.pswp.on('dynamicCaptionMeasureSize', (e) => { if (e.slide?.dynamicCaption?.type === 'aside') { return } const thumbnailNavHeight = getThumbnailNavHeight(lightbox.pswp) if (thumbnailNavHeight) { e.captionSize.y += thumbnailNavHeight } }) }) } /** * Lightbox: Thumbnail navigation * * https://photoswipe.com/adding-ui-elements/ * * @since 1.10 */ if (thumbnailElement) { // Register thumbnail navigation and add images to the DOM lightbox.on('uiRegister', function () { lightbox.pswp.ui.registerElement({ name: 'thumbnail-navigation', className: 'pswp__thumbnail-nav-wrapper', appendTo: 'wrapper', // or 'bar' onInit: (el, pswp) => { const thumbnailNav = document.createElement('div') thumbnailNav.className = 'pswp__thumbnail-nav' const thumbnailImages = [] const updateThumbnailNavHeight = () => { if (!pswp.element) { return } pswp.element.classList.add('pswp--has-thumbnail-nav') pswp.element.style.setProperty('--pswp-thumbnail-nav-height', `${el.offsetHeight}px`) } let lightboxImageNodes = [] // lightboxElement has lightbox id, get all nodes with the same lightbox id (@since 1.12) if (lightboxId) { lightboxImageNodes = bricksQuerySelectorAll( document, `[data-pswp-id="${lightboxId}"][data-pswp-src]` ) } // else: Get all nodes with 'data-pswp-src' attribute else { lightboxImageNodes = lightboxElement.querySelectorAll('a[data-pswp-src]') } let lightboxImageUrls = [] lightboxImageNodes.forEach((item) => { // Skip duplicate slides if (item.parentNode.classList.contains('swiper-slide-duplicate')) { return } let src = item.getAttribute('href') if (src && !lightboxImageUrls.includes(src)) { lightboxImageUrls.push(src) } }) // Add images to the thumbnail navigation lightboxImageUrls.forEach((url, index) => { const thumbImg = document.createElement('img') thumbImg.src = url thumbImg.dataset.index = index thumbnailImages.push(thumbImg) // Thumbnail size let thumbnailSize = thumbnailElement.getAttribute('data-lightbox-thumbnail-size') if (thumbnailSize) { thumbImg.style.width = isNaN(thumbnailSize) ? thumbnailSize : thumbnailSize + 'px' } thumbnailNav.appendChild(thumbImg) }) el.appendChild(thumbnailNav) updateThumbnailNavHeight() const thumbnailNavResizeObserver = new ResizeObserver(updateThumbnailNavHeight) thumbnailNavResizeObserver.observe(el) thumbnailImages.forEach((thumbImg) => { thumbImg.addEventListener('load', updateThumbnailNavHeight) }) pswp.on('resize', updateThumbnailNavHeight) pswp.on('destroy', () => { thumbnailNavResizeObserver.disconnect() thumbnailImages.forEach((thumbImg) => { thumbImg.removeEventListener('load', updateThumbnailNavHeight) }) }) // Go to thumbnail index thumbnailNav.addEventListener('click', (e) => { let imageIndex = parseInt(e.target.getAttribute('data-index')) if (Number.isInteger(imageIndex)) { thumbnailNav.setAttribute('data-active-index', imageIndex) // NOTE: Hack needed when loop is enabled to go to correct index if (options?.loop) { imageIndex += 2 } /** * For Carousel: Find the correct index * * Why: SwiperJS duplicates slides with 'loop' setting enabled, so first slide is not always index 0. * * @since 1.12.2 */ if (gallery.classList.contains('brxe-carousel')) { let swiperId = gallery.getAttribute('data-script-id') // Get all slides in an array let tempArr = bricksData.swiperInstances[swiperId].slides // Map the data we need .map((slide, index) => { return { slide, index, pswpIndex: Number(slide.dataset.swiperSlideIndex) } }) // Filter out all duplicates and find correct index .filter( (item) => !item.slide.classList.contains('swiper-slide-duplicate') && item.pswpIndex === imageIndex ) if (tempArr.length) { imageIndex = tempArr[0].index } } pswp.goTo(imageIndex) } }) } }) }) // Update thumbnail navigation on init && slide change lightbox.on('change', function () { const thumbnailNav = document.querySelector('.pswp__thumbnail-nav') if (thumbnailNav) { let activeImage = thumbnailNav.querySelector('.active') // Remove 'active' class from the old active thumbnail image if (activeImage) { activeImage.classList.remove('active') } // Get the active image index let currentIndex = lightbox.pswp.currSlide.data.element.dataset.pswpIndex || lightbox.pswp.currIndex // Add 'active' class to the now current thumbnail image activeImage = thumbnailNav.querySelector(`img[data-index="${currentIndex}"`) if (activeImage) { activeImage.classList.add('active') let activeImageRect = activeImage.getBoundingClientRect() let marginLeft = thumbnailNav.offsetWidth / 2 - activeImage.offsetLeft - activeImageRect.width / 2 marginLeft += (window.innerWidth - thumbnailNav.offsetWidth) / 2 thumbnailNav.style.transform = 'translateX(' + marginLeft + 'px)' } } }) } /** * Lightbox video (not supported in Photoswipe natively) * * Supported units: px, %, vw, vh * * Generate HTML for YouTube, Vimeo, and <video> embeds. * * https://photoswipe.com/data-sources/ */ lightbox.on('itemData', (e) => { let photoswipeInitialised = document.querySelector('.brx .pswp__container') // Add .brx class to avoid conflict with Woo pwsp (@since 1.12.2) let videoUrl = lightboxElement.getAttribute('data-pswp-video-url') let width = lightboxElement.getAttribute('data-pswp-width') let height = lightboxElement.getAttribute('data-pswp-height') let controls = lightboxElement.getAttribute('data-no-controls') == 1 ? 0 : 1 const muted = lightboxElement.getAttribute('data-muted') == 1 ? true : false // @since 2.1 // width in '%' or 'vh' if (width && (width.includes('%') || width.includes('vw'))) { width = window.innerWidth * (parseInt(width) / 100) } // height in '%' or 'vw' if (height && (height.includes('%') || height.includes('vh'))) { height = window.innerHeight * (parseInt(height) / 100) } // Default width: 1280px if (!width) { width = 1280 } // Auto-height (16:9) if (!height || height == 720) { height = Math.round((width / 16) * 9) } if (!photoswipeInitialised && videoUrl) { let html = bricksGetLightboxVideoNode(videoUrl, controls, muted) e.itemData = { html: html.outerHTML, // Convert DOM node to HTML string width: width, height: height } } }) // Content added to the DOM: Autoplay <video> after lightbox is opened lightbox.on('contentAppend', ({ content }) => { if (content.element) { let photoswipeVideo = content.element.querySelector('video') if (photoswipeVideo) { photoswipeVideo.play() } } }) // Fix 'loop' type carousel element requires double clicks on last slide (due to swiperJS-generated slide duplicates) if (gallery.classList.contains('brxe-carousel')) { let swiperId = gallery.getAttribute('data-script-id') // Correct the number of items as swiperJS duplicates slides with 'loop' setting enabled if (bricksData.swiperInstances?.[swiperId]?.loopedSlides) { // https://photoswipe.com/filters/#numitems lightbox.addFilter('numItems', (numItems, dataSource) => { // Lightbox has no children: Return original numItems if (dataSource.gallery) { let duplicateSlides = 0 if (dataSource.gallery.classList.contains('brxe-carousel')) { // Carousel duplicateSlides = dataSource.gallery.querySelectorAll('.swiper-slide-duplicate').length } // Something wrong if duplicateSlides more than original numItems, so return original numItems numItems = numItems > duplicateSlides ? numItems - duplicateSlides : numItems } return numItems }) // Modify 'clickedIndex' as 'numItems' has been modified lightbox.addFilter('clickedIndex', (clickedIndex, e) => { let currentSlide = e.target.closest('.swiper-slide') if (currentSlide) { // Store all slides in an array let tempArr = bricksData.swiperInstances[swiperId].slides .map((slide, index) => { return { slide, index } }) .filter(Boolean) if (tempArr.length) { // Current clicked swiper slide index from data-swiper-slide-index attribute let currentSwiperSlideIndex = parseInt(currentSlide.dataset.swiperSlideIndex) // Find first result whehre data-swiper-slide-index is equal to currentSlideIndex as numItems changed let simulateSlide = tempArr.filter( (x) => x.slide.dataset.swiperSlideIndex == currentSwiperSlideIndex ) if (simulateSlide.length) { // Get the index of the first result clickedIndex = simulateSlide[0].index } } } return clickedIndex }) } } // Store lightbox instance on the element to access it later (e.g. to destroy it before re-init) (#86c9qjvd8; @since 2.3.5) lightboxElement.bricksPhotoswipe = lightbox lightbox.init() } }) function bricksPhotoswipe() { bricksPhotoswipeFn.run() } /** * Return iframe or video DOM node for lightbox video * * @param {string} videoUrl * @param {boolean} controls @since 1.10.3 * @param {boolean} muted @since 2.1 * * @returns iframe or video DOM node * * @since 1.7.2 * @since 2.0: Change the way we parse YouTube video, to also support live and shorts videos. */ function bricksGetLightboxVideoNode(videoUrl, controls, muted) { if (videoUrl) { hasContent = true let isIframe = false // For YouTube and Vimeo embeds if (videoUrl.indexOf('youtube.com') !== -1 || videoUrl.indexOf('youtu.be') !== -1) { isIframe = true const videoData = bricksGetYouTubeVideoLinkData(videoUrl) videoUrl = videoData.url if (videoData.id) { // Add parameters (check if URL already has parameters from bricksGetYouTubeVideoLinkData) (@since 2.1.3) const separator = videoUrl.indexOf('?') !== -1 ? '&' : '?' videoUrl += separator + 'autoplay=1' videoUrl += '&rel=0' // Hide YouTube controls if (!controls) { videoUrl += '&controls=0' } // Mute video (YouTube only supports muted autoplay on iOS Safari) if (muted) { videoUrl += '&mute=1' } } } if (videoUrl.indexOf('vimeo.com') !== -1) { const isVimeoProgressive = videoUrl.indexOf('/progressive_redirect/') !== -1 // Progressive mode is a direct video file link, not an iframe embed if (isVimeoProgressive) { isIframe = false } else { isIframe = true // Transform Vimeo video URL into valid embed URL if (videoUrl.indexOf('player.vimeo.com/video') === -1) { videoUrl = videoUrl.replace('vimeo.com', 'player.vimeo.com/video') } videoUrl += '?autoplay=1' // Hide Vimeo controls if (!controls) { videoUrl += '&controls=0' } } } if (isIframe) { // Create <iframe> for YouTube/Vimeo video let iframeElement = document.createElement('iframe') iframeElement.setAttribute('src', videoUrl) iframeElement.setAttribute('allow', 'autoplay') iframeElement.setAttribute('allowfullscreen', 1) return iframeElement } // Create <video> element (trigger autoplay in Photoswipe) let videoElement = document.createElement('video') videoElement.setAttribute('src', videoUrl) if (controls) { videoElement.setAttribute('controls', 1) } videoElement.setAttribute('playsinline', 1) return videoElement } } /** * Element: Accordion */ const bricksAccordionFn = new BricksFunction({ parentNode: document, selector: '.brxe-accordion, .brxe-accordion-nested', /** * Force reinit as the items might increase/decrease inside nested accordions. * * Each item has .listening class to avoid multiple event listeners * * @since 1.9.4 */ forceReinit: true, eachElement: (accordion) => { const slideUp = (target, duration = 200) => { target.style.display = 'block' // Ensure target is visible even without brx-open class (@since 1.12) target.style.transitionProperty = 'height, margin, padding' target.style.transitionDuration = `${duration}ms` target.style.height = `${target.offsetHeight}px` target.offsetHeight target.style.overflow = 'hidden' target.style.height = 0 target.style.paddingTop = 0 target.style.paddingBottom = 0 target.style.marginTop = 0 target.style.marginBottom = 0 // Get the accordion item, target is the .accordion-content-wrapper let item = target.parentNode // Remove .brx-open class without delay (@since 1.12) item.classList.remove('brx-open') window.setTimeout(() => { target.style.display = 'none' target.style.removeProperty('height') target.style.removeProperty('padding-top') target.style.removeProperty('padding-bottom') target.style.removeProperty('margin-top') target.style.removeProperty('margin-bottom') target.style.removeProperty('overflow') target.style.removeProperty('transition-duration') target.style.removeProperty('transition-property') // Trigger custom event "bricks/accordion/close" (#86bwtqc4j) document.dispatchEvent( new CustomEvent('bricks/accordion/close', { detail: { elementId: accordion.getAttribute('data-script-id'), closeItem: item } }) ) }, duration) } const slideDown = (target, duration = 200) => { target.style.removeProperty('display') let display = window.getComputedStyle(target).display if (display === 'none') { display = 'block' } target.style.display = display let height = target.offsetHeight target.style.overflow = 'hidden' target.style.height = 0 target.style.paddingTop = 0 target.style.paddingBottom = 0 target.style.marginTop = 0 target.style.marginBottom = 0 target.offsetHeight target.style.transitionProperty = 'height, margin, padding' target.style.transitionDuration = `${duration}ms` target.style.height = `${height}px` target.style.removeProperty('padding-top') target.style.removeProperty('padding-bottom') target.style.removeProperty('margin-top') target.style.removeProperty('margin-bottom') // Get the accordion item, target is the .accordion-content-wrapper let item = target.parentNode // Add .brx-open class without delay (@since 1.12) item.classList.add('brx-open') window.setTimeout(() => { target.style.removeProperty('height') target.style.removeProperty('overflow') target.style.removeProperty('transition-duration') target.style.removeProperty('transition-property') // Trigger custom event "bricks/accordion/open" (#86bwtqc4j) document.dispatchEvent( new CustomEvent('bricks/accordion/open', { detail: { elementId: accordion.getAttribute('data-script-id'), openItem: item } }) ) }, duration) } const slideToggle = (target, duration = 200) => { if (window.getComputedStyle(target).display === 'none') { return slideDown(target, duration) } else { return slideUp(target, duration) } } const expandItem = (item) => { item.classList.add('brx-open') let title = item.querySelector('.accordion-title-wrapper') ?? false if (title) { title.setAttribute('aria-expanded', 'true') } } let items = Array.from(accordion.children) let duration = accordion.hasAttribute('data-transition') ? isNaN(accordion.dataset.transition) ? 0 : accordion.dataset.transition : 200 let expandFirstItem = accordion.dataset.scriptArgs?.includes('expandFirstItem') let independentToggle = accordion.dataset.scriptArgs?.includes('independentToggle') // Get index of item to expand (@since 1.12) let expandItemIndexes = expandFirstItem ? ['0'] : false if (expandItemIndexes === false && accordion.hasAttribute('data-expand-item')) { expandItemIndexes = accordion.getAttribute('data-expand-item').split(',') // Split by ",", to get an array (@since 2.0) } let hash = window.location.hash || '' // Hash with # prefix // Only recognise nestables as accordion items items = items.filter( (item) => item.classList.contains('brxe-section') || item.classList.contains('brxe-container') || item.classList.contains('brxe-block') || item.classList.contains('brxe-div') || item.classList.contains('accordion-item') ) items.forEach((item, index) => { // Expand item by index (@since 1.12) // NOTE: expandItemIndexes is an array of strings, so we need to convert index to string (@since 2.0) if (expandItemIndexes && expandItemIndexes.includes(index.toString())) { expandItem(item) } // Expand accordion item if 'id' matches URL hash (@since 1.8.6) let anchorId = item?.id && !item.id.startsWith('brxe-') ? `#${item.id}` : '' if (anchorId && anchorId === hash) { expandItem(item) } if (item.classList.contains('listening')) { return } // Ensure click event listener is only added once item.classList.add('listening') /** * Init title click listener * * Listen on accordion item also allows to re-run script in builder without having to setup any custom destroy() */ item.addEventListener('click', (e) => { let title = e.target.closest('.accordion-title-wrapper') if (!title) { return } let item = title.parentNode if (!item) { return } let content = item.querySelector('.accordion-content-wrapper') if (!content) { return } /** * Builder: Return if selector detector is active * * @since 2.0 */ const selectorDetectorActive = e.target.closest('.bricks-active-selector-detector') if (selectorDetectorActive) { return } // Stop propagation to avoid triggering nested accordion items e.stopPropagation() // No independent toggle: slideUp .open item (if it's currently not open) if (!independentToggle) { // Select only direct children items, not nested accordion items (@since 2.1.3) let openItems = accordion.querySelectorAll(':scope > .brx-open') if (openItems.length) { openItems.forEach((openItem) => { let openContent = openItem.querySelector('.accordion-content-wrapper') if (openContent && openContent !== content) { slideUp(openContent, duration) // Update the aria-label and aria-expanded attributes (@since 1.11) openContent.previousElementSibling.setAttribute( 'aria-label', window.bricksData.i18n.openAccordion ) openContent.previousElementSibling.setAttribute('aria-expanded', 'false') } }) } } // Check if item is currently going to be opened (must be captured before slideToggle) let openingItem = !item.classList.contains('brx-open') /** * Add or remove URL hash * * @since 1.9.3: use replaceState to prevent browser from storing history with each accordion item click * * @since 1.8.6 */ if (anchorId && openingItem) { history.replaceState(null, null, anchorId) } else { history.replaceState(null, null, ' ') } // slideToggle target accordion content slideToggle(content, duration) // Update the aria-label and aria-expanded attributes if (item.classList.contains('brx-open')) { // brx-open class added/removed without delay (@since 1.12) title.setAttribute('aria-expanded', 'true') } else { // brx-open class added/removed without delay (@since 1.12) title.setAttribute('aria-expanded', 'false') } }) // Add keyboard support for nested accordions (@since 1.11) let titleWrapper = item.querySelector('.accordion-title-wrapper') || item if (titleWrapper.getAttribute('role') === 'button') { titleWrapper.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() titleWrapper.click() } }) } }) } }) function bricksAccordion() { bricksAccordionFn.run() } /** * Element: Animated Typing */ const bricksAnimatedTypingFn = new BricksFunction({ parentNode: document, selector: '.brxe-animated-typing', windowVariableCheck: ['Typed'], eachElement: (element) => { let scriptId = element.dataset.scriptId let scriptArgs try { scriptArgs = JSON.parse(element.dataset.scriptArgs) } catch (e) { return false } let typedElement = element.querySelector('.typed') if (!typedElement) { return } /** * Destroy typing animation * * @since 1.8.5: If not inside a splideJS slider (#862jz1gtn) */ if ( window.bricksData.animatedTypingInstances[scriptId] && !element.closest('.brxe-slider-nested.splide') ) { window.bricksData.animatedTypingInstances[scriptId].destroy() } if (!scriptArgs.hasOwnProperty('strings') || !scriptArgs.strings) { return } if (Array.isArray(scriptArgs.strings) && !scriptArgs.strings.toString()) { return } // Replace all content in strings to HTML entities so the animation can play smoothly (@since 2.1) if (Array.isArray(scriptArgs.strings)) { scriptArgs.strings = scriptArgs.strings.map((str) => { return str.replace(/&/g, '&') }) } window.bricksData.animatedTypingInstances[scriptId] = new Typed(typedElement, scriptArgs) /** * Inside popup: Restart animation when the popup is opened * * @since 1.10.1 */ const closestPopup = element.closest('.brx-popup:not([data-popup-ajax])') // Only if not an AJAX popup if (closestPopup) { document.addEventListener('bricks/popup/open', (event) => { if (event.detail.popupElement === closestPopup) { // Correct popup is opened: Restart the typing animation window.bricksData.animatedTypingInstances[scriptId].reset() } }) } } }) function bricksAnimatedTyping() { bricksAnimatedTypingFn.run() } /** * Element: Audio */ const bricksAudioFn = new BricksFunction({ parentNode: document, selector: '.brxe-audio', windowVariableCheck: ['MediaElementPlayer'], eachElement: (element) => { let audioElement = element.querySelector('audio') if (audioElement) { let mediaElementPlayer = new MediaElementPlayer(audioElement) } } }) function bricksAudio() { bricksAudioFn.run() } /** * Element: Post Reading Time (of #brx-content text) * * @since 1.8.5 */ const bricksPostReadingTimeFn = new BricksFunction({ parentNode: document, selector: '.brxe-post-reading-time', eachElement: (element) => { let wordsPerMinute = element.getAttribute('data-wpm') let charactersPerMinute = element.getAttribute('data-cpm') /** * Return: No words/characters per minute set means we are inside a query loop (with no 'contentSelector' set) * * Reading time is calculated on the server side and added to HTML in element's render() function. * * @since 1.10 */ if (!wordsPerMinute && !charactersPerMinute) { return } let contentSelector = element.dataset.contentSelector || '.brxe-post-content' let closestQueryLoop = element.closest('[data-query-loop-index]') // NOTE: Only works if query loop element has CSS settings let content = closestQueryLoop ? closestQueryLoop.querySelector(contentSelector) : document.querySelector(contentSelector) // Fallback to #brx-content if (!content) { content = document.querySelector('#brx-content') } if (!content) { return } let prefix = element.getAttribute('data-prefix') || '' let suffix = element.getAttribute('data-suffix') || '' /** * Define chunk size for long words (used for non-Chinese and non-Japanese text) * NOTE: These values are magic numbers so if there's a need we can provide a setting to adjust them * * @since 1.10 */ let longWordChunkSize = 5 let longWordThreshold = 15 let articleText = content.textContent /** * Define average characters per word for Chinese and Japanese * NOTE: These values are approximate and may not be accurate for all text * * @since 1.10 */ const averageCharactersPerWord = { chinese: 1.5, // Average characters per word for Chinese japanese: 2.5 // Average characters per word for Japanese } // Function to detect language based on text function detectLanguage(char) { if (/[\u4e00-\u9fa5]/.test(char)) { return 'chinese' } else if (/[\u3040-\u30FF]/.test(char)) { return 'japanese' } else { return 'other' } } // Function to split long words into chunks function splitLongWords(text, chunkSize, longWordThreshold) { const regex = new RegExp(`\\S{${longWordThreshold},}`, 'g') return text.replace(regex, (longWord) => { return longWord.match(new RegExp(`.{1,${chunkSize}}`, 'g')).join(' ') }) } // Function to count words based on language function countWords(text) { let totalWordCount = 0 let chineseCharacterCount = 0 let japaneseCharacterCount = 0 let otherText = '' for (const char of text) { const lang = detectLanguage(char) if (lang === 'chinese') { chineseCharacterCount++ } else if (lang === 'japanese') { japaneseCharacterCount++ } else { otherText += char } } // Calculate words for Chinese and Japanese characters const chineseWordCount = chineseCharacterCount / averageCharactersPerWord['chinese'] const japaneseWordCount = japaneseCharacterCount / averageCharactersPerWord['japanese'] // Split long words in the other text otherText = splitLongWords(otherText, longWordChunkSize, longWordThreshold) // Split the other text by spaces to count words const otherWordCount = otherText.split(/\s+/).filter((word) => word.length > 0).length // Sum up all the word counts totalWordCount = chineseWordCount + japaneseWordCount + otherWordCount return totalWordCount } // Calculate reading time let readingTime if (charactersPerMinute) { let characterCount = articleText.replace(/\s+/g, '').length readingTime = Math.ceil(characterCount / parseInt(charactersPerMinute)) } else { let totalWordCount = countWords(articleText) readingTime = Math.ceil(totalWordCount / parseInt(wordsPerMinute)) } element.textContent = prefix + readingTime + suffix } }) function bricksPostReadingTime() { bricksPostReadingTimeFn.run() } /** * Element: Countdown */ const bricksCountdownFn = new BricksFunction({ parentNode: document, selector: '.brxe-countdown', eachElement: (element) => { // Countdown logic countdown = (element, settings, init) => { // STEP: Get timezone from settings let timezoneSign = settings.timezone[3] === '+' ? 1 : -1 let timezoneHours = parseInt(settings.timezone.substring(4, 6)) let timezoneMinutes = parseInt(settings.timezone.substring(7, 9)) // Convert hours and minutes to minutes let countdownCreatorTimezone = timezoneSign * (timezoneHours * 60 + timezoneMinutes) // Convert timezone to milliseconds let countdownCreatorTimezoneMs = countdownCreatorTimezone * 60000 // Get timezone offset of visitor in minutes let viewerOffsetMinutes = new Date().getTimezoneOffset() // Convert to millisecond and flip the sign here because getTimezoneOffset() returns the offset with an opposite sign let viewerOffsetMs = -viewerOffsetMinutes * 60000 let date = settings.date.replace(' ', 'T') // Replace needed for iOS Safari (NaN) // Get time of the target date in milliseconds let targetDate = new Date(date).getTime() // STEP: Adjust the target date for the visitors' timezone offset and the timezone setting offset let targetDateAdjusted = targetDate + viewerOffsetMs - countdownCreatorTimezoneMs // Get current date and time in UTC milliseconds let now = new Date().getTime() // Calculate the difference in milliseconds let diff = targetDateAdjusted - now // Countdown date reached if (diff <= 0) { // Stop countdown clearInterval(element.dataset.bricksCountdownId) if (settings.action === 'hide') { element.innerHTML = '' return } else if (settings.action === 'text') { element.innerHTML = settings.actionText return } } // Add HTML nodes for each field (spans: .prefix, .format, .suffix) if (init) { // Builder: Remove HTML from previous instance element.innerHTML = '' settings.fields.forEach((field) => { if (!field.format) { return } let fieldNode = document.createElement('div') fieldNode.classList.add('field') if (field.prefix) { let prefixNode = document.createElement('span') prefixNode.classList.add('prefix') prefixNode.innerHTML = field.prefix fieldNode.appendChild(prefixNode) } let formatNode = document.createElement('span') formatNode.classList.add('format') fieldNode.appendChild(formatNode) if (field.suffix) { let suffixNode = document.createElement('span') suffixNode.classList.add('suffix') suffixNode.innerHTML = field.suffix fieldNode.appendChild(suffixNode) } element.appendChild(fieldNode) }) } let fieldNodes = bricksQuerySelectorAll(element, '.field') let days = Math.floor(diff / (1000 * 60 * 60 * 24)) let hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) let minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) let seconds = Math.floor((diff % (1000 * 60)) / 1000) settings.fields.forEach((field, index) => { if (!field.format || !fieldNodes[index]) { return } let format = field.format.toLowerCase() // Add leading zero if format is uppercase & one digit (e.g. %D and value less than 10) // DAYS if (format.includes('%d')) { if (field.format.includes('%D')) { days <= 9 ? (days = `0${days}`) : days } fieldNodes[index].querySelector('.format').innerHTML = format.replace( '%d', diff <= 0 ? 0 : days ) } // HOURS else if (format.includes('%h')) { if (field.format.includes('%H')) { hours <= 9 ? (hours = `0${hours}`) : hours } fieldNodes[index].querySelector('.format').innerHTML = format.replace( '%h', diff <= 0 ? 0 : hours ) } // MINUTES else if (format.includes('%m')) { if (field.format.includes('%M')) { minutes <= 9 ? (minutes = `0${minutes}`) : minutes } fieldNodes[index].querySelector('.format').innerHTML = format.replace( '%m', diff <= 0 ? 0 : minutes ) } // SECONDS else if (format.includes('%s')) { if (field.format.includes('%S')) { seconds <= 9 ? (seconds = `0${seconds}`) : seconds } fieldNodes[index].querySelector('.format').innerHTML = format.replace( '%s', diff <= 0 ? 0 : seconds ) } }) } let settings = element.dataset.bricksCountdownOptions try { settings = JSON.parse(settings) } catch (e) { return false } if (settings.hasOwnProperty('date') && settings.hasOwnProperty('fields')) { // Get existing countdownId let countdownId = element.dataset.bricksCountdownId // Destroy existing instance by clearing the interval if (countdownId) { clearInterval(countdownId) } // Init countdown countdown(element, settings, true) // Call countdown every second (= 1000ms) countdownId = setInterval(countdown, 1000, element, settings, false) element.dataset.bricksCountdownId = countdownId } } }) function bricksCountdown() { bricksCountdownFn.run() } /** * Element: Counter * With custom run function, because we need to forceReinit only for counter inside popup */ const bricksCounterFn = new BricksFunction({ parentNode: document, selector: '.brxe-counter', subscribeEvents: [ 'bricks/popup/open', 'bricks/ajax/pagination/completed', 'bricks/ajax/load_page/completed', 'bricks/ajax/query_result/displayed' ], forceReinit: (element, index) => { // Force reinit if counter is inside popup return element.closest('.brx-popup') }, eachElement: (element) => { let settings = element.dataset.bricksCounterOptions try { settings = JSON.parse(settings) } catch (e) { return false } let countNode = element.querySelector('.count') let countFrom = settings.hasOwnProperty('countFrom') ? parseInt(settings.countFrom) : 0 let countTo = settings.hasOwnProperty('countTo') ? parseInt(settings.countTo) : 100 let durationInMs = settings.hasOwnProperty('duration') ? parseInt(settings.duration) : 1000 let separator = settings?.separator // Min. duration: 500ms if (durationInMs < 500) { durationInMs = 500 } let diff = countTo - countFrom let timeout = durationInMs / diff let incrementBy = 1 // Min. timeout: 16ms ()= typical screen refresh rate (60fps)) let minTimeout = 16 if (timeout < minTimeout) { incrementBy = Math.ceil(minTimeout / timeout) timeout = minTimeout } // Vanilla JS countUp function let countUp = () => { // Get current count (locale string back to number) let count = countNode.innerText.replace(/\D/g, '') count = isNaN(count) ? countFrom : parseInt(count) // Calculate new count: Make sure we don't run over max. count let newCount = count + incrementBy < countTo ? count + incrementBy : countTo // countTo reached yet: Stop interval if (count >= countTo) { clearInterval(countNode.dataset.counterId) delete countNode.dataset.counterId return } if (settings.thousands && separator) { // Force locale to en-US to ensure separator is comma so we can replace it with user defined separator (@since 1.9.3) countNode.innerText = newCount.toLocaleString('en-US').replaceAll(',', separator) } else if (settings.thousands) { // For previous version, we don't touch the toLocaleString() function as users locale can be different countNode.innerText = newCount.toLocaleString() } else { countNode.innerText = newCount } } let callback = () => { // Reset count countNode.innerText = countFrom // Interval not yet running: Start interval if (countNode.dataset.counterId == undefined) { countNode.dataset.counterId = setInterval(countUp, timeout) } } // Run countUp() when popup is open (has no .hide class) let popup = countNode.closest('.brx-popup') if (popup) { if (!popup.classList.contains('hide')) { callback() } } // Run countUp() when element enters viewport else { new BricksIntersect({ element: element, callback: callback }) } }, listenerHandler: (event) => { if (event?.type) { switch (event.type) { case 'bricks/popup/open': // Change parentNode to the opened popup let settings = { parentNode: event.details?.popupElement ? event.details.popupElement : document } bricksCounterFn.run(settings) break default: bricksCounterFn.run() break } } } }) function bricksCounter() { bricksCounterFn.run() } /** * Element: Post Table of Contents (tocbot) * * @since 1.8.5 */ const bricksTableOfContentsFn = new BricksFunction({ parentNode: document, selector: '.brxe-post-toc', forceReinit: true, // Force reinit so eachElement will be called again on Bricks AJAX events, no double init as tocbot is singleton (#86c463v6t; @since 2.0) eachElement: (toc) => { // Check if toc is visible const isVisible = toc.offsetParent !== null && !!(toc.offsetWidth || toc.offsetHeight || toc.getClientRects().length) // If visible, initialize immediately if (isVisible) { initializeTocbot(toc) return } // If not visible, create observer to initialize when it becomes visible const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { // Element is visible, initialize tocbot initializeTocbot(toc) // Disconnect observer since we only need to initialize once observer.disconnect() } }) }, { threshold: 0.1 // Trigger when at least 10% is visible } ) // Start observing the ToC element observer.observe(toc) function initializeTocbot(toc) { // Always destroy tocbot first (#86c463v6t) if (window.tocbot) { window.tocbot.destroy() } const scriptId = toc.dataset.scriptId // STEP: Create IDs for each heading in the content (if heading has no 'id') let contentSelector = toc.dataset.contentSelector || '.brxe-post-content' let content = document.querySelector(contentSelector) // Fallback to #brx-content if (!content) { content = document.querySelector('#brx-content') if (content) { contentSelector = '#brx-content' } } if (!content) { return } let headingSelectors = toc.dataset.headingSelectors || 'h2, h3' let headings = content.querySelectorAll(headingSelectors) let headingMap = {} // STEP: Generate unique element 'id' for each heading headings.forEach((heading) => { // Heading already has an 'id': Add ID to map & continue with next heading if (heading.id && !headingMap[heading.id]) { headingMap[heading.id] = 1 return } let generatedId = generateIDFromTextContent(heading.textContent, scriptId) // Generated ID already exists: Append index (e.g.: #heading-1, #heading-2, etc.) if (headingMap[generatedId]) { headingMap[generatedId]++ generatedId = `${generatedId}-${headingMap[generatedId]}` } // Add ID to map else { headingMap[generatedId] = 1 } // Assign the generated ID to the heading and track it. heading.id = generatedId }) function generateIDFromTextContent(text, scriptId) { let baseId = text .trim() .toLowerCase() .normalize('NFD') // Remove accents .replace(/[\u0300-\u036f]/g, '') // Remove accents .replace(/[!@#$%^&*()=:;,.„“"'`]/gi, '') // Remove special characters .replace(/\//gi, '-') // Replace slashes with dashes .split(' ') .join('-') // ID starts with a number: Prefix required (CSS.escape works too, but looks so ugly) if (/^\d/.test(baseId)) { return `${scriptId}-${baseId}` } return baseId } let headingsOffset = parseInt(toc.dataset.headingsOffset) || 0 // Smooth scroll enabled via Bricks settings let scrollSmooth = toc.hasAttribute('data-smooth-scroll') // STEP: tocbot options (https://tscanlin.github.io/tocbot/#api) let options = { tocSelector: `.brxe-post-toc[data-script-id="${scriptId}"]`, contentSelector: contentSelector, headingSelector: headingSelectors, ignoreSelector: toc.dataset.ignoreSelector || '.toc-ignore', // Use document-level heading offsets when headings sit inside positioned wrappers (#86c02fu18; @since 2.3.6) hasInnerContainers: true, linkClass: 'toc-link', extraLinkClasses: '', activeLinkClass: 'is-active-link', listClass: 'toc-list', extraListClasses: '', isCollapsedClass: 'is-collapsed', collapsibleClass: 'is-collapsible', listItemClass: 'toc-list-item', activeListItemClass: 'is-active-li', collapseDepth: toc.dataset.collapseInactive ? 0 : 6, scrollSmooth: headingsOffset, scrollSmoothDuration: scrollSmooth && headingsOffset ? 420 : 0, scrollSmoothOffset: headingsOffset ? -headingsOffset : 0, headingsOffset: headingsOffset, throttleTimeout: 0, positionFixedSelector: null, positionFixedClass: 'is-position-fixed', fixedSidebarOffset: 'auto', includeHtml: false, includeTitleTags: false, orderedList: false, // TODO: Add "Numbered" setting scrollContainer: null, skipRendering: false, headingLabelCallback: false, ignoreHiddenElements: false, headingObjectCallback: null, basePath: '', disableTocScrollSync: false, tocScrollOffset: 0 } // Init tocbot window.tocbot.init(options) // NOTE: Not needed as tocbot is a singleton that only allows one instance (@since 1.11.1) // window.bricksData.activeToc = scriptId } } }) function bricksTableOfContents() { bricksTableOfContentsFn.run() } /** * Element: Form * * Init recaptcha explicit on Google reCAPTCHA callback. */ const bricksFormFn = new BricksFunction({ parentNode: document, selector: '.brxe-form', eachElement: (form) => { let elementId = form.getAttribute('data-element-id') // Variable: Use custom validation for all fields on submit (@since 2.2) const customValidateAllFields = form.getAttribute('data-validate-all-fields') === 'true' // Disable form validation on blur, input (@since 1.12) const validationDisabledOn = JSON.parse(form.getAttribute('data-validation-disabled-on')) || [] // Validate required checkboxes let checkboxes = bricksQuerySelectorAll(form, 'input[type="checkbox"]') checkboxes.forEach((checkbox) => { if (checkbox.required) { checkbox.addEventListener('click', (event) => { let cbName = checkbox.getAttribute('name') let group = bricksQuerySelectorAll(form, `input[name="${cbName}"]`) let atLeastOneChecked = false group.forEach((item) => { if (item.checked === true) { atLeastOneChecked = true } }) if (atLeastOneChecked) { group.forEach((item) => { item.required = false }) } else { group.forEach((item) => { item.required = true }) } }) } }) /** * Field type: image & gallery * * Listen to click event to open media library & store selected image(s) in adjacent hidden input field. * * @since 2.1 */ /** * Helper function: Initialize drag and drop for gallery image sorting * * @param {HTMLElement} element - The draggable image preview element * @param {HTMLElement} container - The gallery preview container */ function initializeDragAndDrop(element, container) { element.addEventListener('dragstart', (e) => { element.classList.add('dragging') element.style.opacity = '0.5' e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/html', element.innerHTML) }) element.addEventListener('dragend', (e) => { element.style.opacity = '1' element.classList.remove('dragging') // Update hidden input with new order updateGalleryOrder(container) }) element.addEventListener('dragover', (e) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' const draggingElement = container.querySelector('.dragging') if (draggingElement && draggingElement !== element) { const allPreviews = Array.from(container.querySelectorAll('.image-preview')) const draggedIndex = allPreviews.indexOf(draggingElement) const targetIndex = allPreviews.indexOf(element) if (draggedIndex < targetIndex) { container.insertBefore(draggingElement, element.nextSibling) } else { container.insertBefore(draggingElement, element) } } }) element.addEventListener('drop', (e) => { e.preventDefault() e.stopPropagation() }) } /** * Helper function: Update hidden input with current gallery image order * * @param {HTMLElement} container - The gallery preview container */ function updateGalleryOrder(container) { const hiddenInput = container.parentNode.querySelector('input[type="hidden"]') if (!hiddenInput) return const imagePreviews = container.querySelectorAll('.image-preview') const imageIds = [] imagePreviews.forEach((preview) => { const img = preview.querySelector('img') if (img && img.dataset.attachmentId) { imageIds.push(img.dataset.attachmentId) } }) hiddenInput.value = imageIds.join(',') } /** * Helper function: Create image preview HTML with remove button * * @param {object} attachment - Attachment object from WordPress media library * @param {HTMLElement} container - Container element (image-preview or gallery-preview) * @returns {HTMLElement} - Image preview wrapper element */ function createImagePreviewWithRemove(attachment, container) { const previewWrapper = document.createElement('div') previewWrapper.classList.add('image-preview') // Make gallery items draggable for sorting const isGallery = container.classList.contains('gallery-preview') if (isGallery) { previewWrapper.setAttribute('draggable', 'true') previewWrapper.style.cursor = 'move' } // Create image element const img = document.createElement('img') img.src = attachment.url img.alt = attachment.alt || '' img.title = attachment.title || '' img.style = 'max-height: 150px' img.dataset.attachmentId = attachment.id // Create remove button const removeButton = document.createElement('button') removeButton.type = 'button' removeButton.classList.add('choose-files', 'remove') removeButton.dataset.action = 'media-library' removeButton.dataset.attachmentId = attachment.id removeButton.textContent = window.bricksData.i18n.remove // Add remove button listener addImageRemoveListener(removeButton, container) previewWrapper.appendChild(img) previewWrapper.appendChild(removeButton) // Add drag and drop event listeners for gallery sorting if (isGallery) { initializeDragAndDrop(previewWrapper, container) } return previewWrapper } /** * Helper function: Add event listener to image remove button * * @param {HTMLElement} removeButton - Remove button element * @param {HTMLElement} container - Container element (image-preview or gallery-preview) */ function addImageRemoveListener(removeButton, container) { removeButton.addEventListener('click', (event) => { event.preventDefault() const attachmentId = removeButton.dataset.attachmentId const previewWrapper = removeButton.closest('.image-preview') const isGallery = container.classList.contains('gallery-preview') // Get hidden input field let hiddenInput if (isGallery) { hiddenInput = container.parentNode.querySelector('input[type="hidden"]') } else { hiddenInput = previewWrapper.parentNode.querySelector('input[type="hidden"]') } if (isGallery && hiddenInput) { // Gallery: Remove specific attachment ID from comma-separated list let currentValue = hiddenInput.value let imageIds = currentValue ? currentValue.split(',').map((id) => id.trim()) : [] imageIds = imageIds.filter((id) => id !== attachmentId) hiddenInput.value = imageIds.join(',') // Trigger validation after image removal (@since 2.2) if ( hiddenInput.getAttribute('data-error-message') && !(validationDisabledOn.includes('input') && validationDisabledOn.includes('blur')) && bricksIsFrontend ) { validateImageGalleryInput(hiddenInput) } } else if (hiddenInput) { // Single image: Clear the hidden input value hiddenInput.value = '' // Trigger validation after image removal (@since 2.2) if ( hiddenInput.getAttribute('data-error-message') && !(validationDisabledOn.includes('input') && validationDisabledOn.includes('blur')) && bricksIsFrontend ) { validateImageGalleryInput(hiddenInput) } } // Remove the preview wrapper from DOM if (previewWrapper) { previewWrapper.remove() } }) } /** * Helper function: Handle media library selection for image/gallery fields * * @param {HTMLElement} imageField - The button that opens the media library * @param {object} frame - WordPress media frame object * @param {boolean} isGallery - Whether this is a gallery field */ function handleMediaSelection(imageField, frame, isGallery) { frame.on('select', function () { const selection = frame.state().get('selection') const attachments = selection.map((attachment) => attachment.toJSON()) if (isGallery) { // Gallery: Handle multiple images const galleryPreview = imageField.parentNode.querySelector('.gallery-preview') const hiddenInput = imageField.parentNode.querySelector('input[type="hidden"]') if (!galleryPreview) return // Delete all existing .image-preview elements const existingPreviews = galleryPreview.querySelectorAll('.image-preview') existingPreviews.forEach((preview) => preview.remove()) // Collect new image IDs const newIds = [] // Add each selected attachment attachments.forEach((attachment) => { // Create and append preview const previewElement = createImagePreviewWithRemove( { ...attachment, url: attachment.sizes?.thumbnail?.url || attachment.url }, galleryPreview ) galleryPreview.appendChild(previewElement) // Add to IDs array newIds.push(String(attachment.id)) }) // Update hidden input with comma-separated IDs if (hiddenInput) { hiddenInput.value = newIds.join(',') // Trigger validation after image selection (@since 2.2) if ( hiddenInput.getAttribute('data-error-message') && !(validationDisabledOn.includes('input') && validationDisabledOn.includes('blur')) && bricksIsFrontend ) { validateImageGalleryInput(hiddenInput) } } } else { // Single image: Handle one image const imagePreview = imageField.parentNode.querySelector('.image-preview') const hiddenInput = imageField.parentNode.querySelector('input[type="hidden"]') if (!attachments[0]) return const attachment = attachments[0] if (imagePreview) { // Remove existing preview const existingPreview = imagePreview.querySelector('img') if (existingPreview) { existingPreview.remove() } // Create and append new image const img = document.createElement('img') img.src = attachment.url img.alt = attachment.alt || '' img.title = attachment.title || '' img.style = 'max-height: 150px' imagePreview.insertBefore(img, imagePreview.firstChild) } // Update hidden input with attachment ID if (hiddenInput) { hiddenInput.value = attachment.id // Trigger validation after image selection (@since 2.2) if ( hiddenInput.getAttribute('data-error-message') && !(validationDisabledOn.includes('input') && validationDisabledOn.includes('blur')) && bricksIsFrontend ) { validateImageGalleryInput(hiddenInput) } } } }) } // Initialize image and gallery field click handlers let imageFields = bricksQuerySelectorAll(form, '.choose-files.image') imageFields.forEach((imageField) => { imageField.addEventListener('click', (event) => { event.preventDefault() const isGallery = imageField.classList.contains('multiple') // Open the WordPress media modal for images let frame = window.wp.media({ multiple: isGallery, library: { type: 'image' } }) // Handle media selection handleMediaSelection(imageField, frame, isGallery) // Check if there are pre-populated image IDs and select them in the media library const hiddenInput = imageField.parentNode.querySelector('input[type="hidden"]') if (hiddenInput && hiddenInput.value) { const imageIds = hiddenInput.value.split(',').map((id) => id.trim()) // Create selection from existing IDs when frame is ready if (imageIds.length > 0) { frame.on('open', function () { const selection = frame.state().get('selection') imageIds.forEach((imageId) => { const attachment = wp.media.attachment(imageId) attachment.fetch() selection.add(attachment) }) }) } } frame.open() }) }) // Initialize existing remove buttons (for pre-populated images) let existingRemoveButtons = bricksQuerySelectorAll(form, '.choose-files.remove') existingRemoveButtons.forEach((removeButton) => { const container = removeButton.closest('.gallery-preview') || removeButton.closest('.image-preview') if (container) { // Set attachment ID from existing image if not already set if (!removeButton.dataset.attachmentId) { const img = removeButton.parentNode.querySelector('img') if (img && img.dataset.attachmentId) { removeButton.dataset.attachmentId = img.dataset.attachmentId } } addImageRemoveListener(removeButton, container) } }) // Initialize drag and drop for existing gallery images let existingGalleryPreviews = bricksQuerySelectorAll(form, '.gallery-preview') existingGalleryPreviews.forEach((galleryContainer) => { const imagePreviews = galleryContainer.querySelectorAll('.image-preview') imagePreviews.forEach((preview) => { // Make draggable preview.setAttribute('draggable', 'true') preview.style.cursor = 'move' // Initialize drag and drop handlers initializeDragAndDrop(preview, galleryContainer) }) }) /** * Listen to field blur/input events to validate form fields * * If field has data-error-message attribute, add event listener. * * @since 1.9.2 */ const inputFields = form.querySelectorAll( 'input:not([type="hidden"]):not([type="file"]):not([type="checkbox"]):not([type="radio"]), textarea' ) inputFields.forEach((inputField) => { if (inputField.getAttribute('data-error-message') && bricksIsFrontend) { // Attach event listeners for initial validation and subsequent changes (@since 1.12) // Only check for errors, if the error message trigger doesn't include "input" if (!validationDisabledOn.includes('input')) { inputField.addEventListener( 'input', // Debounce the input event for performance reasons (@since 1.12) window.bricksUtils.debounce(() => { validateInput(inputField) }, 300) ) } // Only check for errors, if the error message trigger doesn't includes "blur" if (!validationDisabledOn.includes('blur')) { inputField.addEventListener( 'blur', // Debounce the blur event for performance reasons (@since 1.12) window.bricksUtils.debounce(() => { validateInput(inputField) }, 300) ) } } }) /** * Listen to checkbox and radio field changes for validation * * Special case: error messages are inside .form-group * Need to check if any field inside the group is checked * * @since 2.2 */ const checkboxRadioFields = form.querySelectorAll('input[type="checkbox"], input[type="radio"]') checkboxRadioFields.forEach((field) => { // Check if the parent .form-group has data-error-message attribute const formGroup = field.closest('.form-group') if (formGroup && formGroup.getAttribute('data-error-message') && bricksIsFrontend) { // Only check for errors if validation is not disabled on "input" or "blur" if (!(validationDisabledOn.includes('input') && validationDisabledOn.includes('blur'))) { // Listen to change event for checkboxes and radios field.addEventListener('change', () => { validateCheckboxRadio(field) }) } } }) /** * Listen to select field changes for validation * * @since 2.3 */ const selectFields = form.querySelectorAll('select') selectFields.forEach((select) => { if (select.getAttribute('data-error-message') && bricksIsFrontend) { // Only check for errors if validation is not disabled on "input" or "blur" if (!(validationDisabledOn.includes('input') && validationDisabledOn.includes('blur'))) { select.addEventListener('change', () => validateInput(select)) } } }) /** * Helper function to show/hide error message in form-group * * @param {HTMLElement} formGroup - The form group element * @param {boolean} showError - Whether to show the error * @param {string} errorMsg - The error message to display */ function updateErrorMessage(formGroup, showError, errorMsg) { let errorDiv = formGroup.querySelector('.form-group-error-message') if (!errorDiv) { errorDiv = document.createElement('div') errorDiv.classList.add('form-group-error-message') formGroup.appendChild(errorDiv) } if (showError && errorMsg) { errorDiv.innerText = errorMsg errorDiv.classList.add('show') } else { errorDiv.innerText = '' errorDiv.classList.remove('show') } } /** * Function to validate input fields */ function validateInput(inputField) { const value = inputField.value.trim() const errorMsg = inputField.getAttribute('data-error-message') // Find the closest parent form group and then find the error message div within it let formGroup = inputField.closest('.form-group') let showError = false // Check if field is required (or was required before TinyMCE removed it) const isRequired = inputField.hasAttribute('required') || inputField.dataset.wasRequired === 'true' // Required & empty if (isRequired && !value) { showError = true } // Validate number input if (inputField.type === 'number') { const min = parseFloat(inputField.getAttribute('min')) const max = parseFloat(inputField.getAttribute('max')) const valueAsNumber = parseFloat(value) if ((min !== null && valueAsNumber < min) || (max !== null && valueAsNumber > max)) { showError = true } } // Validate email if (inputField.type === 'email') { showError = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) } // Validate URL if (inputField.type === 'url') { showError = !/^(https?:\/\/)?([\w.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/.test(value) } // Not required & empty if (!isRequired && value == '') { showError = false } updateErrorMessage(formGroup, showError, errorMsg) return !showError } /** * Function to validate checkbox and radio fields * * Special case: Check all fields inside .form-group to see if any is checked * * @since 2.2 */ function validateCheckboxRadio(field) { // Find the closest parent form group let formGroup = field.closest('.form-group') if (!formGroup) { return true } // Get all checkbox/radio fields within this form-group const fieldType = field.type const fieldName = field.getAttribute('name') const allFields = formGroup.querySelectorAll( `input[type="${fieldType}"][name="${fieldName}"]` ) // Check if at least one field is checked let atLeastOneChecked = false allFields.forEach((item) => { if (item.checked) { atLeastOneChecked = true } }) // Get error message from the .form-group container (not from individual fields) const errorMsg = formGroup.getAttribute('data-error-message') let showError = false // If field is required and nothing is checked, show error if (field.hasAttribute('required') && !atLeastOneChecked) { showError = true } updateErrorMessage(formGroup, showError, errorMsg) return !showError } /** * Function to validate file input fields * * @param {HTMLElement} fileInput - The file input field * @param {Object} files - The files object tracking uploaded files * @since 2.2 */ function validateFileInput(fileInput, files) { // Find the closest parent form group let formGroup = fileInput.closest('.form-group') if (!formGroup) { return true } // Get error message const errorMsg = fileInput.getAttribute('data-error-message') let showError = false // Check if field is required and has no files if (fileInput.hasAttribute('required')) { const inputName = fileInput.getAttribute('name') const hasFilesInInput = fileInput.files && fileInput.files.length > 0 const hasFilesInObject = files.hasOwnProperty(inputName) && files[inputName].length > 0 // Show error if no files are selected in both input and files object if (!hasFilesInInput && !hasFilesInObject) { showError = true } } updateErrorMessage(formGroup, showError, errorMsg) return !showError } /** * Function to validate image and gallery input fields (hidden inputs) * * @param {HTMLElement} hiddenInput - The hidden input field containing attachment IDs * @since 2.2 */ function validateImageGalleryInput(hiddenInput) { // Find the closest parent form group let formGroup = hiddenInput.closest('.form-group') if (!formGroup) { return true } // Get error message const errorMsg = hiddenInput.getAttribute('data-error-message') let showError = false // Check if field is required and has no value if (hiddenInput.hasAttribute('required')) { const value = hiddenInput.value.trim() // Show error if no attachment IDs are present if (!value) { showError = true } } updateErrorMessage(formGroup, showError, errorMsg) return !showError } // STEP: Handle password toggle buttons (@since 1.12) const passwordToggles = form.querySelectorAll('.password-toggle') passwordToggles.forEach((toggle) => { const input = toggle.previousElementSibling // Skip if no valid input found if (!input || !input.type.match(/password|text/)) { return } toggle.addEventListener('click', () => { const isPassword = input.type === 'password' input.type = isPassword ? 'text' : 'password' // Update aria-label based on next action available toggle.setAttribute( 'aria-label', isPassword ? window.bricksData.i18n.hidePassword : window.bricksData.i18n.showPassword ) // Toggle show/hide password icon toggle.querySelector('.show-password').classList.toggle('hide') toggle.querySelector('.hide-password').classList.toggle('hide') }) }) // Init datepicker let flatpickrElements = bricksQuerySelectorAll(form, '.flatpickr') flatpickrElements.forEach((flatpickrElement) => { let flatpickrOptions = flatpickrElement.dataset.bricksDatepickerOptions if (flatpickrOptions) { flatpickrOptions = JSON.parse(flatpickrOptions) // Disable native mobile date input as it looks different from all other fields // @since 1.7 (https://flatpickr.js.org/mobile-support/) flatpickrOptions.disableMobile = true flatpickrOptions.onReady = (a, b, fp) => { if (fp.altInput) { let ariaLabel = fp.altInput.previousElementSibling ? fp.altInput.previousElementSibling.getAttribute('aria-label') : 'Date' fp.altInput.setAttribute('aria-label', ariaLabel || 'Date') } } flatpickr(flatpickrElement, flatpickrOptions) } }) // Init file input, to validate files on user selection let files = {} let fileInputInstances = bricksQuerySelectorAll(form, 'input[type=file]') fileInputInstances.forEach((input) => { let inputRef = input.getAttribute('data-files-ref') let maxSize = input.getAttribute('data-maxsize') || false let maxLength = input.getAttribute('data-limit') || false maxSize = maxSize ? parseInt(maxSize) * 1024 * 1024 : false // Validate file input if it has error message and validation is not disabled on input if ( input.getAttribute('data-error-message') && !(validationDisabledOn.includes('input') && validationDisabledOn.includes('blur')) && bricksIsFrontend ) { input.addEventListener('change', () => { validateFileInput(input, files) }) } input.addEventListener('change', (e) => { let fileList = e.target.files let fileListLength = fileList.length let inputName = input.getAttribute('name') if (!fileListLength) { return } let fileResultEl = form.querySelector(`.file-result[data-files-ref="${inputRef}"]`) for (let i = 0; i < fileListLength; i++) { let file = fileList[i] let error = false // Populate upload HTML let resultEl = fileResultEl.cloneNode(true) // Erorro: Max. number of files exceeded if ( maxLength && files.hasOwnProperty(inputName) && files[inputName].length >= maxLength ) { error = 'limit' } // Error: File exceeds size limit if (maxSize && file.size > maxSize) { error = 'size' } resultEl.classList.add('show') if (error) { resultEl.classList.add('danger') // Remove text and button elements resultEl.querySelector('.text').remove() resultEl.querySelector('.remove').remove() const closeIcon = resultEl.querySelector('svg') // Insert error message as first child const errorMessage = resultEl .getAttribute(`data-error-${error}`) .replace('%s', file.name) resultEl.insertAdjacentHTML('afterbegin', errorMessage) // Add onClick event to close the error message closeIcon.addEventListener('click', () => { resultEl.remove() }) } // Add file else { if (!files.hasOwnProperty(inputName)) { files[inputName] = [] } files[inputName].push(file) let resultText = resultEl.querySelector('.text') let resultRemove = resultEl.querySelector('.remove') // Remove svg icon resultEl.querySelector('svg').remove() resultText.innerHTML = file.name resultRemove.setAttribute('data-name', file.name) resultRemove.setAttribute('data-field', inputName) // Remove file listener resultRemove.addEventListener('click', (e) => { let fileName = e.target.getAttribute('data-name') let fieldName = e.target.getAttribute('data-field') let fieldFiles = files[fieldName] for (let k = 0; k < fieldFiles.length; k++) { if (fieldFiles[k].name === fileName) { files[inputName].splice(k, 1) break } } resultEl.remove() // Remove "fileName" form input.files array as well (@since 2.0.2) const newFileList = new DataTransfer() for (let i = 0; i < input.files.length; i++) { const inputFile = input.files[i] if (inputFile.name !== fileName) { newFileList.items.add(inputFile) } } input.files = newFileList.files // Trigger validation after file removal (@since 2.2) if ( input.getAttribute('data-error-message') && !( validationDisabledOn.includes('input') && validationDisabledOn.includes('blur') ) && bricksIsFrontend ) { validateFileInput(input, files) } }) } // Add result fileResultEl.parentNode.insertBefore(resultEl, fileResultEl.nextSibling) } }) }) // Form submit form.addEventListener('submit', (event) => { event.preventDefault() if (!bricksIsFrontend) { return } /** * STEP: Validate all input fields before submission * * @since 1.11 * @since 2.3: Include select fields in validation */ let isValid = true for (const inputField of [...inputFields, ...selectFields]) { if (inputField.getAttribute('data-error-message') && bricksIsFrontend) { const isCurrentValid = validateInput(inputField) isValid = isValid && isCurrentValid if (!isValid && !customValidateAllFields) { // Stop further validation if one field is invalid and we don't need to validate all fields (@since 2.2) break } } } /** * STEP: Validate checkbox and radio fields before submission * * @since 2.2 */ if (isValid || customValidateAllFields) { // Get all form groups with checkbox/radio that have error messages const formGroupsWithCheckboxRadio = form.querySelectorAll('.form-group[data-error-message]') formGroupsWithCheckboxRadio.forEach((formGroup) => { const checkboxRadio = formGroup.querySelector( 'input[type="checkbox"], input[type="radio"]' ) if (checkboxRadio) { const isCurrentValid = validateCheckboxRadio(checkboxRadio) isValid = isValid && isCurrentValid } }) } /** * STEP: Validate file input fields before submission * * @since 2.2 */ if (isValid || customValidateAllFields) { fileInputInstances.forEach((fileInput) => { if (fileInput.getAttribute('data-error-message')) { const isCurrentValid = validateFileInput(fileInput, files) isValid = isValid && isCurrentValid } }) } /** * STEP: Validate image and gallery fields before submission * * @since 2.2 */ if (isValid || customValidateAllFields) { // Get all form groups with hidden inputs for image/gallery fields const imageGalleryFormGroups = form.querySelectorAll('.form-group') imageGalleryFormGroups.forEach((formGroup) => { // Check if this form group contains a hidden input with data-error-message const hiddenInput = formGroup.querySelector('input[type="hidden"][data-error-message]') if (hiddenInput) { const isCurrentValid = validateImageGalleryInput(hiddenInput) isValid = isValid && isCurrentValid } }) } // Abort submission if form is not valid if (!isValid) { return } // Get hCaptcha widget ID (necessary when using multiple forms on one page) let hcaptchaIframe = form.querySelector('[data-hcaptcha-widget-id]') let widgetId = hcaptchaIframe ? hcaptchaIframe.getAttribute('data-hcaptcha-widget-id') : '' /** * STEP Invisible hCaptcha * * Trigger hcaptcha.execute() programmatically as 'data-callback' function can't contain any arguments. * * https://docs.hcaptcha.com/configuration/#hcaptchaexecutewidgetid * * @since 1.9.2 */ if ( typeof window?.hcaptcha?.execute === 'function' && document.querySelector('.h-captcha[data-size="invisible"]') ) { hcaptcha .execute(widgetId, { async: true }) .then(() => { // Success: Submit form bricksSubmitForm(elementId, form, files, null) }) .catch((err) => { console.warn(err) }) return } // STEP: Turnstile (Cloudflare) let turnstileElement = form.querySelector('.cf-turnstile') if (turnstileElement && typeof window.turnstile !== 'undefined') { let widgetRef = bricksGetTurnstileWidgetReference(turnstileElement) let hiddenInput = turnstileElement.querySelector('input[name="cf-turnstile-response"]') // Queue this submit so Turnstile callback can resume it after a fresh token is generated (@since 2.3.2) const setPendingTurnstileSubmission = () => { window.bricksPendingTurnstileSubmission = { elementId: elementId, form: form, files: files } } //@since 2.3.2 const requestFreshTurnstileToken = () => { // Clear any stale token before requesting a new one to prevent timeout-or-duplicate errors. if (hiddenInput) { hiddenInput.value = '' } setPendingTurnstileSubmission() if (!widgetRef) { return } turnstile.reset(widgetRef) if (typeof turnstile.execute === 'function') { turnstile.execute(widgetRef) } } // If previous submit failed, force a fresh token to avoid timeout-or-duplicate errors (@since 2.3.2) if (form.dataset?.brxTurnstileRefresh === 'true') { form.dataset.brxTurnstileRefresh = 'false' requestFreshTurnstileToken() return } // Check if Turnstile has a response using the proper API let turnstileResponse = widgetRef ? turnstile.getResponse(widgetRef) : null // Check for the hidden input field if (!turnstileResponse) { turnstileResponse = hiddenInput ? hiddenInput.value : null } // If no response token exists yet, wait for Turnstile to complete if (!turnstileResponse) { setPendingTurnstileSubmission() // Prevent form submission until Turnstile completes return } } // STEP: reCAPTCHA (Google) let recaptchaElement = document.getElementById(`recaptcha-${elementId}`) let recaptchaErrorEl = form.querySelector('.recaptcha-error') if (!recaptchaElement) { bricksSubmitForm(elementId, form, files, null) return } let recaptchaSiteKey = recaptchaElement.getAttribute('data-key') if (!recaptchaSiteKey) { recaptchaErrorEl.classList.add('show') return } try { grecaptcha.ready(() => { try { grecaptcha .execute(recaptchaSiteKey, { action: 'bricks_form_submit' }) .then((token) => { recaptchaErrorEl.classList.remove('show') bricksSubmitForm(elementId, form, files, token) }) .catch((error) => { recaptchaErrorEl.classList.add('show') form.querySelector('.alert').innerText = `Google reCaptcha ${error}` }) } catch (error) { recaptchaErrorEl.classList.add('show') form.querySelector('.alert').innerText = `Google reCaptcha ${error}` } }) } catch (error) { recaptchaErrorEl.classList.add('show') form.querySelector('.alert').innerText = `Google reCaptcha ${error}` } }) } }) function bricksForm() { bricksFormFn.run() } function bricksGetTurnstileWidgetReference(turnstileElement) { if (!turnstileElement) { return null } let widgetRef = turnstileElement.getAttribute('data-widget-id') || turnstileElement.getAttribute('data-turnstile-widget-id') if (!widgetRef) { let turnstileIframe = turnstileElement.querySelector('iframe') if (turnstileIframe?.id) { widgetRef = turnstileIframe.id } } if (!widgetRef) { let turnstileElementId = turnstileElement.getAttribute('id') if (turnstileElementId) { widgetRef = `#${turnstileElementId}` } } return widgetRef } /** * Global Turnstile callback function * * Called by Turnstile when validation completes successfully. * Proceeds with form submission if there's a pending submission. * * @since 1.9.2 */ window.bricksTurnstileCallback = function (token) { // Check if there's a pending form submission waiting for Turnstile if (window.bricksPendingTurnstileSubmission) { const submission = window.bricksPendingTurnstileSubmission // Clear the pending submission window.bricksPendingTurnstileSubmission = null // Proceed with form submission now that Turnstile is complete bricksSubmitForm(submission.elementId, submission.form, submission.files, null) } } /** * Global Turnstile error callback function * * Called by Turnstile when validation fails or encounters an error. * Proceeds with form submission anyway to let backend handle the error. * * @since 1.9.2 */ window.bricksTurnstileErrorCallback = function (error) { // Check if there's a pending form submission waiting for Turnstile if (window.bricksPendingTurnstileSubmission) { const submission = window.bricksPendingTurnstileSubmission // Clear the pending submission window.bricksPendingTurnstileSubmission = null // Proceed with form submission anyway (backend will handle validation failure) bricksSubmitForm(submission.elementId, submission.form, submission.files, null) } } function bricksSubmitForm(elementId, form, files, recaptchaToken, nonceRefreshed) { // Is WordPress password form: Do a regular form submission (@since 1.11.1) if (form.action && form.action.includes('action=postpass')) { form.submit() return } // Prevent multiple submits (@since 2.1) if (form.dataset?.submitting === 'true') { return } let submitButton = form.querySelector('button[type=submit]') submitButton.classList.add('sending') submitButton.disabled = true // Disable submit button while submitting form.dataset.submitting = 'true' // Set submitting state (@since 2.1) // Form inside loop: Get the post ID from the form (@since 1.11.1) const loopId = form.dataset.loopObjectId ? form.dataset.loopObjectId : window.bricksData.postId let formData = new FormData(form) formData.append('action', 'bricks_form_submit') formData.append('loopId', loopId) // To render dynamic data (@since 1.11.1) formData.append('postId', window.bricksData.postId) formData.append('formId', elementId) formData.append('recaptchaToken', recaptchaToken || '') formData.append('nonce', window.bricksData.formNonce) formData.append('referrer', location.toString()) // Submit component ID (@since 2.1) const componentId = form.getAttribute('data-component-id') ?? false if (componentId) { formData.append('componentId', componentId) } // Get and parse notice data (@since 1.11.1) const noticeData = JSON.parse(form.getAttribute('data-notice')) // Submit current URL params (@since 1.11) let params = {} window.location.search .substring(1) .split('&') .forEach((param) => { let pair = param.split('=') params[pair[0]] = decodeURIComponent(pair[1]) }) formData.append('urlParams', JSON.stringify(params)) // Global element ID let globalId = form.getAttribute('data-global-id') if (globalId) { formData.append('globalId', globalId) } // Current language (Polylang, WPML) (@since 2.2) let lang = form.getAttribute('data-lang') if (lang) { formData.append('lang', lang) } // Current language code (Polylang, WPML) (#86c94gr3q; @since 2.3.2) let langCode = window.bricksData?.language || false if (langCode) { formData.append('langCode', langCode) } // Append files for (let inputName in files) { files[inputName].forEach((file) => { formData.append(`${inputName}[]`, file, file.name) }) } // Form submit event (@since 1.9.2) document.dispatchEvent(new CustomEvent('bricks/form/submit', { detail: { elementId, formData } })) let url = window.bricksData.ajaxUrl let xhr = new XMLHttpRequest() xhr.open('POST', url, true) // Successful response xhr.onreadystatechange = function () { let getResponse = (data) => { try { return JSON.parse(data) } catch (e) { return null } } let res = getResponse(xhr.response) if (window.bricksData.debug) { console.warn('bricks_form_submit', xhr, res) } // Unknown reason but we should allow user to resubmit (@since 2.2) if (!res) { submitButton.classList.remove('sending') submitButton.disabled = false form.dataset.submitting = 'false' } // Return: No response or response not yet DONE (@since 1.9.6) if (!res || xhr?.readyState != 4) { return } /** * Form success/error event * * error: res.success = false or res.data.type = 'error' * success: res.success = true and res.data.type = 'success' * * res.data.type = 'redirect' nothing happens * * @since 1.9.2 */ let formEventName // Check for invalid nonce (@since 1.9.6) if (res?.data?.code === 'invalid_nonce') { // Refresh form nonce and resubmit form (if refresh has not yet been attempted) if (!nonceRefreshed) { // Set submitting state to false to allow resubmit (@since 2.2) form.dataset.submitting = 'false' bricksRegenerateNonceAndResubmit(elementId, form, files, recaptchaToken) return } } else { if (res.success && res.data?.type === 'success') { formEventName = 'bricks/form/success' } else if (!res.success || res.data?.type === 'error') { formEventName = 'bricks/form/error' } } if (formEventName) { document.dispatchEvent( new CustomEvent(formEventName, { detail: { elementId, formData, res } }) ) } // Google Tag Manager: Newsletter signup (action: 'mailchimp' or 'sendgrid') if (res.success && (res.data?.action === 'mailchimp' || res.data?.action === 'sendgrid')) { window.dataLayer = window.dataLayer || [] window.dataLayer.push({ event: 'bricksNewsletterSignup' }) } let allowResubmit = true // Allow resubmit by default (@since 2.1) // Redirect after successful form submit if (res.success && res.data?.redirectTo) { allowResubmit = false setTimeout( () => { window.location.href = res.data.redirectTo }, parseInt(res.data?.redirectTimeout) || 0 ) } else if (res.success && res.data?.refreshPage) { allowResubmit = false // Refresh page after successful form submit (@since 1.11.1) setTimeout( () => { window.location.reload() }, 1000 // Wait for one second before refreshing to allow user to see the success message ) } // Generate form submit message HTML if (form.querySelector('.message')) { form.querySelector('.message').remove() } // Create message element let messageEl = document.createElement('div') messageEl.classList.add('message') let messageText = document.createElement('div') messageText.classList.add('text') // Show form response message if (res.data?.message) { if (res.data.message?.errors) { // User login/registration errors let errors = res.data.message.errors let errorKeys = Object.keys(errors) errorKeys.forEach((errorKey) => { messageText.innerHTML += errors[errorKey][0] + '<br>' }) } else { messageText.innerHTML = res.data.message } } messageEl.appendChild(messageText) if (res.data?.info) { let submitInfoInner = document.createElement('div') let submitInfoText = document.createElement('div') submitInfoText.innerHTML = res.data.info.join('<br>') messageEl.appendChild(submitInfoInner) submitInfoInner.appendChild(submitInfoText) } else { messageEl.classList.add(res.data.type) } // @since 1.11.1: Add notice controls (auto-close, close with button) if (noticeData) { const closeNotice = () => { // To add fade out effect messageEl.classList.add('closing') // Remove message element after animation setTimeout(() => { messageEl?.remove() }, 200) // Animation duration is defined in CSS } // Add close button if (noticeData.closeButton) { let closeButton = document.createElement('button') closeButton.classList.add('close') closeButton.innerHTML = '<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g stroke-linecap="round" stroke-width="2" stroke="currentcolor" fill="none" stroke-linejoin="round"><path d="M0.5,0.5l23,23"></path><path d="M23.5,0.5l-23,23"></path></g><path fill="none" d="M0,0h24v24h-24Z"></path></svg>' messageEl.appendChild(closeButton) closeButton.addEventListener('click', (e) => { e.preventDefault() closeNotice() }) } // Auto-close notice after .. ms const noticeCloseAfter = noticeData?.closeAfter if (noticeCloseAfter) { setTimeout(() => { closeNotice() }, parseInt(noticeCloseAfter)) } } form.appendChild(messageEl) submitButton.classList.remove('sending') // If allowResubmit, enable submit button and remove submitting state if (allowResubmit) { submitButton.disabled = false form.dataset.submitting = 'false' } // Success handling if (res.success) { // Clear form data, if action is not 'updatePost' (@since 2.1) if (res?.data?.reset != false) { form.reset() for (let inputName in files) { delete files[inputName] } let fileResults = bricksQuerySelectorAll(form, '.file-result.show') if (fileResults !== null) { fileResults.forEach((resultEl) => { resultEl.remove() }) } /** * Image: Remove all image previews */ let imagePreviewRemoveButtons = bricksQuerySelectorAll(form, '.image-preview .remove') imagePreviewRemoveButtons.forEach((removeButton) => { removeButton.click() }) } /** * Close WP login modal (in builder and wp-admin area) * * Go to top window and trigger click on button.wp-auth-check-close. * * @since 1.10.2 * @since 1.11.1 Unlock password protection form will not close the login modal */ if (window.top != window.self && !form.querySelector('input[name="brx_pp_temp_id"]')) { let closeLoginModelButton = window.top.document.querySelector('button.wp-auth-check-close') if (closeLoginModelButton) { closeLoginModelButton.click() } } } else { let turnstileElement = form.querySelector('.cf-turnstile') // Get current Turnstile widget reference (@since 2.3.2) let turnstileWidgetRef = bricksGetTurnstileWidgetReference(turnstileElement) let turnstileHiddenInput = turnstileElement ? turnstileElement.querySelector('input[name="cf-turnstile-response"]') : null // Reset Turnstile widget if form submit failed (@since 1.9.5) if (turnstileElement && typeof window.turnstile !== 'undefined' && turnstileWidgetRef) { turnstile.reset(turnstileWidgetRef) // Flag next submit to force token regeneration instead of reusing the failed token (@since 2.3.2) form.dataset.brxTurnstileRefresh = 'true' if (turnstileHiddenInput) { // Keep hidden response in sync with reset state (@since 2.3.2) turnstileHiddenInput.value = '' } } // Remove file result errors let fileResultErrors = bricksQuerySelectorAll(form, '.file-result.show.danger') if (fileResultErrors !== null) { fileResultErrors.forEach((resultEl) => { resultEl.remove() }) } } } xhr.send(formData) } /** * Regenerate nonce and resubmit form * * Needed for form submissions that require a valid/fresh nonce on cached pages. * * @since 1.9.6 */ function bricksRegenerateNonceAndResubmit(elementId, form, files, recaptchaToken) { let xhrNonce = new XMLHttpRequest() xhrNonce.open('POST', window.bricksData.ajaxUrl + '?t=' + new Date().getTime(), true) // Add timestamp to avoid caching xhrNonce.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') xhrNonce.onreadystatechange = function () { if (xhrNonce.readyState === XMLHttpRequest.DONE) { let newNonce = xhrNonce.responseText window.bricksData.formNonce = newNonce // Update the nonce in the global bricksData object bricksSubmitForm(elementId, form, files, recaptchaToken, true) // Resubmit with new nonce } } xhrNonce.send('action=bricks_regenerate_form_nonce') } /** * Form: Field type richtext (= TinyMCE editor) * * @since 2.1 */ const bricksTinyMCEFn = new BricksFunction({ parentNode: document, selector: '.brxe-form .form-field-richtext', windowVariableCheck: ['tinymce'], eachElement: (textareaElement) => { // Retrieve the TinyMCE settings from the data attribute let tinymceSettings = textareaElement.getAttribute('data-tinymce-settings') tinymceSettings = tinymceSettings ? JSON.parse(tinymceSettings) : {} const tinymce8 = window.tinymce8 || window.tinymce // Remove required attribute to prevent browser validation error "not focusable" (@since 2.2) // TinyMCE hides the textarea, making it non-focusable for HTML5 validation // Custom validation handles richtext field validation instead if (textareaElement.hasAttribute('required')) { textareaElement.removeAttribute('required') textareaElement.dataset.wasRequired = 'true' } // Initialize TinyMCE editor tinymce8.init({ ...tinymceSettings, selector: `#${textareaElement.id}`, suffix: '.min', license_key: 'gpl', // https://www.tiny.cloud/docs/tinymce/latest/license-key/ setup: function (editor) { // "Add media" button to open WP media modal editor.ui.registry.addButton('bricks_add_media', { text: 'Add Media', icon: 'image', onAction: function () { // Open the WordPress media modal let frame = window.wp.media({ title: 'Insert Media', button: { text: 'Insert' }, multiple: false }) frame.on('select', function () { let attachment = frame.state().get('selection').first().toJSON() // Insert into TinyMCE at cursor editor.insertContent( `<img src="${attachment.url}" alt="${attachment.alt}" title="${attachment.title}" />` ) }) frame.open() } }) const isDataErrorMessageSet = textareaElement.getAttribute('data-error-message') editor.on( 'change', // Trigger validation on change events (@since 2.2) window.bricksUtils.debounce(() => { editor.save() // Only add if data-error-message is set isDataErrorMessageSet && textareaElement.dispatchEvent(new Event('input', { bubbles: true })) }, 300) ) // Trigger validation on keyup events (@since 2.2) // Only add if data-error-message is set if (isDataErrorMessageSet) { editor.on( 'keyup', window.bricksUtils.debounce(() => { editor.save() textareaElement.dispatchEvent(new Event('input', { bubbles: true })) }, 300) ) // Trigger validation on blur event (@since 2.2) editor.on( 'blur', window.bricksUtils.debounce(() => { editor.save() textareaElement.dispatchEvent(new Event('blur', { bubbles: true })) }, 300) ) } } }) } }) function bricksTinyMCE() { bricksTinyMCEFn.run() } /** * IsotopeJS (Image Gallery & Posts) */ const bricksIsotopeFn = new BricksFunction({ parentNode: document, selector: '.bricks-layout-wrapper.isotope', forceReinit: true, // To update Isotope instance (@since 1.9.8) windowVariableCheck: ['Isotope'], eachElement: (el) => { let elementId = false let elementName = '' if (el.classList.contains('bricks-masonry')) { // masonry-layout element (@since 1.11.1) elementId = el.getAttribute('data-script-id') elementName = 'masonry-element' } else if (el.classList.contains('brxe-image-gallery')) { // image-gallery element elementId = el.getAttribute('data-script-id') elementName = 'image-gallery' } else { // posts element elementId = el.closest('.brxe-posts')?.getAttribute('data-script-id') elementName = 'posts' } if (!elementId || !elementName) { return } // Check if Isotope instance already exists (@since 1.9.8) if (bricksIsFrontend && window.bricksData.isotopeInstances[elementId]) { if (window.bricksData.isotopeInstances[elementId].element?.isConnected) { // The element is still in the DOM, Run update function bricksUtils.updateIsotopeInstance(elementId) return } else { // Maybe the element was removed from the DOM (Inside AJAX Popup) (@since 1.12) window.bricksData.isotopeInstances[elementId].instance.destroy() // Remove instance from global object delete window.bricksData.isotopeInstances[elementId] } } else if (window.bricksData.isotopeInstances[elementId]) { // In builder always destroy the instance and reinitialize it or it will be multiple instances (@since 2.0) window.bricksData.isotopeInstances[elementId].instance.destroy() // Remove instance from global object delete window.bricksData.isotopeInstances[elementId] } // isotopeInstance options let options = {} let layout = el.getAttribute('data-layout') if (elementName === 'masonry-element') { // masonry-layout element layout = 'masonry' let brxMasonryData = el.getAttribute('data-brx-masonry-json') || '{}' let brxMasonrySettings = {} try { brxMasonrySettings = JSON.parse(brxMasonryData) } catch (e) { console.error('Bricks: Invalid JSON data for Masonry element') } let transitionDuration = brxMasonrySettings?.transitionDuration || '0.4s' let transitionMode = brxMasonrySettings?.transitionMode || 'scale' options = { itemSelector: '.bricks-masonry > *:not(.bricks-isotope-sizer):not(.bricks-gutter-sizer)', percentPosition: true, masonry: { columnWidth: '.bricks-isotope-sizer', gutter: '.bricks-gutter-sizer', horizontalOrder: brxMasonrySettings?.horizontalOrder || false //@since 2.0 }, transitionDuration: transitionDuration } if (transitionDuration === '0s' || transitionDuration === '0') { // User might want to disable isotope animation and use CSS animations for the child elements options.hiddenStyle = { opacity: 1 } options.visibleStyle = { opacity: 1 } options.instantLayout = true } else { // Use Brick's transition mode switch (transitionMode) { case 'fade': options.hiddenStyle = { opacity: 0 } options.visibleStyle = { opacity: 1 } break case 'slideLeft': options.hiddenStyle = { opacity: 0, transform: 'translateX(-50%)' } options.visibleStyle = { opacity: 1, transform: 'translateX(0)' } break case 'slideRight': options.hiddenStyle = { opacity: 0, transform: 'translateX(50%)' } options.visibleStyle = { opacity: 1, transform: 'translateX(0)' } break case 'skew': options.hiddenStyle = { opacity: 0, transform: 'skew(20deg)' } options.visibleStyle = { opacity: 1, transform: 'skew(0)' } break default: // 'scale' is isotopes default break } } } else { // posts or image-gallery element options = { itemSelector: '.bricks-layout-item', percentPosition: true } if (layout === 'grid') { options.layoutMode = 'fitRows' options.fitRows = { gutter: '.bricks-gutter-sizer' } } else if (layout === 'masonry' || layout === 'metro') { options.masonry = { columnWidth: '.bricks-isotope-sizer', gutter: '.bricks-gutter-sizer' } } } let isotopeInstance = new Isotope(el, options) /** * Add isotope-before-init class for isotope elements to avoid unstyled content on initial load. Remove the class after init Isotope. * * Cannot use layoutComplete as certain elements not triggered if instantLayout is enabled * * @since 1.12 */ setTimeout(() => { el.classList.remove('isotope-before-init') }, 250) // Isotope filtering (https://isotope.metafizzy.co/filtering.html) // TODO Make it work on grid & list layout as well (those don't have .isotope class) let filters = el.parentNode.querySelector('.bricks-isotope-filters') if (filters) { filters.addEventListener('click', (e) => { let filterValue = e.target.getAttribute('data-filter') let activeFilter = filters.querySelector('li.active') if (!filterValue || !bricksIsFrontend) { return } if (activeFilter) { activeFilter.classList.remove('active') } e.target.classList.add('active') // Example: https://codepen.io/desandro/pen/BgcCD isotopeInstance.arrange({ filter: filterValue }) }) } // Store isotopeInstance in window.bricksData.isotopeInstances window.bricksData.isotopeInstances[elementId] = { elementId: elementId, instance: isotopeInstance, layout: layout, filters: filters, options: options, element: el, elementName: elementName } /** * Handle native browser loading="lazy" attribute * * Ensure layout updates after every 20% progress and once done. * * @since 1.9.9 */ bricksUtils.updateIsotopeOnImageLoad(elementId) }, listenerHandler: (event) => { // Need some delay to allow for new elements to be added to the DOM (e.g. after Query filters DOM added) (@since 1.9.8) setTimeout(() => { bricksIsotopeFn.run() }, 100) } }) function bricksIsotope() { bricksIsotopeFn.run() } /** * Update Isotope instance on certain events * * Centralize usage of bricksUtils.updateIsotopeInstance in one function. * * Listening to: bricks/tabs/changed, bricks/accordion/open * Not listening to: bricks/ajax/end or bricks/ajax/query_result/displayed (might cause double execution) * * @since 1.9.8 * @since 1.10: Listening to window onload to ensure isotopes are updated after all CSS are loaded */ function bricksIsotopeListeners() { // Tab change event: Pane with isotope will be visible after tab change document.addEventListener('bricks/tabs/changed', (event) => { const tabActivePane = event.detail?.activePane || false if (tabActivePane) { // Check if the pane contains an isotope instance const isotopeElements = tabActivePane.querySelectorAll( '.bricks-layout-wrapper.isotope[data-script-id]' ) if (isotopeElements.length) { isotopeElements.forEach((el) => { const isotopeId = el.getAttribute('data-script-id') // setTimeout 0 for smoother transition setTimeout(() => { bricksUtils.updateIsotopeInstance(isotopeId) }, 0) }) } } }) // Accordion change event: Pane with isotope will be visible after accordion change document.addEventListener('bricks/accordion/open', (event) => { const openItem = event.detail?.openItem || false if (openItem) { // Check if the item contains an isotope instance const isotopeElements = openItem.querySelectorAll( '.bricks-layout-wrapper.isotope[data-script-id]' ) if (isotopeElements.length) { isotopeElements.forEach((el) => { const isotopeId = el.getAttribute('data-script-id') // setTimeout 0 for smoother transition setTimeout(() => { bricksUtils.updateIsotopeInstance(isotopeId) }, 0) }) } } }) /** * New nodes added to the DOM event * * Update Isotope instance immediately or the new nodes will be visible for a short time. * * @since 1.11.1 */ document.addEventListener('bricks/ajax/nodes_added', (event) => { const queryId = event?.detail?.queryId if (!queryId) { return } // Get query Instance const queryInstance = window.bricksData.queryLoopInstances[queryId] || false if (!queryInstance) { return } // Check results container has bricks-masonry or isotope instance has been initialized const parentIsMasonry = queryInstance.resultsContainer?.classList?.contains('bricks-masonry') || window.bricksData.isotopeInstances[queryId] if (parentIsMasonry) { if (window.bricksData.isotopeInstances[queryId]) { bricksUtils.updateIsotopeInstance(queryId) } else { bricksUtils.updateIsotopeInstance( queryInstance.resultsContainer?.getAttribute('data-script-id') ) } } }) /** * When megamenu is repositioned, update isotope instance (#86c3wdmpd) * * @since 2.0 */ document.addEventListener('bricks/megamenu/repositioned', (event) => { const submenu = event.detail?.submenu || false if (submenu) { // Check if the submenu contains an isotope instance const isotopeElements = submenu.querySelectorAll( '.bricks-layout-wrapper.isotope[data-script-id]' ) if (isotopeElements.length) { isotopeElements.forEach((el) => { const isotopeId = el.getAttribute('data-script-id') // setTimeout 0 for smoother transition setTimeout(() => { bricksUtils.updateIsotopeInstance(isotopeId) }, 0) }) } } }) // Ensure each Isotope instance runs updateIsotopeInstance after all CSS are loaded (@since 1.10) window.addEventListener('load', () => { bricksIsotope() }) } /** * Element: Map (Leaflet) * * Init Leaflet * * @since 2.1 */ const bricksMapLeafletFn = new BricksFunction({ parentNode: document, selector: '.brxe-map-leaflet', eachElement: (mapEl, index) => { const settings = JSON.parse(mapEl.dataset.mapOptions) // Get unique map ID from data-script-id attribute (@since 2.2) const mapId = mapEl.dataset.scriptId || mapEl.id || `leaflet-map-${index}` // Prepeares the base map layers, that we will append to map later const prepareBaseMaps = () => { const baseMaps = [] // Loop trough all base layers settings.layers.forEach((layer) => { const { name: layerName, url: layerUrl, ...layerOptions } = layer // Create new layers (L.tileLayer) baseMaps[layerName] = L.tileLayer(layerUrl, layerOptions) }) return baseMaps } // Add all markers to the map, directly. "map" should be available in the scope const prepareMarkers = () => { // Loop through all addresses to set markers, infoBoxes, bounds etc. for (let i = 0; i < settings.markers.length; i++) { let options = settings.markers[i] const markerOptions = {} // Add custom marker icon if (options.icon) { markerOptions.icon = L.icon(options.icon) } // Add marker to map (important: We directly add to the map element) const m = L.marker([options.lat, options.lng], markerOptions).addTo(map) // Add popup to marker (if popupText is set) if (options.popupText) { m.bindPopup(options.popupText) } } } // Layers const baseMaps = prepareBaseMaps() const baseMapsLayers = Object.values(baseMaps) // Always set first layer as a default (directly via map settings) if (baseMapsLayers.length) { settings.map['layers'] = baseMapsLayers[0] } // Init a map, pass settings to it var map = L.map(mapEl, settings.map) // Add baseMap layers (only if more than one, so that we don't have "Layer" control with only one layer) if (baseMapsLayers.length > 1) { L.control.layers(baseMaps).addTo(map) } prepareMarkers() // Store Leaflet map instance globally for runtime access (window.bricksData.leafletMapInstances[mapId]) (@since 2.2) if (!window.bricksData.leafletMapInstances) { window.bricksData.leafletMapInstances = {} } window.bricksData.leafletMapInstances[mapId] = map } }) function bricksMapLeaflet() { // Only run when "name" function/variable is available (@since 2.1) const whenAvailable = (name, callback, timeout = 500) => { // Store the interval id var intervalId = window.setInterval(function () { if (window[name]) { // Clear the interval id window.clearInterval(intervalId) // Call back callback(window[name]) } }, timeout) } // We need to use whenAvailable to ensure that the Leaflet library is loaded, // but only if there is a Leaflet map on the page (.brxe-map-leaflet class) if (document.querySelector('.brxe-map-leaflet')) { whenAvailable('L', () => { // Timeout in builder setTimeout( () => { bricksMapLeafletFn.run() }, bricksIsFrontend ? 0 : 1000 // If we are in a builder, wait 1 second before running the function ) }) } return } /** * Element: Pie Chart */ const bricksPieChartFn = new BricksFunction({ parentNode: document, selector: '.brxe-pie-chart', windowVariableCheck: ['EasyPieChart'], eachElement: (element) => { new BricksIntersect({ element: element, callback: (el) => { // HTMLCollection of canvas (grab first one) let canvas = el.getElementsByTagName('canvas') // Remove canvas first, before EasyPieChart init if (canvas.length) { canvas[0].remove() } /** * Extract the value of a CSS variable from a given string * * If the string is a CSS variable (in the format 'var(--variable-name)'), * retrieve the variable value from the computed styles of the element. * Otherwise, it returns the original string. * * @since 1.9.4 */ const extractCSSVar = (cssVarString) => { const cssVarPattern = /var\((--[^)]+)\)/ const match = cssVarString.match(cssVarPattern) if (match) { // Extract the variable name from the match and get its value const varName = match[1] return getComputedStyle(el).getPropertyValue(varName).trim() } // Return the original string if it's not a CSS variable return cssVarString } const barColor = extractCSSVar(el.dataset.barColor) const trackColor = extractCSSVar(el.dataset.trackColor) new EasyPieChart(el, { size: el.dataset.size && el.dataset.size > 0 ? el.dataset.size : 160, lineWidth: el.dataset.lineWidth, barColor: barColor, trackColor: trackColor, lineCap: el.dataset.lineCap, scaleColor: el.dataset.scaleColor, scaleLength: el.dataset.scaleLength, rotate: 0 }) }, threshold: 1 }) } }) function bricksPieChart() { bricksPieChartFn.run() } /** * Element: Pricing Tables (Pricing toggle) */ const bricksPricingTablesFn = new BricksFunction({ parentNode: document, selector: '.brxe-pricing-tables', eachElement: (element) => { let tabs = bricksQuerySelectorAll(element, '.tab') let pricingTables = bricksQuerySelectorAll(element, '.pricing-table') tabs.forEach((tab) => { if (tab.classList.contains('listening')) { return } tab.classList.add('listening') tab.addEventListener('click', () => { // Return if selected tab is .active if (tab.classList.contains('active')) { return } // Toggle pricing table .active pricingTables.forEach((pricingTable) => { pricingTable.classList.toggle('active') }) // Toggle .active tab tabs.forEach((tab) => { tab.classList.remove('active') }) tab.classList.add('active') }) }) } }) function bricksPricingTables() { bricksPricingTablesFn.run() } /** * Element: Post Reading Progress Bar * * @since 1.8.5 */ const bricksPostReadingProgressBarFn = new BricksFunction({ parentNode: document, selector: '.brxe-post-reading-progress-bar', eachElement: (element) => { // Get content element let contentEl = element.dataset.contentSelector ? document.querySelector(element.dataset.contentSelector) : false window.addEventListener('scroll', () => { // Scrolled from document top let scrolled = window.scrollY // Document height minus the visible part of the window let height = document.documentElement.scrollHeight - document.documentElement.clientHeight // STEP: Calculate scroll position of specific element if (contentEl) { let rect = contentEl.getBoundingClientRect() height = rect.height scrolled = rect.top > 0 ? 0 : -rect.top } // Calculate the percentage of the document or contentEl that has been scrolled from the top element.setAttribute('value', Math.ceil((scrolled / height) * 100)) }) } }) function bricksPostReadingProgressBar() { bricksPostReadingProgressBarFn.run() } /** * Element: Progress Bar (animate fill-up bar) */ const bricksProgressBarFn = new BricksFunction({ parentNode: document, selector: '.brxe-progress-bar .bar span', eachElement: (bar) => { new BricksIntersect({ element: bar, callback: () => { if (bar.dataset.width) { setTimeout(() => { bar.style.width = bar.dataset.width }, 'slow') } }, threshold: 1 }) } }) function bricksProgressBar() { bricksProgressBarFn.run() } /** * SplideJS: For all nestable elements * * @since 1.5 */ const bricksSplideFn = new BricksFunction({ parentNode: document, selector: '.brxe-slider-nested.splide', windowVariableCheck: ['Splide'], forceReinit: (element, index) => { // Allow force reinit inside builder return !bricksIsFrontend }, eachElement: (splideElement) => { // Add .splide__slide to individual slide (perfect for in/builder) let slides = bricksQuerySelectorAll(splideElement, [ '.splide__list > .brxe-container', '.splide__list > .brxe-block', '.splide__list > .brxe-div' ]) slides.forEach((slide) => { slide.classList.add('splide__slide') slide.dataset.id = slide.id }) let scriptId = splideElement.dataset.scriptId // Destroy existing splideJS instance if (window.bricksData.splideInstances.hasOwnProperty(scriptId)) { window.bricksData.splideInstances[scriptId].destroy() } let splideOptions = {} try { splideOptions = JSON.parse(splideElement.dataset.splide) // Add i18n configuration (@since 1.12.2) // See: https://splidejs.com/guides/i18n/#default-texts splideOptions.i18n = { prev: window.bricksData.i18n.prevSlide, next: window.bricksData.i18n.nextSlide, first: window.bricksData.i18n.firstSlide, last: window.bricksData.i18n.lastSlide, slideX: window.bricksData.i18n.slideX, pageX: window.bricksData.i18n.slideX, // Reuse slideX for pageX play: window.bricksData.i18n.play, pause: window.bricksData.i18n.pause, carousel: window.bricksData.i18n.splide.carousel, select: window.bricksData.i18n.splide.select, slide: window.bricksData.i18n.splide.slide, slideLabel: window.bricksData.i18n.splide.slideLabel } } catch (e) { console.warn('bricksSplide: Error parsing JSON of data-script-args', scriptId) } // STEP: If splideOptions.direction is 'auto', set it based on the document direction // and replace it, but without "i18n" object (@since 2.2) if (splideOptions.direction === 'auto') { splideOptions.direction = document.dir === 'rtl' ? 'rtl' : 'ltr' splideElement.dataset.splide = JSON.stringify({ ...splideOptions, i18n: undefined }) } // Init & mount splideJS let splideInstance = new Splide(splideElement) // https://splidejs.com/guides/apis/#go splideInstance.mount() // Set auto height if (splideOptions?.autoHeight) { let updateHeight = () => { try { // Ensure splideInstance and Components are defined let slidesComponent = splideInstance?.Components?.Slides if (!slidesComponent) { console.error('Slides component is undefined:', scriptId) return } let slideObject = slidesComponent.getAt(splideInstance.index) // Ensure slideObject and slide are defined let slide = slideObject?.slide if (!slide) { console.error('Slide is undefined:', scriptId) return } // Ensure slide.parentElement is defined before setting style let parentElement = slide.parentElement if (!parentElement) { console.error('Parent element is undefined:', scriptId) return } // Get height of largest visible slide (in case "Items to show" > 1) let autoHeight = 0 slides.forEach((slide) => { if (slide.classList.contains('is-visible') && slide.offsetHeight > autoHeight) { autoHeight = slide.offsetHeight } }) if (!autoHeight) { autoHeight = slide.offsetHeight } parentElement.style.height = `${autoHeight}px` } catch (error) { // Log any other unexpected errors console.error('An error occurred while updating the height:', error, scriptId) } } // Call updateHeight if splideInstance is defined if (splideInstance) { updateHeight() // Attach event listeners if splideInstance.on is a function if (typeof splideInstance.on === 'function') { splideInstance.on('move resize', updateHeight) splideInstance.on('moved', updateHeight) // Needed when "Items to show" > 1 to get visible slides } else { console.error('splideInstance.on is not a function') } } else { console.error('splideInstance is undefined') } } // Store splideJS instance in bricksData to destroy and re-init window.bricksData.splideInstances[scriptId] = splideInstance // NOTE: To ensure Bricks element ID is used (important also for builder), and not the randomly by splide generated ID (see: slide.js:mount()) // Improvement: Tweak CSS selector for 'bricksSplide' elements to use #parent.id > .{slide-class} slides.forEach((slide, index) => { if (slide.dataset.id) { slide.id = slide.dataset.id // Set 'aria-controls' value to slide.id let pagination = splideElement.querySelector('.splide__pagination') if (pagination) { let paginationButton = pagination.querySelector( `li:nth-child(${index + 1}) .splide__pagination__page` ) if (paginationButton) { paginationButton.setAttribute('aria-controls', slide.id) } } } // Get & set background-image added via lazy load through 'data-style' attribute inside query loop if (!slide.classList.contains('bricks-lazy-hidden')) { let style = slide.getAttribute('style') || '' if (slide.dataset.style) { style += slide.dataset.style slide.setAttribute('style', style) } } }) } }) function bricksSplide() { bricksSplideFn.run() } /** * Refresh Splide when hidden containers become visible. * * Keep this as one document listener instead of registering one listener per slider instance. */ function bricksSplideListeners() { const refreshSplideInstances = (element) => { if (!element) { return } // Limit refreshes to sliders inside the tab pane or accordion item that changed. let splideElements = element.matches?.('.brxe-slider-nested.splide') ? [element] : [] splideElements = [...splideElements, ...element.querySelectorAll('.brxe-slider-nested.splide')] splideElements.forEach((splideElement) => { let scriptId = splideElement?.dataset?.scriptId let splideInstance = scriptId ? window.bricksData?.splideInstances?.[scriptId] : false if (!splideInstance) { return } // Splide can clone a playing preview-image iframe during refresh. Keep the active iframe intact. let playedPreviewVideo = splideElement.querySelector( 'iframe[data-bricks-video-preview-iframe="true"][src*="autoplay=1"][src*="youtube"], iframe[data-bricks-video-preview-iframe="true"][src*="autoplay=1"][src*="vimeo"]' ) if (playedPreviewVideo) { return } // Let the browser apply the visibility/layout change before Splide reads dimensions. window.requestAnimationFrame(() => { if (splideElement.isConnected) { splideInstance.refresh() } }) }) } document.addEventListener('bricks/tabs/changed', (event) => { refreshSplideInstances(event.detail?.activePane) }) document.addEventListener('bricks/accordion/open', (event) => { refreshSplideInstances(event.detail?.openItem) }) } /** * SwiperJS touch slider: Carousel, Slider, Testimonials */ const bricksSwiperFn = new BricksFunction({ parentNode: document, selector: '.bricks-swiper-container', windowVariableCheck: ['Swiper'], forceReinit: (element, index) => { // Allow Force reinit inside Builder (@since 1.8.2) return !bricksIsFrontend }, eachElement: (swiperElement) => { let scriptArgs try { scriptArgs = JSON.parse(swiperElement.dataset.scriptArgs) } catch (e) { console.warn('bricksSwiper: Error parsing JSON of data-script-args', swiperElement) scriptArgs = {} } let element = swiperElement.classList.contains('[class*=brxe-]') ? swiperElement : swiperElement.closest('[class*=brxe-]') if (!element) { return } // @since 1.5: Nestable elements: Add .swiper-slide to individual slide (perfect for in/builder) let slides = bricksQuerySelectorAll(swiperElement, [ '.splide__list > .brxe-container', '.splide__list > .brxe-block', '.splide__list > .brxe-div' ]) slides.forEach((slide) => slide.classList.add('swiper-slide')) let scriptId = element.dataset.scriptId let swiperInstance = window.bricksData.swiperInstances.hasOwnProperty(scriptId) ? window.bricksData.swiperInstances[scriptId] : undefined if (swiperInstance) { swiperInstance.destroy() } scriptArgs.observer = false // Not working and not necessary (= set to false) scriptArgs.observeParents = true scriptArgs.resizeObserver = true // Defaults scriptArgs.slidesToShow = scriptArgs.hasOwnProperty('slidesToShow') ? scriptArgs.slidesToShow : 1 scriptArgs.slidesPerGroup = scriptArgs.hasOwnProperty('slidesPerGroup') ? scriptArgs.slidesPerGroup : 1 scriptArgs.speed = scriptArgs.hasOwnProperty('speed') ? parseInt(scriptArgs.speed) : 300 scriptArgs.effect = scriptArgs.hasOwnProperty('effect') ? scriptArgs.effect : 'slide' scriptArgs.spaceBetween = scriptArgs.hasOwnProperty('spaceBetween') ? scriptArgs.spaceBetween : 0 scriptArgs.initialSlide = scriptArgs.hasOwnProperty('initialSlide') ? scriptArgs.initialSlide : 0 // Enable keyboard control when in viewport (only on frontend as it messes with contenteditable in builder) scriptArgs.keyboard = { enabled: bricksIsFrontend, onlyInViewport: true, pageUpDown: false } // Disabled & hide navigation buttons when there are not enough slides for sliding scriptArgs.watchOverflow = true // Effect: Flip if (scriptArgs.hasOwnProperty('effect') && scriptArgs.effect === 'flip') { scriptArgs.flipEffect = { slideShadows: false } } // Set crossFade to true to avoid seeing content behind or underneath slide (https://swiperjs.com/swiper-api#fade-effect) if (scriptArgs.hasOwnProperty('effect') && scriptArgs.effect === 'fade') { scriptArgs.fadeEffect = { crossFade: true } } // Arrows if (scriptArgs.navigation) { scriptArgs.navigation = { prevEl: element.querySelector('.bricks-swiper-button-prev'), nextEl: element.querySelector('.bricks-swiper-button-next') } } // Dots if (scriptArgs.pagination) { scriptArgs.pagination = { el: element.querySelector('.swiper-pagination'), type: 'bullets', clickable: true } if (scriptArgs.dynamicBullets == true) { delete scriptArgs.dynamicBullets scriptArgs.pagination.dynamicBullets = true // scriptArgs.pagination.dynamicMainBullets = 1 } } // Add a11y configuration (@since 1.12.2) // See: https://swiperjs.com/swiper-api#accessibility-parameters scriptArgs.a11y = { prevSlideMessage: window.bricksData.i18n.prevSlide, nextSlideMessage: window.bricksData.i18n.nextSlide, firstSlideMessage: window.bricksData.i18n.firstSlide, lastSlideMessage: window.bricksData.i18n.lastSlide, paginationBulletMessage: window.bricksData.i18n.swiper.paginationBulletMessage, slideLabelMessage: window.bricksData.i18n.swiper.slideLabelMessage } swiperInstance = new Swiper(swiperElement, scriptArgs) // Store swiper instance in bricksData to destroy and re-init window.bricksData.swiperInstances[scriptId] = swiperInstance } }) function bricksSwiper() { bricksSwiperFn.run() } // Recheck viewport-based Swiper breakpoints when the slider container itself no longer resizes. function bricksSwiperUpdateBreakpoints() { Object.keys(window.bricksData?.swiperInstances || {}).forEach((scriptId) => { const swiperInstance = window.bricksData.swiperInstances[scriptId] if (!swiperInstance || swiperInstance.destroyed) { return } if (typeof swiperInstance.setBreakpoint === 'function') { swiperInstance.setBreakpoint() } if (typeof swiperInstance.update === 'function') { swiperInstance.update() } }) } /** * Element: Video (YouTube, Vimeo, File URL) */ const bricksVideoFn = new BricksFunction({ parentNode: document, selector: '.brxe-video', eachElement: (element) => { // Remove overlay & icon if (bricksIsFrontend) { const onElementAction = () => { let videoOverlay = element.querySelector('.bricks-video-overlay') let videoOverlayIcon = element.querySelector('.bricks-video-overlay-icon') if (videoOverlay) { videoOverlay.remove() } if (videoOverlayIcon) { videoOverlayIcon.remove() } } element.addEventListener('click', onElementAction) // Remove overlay & icon on keydown (Enter/Space) (@since 1.11) element.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { onElementAction() } // Prevent default action (e.g. scrolling down on space), if there is no VIDEO elements inside the element if (!element.querySelector('video') && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault() } }) } // 'video' HTML (videoType: media, file, meta) let videoElement = element.querySelector('video') if (!videoElement) { return } // Init custom HTML5 <video> player (https://plyr.io) if (window.hasOwnProperty('Plyr')) { let elementId = element.dataset.scriptId let video = element.querySelector('.bricks-plyr') let player = window.bricksData?.videoInstances?.[elementId] || undefined if (player) { player.destroy() } if (video) { // 'autoplay' only runs if video is 'muted' player = new Plyr(video) } window.bricksData.videoInstances[elementId] = player } // Necessary for autoplaying in iOS (https://webkit.org/blog/6784/new-video-policies-for-ios/) if (videoElement.hasAttribute('autoplay')) { videoElement.setAttribute('playsinline', true) } // Add data-is-loaded attribute when video starts playing (@since 2.2) // This allows CSS to differentiate between poster display and active video playback videoElement.addEventListener('play', function () { this.setAttribute('data-is-loaded', 'true') }) } }) function bricksVideo() { bricksVideoFn.run() } /** * Load Facebook SDK & render Facebook widgets * * https://developers.facebook.com/docs/javascript/reference/FB.init/v3.3 * * @since 1.4 Use XMLHttpRequest instead of jquery.ajax() */ function bricksFacebookSDK() { // Return: Page has no Facebook Page element let facebookPageElement = document.querySelector('.brxe-facebook-page') if (!facebookPageElement) { return } let locale = window.bricksData.hasOwnProperty('locale') ? window.bricksData.locale : 'en_US' let facebookAppId = window.bricksData.hasOwnProperty('facebookAppId') ? window.bricksData.facebookAppId : null let facebookSdkUrl = `https://connect.facebook.net/${locale}/sdk.js` let xhr = new XMLHttpRequest() xhr.open('GET', facebookSdkUrl) // Successful response: Create & add FB script to DOM and run function to generate Facebook Page HTML xhr.onreadystatechange = function () { if (this.readyState == 4 && this.status == 200) { let fbScript = document.createElement('script') // Builder: Compatible with Cloudflare Rocket Loader (@since 2.0) if (!bricksIsFrontend && window.bricksData?.builderCloudflareRocketLoader) { fbScript.setAttribute('data-cfasync', 'false') } fbScript.type = 'text/javascript' fbScript.id = 'bricks-facebook-page-sdk' fbScript.appendChild(document.createTextNode(xhr.responseText)) document.body.appendChild(fbScript) FB.init({ appId: facebookAppId, version: 'v3.3', xfbml: true // render }) } } xhr.send() } /** * Prettify <pre> and <code> HTML tags * * https://github.com/googlearchive/code-prettify */ const bricksPrettifyFn = new BricksFunction({ parentNode: document, selector: '.prettyprint.prettyprinted', run: () => { if (!window.hasOwnProperty('PR')) { return } PR.prettyPrint() // Builder: Re-init prettify let prettyprinted = bricksQuerySelectorAll(document, '.prettyprint.prettyprinted') if (!bricksIsFrontend && prettyprinted.length) { prettyprinted.forEach((prettyprint) => { prettyprint.classList.remove('prettyprinted') PR.prettyPrint() }) } } }) function bricksPrettify() { bricksPrettifyFn.run() } /** * Improve a11y keyboard navigation by making sure after skipping to the content, the next tab hit continues down the content * * https://axesslab.com/skip-links/ */ function bricksSkipLinks() { let skipLinks = bricksQuerySelectorAll(document, '.skip-link') if (!skipLinks) { return } skipLinks.forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault() let toElement = document.getElementById(link.href.split('#')[1]) if (toElement) { toElement.setAttribute('tabindex', '-1') toElement.addEventListener( 'blur', () => { toElement.removeAttribute('tabindex') }, { once: true } ) toElement.focus() } }) }) } /** * Element: Image Gallery with Infinite Scroll (Load More on Scroll) * * @since 2.3.1 */ const bricksGalleryInfiniteScrollFn = new BricksFunction({ parentNode: document, selector: '.brxe-image-gallery[data-brx-load-more="gallery"]', eachElement: (gallery) => { const loadMoreSettings = bricksUtils.imageGalleryGetResolvedLoadMoreSettings(gallery) const infinite = loadMoreSettings?.infinite == true || false if (!infinite || gallery.dataset?.brxGalleryInfiniteInit === 'true') { return } gallery.dataset.brxGalleryInfiniteInit = 'true' const getTrail = () => gallery.querySelector(':scope > .brx-gallery-load-more-trail') const getDelayMs = (value, fallback = 600) => { if (value === undefined || value === null || value === '') { return fallback } if (typeof value === 'number') { return Math.max(0, parseInt(value)) } if (typeof value === 'string') { if (value.includes('ms')) { return Math.max(0, parseInt(value)) } if (value.includes('s')) { return Math.max(0, Math.round(parseFloat(value) * 1000)) } const parsed = parseInt(value) return isNaN(parsed) ? fallback : Math.max(0, parsed) } return fallback } // Minimum 1px margin to ensure intersection is triggered const getMarginValue = (value, fallback = '200px') => { if (value === undefined || value === null || value === '') { return fallback } if (typeof value === 'number') { return `${Math.max(1, parseInt(value))}px` } // Only allow px if (typeof value === 'string' && value.includes('px')) { const parsed = parseInt(value) return isNaN(parsed) ? fallback : `${Math.max(1, parsed)}px` } return fallback } // Use scrollDelay from settings const loadDelay = getDelayMs(loadMoreSettings?.scrollDelay, 600) const observerMargin = getMarginValue( loadMoreSettings?.scrollOffset ?? loadMoreSettings?.observerMargin, '200px' ) let isLoading = false let loadTimer = null let nextAllowedAt = Date.now() + loadDelay let observeToken = 0 const observeTrail = () => { const trail = getTrail() if (!trail) { return } // Increment observeToken to invalidate previous observers when a new one is created (e.g. due to rapid scrolling) const currentToken = ++observeToken new BricksIntersect({ element: trail, once: true, threshold: 0, rootMargin: observerMargin, callback: () => { // Ignore stale observers if (currentToken !== observeToken) { return } scheduleLoad() } }) } // Load more function with delay and locking to prevent multiple simultaneous loads const scheduleLoad = () => { if (isLoading || loadTimer) { return } // Calculate delay to ensure a minimum gap between loads, even if user scrolls rapidly const wait = Math.max(0, nextAllowedAt - Date.now()) loadTimer = setTimeout(() => { loadTimer = null isLoading = true const hasMore = bricksUtils.imageGalleryLoadMore(gallery) isLoading = false nextAllowedAt = Date.now() + loadDelay if (!hasMore) { return } const nextTrail = getTrail() // Continue if still visible, otherwise wait for next intersection if (nextTrail && BricksIsInViewport(nextTrail)) { scheduleLoad() } else { observeTrail() } }, wait) } // Initial check const trail = getTrail() if (!trail) { return } // If already visible, schedule with strict delay if (BricksIsInViewport(trail)) { scheduleLoad() } else { observeTrail() } } }) function bricksGalleryInfiniteScroll() { bricksGalleryInfiniteScrollFn.run() } /** * Bind element interactions to elements (frontend only) * * @since 1.6 */ const bricksInteractionsFn = new BricksFunction({ parentNode: document, selector: '[data-interactions]', frontEndOnly: true, eachElement: (sourceEl) => { let interactions = [] try { interactions = JSON.parse(sourceEl.dataset.interactions) } catch (e) { console.info('error:bricksInteractions', e) return false } let interactionGroupId = sourceEl.dataset?.interactionId || false if (!interactions || !interactionGroupId) { return } interactions.forEach((interaction) => { let bindToDocument = false if (!interaction?.trigger) { return } // trigger: 'click', 'mouseover', 'scroll', etc. if (interaction.trigger === 'scroll') { let scrollOffset = 0 if (interaction?.scrollOffset) { scrollOffset = interaction?.scrollOffset.replace('px', '') if (scrollOffset.includes('%')) { let documentHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight ) scrollOffset = (documentHeight / 100) * parseInt(scrollOffset) } else if (scrollOffset.includes('vh')) { scrollOffset = (window.innerHeight / 100) * parseInt(scrollOffset) } } interaction.scrollOffset = scrollOffset } else if (interaction.trigger === 'mouseleaveWindow') { interaction.trigger = 'mouseleave' bindToDocument = true } // Return: No more sourceEl if (!sourceEl) { return } // STEP: store the source element interaction.el = sourceEl // STEP: Interaction group Id interaction.groupId = bindToDocument ? 'document' : interactionGroupId // STEP: Store interaction if (!window.bricksData?.interactions) { window.bricksData.interactions = [] } window.bricksData.interactions.push(interaction) // STEP: Create interaction event listeners switch (interaction.trigger) { case 'click': case 'mouseover': case 'mouseenter': case 'mouseleave': case 'focus': case 'blur': let attachEl = bindToDocument ? document.documentElement : sourceEl attachEl.addEventListener( interaction.trigger, (e) => bricksInteractionCallback(e, interaction), { once: interaction?.runOnce } ) break // @since 1.8.4 case 'animationEnd': let targetAnimationId = interaction?.animationId || false // Target animation not set: Find last previous animation interaction (action is 'startAnimation'), and must be in the same interaction group if (!targetAnimationId) { let previousInteraction = window.bricksData.interactions.filter((int) => { return ( int.groupId === interactionGroupId && int.action === 'startAnimation' && int.id !== interaction.id ) }) if (previousInteraction.length) { targetAnimationId = previousInteraction[previousInteraction.length - 1].id } } // @since 1.8.4 - Listen to `bricks/animation/end/${animationId}` if (targetAnimationId && targetAnimationId !== interaction.id) { document.addEventListener( `bricks/animation/end/${targetAnimationId}`, (evt) => { bricksInteractionCallbackExecution(sourceEl, interaction) }, { once: interaction?.runOnce } ) } break case 'contentLoaded': let delay = interaction?.delay || 0 if (delay && delay.includes('ms')) { delay = parseInt(delay) } else if (delay && delay.includes('s')) { delay = parseFloat(delay) * 1000 } setTimeout(() => { bricksInteractionCallbackExecution(sourceEl, interaction) }, delay) break case 'enterView': new BricksIntersect({ element: sourceEl, callback: (sourceEl) => { // Prevent re-triggering if element is already animating (@since 2.2) if ( interaction.action === 'startAnimation' && Array.from(sourceEl.classList).some((className) => className.startsWith('brx-animate-') ) ) { return } bricksInteractionCallbackExecution(sourceEl, interaction) }, once: interaction?.runOnce, trigger: interaction?.trigger, rootMargin: interaction?.rootMargin // @since 2.0 }) break /** * Don't use rootMargin * * Because if element has enterView & leaveView interactions, the leaveView will be ignored when scrolling up * * @see #38ve0he * * @since 1.6.2 */ case 'leaveView': new BricksIntersect({ element: sourceEl, callback: (sourceEl) => bricksInteractionCallbackExecution(sourceEl, interaction), once: interaction?.runOnce, trigger: interaction?.trigger }) break /** * Show/Hide popup trigger * @since 1.8.2 */ case 'showPopup': case 'hidePopup': let listenEvent = interaction.trigger === 'showPopup' ? 'bricks/popup/open' : 'bricks/popup/close' document.addEventListener(listenEvent, (event) => { let popupElement = event.detail?.popupElement || false // Only run if this popup is the sourceEl if (!popupElement || popupElement !== sourceEl) { return } // STEP: Handle runOnce - As we are listening to a specific popup event, we cannot set once: true on addEventListener bricksUtils.maybeRunOnceInteractions(sourceEl, interaction) }) break /** * Bricks AJAX trigger (AJAX pagination, infinite scroll) * * @since 1.9 */ case 'ajaxStart': case 'ajaxEnd': let ajaxEvent = interaction.trigger === 'ajaxStart' ? 'bricks/ajax/start' : 'bricks/ajax/end' let ajaxQueryId = interaction?.ajaxQueryId || false // Return: No ajaxQueryId if (!ajaxQueryId) { return } document.addEventListener(ajaxEvent, (event) => { let queryId = event.detail?.queryId || false // Return: No queryId if (!queryId) { return } // Only run if this ajaxQueryId is the sourceEl if (queryId !== ajaxQueryId) { return } // STEP: Handle runOnce - As we are listening to a specific ajax event, we cannot set once: true on addEventListener bricksUtils.maybeRunOnceInteractions(sourceEl, interaction) }) break // Form submit events (@since 1.9.2) case 'formSubmit': case 'formSuccess': case 'formError': let targetFormId = interaction?.formId // Return: No targetFormId if (!targetFormId) { return } // Set formEvent, based on interaction.trigger (eg. formSubmit => bricks/form/submit) let formEvent = `bricks/form/${interaction.trigger.replace('form', '').toLowerCase()}` document.addEventListener(formEvent, (event) => { let formId = event.detail?.elementId // Return: No elementFormId if (!formId) { return } targetFormId = targetFormId.replace('#', '') targetFormId = targetFormId.replace('brxe-', '') // Return: formId not equal to targetFormId if (formId !== targetFormId) { return } // STEP: Handle runOnce - As we are listening to a specific form event, we cannot set once: true on addEventListener bricksUtils.maybeRunOnceInteractions(sourceEl, interaction) }) break // Filter interactions (@since 1.11) case 'filterOptionEmpty': case 'filterOptionNotEmpty': let filterElementId = interaction?.filterElementId || false // Return: No filterElementId if (!filterElementId) { return } // In case user uses #brxe- prefix filterElementId = filterElementId.replace('#', '') filterElementId = filterElementId.replace('brxe-', '') // Set filterEvent, based on interaction.trigger (eg. filterOptionEmpty => bricks/filter/option/empty) let filterEvent = `bricks/filter/option/${interaction.trigger .replace('filterOption', '') .toLowerCase()}` document.addEventListener(filterEvent, (event) => { let elementIds = event.detail?.filterElementIds || [] // Return: No elementIds or filterElementId not in elementIds if (!elementIds.length || !elementIds.includes(filterElementId)) { return } // STEP: Handle runOnce - As we are listening to a specific filter event, we cannot set once: true on addEventListener bricksUtils.maybeRunOnceInteractions(sourceEl, interaction) }) break // Interactions for WooCommerce events (@since 2.0) case 'wooAddedToCart': case 'wooAddingToCart': case 'wooRemovedFromCart': case 'wooUpdateCart': case 'wooCouponApplied': case 'wooCouponRemoved': if (typeof jQuery === 'undefined') { return } let wooEvent = null if (interaction.trigger === 'wooAddedToCart') { wooEvent = 'added_to_cart' } else if (interaction.trigger === 'wooAddingToCart') { wooEvent = 'adding_to_cart' } else if (interaction.trigger === 'wooRemovedFromCart') { wooEvent = 'item_removed_from_classic_cart' } else if (interaction.trigger === 'wooUpdateCart') { wooEvent = 'updated_cart_totals' } else if (interaction.trigger === 'wooCouponApplied') { wooEvent = 'applied_coupon applied_coupon_in_checkout' } else if (interaction.trigger === 'wooCouponRemoved') { wooEvent = 'removed_coupon removed_coupon_in_checkout' } if (wooEvent) { jQuery(document.body).on(wooEvent, (event) => { bricksUtils.maybeRunOnceInteractions(sourceEl, interaction) }) } break } }) // After all interactions are added, check if any load more button should be hidden bricksUtils.hideOrShowLoadMoreButtons('all') } }) function bricksInteractions() { bricksInteractionsFn.run() } /** * Trap focus inside an element * * bricksNavNested, bricksNavMenuMobile, bricksPopups, bricksOffcanvas * * @param {*} event * @param {*} node * * @since 1.11 */ function bricksTrapFocus(event, node) { if (event.key === 'Tab') { const focusableElements = bricksGetVisibleFocusables(node) const firstFocusableElement = focusableElements[0] const lastFocusableElement = focusableElements[focusableElements.length - 1] if (event.shiftKey) { // Shift + Tab if (document.activeElement === firstFocusableElement) { lastFocusableElement.focus() event.preventDefault() } } else { // Tab if (document.activeElement === lastFocusableElement) { firstFocusableElement.focus() event.preventDefault() } } } } /** * Focus on first focusable element inside an element * * Example: bricksNavMenuMobile, bricksPopups * * @param {*} node * @since 1.11 */ function bricksFocusOnFirstFocusableElement(node, waitForVisible = true) { let focusableElements = bricksGetFocusables(node) let firstFocusableElement = focusableElements[0] if (!firstFocusableElement) return if (!waitForVisible) { // For NestedNav avoid breaking change (#86c31fdvz) firstFocusableElement.focus() return } // Enhance logic to consider the element's visibility to solve OffCanvas issues (@since 2.0) // Check if the element is focusable let maxTries = 60 // ~1s of attempts (60 frames) let tries = 0 function canReceiveFocus(element) { // Check if element is visible, not hidden, not covered const style = window.getComputedStyle(element) if ( style.display === 'none' || style.visibility !== 'visible' || parseFloat(style.opacity) < 0.1 ) { return false } const rect = element.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) return false // Optional: check if element is off-screen if ( rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth ) { return false } return true } function tryFocus() { if (canReceiveFocus(firstFocusableElement)) { firstFocusableElement.focus() } else if (tries++ < maxTries) { requestAnimationFrame(tryFocus) } else { console.warn('Element never became focusable:', firstFocusableElement) } } requestAnimationFrame(tryFocus) } /** * Popups * * @since 1.6 * @since 1.11: Trap focus via bricksTrapFocus */ function bricksPopups() { let lastFocusedElement = null const escClosePopup = (event, popupElement) => { if (event.key === 'Escape') { bricksClosePopup(popupElement) } } const backdropClosePopup = (event, popupElement) => { if (event.target.classList.contains('brx-popup-backdrop')) { bricksClosePopup(popupElement) } // Backdrop disabled: Close popup when clicking outside popup else if (event.target.classList.contains('brx-popup')) { bricksClosePopup(popupElement) } } /** * Listen to document bricks/popup/open event * * event.detail.popupElement: Popup element * event.detail.popupId: Popup id * - Not for Map infobox popup (@since 2.0) * @since 1.7.1 */ document.addEventListener('bricks/popup/open', (event) => { // STEP: Get popup element const popupElement = event.detail?.popupElement || false if (!popupElement || !bricksIsFrontend) { return } // Do not execute this logic if the opened popup is AJAX infobox (@since 2.0) if (popupElement.classList.contains('brx-infobox-popup')) { return } // STEP: Add close event listeners for popup // Moved on top, to always run (@since 1.12.3) const popupCloseOn = popupElement.dataset?.popupCloseOn || 'backdrop-esc' if (popupCloseOn.includes('esc')) { // STEP: Listen for ESC key pressed to close popup const escEventHandler = (event) => escClosePopup(event, popupElement) document.addEventListener('keyup', escEventHandler) // Remove the ESC event listener when popup is closed document.addEventListener('bricks/popup/close', () => { document.removeEventListener('keyup', escEventHandler) }) } if (popupCloseOn.includes('backdrop')) { // STEP: Listen for click outside popup to close popup const backdropEventHandler = (event) => backdropClosePopup(event, popupElement) document.addEventListener('click', backdropEventHandler) // Remove the backdrop event listener when popup is closed document.addEventListener('bricks/popup/close', () => { document.removeEventListener('click', backdropEventHandler) }) } // Store the last focused element before opening the popup (@since 1.11.1) lastFocusedElement = document.activeElement // STEP: Scroll to top of popup content (@since 1.8.4) // Moved outside of timeout, to prevent popup content jump (@since 1.12.2) if (popupElement.dataset?.popupScrollToTop) { popupElement.querySelector('.brx-popup-content')?.scrollTo(0, 0) } // STEP: Try to scroll to fist focusable element, before popup is opened (@since 1.12.2) if (!popupElement.dataset?.popupDisableAutoFocus) { // STEP: Find first focusable element inside popup const focusables = bricksGetFocusables(popupElement) const firstFocusable = focusables.length > 0 ? focusables[0] : null // Only run the code, if there is at least one focusable element (@since 1.12.3) if (firstFocusable) { // To prevent wrong calculation, scroll to top of popup content popupElement.querySelector('.brx-popup-content')?.scrollTo(0, 0) // Get the element's position relative to the container const elementRect = firstFocusable.getBoundingClientRect() const containerRect = popupElement .querySelector('.brx-popup-content') .getBoundingClientRect() // Calculate the scroll position needed const scrollTop = elementRect.top - containerRect.top + popupElement.scrollTop - 10 // Add some padding at the top // Scroll the container popupElement.querySelector('.brx-popup-content').scrollTo({ top: scrollTop }) } } // Timeout is necessary to allow popup to be fully rendered & focusable (e.g. opening animation set) setTimeout(() => { //STEP: Autofocus on first focusable element inside popup if (!popupElement.dataset?.popupDisableAutoFocus) { bricksFocusOnFirstFocusableElement(popupElement) } // STEP: Scroll to top of popup content if (popupElement.dataset?.popupScrollToTop) { popupElement .querySelector('.brx-popup-content') ?.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) } }, 100) // STEP: Add focus trap - Not allowing to tab outside popup const focusTrapEventHandler = (event) => bricksTrapFocus(event, popupElement) document.addEventListener('keydown', focusTrapEventHandler) // Remove the focus trap event listener when popup is closed document.addEventListener('bricks/popup/close', () => { document.removeEventListener('keydown', focusTrapEventHandler) // Restore focus to the last focused element (@since 1.11.1) if (lastFocusedElement) { lastFocusedElement.focus() } }) }) } /** * Scroll interaction listener * * @since 1.9.5 - Remove debounce 100ms(#86bwp4aax) * * @since 1.6 */ function bricksScrollInteractions() { // Get scroll interactions anew on every scroll (new interactions could have been added via AJAX pagination or infinite scroll) let interactions = Array.isArray(window.bricksData?.interactions) ? window.bricksData.interactions : [] let scrolled = window.scrollY let runOnceIndexToRemove = [] interactions.forEach((interaction, index) => { // Skip non-scroll interactions if (interaction?.trigger !== 'scroll') { return } if (scrolled >= interaction.scrollOffset) { bricksInteractionCallbackExecution(interaction.el, interaction) // Push interaction.id instead of index (@since 1.10) to remove from window.bricksData.interactions below if (interaction?.runOnce) { runOnceIndexToRemove.push(interaction.id) } } }) // Remove interaction from window.bricksData.interactions after looping over all interactions runOnceIndexToRemove.forEach((interactionId) => { window.bricksData.interactions = window.bricksData.interactions.filter( (interaction) => interaction.id !== interactionId ) }) } /** * Interactions callback * * @param {Event} event - The event that triggered the interaction * @param {Object} interaction - The interaction configuration object (@since 2.0) * * @since 1.6 */ function bricksInteractionCallback(event, interaction) { if (event?.type === 'click') { // Return: Don't run interaction when clicking on an anchor ID (except for # itself) if ( event.target.tagName === 'A' && event.target.getAttribute('href') !== '#' && event.target.getAttribute('href')?.startsWith('#') ) { return } // Only prevent default if it's not disabled (@since 2.0) if (!interaction?.disablePreventDefault) { event.preventDefault() } } // Run interaction callback execution (@since 2.0.1) bricksInteractionCallbackExecution(interaction.el, interaction) } /** * Interaction action execution * * @since 1.6 */ function bricksInteractionCallbackExecution(sourceEl, config) { // Actions that don't require a target (@since 2.3.1) const actionWithoutTarget = [ 'clearForm', 'storageAdd', 'storageRemove', 'storageCount', 'loadMore', 'toggleOffCanvas', 'openAddress', 'closeAddress' ].includes(config?.action) const targetMode = config?.target || 'self' let target // If action doesn't require a target, skip target selection and directly execute the action logic (@since 2.3.1) if (!actionWithoutTarget) { switch (targetMode) { case 'custom': if (config?.targetSelector) { target = bricksQuerySelectorAll(document, config.targetSelector) } break case 'popup': if (config?.templateId) { // Target looping popup by matching 'data-interaction-loop-id' with 'data-popup-loop-id' + templateId (@since 1.8.4) if (sourceEl.dataset?.interactionLoopId) { target = bricksQuerySelectorAll( document, `.brx-popup[data-popup-id="${config.templateId}"][data-popup-loop-id="${sourceEl.dataset.interactionLoopId}"]` ) } // No popup found: Try finding popup by 'data-popup-id' if (!target || !target.length) { target = bricksQuerySelectorAll( document, `.brx-popup[data-popup-id="${config.templateId}"]` ) } } break case 'offcanvas': if (config?.offCanvasSelector) { // Ensure offCanvasSelector is set target = sourceEl } break default: target = sourceEl // = self } } if (!actionWithoutTarget && !target) { return } // Convert target to array if it's not already an array (@since 2.3.1) if (Array.isArray(target)) { // Keep target as-is } else if (target) { target = [target] } else { target = [] } // Interaction condition not fulfilled if (!bricksInteractionCheckConditions(config)) { // Execute logic if condition unmet (@since 1.9.6) switch (config?.action) { case 'startAnimation': target.forEach((el) => { // Remove 'data-interaction-hidden-on-load' attribute or else element will be hidden (#86bwvh3hv) el.removeAttribute('data-interaction-hidden-on-load') }) break } // Return: Don't run interaction return } switch (config?.action) { case 'show': case 'hide': target.forEach((el) => { // Popup if (el?.classList.contains('brx-popup')) { if (config.action === 'show') { // Extra params for AJAX popup - @since 1.9.4 let extraParams = {} if (config?.popupContextId) { // User defined popupContextId extraParams.popupContextId = config.popupContextId } if (config?.popupContextType) { // User defined popupContextType extraParams.popupContextType = config.popupContextType } if (sourceEl.dataset?.interactionLoopId) { // Interaction has loopId: it's a looping popup extraParams.loopId = sourceEl.dataset.interactionLoopId } bricksOpenPopup(el, 0, extraParams) } else if (config.action === 'hide') { bricksClosePopup(el) } } // Regular element else { // Hide if (config.action === 'hide') { el.style.display = 'none' } // Show (remove display: none & only set display: block as a fallback) else { if (el.style.display === 'none') { el.style.display = null // Check if element is still hidden (e.g. display: none set by CSS) let styles = window.getComputedStyle(el) if (styles.display === 'none') { el.style.display = 'block' } } else { el.style.display = 'block' } } } }) break case 'setAttribute': case 'removeAttribute': case 'toggleAttribute': const attributeKey = config?.actionAttributeKey if (attributeKey) { target.forEach((el) => { let attributeValue = config?.actionAttributeValue || '' // Attribute 'class' if (attributeKey === 'class') { let classNames = attributeValue ? attributeValue.split(' ') : [] classNames.forEach((className) => { if (config.action === 'setAttribute') { el.classList.add(className) } else if (config.action === 'removeAttribute') { el.classList.remove(className) } else { el.classList.toggle(className) } }) } // All other attributes else { if (config.action === 'setAttribute') { el.setAttribute(attributeKey, attributeValue) } else if (config.action === 'removeAttribute') { el.removeAttribute(attributeKey) } else { // Toggle attribute if (el.hasAttribute(attributeKey)) { el.removeAttribute(attributeKey) } else { el.setAttribute(attributeKey, attributeValue) } } } }) } break case 'scrollTo': const scrollTarget = target[0] // Return: No scroll target if (!scrollTarget || !scrollTarget.scrollIntoView) { return } // Offset & timeout as set by user let offset = config?.scrollToOffset || 0 let delay = config?.scrollToDelay || 1 // (1ms to allow DOM to update) setTimeout(() => { // Remove 'px' from offset if is a string if (typeof offset === 'string') { offset = offset.replace('px', '') } // Get the offsetTop of the target element let targetOffsetTop = scrollTarget.getBoundingClientRect().top // Scroll to target element with offset window.scrollBy(0, targetOffsetTop - parseInt(offset)) }, parseInt(delay)) break // Clears all form fields (@since 2.0) case 'clearForm': const formSelector = config?.targetFormSelector let formElements = null // If trigger is one of the 'formSubmit', 'formSuccess', 'formError', use form element itself if (['formSubmit', 'formSuccess', 'formError'].includes(config.trigger)) { let formSelectorId = config?.formId formSelectorId = formSelectorId.replace('#', '') formSelectorId = formSelectorId.replace('brxe-', '') formElements = document.querySelectorAll(`.brxe-form[data-element-id="${formSelectorId}"]`) } // If formSelector is set, use "form" as selector else if (!formSelector) { formElements = document.querySelectorAll('form') } else { formElements = document.querySelectorAll(formSelector) } if (formElements && formElements.length) { // Clear all form fields formElements.forEach((form) => { const inputs = form.querySelectorAll('input, textarea, select') inputs.forEach((input) => { // Do not clear the "hidden" inputs (e.g. password protection ID input) (@since 2.2) if (input.type === 'hidden') { return } if (input.tagName === 'SELECT') { input.selectedIndex = 0 } else if (input.tagName === 'TEXTAREA') { input.value = '' } else if (input.type === 'checkbox' || input.type === 'radio') { input.checked = false } else { input.value = '' } }) // Search for all file results buttons and click on them, to clear them const fileResults = form.querySelectorAll('.file-result.show > .bricks-button.remove') fileResults.forEach((fileResult) => { fileResult.click() }) }) } break // Click element (@since 2.1.3) case 'click': if (target && target.length) { target.forEach((clickTarget) => { clickTarget.click() }) } break case 'storageAdd': case 'storageRemove': case 'storageCount': const storageType = config?.storageType const storageKey = config?.actionAttributeKey const storageValue = config.hasOwnProperty('actionAttributeValue') ? config.actionAttributeValue : 0 if (storageType && storageKey) { if (config.action === 'storageAdd') { bricksStorageSetItem(storageType, storageKey, storageValue) } else if (config.action === 'storageRemove') { bricksStorageRemoveItem(storageType, storageKey) } else if (config.action === 'storageCount') { let counter = bricksStorageGetItem(storageType, storageKey) counter = counter ? parseInt(counter) : 0 bricksStorageSetItem(storageType, storageKey, counter + 1) } } break case 'startAnimation': const animationType = config?.animationType if (animationType) { let animationDelay = 0 // Calculating animation delay, so we can timout the animation below (@since 2.0) if (config?.animationDelay) { if (config.animationDelay.includes('ms')) { animationDelay = parseInt(config.animationDelay) } else if (config.animationDelay.includes('s')) { animationDelay = parseFloat(config.animationDelay) * 1000 } } // Delay the action execution (@since 2.0) setTimeout(() => { target.forEach((el) => { // Default animation duration: 1s let removeAnimationAfterMs = 1000 let isPopup = el?.classList.contains('brx-popup') // Apply animation to popup content (@since 1.8) if (isPopup) { el = el.querySelector('.brx-popup-content') } // Get custom animation-duration if (config?.animationDuration) { el.style.animationDuration = config.animationDuration if (config.animationDuration.includes('ms')) { removeAnimationAfterMs = parseInt(config.animationDuration) } else if (config.animationDuration.includes('s')) { removeAnimationAfterMs = parseFloat(config.animationDuration) * 1000 } } // Get custom animation-delay if (config?.animationDelay) { // Here we can just add the adnimationDelay, as we already calculated it above (@since 2.0) removeAnimationAfterMs += animationDelay } /** * Animate popup * * @since 1.7 - Popup use removeAnimationAfterMs for setTimeout duration) * @since 1.8.5 - Check config.trigger to avoid recursive error (#866aqzzwf) */ if (isPopup && config.trigger !== 'showPopup' && config.trigger !== 'hidePopup') { let popupNode = el.parentNode // el = .brx-popup-content let extraParams = {} // Extra parameters for popup is required for looping popup interaction (@since 1.11) if (config?.popupContextId) { // User defined popupContextId extraParams.popupContextId = config.popupContextId } if (config?.popupContextType) { // User defined popupContextType extraParams.popupContextType = config.popupContextType } if (sourceEl.dataset?.interactionLoopId) { // Interaction has loopId: It's a looping popup extraParams.loopId = sourceEl.dataset.interactionLoopId } // Animate: open popup (if animationType includes 'In') if (animationType.includes('In')) { bricksOpenPopup(popupNode, removeAnimationAfterMs, extraParams) } } el.classList.add('brx-animated') el.setAttribute('data-animation', animationType) el.setAttribute('data-animation-id', config.id || '') // Remove animation class after animation duration + delay to run again bricksAnimationFn.run({ elementsToAnimate: [el], removeAfterMs: removeAnimationAfterMs }) }) }, animationDelay) } break case 'loadMore': const queryId = config?.loadMoreQuery const queryConfig = window.bricksData.queryLoopInstances?.[queryId] if (!queryConfig) { return } // Support Component (@since 1.12.2) const componentId = queryConfig?.componentId || false const selectorId = bricksUtils.getQueryTrailSelectorId(queryId, componentId) // Query trail is the last element of the results container const queryTrail = Array.from( queryConfig.resultsContainer?.querySelectorAll(`.brxe-${selectorId}:not(.brx-popup)`) ).pop() if (queryTrail) { if (!sourceEl.classList.contains('is-loading')) { // Add "is-loading" class to the source element so we could style some spinner animation sourceEl.classList.add('is-loading') // Add the query ID to the trail so that the load page could fetch the query config queryTrail.dataset.queryElementId = queryId if (componentId) { queryTrail.dataset.queryComponentId = componentId } // No delay for load more interaction (@since 1.12.2) bricksQueryLoadPage(queryTrail, true).then((data) => { sourceEl.classList.remove('is-loading') // Hide or show the "Load More" buttons bricksUtils.hideOrShowLoadMoreButtons(queryId) }) } } break // Load more for gallery (@since 2.3.1) case 'loadMoreGallery': const targetSelector = config.loadMoreTargetSelector || '' if (targetSelector) { const targets = bricksQuerySelectorAll(document, targetSelector) targets.forEach((targetEl) => { if ( targetEl.matches && targetEl.matches('.brxe-image-gallery[data-brx-load-more="gallery"]') ) { bricksUtils.imageGalleryLoadMore(targetEl) } }) } break // Execute the function (since 1.9.5) case 'javascript': if (config?.jsFunction) { let userFunction = window[config.jsFunction] ?? false // jsFunction is a string, maybe the function stored in an object, separated by dot (.) if (config.jsFunction.includes('.')) { const jsFunctionParts = config.jsFunction.split('.') let tempFunctionTest = window // Loop through the parts, and get the function jsFunctionParts.forEach((part) => { tempFunctionTest = tempFunctionTest[part] }) // Check if the function exists if (typeof tempFunctionTest === 'function') { userFunction = tempFunctionTest } } // Run the function if it exists if (userFunction && typeof userFunction === 'function') { // Default brxParams let brxParams = { source: sourceEl, targets: target } // Build actual parameters let customParams = {} if (config?.jsFunctionArgs) { // jsFunctionArgs is an array if (Array.isArray(config.jsFunctionArgs)) { config.jsFunctionArgs.forEach((arg, index) => { if (arg?.jsFunctionArg && arg?.id) { let key = arg.id let value = arg.jsFunctionArg // If the jsFunctionArg is %brx%, replace it with brxParams if (arg.jsFunctionArg === '%brx%') { key = arg.jsFunctionArg value = brxParams } // Add the parameter to the customParams object customParams[key] = value } }) } } target.forEach((el) => { // Add current el as target to %brx% parameter if (customParams?.['%brx%']) { customParams['%brx%'].target = el } // Run the function, pass the parameters only if customParams is not empty if (Object.keys(customParams).length) { // Each customParam key is a parameter userFunction(...Object.keys(customParams).map((key) => customParams[key])) } else { userFunction() } }) } else { console.error( `Bricks interaction: Custom JavaScript function "${config.jsFunction}" not found.` ) } } break // toggleOffCanvas (@since 1.11) case 'toggleOffCanvas': const offCanvasSelector = config?.offCanvasSelector || false if (!offCanvasSelector) { return } // Don't use the target, just pass the selector to the function bricksUtils.toggleAction(sourceEl, { selector: offCanvasSelector }) break // openAddress, closeAddress (@since 2.0) case 'openAddress': case 'closeAddress': bricksUtils.toggleMapInfoBox(config) break } } /** * Open Bricks popup (frontend only) * * @param {obj} object Popup element node or popup ID * @param {int} timeout Timeout in ms to run counter * @param {obj} additionalParam Additional parameters (@since 1.9.4) * @since 1.7.1 */ function bricksOpenPopup(object, timeout = 0, additionalParam = {}) { if (!bricksIsFrontend) { return } let popupElement // Check: Popup is element node OR popup ID if (object) { if (object.nodeType === Node.ELEMENT_NODE) { popupElement = object } // Check: object is the popup ID else if (object) { popupElement = document.querySelector(`.brx-popup[data-popup-id="${object}"]`) } } // Return: no popup element found if (!popupElement) { return } const popupId = popupElement.dataset.popupId // Return: Popup limit reached if (!bricksPopupCheckLimit(popupElement)) { return } // Return: Don't shown popup on this breakpoint (@since 1.9.4) if (!bricksPopupCheckBreakpoint(popupElement)) { return } // Show popup popupElement.classList.remove('hide') // @since 1.7.1 - Add "no-scroll" class to the body if 'data-popup-body-scroll' is not set if (!popupElement.dataset.popupBodyScroll) { document.body.classList.add('no-scroll') } // Set popup height to viewport height (@since 1.8.2) bricksSetVh() // Fecth popup content (promise), maybe it was loaded via AJAX (@since 1.9.4) bricksFetchPopupContent(popupElement, additionalParam).then((content) => { // Replace popup content with fetched content (only AJAX popups has content) if (content !== '') { popupElement.querySelector('.brx-popup-content').innerHTML = content // Popup AJAX content loaded new DOM added (@since 1.9.4) const popupContentLoadedEvent = new CustomEvent('bricks/ajax/popup/loaded', { detail: { popupId, popupElement } }) document.dispatchEvent(popupContentLoadedEvent) } // @since 1.7.1 - Trigger custom event for the "bricks/popup/open" trigger, Provide the popup ID and the popup element const showPopupEvent = new CustomEvent('bricks/popup/open', { detail: { popupId, popupElement } }) document.dispatchEvent(showPopupEvent) // Run counter after timeout animation finishes (delay + duration) setTimeout(() => { bricksCounter() }, timeout) // Store the number of times this popup was shown bricksPopupCounter(popupElement) }) } /** * Fetch popup content (promise), maybe content should fetch via AJAX * * Return empty string if popup content is not loaded via AJAX or error. * * @since 1.9.4 */ function bricksGetCurrentUrlParams() { let urlParams = {} try { new URLSearchParams(window.location.search).forEach((value, key) => { if (urlParams[key] === undefined) { urlParams[key] = value } else if (Array.isArray(urlParams[key])) { urlParams[key].push(value) } else { urlParams[key] = [urlParams[key], value] } }) } catch (e) { console.warn('Failed to parse URL parameters:', e) } return urlParams } function bricksFetchPopupContent(popupElement, additionalParam = {}, nonceRefreshed = false) { return new Promise((resolve, reject) => { const isAjax = popupElement.dataset?.popupAjax || false // Return: Popup content is not loaded via AJAX if (!isAjax) { resolve('') return } // Clear popup content popupElement.querySelector('.brx-popup-content').innerHTML = '' let popupElementId = popupElement.dataset.popupId let ajaxData = { postId: window.bricksData.postId, popupId: popupElementId, nonce: window.bricksData.nonce, popupContextId: false, popupContextType: 'post', isLooping: false, popupLoopId: false, queryElementId: false, lang: window.bricksData.language || false, // @since 2.0 mainQueryId: window.bricksData.mainQueryId || false, // Record the main query ID (@since 2.0) urlParams: bricksGetCurrentUrlParams() } // Popup is looping if (additionalParam?.loopId) { ajaxData.isLooping = true } // Set templateContext if (additionalParam?.popupContextId) { // Use user defined context ajaxData.popupContextId = additionalParam.popupContextId // Set popupContextType (only use if popupContextId is set) if (additionalParam?.popupContextType) { ajaxData.popupContextType = additionalParam.popupContextType } } // If loopId is set but no popupContextId, use default current context if (additionalParam?.loopId && !ajaxData.popupContextId) { // Try to get the queryElementId from the loopId, loopId is combination of queryElementId:loopIndex:loopObjectType:loopObjectId let loopParts = additionalParam.loopId.split(':') // loopParts.length at least 4 (top loop), 6 (nested loop) (@since 1.12) if (loopParts.length >= 4 && loopParts[0].length >= 6) { ajaxData.queryElementId = loopParts[0] ajaxData.popupLoopId = additionalParam.loopId } } let url = window.bricksData.restApiUrl.concat('load_popup_content') // Add Get lang parameter for WPML if current url has lang parameter (@since 2.0) if ( window.bricksData.multilangPlugin === 'wpml' && (window.location.search.includes('lang=') || window.bricksData.wpmlUrlFormat != 3) ) { // use window.bricksData.language to get the current language url = url.concat('?lang=' + window.bricksData.language) } // AJAX popup start event - AJAX loader purposes (@since 1.9.4) document.dispatchEvent( new CustomEvent('bricks/ajax/popup/start', { detail: { popupId: popupElementId, popupElement: popupElement } }) ) // Support dynamic style in Popup settings (looping popup) (@since 1.12) if (ajaxData.isLooping) { popupElement.querySelector('.brx-popup-backdrop')?.removeAttribute('data-query-loop-index') } let xhr = new XMLHttpRequest() xhr.open('POST', url, true) xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8') xhr.setRequestHeader('X-WP-Nonce', window.bricksData.wpRestNonce) // Successful response xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { let status = xhr.status let popupContent = '' let res try { res = JSON.parse(xhr.response) } catch (e) { console.error('Error parsing response:', e) } // Success if (status === 0 || (status >= 200 && status < 400)) { const html = res?.html || false const styles = res?.styles || false const popups = res?.popups || false const error = res?.error || false const contentClasses = res?.contentClasses || false if (error) { console.error('error:bricksFetchPopupContent', error) } if (html) { popupContent = html } if (styles) { // Combine the styles into popupContent popupContent += styles } if (contentClasses) { // Remove all classes except brx-popup-content, brxe-container, brx-woo-quick-view (@since 1.10.2) const popupContentNode = popupElement.querySelector( '.brx-popup-content.brx-woo-quick-view' ) if (popupContentNode) { let classesToKeep = ['brx-popup-content', 'brxe-container', 'brx-woo-quick-view'] popupContentNode.classList.forEach((className) => { if (!classesToKeep.includes(className)) { popupContentNode.classList.remove(className) } }) // Add classes to the popup content, contentClasses is an array of classes popupContentNode.classList.add(...contentClasses) if (!document.body.classList.contains('woocommerce')) { // Add woocommerce class into popup content if no woocommerce class in body, ensure styles rely on .woocommerce are applied popupContentNode.classList.add('woocommerce') } } } if (popups && Object.keys(popups).length) { /** * Some of the popups already exist on the page, only add if it's not already there * popups is object array, key is popupId, value is popupHtml */ Object.entries(popups).forEach(([popupId, popupHtml]) => { if (!document.querySelector(`.brx-popup[data-popup-id="${popupId}"]`)) { document.body.insertAdjacentHTML('beforeend', popupHtml) } }) } // Support dynamic style in Popup settings (looping popup) (@since 1.12) if (ajaxData.isLooping) { popupElement .querySelector('.brx-popup-backdrop') ?.setAttribute('data-query-loop-index', 0) } } else if (res?.code === 'rest_cookie_invalid_nonce' && !nonceRefreshed) { // Refresh nonce and retry (@since 1.11) bricksRegenerateNonceAndRetryPopup(popupElement, additionalParam) .then(resolve) .catch(reject) return } resolve(popupContent) // AJAX popup end event - AJAX loader purposes (@since 1.9.4) document.dispatchEvent( new CustomEvent('bricks/ajax/popup/end', { detail: { popupId: popupElementId, popupElement: popupElement } }) ) } } xhr.send(JSON.stringify(ajaxData)) }) } /** * Regenerate nonce and retry fetching popup content * * @param {*} popupElement * @param {*} additionalParam * @returns * * @since 1.11 */ function bricksRegenerateNonceAndRetryPopup(popupElement, additionalParam) { return new Promise((resolve, reject) => { let xhrNonce = new XMLHttpRequest() xhrNonce.open('POST', window.bricksData.ajaxUrl, true) xhrNonce.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') xhrNonce.onreadystatechange = function () { if (xhrNonce.readyState === XMLHttpRequest.DONE) { if (xhrNonce.status >= 200 && xhrNonce.status < 400) { let response try { response = JSON.parse(xhrNonce.responseText) } catch (e) { reject('Invalid response from server when regenerating nonce') return } if (response.success && response.data) { window.bricksData.nonce = response.data.bricks_nonce window.bricksData.wpRestNonce = response.data.rest_nonce bricksFetchPopupContent(popupElement, additionalParam, true).then(resolve).catch(reject) } else { reject('Failed to regenerate nonces: Invalid response structure') } } else { reject('Failed to regenerate nonce') } } } xhrNonce.send('action=bricks_regenerate_query_nonce') }) } /** * Close Bricks popup (frontend only) * * @param object Popup element node or popup ID * * @since 1.7.1 */ function bricksClosePopup(object) { if (!bricksIsFrontend) { return } let popupElement // Check: Popup is element node OR popup ID if (object) { if (object.nodeType === Node.ELEMENT_NODE) { popupElement = object } // Check: object is the popup ID else if (object) { popupElement = document.querySelector(`.brx-popup[data-popup-id="${object}"]`) } } // Fallback: Get first popup on the page // else {popupElement = document.querySelector(`.brx-popup[data-popup-id]`)} if (!popupElement) { return } const popupId = popupElement.dataset.popupId popupElement.classList.add('hide') // @since 1.7.1 - Remove "no-scroll" class to the body if 'data-popup-body-scroll' is not set if (!popupElement.dataset.popupBodyScroll) { /** * Going to remove the no-scroll class, but check if there are other popups opened and the body should not scroll * * @since 1.9.4 */ if (!document.querySelectorAll('.brx-popup:not(.hide):not([data-popup-body-scroll])').length) { document.body.classList.remove('no-scroll') } } // @since 1.7.1 - Trigger custom event for the "bricks/popup/close" trigger, Provide the popup ID and the popup element const hidePopupEvent = new CustomEvent('bricks/popup/close', { detail: { popupId, popupElement } }) document.dispatchEvent(hidePopupEvent) } /** * Popups: Check show up limits * * true: ok * false: limit overflow * * NOTE: Limits are stored in "brx_popup_${popupId}_total" * * @since 1.6 */ function bricksPopupCheckLimit(element) { let limits = element?.dataset?.popupLimits let popupId = element?.dataset?.popupId if (!limits) { return true } try { limits = JSON.parse(limits) } catch (e) { console.info('error:bricksPopupCheckLimit', e) return true } let overflow = false let now = Date.now() Object.entries(limits).forEach(([key, value]) => { if (key === 'timeStorageInHours') { let lastShown = parseInt( bricksStorageGetItem('localStorage', `brx_popup_${popupId}_lastShown`) ) let nextAllowedShowTime = lastShown + value * 3600000 // hours to milliseconds if (now < nextAllowedShowTime) { overflow = true } } else { let counter = bricksStorageGetItem(key, `brx_popup_${popupId}_total`) counter = counter ? parseInt(counter) : 0 overflow = overflow || counter >= value } }) if (!overflow && limits.timeStorageInHours) { bricksStorageSetItem('localStorage', `brx_popup_${popupId}_lastShown`, now.toString()) } return !overflow } /** * Popups: Check breakpoint * * true: ok * false: breakpoint not met * * @since 1.9.4 */ function bricksPopupCheckBreakpoint(popupElement) { // If this is not a popup, retrun false so this unexpected element won't be shown if (!popupElement?.classList?.contains('brx-popup')) { return false } // STEP: Check for the breakpoint data attribute const popupBreakpoint = parseInt(popupElement.getAttribute('data-popup-show-at')) // STEP: Show at breakpoint width if (popupBreakpoint) { // Viewport width is smaller than breakpoint width: Set 'hide' class & return if (window.innerWidth < popupBreakpoint) { return false } } // STEP: Extract array of widths from data attribute for multi-select const attributeValue = popupElement.getAttribute('data-popup-show-on-widths') const showOnWidthRanges = attributeValue ? attributeValue.split(',') : [] // Check against selected breakpoints to determine if the popup should be displayed for the current viewport width if (showOnWidthRanges.length) { const withinSelectedBreakpoints = showOnWidthRanges.some((range) => { const [min, max] = range.split('-').map((value) => { const numberValue = Number(value) if (isNaN(numberValue)) { console.error(`Invalid width value: ${value}`) return 0 } return numberValue }) return window.innerWidth >= min && window.innerWidth <= max }) if (!withinSelectedBreakpoints) { return false } } // Reached here: Breakpoint check passed return true } /** * Popups: Store how many times popup was displayed * * NOTE: limits are stored in "brx_popup_${popupId}_total" * * @since 1.6 */ function bricksPopupCounter(element) { let limits = element?.dataset?.popupLimits let popupId = element?.dataset?.popupId if (!limits) { return } try { limits = JSON.parse(limits) } catch (e) { console.info('error:bricksPopupCounter', e) return true } Object.entries(limits).forEach(([key, value]) => { let counter = bricksStorageGetItem(key, `brx_popup_${popupId}_total`) counter = counter ? parseInt(counter) : 0 bricksStorageSetItem(key, `brx_popup_${popupId}_total`, counter + 1) }) } /** * Check interactions conditions * * @since 1.6 */ function bricksInteractionCheckConditions(config) { // STEP: No conditions if (!Array.isArray(config?.interactionConditions)) { return true } let relation = config?.interactionConditionsRelation || 'and' // Start with true if relation is 'and', false otherwise ('or') let runInteraction = relation === 'and' /** * Convert storage value to number to be used in >=, <=, >, < conditions * * @see #862j9fr6y * * @since 1.7.1 */ const convertToNumber = (value) => (!isNaN(value) ? parseFloat(value) : 0) // STEP: Check the interaction conditions config.interactionConditions.forEach((condition) => { let conditionType = condition?.conditionType let storageKey = condition?.storageKey || false let runCondition = false if (conditionType && storageKey) { let storageCompare = condition?.storageCompare || 'exists' let storageCompareValue = condition?.storageCompareValue let storageValue = bricksStorageGetItem(conditionType, storageKey) switch (storageCompare) { case 'exists': runCondition = storageValue !== null break case 'notExists': runCondition = storageValue === null break case '==': runCondition = storageValue == storageCompareValue break case '!=': runCondition = storageValue != storageCompareValue break case '>=': runCondition = convertToNumber(storageValue) >= convertToNumber(storageCompareValue) break case '<=': runCondition = convertToNumber(storageValue) <= convertToNumber(storageCompareValue) break case '>': runCondition = convertToNumber(storageValue) > convertToNumber(storageCompareValue) break case '<': runCondition = convertToNumber(storageValue) < convertToNumber(storageCompareValue) break } } else { runCondition = true } runInteraction = relation === 'and' ? runInteraction && runCondition : runInteraction || runCondition }) return runInteraction } /** * Storage helper function to get value stored under a specific key * * @since 1.6 */ function bricksStorageGetItem(type, key) { if (!key) { return } let value try { switch (type) { // Per page load case 'windowStorage': value = window.hasOwnProperty(key) ? window[key] : null break // Per session case 'sessionStorage': value = sessionStorage.getItem(key) break // Across sessions case 'localStorage': value = localStorage.getItem(key) break } } catch (e) { console.info('error:bricksStorageGetItem', e) } return value } /** * Storage helper function to set value for a specific storage key * * @since 1.6 */ function bricksStorageSetItem(type, key, value) { if (!key) { return } try { switch (type) { case 'windowStorage': window[key] = value break case 'sessionStorage': sessionStorage.setItem(key, value) break case 'localStorage': localStorage.setItem(key, value) break } } catch (e) { console.info('error:bricksStorageSetItem', e) } } /** * Storage helper function to remove a specific storage key * * @since 1.6 */ function bricksStorageRemoveItem(type, key) { if (!key) { return } try { switch (type) { case 'windowStorage': delete window[key] break case 'sessionStorage': sessionStorage.removeItem(key) break case 'localStorage': localStorage.removeItem(key) break } } catch (e) { console.info('error:bricksStorageRemoveItem', e) } } /** * Nav nested * * Mobile menu toggle * * Listeners: * - press ESC key to close .brx-nav-nested-items & auto-focus on mobile menu toggle * - press ENTER or SPACE to open .brxe-nav-nested-inner * - press TAB to trap focus inside .brx-nav-nested-items * * NOTE: Mobile menu toggle via .brx-toggle-div listener * * @since 1.8 */ function bricksNavNested() { // Return: Builder has its own logic for showing the brx-nav-nested-items while editing if (!bricksIsFrontend) { return } let navNestedObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { let navNested = mutation.target // STEP: Open navNested if ( navNested.classList.contains('brx-open') && !navNested.classList.contains('brx-closing') ) { // Set popup height to viewport height (@since 1.8.2) bricksSetVh() // Nav nested mobile menu uses 'top' & 'bottom' 0 instead of 100vh, though // Trap focus inside navNested (@since 1.11) let navNestedItems = navNested.querySelector('.brx-nav-nested-items') if (navNestedItems) { navNestedItems.addEventListener('keydown', (event) => bricksTrapFocus(event, navNestedItems) ) } // Add class to body to prevent scrolling document.body.classList.add('no-scroll') // Close toggle inside navNested is open let toggleInside = navNested.querySelector('.brx-nav-nested-items button.brxe-toggle') if (toggleInside) { setTimeout(() => { toggleInside.classList.add('is-active') toggleInside.setAttribute('aria-expanded', true) toggleInside.focus() }, 100) } // Auto-focus on first focusable element inside .brxe-nav-nested else { bricksFocusOnFirstFocusableElement(navNested, false) // Check (#86c31fdvz) } } // STEP: Close nav nested else { // Remove class to body to prevent scrolling document.body.classList.remove('no-scroll') // Focus on toggle element that opened the nav nested ([data-toggle-script-id]) let toggleScriptId = navNested.dataset.toggleScriptId let toggleNode = document.querySelector( `button[data-script-id="${toggleScriptId}"],[data-interaction-id="${toggleScriptId}"][data-brx-toggle-offcanvas]` ) if (toggleNode) { toggleNode.setAttribute('aria-expanded', false) toggleNode.classList.remove('is-active') toggleNode.focus() } } } }) }) let navNestedElements = bricksQuerySelectorAll(document, '.brxe-nav-nested') if (!navNestedElements.length) { return } // STEP: Observe class list changes on .brxe-nav-nested navNestedElements.forEach((navNested) => { navNestedObserver.observe(navNested, { attributes: true, attributeFilter: ['class'] }) navNested.addEventListener('keydown', (event) => { const focusedElement = document.activeElement const isTopLevel = focusedElement.closest( '.brx-nav-nested-items > li, .brx-nav-nested-items > .brxe-dropdown' ) !== null const isInDropdown = focusedElement.closest('.brx-dropdown-content') !== null const isInSubmenuToggle = focusedElement.closest('.brx-submenu-toggle') !== null bricksHandleMenuKeyNavigation(event, { isTopLevel, isInDropdown, isInSubmenuToggle, getNextFocusable: bricksGetNextMenuFocusableInSubmenuToggle, getPreviousFocusable: bricksGetPreviousMenuFocusableInSubmenuToggle, getLastFocusableInSubmenuToggle: bricksMenuGetLastFocusableInSubmenuToggle, focusNextElement: (el) => bricksMenuFocusNextElement(el, '.brx-nav-nested-items'), focusPreviousElement: (el) => bricksMenuFocusPreviousElement(el, '.brx-nav-nested-items'), focusFirstElement: bricksMenuFocusFirstElement, focusLastElement: bricksMenuFocusLastElement, closeSubmenu: (toggleButton) => bricksSubmenuToggle(toggleButton, 'remove') }) }) }) // STEP: ESC key pressed: Close mobile menu document.addEventListener('keyup', (e) => { if (e.key === 'Escape') { bricksNavNestedClose() } }) // STEP: Click outside of .brxe-nav-nested && not on a toggle: Close mobile menu document.addEventListener('click', (e) => { let navNested = e.target.closest('.brxe-nav-nested') let clickOnToggle = e.target.closest('.brxe-toggle') if (!navNested && !clickOnToggle) { bricksNavNestedClose() } }) } /** * Nav nested: Close mobile menu */ function bricksNavNestedClose() { let navNestedOpen = bricksQuerySelectorAll(document, '.brxe-nav-nested.brx-open') navNestedOpen.forEach((navNested) => { navNested.classList.add('brx-closing') // Close .brx-open after 200ms to prevent mobile menu styles from unsetting while mobile menu fades out setTimeout(() => { navNested.classList.remove('brx-closing') navNested.classList.remove('brx-open') }, 200) }) } /** * Nav menu element * * @since 1.11.1 */ const bricksNavMenuFn = new BricksFunction({ parentNode: document, selector: '.brxe-nav-menu', eachElement: (element) => { element.addEventListener('keydown', (event) => { const focusedElement = document.activeElement const isTopLevel = focusedElement.closest('.bricks-nav-menu > li') !== null const isInDropdown = focusedElement.closest('.sub-menu') !== null const isInSubmenuToggle = focusedElement.closest('.brx-submenu-toggle') !== null bricksHandleMenuKeyNavigation(event, { isTopLevel, isInDropdown, isInSubmenuToggle, getNextFocusable: bricksGetNextMenuFocusableInSubmenuToggle, getPreviousFocusable: bricksGetPreviousMenuFocusableInSubmenuToggle, getLastFocusableInSubmenuToggle: bricksMenuGetLastFocusableInSubmenuToggle, focusNextElement: (el) => bricksMenuFocusNextElement(el, '.bricks-nav-menu'), focusPreviousElement: (el) => bricksMenuFocusPreviousElement(el, '.bricks-nav-menu'), focusFirstElement: bricksMenuFocusFirstElement, focusLastElement: bricksMenuFocusLastElement, closeSubmenu: (toggleButton) => { toggleButton.setAttribute('aria-expanded', 'false') toggleButton.closest('.menu-item-has-children').classList.remove('open') } }) }) } }) function bricksNavMenu() { bricksNavMenuFn.run() } /** * Handles arrow key navigation for menu structures * Supports both Nav Nested and Nav Menu elements * * @since 1.11.1 * * @param {Event} event - The keydown event * @param {Object} options - Configuration options */ function bricksHandleMenuKeyNavigation(event, options) { const { isTopLevel, isInDropdown, isInSubmenuToggle, getNextFocusable, getPreviousFocusable, getLastFocusableInSubmenuToggle, focusNextElement, focusPreviousElement, focusFirstElement, focusLastElement, closeSubmenu } = options const key = event.key const focusedElement = document.activeElement if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(key)) { event.preventDefault() } // Determine RTL status based on the document or element direction const isRTL = document.dir === 'rtl' || document.documentElement.dir === 'rtl' || focusedElement.closest('[dir="rtl"]') !== null const nextKey = isRTL ? 'ArrowLeft' : 'ArrowRight' const prevKey = isRTL ? 'ArrowRight' : 'ArrowLeft' switch (key) { case 'ArrowDown': if (isTopLevel) { if (isInSubmenuToggle) { const submenuToggle = focusedElement.closest('.brx-submenu-toggle') const dropdown = submenuToggle.nextElementSibling const toggleButton = submenuToggle.querySelector('button[aria-expanded]') if ( dropdown && (dropdown.classList.contains('brx-dropdown-content') || dropdown.classList.contains('sub-menu')) && toggleButton && toggleButton.getAttribute('aria-expanded') === 'true' ) { const firstLink = dropdown.querySelector('a, button') if (firstLink) { firstLink.focus() } } else { focusNextElement(focusedElement) } } else { focusNextElement(focusedElement) } } else if (isInDropdown) { focusNextElement(focusedElement) } break case nextKey: if (isTopLevel) { if (isInSubmenuToggle) { const nextFocusable = getNextFocusable(focusedElement, isRTL) if (nextFocusable) { nextFocusable.focus() } else { // Move to the next menu item focusNextElement(focusedElement.closest('.brxe-dropdown, .menu-item')) } } else { focusNextElement(focusedElement) } } break case 'ArrowUp': if (isTopLevel) { focusPreviousElement(focusedElement) } else if (isInDropdown) { const prevElement = focusPreviousElement(focusedElement) if (!prevElement) { const parentToggle = focusedElement .closest('.brxe-dropdown') .querySelector('.brx-submenu-toggle') if (parentToggle) { const focusTarget = getLastFocusableInSubmenuToggle(parentToggle, isRTL) if (focusTarget) focusTarget.focus() } } } break case prevKey: if (isTopLevel) { if (isInSubmenuToggle) { const prevFocusable = getPreviousFocusable(focusedElement, isRTL) if (prevFocusable) { prevFocusable.focus() } else { focusPreviousElement(focusedElement.closest('.brxe-dropdown, .menu-item')) } } else { focusPreviousElement(focusedElement) } } else if (isInDropdown) { const prevElement = focusPreviousElement(focusedElement) if (!prevElement) { const parentToggle = focusedElement .closest('.brxe-dropdown') .querySelector('.brx-submenu-toggle') if (parentToggle) { const focusTarget = getLastFocusableInSubmenuToggle(parentToggle, isRTL) if (focusTarget) focusTarget.focus() } } } break case 'Home': focusFirstElement( isInDropdown ? focusedElement.closest('.sub-menu, .brx-dropdown-content') : focusedElement.closest('.bricks-nav-menu, .brx-nav-nested-items') ) break case 'End': focusLastElement( isInDropdown ? focusedElement.closest('.sub-menu, .brx-dropdown-content') : focusedElement.closest('.bricks-nav-menu, .brx-nav-nested-items') ) break } } /** * Focuses on the next element in a menu * * @since 1.11.1 * * @param {HTMLElement} currentElement - The current focused element * @param {string} menuSelector - The selector for the menu container * @returns {HTMLElement|null} - The newly focused element or null */ function bricksMenuFocusNextElement(currentElement, menuSelector) { if (!currentElement) { return null } const parent = currentElement.closest('ul') || currentElement.closest(menuSelector) if (!parent) { return null } const items = Array.from(parent.children).filter((item) => item.querySelector('a, button, .brx-submenu-toggle') ) const currentIndex = items.findIndex((item) => item.contains(currentElement)) const nextItem = items[currentIndex + 1] // If there's no next item, return null instead of wrapping around if (!nextItem) { return null } let focusTarget if (nextItem.querySelector('.brx-submenu-toggle')) { const submenuToggle = nextItem.querySelector('.brx-submenu-toggle') const link = submenuToggle.querySelector('a') const button = submenuToggle.querySelector('button') // Always focus on the link first, regardless of RTL or LTR focusTarget = link || button } else { focusTarget = nextItem.querySelector('a') || nextItem.querySelector('button') } if (focusTarget) { focusTarget.focus() } return focusTarget } /** * Focuses on the previous element in a menu * * @since 1.11.1 * * @param {HTMLElement} currentElement - The current focused element * @param {string} menuSelector - The selector for the menu container * @returns {HTMLElement|null} - The newly focused element or null */ function bricksMenuFocusPreviousElement(currentElement, menuSelector) { if (!currentElement) { return null } const parent = currentElement.closest('ul') || currentElement.closest(menuSelector) if (!parent) { return null } const items = Array.from(parent.children).filter((item) => item.querySelector('a, button, .brx-submenu-toggle') ) const currentIndex = items.findIndex((item) => item.contains(currentElement)) const prevItem = items[currentIndex - 1] if (!prevItem) return null let focusTarget if (prevItem.querySelector('.brx-submenu-toggle')) { // If the previous item has a submenu toggle, focus on the button first focusTarget = prevItem.querySelector('.brx-submenu-toggle button') || prevItem.querySelector('.brx-submenu-toggle a') } else { focusTarget = prevItem.querySelector('a') || prevItem.querySelector('button') || prevItem.querySelector('.brx-submenu-toggle') } if (focusTarget) { focusTarget.focus() } return focusTarget } /** * Focuses on the first element in a menu * * @since 1.11.1 * * @param {HTMLElement} container - The container element */ function bricksMenuFocusFirstElement(container) { const focusableElements = bricksGetFocusables(container) if (focusableElements.length > 0) { const firstElement = focusableElements[0] if (firstElement.classList.contains('brx-submenu-toggle')) { const firstFocusableInToggle = firstElement.querySelector('a, button') if (firstFocusableInToggle) firstFocusableInToggle.focus() } else { firstElement.focus() } } } /** * Focuses on the last visible element in a menu * * @since 1.11.1 * * @param {HTMLElement} container - The container element */ function bricksMenuFocusLastElement(container) { const focusableElements = bricksGetFocusables(container) if (focusableElements.length > 0) { for (let i = focusableElements.length - 1; i >= 0; i--) { const element = focusableElements[i] if (bricksIsElementVisible(element)) { if (element.classList.contains('brx-submenu-toggle')) { const lastFocusableInToggle = element.querySelector('button') || element.querySelector('a') if (lastFocusableInToggle && bricksIsElementVisible(lastFocusableInToggle)) { lastFocusableInToggle.focus() return } } else { element.focus() return } } } } } /** * Gets the next focusable element in a submenu toggle * * @since 1.11.1 * * @param {HTMLElement} element - The current element * @param {boolean} isRTL - Whether the layout is right-to-left * @returns {HTMLElement|null} - The next focusable element or null */ function bricksGetNextMenuFocusableInSubmenuToggle(element, isRTL) { const submenuToggle = element.closest('.brx-submenu-toggle') const focusables = Array.from(submenuToggle.querySelectorAll('a, button')) // Always go from link to button, regardless of RTL or LTR if (element.tagName.toLowerCase() === 'a') { return focusables[1] // Return the button (second element) } // If on the button, signal to move to the next menu item return null } /** * Gets the previous focusable element in a submenu toggle * * @since 1.11.1 * * @param {HTMLElement} element - The current element * @param {boolean} isRTL - Whether the layout is right-to-left * @returns {HTMLElement|null} - The previous focusable element or null */ function bricksGetPreviousMenuFocusableInSubmenuToggle(element, isRTL) { const submenuToggle = element.closest('.brx-submenu-toggle') const focusables = Array.from(submenuToggle.querySelectorAll('a, button')) // Always go from button to link, regardless of RTL or LTR if (element.tagName.toLowerCase() === 'button') { const link = focusables.find((el) => el.tagName.toLowerCase() === 'a') return link || null // Return null if no link exists, allowing navigation to previous menu item } // If on the link, signal to move to the previous menu item return null } /** * Gets the last focusable element in a submenu toggle * * @since 1.11.1 * * @param {HTMLElement} submenuToggle - The submenu toggle element * @param {boolean} isRTL - Whether the layout is right-to-left * @returns {HTMLElement|null} - The last focusable element or null */ function bricksMenuGetLastFocusableInSubmenuToggle(submenuToggle, isRTL) { const focusables = Array.from(submenuToggle.querySelectorAll('a, button')) return isRTL ? focusables[0] : focusables[focusables.length - 1] } /** * Checks if an element is visible * * @since 1.11.1 * * @param {HTMLElement} element - The element to check * @returns {boolean} - Whether the element is visible */ function bricksIsElementVisible(element) { const rect = element.getBoundingClientRect() const style = window.getComputedStyle(element) return ( rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' ) } /** * Offcanvas element * * - Show by adding .show (click on toggle) * - Close by removeing .show (backdrop click/ESC key press) * * @since 1.8 */ function bricksOffcanvas() { if (!bricksIsFrontend) { return } let offcanvasElements = bricksQuerySelectorAll(document, '.brxe-offcanvas') if (!offcanvasElements.length) { return } // STEP: Figure out if we should skip transition of body (in case the offcanvas is open on page load + offset effect) let isOffsetOnPageLoad = offcanvasElements.some((offcanvas) => { return offcanvas.classList.contains('brx-open') && offcanvas.dataset.effect === 'offset' }) // Extract this to own function, as we use it in multiple places (@since 2.0) const offcanvasAction = (offcanvas) => { let inner = offcanvas.querySelector('.brx-offcanvas-inner') let transitionDuration = inner ? (transitionDuration = parseFloat(window.getComputedStyle(inner).getPropertyValue('transition-duration')) * 1000) : 200 // STEP: Open offcanvas if (offcanvas.classList.contains('brx-open')) { // Set popup height to viewport height (@since 1.8.2) bricksSetVh() // Offset body by height/width of offcanvas if (offcanvas.dataset.effect === 'offset') { if (inner) { // Get CSS transition value of .brx-offcanvas-inner let direction = offcanvas.getAttribute('data-direction') let transition = window.getComputedStyle(inner).getPropertyValue('transition') document.body.style.margin = '0' // Only set transition on body if it's not on page load (@since 2.0) if (!isOffsetOnPageLoad) { document.body.style.transition = transition.replace('transform', 'margin') } // Offset body by height/width of offcanvas const isRTL = document.dir === 'rtl' || document.documentElement.dir === 'rtl' // Horizontal (top/bottom) if (direction === 'top') { document.body.style.marginTop = `${inner.offsetHeight}px` } else if (direction === 'bottom') { document.body.style.marginTop = `-${inner.offsetHeight}px` } // Vertical (left/right) else if (direction === 'left') { if (isRTL) { // Use negative marginRight for RTL instead of marginLeft (@since 1.11) document.body.style.marginRight = `-${inner.offsetWidth}px` } else { document.body.style.marginLeft = `${inner.offsetWidth}px` } document.body.style.overflowX = 'hidden' } else if (direction === 'right') { // Use marginRight for RTL (@since 1.11) if (isRTL) { document.body.style.marginRight = `${inner.offsetWidth}px` } else { document.body.style.marginLeft = `-${inner.offsetWidth}px` } document.body.style.overflowX = 'hidden' } // If it's offset on page load, we need to set the transition on body after the offset/margin is applied to body (@since 2.0) if (isOffsetOnPageLoad) { setTimeout(() => { document.body.style.transition = transition.replace('transform', 'margin') }, 0) } isOffsetOnPageLoad = false } } // Trap focus inside offcanvas (@since 1.11) offcanvas.addEventListener('keydown', (event) => bricksTrapFocus(event, offcanvas)) // Disable body scroll if (offcanvas.dataset.noScroll) { document.body.classList.add('no-scroll') } // Auto-focus not disabled (@since 1.10.2) if (offcanvas.dataset?.noAutoFocus !== 'true') { // Auto-focus on first focusable element inside .brx-offcanvas bricksFocusOnFirstFocusableElement(offcanvas) } if (offcanvas.dataset?.scrollToTop === 'true') { // Auto Scroll to top of offcanvas (@since 1.10.2) let offcanvasInner = offcanvas.querySelector('.brx-offcanvas-inner') if (offcanvasInner) { offcanvasInner.scrollTop = 0 } } // Toggle inside offcanvas is open let offcanvasToggles = offcanvas.querySelectorAll( '.brx-offcanvas-inner button.brxe-toggle, .brx-offcanvas-inner [data-brx-toggle-offcanvas="true"]' ) if (offcanvasToggles.length) { offcanvasToggles.forEach((offcanvasToggle) => { let isTargetCurrentOffcanvas = false const targetSelector = offcanvasToggle.dataset?.selector || '.brxe-offcanvas' if (targetSelector) { const targetElements = document.querySelectorAll(targetSelector) || [] // Check if it's targetting current offcanvas isTargetCurrentOffcanvas = Array.from(targetElements).includes(offcanvas) } else { // Without selector, it's meant for current offcanvas isTargetCurrentOffcanvas = true } // Only set is-active and aria-expanded on the correct toggle element (@since 2.0) if (isTargetCurrentOffcanvas) { offcanvasToggle.classList.add('is-active') offcanvasToggle.setAttribute('aria-expanded', true) bricksUtils.setOffcanvasToggleAriaLabel(offcanvasToggle, true) } }) } } // STEP: Close offcanvas else { offcanvas.classList.add('brx-closing') // Moved visibility style to class, and improve MutationObserver to prevent infinite loop (@since 2.0) // Focus on toggle element that opened the offcanvas ([data-toggle-script-id]) let toggleScriptId = offcanvas.dataset.toggleScriptId let toggleNode = document.querySelector( `button[data-script-id="${toggleScriptId}"], [data-interaction-id="${toggleScriptId}"][data-brx-toggle-offcanvas]` ) if (toggleNode) { toggleNode.setAttribute('aria-expanded', false) toggleNode.classList.remove('is-active') bricksUtils.setOffcanvasToggleAriaLabel(toggleNode, false) toggleNode.focus() } if (offcanvas.dataset.effect === 'offset') { if (document.body.style.marginTop) { document.body.style.margin = '0' } setTimeout(() => { document.body.style.margin = null document.body.style.overflow = null document.body.style.transition = null }, transitionDuration) } setTimeout(() => { // Remove .brx-closing class, as the offcanvas is closed (@since 2.0) offcanvas.classList.remove('brx-closing') // Re-enable body scroll if (offcanvas.dataset.noScroll) { document.body.classList.remove('no-scroll') bricksSubmenuPosition() } }, transitionDuration) } } let offcanvasObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { // STEP: Don't run mutation, if we add or remove .brx-closing class const oldValue = mutation.oldValue || '' const newValue = mutation.target.classList const oldClasses = oldValue.split(' ') const newClasses = Array.from(newValue) // Skip if we contain "brx-closing" if (oldClasses.includes('brx-closing') || newClasses.includes('brx-closing')) { return } offcanvasAction(mutation.target) } }) }) offcanvasElements.forEach((offcanvas) => { // STEP: Observe class list changes on .brxe-offcanvas offcanvasObserver.observe(offcanvas, { attributes: true, attributeFilter: ['class'], attributeOldValue: true // To get the old value of the class attribute (@since 2.0) }) // STEP: Close offcanvas when clicking on backdrop let backdrop = offcanvas.querySelector('.brx-offcanvas-backdrop') if (backdrop) { backdrop.addEventListener('click', (e) => { bricksOffcanvasClose('backdrop') }) } // STEP: If offcanvas is open by default, update (@since 2.0) if (offcanvas.classList.contains('brx-open')) { offcanvasAction(offcanvas) } }) // STEP: ESC key pressed: Close offcanvas & focus on offcanvas toggle button document.addEventListener('keyup', (e) => { if (e.key === 'Escape') { bricksOffcanvasClose('esc') } }) } /** * Close all open offcanvas elements * * @since 1.9.3: event 'force' closes the offcanvas without checking the trigger event (e.g. click on anchor link that's located outside the offcanvas) * * @since 1.8 */ function bricksOffcanvasClose(event) { let openOffcanvasElements = bricksQuerySelectorAll(document, '.brxe-offcanvas.brx-open') openOffcanvasElements.forEach((openOffcanvas) => { // STEP: Close offcanvas on backdrop (click) OR (ESC) key press (@since 1.9.1) const closeOn = openOffcanvas.dataset?.closeOn || 'backdrop-esc' if (closeOn.includes(event) || event === 'force') { openOffcanvas.classList.remove('brx-open') } // STEP: Remove is-active class and set aria-expanded to false for all toggles inside the offcanvas (@since 1.11) let offcanvasToggles = openOffcanvas.querySelectorAll( '.brx-offcanvas-inner > button.brxe-toggle, .brx-offcanvas-inner > [data-brx-toggle-offcanvas="true"]' ) if (offcanvasToggles.length) { offcanvasToggles.forEach((offcanvasToggle) => { offcanvasToggle.classList.remove('is-active') offcanvasToggle.setAttribute('aria-expanded', false) bricksUtils.setOffcanvasToggleAriaLabel(offcanvasToggle, false) }) } }) } /** * Toggle mobile menu open inside "Div" inside .brx-nav-nested-items * * Set diplay on div according to toggle display (initially and on window resize). * * @since 1.8 */ function bricksToggleDisplay() { let toggleElements = bricksQuerySelectorAll(document, '.brxe-toggle') if (!toggleElements.length) { return } toggleElements.forEach((toggle) => { // Mobile menu close toggle inside 'div' inside .brx-nav-nested-items: Hide div if ( toggle.closest('.brx-nav-nested-items') && !toggle.parentNode.classList.contains('brx-nav-nested-items') && !toggle.parentNode.classList.contains('brx-toggle-div') ) { // Hide parent div if toggle is hidden let toggleStyles = window.getComputedStyle(toggle) if (toggleStyles.display === 'none') { toggle.parentNode.style.display = 'none' // Close mobile menu (@since 1.11) bricksNavNestedClose() } else { toggle.parentNode.style.display = null } } }) } /** * Close Nav menu element mobile menu on window resize * * @since 1.11 */ function bricksNavMenuMobileToggleDisplay() { let navMenuMobileToggles = bricksQuerySelectorAll(document, '.bricks-mobile-menu-toggle') navMenuMobileToggles.forEach((toggle) => { // Mobile menu is open if (toggle.parentNode.classList.contains('show-mobile-menu')) { let toggleStyles = window.getComputedStyle(toggle) // Mobile menu toggle not visible: Close mobile menu (@since 1.11) if (toggleStyles.display === 'none') { toggle.click() } } }) } /** * Toggle element * * Click event listener added here centrally to avoid multiple event listeners. * * @since 1.9.8 */ const bricksToggleFn = new BricksFunction({ parentNode: document, selector: '.brxe-toggle', frontEndOnly: true, eachElement: (toggle) => { toggle.addEventListener('click', (e) => { e.preventDefault() // Refactor to use bricksUtils.toggleAction(toggle) (@since 1.11) bricksUtils.toggleAction(toggle) }) } }) /** * Toggle element * * Default toggles: * * - Nav nested mobile menu (.brxe-nav-nested) * - Offcanvas element (.brxe-offcanvas) * * @since 1.8 */ function bricksToggle() { if (!bricksIsFrontend) { return } bricksToggleDisplay() bricksToggleFn.run() } /** * Toggle mode (light, dark) element * * @since 2.2 */ const bricksToggleModeFn = new BricksFunction({ parentNode: document, selector: '.brxe-toggle-mode', // Target the button instead of the span (#86c73854r;) frontEndOnly: true, eachElement: (button) => { // Toggle light/dark mode button.addEventListener('click', (e) => { e.preventDefault() // Get current mode from body data-brx-theme attribute let currentTheme = document.documentElement.dataset.brxTheme let changeTo = currentTheme === 'dark' ? 'light' : 'dark' // Update body data-brx-theme attribute & local storage document.documentElement.dataset.brxTheme = changeTo localStorage.setItem('brx_mode', changeTo) }) } }) /** * Toggle mode (light, dark) element * * @since 2.2 */ function bricksToggleMode() { if (!bricksIsFrontend) { return } bricksToggleModeFn.run() } /** * Toggle sub menu: Nav menu, Dropdown * * Toggle: * - click on dropdown toggle * - press ENTER key * - press SPACE key * * Hide: * - click outside dropdown * - click on another dropdown toggle * - press ESC key * - press TAB key to tab out of dropdown * * Not added: * - press ARROR UP/DOWN key to navigate dropdown items (prevents page scroll) * * @since 1.8 */ function bricksSubmenuToggle(toggle, action = 'toggle') { // Menu item: Parent of .brx-submenu-toggle (@since 1.8 to allow usage of non 'li' HTML tag on dropdown element) let menuItem = toggle.parentNode.classList.contains('brx-submenu-toggle') ? toggle.parentNode.parentNode : false // Return: No menu item found if (!menuItem) { return } // STEP: Multilevel menu let multilevel = toggle.closest('.brx-has-multilevel') if (multilevel) { // Hide currently active parent menu item (if it's not a megamenu) let activeMenuItem = menuItem.parentNode.closest('.active') if (activeMenuItem && !activeMenuItem.classList.contains('brx-has-megamenu')) { activeMenuItem.classList.remove('active') } // Focus on first focusable element in submenu (small timoute required) setTimeout(() => { let submenu = menuItem.querySelector('ul') || menuItem.querySelector('.brx-dropdown-content') if (submenu) { bricksFocusOnFirstFocusableElement(submenu) } }, 100) } // add, remove, toggle .open class (& add/remove .active class) if (action === 'add') { menuItem.classList.add('open') menuItem.classList.add('active') } else if (action === 'remove') { menuItem.classList.remove('open') menuItem.classList.remove('active') } else { menuItem.classList.toggle('open') } // STEP: Update ::before pseudo element width and height (@since 1.11.1) const hasDropdown = menuItem.classList.contains('brxe-dropdown') if (hasDropdown) { const dropdownContent = menuItem.querySelector('.brx-dropdown-content') if (dropdownContent) { // Only execute this, if we are opening the dropdown (@since 1.12) if (menuItem.classList.contains('open')) { // Create own function for updating the gap height (@since 1.12) const updateGapHeight = (e) => { // Do not recalculate if we transitioned a property that does not affect the gap height const skipEvents = [ 'color', 'background-color', 'border-color', 'box-shadow', 'visibility', 'opacity', 'filter' ] if (e?.propertyName && skipEvents.includes(e.propertyName)) { return } const menuItemRect = menuItem.getBoundingClientRect() const dropdownContentRect = dropdownContent.getBoundingClientRect() // Calculate properties we need const contentBorder = parseFloat(window.getComputedStyle(dropdownContent).borderTopWidth) const contentTopPos = dropdownContentRect.top + contentBorder const menuItemBottomPos = menuItemRect.bottom const totalValue = contentTopPos - menuItemBottomPos // Set height of ::before pseudo element (difference between dropdown content top and menu item bottom) menuItem.style.setProperty('--brx-dropdown-height-before', `${totalValue}px`) } // Only add event listener, if there is no propety --brx-dropdown-height-before set if (!menuItem.style.getPropertyValue('--brx-dropdown-height-before')) { dropdownContent.addEventListener('transitionend', updateGapHeight) } // Immediate update, even before transitions are done (@since 1.12) updateGapHeight() } } } // Set 'aria-expanded' toggle.setAttribute('aria-expanded', menuItem.classList.contains('open')) // Re-position submenu on every toggle // bricksSubmenuPosition(100) } /** * * Sub menu event listeners (Nav menu, Dropdown) * * mouseenter: Open submenu * mouseleave: Close submenu * Escape key pressed: Close all open sub menus outside non-active element * Click outside submenu: Close all open sub menus * * @since 1.8 */ const bricksSubmenuListenersFn = new BricksFunction({ parentNode: document, selector: '.bricks-nav-menu .menu-item-has-children, .brxe-dropdown', eachElement: (submenuItem) => { // Skip mouse listeners: Static, Multilevel, active menu item let skipMouseListeners = submenuItem.closest('[data-static]') || submenuItem.closest('.brx-has-multilevel') || submenuItem.classList.contains('active') if (skipMouseListeners) { return } // Open submenu on mouseenter submenuItem.addEventListener('mouseenter', function (e) { // Return: Mobile menu (Nav menu, Nav nested) if ( submenuItem.closest('.show-mobile-menu') || submenuItem.closest('.brxe-nav-nested.brx-open') ) { return } // Return: Toggle on "click" if (submenuItem.getAttribute('data-toggle') === 'click') { return } let toggle = e.target.querySelector('[aria-expanded="false"]') if (toggle) { // Only close submenus if the toggle is the top-level menu item (@since 1.11.1.1) if (!toggle.closest('.brxe-dropdown.open') && !toggle.closest('.bricks-menu-item.open')) { bricksUtils.closeAllSubmenus(toggle) // Close all open submenus (@since 1.11.1) } bricksSubmenuToggle(toggle) } }) // Close submenu on mouseleave submenuItem.addEventListener('mouseleave', function (e) { // Skip mobile menu (Nav menu, Nav nested) if ( submenuItem.closest('.show-mobile-menu') || submenuItem.closest('.brxe-nav-nested.brx-open') ) { return } // Return: Toggle on "click" if (submenuItem.getAttribute('data-toggle') === 'click') { return } let toggle = e.target.querySelector('[aria-expanded="true"]') if (toggle) { // Return: If submenu is .active (opened manually via toggle click) let menuItem = toggle.closest('.menu-item') if (!menuItem) { menuItem = toggle.closest('.brxe-dropdown') } if (menuItem && menuItem.classList.contains('active')) { return } bricksSubmenuToggle(toggle) } }) } }) function bricksSubmenuListeners() { bricksSubmenuListenersFn.run() // (@since 2.0) document.addEventListener('keyup', function (e) { if (e.key === 'Escape') { bricksUtils.closeAllSubmenus(e.target) } // STEP: Tabbed out of menu item: Close menu item (if it does not contain the active element) else if (e.key === 'Tab') { setTimeout(() => { let openToggles = bricksQuerySelectorAll(document, '[aria-expanded="true"]') // NOTE: Can't listen to tabbing out of window (in case there is no focusable element after the last open submenu on the page) openToggles.forEach((toggle) => { let menuItem = toggle.closest('.menu-item') if (!menuItem) { menuItem = toggle.closest('.brxe-dropdown') } if ( (menuItem && !menuItem.contains(document.activeElement)) || document.activeElement.tagName === 'BODY' ) { bricksSubmenuToggle(toggle, 'remove') // Close submenu (@since 1.12) } }) }, 0) } }) document.addEventListener('click', (e) => { let target = e.target let linkUrl = null // If target is not an anchor link: Get closest anchor link if (target && target.nodeName !== 'A') { target = target.closest('a[href]') } // Get link URL if (target) { linkUrl = target.getAttribute('href') } if (linkUrl && linkUrl.includes('#')) { // Prevent default on anchor link (#) if (linkUrl === '#' || linkUrl === '/#') { e.preventDefault() } // Click on section anchor link (e.g. #section) else { // Get element 'id' to scroll to from the hash URL (@since 1.9.2) let scrollToElementId = linkUrl.split('#')[1] // Inside offcanvas: Close offcanvas let offcanvas = e.target.closest('.brxe-offcanvas') if (offcanvas) { bricksOffcanvasClose('force') } // Inside mobile menu: Close mobile menu (@since 1.8.4) let isMobileMenu = e.target.closest('.brxe-nav-nested.brx-open') if (isMobileMenu) { bricksNavNestedClose() // Scroll to anchor link (after 200ms when mobile menu is closed) let element = document.getElementById(scrollToElementId) if (element) { setTimeout(() => { element.scrollIntoView() }, 200) } } let hashTarget = document.getElementById(scrollToElementId) if (hashTarget) { let isProductTabs = hashTarget.classList.contains('woocommerce-Tabs-panel') // Hash ID is Accordion title: Open accordion/tab (@since 1.9) let accordionTitle = hashTarget.firstChild && hashTarget.firstChild.classList && hashTarget.firstChild.classList.contains('accordion-title-wrapper') ? hashTarget.firstChild : null if (accordionTitle && !isProductTabs) { accordionTitle.click() return } // Hash ID is Tab title: Open accordion/tab (@since 1.9) let tabTitle = hashTarget.closest('.tab-title') if (tabTitle && !isProductTabs) { tabTitle.click() } // Target outside popup: Close popup (@since 1.9) let popup = e.target.closest('.brx-popup') if (popup && !popup.contains(hashTarget)) { bricksClosePopup(popup) } } } } /** * STEP: Toggle submenu button click (default) OR entire .brx-submenu-toggle on click (if 'toggleOn' set to: click, or both) * @since 2.0: Target the dropdown itslef, but skip all clicks inside dropdown content (#86c21pqmy) */ const submenuToggle = e.target.closest('.brx-submenu-toggle') const dropdown = e.target.closest('.brxe-dropdown') const dropdownContent = e.target.closest('.brx-dropdown-content') if (dropdown && (!dropdownContent || dropdownContent.parentNode !== dropdown)) { // This is a Dropdown element handleDropdownToggle(dropdown, e, true) } else if (submenuToggle) { // This is submenu toggle inside Nav Menu handleDropdownToggle(submenuToggle, e, false) } // STEP: Click outside submenu: Close open sub menus let openSubmenuButtons = bricksQuerySelectorAll( document, '.brx-submenu-toggle > button[aria-expanded="true"]' ) openSubmenuButtons.forEach((toggleButton) => { let menuItem = toggleButton.closest('li') if (!menuItem) { menuItem = toggleButton.closest('.brxe-dropdown') } if (!menuItem || menuItem.contains(e.target)) { return } bricksSubmenuToggle(toggleButton) menuItem.classList.remove('active') }) }) /** * Helper function to handle dropdown/submenu toggle logic * @since 2.0 */ function handleDropdownToggle(element, e, isDropdown) { let toggleOn = 'hover' // If current element has data-toggle attribute, use that (@since 2.0) if (element.hasAttribute('data-toggle')) { toggleOn = element.getAttribute('data-toggle') } // else, get the closest element with data-toggle attribute (if any - used for multilevel) (@since 2.0) else { let toggleOnNode = element.closest('[data-toggle]') if (toggleOnNode) { toggleOn = toggleOnNode.getAttribute('data-toggle') } } // Nav menu: Toggle on entire .brx-submenu-toggle click if (element.closest('.brxe-nav-menu.show-mobile-menu')) { toggleOn = 'click' } // Nav nested: Toggle on entire .brx-submenu-toggle click if (element.closest('.brxe-nav-nested.brx-open')) { toggleOn = 'click' } let toggleButton = toggleOn === 'hover' ? e.target.closest('[aria-expanded]') : element.querySelector( isDropdown ? '.brx-submenu-toggle button[aria-expanded]' : 'button[aria-expanded]' ) // Dropdown: Only check inside submenu toggle (@since 2.0) /** * Return: Toggle on set to "hover" * * @since 1.8.4: Remove e.screenX = 0 && e.screenY = 0 check as not working in Safari */ let isKeyboardEvent = e.detail === 0 if (!isKeyboardEvent && toggleOn !== 'click' && toggleOn !== 'both') { toggleButton = null } if (toggleButton) { bricksSubmenuToggle(toggleButton) // Set .open & active & aria-expanded in case toggle was already .open on mouseenter let targetElement = isDropdown ? element : element.parentNode targetElement.classList.toggle('active') setTimeout(() => { if (targetElement.classList.contains('active')) { targetElement.classList.add('open') } toggleButton.setAttribute('aria-expanded', targetElement.classList.contains('open')) }, 0) } } // STEP: Set aria-current for all links inside brx-submenu-toggle. Previously in bricksSubmenuPosition (@since 2.0) const submenuToggles = bricksQuerySelectorAll(document, '.brx-submenu-toggle') submenuToggles.forEach((submenuToggle) => { const menuItem = submenuToggle.parentNode const submenu = menuItem.querySelector('.brx-megamenu') || menuItem.querySelector('.brx-dropdown-content') || menuItem.querySelector('ul') // Submenu has aria-current="page" menu item: Add .aria-current to toplevel .brx-submenu-toggle if (submenu && submenu.querySelector('[aria-current="page"]')) { submenuToggle.classList.add('aria-current') } }) } /** * Submenu position (re-run on window resize) * * Mega menu: Nav menu (Bricks template) & Dropdown. * Re-position submenu in case of viewport overflow. * * @param {number} timeout Timeout in ms before calculating submenu position. * * @since 1.8 */ const bricksSubmenuPositionFn = new BricksFunction({ parentNode: document, selector: '.brx-submenu-toggle', forceReinit: true, eachElement: (submenuToggle) => { let menuItem = submenuToggle.parentNode let submenu = menuItem.querySelector('.brx-megamenu') || menuItem.querySelector('.brx-dropdown-content') || menuItem.querySelector('ul') // Skip: Submenu not found (not rendered due to element condition) if (!submenu) { return } submenu.classList.add('brx-submenu-positioned') // Skip: Static submenu (e.g. Dropdown inside Offcanvas) if (menuItem.hasAttribute('data-static')) { return } let docWidth = document.body.clientWidth // document width without scrollbar // STEP: Mega menu let hasMegamenu = menuItem.classList.contains('brx-has-megamenu') if (hasMegamenu) { // Get mega menu settings let referenceNodeSelector = menuItem.dataset.megaMenu let verticalReferenceNodeSelector = menuItem.dataset.megaMenuVertical // Get reference node let referenceNode = document.body // Default: Cover entire body width if (referenceNodeSelector) { let customReferenceNode = document.querySelector(referenceNodeSelector) if (customReferenceNode) { referenceNode = customReferenceNode } } // Get node rects for calculation let menuItemRect = menuItem.getBoundingClientRect() let referenceNodeRect = referenceNode.getBoundingClientRect() // Set horizontal position and width submenu.style.left = `-${menuItemRect.left - referenceNodeRect.left}px` submenu.style.minWidth = `${referenceNodeRect.width}px` // Set vertical position (if selector was added and node exists) if (verticalReferenceNodeSelector) { let verticalReferenceNode = document.querySelector(verticalReferenceNodeSelector) if (verticalReferenceNode) { let verticalReferenceNodeRect = verticalReferenceNode.getBoundingClientRect() submenu.style.top = `${ menuItemRect.height + verticalReferenceNodeRect.bottom - menuItemRect.bottom }px` } } // Dispatch custom event after repositioning the mega menu (@since 2.0) if (bricksIsFrontend) { document.dispatchEvent( new CustomEvent('bricks/megamenu/repositioned', { detail: { menuItem: menuItem, submenu: submenu } }) ) } } // STEP: Default submenu else { // Remove overflow class to reapply logic on window resize if (submenu.classList.contains('brx-multilevel-overflow-right')) { submenu.classList.remove('brx-multilevel-overflow-right') } if (submenu.classList.contains('brx-submenu-overflow-right')) { submenu.classList.remove('brx-submenu-overflow-right') } if (submenu.classList.contains('brx-sub-submenu-overflow-right')) { submenu.classList.remove('brx-sub-submenu-overflow-right') } // Check if submenu is nested inside another brx-dropdown let isToplevel = !menuItem.parentNode.closest('.menu-item') && !menuItem.parentNode.closest('.brxe-dropdown') // STEP: Re-position in case of viewport overflow let submenuRect = submenu.getBoundingClientRect() let submenuWidth = submenuRect.width let submenuRight = submenuRect.right let submenuLeft = Math.ceil(submenuRect.left) // STEP: Submenu wider than viewport: Set submenu to viewport width if (submenuWidth > docWidth) { submenu.style.left = `-${submenuLeft}px` submenu.style.minWidth = `${docWidth}px` } // STEP: Dropdown content overflows viewport to the right: Re-position to prevent horizontal scrollbar else if (submenuRight > docWidth) { let multilevel = submenu.closest('.brx-has-multilevel') // Top level of multilevel menu: Position all menus to the right if (multilevel) { submenu.classList.add('brx-multilevel-overflow-right') } // Default submenu else { if (isToplevel) { submenu.classList.add('brx-submenu-overflow-right') } else { submenu.classList.add('brx-sub-submenu-overflow-right') } } } // STEP: Dropdown content overflows viewport on the left else if (submenuLeft < 0) { submenu.style.left = !isToplevel ? '100%' : '0' // Position submenu to the right of the parent menu item (@since 2.0) submenu.style.right = 'auto' } } } }) function bricksSubmenuPosition(timeout = 0) { setTimeout(() => { bricksSubmenuPositionFn.run() }, timeout) } /** * Handle submenu before position logic on window resize * - Save initial width and height by using requestAnimationFrame * - Only execute bricksSubmenuBeforePosition if actual resize is detected * - DO NOT run this function multiple times * @since 2.0 */ function bricksSubmenuWindowResizeHandler() { let lastWidth, lastHeight, submenuTimeout, lastBodyWidth /** * Remove .brx-submenu-positioned class from submenu elements to apply display:none while resizing * @since 1.12.2 */ const bricksSubmenuBeforePosition = () => { let submenuToggles = bricksQuerySelectorAll(document, '.brx-submenu-toggle') submenuToggles.forEach((submenuToggle) => { let menuItem = submenuToggle.parentNode let submenu = menuItem.querySelector('.brx-megamenu') || menuItem.querySelector('.brx-dropdown-content') || menuItem.querySelector('ul') // Skip: Submenu not found (not rendered due to element condition) if (!submenu) { return } submenu.classList.remove('brx-submenu-positioned') }) } /** * Resize event handler * - Only execute if width actually changed (ignore height changes mobile address bar/height changes) * - Also trigger if body width changed (e.g. no-scroll classe on body) (@since 2.1) */ const handleResize = () => { const currentWidth = window.innerWidth const currentHeight = window.innerHeight const currentBodyWidth = document.body.clientWidth // (@since 2.1) // Only recalculate if width changes if (currentWidth === lastWidth && currentBodyWidth === lastBodyWidth) { return } // Clear timeout clearTimeout(submenuTimeout) // Actual resize detected, execute logic to hide submenus while resizing bricksSubmenuBeforePosition() // Re-calculate left position on window resize with debounce (@since 1.8) submenuTimeout = setTimeout(bricksSubmenuPosition, 250) // Update stored dimensions lastWidth = currentWidth lastHeight = currentHeight lastBodyWidth = currentBodyWidth } // Wait for stable viewport dimensions before starting to listen for resize events const waitForStableViewport = () => { let width = window.innerWidth let height = window.innerHeight requestAnimationFrame(() => { if (width === window.innerWidth && height === window.innerHeight) { // Viewport is stable, set the initial dimensions lastWidth = width lastHeight = height lastBodyWidth = document.body.clientWidth // Start listening for actual resize events window.addEventListener('resize', handleResize) } else { // Viewport is still changing, keep checking waitForStableViewport() } }) } // Initial check waitForStableViewport() // Body resize observer (in case body width changes without window resize, e.g. no-scroll class added to body) (@since 2.1) if (window.bricksData && !window.bricksData.bodyResizeObserver) { window.bricksData.bodyResizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { const newBodyWidth = entry.contentRect.width // Only trigger if body width actually changed if (newBodyWidth !== lastBodyWidth) { handleResize() } } }) window.bricksData.bodyResizeObserver.observe(document.body) } } /** * Multi level menu item: "Nav menu" OR "Dropdown" element * * Add 'back' text to multilevel submenus & click listeners. * * @since 1.8 */ function bricksMultilevelMenu() { // STEP: Nav nested: Multilevel enabled let navNestedElements = bricksQuerySelectorAll(document, '.brxe-nav-nested.multilevel') navNestedElements.forEach((navNested) => { let backText = navNested.getAttribute('data-back-text') let dropdowns = navNested.querySelectorAll('.brxe-dropdown') dropdowns.forEach((dropdown) => { dropdown.classList.add('brx-has-multilevel') dropdown.setAttribute('data-toggle', 'click') dropdown.setAttribute('data-back-text', backText) }) }) // STEP: Create "back" HTML & listeners let multilevelItems = bricksQuerySelectorAll(document, '.brx-has-multilevel') multilevelItems.forEach((menuItem) => { let backText = menuItem.getAttribute('data-back-text') || 'Back' let submenus = bricksQuerySelectorAll(menuItem, 'ul') submenus.forEach((submenu, index) => { // Return on top level menu item if (index === 0) { return } // Add back list item as first submenu node: li > a.brx-multilevel-back let backLink = document.createElement('a') backLink.classList.add('brx-multilevel-back') backLink.setAttribute('href', '#') backLink.innerText = backText let backListItem = document.createElement('li') backListItem.classList.add('menu-item') backListItem.appendChild(backLink) submenu.insertBefore(backListItem, submenu.firstChild) // Listener to click on back link backLink.addEventListener('click', function (e) { e.preventDefault() // Hide current submenu let activeMenuItem = e.target.closest('.active') if (activeMenuItem) { activeMenuItem.classList.remove('open') activeMenuItem.classList.remove('active') // Set: aria-label="false" let submenuToggle = activeMenuItem.querySelector('.brx-submenu-toggle > button') if (submenuToggle) { submenuToggle.setAttribute('aria-expanded', false) } // Set parent menu item to active let parentMenuItem = activeMenuItem.parentNode.closest('.open') if (parentMenuItem) { parentMenuItem.classList.add('active') let parentSubmenu = parentMenuItem.querySelector('ul') if (parentSubmenu) { // Focus on first focusable element in parent menu item bricksFocusOnFirstFocusableElement(parentSubmenu) } } } }) }) }) } /** * Nav menu: Open/close mobile menu * * Open/close: Click on mobile menu hamburger * Close: Click on mobile menu overlay OR press ESC key */ function bricksNavMenuMobile() { let toggles = bricksQuerySelectorAll(document, '.bricks-mobile-menu-toggle') if (!toggles.length) { return } // STEP: Observe mobile menu toggle via MutationObserver (.show-mobile-menu class) let navMenuObserver = new MutationObserver((mutations) => { // Set popup height to viewport height (@since 1.8.2) bricksSetVh() mutations.forEach((mutation) => { // Add/remove .no-scroll body class if (mutation.target.classList.contains('show-mobile-menu')) { document.body.classList.add('no-scroll') } else { document.body.classList.remove('no-scroll') } }) }) // STEP: Observe class list changes on .brxe-nav-nested toggles.forEach((toggle) => { let navMenu = toggle.closest('.brxe-nav-menu') navMenuObserver.observe(navMenu, { attributes: true, attributeFilter: ['class'] }) }) let lastFocusedElement = null // STEP: Toggle mobile menu (click on hamburger) document.addEventListener('click', (e) => { let mobileMenuToggle = e.target.closest('.bricks-mobile-menu-toggle') if (mobileMenuToggle) { // Toggle mobile menu let navMenu = mobileMenuToggle.closest('.brxe-nav-menu') navMenu.classList.toggle('show-mobile-menu') // Toggle aria-expanded let expanded = navMenu.classList.contains('show-mobile-menu') let ariaLabel = expanded ? window.bricksData.i18n.closeMobileMenu : window.bricksData.i18n.openMobileMenu mobileMenuToggle.setAttribute('aria-expanded', expanded) mobileMenuToggle.setAttribute('aria-label', ariaLabel) if (expanded) { lastFocusedElement = document.activeElement setTimeout(() => { let navMenuMobile = navMenu.querySelector('.bricks-mobile-menu-wrapper') // Auto-focus first focusable element in mobile menu bricksFocusOnFirstFocusableElement(navMenuMobile) // Trap focus inside mobile menu (@since 1.11) // Allow focusing on the "close" button of the mobile menu (which is outside navMenuMobile (@since 2.3) navMenu.addEventListener('keydown', (event) => { // Only trap focus if mobile menu is currently open, otherwise we can't focus out of mobile toggle (@since 2.3) if (navMenu.classList.contains('show-mobile-menu')) { bricksTrapFocus(event, navMenu) } }) }, 100) } else { if (lastFocusedElement) { lastFocusedElement.focus() } } } }) // STEP: Close mobile menu: Click on mobile menu overlay OR section anchor ID was clicked (e.g. #section) document.addEventListener('click', (e) => { let navMenu = e.target.closest('.brxe-nav-menu') if (!navMenu) { return } let mobileMenuToggle = navMenu.querySelector('.bricks-mobile-menu-toggle') // Click on overlay: Close mobile menu if (e.target.classList.contains('bricks-mobile-menu-overlay')) { navMenu.classList.remove('show-mobile-menu') // Toggle aria-expanded navMenu.querySelector('.bricks-mobile-menu-toggle').setAttribute('aria-expanded', false) mobileMenuToggle.setAttribute('aria-expanded', false) mobileMenuToggle.setAttribute('aria-label', window.bricksData.i18n.openMobileMenu) } // Click on anchor ID: Close mobile menu else if (e.target.closest('.bricks-mobile-menu-wrapper')) { let navLinkUrl = e.target.tagName === 'A' ? e.target.getAttribute('href') : '' // Close section link click (e.g.: #portfolio) if (navLinkUrl.length > 1 && navLinkUrl.includes('#')) { navMenu.classList.remove('show-mobile-menu') // Toggle aria-expanded mobileMenuToggle.setAttribute('aria-expanded', false) mobileMenuToggle.setAttribute('aria-label', window.bricksData.i18n.openMobileMenu) } } }) // STEP: ESC key pressed: Close mobile menu & focus on mobile menu toggle button document.addEventListener('keyup', (e) => { if (e.key === 'Escape') { let openMobileMenu = document.querySelector('.brxe-nav-menu.show-mobile-menu') if (openMobileMenu) { openMobileMenu.classList.remove('show-mobile-menu') let toggle = openMobileMenu.querySelector('.bricks-mobile-menu-toggle') if (toggle) { toggle.setAttribute('aria-expanded', false) toggle.setAttribute('aria-label', window.bricksData.i18n.openMobileMenu) setTimeout(() => { toggle.focus() }, 10) } } } }) } /** * Element: Back to top * * @since 1.11 */ const bricksBackToTopFn = new BricksFunction({ parentNode: document, selector: '.brxe-back-to-top', eachElement: (element) => { // Add tabindex="-1" to body if move focus is enabled (accessibility improvement @since 2.2) if (element.hasAttribute('data-move-focus-to-top')) { document.body.setAttribute('tabindex', '-1') } // Scroll to top on click element.addEventListener('click', function (e) { e.preventDefault() window.scrollTo({ top: 0, behavior: element.hasAttribute('data-smooth-scroll') ? 'smooth' : 'auto' }) // Move focus to body if enabled (accessibility improvement @since 2.2) if (element.hasAttribute('data-move-focus-to-top')) { // Use a small delay to allow smooth scroll to finish (if enabled) const delay = element.hasAttribute('data-smooth-scroll') ? 100 : 0 setTimeout(() => { document.body.focus({ preventScroll: true }) }, delay) } }) // Visibility on scroll let visibleAfter = element.dataset.visibleAfter || 0 let visibleOnScrollUp = element.classList.contains('up') if (visibleAfter || visibleOnScrollUp) { let lastScrollTop = 0 window.addEventListener('scroll', function () { let scrollTop = document.documentElement.scrollTop let visible = true // Only show on scroll up if (visibleOnScrollUp) { if (scrollTop > lastScrollTop) { visible = false } else { visible = true } lastScrollTop = scrollTop } // Visible after scrolling down ...px if (window.scrollY > visibleAfter && visible) { visible = true } else { visible = false } if (visible) { element.classList.add('visible') } else { element.classList.remove('visible') } }) } } }) function bricksBackToTop() { bricksBackToTopFn.run() } /** * Helper function to get all focusable elements to auto-focus on (accessibility) * * @since 1.8 */ function bricksGetFocusables(node) { let focusableElements = node.querySelectorAll( 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])' ) // Filter out elements with display: none return Array.prototype.filter.call(focusableElements, (element) => { // Element or parent element has "inert" attribute, skip it - it's not focusable (@since 1.11) if (element.hasAttribute('inert') || element.closest('[inert]')) { return false } return window.getComputedStyle(element).display !== 'none' }) } /** * Get all focusable & visible elements within a node * * @since 1.11 */ function bricksGetVisibleFocusables(node) { // Return all focusable & visible elements return bricksGetFocusables(node).filter((element) => { return element.offsetWidth > 0 || element.offsetHeight > 0 }) } /** * Pause audio/video when popup is closed * * bricksPauseMediaFn.run() pauses all audio & video. * * @since 1.8 */ const bricksPauseMediaFn = new BricksFunction({ parentNode: document, selector: 'video, audio, iframe[src*="youtube"], iframe[src*="vimeo"]', subscribeEvents: ['bricks/popup/close'], forceReinit: true, eachElement: (element) => { // STEP: Pause video or audio if ( (element.tagName === 'VIDEO' || element.tagName === 'AUDIO') && element.pause && typeof element.pause === 'function' ) { element.pause() // Continue next element return } // STEP: Pause YouTube or Vimeo video if (element.tagName === 'IFRAME') { let src = element.getAttribute('src') let isYoutube = src.includes('youtube') let isVimeo = src.includes('vimeo') let command = isYoutube ? { event: 'command', func: 'pauseVideo', args: '' } : { method: 'pause' } if (isVimeo || isYoutube) { // Note that if the youtube video is not enableJSAPI, we can't pause it element.contentWindow.postMessage(JSON.stringify(command), '*') // Continue next element return } } }, listenerHandler: (event) => { if (event?.type) { switch (event.type) { case 'bricks/popup/close': let popupElement = event?.detail?.popupElement if (popupElement) { bricksPauseMediaFn.run({ parentNode: popupElement }) } break } } } }) /** * Dynamically set active/inactive anchor link * * As we no longer set aria-current="page" attribute on anchor links, we need to set it dynamically on click and page load. * * @since 1.11 */ const bricksAnchorLinksFn = new BricksFunction({ parentNode: document, selector: 'a[data-brx-anchor][href*="#"]', frontEndOnly: true, eachElement: (anchor) => { // Normalize pathname by removing trailing slashes for accurate comparison (@since 2.3.2) const normalizePathname = (pathname = '') => { let normalizedPath = pathname.replace(/\/+$/, '') return normalizedPath || '/' } const getAnchorLinkURL = (link) => { try { return new URL(link.getAttribute('href'), window.location.href) } catch (e) { return false } } // Determine if the anchor link is for the current page by comparing the origin and normalized pathname (ignoring trailing slashes) (@since 2.3.2) const isAnchorLinkCurrentPage = (linkURL) => { if (!linkURL || linkURL.origin !== window.location.origin) { return false } return normalizePathname(linkURL.pathname) === normalizePathname(window.location.pathname) } const setLinkActive = (link, active = true) => { let isNavitem = link.closest('.menu-item') || false if (active) { // Add aria-current="page" attribute to the clicked anchor link link.setAttribute('aria-current', 'page') if (isNavitem) { // Add current-menu-item class to nav menu item link.closest('.menu-item').classList.add('current-menu-item') } } else { // Remove aria-current="page" attribute from anchor link link.removeAttribute('aria-current') if (isNavitem) { // Remove current-menu-item class from nav menu item link.closest('.menu-item').classList.remove('current-menu-item') } } } // Get current URL hash let currentURLHash = window.location.hash || false // Set active link on page load if (currentURLHash && currentURLHash.length > 1) { // Get anchor link URL/hash let anchorLinkURL = getAnchorLinkURL(anchor) let anchorLinkHash = anchorLinkURL?.hash ? anchorLinkURL.hash.substring(1) : false if ( anchorLinkHash && anchorLinkHash === currentURLHash.substring(1) && isAnchorLinkCurrentPage(anchorLinkURL) ) { // STEP: Remove related active menu items or it will be multiple active items on page load when hash link exists (@since 1.12) let nearestNav = anchor.closest('.bricks-nav-menu, .brxe-nav-nested') || false if (nearestNav) { // Search all menu items inside the nav menu let navMenuItems = nearestNav.querySelectorAll('.menu-item') navMenuItems.forEach((item) => { // Search if any link has aria-current="page" attribute, exclude data-brx-anchor as it will be handled in another instance, otherwise only one link will be active on entire page let anchorLinks = item.querySelectorAll('a:not([data-brx-anchor])') // Remove all aria-current="page" attributes and it's current-menu-item class if (anchorLinks.length > 0) { item.classList.remove('current-menu-item') anchorLinks.forEach((link) => { link.removeAttribute('aria-current') }) } }) } // STEP: Set active link setLinkActive(anchor) } } // Dynamically set active link on click anchor.addEventListener('click', (e) => { // STEP: Remove related active menu items or it will be multiple active items when anchor link clicked (@since 1.12) let nearestNav = anchor.closest('.bricks-nav-menu, .brxe-nav-nested') || false if (nearestNav) { // Remove all current-menu-item classes from every nav menu item let navMenuItems = nearestNav.querySelectorAll('.menu-item') navMenuItems.forEach((item) => { item.classList.remove('current-menu-item') // Remove all aria-current="page" attributes from every anchor link inside the nav menu let anchorLinks = item.querySelectorAll('a[aria-current="page"]') anchorLinks.forEach((link) => { link.removeAttribute('aria-current') }) }) } // STEP: Set active link setLinkActive(anchor) }) } }) function bricksAnchorLinks() { bricksAnchorLinksFn.run() } /** * A Helper function to get query result by queryId (query element) * * @param {string} queryId Query element ID * @param {boolean} isPopState Flag: Popstate event (@since 1.11) * @param {boolean} nonceRefreshed Flag: Nonce refreshed (@since 1.11) */ function bricksGetQueryResult(queryId, isPopState = false, nonceRefreshed = false) { return new Promise(function (resolve, reject) { if (!queryId) { reject('No queryId provided') return } if (typeof window.bricksUtils.getFiltersForQuery !== 'function') { reject('Query filters JS not loaded') return } const queryInstance = window.bricksData.queryLoopInstances[queryId] || false if (!queryInstance) { reject('Query instance not found') return } if (!nonceRefreshed) { // Allow newer requests to abort older requests, example: search filter (@since 1.12) bricksUtils.maybeAbortXhr(queryId) } let url = window.bricksData.restApiUrl.concat('query_result') // Build allFilters array, no key is needed, just need each filter's ID let allFilters = window.bricksUtils.getFiltersForQuery(queryId, 'filterId') // Get selected filters for the query let selectedFilters = window.bricksUtils.getSelectedFiltersForQuery(queryId) // Get active filters tags for the query (@since 2.0) let afTags = window.bricksUtils.getDynamicTagsForParse(queryId) let originalQueryVars = queryInstance?.originalQueryVars === '[]' ? queryInstance?.queryVars : queryInstance?.originalQueryVars let queryData = { postId: window.bricksData.postId, queryElementId: queryId, originalQueryVars: originalQueryVars, // for query filters retrieve original DD parsed query vars (@since 1.11.1) pageFilters: window.bricksData.pageFilters || false, filters: allFilters, // for dynamic filter update selectedFilters: selectedFilters, // for active filter update (@since 1.11) afTags: afTags, // for active filters tags update (@since 2.0) nonce: window.bricksData.nonce, baseUrl: window.bricksData.baseUrl, lang: window.bricksData.language || false, mainQueryId: window.bricksData.mainQueryId || false, // Record the main query ID (@since 2.0) activeSearchTemplate: window.bricksData.activeSearchTemplate || false, // Current active search template ID (@since 2.2) urlParams: bricksGetCurrentUrlParams() // #86c21d4m0; @since 2.3.8 } // Add Get lang parameter for WPML if current url has lang parameter (@since 1.9.9) if ( window.bricksData.multilangPlugin === 'wpml' && (window.location.search.includes('lang=') || window.bricksData.wpmlUrlFormat != 3) ) { // use window.bricksData.language to get the current language url = url.concat('?lang=' + window.bricksData.language) } // Flag: Query result is loading window.bricksData.queryLoopInstances[queryId].isLoading = 1 /** * Ajax start event - ajax loader purposes * Do not dispatch if: * - nonceRefreshed * - older xhr aborted */ if (!nonceRefreshed && !window.bricksData.queryLoopInstances[queryId].xhrAborted) { document.dispatchEvent( new CustomEvent('bricks/ajax/start', { detail: { queryId: queryId, isPopState: isPopState } }) ) } // Make API call to get latest query result let xhr = new XMLHttpRequest() // Store xhr instance to abort if needed (@since 1.12) window.bricksData.queryLoopInstances[queryId].xhr = xhr xhr.open('POST', url, true) xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8') xhr.setRequestHeader('X-WP-Nonce', window.bricksData.wpRestNonce) // Successful response xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { if (window.bricksData.queryLoopInstances[queryId].xhr === xhr) { // Clear xhr instance window.bricksData.queryLoopInstances[queryId].xhr = null window.bricksData.queryLoopInstances[queryId].xhrAborted = false } let status = xhr.status let res try { res = JSON.parse(xhr.response) } catch (e) { reject('Invalid response from server') return } // Success if (status === 0 || (status >= 200 && status < 400)) { // Flag: Query result is loading window.bricksData.queryLoopInstances[queryId].isLoading = 0 let error = res?.error || false if (error) { console.error('error: bricksGetQueryResult', error) reject(error) } else { // Resolve the promise with the response resolve(res) } // Ajax end event - ajax loader purposes document.dispatchEvent( new CustomEvent('bricks/ajax/end', { detail: { queryId: queryId } }) ) // Remove this logic or all URL params will be removed @since 1.11 // update window.history.state back to original baseUrl // history.pushState('', document.title, window.bricksData.baseUrl) // Emit event document.dispatchEvent( new CustomEvent('bricks/ajax/query_result/completed', { detail: { queryId: queryId } }) ) } else if (res?.code === 'rest_cookie_invalid_nonce') { // Refresh nonce and retry if not already attempted if (!nonceRefreshed) { bricksRegenerateNonceAndRetryQuery(queryId).then(resolve).catch(reject) } else { reject('Nonce verification failed after refresh') } } else { reject(`Request failed with status ${status}`) } } } xhr.send(JSON.stringify(queryData)) }) } /** * Regenerate nonce and retry query * * @param {*} queryId * @since 1.11 */ function bricksRegenerateNonceAndRetryQuery(queryId) { return new Promise((resolve, reject) => { let xhrNonce = new XMLHttpRequest() xhrNonce.open('POST', window.bricksData.ajaxUrl, true) xhrNonce.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') xhrNonce.onreadystatechange = function () { if (xhrNonce.readyState === XMLHttpRequest.DONE) { if (xhrNonce.status >= 200 && xhrNonce.status < 400) { let response try { response = JSON.parse(xhrNonce.responseText) } catch (e) { reject('Invalid response from server when regenerating nonce') return } if (response.success && response.data) { window.bricksData.nonce = response.data.bricks_nonce window.bricksData.wpRestNonce = response.data.rest_nonce let isPopState = false let nonceRefreshed = true bricksGetQueryResult(queryId, isPopState, nonceRefreshed).then(resolve).catch(reject) } else { reject('Failed to regenerate nonces: Invalid response structure') } } else { reject('Failed to regenerate nonce') } } } xhrNonce.send('action=bricks_regenerate_query_nonce') }) } function bricksDisplayQueryResult(targetQueryId, res) { const html = res?.html || false const styles = res?.styles || false const popups = res?.popups || false const updatedQuery = res?.updated_query || false const updatedFilters = res?.updated_filters || false const parsedAfTags = res?.parsed_af_tags || false // @since 2.0 // Get query instance const queryInstance = window.bricksData.queryLoopInstances[targetQueryId] || false // Get query result container const resultsContainer = queryInstance?.resultsContainer || false if (!queryInstance || !resultsContainer) { return } const queryComponentId = queryInstance?.componentId || false const selectorId = bricksUtils.getQueryTrailSelectorId(targetQueryId, queryComponentId) // Keep a copy of the bricks-gutter-sizer from the results container (posts element) const gutterSizer = resultsContainer.querySelector('.bricks-gutter-sizer') const isotopSizer = resultsContainer.querySelector('.bricks-isotope-sizer') const actualLoopDOM = resultsContainer.querySelectorAll( `.brxe-${selectorId}, .bricks-posts-nothing-found` ) // Find the HTML comment <!--brx-loop-start-QUERYID--> const loopStartComment = document .createNodeIterator(resultsContainer, NodeFilter.SHOW_COMMENT, { acceptNode: function (node) { return node.nodeValue === `brx-loop-start-${targetQueryId}` } }) .nextNode() const hasOldResults = actualLoopDOM.length > 0 || loopStartComment if (hasOldResults) { /** * - Remove all brxe-<targetQueryId> nodes, and .bricks-posts-nothing-found div from the results container */ resultsContainer .querySelectorAll(`.brxe-${selectorId}, .bricks-posts-nothing-found`) .forEach((el) => el.remove()) } if (html) { // Add new HTML inside the queryParentElement if (hasOldResults) { // Find the HTML comment <!--brx-loop-start-QUERYID--> and insert the HTML string right after it if (loopStartComment) { // Check if the first HTML tag is a <td> or <tr> const firstTag = html.match(/<\s*([a-z0-9]+)([^>]+)?>/i) let tempDiv = null // Special case for <td> and <tr> tags if (firstTag && (firstTag[1] === 'td' || firstTag[1] === 'tr')) { tempDiv = document.createElement('tbody') } else { tempDiv = document.createElement('div') } // Insert the HTML string inside the temp div (Browser will parse the HTML string to DOM nodes and auto correct any invalid HTML tags) tempDiv.innerHTML = html // Get the child nodes of the temp div let newNodes = Array.from(tempDiv.childNodes) //reverse the array to insert the nodes in the correct order newNodes.reverse() newNodes.forEach((node) => { if (loopStartComment.nextSibling) { loopStartComment.parentNode?.insertBefore(node, loopStartComment.nextSibling) } else { loopStartComment.parentNode?.appendChild(node) } }) // Remove the temp div tempDiv.remove() } } else { resultsContainer.insertAdjacentHTML('beforeend', html) } } // Restore the bricks-gutter-sizer if (gutterSizer) { resultsContainer.appendChild(gutterSizer) } // Restore the bricks-isotope-sizer if (isotopSizer) { resultsContainer.appendChild(isotopSizer) } // Remove old query looping popup elements const oldLoopPopupNodes = document.querySelectorAll( `.brx-popup[data-popup-loop="${targetQueryId}"]` ) oldLoopPopupNodes.forEach((el) => el.remove()) if (popups) { // Add popups HTML at the end of the body document.body.insertAdjacentHTML('beforeend', popups) } // Emit bricks/ajax/nodes_added (@since 1.11.1), move after popups added document.dispatchEvent( new CustomEvent('bricks/ajax/nodes_added', { detail: { queryId: targetQueryId } }) ) if (styles) { // Create a style element if not exists let styleElement = document.querySelector(`#brx-query-styles-${targetQueryId}`) if (!styleElement) { styleElement = document.createElement('style') styleElement.id = `brx-query-styles-${targetQueryId}` // Add style element to footer document.body.appendChild(styleElement) } // Add styles to the style element styleElement.innerHTML = styles } // (@since 1.12.2) if (updatedQuery) { bricksUtils.updateQueryResultStats(targetQueryId, 'query', updatedQuery) } /** * STEP: Replace any existing span[data-brx-af-count] innerHTML with the updated count * * {active_filters_count} DD * @since 2.0 */ if (parsedAfTags) { window.bricksUtils.updateParsedDynamicTags(targetQueryId, parsedAfTags) } /** * STEP: Update queryInstance page and maxPage */ if (updatedQuery?.query_vars?.paged !== undefined) { window.bricksData.queryLoopInstances[targetQueryId].page = parseInt( updatedQuery.query_vars.paged ) } if (updatedQuery?.max_num_pages !== undefined) { window.bricksData.queryLoopInstances[targetQueryId].maxPages = parseInt( updatedQuery.max_num_pages ) } if (updatedQuery?.start !== undefined) { window.bricksData.queryLoopInstances[targetQueryId].start = parseInt(updatedQuery.start) } if (updatedQuery?.end !== undefined) { window.bricksData.queryLoopInstances[targetQueryId].end = parseInt(updatedQuery.end) } /** * STEP: Update filterInstance elements */ if (updatedFilters) { for (let filterId in updatedFilters) { const filterHtml = updatedFilters[filterId] const filterInstance = window.bricksData.filterInstances[filterId] || false if (!filterInstance) { continue } // Search filter type: Skip or current focus cursor will be lost if (filterInstance.filterType === 'search' || filterInstance.filterType === 'datepicker') { continue } // Create a dummy div and hold the new filter HTML const dummyDiv = document.createElement('div') dummyDiv.innerHTML = filterHtml let newFilterElement = dummyDiv.childNodes[0] // Choices.js select filter: Destroy existing Choices instance (IMPORTANT) (@since 2.3) if ( filterInstance.filterType === 'select' && filterInstance.choicesInstance && typeof filterInstance.choicesInstance.destroy === 'function' ) { filterInstance.choicesInstance.destroy() // filterElement is the select element but not the root (@since 2.3) newFilterElement = newFilterElement.querySelector('select.brxe-filter-select') } // Replace the old filter element with the new one filterInstance.filterElement.replaceWith(newFilterElement) // Update the filterInstance filterElement filterInstance.filterElement = newFilterElement // Remove dummy div dummyDiv.remove() } } /** * STEP: Show or hide Load more buttons */ bricksUtils.hideOrShowLoadMoreButtons(targetQueryId) // Emit event document.dispatchEvent( new CustomEvent('bricks/ajax/query_result/displayed', { detail: { queryId: targetQueryId } }) ) // Infinite scroll if (queryInstance.infiniteScroll) { setTimeout(() => { let newQueryTrail = Array.from( queryInstance.resultsContainer?.querySelectorAll(`.brxe-${selectorId}:not(.brx-popup)`) ).pop() if (!newQueryTrail) { return } newQueryTrail.dataset.queryElementId = targetQueryId // Check if the query trail is still visible, if yes, triggers the next page if (BricksIsInViewport(newQueryTrail)) { bricksQueryLoadPage(newQueryTrail) } // Add a new observer else { new BricksIntersect({ element: newQueryTrail, callback: (newQueryTrail) => bricksQueryLoadPage(newQueryTrail), once: true, rootMargin: queryInstance.observerMargin }) } }, 250) } } /** * Convert YouTube video URL to the embed URL, and also return video ID * * @param {string} url YouTube video URL * * @return {Object} Object containing embed URL and video ID * * @since 2.0 */ function bricksGetYouTubeVideoLinkData(url) { if (!url) { return { url: '', id: null } } const youtubeRegex = /(?:youtube(?:-nocookie)?\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|shorts\/|live\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/i const match = url.match(youtubeRegex) if (match && match[1]) { const videoId = match[1] // Create embed URL with video ID let embedUrl = `https://www.youtube.com/embed/${videoId}` // Preserve query parameters from the original URL (@since 2.1.3) try { const urlObj = new URL(url) const params = new URLSearchParams(urlObj.search) // Remove the 'v' parameter as it's already in the path params.delete('v') // If there are remaining parameters, append them to the embed URL const paramString = params.toString() if (paramString) { embedUrl += '?' + paramString } } catch (e) { // If URL parsing fails, continue without parameters console.warn('Failed to parse URL parameters:', e) } return { url: embedUrl, id: videoId } } // If no match, return the original URL return { url, id: null } } /** * Set viewport height CSS variable: --bricks-vh * * Used in popup to cover viewport correctly on mobile devices. * * @since 1.8.2 */ function bricksSetVh() { const vh = window.innerHeight * 0.01 // Set var on documentElement (<html>) document.documentElement.style.setProperty('--bricks-vh', `${vh}px`) } /** * Helper function to run all BricksFunctions * * @since 1.11 */ function bricksRunAllFunctions() { if (!window.bricksFunctions) { return } window.bricksFunctions.forEach((fn) => { if (fn.run && typeof fn.run === 'function') { fn.run() } }) } /** * Enqueue custom scripts */ let bricksIsFrontend let bricksTimeouts = {} document.addEventListener('DOMContentLoaded', (event) => { bricksIsFrontend = document.body.classList.contains('bricks-is-frontend') // Block Editor Integration: MutationObserver on editor container (@since 2.1.2) bricksBlockEditorIntegration() // Nav menu & Dropdown (@since 1.8) bricksNavMenu() bricksMultilevelMenu() bricksNavMenuMobile() bricksStickyHeader() bricksOnePageNavigation() bricksSkipLinks() bricksFacebookSDK() bricksSearchToggle() bricksPopups() bricksSwiper() // Sequence matters: before bricksSplide() bricksSplide() // Sequence matters: after bricksSwiper() bricksSplideListeners() // Run after bricksSwiper() & bricksSplide() as those need to generate required duplicate nodes first bricksPhotoswipe() bricksTinyMCE() bricksPrettify() bricksAccordion() bricksAnimatedTyping() bricksAudio() bricksCountdown() bricksCounter() bricksTableOfContents() bricksPricingTables() bricksVideo() bricksLazyLoad() bricksAnimation() bricksMotionParallax() bricksPieChart() bricksPostReadingProgressBar() bricksProgressBar() bricksForm() bricksInitQueryLoopInstances() bricksQueryPagination() bricksInteractions() bricksAlertDismiss() bricksTabs() bricksVideoOverlayClickDetector() bricksBackgroundVideoInit() bricksPostReadingTime() bricksBackToTop() // @since 1.11 bricksMapLeaflet() // @since 2.1 bricksNavNested() bricksOffcanvas() bricksToggle() // Light/dark mode toggle (@since 2.2) bricksToggleMode() // After bricksNavNested() ran (added .brx-has-multilevel) bricksSubmenuListeners() bricksSubmenuPosition(250) bricksAjaxLoader() bricksAnchorLinks() // @since 1.11 bricksUtils.imageGalleryLoadMoreSync('all') // @since 2.3.1 bricksGalleryInfiniteScroll() // @since 2.3.1 /** * Execute on initial page load to hide invalid buttons * * Maybe orphaned interactions set by user after removed image gallery element or changed its ID, etc. * * @since 2.3.1 */ bricksUtils.imageGalleryLoadMoreButtonVisibility('all') // Run last to make sure all elements are loaded bricksIsotope() bricksIsotopeListeners() // Handle submenu before position logic (@since 2.0) window.addEventListener('load', bricksSubmenuWindowResizeHandler) /** * Debounce * * Use timeout object to allow for individual clearTimeout() calls. * * @since 1.8 */ window.addEventListener('resize', () => { Object.keys(bricksTimeouts).forEach((key) => { clearTimeout(bricksTimeouts[key]) }) // Frontend: 1vh calculation based on window.innerHeight (for mobile devices) if (bricksIsFrontend) { bricksTimeouts.bricksVh = setTimeout(bricksSetVh, 250) // Keep Swiper breakpoint settings in sync after capped containers stop triggering resize observers. bricksTimeouts.bricksSwiperBreakpoints = setTimeout(bricksSwiperUpdateBreakpoints, 250) } // Builder: Re-init swiperJS on window resize for switching between breakpoints, etc. else { bricksTimeouts.bricksSwiper = setTimeout(bricksSwiper, 250) bricksTimeouts.bricksSplide = setTimeout(bricksSplide, 250) } // Re-calculate left position on window resize with debounce (@since 1.8) Moved to bricksSubmenuWindowResizeHandler() // bricksTimeouts.bricksSubmenuPosition = setTimeout(bricksSubmenuPosition, 250) // Tabs: Accordion at breakpoint (@since 2.1) bricksTimeouts.tabsAdjustLayoutOnMobile = setTimeout(bricksTabsAccordionLayoutOnMobile, 250) // Re-calculate motion parallax settings and offsets after viewport changes. bricksTimeouts.bricksMotionParallax = setTimeout(bricksMotionParallax, 150) // Set mobile menu open toggle parent div display according to toggle display bricksTimeouts.bricksToggleDisplay = setTimeout(bricksToggleDisplay, 100) // Close nav menu mobile menu on resize bricksTimeouts.bricksNavMenuMobileToggleDisplay = setTimeout( bricksNavMenuMobileToggleDisplay, 100 ) }) ;[ 'bricks/ajax/pagination/completed', 'bricks/ajax/load_page/completed', 'bricks/ajax/popup/loaded', 'bricks/ajax/query_result/displayed' ].forEach((eventName) => { document.addEventListener(eventName, () => { bricksUtils.imageGalleryLoadMoreSync('all') }) }) /** * Separate event registration from bricksInteractionsFn * * 100ms timeout to ensure bricksInteractionsFn has been initialized & set window.bricksData.interactions * * @since 1.8 */ setTimeout(() => { let interactions = Array.isArray(window.bricksData?.interactions) ? window.bricksData.interactions : [] // Scroll interaction(s) found: Listen to scroll event if (interactions.find((interaction) => interaction?.trigger === 'scroll')) { document.addEventListener('scroll', bricksScrollInteractions) } }, 100) })
修改文件时间
将文件时间修改为当前时间的前一年
删除文件