Keep centered popovers inside the viewport and place them before paint

Measure the popover in a hidden, out-of-flow state and position it in a
layout effect, so opening it no longer grows the page for a frame.
Centered (top/bottom) popovers are clamped to stay within the viewport.
Also ignore /test and /.tmp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 04:58:51 +01:00
co-authored by Claude Opus 5.5
parent f9fa9da091
commit d9b1463db2
3 changed files with 80 additions and 10 deletions
+42 -2
View File
@@ -6,11 +6,22 @@ import twuiNumberfy from "../../utils/numberfy";
type Params = {
targetElRef: React.RefObject<HTMLElement | null>;
position: (typeof TWUIPopoverStyles)[number];
/**
* The popover itself, once rendered, so a centered one can be kept
* inside the viewport.
*/
contentEl?: HTMLElement | null;
};
/**
* The gap kept between a centered popover and the viewport's edge.
*/
const ViewportMargin = 8;
export default function twuiGrabPopoverStyles({
position,
targetElRef,
contentEl,
}: Params): React.CSSProperties {
if (!targetElRef.current) return {};
const rect = targetElRef.current.getBoundingClientRect();
@@ -28,15 +39,21 @@ export default function twuiGrabPopoverStyles({
zIndex: 100,
};
const centeredLeft = clampCenteredLeft({
center: rect.left + window.scrollX + rect.width / 2,
width: contentEl?.offsetWidth || 0,
viewportWidth,
});
const defaultBottomStyle: React.CSSProperties = {
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2,
left: centeredLeft,
transform: "translateX(-50%)",
};
const defaultTopStyleStyle: React.CSSProperties = {
bottom: viewportHeight - (rect.top + window.scrollY) + 8,
left: rect.left + window.scrollX + rect.width / 2,
left: centeredLeft,
transform: "translateX(-50%)",
};
@@ -76,3 +93,26 @@ export default function twuiGrabPopoverStyles({
return popoverStyle;
}
/**
* Where a popover centered on its target (`translateX(-50%)`) puts its
* middle, moved in so its edges stay inside the viewport. A popover wider
* than the viewport, or one not measured yet, stays centered.
*/
function clampCenteredLeft({
center,
width,
viewportWidth,
}: {
center: number;
width: number;
viewportWidth: number;
}) {
const half = width / 2;
const min = window.scrollX + half + ViewportMargin;
const max = window.scrollX + viewportWidth - half - ViewportMargin;
if (!width || min > max) return center;
return Math.min(Math.max(center, min), max);
}
+36 -8
View File
@@ -14,6 +14,17 @@ type Props = TWUI_MODAL_PROPS & {
popoverContentActiveRef: React.MutableRefObject<boolean>;
};
/**
* How the popover renders before it has been placed: out of the flow and
* invisible, so it can be measured without moving anything.
*/
const UnplacedStyle: React.CSSProperties = {
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
};
/**
* # Modal Main Component
*/
@@ -30,16 +41,32 @@ export default function PopoverComponent({
isPopover,
...props
}: Props) {
const [style, setStyle] = React.useState({});
const contentRef = React.useRef<HTMLDivElement>(null);
const [style, setStyle] = React.useState<React.CSSProperties | null>(
null,
);
React.useEffect(() => {
if (open && targetElRef?.current) {
const popoverStyle = twuiGrabPopoverStyles({
/**
* Placed before the browser paints, against the popover's own size:
* until then it is rendered out of the flow and hidden, so opening it
* never grows the page for a frame (a scrollbar appearing and the
* layout jumping).
*/
React.useLayoutEffect(() => {
if (!open) {
setStyle(null);
return;
}
if (!targetElRef?.current) return;
setStyle(
twuiGrabPopoverStyles({
position,
targetElRef,
});
setStyle(popoverStyle);
}
contentEl: contentRef.current,
}),
);
}, [open, targetElRef, position]);
const closeTimeoutRef = React.useRef<any>(null);
@@ -79,7 +106,8 @@ export default function PopoverComponent({
"twui-popover-content",
props.className,
)}
style={{ ...style, ...props.style }}
componentRef={contentRef}
style={{ ...(style || UnplacedStyle), ...props.style }}
onMouseEnter={
trigger === "hover" ? popoverEnterFn : props.onMouseEnter
}