diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 15190d4b..40d6ad7c 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -1625,7 +1625,10 @@ function LeadStatusPill({ lead }: { lead?: ContactCampaignProgress | null }) { )} {meta.label} - + {/* The reason, when there is one, is worth more than the word it + covers — and React owning the attribute is what clears any word + the tooltip helper left here before the lead changed state. */} + {meta.label} diff --git a/web/src/lib/helper/clippedTitle.test.ts b/web/src/lib/helper/clippedTitle.test.ts index fb713232..825058ed 100644 --- a/web/src/lib/helper/clippedTitle.test.ts +++ b/web/src/lib/helper/clippedTitle.test.ts @@ -58,6 +58,19 @@ describe("clippedTitle", () => { expect(el.getAttribute("title")).toBe("Could not send: mailbox rejected the recipient"); }); + // A caller can start supplying a title after we have already titled the + // element, so ownership has to be judged on the value, not the element. + it("stands down when a caller titles an element it had titled", () => { + const el = span("Queued", 420, 160); + fire(clippedTitle.onMouseEnter, el); + expect(el.getAttribute("title")).toBe("Queued"); + el.title = "Could not send: mailbox rejected the recipient"; + fire(clippedTitle.onMouseEnter, el); + expect(el.getAttribute("title")).toBe("Could not send: mailbox rejected the recipient"); + fire(clippedTitle.onMouseLeave, el); + expect(el.getAttribute("title")).toBe("Could not send: mailbox rejected the recipient"); + }); + // Nothing removes the attribute on re-render, so it must not outlive the // hover that set it: a row whose text changes underneath a resting cursor // would otherwise show the previous value. diff --git a/web/src/lib/helper/clippedTitle.ts b/web/src/lib/helper/clippedTitle.ts index 213ad14a..9fa11d96 100644 --- a/web/src/lib/helper/clippedTitle.ts +++ b/web/src/lib/helper/clippedTitle.ts @@ -7,19 +7,22 @@ import type { MouseEvent } from "react"; -// Only ever remove a title this module put there. An element with its own -// `title` prop keeps it: React rewrites that attribute only when the prop value -// changes, so taking it away once would lose it for good. -const ours = new WeakSet(); +// The exact string this module last wrote on an element. Anything else in the +// attribute belongs to the caller and is left alone: React rewrites a `title` +// prop only when its value changes, so clobbering it once would lose it for +// good. Matching on the value rather than the element matters because a caller +// can start supplying a title after we have already titled it. +const ours = new WeakMap(); function titleWhenClipped(e: MouseEvent) { const el = e.currentTarget; - if (el.hasAttribute("title") && !ours.has(el)) return; + const current = el.getAttribute("title"); + if (current !== null && current !== ours.get(el)) return; const text = el.textContent ?? ""; if (text && el.scrollWidth > el.clientWidth) { el.title = text; - ours.add(el); - } else if (ours.has(el)) { + ours.set(el, text); + } else if (current !== null) { el.removeAttribute("title"); ours.delete(el); } @@ -27,7 +30,7 @@ function titleWhenClipped(e: MouseEvent) { function clearTitle(e: MouseEvent) { const el = e.currentTarget; - if (!ours.has(el)) return; + if (el.getAttribute("title") !== ours.get(el)) return; el.removeAttribute("title"); ours.delete(el); }