v3: stop cards and modal bodies from clipping the editor's popups
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
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>
This commit is contained in:
109
src/hooks/useAnchoredFloat.ts
Normal file
109
src/hooks/useAnchoredFloat.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user