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
? rect.bottom + sy + GAP side has more room. The final clamp guarantees the popup
: rect.top + sy - ph - GAP; 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.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';