mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 14:13:34 +00:00
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:
1 parent
771a6538c0
commit
61e6ff2ab2
5 files changed
+189
-76
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user