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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user