fix: render suggestion dropdown in Portal to escape BillModal overflow container

This commit is contained in:
null 2026-06-04 01:45:17 -05:00
parent 52af533845
commit 68667fea59
1 changed files with 42 additions and 42 deletions

View File

@ -8,6 +8,7 @@ import {
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import * as Portal from '@radix-ui/react-portal';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
// Debounce helper // Debounce helper
@ -89,7 +90,6 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
const [conflicts, setConflicts] = useState([]); const [conflicts, setConflicts] = useState([]);
const [retroFeedback, setRetroFeedback] = useState(null); const [retroFeedback, setRetroFeedback] = useState(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const dropdownRef = useRef(null);
const debouncedInput = useDebounce(input.trim(), 380); const debouncedInput = useDebounce(input.trim(), 380);
@ -132,16 +132,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [debouncedInput, billId]); }, [debouncedInput, billId]);
// Close suggestion dropdown on outside click // Popover handles its own outside-click dismissal no manual handler needed
useEffect(() => {
function handler(e) {
if (!dropdownRef.current?.contains(e.target) && !inputRef.current?.contains(e.target)) {
setShowSuggestions(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
async function handleAdd(merchantText) { async function handleAdd(merchantText) {
const text = (merchantText || input).trim(); const text = (merchantText || input).trim();
@ -265,18 +256,19 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
</Button> </Button>
</div> </div>
{/* Conflict warning */} {/* Suggestions rendered in a Portal so the list escapes the BillModal's
{conflicts.length > 0 && input.trim().length >= 2 && ( overflow-y-auto container and is fully clickable regardless of scroll */}
<div className="mt-1.5"> {showSuggestions && filteredSuggestions.length > 0 && inputRef.current && (
<ConflictWarning conflicts={conflicts} /> <Portal.Root>
</div>
)}
{/* Suggestions dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div <div
ref={dropdownRef} style={{
className="absolute left-0 right-14 top-full z-50 mt-1 overflow-hidden rounded-lg border border-border/80 bg-card shadow-md" position: 'fixed',
top: inputRef.current.getBoundingClientRect().bottom + 4,
left: inputRef.current.getBoundingClientRect().left,
width: inputRef.current.getBoundingClientRect().width,
zIndex: 9999,
}}
className="overflow-hidden rounded-lg border border-border/80 bg-card shadow-md"
> >
<p className="border-b border-border/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="border-b border-border/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Recent unmatched transactions Recent unmatched transactions
@ -289,7 +281,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
key={s.id} key={s.id}
type="button" type="button"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-xs hover:bg-muted/50 focus:bg-muted/50 focus:outline-none" className="flex w-full items-center gap-3 px-3 py-2 text-left text-xs hover:bg-muted/50 focus:bg-muted/50 focus:outline-none"
onClick={() => pickSuggestion(s)} onMouseDown={e => { e.preventDefault(); pickSuggestion(s); }}
> >
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span> <span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
@ -301,6 +293,14 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
})} })}
</div> </div>
</div> </div>
</Portal.Root>
)}
{/* Conflict warning */}
{conflicts.length > 0 && input.trim().length >= 2 && (
<div className="mt-1.5">
<ConflictWarning conflicts={conflicts} />
</div>
)} )}
</div> </div>