From 39cc27601a258ad8ebaa8ecd282c463182969e7e Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Mon, 23 Feb 2026 13:59:01 +0700 Subject: [PATCH] refactor(hooks): add useClickOutside hook to consolidate click-outside logic --- .../components/Transfer/TokenSelector.tsx | 15 ++-------- .../components/contact-book/ContactPicker.tsx | 18 ++---------- .../popovers/ContactGroupPopover.tsx | 18 +++--------- .../components/popovers/EditBatchPopover.tsx | 19 ++---------- .../components/popovers/TokenPillPopover.tsx | 16 ++-------- packages/nextjs/hooks/useClickOutside.ts | 29 +++++++++++++++++++ 6 files changed, 42 insertions(+), 73 deletions(-) create mode 100644 packages/nextjs/hooks/useClickOutside.ts diff --git a/packages/nextjs/components/Transfer/TokenSelector.tsx b/packages/nextjs/components/Transfer/TokenSelector.tsx index 842f8053..351123ae 100644 --- a/packages/nextjs/components/Transfer/TokenSelector.tsx +++ b/packages/nextjs/components/Transfer/TokenSelector.tsx @@ -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; @@ -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); diff --git a/packages/nextjs/components/contact-book/ContactPicker.tsx b/packages/nextjs/components/contact-book/ContactPicker.tsx index 8b274e1d..92162345 100644 --- a/packages/nextjs/components/contact-book/ContactPicker.tsx +++ b/packages/nextjs/components/contact-book/ContactPicker.tsx @@ -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; @@ -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( diff --git a/packages/nextjs/components/popovers/ContactGroupPopover.tsx b/packages/nextjs/components/popovers/ContactGroupPopover.tsx index 36eee91c..a7e248d1 100644 --- a/packages/nextjs/components/popovers/ContactGroupPopover.tsx +++ b/packages/nextjs/components/popovers/ContactGroupPopover.tsx @@ -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; @@ -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, @@ -38,18 +39,7 @@ export function ContactGroupPopover({ const [searchTerm, setSearchTerm] = useState(""); const rootRef = useRef(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; diff --git a/packages/nextjs/components/popovers/EditBatchPopover.tsx b/packages/nextjs/components/popovers/EditBatchPopover.tsx index 8d69a876..613b7ad2 100644 --- a/packages/nextjs/components/popovers/EditBatchPopover.tsx +++ b/packages/nextjs/components/popovers/EditBatchPopover.tsx @@ -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"; @@ -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 }); diff --git a/packages/nextjs/components/popovers/TokenPillPopover.tsx b/packages/nextjs/components/popovers/TokenPillPopover.tsx index d1eef1a9..490c7820 100644 --- a/packages/nextjs/components/popovers/TokenPillPopover.tsx +++ b/packages/nextjs/components/popovers/TokenPillPopover.tsx @@ -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; @@ -34,18 +35,7 @@ export function TokenPillPopover({ const [open, setOpen] = useState(false); const rootRef = useRef(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"); diff --git a/packages/nextjs/hooks/useClickOutside.ts b/packages/nextjs/hooks/useClickOutside.ts new file mode 100644 index 00000000..21447f0d --- /dev/null +++ b/packages/nextjs/hooks/useClickOutside.ts @@ -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, + onClose: () => void, + options?: { isActive?: boolean; triggerRef?: RefObject }, +): 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]); +}