fix: render suggestion dropdown in Portal to escape BillModal overflow container
This commit is contained in:
parent
52af533845
commit
68667fea59
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// Debounce helper
|
||||
|
|
@ -89,7 +90,6 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
|
|||
const [conflicts, setConflicts] = useState([]);
|
||||
const [retroFeedback, setRetroFeedback] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const debouncedInput = useDebounce(input.trim(), 380);
|
||||
|
||||
|
|
@ -132,16 +132,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
|
|||
return () => { cancelled = true; };
|
||||
}, [debouncedInput, billId]);
|
||||
|
||||
// Close suggestion dropdown on outside click
|
||||
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);
|
||||
}, []);
|
||||
// Popover handles its own outside-click dismissal — no manual handler needed
|
||||
|
||||
async function handleAdd(merchantText) {
|
||||
const text = (merchantText || input).trim();
|
||||
|
|
@ -265,43 +256,52 @@ export default function BillMerchantRules({ billId, onRulesChanged }) {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Suggestions — rendered in a Portal so the list escapes the BillModal's
|
||||
overflow-y-auto container and is fully clickable regardless of scroll */}
|
||||
{showSuggestions && filteredSuggestions.length > 0 && inputRef.current && (
|
||||
<Portal.Root>
|
||||
<div
|
||||
style={{
|
||||
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">
|
||||
Recent unmatched transactions
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredSuggestions.map(s => {
|
||||
const amountVal = Math.abs(Number(s.amount || 0)) / 100;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
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"
|
||||
onMouseDown={e => { e.preventDefault(); pickSuggestion(s); }}
|
||||
>
|
||||
<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="shrink-0 font-mono text-muted-foreground tabular-nums">
|
||||
${amountVal.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Portal.Root>
|
||||
)}
|
||||
|
||||
{/* Conflict warning */}
|
||||
{conflicts.length > 0 && input.trim().length >= 2 && (
|
||||
<div className="mt-1.5">
|
||||
<ConflictWarning conflicts={conflicts} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute left-0 right-14 top-full z-50 mt-1 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">
|
||||
Recent unmatched transactions
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredSuggestions.map(s => {
|
||||
const amountVal = Math.abs(Number(s.amount || 0)) / 100;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
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"
|
||||
onClick={() => pickSuggestion(s)}
|
||||
>
|
||||
<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="shrink-0 font-mono text-muted-foreground tabular-nums">
|
||||
${amountVal.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue