fix: render mention autocomplete via portal to prevent overflow clipping

The mention suggestion dropdown was getting clipped when typing at the
end of a long description inside modals/dialogs because parent containers
had overflow-y-auto. Render it via createPortal to document.body with
fixed positioning and z-index 9999 so it always appears above all UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 07:22:24 -05:00
parent c6364149b1
commit 0fd75aa579

View file

@ -8,6 +8,7 @@ import {
useState, useState,
type DragEvent, type DragEvent,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { import {
CodeMirrorEditor, CodeMirrorEditor,
MDXEditor, MDXEditor,
@ -82,6 +83,9 @@ interface MentionState {
query: string; query: string;
top: number; top: number;
left: number; left: number;
/** Viewport-relative coords for portal positioning */
viewportTop: number;
viewportLeft: number;
textNode: Text; textNode: Text;
atPos: number; atPos: number;
endPos: number; endPos: number;
@ -155,6 +159,8 @@ function detectMention(container: HTMLElement): MentionState | null {
query, query,
top: rect.bottom - containerRect.top, top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left, left: rect.left - containerRect.left,
viewportTop: rect.bottom,
viewportLeft: rect.left,
textNode: textNode as Text, textNode: textNode as Text,
atPos, atPos,
endPos: offset, endPos: offset,
@ -554,46 +560,48 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
plugins={plugins} plugins={plugins}
/> />
{/* Mention dropdown */} {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
{mentionActive && filteredMentions.length > 0 && ( {mentionActive && filteredMentions.length > 0 &&
<div createPortal(
className="absolute z-50 min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md" <div
style={{ top: mentionState.top + 4, left: mentionState.left }} className="fixed z-[9999] min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
> style={{ top: mentionState.viewportTop + 4, left: mentionState.viewportLeft }}
{filteredMentions.map((option, i) => ( >
<button {filteredMentions.map((option, i) => (
key={option.id} <button
className={cn( key={option.id}
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors", className={cn(
i === mentionIndex && "bg-accent", "flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
)} i === mentionIndex && "bg-accent",
onMouseDown={(e) => { )}
e.preventDefault(); // prevent blur onMouseDown={(e) => {
selectMention(option); e.preventDefault(); // prevent blur
}} selectMention(option);
onMouseEnter={() => setMentionIndex(i)} }}
> onMouseEnter={() => setMentionIndex(i)}
{option.kind === "project" && option.projectId ? ( >
<span {option.kind === "project" && option.projectId ? (
className="inline-flex h-2 w-2 rounded-full border border-border/50" <span
style={{ backgroundColor: option.projectColor ?? "#64748b" }} className="inline-flex h-2 w-2 rounded-full border border-border/50"
/> style={{ backgroundColor: option.projectColor ?? "#64748b" }}
) : ( />
<AgentIcon ) : (
icon={option.agentIcon} <AgentIcon
className="h-3.5 w-3.5 shrink-0 text-muted-foreground" icon={option.agentIcon}
/> className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
)} />
<span>{option.name}</span> )}
{option.kind === "project" && option.projectId && ( <span>{option.name}</span>
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground"> {option.kind === "project" && option.projectId && (
Project <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
</span> Project
)} </span>
</button> )}
))} </button>
</div> ))}
)} </div>,
document.body,
)}
{isDragOver && canDropImage && ( {isDragOver && canDropImage && (
<div <div