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 { 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<ForgeLookupComboboxProps> = ({
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(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<ForgeLookupComboboxProps> = ({
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<ForgeLookupComboboxProps> = ({
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 ? (
<div
id="forge-lookup-list"
role="listbox"
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"
>
<ScrollableOverlay preventOverscroll outerClassName="max-h-44 min-h-0">
{loading ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('forge.lookup.loading')}
</div>
) : options.length === 0 ? (
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
) : (
options.map((option, index) => (
<div
key={option.key}
id={`forge-lookup-${kind}-${option.key}`}
role="option"
aria-selected={index === highlighted}
className={cn(
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
index === highlighted && 'bg-interactive-selection',
)}
onClick={() => choose(option)}
onMouseMove={() => setHighlighted(index)}
>
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" />
) : option.color ? (
<span aria-hidden className="size-2 shrink-0 rounded-full" style={{ backgroundColor: normalizeColor(option.color) ?? 'var(--status-info)' }} />
) : 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>
) : null}
{open && hasLookup && panelPos
? createPortal(
<div
ref={panelRef}
id="forge-lookup-list"
role="listbox"
className="z-50 min-w-0 max-w-full overflow-hidden rounded-md border border-border/60 bg-[var(--surface-elevated)] shadow-lg"
style={{
position: 'fixed',
top: panelPos.top,
left: panelPos.left,
width: panelPos.width,
transform: panelPos.flip ? 'translateY(-100%)' : undefined,
}}
>
<ScrollableOverlay preventOverscroll outerClassName="max-h-44 min-h-0">
{loading || !initialized ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('forge.lookup.loading')}
</div>
) : options.length === 0 ? (
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
) : (
options.map((option, index) => (
<div
key={option.key}
id={`forge-lookup-${kind}-${option.key}`}
role="option"
aria-selected={index === highlighted}
className={cn(
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
index === highlighted && 'bg-interactive-selection',
)}
onClick={() => choose(option)}
onMouseMove={() => setHighlighted(index)}
>
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" className="size-3.5 shrink-0 rounded-full object-cover" />
) : option.color ? (
<span aria-hidden className="size-2 shrink-0 rounded-full" style={{ backgroundColor: normalizeColor(option.color) ?? 'var(--status-info)' }} />
) : 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>
);
};
@@ -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<ForgeMentionTextareaProps> = ({
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [token, setToken] = useState<MentionToken | null>(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<ForgeMentionTextareaProps> = ({
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<ForgeMentionTextareaProps> = ({
aria-controls={openToken ? 'forge-mention-list' : undefined}
aria-activedescendant={openToken && filtered[highlighted] ? `forge-mention-${filtered[highlighted].key}` : undefined}
/>
{openToken ? (
<div
id="forge-mention-list"
role="listbox"
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"
>
{loading ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('forge.lookup.loading')}
</div>
) : filtered.length === 0 ? (
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
) : (
filtered.map((option, index) => (
<div
key={option.key}
id={`forge-mention-${option.key}`}
role="option"
aria-selected={index === highlighted}
className={cn(
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
index === highlighted && 'bg-interactive-selection',
)}
onClick={() => insertMention(option)}
onMouseMove={() => setHighlighted(index)}
>
{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>
) : null}
{openToken && panelPos
? createPortal(
<div
ref={panelRef}
id="forge-mention-list"
role="listbox"
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"
style={{
position: 'fixed',
top: panelPos.top,
left: panelPos.left,
width: panelPos.width,
transform: panelPos.flip ? undefined : 'translateY(-100%)',
}}
>
{loading || !initialized ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('forge.lookup.loading')}
</div>
) : filtered.length === 0 ? (
<div className="px-2 py-1.5 typography-micro text-muted-foreground">{t('forge.lookup.empty')}</div>
) : (
filtered.map((option, index) => (
<div
key={option.key}
id={`forge-mention-${option.key}`}
role="option"
aria-selected={index === highlighted}
className={cn(
'flex cursor-pointer items-center gap-1.5 px-2 py-1 typography-micro text-foreground',
index === highlighted && 'bg-interactive-selection',
)}
onClick={() => insertMention(option)}
onMouseMove={() => setHighlighted(index)}
>
{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>
);
};
@@ -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<ForgeLookupOption[]>([]);
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 };
};