mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
Merge pull request #781 from warmbly/fix/workspace-selector-click-outside
feat: close every dashboard dropdown through one useClickOutside(open, onClose, refs) hook so a press outside, Escape (innermost first) or another dropdown opening closes it
This commit is contained in:
@@ -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 `<label htmlFor>` pointing at the switch would double-fire too, so use a plain element for the row title
|
||||
- **a page is not shipped until it is routed, linked and titled.** Three lists have to agree, and nothing fails the build when they do not: the route table in `web/src/main.tsx`, the nav that links to it (`AppNav`, `settings/layout.tsx`), and the title map in `web/src/hooks/useDocumentTitle.ts` (static pathnames in `ROUTE_TITLES`, `:id` routes as a regex in `PARAM_ROUTES`). A nav entry with no route renders nothing; a page component with no route is dead code nobody can reach; a route with no title falls through to the literal `"Page not found | Warmbly"` in the tab, which reads as a broken app on a page that works. All three drift silently, so check them together, and after a merge that touched routing check the settings nav against `main.tsx` specifically
|
||||
- every detail page reachable from a list has a way back on all viewports: a "← Section" link above the title (see `campaigns/[id]/layout.tsx`) or a back arrow in its header (see `AutomationFlow`); the header breadcrumb is desktop-only and its crumbs must stay clickable, so it does not count as the only route back
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function LanguagePicker({ value, onChange }: { value: string[]; o
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}, []);
|
||||
useClickOutside(ref, close);
|
||||
useClickOutside(open, close, ref);
|
||||
const placement = useFlipPlacement(triggerRef, open, 270);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
|
||||
@@ -767,7 +767,7 @@ function CSVSelector({
|
||||
const [drop, setDrop] = React.useState<boolean>(false);
|
||||
const dropRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(dropRef, () => setDrop(false))
|
||||
useClickOutside(drop, () => setDrop(false), dropRef);
|
||||
|
||||
return (<>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-5 sm:justify-between">
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function TextareaAIEdit({
|
||||
}, [sel, syncRects]);
|
||||
|
||||
// Dismiss when the user clicks anywhere outside the floating layer and
|
||||
// the textarea (mirrors useClickOutside, plus the textarea exception).
|
||||
// the textarea (like useClickOutside, plus the textarea exception).
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function SenderSelector({
|
||||
const [query, setQuery] = React.useState("");
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
const placement = useFlipPlacement(triggerRef, open, 320);
|
||||
|
||||
const tagById = React.useMemo(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CalendarIcon, XIcon } from "lucide-react";
|
||||
import Calendar from "../../Calendar";
|
||||
import { format } from "date-fns";
|
||||
import { Label } from "@/components/ui/field";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
export default function DateSelect({
|
||||
title,
|
||||
@@ -18,19 +19,7 @@ export default function DateSelect({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const modalRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
if (open) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(open, () => setOpen(false), modalRef);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
|
||||
@@ -2743,7 +2743,7 @@ function AddNodeMenu({
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
return (
|
||||
<div ref={ref} className="relative inline-flex">
|
||||
{/* Primary click = add an email step (the default, common case). */}
|
||||
@@ -3833,7 +3833,7 @@ function AIContextToggle({
|
||||
function DealNameVariableMenu({ onPick }: { onPick: (token: string) => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
@@ -3873,7 +3873,7 @@ const DEAL_CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CHF", "SEK",
|
||||
function CurrencyPicker({ value, onChange }: { value: string; onChange: (c: string) => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
return (
|
||||
<div ref={ref} className="relative inline-flex">
|
||||
<button
|
||||
|
||||
@@ -90,7 +90,7 @@ function Panel({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
|
||||
placement: "bottom-start",
|
||||
gap: 6,
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ImageMenu({ editor }: { editor: Editor }) {
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const fileRef = React.useRef<HTMLInputElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
|
||||
placement: "bottom-start",
|
||||
gap: 6,
|
||||
@@ -59,20 +59,6 @@ export function ImageMenu({ editor }: { editor: Editor }) {
|
||||
const del = useDeleteEmailImage();
|
||||
const confirm = useConfirm();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// The delete confirmation sits above this menu, so Escape belongs
|
||||
// to it first: only the innermost layer closes.
|
||||
if (document.querySelector("[role='alertdialog']")) return;
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => document.removeEventListener("keydown", onKey, true);
|
||||
}, [open]);
|
||||
|
||||
const pick = async (files: FileList | File[] | null) => {
|
||||
const file = Array.from(files ?? [])[0];
|
||||
if (!file) return;
|
||||
|
||||
@@ -439,6 +439,9 @@ function Toolbar({
|
||||
}) {
|
||||
const [linkOpen, setLinkOpen] = React.useState(false);
|
||||
const [linkUrl, setLinkUrl] = React.useState("");
|
||||
const linkBtnRef = React.useRef<HTMLSpanElement>(null);
|
||||
const linkPanelRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(linkOpen, () => setLinkOpen(false), [linkBtnRef, linkPanelRef]);
|
||||
|
||||
const applyLink = () => {
|
||||
const url = linkUrl.trim();
|
||||
@@ -516,16 +519,18 @@ function Toolbar({
|
||||
<Btn active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()} title="Numbered list">
|
||||
<ListOrderedIcon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
<Btn
|
||||
active={editor.isActive("link")}
|
||||
onClick={() => {
|
||||
setLinkUrl(editor.getAttributes("link").href ?? "");
|
||||
setLinkOpen((o) => !o);
|
||||
}}
|
||||
title="Link"
|
||||
>
|
||||
<Link2Icon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
<span ref={linkBtnRef} className="contents">
|
||||
<Btn
|
||||
active={editor.isActive("link")}
|
||||
onClick={() => {
|
||||
setLinkUrl(editor.getAttributes("link").href ?? "");
|
||||
setLinkOpen((o) => !o);
|
||||
}}
|
||||
title="Link"
|
||||
>
|
||||
<Link2Icon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
</span>
|
||||
<ImageMenu editor={editor} />
|
||||
<ButtonInsert editor={editor} />
|
||||
<Divider />
|
||||
@@ -574,6 +579,7 @@ function Toolbar({
|
||||
<AnimatePresence>
|
||||
{linkOpen && (
|
||||
<motion.div
|
||||
ref={linkPanelRef}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
@@ -588,8 +594,6 @@ function Toolbar({
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyLink();
|
||||
} else if (e.key === "Escape") {
|
||||
setLinkOpen(false);
|
||||
}
|
||||
}}
|
||||
placeholder="https://…"
|
||||
@@ -681,7 +685,7 @@ export function VariableMenu({
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
// Ignores clicks inside the portaled [data-floating] panel, so only a click
|
||||
// truly outside the trigger+panel closes it.
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
// floating-ui keeps the panel glued to the trigger through scroll/resize.
|
||||
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
|
||||
placement: "bottom-start",
|
||||
@@ -893,7 +897,7 @@ export function VariableMenu({
|
||||
function FormMenu({ onPick }: { onPick: (publicId: string) => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
|
||||
placement: "bottom-start",
|
||||
gap: 6,
|
||||
|
||||
@@ -15,6 +15,7 @@ import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys"
|
||||
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
|
||||
import { STANDARD_VARS, buildToken, cleanFieldName, isStandardKey } from "@/lib/templateVars";
|
||||
import { markJustInserted, consumeJustInserted, freshId } from "./justInserted";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
interface ConditionalAttrs {
|
||||
expr: string; // the condition after `{{if `, e.g. `.Company` or `eq .Industry "SaaS"`
|
||||
@@ -323,25 +324,7 @@ function ConditionalBuilder({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [field, op, value, raw, thenText, elseText, showElse, rawMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (!localRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
useClickOutside(true, onClose, localRef);
|
||||
|
||||
const fields = [
|
||||
...STANDARD_VARS.map((v) => v.key),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Link2Icon, XIcon, CheckIcon } from "lucide-react";
|
||||
import { useForms } from "@/lib/api/hooks/app/forms";
|
||||
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
|
||||
import { buildFormLinkToken } from "@/lib/templateVars";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -190,25 +191,7 @@ function FormLinkChipEditor({
|
||||
[setFloating],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (!localRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
useClickOutside(true, onClose, localRef);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { BracesIcon, XIcon, CheckIcon } from "lucide-react";
|
||||
import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys";
|
||||
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import {
|
||||
STANDARD_VARS,
|
||||
buildToken,
|
||||
@@ -212,25 +213,7 @@ function VariableChipEditor({
|
||||
[setFloating],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (!localRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
useClickOutside(true, onClose, localRef);
|
||||
|
||||
const options = [
|
||||
...STANDARD_VARS.map((v) => ({ key: v.key, label: v.label })),
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function CategoryPicker({
|
||||
const [query, setQuery] = React.useState("");
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
// ~270px: 33px search input + 56 max-h list (224px) + borders.
|
||||
const placement = useFlipPlacement(triggerRef, open, 270);
|
||||
|
||||
|
||||
@@ -438,7 +438,7 @@ function FieldRow({
|
||||
const [showType, setShowType] = React.useState(false);
|
||||
const dropRef = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
useClickOutside(dropRef, () => setShowType(false));
|
||||
useClickOutside(showType, () => setShowType(false), dropRef);
|
||||
const typePlacement = useFlipPlacement(triggerRef, showType, 180);
|
||||
const { data: existingKeys = [] } = useCustomFieldKeys();
|
||||
const typeDef = FIELD_TYPES.find((t) => t.id === field.type)!;
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function CustomFieldKeyInput({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [active, setActive] = React.useState(-1);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
|
||||
// Nothing to offer once the name already is a field.
|
||||
const matches = keys.includes(value) ? [] : suggestKeys(value, keys);
|
||||
|
||||
@@ -891,7 +891,7 @@ function DateRange({
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
// The trigger wraps anywhere along the toolbar row, so the panel side is
|
||||
// measured, not fixed: a fixed right-0 clipped it against the drawer edge.
|
||||
const align = useFlipAlignment(ref, open, 256);
|
||||
|
||||
@@ -272,7 +272,7 @@ function CampaignPicker({
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
const placement = useFlipPlacement(triggerRef, open, 290);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -145,7 +145,7 @@ function CCPicker({
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [query, setQuery] = React.useState("");
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
const placement = useFlipPlacement(triggerRef, open, 300);
|
||||
|
||||
const q = useDebouncedValue(query.trim(), 250);
|
||||
|
||||
@@ -443,9 +443,7 @@ function Pill({
|
||||
const open = openKey === id;
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
useClickOutside(ref, () => {
|
||||
if (open) setOpenKey(null);
|
||||
});
|
||||
useClickOutside(open, () => setOpenKey(null), ref);
|
||||
const placement = useFlipPlacement(triggerRef, open, 300);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AssigneeTeamPicker({
|
||||
const { data: teams } = useTeams();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
|
||||
const memberList = members ?? [];
|
||||
const teamList = teams ?? [];
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function TaskTypePicker({
|
||||
const [query, setQuery] = React.useState("");
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
// ~290px: 33px search input + 56 max-h list (224px) + the "No type"
|
||||
// row + borders, so the flip kicks in before a tall list clips.
|
||||
const placement = useFlipPlacement(triggerRef, open, 290);
|
||||
@@ -416,7 +416,7 @@ function SwatchPopover({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
useClickOutside(true, onClose, ref);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function BulkTagPopover({ ids }: { ids: string[] }) {
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
// ~330px: toggle + search header + max-h-48 list + footer.
|
||||
const placement = useFlipPlacement(triggerRef, open, 330);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
|
||||
|
||||
import { FONT_CATALOG, ensureFont, resolveDesign } from "./designCore";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
export default function FontPicker({
|
||||
value,
|
||||
@@ -23,6 +24,7 @@ export default function FontPicker({
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
|
||||
placement: "bottom-start",
|
||||
gap: 6,
|
||||
@@ -38,26 +40,7 @@ export default function FontPicker({
|
||||
for (const key of Object.keys(FONT_CATALOG)) ensureFont(resolveDesign({ font_family: key }));
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (!panelRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(open, () => setOpen(false), [triggerRef, panelRef]);
|
||||
|
||||
const setRefs = React.useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
@@ -70,7 +53,10 @@ export default function FontPicker({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={(el) => setReference(el)}
|
||||
ref={(el) => {
|
||||
triggerRef.current = el;
|
||||
setReference(el);
|
||||
}}
|
||||
type="button"
|
||||
aria-label="Font"
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import Calendar from "../Calendar";
|
||||
import { format } from "date-fns";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
export default function MiniDate({
|
||||
onChange,
|
||||
@@ -14,20 +15,7 @@ export default function MiniDate({
|
||||
const [drop, setDrop] = React.useState<boolean>(false);
|
||||
const modalRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
setDrop(false);
|
||||
}
|
||||
};
|
||||
if (drop) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [drop]);
|
||||
useClickOutside(drop, () => setDrop(false), modalRef);
|
||||
|
||||
return (<div className="relative w-full" ref={modalRef}>
|
||||
<div onClick={() => setDrop(true)} className={`w-full cursor-pointer font-sans text-[15px] bg-transparent text-slate-700 border rounded-md px-3 py-2.5 transition duration-300 ease focus:outline-none ${drop ? "border-slate-300" : "border-slate-200 hover:border-slate-300"} shadow-sm focus:shadow`}>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function CampaignSelector({ onAdd, onRemove, selected, reverse }:
|
||||
|
||||
const popupRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(popupRef, () => setShow(false))
|
||||
useClickOutside(show, () => setShow(false), popupRef);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={popupRef}>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function FolderSelector({ onAdd, onRemove, selected }: { onAdd: (
|
||||
const [show, setShow] = React.useState<boolean>(false);
|
||||
const popupRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(popupRef, () => setShow(false))
|
||||
useClickOutside(show, () => setShow(false), popupRef);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={popupRef}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SelectMenu from "./SelectMenu";
|
||||
import SelectOption from "./SelectOption";
|
||||
import { RiTimeLine } from "@remixicon/react";
|
||||
import { timeOptions, to12Hour } from "@/lib/core/time";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
export default function TimeSelector({
|
||||
value,
|
||||
@@ -15,22 +16,7 @@ export default function TimeSelector({
|
||||
const [show, setShow] = React.useState<boolean>(false);
|
||||
const popupRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
show &&
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShow(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [show]);
|
||||
useClickOutside(show, () => setShow(false), popupRef);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={popupRef}>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function PresenceAvatars() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
|
||||
if (members.length === 0) return null;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export function MultiPicker({
|
||||
const [query, setQuery] = React.useState("");
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
const placement = useFlipPlacement(triggerRef, open, 270);
|
||||
|
||||
const byId = React.useMemo(() => new Map(options.map((o) => [o.id, o])), [options]);
|
||||
|
||||
@@ -29,7 +29,7 @@ function formatWhen(iso: string | Date): string {
|
||||
export default function ComposeDraftsItem() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const boxRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(boxRef, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), boxRef);
|
||||
|
||||
const draftsQ = useComposeDrafts();
|
||||
const deleteMut = useDeleteComposeDraft();
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ContactRecipientField({
|
||||
up: boolean;
|
||||
} | null>(null);
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(rootRef, () => setBrowseOpen(false));
|
||||
useClickOutside(browseOpen, () => setBrowseOpen(false), rootRef);
|
||||
|
||||
const measureBrowse = React.useCallback(() => {
|
||||
const el = rootRef.current;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckIcon, ChevronDownIcon, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
export interface FilterMenuOption {
|
||||
id: string;
|
||||
@@ -41,34 +42,7 @@ export default function FilterMenu({
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Own outside-close handling instead of useClickOutside: the trigger
|
||||
// usually lives inside a data-floating panel, which the shared hook
|
||||
// deliberately ignores — so clicking elsewhere in that panel (or the
|
||||
// sibling filter) would never close this menu. Anything outside the
|
||||
// trigger and this menu's own dropdown closes it; Escape too.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (ref.current?.contains(t)) return;
|
||||
if (panelRef.current?.contains(t)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(open, () => setOpen(false), [ref, panelRef]);
|
||||
|
||||
const active = options.find((o) => o.id === value) ?? null;
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function MailboxPicker({
|
||||
const [anchor, setAnchor] = React.useState<{ top: number; left: number; up: boolean } | null>(null);
|
||||
const boxRef = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
useClickOutside(boxRef, () => setOpen(false));
|
||||
useClickOutside(open, () => setOpen(false), boxRef);
|
||||
|
||||
const storeEmails = useAppStore((s) => s.emails);
|
||||
const storeTags = useAppStore((s) => s.tags);
|
||||
@@ -217,14 +217,6 @@ export default function MailboxPicker({
|
||||
: { top: anchor.top }),
|
||||
}}
|
||||
className="max-w-[calc(100vw-16px)] rounded-lg border border-slate-200 bg-white shadow-xl overflow-hidden"
|
||||
onKeyDown={(e) => {
|
||||
// Innermost layer only: the composer around it keeps its own Escape.
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{/* Search + tag filter header: one compact row */}
|
||||
<div className="px-1.5 pt-1.5 pb-1 border-b border-slate-100 flex items-center gap-1">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useOutboxStore, type OutboxEntry } from "@/hooks/useOutboxStore";
|
||||
import { useComposeStore } from "@/hooks/useComposeStore";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
function secondsLeft(entry: OutboxEntry, now: number): number {
|
||||
return Math.max(0, Math.ceil((entry.scheduledAt - now) / 1000));
|
||||
@@ -59,30 +60,7 @@ export default function OutboxIndicator() {
|
||||
if (entries.length === 0) setOpen(false);
|
||||
}, [entries.length]);
|
||||
|
||||
// Outside click / Escape closes the dropdown (same shape as FilterMenu).
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (pillRef.current?.contains(t)) return;
|
||||
if (panelRef.current?.contains(t)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(open, () => setOpen(false), [pillRef, panelRef]);
|
||||
|
||||
const sorted = React.useMemo(
|
||||
() => [...entries].sort((a, b) => a.scheduledAt - b.scheduledAt),
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CreditsMeter() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const close = React.useCallback(() => setOpen(false), []);
|
||||
useClickOutside(ref, close);
|
||||
useClickOutside(open, close, ref);
|
||||
|
||||
// Nothing to gauge when the ledger is bypassed (self-host without billing).
|
||||
if (!canSee || !metered || credits.isPending || !credits.data || credits.data.unlimited) return null;
|
||||
|
||||
@@ -93,7 +93,7 @@ export function NotificationBell() {
|
||||
const [filter, setFilter] = React.useState<"all" | "unread">("all");
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const close = React.useCallback(() => setOpen(false), []);
|
||||
useClickOutside(ref, close);
|
||||
useClickOutside(open, close, ref);
|
||||
|
||||
const unread = data?.unread ?? 0;
|
||||
const items = data?.notifications ?? [];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CalendarIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Calendar from "@/components/app/Calendar";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
// Parse "yyyy-MM-dd" as a LOCAL date (avoids the UTC shift new Date("yyyy-MM-dd")
|
||||
// introduces, which can land the picker a day off in negative-offset timezones).
|
||||
@@ -41,34 +42,7 @@ export function DatePicker({
|
||||
const wrapRef = React.useRef<HTMLDivElement>(null);
|
||||
const selected = parseISODate(value);
|
||||
|
||||
// Close on outside click. BUBBLE phase (not capture): the Calendar panel is
|
||||
// portaled to <body> and stops mousedown bubbling, so its own clicks (month
|
||||
// nav, day select) never reach here and don't dismiss it mid-interaction.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("touchstart", onDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("touchstart", onDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Escape closes the calendar only: stopped in the window capture phase so
|
||||
// the popover or dialog holding this field stays open.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [open]);
|
||||
useClickOutside(open, () => setOpen(false), wrapRef);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className={cn("relative inline-flex", className)}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { PipetteIcon } from "lucide-react";
|
||||
|
||||
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
@@ -94,6 +95,7 @@ export default function ColorPicker({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [text, setText] = React.useState(current);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, {
|
||||
placement: "bottom-start",
|
||||
gap: 6,
|
||||
@@ -101,27 +103,7 @@ export default function ColorPicker({
|
||||
|
||||
React.useEffect(() => setText(current), [current]);
|
||||
|
||||
// Capture phase: a dialog card that stops mousedown must not swallow this.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (!panelRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(open, () => setOpen(false), [triggerRef, panelRef]);
|
||||
|
||||
const hsv = hexToHsv(HEX_RE.test(current) ? current : fallback);
|
||||
|
||||
@@ -149,7 +131,10 @@ export default function ColorPicker({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={(el) => setReference(el)}
|
||||
ref={(el) => {
|
||||
triggerRef.current = el;
|
||||
setReference(el);
|
||||
}}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? "Pick a color"}
|
||||
|
||||
@@ -44,6 +44,7 @@ import React, {
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
|
||||
interface MenuCtx {
|
||||
id: string;
|
||||
@@ -58,14 +59,6 @@ interface MenuCtx {
|
||||
|
||||
const Ctx = createContext<MenuCtx | null>(null);
|
||||
|
||||
// Every open menu, oldest first. Opening one closes the others unless it was
|
||||
// opened from inside them, and Escape closes only the newest.
|
||||
interface OpenMenu {
|
||||
panel: () => HTMLElement | null;
|
||||
close: () => void;
|
||||
}
|
||||
const openMenus: OpenMenu[] = [];
|
||||
|
||||
function useMenu() {
|
||||
const c = useContext(Ctx);
|
||||
if (!c) throw new Error("PopoverMenu primitives must be used inside <PopoverMenu>");
|
||||
@@ -226,48 +219,8 @@ export function PopoverMenuContent({
|
||||
};
|
||||
}, [open, side, align, sideOffset, triggerRef, anchorPoint, setOpen]);
|
||||
|
||||
// Read through a ref: callers often pass an inline onOpenChange, and
|
||||
// re-running the effect below would reorder the stack on every render.
|
||||
const setOpenRef = useRef(setOpen);
|
||||
setOpenRef.current = setOpen;
|
||||
|
||||
// One menu at a time, closed by a press anywhere else or by Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const self: OpenMenu = { panel: () => ref.current, close: () => setOpenRef.current(false) };
|
||||
// A menu with no trigger (anchored at a point) opened from wherever focus is.
|
||||
const origin = triggerRef.current ?? document.activeElement;
|
||||
// The stack is one chain of nested menus, so the menu holding the origin
|
||||
// and everything below it stay; everything above it closes.
|
||||
let parent = openMenus.length - 1;
|
||||
while (parent >= 0 && !(origin && openMenus[parent].panel()?.contains(origin))) parent--;
|
||||
for (const other of openMenus.slice(parent + 1)) other.close();
|
||||
openMenus.push(self);
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (ref.current?.contains(t)) return;
|
||||
if (triggerRef.current?.contains(t)) return;
|
||||
// A press inside another portaled floating layer this menu opened (a
|
||||
// date-picker calendar, a nested SelectMenu) must not close this menu.
|
||||
const el = t as Element | null;
|
||||
if (el && typeof el.closest === "function" && el.closest("[data-floating]")) return;
|
||||
self.close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && openMenus[openMenus.length - 1] === self) self.close();
|
||||
};
|
||||
// Capture phase: dialogs stop mousedown propagation on their card so the
|
||||
// backdrop does not close them, which would otherwise swallow this too.
|
||||
// Pointer events so a tap closes it on touch screens as well.
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
const at = openMenus.indexOf(self);
|
||||
if (at >= 0) openMenus.splice(at, 1);
|
||||
};
|
||||
}, [open, triggerRef]);
|
||||
// A press outside, Escape (innermost first), or another menu opening closes it.
|
||||
useClickOutside(open, () => setOpen(false), [triggerRef, ref]);
|
||||
|
||||
// Keyboard: focus lands on the panel (never over an autofocused input), and
|
||||
// closing hands it back to whatever held it before, if it is still there.
|
||||
|
||||
@@ -5,7 +5,7 @@ import CampaignSelector from '@/components/app/popup/select/CampaignSelector';
|
||||
import Selector from '@/components/app/popup/select/Selector';
|
||||
import { RiAddLine, RiCloseLine } from '@remixicon/react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import useClickOutside from './useClickOutside';
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import SelectMenu from '@/components/app/popup/select/SelectMenu';
|
||||
import SelectOption from '@/components/app/popup/select/SelectOption';
|
||||
import MiniTextArea from '@/components/app/popup/MiniTextArea';
|
||||
@@ -251,7 +251,7 @@ function FieldEdit({
|
||||
const [show, setShow] = React.useState<boolean>(false);
|
||||
const dropRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
useClickOutside(dropRef, () => setShow(false))
|
||||
useClickOutside(show, () => setShow(false), dropRef);
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex gap-2'>
|
||||
|
||||
@@ -15,7 +15,7 @@ import Selector from "@/components/app/popup/select/Selector";
|
||||
import SelectMenu from "@/components/app/popup/select/SelectMenu";
|
||||
import SelectOption from "@/components/app/popup/select/SelectOption";
|
||||
import { twColors } from "tailwindv4-colors";
|
||||
import useClickOutside from "./useClickOutside";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import CampaignSelector from "@/components/app/popup/select/CampaignSelector";
|
||||
import AddContacts from "@/components/app/AddContacts";
|
||||
import BulkEditContactsProvider from "./BulkEditContactsProvider";
|
||||
@@ -917,7 +917,7 @@ function SortBySelection({
|
||||
const [show, setShow] = React.useState<boolean>(false)
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => setShow(false))
|
||||
useClickOutside(show, () => setShow(false), ref);
|
||||
|
||||
return (<div className="space-y-4">
|
||||
<div className="relative" ref={ref}>
|
||||
@@ -967,7 +967,7 @@ function CustomFieldRow({
|
||||
const [show, setShow] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickOutside(ref, () => setShow(false));
|
||||
useClickOutside(show, () => setShow(false), ref);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// useClickOutside is how every dropdown closes: a press outside it, Escape (the
|
||||
// innermost one only), or focus moving into an iframe.
|
||||
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import useClickOutside from "./useClickOutside";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// A wrapper holding trigger and panel, plus an optional portaled floating
|
||||
// layer it opens (a calendar).
|
||||
function Drop({ name, layer = false, children }: { name: string; layer?: boolean; children?: React.ReactNode }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
useClickOutside(open, () => setOpen(false), ref);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)}>
|
||||
{name}
|
||||
</button>
|
||||
{open && (
|
||||
<div data-testid={`${name}-panel`}>
|
||||
{children}
|
||||
{layer &&
|
||||
createPortal(<div data-floating="" data-testid={`${name}-layer`} />, document.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggle = (name: string) => fireEvent.click(screen.getByRole("button", { name }));
|
||||
const isOpen = (name: string) => !!screen.queryByTestId(`${name}-panel`);
|
||||
|
||||
describe("useClickOutside", () => {
|
||||
it("closes on a press outside, and not on one inside it or a floating layer it opened", () => {
|
||||
render(
|
||||
<>
|
||||
<Drop name="A" layer />
|
||||
<div data-testid="out" />
|
||||
</>,
|
||||
);
|
||||
toggle("A");
|
||||
fireEvent.pointerDown(screen.getByTestId("A-panel"));
|
||||
fireEvent.pointerDown(screen.getByTestId("A-layer"));
|
||||
expect(isOpen("A")).toBe(true);
|
||||
fireEvent.pointerDown(screen.getByTestId("out"));
|
||||
expect(isOpen("A")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes on a press elsewhere in the floating dialog card that holds it", () => {
|
||||
render(
|
||||
<div data-floating="" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Drop name="A" />
|
||||
<div data-testid="card-body" />
|
||||
</div>,
|
||||
);
|
||||
toggle("A");
|
||||
fireEvent.pointerDown(screen.getByTestId("card-body"));
|
||||
expect(isOpen("A")).toBe(false);
|
||||
});
|
||||
|
||||
it("takes Escape for the innermost dropdown only, and keeps it from the dialog around it", () => {
|
||||
let dialogSawEscape = false;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dialogSawEscape = true;
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
render(
|
||||
<Drop name="Outer">
|
||||
<Drop name="Inner" />
|
||||
</Drop>,
|
||||
);
|
||||
toggle("Outer");
|
||||
toggle("Inner");
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect([isOpen("Outer"), isOpen("Inner")]).toEqual([true, false]);
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(isOpen("Outer")).toBe(false);
|
||||
expect(dialogSawEscape).toBe(false);
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(dialogSawEscape).toBe(true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
it("leaves Escape to a confirm opened above it, and still takes it inside a modal", () => {
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<Drop name="A" />
|
||||
<div role="alertdialog" />
|
||||
</>,
|
||||
);
|
||||
toggle("A");
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(isOpen("A")).toBe(true);
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<div aria-modal="true">
|
||||
<Drop name="B" />
|
||||
</div>,
|
||||
);
|
||||
toggle("B");
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(isOpen("B")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the open dropdown when another one opens", () => {
|
||||
render(
|
||||
<>
|
||||
<Drop name="A" />
|
||||
<Drop name="B" />
|
||||
</>,
|
||||
);
|
||||
toggle("A");
|
||||
toggle("B");
|
||||
expect([isOpen("A"), isOpen("B")]).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("closes on a press inside an iframe, which only blurs the window", async () => {
|
||||
render(
|
||||
<>
|
||||
<Drop name="A" />
|
||||
<iframe title="body" />
|
||||
</>,
|
||||
);
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
toggle("A");
|
||||
// Leaving the tab is not a press inside the page.
|
||||
fireEvent.blur(window);
|
||||
await settle();
|
||||
expect(isOpen("A")).toBe(true);
|
||||
screen.getByTitle("body").focus();
|
||||
fireEvent.blur(window);
|
||||
await settle();
|
||||
expect(isOpen("A")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// The one way a dropdown, popover or picker closes itself: a press anywhere
|
||||
// outside it, Escape, or another one opening. Every floating layer in the
|
||||
// dashboard goes through this so they all behave the same.
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
type ElRef = RefObject<HTMLElement | null>;
|
||||
|
||||
// Every open layer, oldest first. Opening one closes the others unless it was
|
||||
// opened from inside them, and Escape closes only the newest.
|
||||
interface Layer {
|
||||
contains: (node: Node) => boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
const FOCUSABLE = "button:not(:disabled), [href], input:not(:disabled), select, textarea, [tabindex]:not([tabindex=\"-1\"])";
|
||||
|
||||
/**
|
||||
* Close an open floating layer on a press outside `inside`, on Escape (newest
|
||||
* layer first), and when another layer opens outside it. `inside[0]` is where
|
||||
* the layer was opened from (its trigger, or a wrapper holding trigger and
|
||||
* panel). A portaled panel marked `[data-floating]` counts as inside it.
|
||||
*/
|
||||
export default function useClickOutside(open: boolean, onClose: () => void, inside: ElRef | ElRef[]) {
|
||||
// Through refs: callers pass inline closures and ref arrays, and re-running
|
||||
// the effect on every render would reorder the stack.
|
||||
const dismiss = useRef(onClose);
|
||||
dismiss.current = onClose;
|
||||
const refs = useRef<ElRef[]>([]);
|
||||
refs.current = Array.isArray(inside) ? inside : [inside];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const contains = (node: Node) => refs.current.some((r) => r.current?.contains(node));
|
||||
const self: Layer = { contains, close: () => dismiss.current() };
|
||||
|
||||
// The stack is one chain of nested layers: the one holding the origin and
|
||||
// everything below it stay, everything above it closes. Opened from inside
|
||||
// a floating panel the stack cannot see, it closes nothing.
|
||||
const origin = refs.current[0]?.current ?? document.activeElement;
|
||||
let parent = layers.length - 1;
|
||||
while (parent >= 0 && !(origin && layers[parent].contains(origin))) parent--;
|
||||
const nested = parent < 0 && !!origin?.closest?.("[data-floating]");
|
||||
if (!nested) for (const other of layers.slice(parent + 1)) other.close();
|
||||
layers.push(self);
|
||||
|
||||
// A floating layer this one opened (its own portaled panel, a calendar, a
|
||||
// nested menu) is inside it; the panel or dialog holding it is not.
|
||||
const isInside = (node: Node) => {
|
||||
if (contains(node)) return true;
|
||||
const floating = (node as Element).closest?.("[data-floating]");
|
||||
return !!floating && !refs.current.some((r) => r.current && floating.contains(r.current));
|
||||
};
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!isInside(e.target as Node)) self.close();
|
||||
};
|
||||
// Escape takes the innermost layer only, and stops there so the dialog or
|
||||
// drawer holding it stays open. Focus inside goes back to where it was opened.
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" || layers[layers.length - 1] !== self) return;
|
||||
// A modal opened above this layer (the confirm, a dialog) owns Escape first.
|
||||
const modals = document.querySelectorAll('[role="alertdialog"], [aria-modal="true"]');
|
||||
const above = Array.from(modals).some((m) => !refs.current.some((r) => r.current && m.contains(r.current)));
|
||||
if (above) return;
|
||||
e.stopPropagation();
|
||||
const active = document.activeElement;
|
||||
const origin = refs.current[0]?.current;
|
||||
self.close();
|
||||
if (!origin?.isConnected || !active || !isInside(active)) return;
|
||||
const target = origin.matches(FOCUSABLE) ? origin : origin.querySelector<HTMLElement>(FOCUSABLE);
|
||||
target?.focus({ preventScroll: true });
|
||||
};
|
||||
// A press inside an iframe (an email body) never reaches this document; it only blurs the window.
|
||||
let blurTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onBlur = () => {
|
||||
blurTimer = setTimeout(() => {
|
||||
if (document.activeElement?.tagName === "IFRAME") self.close();
|
||||
});
|
||||
};
|
||||
// Capture phase: dialogs stop mousedown propagation on their card so the
|
||||
// backdrop does not close them, which would otherwise swallow this too.
|
||||
// Pointer events so a tap closes it on touch screens as well.
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
clearTimeout(blurTimer);
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
const at = layers.indexOf(self);
|
||||
if (at >= 0) layers.splice(at, 1);
|
||||
};
|
||||
}, [open]);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { RefObject} from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Calls handler when a click happens outside the element referenced by ref.
|
||||
* Optionally pass a second ref if the trigger button itself should not count as “outside”.
|
||||
*/
|
||||
export default function useClickOutside(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
handler: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
|
||||
// Ignore clicks inside any portaled floating layer (a date-picker calendar,
|
||||
// a SelectMenu, a nested popover). Those render to <body>, outside `ref`, so
|
||||
// without this a click into a popover this element opened would dismiss it.
|
||||
const el = event.target as Element | null;
|
||||
if (el && typeof el.closest === "function" && el.closest("[data-floating]")) return;
|
||||
|
||||
handler();
|
||||
};
|
||||
|
||||
// Capture phase so we still fire inside containers that call
|
||||
// stopPropagation() on mousedown (e.g. the contact edit slide-over,
|
||||
// which stops bubbling so clicks inside the panel don't dismiss the
|
||||
// overlay). Bubble-phase listeners never reach document in that case.
|
||||
document.addEventListener('mousedown', listener, true);
|
||||
document.addEventListener('touchstart', listener, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', listener, true);
|
||||
document.removeEventListener('touchstart', listener, true);
|
||||
};
|
||||
}, [ref, handler]);
|
||||
}
|
||||
Reference in New Issue
Block a user