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

View File

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

View File

@@ -6,6 +6,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { EmojiPicker, type Locale as EmojiLocale } from "frimousse"; import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react"; import { useLingui } from "@lingui/react";
@@ -13,6 +14,7 @@ import { charCountClass } from "../utils/charCount.ts";
import { MentionDropdown } from "./MentionDropdown.tsx"; import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts"; import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts"; import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
import { useAnchoredFloat } from "../hooks/useAnchoredFloat.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
@@ -91,6 +93,23 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
} }
}, [emojiOpen]); }, [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(() => { useEffect(() => {
if (!autoResize) return; if (!autoResize) return;
const el = textareaRef.current; const el = textareaRef.current;
@@ -219,16 +238,19 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
{value.length} / {maxLength} {value.length} / {maxLength}
</span> </span>
)} )}
{mentionOpen && ( {mentionOpen && createPortal(
<MentionDropdown <MentionDropdown
results={mentionResults} results={mentionResults}
selectedIndex={mentionSelectedIndex} selectedIndex={mentionSelectedIndex}
onSelect={handleMentionSelect} onSelect={handleMentionSelect}
/> style={mentionStyle ?? { visibility: "hidden" }}
/>,
document.body,
)} )}
{emojiOpen && ( {emojiOpen && createPortal(
<div <div
className="emoji-picker-float" className="emoji-picker-float"
style={emojiStyle ?? { visibility: "hidden" }}
onKeyDownCapture={(e) => { onKeyDownCapture={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
@@ -294,7 +316,8 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
<EmojiPicker.List /> <EmojiPicker.List />
</EmojiPicker.Viewport> </EmojiPicker.Viewport>
</EmojiPicker.Root> </EmojiPicker.Root>
</div> </div>,
document.body,
)} )}
</div> </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;
}