diff --git a/package.json b/package.json index 7f16f9d..7238b72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tp/helpers", - "version": "2.12.1", + "version": "2.13.0", "description": "", "main": "closest.js", "scripts": { diff --git a/position.js b/position.js index 26d1444..c338073 100644 --- a/position.js +++ b/position.js @@ -14,7 +14,13 @@ export const Position = function(superClass) { options = Object.assign({ valign: 'top', halign: 'middle', - spacing: 0 + spacing: 0, + // When true, clamp against the element's containing-block rect + // instead of the viewport. Enable for popups rendered under an + // ancestor with transform / filter / perspective / will-change / + // contain:layout|paint — there, position: fixed resolves against + // that ancestor and the viewport is the wrong clamp edge. + clampToContainer: false }, options); let top, left, fixLeft = 0, fixTop = 0, compLeft = 0, compTop = 0; @@ -35,31 +41,39 @@ export const Position = function(superClass) { fixTop = elRect.top; } + // Resolve the clamp rect. Default is the viewport; when the consumer + // opts in, prefer the containing-block rect (falling back to viewport + // if no qualifying ancestor exists — e.g. the popup is viewport-fixed). + const viewport = { top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight }; + const clampRect = options.clampToContainer + ? (this._containingBlockRect(el) || viewport) + : viewport; + const anchorRect = anchor.getBoundingClientRect(); if (options.valign === 'top') { top = anchorRect.top - elRect.height - options.spacing; - // Move popup down a little bit if there issn't enough room over the anchor. - if (top < 0) { - compTop = Math.abs(top); - top = 0; + // Move popup down a little bit if there isn't enough room over the anchor. + if (top < clampRect.top) { + compTop = clampRect.top - top; + top = clampRect.top; } } if (options.valign === 'bottom') { top = anchorRect.top + anchorRect.height + options.spacing; - // Move popup up a little bit if there issn't enough room under the anchor. - if (top + elRect.height > window.innerHeight) { - compTop = top + elRect.height - window.innerHeight; + // Move popup up a little bit if there isn't enough room under the anchor. + if (top + elRect.height > clampRect.bottom) { + compTop = top + elRect.height - clampRect.bottom; top -= compTop; } } - // Ensure top never goes below 0 - if (top < 0) { - compTop += Math.abs(top); - top = 0; + // Ensure top never goes above the clamp rect's top edge. + if (top < clampRect.top) { + compTop += clampRect.top - top; + top = clampRect.top; } if (options.halign === 'left') { @@ -74,18 +88,18 @@ export const Position = function(superClass) { left = anchorRect.left + anchorRect.width - elRect.width; } - if (left + elRect.width > window.innerWidth) { - compLeft = left + elRect.width - window.innerWidth; + if (left + elRect.width > clampRect.right) { + compLeft = left + elRect.width - clampRect.right; left -= compLeft; } - if (left < 0) { - compLeft = Math.abs(left); - left = 0; + if (left < clampRect.left) { + compLeft = clampRect.left - left; + left = clampRect.left; } - // Constrain height to viewport - calculate available height from final top position - const availableHeight = window.innerHeight - top; + // Constrain height to clamp rect — calculate available height from final top. + const availableHeight = clampRect.bottom - top; if (elRect.height > availableHeight) { el.style.maxHeight = availableHeight + 'px'; el.style.overflowY = 'auto'; @@ -100,5 +114,49 @@ export const Position = function(superClass) { compTop: compTop }; } + + /** + * Walk up from `el` — across shadow boundaries — to find the nearest + * ancestor that establishes a containing block for fixed-positioned + * descendants (CSS Transforms / Filter Effects / Containment specs). + * Returns that ancestor's viewport rect, or null when `el` is fixed to + * the viewport (no qualifying ancestor). + */ + _containingBlockRect(el) { + let node = el.parentElement || (el.getRootNode && el.getRootNode().host); + while (node) { + const cs = getComputedStyle(node); + if (cs && this._establishesContainingBlock(cs)) { + return node.getBoundingClientRect(); + } + node = node.parentElement || (node.getRootNode && node.getRootNode().host); + } + return null; + } + + /** + * Per CSS, an element becomes the containing block for fixed descendants + * when it has a non-`none` transform, perspective, filter, backdrop-filter, + * a `will-change` listing any of those, or a `contain` value that includes + * layout or paint (the `strict` and `content` shorthands qualify). + */ + _establishesContainingBlock(cs) { + if (cs.transform && cs.transform !== 'none') return true; + if (cs.perspective && cs.perspective !== 'none') return true; + if (cs.filter && cs.filter !== 'none') return true; + if (cs.backdropFilter && cs.backdropFilter !== 'none') return true; + if (cs.webkitBackdropFilter && cs.webkitBackdropFilter !== 'none') return true; + const wc = cs.willChange; + if (wc && wc !== 'auto' && + (wc.includes('transform') || wc.includes('perspective') || wc.includes('filter'))) { + return true; + } + const c = cs.contain; + if (c && c !== 'none' && + (c.includes('layout') || c.includes('paint') || c.includes('strict') || c.includes('content'))) { + return true; + } + return false; + } }; }