diff --git a/.gitignore b/.gitignore index 5d39cc7..6cdfb4a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ dist # dev playground artifacts (bunext) .bunext +/test +/.tmp \ No newline at end of file diff --git a/src/(functions)/popver/grab-popover-styles.ts b/src/(functions)/popver/grab-popover-styles.ts index e8c86d9..712b03a 100644 --- a/src/(functions)/popver/grab-popover-styles.ts +++ b/src/(functions)/popver/grab-popover-styles.ts @@ -6,11 +6,22 @@ import twuiNumberfy from "../../utils/numberfy"; type Params = { targetElRef: React.RefObject; 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); +} diff --git a/src/(partials)/PopoverComponent.tsx b/src/(partials)/PopoverComponent.tsx index 3fb1371..6f93af0 100644 --- a/src/(partials)/PopoverComponent.tsx +++ b/src/(partials)/PopoverComponent.tsx @@ -14,6 +14,17 @@ type Props = TWUI_MODAL_PROPS & { popoverContentActiveRef: React.MutableRefObject; }; +/** + * 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(null); + const [style, setStyle] = React.useState( + 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(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 }