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:
43
src/App.css
43
src/App.css
@@ -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] {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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