From 76f43b97962ee8aa510fab58dd557a9f5515f56c Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 1 Oct 2026 08:38:57 -0700 Subject: [PATCH 1/2] feat: close every dashboard dropdown through one useClickOutside(open, onClose, refs) hook that the shared PopoverMenu, the old useClickOutside callers and the hand-rolled pickers now all use, so a press outside (including inside a floating dialog card or an email iframe) closes it, Escape closes only the innermost one and returns focus to its trigger, and opening one closes the others --- AGENTS.md | 2 +- .../app/settings/sending/LanguagePicker.tsx | 2 +- web/src/components/app/AddContacts.tsx | 2 +- .../components/app/advisor/AdvisorCard.tsx | 4 +- web/src/components/app/ai/TextareaAIEdit.tsx | 2 +- .../campaigns/preferences/SenderSelector.tsx | 2 +- .../campaigns/schedule/ScheduleDateSelect.tsx | 15 +-- .../app/campaigns/sequences/CampaignFlow.tsx | 6 +- .../campaigns/sequences/DesignControls.tsx | 2 +- .../app/campaigns/sequences/ImageControls.tsx | 2 +- .../campaigns/sequences/RichTextEditor.tsx | 32 +++-- .../sequences/nodes/ConditionalNode.tsx | 21 +-- .../sequences/nodes/FormLinkNode.tsx | 21 +-- .../sequences/nodes/VariableNode.tsx | 21 +-- .../app/contacts/CategoryPicker.tsx | 2 +- .../app/contacts/ContactsEditBulk.tsx | 2 +- .../app/contacts/CustomFieldKeyInput.tsx | 2 +- .../app/contacts/contact-edit/ActivityTab.tsx | 2 +- .../app/contacts/contact-edit/DetailsTab.tsx | 2 +- .../app/contacts/contact-edit/LeadCCBar.tsx | 2 +- .../app/contacts/filters/FilterBar.tsx | 4 +- .../components/app/crm/AssigneeTeamPicker.tsx | 2 +- web/src/components/app/crm/TaskTypePicker.tsx | 4 +- .../components/app/emails/BulkTagPopover.tsx | 2 +- .../app/emails/import/MailboxImportsMenu.tsx | 4 +- web/src/components/app/forms/FontPicker.tsx | 28 +--- web/src/components/app/popup/MiniDate.tsx | 16 +-- .../app/popup/select/CampaignSelector.tsx | 2 +- .../app/popup/select/FolderSelector.tsx | 2 +- .../app/popup/select/TimeSelector.tsx | 18 +-- .../app/presence/PresenceAvatars.tsx | 2 +- .../app/segments/SegmentPickers.tsx | 2 +- .../app/unibox/compose/ComposeDraftsItem.tsx | 2 +- .../unibox/compose/ContactRecipientField.tsx | 2 +- .../app/unibox/compose/FilterMenu.tsx | 30 +---- .../app/unibox/compose/MailboxPicker.tsx | 10 +- .../app/unibox/compose/OutboxIndicator.tsx | 26 +--- web/src/components/layout/CreditsMeter.tsx | 2 +- .../components/layout/NotificationBell.tsx | 2 +- web/src/components/ui/DatePicker.tsx | 30 +---- web/src/components/ui/color-picker.tsx | 29 +---- web/src/components/ui/popover-menu.tsx | 53 +------- web/src/hooks/BulkEditContactsProvider.tsx | 4 +- web/src/hooks/ContactsProvider.tsx | 6 +- web/src/hooks/useClickOutside.test.tsx | 121 ++++++++++++++++++ web/src/hooks/useClickOutside.ts | 93 ++++++++++++++ web/src/hooks/useClickOutside.tsx | 37 ------ .../lib/api/hooks/app/advisor/useAdvisor.ts | 2 +- .../app/emails/useMailboxImportActions.ts | 2 +- 49 files changed, 308 insertions(+), 375 deletions(-) create mode 100644 web/src/hooks/useClickOutside.test.tsx create mode 100644 web/src/hooks/useClickOutside.ts delete mode 100644 web/src/hooks/useClickOutside.tsx diff --git a/AGENTS.md b/AGENTS.md index 1066a2c7d..d6eea0f54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -306,7 +306,7 @@ Everything in the dashboard must use our own theme, not browser/library defaults - Row interactions: list rows behave like the campaigns list — clicking anywhere on a row opens that item's detail (drawer or page); right-side action buttons (3-dots / "More") either open a relevant detail/tab or drop a short menu of the actions for that row (the mailbox 3-dots menus Settings and Disconnect). A destructive action belongs in that menu as a `danger` item as well as in the detail's own danger zone, because the selection bar is not where anyone looks to remove one row. Inner interactive controls (checkbox, dropdown trigger, action buttons) must `e.stopPropagation()` so they don't also fire the row's open handler. - Prefer realtime over polling: subscribe to the socket and `queryClient.invalidateQueries(...)` on the relevant event instead of `refetchInterval` where an event exists (see `useRealtimeEvents` / `RealtimeManager`). - Interaction details are part of "done". Before calling a dashboard change finished, walk the small things a user hits in the first minute, because these are what make the product feel broken even when the data flow is right: - - every dropdown / popover / picker closes on click-away and on Escape, including when it sits inside a dialog or drawer. Dialog cards stop `mousedown` propagation so the backdrop does not close them; React's `stopPropagation` also stops the native event, so any click-outside listener must be registered in the **capture** phase (`document.addEventListener("mousedown", fn, true)`, as `PopoverMenu` and `useClickOutside` do), never the bubble phase. Escape must close only the innermost layer: the dialog's Escape handler bails out while a `[data-floating]` popover or the `[role="alertdialog"]` confirm is on screen + - every dropdown / popover / picker closes on click-away and on Escape, including when it sits inside a dialog or drawer. Dialog cards stop `mousedown` propagation so the backdrop does not close them; React's `stopPropagation` also stops the native event, so never hand-roll a click-outside listener: every floating layer closes through `useClickOutside` (`@/hooks/useClickOutside`, which `PopoverMenu` uses too). It listens for `pointerdown` in the capture phase, treats a `[data-floating]` layer it opened as inside but the floating panel or dialog holding it as outside, closes on focus moving into an iframe, and takes Escape for the innermost layer only, stopping it there and handing focus back to the trigger. A dialog's own Escape handler still bails out while a `[data-floating]` popover or the `[role="alertdialog"]` confirm is on screen - toggles are the shared `Toggle` (sky pill, 32x18) from `campaigns/preferences/components/CampaignPreferenceBoolBox`; never hand-roll a switch. If a whole row toggles on click, the switch itself must `stopPropagation` so it does not toggle twice, and a `