fix(ui): portal all custom dropdowns to document.body

Custom dropdowns using absolute positioning were clipped by parent
overflow containers (dialogs, scrollable sections). Fixed by rendering
via createPortal with fixed positioning:

- Combobox: default to body portal when no portalContainer prop
- ToolNameSelect: add createPortal + fixed positioning
- AgentSelector: replace overlay+absolute with portal
- ContactInsertSearch: add createPortal + fixed positioning
- ContactSearchBox (instances tab): add createPortal + fixed positioning
This commit is contained in:
viettranx committed 2026-03-11 16:54:33 +07:00
1 parent 771a6538c0
commit 61e6ff2ab2
5 files changed
+189 -76

No files matched your search

+70 -34
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Bot, ChevronDown } from "lucide-react";
import { useHttp } from "@/hooks/use-ws";
@@ -16,6 +17,9 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
const connected = useAuthStore((s) => s.connected);
const [agents, setAgents] = useState<AgentData[]>([]);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
useEffect(() => {
if (!connected) return;
@@ -28,10 +32,39 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
.catch(() => {});
}, [http, connected]);
// Compute dropdown position for portal rendering
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}, [open]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const selected = agents.find((a) => a.agent_key === value);
return (
<div className="relative">
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
@@ -44,38 +77,41 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-lg border bg-popover p-1 shadow-md">
{agents.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
{t("noAgentsAvailable")}
</div>
)}
{agents.map((agent) => (
<button
key={agent.agent_key}
type="button"
onClick={() => {
onChange(agent.agent_key);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent ${
agent.agent_key === value ? "bg-accent" : ""
}`}
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-left">
{agent.display_name || agent.agent_key}
</span>
{agent.is_default && (
<span className="text-xs text-muted-foreground">{t("default")}</span>
)}
</button>
))}
</div>
</>
{open && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="max-h-60 overflow-y-auto rounded-lg border bg-popover p-1 shadow-md"
>
{agents.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
{t("noAgentsAvailable")}
</div>
)}
{agents.map((agent) => (
<button
key={agent.agent_key}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(agent.agent_key);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent ${
agent.agent_key === value ? "bg-accent" : ""
}`}
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-left">
{agent.display_name || agent.agent_key}
</span>
{agent.is_default && (
<span className="text-xs text-muted-foreground">{t("default")}</span>
)}
</button>
))}
</div>,
document.body,
)}
</div>
);
@@ -1,4 +1,5 @@
import { useMemo, useState, useRef, useEffect } from "react";
import { useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -31,6 +32,8 @@ export function ToolNameSelect({
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const allTools = useMemo<ToolOption[]>(() => {
const builtin: ToolOption[] = builtinTools.map((t) => ({
@@ -59,11 +62,28 @@ export function ToolNameSelect({
return { builtin: builtinGroup, custom: customGroup };
}, [filtered]);
// Compute dropdown position for portal rendering
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}, [open, search]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setOpen(false);
}
};
@@ -137,8 +157,12 @@ export function ToolNameSelect({
onClick={() => setOpen(!open)}
/>
</div>
{open && (grouped.builtin.length > 0 || grouped.custom.length > 0) && (
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border p-1 shadow-md">
{open && (grouped.builtin.length > 0 || grouped.custom.length > 0) && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="bg-popover text-popover-foreground max-h-60 overflow-y-auto rounded-md border p-1 shadow-md"
>
{grouped.builtin.length > 0 && (
<>
<div className="text-muted-foreground px-2 py-1 text-[10px] font-semibold uppercase tracking-wider">
@@ -176,7 +200,8 @@ export function ToolNameSelect({
))}
</>
)}
</div>
</div>,
document.body,
)}
</div>
);
+29 -21
View File
@@ -54,21 +54,31 @@ export function Combobox({
return () => document.removeEventListener("mousedown", handler);
}, [open]);
// Compute dropdown position for portal mode
// Compute dropdown position — always use fixed positioning for portal rendering
React.useLayoutEffect(() => {
if (!open || !portalContainer?.current || !containerRef.current) return;
if (!open || !containerRef.current) return;
const inputRect = containerRef.current.getBoundingClientRect();
const portalRect = portalContainer.current.getBoundingClientRect();
const left = inputRect.left - portalRect.left;
const maxWidth = portalRect.width - left;
setDropdownStyle({
position: "absolute",
top: inputRect.bottom - portalRect.top + 4,
left,
width: inputRect.width,
maxWidth,
zIndex: 50,
});
if (portalContainer?.current) {
const portalRect = portalContainer.current.getBoundingClientRect();
const left = inputRect.left - portalRect.left;
const maxWidth = portalRect.width - left;
setDropdownStyle({
position: "absolute",
top: inputRect.bottom - portalRect.top + 4,
left,
width: inputRect.width,
maxWidth,
zIndex: 50,
});
} else {
setDropdownStyle({
position: "fixed",
top: inputRect.bottom + 4,
left: inputRect.left,
width: inputRect.width,
zIndex: 9999,
});
}
}, [open, search, portalContainer]);
const filtered = React.useMemo(() => {
@@ -98,11 +108,8 @@ export function Combobox({
const dropdownContent = open && filtered.length > 0 && (
<div
ref={dropdownRef}
style={portalContainer ? dropdownStyle : undefined}
className={cn(
"bg-popover text-popover-foreground max-h-60 overflow-y-auto rounded-md border p-1 shadow-md",
portalContainer ? "overflow-hidden" : "absolute top-full left-0 z-50 mt-1 min-w-full",
)}
style={dropdownStyle}
className="bg-popover text-popover-foreground max-h-60 overflow-y-auto rounded-md border p-1 shadow-md"
>
{filtered.map((o) => (
<button
@@ -139,9 +146,10 @@ export function Combobox({
onClick={() => setOpen(!open)}
/>
)}
{portalContainer?.current
? dropdownContent && createPortal(dropdownContent, portalContainer.current)
: dropdownContent}
{dropdownContent && createPortal(
dropdownContent,
portalContainer?.current ?? document.body,
)}
</div>
);
}
@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { Save, Check, AlertCircle, Users, FileText, Search, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -157,15 +158,34 @@ function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set<string>;
const [open, setOpen] = useState(false);
const { contacts } = useContactSearch(search);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
// Filter out contacts already in instances
const filtered = contacts.filter((c) => !existingIDs.has(c.sender_id));
// Compute dropdown position for portal rendering
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}, [open, search]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setOpen(false);
}
};
@@ -185,8 +205,8 @@ function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set<string>;
className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{open && search.length >= 2 && filtered.length > 0 && (
<div className="absolute left-1 right-1 z-50 mt-1 max-h-48 overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
{open && search.length >= 2 && filtered.length > 0 && createPortal(
<div ref={dropdownRef} style={dropdownStyle} className="max-h-48 overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
{filtered.map((c) => (
<button
key={c.id}
@@ -211,12 +231,14 @@ function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set<string>;
</div>
</button>
))}
</div>
</div>,
document.body,
)}
{open && search.length >= 2 && filtered.length === 0 && contacts.length === 0 && (
<div className="absolute left-1 right-1 z-50 mt-1 rounded-md border bg-popover p-3 text-center text-xs text-muted-foreground shadow-md">
{open && search.length >= 2 && filtered.length === 0 && contacts.length === 0 && createPortal(
<div ref={dropdownRef} style={dropdownStyle} className="rounded-md border bg-popover p-3 text-center text-xs text-muted-foreground shadow-md">
{t("instances.noContactsFound")}
</div>
</div>,
document.body,
)}
</div>
);
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { Search, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
@@ -24,12 +25,31 @@ export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) {
const [open, setOpen] = useState(false);
const { contacts } = useContactSearch(search);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
// Compute dropdown position for portal rendering
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: Math.min(rect.width, 384), // max-w-sm = 24rem = 384px
zIndex: 9999,
});
}, [open, search]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as Node;
if (
containerRef.current && !containerRef.current.contains(target) &&
(!dropdownRef.current || !dropdownRef.current.contains(target))
) {
setOpen(false);
}
};
@@ -55,8 +75,8 @@ export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) {
className="h-8 w-full max-w-sm rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{open && search.length >= 2 && contacts.length > 0 && (
<div className="absolute left-0 z-50 mt-1 max-h-48 w-full max-w-sm overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
{open && search.length >= 2 && contacts.length > 0 && createPortal(
<div ref={dropdownRef} style={dropdownStyle} className="max-h-48 overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
{contacts.map((c) => (
<button
key={c.id}
@@ -77,12 +97,14 @@ export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) {
</div>
</button>
))}
</div>
</div>,
document.body,
)}
{open && search.length >= 2 && contacts.length === 0 && (
<div className="absolute left-0 z-50 mt-1 w-full max-w-sm rounded-md border bg-popover p-3 text-center text-xs text-muted-foreground shadow-md">
{open && search.length >= 2 && contacts.length === 0 && createPortal(
<div ref={dropdownRef} style={dropdownStyle} className="rounded-md border bg-popover p-3 text-center text-xs text-muted-foreground shadow-md">
{t("instances.noContactsFound")}
</div>
</div>,
document.body,
)}
</div>
);