UNPKG

@shopgate/engage

Version:
207 lines (193 loc) 8.48 kB
import { logger, isDev } from '@shopgate/engage/core/helpers'; import { DATA_IGNORED } from "./constants"; /** * Determines if an element is visually present in the DOM. * Checks for display, visibility, opacity, and transform-based hiding. * * @param {HTMLElement} el The element to evaluate. * @returns {boolean} True if the element is visually visible; false otherwise. */ export const isElementVisible = el => { if (!el || !(el instanceof HTMLElement)) return false; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return false; const { transform } = style; if (transform && transform !== 'none') { const match = transform.match(/translateY\((-?\d+)(px)?\)/); if (match) { const translateY = parseInt(match[1], 10); if (translateY > window.innerHeight) return false; } } return true; }; /** * Finds the widest nested element inside a given container that has a background color * explicitly set via CSS (ignores transparent, inherited, or unset values), and returns * that background color. * * @param {HTMLElement} container The parent element to search within. Must be an actual DOM node. * @returns {string|null} The detected background color (e.g., "rgb(255, 0, 0)") */ export const getElementBackgroundColor = container => { if (!container) { return null; } let widestElement = null; let maxWidth = -Infinity; /** * Recursively traverses the DOM tree starting from the given node, * tracking the widest element that has a background color explicitly set via CSS. * * @param {HTMLElement} node The DOM node to begin traversal from. */ function walk(node) { if (!(node instanceof HTMLElement)) return; const style = window.getComputedStyle(node); const bgColor = style.backgroundColor; const isStyledColor = bgColor && !['transparent', 'rgba(0, 0, 0, 0)', 'inherit', 'initial', 'unset'].includes(bgColor); const rect = node.getBoundingClientRect(); if (isElementVisible(node) && isStyledColor && rect.width > maxWidth) { maxWidth = rect.width; widestElement = node; } Array.from(node.children).forEach(walk); } walk(container); if (widestElement) { const result = window.getComputedStyle(widestElement).backgroundColor; return result; } return null; }; /** * Checks if any of the provided class names reference the custom property in any loaded stylesheet. * * @param {string[]} classList Array of class names to check. * @param {string} customProp The custom property to search for in the stylesheets. * @returns {boolean} True if any class rule uses the custom property. */ const classNamesUseCustomProp = (classList, customProp) => { const allRules = Array.from(document.styleSheets).filter(sheet => { try { return sheet.cssRules; } catch (e) { return false; // Skip cross-origin or restricted stylesheets } }).flatMap(sheet => Array.from(sheet.cssRules || [])); for (const rule of allRules) { // eslint-disable-next-line no-continue if (!rule.selectorText || !rule.cssText.includes(`var(${customProp})`)) continue; for (const className of classList) { if (rule.selectorText.includes(`.${className}`)) { return true; } } } return false; }; /** * Checks if a single element uses the custom property via inline styles or class-based rules. * * @param {HTMLElement} el The element to check. * @param {string} customProp The CSS custom property to search for. * @returns {boolean} True if the element uses the custom property. */ const elementUsesCustomProp = (el, customProp) => { if (!(el instanceof Element)) return false; const styleAttr = el.getAttribute?.('style'); if (styleAttr && styleAttr.includes(`var(${customProp})`)) { return true; } const classList = Array.from(el.classList || []); return classList.length > 0 && classNamesUseCustomProp(classList, customProp); }; /** * Checks if an element or any of its descendants use the custom property. * * @param {HTMLElement} el The root element to inspect. * @param {string} customProp The CSS custom property to look for. * @returns {boolean} True if the element or any descendant uses the custom property. */ const elementOrDescendantsUseCustomProp = (el, customProp) => { // Check if the element itself uses the custom property if (elementUsesCustomProp(el, customProp)) return true; const descendants = el.querySelectorAll('*'); for (const node of descendants) { // eslint-disable-next-line no-continue if (!(node instanceof Element)) continue; if (elementUsesCustomProp(node, customProp)) return true; } return false; }; /** * Returns footer entries that do NOT have safe area insets applied, * either on themselves or in any of their descendants. * * @param {HTMLElement[]} footerElements The footer elements to check. * @returns {HTMLElement[]} An array of direct children that do not use safe area insets. */ const getFooterEntriesWithoutSafeAreaInsets = footerElements => footerElements.filter(child => !elementOrDescendantsUseCustomProp(child, '--safe-area-inset-bottom')); /** * Searches for footer elements that do not have safe area insets applied, and adds a fallback. * @param {HTMLElement} footerEl The footer element whose children are to be checked. */ export const handleSafeAreaInsets = footerEl => { if (!footerEl || !(footerEl instanceof HTMLElement)) { return; } const directChildren = Array.from(footerEl.children); // Filter out elements that where already handled before const childrenToInspect = directChildren.filter(child => child.getAttribute('data-has-safe-area-inset') !== 'true').filter(child => child.getAttribute(DATA_IGNORED) !== 'true'); // Detect footer elements without safe area insets const childrenWithoutInsets = getFooterEntriesWithoutSafeAreaInsets(childrenToInspect); // Apply fallback and mark the elements as handled childrenWithoutInsets.forEach(child => { child.style.setProperty('padding-bottom', 'var(--safe-area-inset-bottom)'); child.style.setProperty('background-color', getElementBackgroundColor(child)); child.setAttribute('data-has-safe-area-inset', 'true'); }); if (isDev && childrenWithoutInsets.length > 0) { logger.warn('Footer elements without safe area insets detected. Please use the "--safe-area-inset-bottom" CSS custom property for bottom insets.', childrenWithoutInsets); } // Mark all other elements which already had insets as handled directChildren.filter(child => childrenWithoutInsets.indexOf(child) === -1).forEach(child => { if (!child.hasAttribute('data-has-safe-area-inset')) { child.setAttribute('data-has-safe-area-inset', 'true'); } }); }; const { style } = document.documentElement; /** * Update the footer height custom property * @param {number} height height */ export const updateFooterHeight = height => { // The TabBar is positioned with `position: fixed`, so it doesn’t contribute to the measured // height of the Footer. Additionally, it’s sometimes animated in/out, which makes dynamic // measurement via JavaScript more complex and error-prone. // // To simplify everything, we include the --tabbar-height CSS custom property to the calculation // of the --footer-height value. const footerHeight = `max(${height}px, var(--tabbar-height, 0px))`; if (style.getPropertyValue('--footer-height') !== footerHeight) { style.setProperty('--footer-height', footerHeight); } // The View component wraps every app page and applies a bottom offset to centrally manage // safe area insets across screens. // // If the measured footer height is > 0px, it means the footer is rendering content that already // accounts for the safe area, so no additional offset is needed. // // If the measured footer height is 0px, the footer is either empty or only contains the tab bar // (which is measured via the CSS variable --footer-height). In that case, we still need to apply // an offset equal to the larger of the bottom safe area inset or the tab bar height. const pageContentOffset = height === 0 ? 'max(var(--footer-height), var(--safe-area-inset-bottom))' : '0px'; if (style.getPropertyValue('--page-content-offset-bottom') !== pageContentOffset) { style.setProperty('--page-content-offset-bottom', pageContentOffset); } };