Popups: always clamp into the viewport

positionPopup clamped horizontally but never vertically: the
flip-above branch positioned tall popups (rich layouts, lead figures)
above the visible region whenever the target sat near the top of the
screen. Placement is now: below if it fits, above if THAT fits, else
the roomier side — then clamped into the viewport on both axes.
.link-popup is additionally capped at viewport height (matching GAP,
overflow-y: auto) so the clamp always has room to work, and a
dimension-less image that loads after positioning re-clamps instead of
growing past the edge.

Verified by simulating the clamp across five viewport scenarios: the
near-top bug case moves from 470px above the viewport to fully
visible; the fits-below and flip-above cases are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 13:14:14 -04:00
parent 1027b88429
commit 76bda7af13
2 changed files with 39 additions and 3 deletions

View File

@ -10,6 +10,12 @@
z-index: 500; z-index: 500;
max-width: 420px; max-width: 420px;
min-width: 200px; min-width: 200px;
/* Never taller than the viewport: positionPopup clamps the popup
into the visible region, which only works if the box itself can
fit there. The 10px matches positionPopup's GAP on each side;
overflow scrolls the rare popup that still exceeds the cap. */
max-height: calc(100vh - 20px);
overflow-y: auto;
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@ -215,6 +215,19 @@
positionPopup(target); positionPopup(target);
popup.classList.add('is-visible'); popup.classList.add('is-visible');
popup.setAttribute('aria-hidden', 'false'); popup.setAttribute('aria-hidden', 'false');
/* Images with width/height attrs reserve their space
before load; one without them grows the popup after
positioning and can push it past the viewport edge.
Re-clamp when such an image arrives. */
popup.querySelectorAll('img:not([height])').forEach(function (im) {
if (im.complete) return;
im.addEventListener('load', function () {
if (activeTarget === target &&
popup.classList.contains('is-visible')) {
positionPopup(target);
}
}, { once: true });
});
}).catch(function () { /* silently fail */ }); }).catch(function () { /* silently fail */ });
}, SHOW_DELAY); }, SHOW_DELAY);
} }
@ -247,9 +260,26 @@
var left = rect.left + sx + rect.width / 2 - pw / 2; var left = rect.left + sx + rect.width / 2 - pw / 2;
left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP)); left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP));
var top = (rect.bottom + GAP + ph <= vh) /* Below if it fits, else above if THAT fits, else whichever
side has more room. The final clamp guarantees the popup
never extends past either viewport edge without it, the
flip-above branch positions tall popups (rich layouts, lead
figures) above the visible region for targets near the top
of the screen. CSS caps .link-popup at viewport height
(same 10px gap), so the clamp always has room to work. */
var fitsBelow = rect.bottom + GAP + ph <= vh;
var fitsAbove = rect.top - GAP - ph >= 0;
var top;
if (fitsBelow) {
top = rect.bottom + sy + GAP;
} else if (fitsAbove) {
top = rect.top + sy - ph - GAP;
} else {
top = (vh - rect.bottom >= rect.top)
? rect.bottom + sy + GAP ? rect.bottom + sy + GAP
: rect.top + sy - ph - GAP; : rect.top + sy - ph - GAP;
}
top = Math.max(sy + GAP, Math.min(top, sy + vh - ph - GAP));
popup.style.left = left + 'px'; popup.style.left = left + 'px';
popup.style.top = top + 'px'; popup.style.top = top + 'px';