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;
|
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] {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
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