Files
gerbeur/src/hooks/useAnchoredFloat.ts
khannurien 1adf7b04d7
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
v3: stop cards and modal bodies from clipping the editor's popups
The emoji picker and the @-mention list were absolutely positioned inside
.mention-textarea-wrap, so every ancestor with a clipping box cut them off —
and TextEditor sits inside plenty: .dump-card-inner, .journal-card and
.rich-content-card clip with overflow: hidden, while .modal-body and the chat
scroller clip just as hard with overflow-y: auto. The picker also always
opened upward, so a textarea near the top of the viewport pushed it off-screen.

Both popups are now portaled to <body> and anchored to the textarea with
position: fixed, which no ancestor can clip. The new useAnchoredFloat hook
flips to whichever side has more room, clamps horizontally on narrow screens,
and publishes the space left as --float-max-h so the list shrinks and scrolls
instead of overflowing. It re-places on scroll, on resize, and through a
ResizeObserver, since autoResize grows the textarea as you type.

z-index goes to 1100 to clear the modal backdrop, staying under tooltips.

Drops two point-fixes that only existed to work around the clipping: the
hardcoded upward flip for the chat composer's mention list, whose leftover
margin would now double the gap, and the claim that the chat modal's body
needed overflow: visible for the popovers' sake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:28:48 +00:00

110 lines
3.8 KiB
TypeScript

import {
type CSSProperties,
type RefObject,
useLayoutEffect,
useState,
} from "react";
/** Space between the anchor and the panel. */
const GAP = 4;
/** Space kept clear of the viewport edges. */
const MARGIN = 8;
interface AnchoredFloatOptions {
/**
* Preferred width in px, or "anchor" to match the anchor element's own width.
* Shrunk when the viewport is narrower either way.
*/
width: number | "anchor";
/** Height of the panel's non-scrolling chrome (search row, borders…). */
chromeHeight: number;
minContentHeight: number;
maxContentHeight: number;
}
/**
* Positions a floating panel against an anchor element with `position: fixed`,
* so no ancestor's `overflow: hidden` / `overflow: auto` can clip it — cards,
* modal bodies and scrollers all cut off an absolutely positioned popup.
*
* Flips to whichever side of the anchor has the most room, clamps horizontally
* to the viewport, and publishes the room left for the scrolling part as the
* `--float-max-h` custom property.
*
* Returns `null` while closed; the first value lands in a layout effect, so the
* panel is never painted at the wrong spot.
*/
export function useAnchoredFloat(
anchorRef: RefObject<HTMLElement | null>,
open: boolean,
{ width, chromeHeight, minContentHeight, maxContentHeight }:
AnchoredFloatOptions,
): CSSProperties | null {
const [style, setStyle] = useState<CSSProperties | null>(null);
useLayoutEffect(() => {
if (!open) return;
const anchor = anchorRef.current;
if (!anchor) return;
const place = () => {
const r = anchor.getBoundingClientRect();
const vw = globalThis.innerWidth;
const vh = globalThis.innerHeight;
// Clamp the anchor's edges into the viewport first: an auto-growing
// textarea can be taller than the screen, and an off-screen edge would
// otherwise put the panel out of reach.
const anchorTop = Math.min(Math.max(r.top, MARGIN), vh - MARGIN);
const anchorBottom = Math.min(Math.max(r.bottom, MARGIN), vh - MARGIN);
const roomAbove = anchorTop - GAP - MARGIN;
const roomBelow = vh - anchorBottom - GAP - MARGIN;
const placeAbove = roomAbove >= roomBelow;
const w = Math.min(width === "anchor" ? r.width : width, vw - MARGIN * 2);
const content = Math.max(
minContentHeight,
Math.min(maxContentHeight, (placeAbove ? roomAbove : roomBelow) -
chromeHeight),
);
// Anchoring by `bottom` when above means the panel's own height never
// enters the computation, so it can't jump as the list loads.
const next: CSSProperties & Record<string, string | number> = {
position: "fixed",
width: w,
left: Math.min(
Math.max(MARGIN, r.left),
Math.max(MARGIN, vw - w - MARGIN),
),
"--float-max-h": `${content}px`,
};
if (placeAbove) {
next.bottom = vh - anchorTop + GAP;
next.top = "auto";
} else {
next.top = anchorBottom + GAP;
next.bottom = "auto";
}
setStyle(next);
};
place();
// `true` catches scrolling in any ancestor scroller, not just the window.
globalThis.addEventListener("scroll", place, true);
globalThis.addEventListener("resize", place);
// The anchor itself moves when an auto-resizing textarea grows.
const observer = new ResizeObserver(place);
observer.observe(anchor);
return () => {
globalThis.removeEventListener("scroll", place, true);
globalThis.removeEventListener("resize", place);
observer.disconnect();
};
}, [open, anchorRef, width, chromeHeight, minContentHeight, maxContentHeight]);
// Gated on `open` rather than cleared on close: the stale value is never
// painted, since the layout effect re-places the panel before the next paint.
return open ? style : null;
}