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:
@@ -36,3 +36,5 @@ dist
|
|||||||
|
|
||||||
# dev playground artifacts (bunext)
|
# dev playground artifacts (bunext)
|
||||||
.bunext
|
.bunext
|
||||||
|
/test
|
||||||
|
/.tmp
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user