Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 2 additions & 13 deletions packages/nextjs/components/Transfer/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { ResolvedToken } from "@polypay/shared";
import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens";
import { useClickOutside } from "~~/hooks/useClickOutside";

interface TokenSelectorProps {
selectedToken: ResolvedToken;
Expand Down Expand Up @@ -31,19 +32,7 @@ export function TokenSelector({ selectedToken, onSelect, disabled = false }: Tok
}
}, [isOpen]);

// Close popover when clicking outside
useEffect(() => {
if (!isOpen) return;

const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
useClickOutside(containerRef, () => setIsOpen(false), { isActive: isOpen });

const handleTokenSelect = (token: ResolvedToken) => {
onSelect(token);
Expand Down
18 changes: 2 additions & 16 deletions packages/nextjs/components/contact-book/ContactPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ContactBookUserIcon } from "../icons/ContactBookUserIcon";
import { Contact } from "@polypay/shared";
import { Search, X } from "lucide-react";
import { useContacts, useGroups } from "~~/hooks";
import { useClickOutside } from "~~/hooks/useClickOutside";

interface ContactPickerProps {
accountId: string | null;
Expand Down Expand Up @@ -35,22 +36,7 @@ export function ContactPicker({ accountId, onSelect, disabled }: ContactPickerPr
}
}, [isOpen]);

// Close picker when clicking outside
useEffect(() => {
const handleClickOutside: (event: MouseEvent) => void = (event: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}

return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
useClickOutside(pickerRef, () => setIsOpen(false), { isActive: isOpen });

// Filter contacts by search term
const filteredContacts = contacts.filter(
Expand Down
18 changes: 4 additions & 14 deletions packages/nextjs/components/popovers/ContactGroupPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"use client";

import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import Image from "next/image";
import { Checkbox, SearchInput } from "../Common";
import { ContactGroup } from "@polypay/shared";
import { useContact, useGroups } from "~~/hooks/api/useContactBook";
import { useClickOutside } from "~~/hooks/useClickOutside";

interface ContactGroupPopoverProps {
contactId: string;
Expand All @@ -26,7 +27,7 @@ export function ContactGroupPopover({
selectedGroup,
selectedGroupIds,
onSelect,
arrowSrc = "/batch/popover-arrow.svg",
arrowSrc = "/icons/arrows/popover-arrow.svg",
arrowWidth = 28,
arrowHeight = 28,
popoverClassName,
Expand All @@ -38,18 +39,7 @@ export function ContactGroupPopover({
const [searchTerm, setSearchTerm] = useState("");
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;

const handleClickOutside = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
useClickOutside(rootRef, () => setOpen(false), { isActive: open });

const contactGroupIds = selectedGroupIds || contact?.groups?.map(entry => entry.group?.id).filter(Boolean) || [];
const isLoading = isLoadingContact || isLoadingGroups;
Expand Down
19 changes: 2 additions & 17 deletions packages/nextjs/components/popovers/EditBatchPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ContactPicker } from "~~/components/contact-book/ContactPicker";
import { useContacts } from "~~/hooks";
import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens";
import { useZodForm } from "~~/hooks/form";
import { useClickOutside } from "~~/hooks/useClickOutside";
import { editBatchSchema } from "~~/lib/form";
import { useAccountStore } from "~~/services/store";
import { formatAddress } from "~~/utils/format";
Expand Down Expand Up @@ -67,23 +68,7 @@ export default function EditBatchPopover({ item, isOpen, onClose, onSave, trigge
}
}, [isOpen, triggerRef]);

useEffect(() => {
if (!isOpen) return;

const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
onClose();
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose, triggerRef]);
useClickOutside(popoverRef, onClose, { isActive: isOpen, triggerRef });

const handleContactSelect = (selectedAddress: string, name: string, contactId: string) => {
form.setValue("recipient", selectedAddress, { shouldValidate: true });
Expand Down
16 changes: 3 additions & 13 deletions packages/nextjs/components/popovers/TokenPillPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client";

import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import Image from "next/image";
import { ResolvedToken } from "@polypay/shared";
import { useMetaMultiSigWallet, useTokenPrices } from "~~/hooks";
import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens";
import { useTokenBalances } from "~~/hooks/app/useTokenBalance";
import { useClickOutside } from "~~/hooks/useClickOutside";

interface TokenPillPopoverProps {
selectedToken: ResolvedToken;
Expand Down Expand Up @@ -34,18 +35,7 @@ export function TokenPillPopover({
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;

const handleClickOutside = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
useClickOutside(rootRef, () => setOpen(false), { isActive: open });

const getTokenUsdValue = (token: ResolvedToken): string => {
const balance = parseFloat(balances[token.address] || "0");
Expand Down
29 changes: 29 additions & 0 deletions packages/nextjs/hooks/useClickOutside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { RefObject, useEffect } from "react";

/**
* Subscribe to mousedown outside a container (and optionally a trigger) and call onClose.
* When isActive is false, the listener is not added.
*/
export function useClickOutside(
containerRef: RefObject<HTMLElement | null>,
onClose: () => void,
options?: { isActive?: boolean; triggerRef?: RefObject<HTMLElement | null> },
): void {
const isActive = options?.isActive !== false;
const triggerRef = options?.triggerRef;

useEffect(() => {
if (!isActive) return;

const handler = (event: MouseEvent) => {
const target = event.target as Node;
if (!containerRef.current) return;
let isOutside = !containerRef.current.contains(target);
if (triggerRef?.current && triggerRef.current.contains(target)) isOutside = false;
if (isOutside) onClose();
};

document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isActive, onClose, triggerRef]);
}