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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user