From a87b3fd228c793ce1892f4fb0dffd21ccc8d381c Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Sat, 15 Aug 2026 10:10:01 +0000 Subject: [PATCH] fix(ui): render forge lookup dropdowns in a portal The assignee/label/milestone combobox and @-mention list were inline absolute-positioned inside the ContextPanel rail (overflow-hidden) and the PR-view scroll container, so the panels were clipped and invisible. Render both via createPortal to document.body with fixed positioning computed from the trigger's viewport rect (viewport-edge clamping, flip to the other side when there is no room), close on outside pointerdown/scroll/resize while still allowing interaction inside the panel, and keep the panel inside the outside-click check. Also show the loading spinner until the first lookup completes instead of a brief 'No matches' flash (useForgeLookup now reports 'initialized'). --- .../forge/actions/ForgeLookupCombobox.tsx | 151 ++++++++++++------ .../forge/actions/ForgeMentionTextarea.tsx | 140 +++++++++++----- .../views/forge/actions/useForgeLookup.ts | 11 +- 3 files changed, 213 insertions(+), 89 deletions(-) diff --git a/packages/ui/src/components/views/forge/actions/ForgeLookupCombobox.tsx b/packages/ui/src/components/views/forge/actions/ForgeLookupCombobox.tsx index 7efc203e..66fbfac7 100644 --- a/packages/ui/src/components/views/forge/actions/ForgeLookupCombobox.tsx +++ b/packages/ui/src/components/views/forge/actions/ForgeLookupCombobox.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@/lib/utils'; import { Icon } from '@/components/icon/Icon'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -54,9 +55,11 @@ export const ForgeLookupCombobox: React.FC = ({ const { t } = useI18n(); const rootRef = useRef(null); const inputRef = useRef(null); + const panelRef = useRef(null); const [open, setOpen] = useState(false); const [highlighted, setHighlighted] = useState(0); - const { options, loading } = useForgeLookup({ provider, directory, sourceRepo, kind, query: value }); + const [panelPos, setPanelPos] = useState<{ top: number; left: number; width: number; flip: boolean } | null>(null); + const { options, loading, initialized } = useForgeLookup({ provider, directory, sourceRepo, kind, query: value }); const hasLookup = useMemo(() => { switch (kind) { @@ -77,17 +80,62 @@ export const ForgeLookupCombobox: React.FC = ({ setHighlighted(0); }, [options]); - // Close on outside click. + // Close on outside click. The dropdown renders in a portal (so it escapes + // the clipped, scrollable forge surfaces), so both the trigger and the + // portal panel count as "inside". useEffect(() => { if (!open) return; const handlePointerDown = (event: MouseEvent | TouchEvent) => { const target = event.target as Node | null; - if (target && rootRef.current && !rootRef.current.contains(target)) setOpen(false); + if (target && rootRef.current && panelRef.current) { + if (rootRef.current.contains(target) || panelRef.current.contains(target)) return; + } + setOpen(false); }; document.addEventListener('pointerdown', handlePointerDown, true); return () => document.removeEventListener('pointerdown', handlePointerDown, true); }, [open]); + // Position the portal panel from the input's viewport rect. `flip` renders + // the panel above the field when there is no room below it. + useEffect(() => { + if (!open || !hasLookup) { + setPanelPos(null); + return; + } + const input = inputRef.current; + if (!input) return; + const rect = input.getBoundingClientRect(); + const gap = 4; + const maxHeight = 176; // matches max-h-44 + const edge = 8; + const width = Math.min(rect.width, window.innerWidth - edge * 2); + const left = Math.max(edge, Math.min(rect.left, window.innerWidth - width - edge)); + const flip = rect.bottom + gap + maxHeight > window.innerHeight - edge && rect.top - gap - maxHeight > edge; + setPanelPos({ top: flip ? rect.top - gap : rect.bottom + gap, left, width, flip }); + }, [hasLookup, open]); + + // Scrolling the page/surfaces under a portal dropdown would leave it + // detached from its field; close unless the interaction is inside the + // panel (its own scrollable list) or the trigger. + useEffect(() => { + if (!open) return; + const closeOnScroll = (event: Event) => { + const target = event.target as Node | null; + if (target && rootRef.current && panelRef.current) { + if (rootRef.current.contains(target) || panelRef.current.contains(target)) return; + } + setOpen(false); + }; + const closeOnResize = () => setOpen(false); + document.addEventListener('scroll', closeOnScroll, true); + window.addEventListener('resize', closeOnResize); + return () => { + document.removeEventListener('scroll', closeOnScroll, true); + window.removeEventListener('resize', closeOnResize); + }; + }, [open]); + const choose = useCallback((option: ForgeLookupOption) => { setOpen(false); onSelect(option); @@ -142,49 +190,60 @@ export const ForgeLookupCombobox: React.FC = ({ aria-activedescendant={open && options[highlighted] ? `forge-lookup-${kind}-${options[highlighted].key}` : undefined} className={cn('h-6 w-36 appearance-none rounded-md bg-[var(--surface-elevated)] px-2 typography-micro text-foreground placeholder:text-muted-foreground', 'ring-1 ring-inset ring-border/60 focus:ring-2 focus:ring-[var(--interactive-focus-ring)] focus-visible:outline-none', className)} /> - {open && hasLookup ? ( -
- - {loading ? ( -
- - {t('forge.lookup.loading')} -
- ) : options.length === 0 ? ( -
{t('forge.lookup.empty')}
- ) : ( - options.map((option, index) => ( -
choose(option)} - onMouseMove={() => setHighlighted(index)} - > - {option.avatarUrl ? ( - - ) : option.color ? ( - - ) : null} - {option.label} - {option.secondary ? ( - {option.secondary} - ) : null} -
- )) - )} -
-
- ) : null} + {open && hasLookup && panelPos + ? createPortal( +
+ + {loading || !initialized ? ( +
+ + {t('forge.lookup.loading')} +
+ ) : options.length === 0 ? ( +
{t('forge.lookup.empty')}
+ ) : ( + options.map((option, index) => ( +
choose(option)} + onMouseMove={() => setHighlighted(index)} + > + {option.avatarUrl ? ( + + ) : option.color ? ( + + ) : null} + {option.label} + {option.secondary ? ( + {option.secondary} + ) : null} +
+ )) + )} +
+
, + document.body, + ) + : null} ); }; \ No newline at end of file diff --git a/packages/ui/src/components/views/forge/actions/ForgeMentionTextarea.tsx b/packages/ui/src/components/views/forge/actions/ForgeMentionTextarea.tsx index cd220866..af4e2374 100644 --- a/packages/ui/src/components/views/forge/actions/ForgeMentionTextarea.tsx +++ b/packages/ui/src/components/views/forge/actions/ForgeMentionTextarea.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@/lib/utils'; import { Icon } from '@/components/icon/Icon'; import { Textarea } from '@/components/ui/textarea'; @@ -65,11 +66,13 @@ export const ForgeMentionTextarea: React.FC = ({ const { t } = useI18n(); const rootRef = useRef(null); const textareaRef = useRef(null); + const panelRef = useRef(null); const [token, setToken] = useState(null); const [highlighted, setHighlighted] = useState(0); + const [panelPos, setPanelPos] = useState<{ top: number; left: number; width: number; flip: boolean } | null>(null); const hasLookup = typeof provider.searchUsers === 'function' && provider.capabilities.userSearch; - const { options, loading } = useForgeLookup({ + const { options, loading, initialized } = useForgeLookup({ provider, directory, sourceRepo, @@ -81,17 +84,63 @@ export const ForgeMentionTextarea: React.FC = ({ setHighlighted(0); }, [options]); - // Close on outside click. + // Close on outside click. The mention list renders in a portal (so it + // escapes the clipped, scrollable forge surfaces), so both the trigger and + // the portal panel count as "inside". useEffect(() => { if (!token) return; const handlePointerDown = (event: MouseEvent | TouchEvent) => { const target = event.target as Node | null; - if (target && rootRef.current && !rootRef.current.contains(target)) setToken(null); + if (target && rootRef.current && panelRef.current) { + if (rootRef.current.contains(target) || panelRef.current.contains(target)) return; + } + setToken(null); }; document.addEventListener('pointerdown', handlePointerDown, true); return () => document.removeEventListener('pointerdown', handlePointerDown, true); }, [token]); + // Position the portal panel from the textarea's viewport rect. It opens + // above the field and flips below when there is no room above it. + const mentionOpen = token !== null && hasLookup; + useEffect(() => { + if (!mentionOpen) { + setPanelPos(null); + return; + } + const textarea = textareaRef.current; + if (!textarea) return; + const rect = textarea.getBoundingClientRect(); + const gap = 4; + const maxHeight = 176; // matches max-h-44 + const edge = 8; + const width = Math.min(rect.width, window.innerWidth - edge * 2); + const left = Math.max(edge, Math.min(rect.left, window.innerWidth - width - edge)); + const flip = rect.top - gap < edge && rect.bottom + gap + maxHeight <= window.innerHeight - edge; + setPanelPos({ top: flip ? rect.bottom + gap : rect.top - gap, left, width, flip }); + }, [mentionOpen]); + + // Scrolling the page/surfaces under a portal dropdown would leave it + // detached from its field; close unless the interaction is inside the + // panel (its own scrollable list) or the trigger. + useEffect(() => { + if (!mentionOpen) return; + const closeOnScroll = (event: Event) => { + const target = event.target as Node | null; + if (target && rootRef.current && panelRef.current) { + if (rootRef.current.contains(target) || panelRef.current.contains(target)) return; + } + setToken(null); + }; + const closeOnResize = () => setToken(null); + document.addEventListener('scroll', closeOnScroll, true); + window.addEventListener('resize', closeOnResize); + return () => { + document.removeEventListener('scroll', closeOnScroll, true); + window.removeEventListener('resize', closeOnResize); + }; + }, [mentionOpen]); + const insertMention = useCallback((option: ForgeLookupOption) => { if (!token) return; const next = `${value.slice(0, token.start)}@${option.label} ${value.slice(textareaRef.current?.selectionStart ?? token.start + token.query.length)}`; @@ -164,43 +213,54 @@ export const ForgeMentionTextarea: React.FC = ({ aria-controls={openToken ? 'forge-mention-list' : undefined} aria-activedescendant={openToken && filtered[highlighted] ? `forge-mention-${filtered[highlighted].key}` : undefined} /> - {openToken ? ( -
- {loading ? ( -
- - {t('forge.lookup.loading')} -
- ) : filtered.length === 0 ? ( -
{t('forge.lookup.empty')}
- ) : ( - filtered.map((option, index) => ( -
insertMention(option)} - onMouseMove={() => setHighlighted(index)} - > - {option.avatarUrl ? ( - - ) : null} - {option.label} - {option.secondary ? {option.secondary} : null} -
- )) - )} -
- ) : null} + {openToken && panelPos + ? createPortal( +
+ {loading || !initialized ? ( +
+ + {t('forge.lookup.loading')} +
+ ) : filtered.length === 0 ? ( +
{t('forge.lookup.empty')}
+ ) : ( + filtered.map((option, index) => ( +
insertMention(option)} + onMouseMove={() => setHighlighted(index)} + > + {option.avatarUrl ? ( + + ) : null} + {option.label} + {option.secondary ? {option.secondary} : null} +
+ )) + )} +
, + document.body, + ) + : null} ); }; \ No newline at end of file diff --git a/packages/ui/src/components/views/forge/actions/useForgeLookup.ts b/packages/ui/src/components/views/forge/actions/useForgeLookup.ts index abb15ff0..f7af3c27 100644 --- a/packages/ui/src/components/views/forge/actions/useForgeLookup.ts +++ b/packages/ui/src/components/views/forge/actions/useForgeLookup.ts @@ -121,9 +121,10 @@ export const useForgeLookup = ({ sourceRepo?: string | null; kind: ForgeLookupKind; query: string; -}): { options: ForgeLookupOption[]; loading: boolean } => { +}): { options: ForgeLookupOption[]; loading: boolean; initialized: boolean } => { const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); + const [initialized, setInitialized] = useState(false); useEffect(() => { const key = cacheKey(kind, directory, sourceRepo, query); @@ -133,6 +134,7 @@ export const useForgeLookup = ({ // Fresh enough: serve without the network call or the debounce timer. setOptions(cached.options); setLoading(false); + setInitialized(true); return; } if (cached) lookupCache.delete(key); @@ -191,7 +193,10 @@ export const useForgeLookup = ({ } catch { if (!cancelled) setOptions([]); } finally { - if (!cancelled) setLoading(false); + if (!cancelled) { + setLoading(false); + setInitialized(true); + } } })(); }, 250); @@ -202,5 +207,5 @@ export const useForgeLookup = ({ }; }, [directory, kind, provider, query, sourceRepo]); - return { options, loading }; + return { options, loading, initialized }; };