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').
This commit is contained in:
2026-08-16 16:29:25 +00:00
parent 66adb65377
commit a87b3fd228
3 changed files with 213 additions and 89 deletions
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -54,9 +55,11 @@ export const ForgeLookupCombobox: React.FC<ForgeLookupComboboxProps> = ({
const { t } = useI18n(); const { t } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null); const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0); 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(() => { const hasLookup = useMemo(() => {
switch (kind) { switch (kind) {
@@ -77,17 +80,62 @@ export const ForgeLookupCombobox: React.FC<ForgeLookupComboboxProps> = ({
setHighlighted(0); setHighlighted(0);
}, [options]); }, [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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handlePointerDown = (event: MouseEvent | TouchEvent) => { const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null; 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); document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true); return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [open]); }, [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) => { const choose = useCallback((option: ForgeLookupOption) => {
setOpen(false); setOpen(false);
onSelect(option); onSelect(option);
@@ -142,49 +190,60 @@ export const ForgeLookupCombobox: React.FC<ForgeLookupComboboxProps> = ({
aria-activedescendant={open && options[highlighted] ? `forge-lookup-${kind}-${options[highlighted].key}` : undefined} 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)} 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 ? ( {open && hasLookup && panelPos
<div ? createPortal(
id="forge-lookup-list" <div
role="listbox" ref={panelRef}
className="absolute left-0 right-0 top-full z-50 mt-1 min-w-0 max-w-full overflow-hidden rounded-md border border-border/60 bg-[var(--surface-elevated)] shadow-lg" id="forge-lookup-list"
> role="listbox"
<ScrollableOverlay preventOverscroll outerClassName="max-h-44 min-h-0"> className="z-50 min-w-0 max-w-full overflow-hidden rounded-md border border-border/60 bg-[var(--surface-elevated)] shadow-lg"
{loading ? ( style={{
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground"> position: 'fixed',
<Icon name="loader-4" className="size-3 animate-spin" /> top: panelPos.top,
{t('forge.lookup.loading')} left: panelPos.left,
</div> width: panelPos.width,
) : options.length === 0 ? ( transform: panelPos.flip ? 'translateY(-100%)' : undefined,
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div> }}
) : ( >
options.map((option, index) => ( <ScrollableOverlay preventOverscroll outerClassName="max-h-44 min-h-0">
<div {loading || !initialized ? (
key={option.key} <div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
id={`forge-lookup-${kind}-${option.key}`} <Icon name="loader-4" className="size-3 animate-spin" />
role="option" {t('forge.lookup.loading')}
aria-selected={index === highlighted} </div>
className={cn( ) : options.length === 0 ? (
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground', <div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
index === highlighted && 'bg-interactive-selection', ) : (
)} options.map((option, index) => (
onClick={() => choose(option)} <div
onMouseMove={() => setHighlighted(index)} key={option.key}
> id={`forge-lookup-${kind}-${option.key}`}
{option.avatarUrl ? ( role="option"
<img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" /> aria-selected={index === highlighted}
) : option.color ? ( className={cn(
<span aria-hidden className="size-2 shrink-0 rounded-full" style={{ backgroundColor: normalizeColor(option.color) ?? 'var(--status-info)' }} /> 'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
) : null} index === highlighted && 'bg-interactive-selection',
<span className="min-w-0 truncate">{option.label}</span> )}
{option.secondary ? ( onClick={() => choose(option)}
<span className="truncate text-muted-foreground">{option.secondary}</span> onMouseMove={() => setHighlighted(index)}
) : null} >
</div> {option.avatarUrl ? (
)) <img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" />
)} ) : option.color ? (
</ScrollableOverlay> <span aria-hidden className="size-2 shrink-0 rounded-full" style={{ backgroundColor: normalizeColor(option.color) ?? 'var(--status-info)' }} />
</div> ) : null}
) : null} <span className="min-w-0 truncate">{option.label}</span>
{option.secondary ? (
<span className="truncate text-muted-foreground">{option.secondary}</span>
) : null}
</div>
))
)}
</ScrollableOverlay>
</div>,
document.body,
)
: null}
</div> </div>
); );
}; };
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -65,11 +66,13 @@ export const ForgeMentionTextarea: React.FC<ForgeMentionTextareaProps> = ({
const { t } = useI18n(); const { t } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null); const rootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [token, setToken] = useState<MentionToken | null>(null); const [token, setToken] = useState<MentionToken | null>(null);
const [highlighted, setHighlighted] = useState(0); 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 hasLookup = typeof provider.searchUsers === 'function' && provider.capabilities.userSearch;
const { options, loading } = useForgeLookup({ const { options, loading, initialized } = useForgeLookup({
provider, provider,
directory, directory,
sourceRepo, sourceRepo,
@@ -81,17 +84,63 @@ export const ForgeMentionTextarea: React.FC<ForgeMentionTextareaProps> = ({
setHighlighted(0); setHighlighted(0);
}, [options]); }, [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(() => { useEffect(() => {
if (!token) return; if (!token) return;
const handlePointerDown = (event: MouseEvent | TouchEvent) => { const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null; 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); document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true); return () => document.removeEventListener('pointerdown', handlePointerDown, true);
}, [token]); }, [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) => { const insertMention = useCallback((option: ForgeLookupOption) => {
if (!token) return; if (!token) return;
const next = `${value.slice(0, token.start)}@${option.label} ${value.slice(textareaRef.current?.selectionStart ?? token.start + token.query.length)}`; 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<ForgeMentionTextareaProps> = ({
aria-controls={openToken ? 'forge-mention-list' : undefined} aria-controls={openToken ? 'forge-mention-list' : undefined}
aria-activedescendant={openToken && filtered[highlighted] ? `forge-mention-${filtered[highlighted].key}` : undefined} aria-activedescendant={openToken && filtered[highlighted] ? `forge-mention-${filtered[highlighted].key}` : undefined}
/> />
{openToken ? ( {openToken && panelPos
<div ? createPortal(
id="forge-mention-list" <div
role="listbox" ref={panelRef}
className="absolute left-0 right-0 bottom-full z-50 mb-1 max-h-44 min-w-0 overflow-hidden rounded-md border border-border/60 bg-[var(--surface-elevated)] shadow-lg" id="forge-mention-list"
> role="listbox"
{loading ? ( className="z-50 min-w-0 max-w-full max-h-44 overflow-y-auto rounded-md border border-border/60 bg-[var(--surface-elevated)] shadow-lg"
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground"> style={{
<Icon name="loader-4" className="size-3 animate-spin" /> position: 'fixed',
{t('forge.lookup.loading')} top: panelPos.top,
</div> left: panelPos.left,
) : filtered.length === 0 ? ( width: panelPos.width,
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div> transform: panelPos.flip ? undefined : 'translateY(-100%)',
) : ( }}
filtered.map((option, index) => ( >
<div {loading || !initialized ? (
key={option.key} <div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
id={`forge-mention-${option.key}`} <Icon name="loader-4" className="size-3 animate-spin" />
role="option" {t('forge.lookup.loading')}
aria-selected={index === highlighted} </div>
className={cn( ) : filtered.length === 0 ? (
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground', <div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
index === highlighted && 'bg-interactive-selection', ) : (
)} filtered.map((option, index) => (
onClick={() => insertMention(option)} <div
onMouseMove={() => setHighlighted(index)} key={option.key}
> id={`forge-mention-${option.key}`}
{option.avatarUrl ? ( role="option"
<img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" /> aria-selected={index === highlighted}
) : null} className={cn(
<span className="min-w-0 truncate">{option.label}</span> 'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
{option.secondary ? <span className="truncate text-muted-foreground">{option.secondary}</span> : null} index === highlighted && 'bg-interactive-selection',
</div> )}
)) onClick={() => insertMention(option)}
)} onMouseMove={() => setHighlighted(index)}
</div> >
) : null} {option.avatarUrl ? (
<img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" />
) : null}
<span className="min-w-0 truncate">{option.label}</span>
{option.secondary ? <span className="truncate text-muted-foreground">{option.secondary}</span> : null}
</div>
))
)}
</div>,
document.body,
)
: null}
</div> </div>
); );
}; };
@@ -121,9 +121,10 @@ export const useForgeLookup = ({
sourceRepo?: string | null; sourceRepo?: string | null;
kind: ForgeLookupKind; kind: ForgeLookupKind;
query: string; query: string;
}): { options: ForgeLookupOption[]; loading: boolean } => { }): { options: ForgeLookupOption[]; loading: boolean; initialized: boolean } => {
const [options, setOptions] = useState<ForgeLookupOption[]>([]); const [options, setOptions] = useState<ForgeLookupOption[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false);
useEffect(() => { useEffect(() => {
const key = cacheKey(kind, directory, sourceRepo, query); 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. // Fresh enough: serve without the network call or the debounce timer.
setOptions(cached.options); setOptions(cached.options);
setLoading(false); setLoading(false);
setInitialized(true);
return; return;
} }
if (cached) lookupCache.delete(key); if (cached) lookupCache.delete(key);
@@ -191,7 +193,10 @@ export const useForgeLookup = ({
} catch { } catch {
if (!cancelled) setOptions([]); if (!cancelled) setOptions([]);
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) {
setLoading(false);
setInitialized(true);
}
} }
})(); })();
}, 250); }, 250);
@@ -202,5 +207,5 @@ export const useForgeLookup = ({
}; };
}, [directory, kind, provider, query, sourceRepo]); }, [directory, kind, provider, query, sourceRepo]);
return { options, loading }; return { options, loading, initialized };
}; };