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

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:
khannurien
2026-09-16 18:28:48 +00:00
parent 231559b3cd
commit 1adf7b04d7
4 changed files with 161 additions and 29 deletions

View File

@@ -4277,21 +4277,14 @@ body.has-player .chat-fab {
max-height: 92vh;
}
/* The composer's popovers (mention list, emoji picker) anchor to the input,
which sits at the very bottom of the modal. The modal body must not clip them
— let them escape the body entirely so they're never cropped. The message
list keeps its own scroll, so nothing else relies on the body clipping. */
/* The chat sizes itself to the modal and the message list carries its own
scroll, so the body never has anything to scroll — dropping its clip keeps a
rounding overflow from raising a second, empty scrollbar beside the messages.
(The composer's popovers no longer need this: they are portaled to <body>.) */
.modal-card:has(.chat) .modal-body {
overflow: visible;
}
/* Open the mention list upward, into the roomy message area above the input. */
.chat-composer .mention-dropdown {
top: auto;
bottom: 100%;
margin: 0 0 4px;
}
/* Very narrow viewports: stack the input and Send button. */
@media (max-width: 400px) {
.chat-composer {
@@ -5806,20 +5799,22 @@ body.has-player .chat-fab {
font-weight: 600;
}
/* Portaled to <body> and positioned from JS (see useAnchoredFloat), for the
same reason as the emoji picker below: the editor sits inside cards, modal
bodies and scrollers whose overflow would clip an absolutely positioned
popup. Above the modal backdrop (1000), below tooltips (9999). */
.mention-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 200;
position: fixed;
z-index: 1100;
max-width: calc(100vw - 16px);
list-style: none;
margin: 2px 0 0;
margin: 0;
padding: 4px 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
max-height: 220px;
max-height: var(--float-max-h, 220px);
overflow-y: auto;
}
.mention-dropdown-item {
@@ -5840,12 +5835,14 @@ body.has-player .chat-fab {
}
/* ── Emoji picker (frimousse) ── */
/* Portaled to <body> and positioned from JS (see useAnchoredFloat): anchored
with `position: fixed` so cards, modal bodies and scrollers can't clip it.
Above the modal backdrop (1000), below tooltips (9999). */
.emoji-picker-float {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 201;
position: fixed;
z-index: 1100;
width: 320px;
max-width: calc(100vw - 16px);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
@@ -5892,7 +5889,7 @@ body.has-player .chat-fab {
}
/* frimousse uses bare attributes (no data- prefix) */
.emoji-picker-float [frimousse-viewport] {
max-height: 220px;
max-height: var(--float-max-h, 220px);
/* frimousse already sets overflow-y: auto inline */
}
.emoji-picker-float [frimousse-category-header] {

View File

@@ -1,3 +1,4 @@
import type { CSSProperties } from "react";
import { Avatar } from "./Avatar.tsx";
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
@@ -5,14 +6,16 @@ interface MentionDropdownProps {
results: UserResult[];
selectedIndex: number;
onSelect: (username: string) => void;
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
style?: CSSProperties;
}
export function MentionDropdown(
{ results, selectedIndex, onSelect }: MentionDropdownProps,
{ results, selectedIndex, onSelect, style }: MentionDropdownProps,
) {
if (results.length === 0) return null;
return (
<ul className="mention-dropdown">
<ul className="mention-dropdown" style={style}>
{results.map((user, i) => (
<li
key={user.id}

View File

@@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
@@ -13,6 +14,7 @@ import { charCountClass } from "../utils/charCount.ts";
import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
import { useAnchoredFloat } from "../hooks/useAnchoredFloat.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { API_URL } from "../config/api.ts";
@@ -91,6 +93,23 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
}
}, [emojiOpen]);
// Both popups are portaled to <body> and anchored to the textarea: the
// editor sits inside cards, modal bodies and scrollers that clip anything
// absolutely positioned out of their box.
const emojiStyle = useAnchoredFloat(textareaRef, emojiOpen, {
width: 320,
chromeHeight: 40, // search row + borders
minContentHeight: 120,
maxContentHeight: 220,
});
const mentionStyle = useAnchoredFloat(textareaRef, mentionOpen, {
width: "anchor", // it used to be stretched edge to edge by left/right: 0
chromeHeight: 10, // list padding + borders
minContentHeight: 90,
maxContentHeight: 220,
});
useEffect(() => {
if (!autoResize) return;
const el = textareaRef.current;
@@ -219,16 +238,19 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
{value.length} / {maxLength}
</span>
)}
{mentionOpen && (
{mentionOpen && createPortal(
<MentionDropdown
results={mentionResults}
selectedIndex={mentionSelectedIndex}
onSelect={handleMentionSelect}
/>
style={mentionStyle ?? { visibility: "hidden" }}
/>,
document.body,
)}
{emojiOpen && (
{emojiOpen && createPortal(
<div
className="emoji-picker-float"
style={emojiStyle ?? { visibility: "hidden" }}
onKeyDownCapture={(e) => {
if (e.key === "Escape") {
e.preventDefault();
@@ -294,7 +316,8 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>
</div>
</div>,
document.body,
)}
</div>
);

View 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;
}