diff --git a/src/App.css b/src/App.css
index 56e145f3..9b502bcf 100644
--- a/src/App.css
+++ b/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
.) */
.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 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 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] {
diff --git a/src/components/MentionDropdown.tsx b/src/components/MentionDropdown.tsx
index 5515c7fb..1fc54d42 100644
--- a/src/components/MentionDropdown.tsx
+++ b/src/components/MentionDropdown.tsx
@@ -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 (
-
+
{results.map((user, i) => (
- (
}
}, [emojiOpen]);
+ // Both popups are portaled to 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(
{value.length} / {maxLength}
)}
- {mentionOpen && (
+ {mentionOpen && createPortal(
+ style={mentionStyle ?? { visibility: "hidden" }}
+ />,
+ document.body,
)}
- {emojiOpen && (
+ {emojiOpen && createPortal(
{
if (e.key === "Escape") {
e.preventDefault();
@@ -294,7 +316,8 @@ export const TextEditor = forwardRef(
-
+ ,
+ document.body,
)}
);
diff --git a/src/hooks/useAnchoredFloat.ts b/src/hooks/useAnchoredFloat.ts
new file mode 100644
index 00000000..59733472
--- /dev/null
+++ b/src/hooks/useAnchoredFloat.ts
@@ -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,
+ open: boolean,
+ { width, chromeHeight, minContentHeight, maxContentHeight }:
+ AnchoredFloatOptions,
+): CSSProperties | null {
+ const [style, setStyle] = useState(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 = {
+ 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;
+}