Add clampToContainer option to Position helper

_posFixed now resolves a clamp rect (viewport by default; the nearest
containing-block rect when clampToContainer is set) and clamps position,
edges, and max-height against it. Fixes popups rendered under an ancestor
with transform/filter/contain:layout|paint, where position: fixed resolves
against that ancestor and the viewport is the wrong clamp edge.
This commit is contained in:
pk
2026-08-09 23:15:05 +02:00
parent d918a19387
commit 4158f88ae3
2 changed files with 78 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@tp/helpers",
"version": "2.12.1",
"version": "2.13.0",
"description": "",
"main": "closest.js",
"scripts": {
+77 -19
View File
@@ -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;
}
};
}