From 1adf7b04d769a72e47d37c1602f2a63779d7c58f Mon Sep 17 00:00:00 2001 From: khannurien Date: Wed, 16 Sep 2026 18:28:48 +0000 Subject: [PATCH] v3: stop cards and modal bodies from clipping the editor's popups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- src/App.css | 43 ++++++------ src/components/MentionDropdown.tsx | 7 +- src/components/TextEditor.tsx | 31 ++++++-- src/hooks/useAnchoredFloat.ts | 109 +++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 src/hooks/useAnchoredFloat.ts 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 ( -