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
+2
View File
@@ -36,3 +36,5 @@ dist
# dev playground artifacts (bunext) # dev playground artifacts (bunext)
.bunext .bunext
/test
/.tmp
+42 -2
View File
@@ -6,11 +6,22 @@ import twuiNumberfy from "../../utils/numberfy";
type Params = { type Params = {
targetElRef: React.RefObject<HTMLElement | null>; targetElRef: React.RefObject<HTMLElement | null>;
position: (typeof TWUIPopoverStyles)[number]; 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({ export default function twuiGrabPopoverStyles({
position, position,
targetElRef, targetElRef,
contentEl,
}: Params): React.CSSProperties { }: Params): React.CSSProperties {
if (!targetElRef.current) return {}; if (!targetElRef.current) return {};
const rect = targetElRef.current.getBoundingClientRect(); const rect = targetElRef.current.getBoundingClientRect();
@@ -28,15 +39,21 @@ export default function twuiGrabPopoverStyles({
zIndex: 100, zIndex: 100,
}; };
const centeredLeft = clampCenteredLeft({
center: rect.left + window.scrollX + rect.width / 2,
width: contentEl?.offsetWidth || 0,
viewportWidth,
});
const defaultBottomStyle: React.CSSProperties = { const defaultBottomStyle: React.CSSProperties = {
top: rect.bottom + window.scrollY + 8, top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2, left: centeredLeft,
transform: "translateX(-50%)", transform: "translateX(-50%)",
}; };
const defaultTopStyleStyle: React.CSSProperties = { const defaultTopStyleStyle: React.CSSProperties = {
bottom: viewportHeight - (rect.top + window.scrollY) + 8, bottom: viewportHeight - (rect.top + window.scrollY) + 8,
left: rect.left + window.scrollX + rect.width / 2, left: centeredLeft,
transform: "translateX(-50%)", transform: "translateX(-50%)",
}; };
@@ -76,3 +93,26 @@ export default function twuiGrabPopoverStyles({
return popoverStyle; 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>; 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 * # Modal Main Component
*/ */
@@ -30,16 +41,32 @@ export default function PopoverComponent({
isPopover, isPopover,
...props ...props
}: 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) { * Placed before the browser paints, against the popover's own size:
const popoverStyle = twuiGrabPopoverStyles({ * 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, position,
targetElRef, targetElRef,
}); contentEl: contentRef.current,
setStyle(popoverStyle); }),
} );
}, [open, targetElRef, position]); }, [open, targetElRef, position]);
const closeTimeoutRef = React.useRef<any>(null); const closeTimeoutRef = React.useRef<any>(null);
@@ -79,7 +106,8 @@ export default function PopoverComponent({
"twui-popover-content", "twui-popover-content",
props.className, props.className,
)} )}
style={{ ...style, ...props.style }} componentRef={contentRef}
style={{ ...(style || UnplacedStyle), ...props.style }}
onMouseEnter={ onMouseEnter={
trigger === "hover" ? popoverEnterFn : props.onMouseEnter trigger === "hover" ? popoverEnterFn : props.onMouseEnter
} }