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:
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user