@@ -1297,24 +1321,31 @@ function ContactsTableBody({
onChange={() => onToggle(c.id, !isSel)}
/>
- |
+ |
{(c.first_name || c.email)?.slice(0, 2).toUpperCase()}
-
+ {/* flex-1, not shrink-to-fit: the chip cap below is a
+ percentage, so this has to be the column's width and
+ not the name's. */}
+
- {name}
+ {name}
+ {/* Neither side of this line may eat the other: shrink-0 chips
+ take a fixed-width Name cell whole and leave no name, while
+ a freely shrinking group collapses to bare colour dots. Cap
+ the tags at 45% and let both ellipsize into their tooltip. */}
{c.categories && c.categories.length > 0 && (
-
+
{c.categories.slice(0, 2).map((cat) => (
-
+
))}
{c.categories.length > 2 && (
x.title).join(", ")}
>
+{c.categories.length - 2}
@@ -1325,33 +1356,39 @@ function ContactsTableBody({
- {c.email}
+ {c.email}
|
-
+ |
{c.company ? (
-
-
- {c.company}
-
+
+
+
+ {c.company}
+
+
) : (
—
)}
|
-
- {c.phone ? (
-
-
- {c.phone}
-
- ) : (
- —
- )}
- |
-
+ {!embedded && (
+ |
+ {c.phone ? (
+
+ ) : (
+ —
+ )}
+ |
+ )}
+
{embedded ? (
) : (
@@ -1383,11 +1420,11 @@ function ContactsTableBody({
)}
{embedded ? (
<>
- |
+ |
{lead?.current_step ? (
Not started
)}
|
-
+ |
{lead?.sender ? (
>
) : (
- |
+ |
{c.campaigns?.length ?? 0}
|
)}
-
+ |
{embedded
? lead?.last_activity_at
? new Date(lead.last_activity_at).toLocaleDateString("en-US", {
@@ -1477,7 +1514,7 @@ function ContactsTableBody({
function Th({ children, className }: { children: React.ReactNode; className?: string }) {
return (
|
{children}
|
@@ -1487,16 +1524,16 @@ function Th({ children, className }: { children: React.ReactNode; className?: st
function StatusPill({ subscribed }: { subscribed: boolean }) {
if (subscribed) {
return (
-
-
- subscribed
+
+
+ subscribed
);
}
return (
-
-
- unsubscribed
+
+
+ unsubscribed
);
}
@@ -1519,7 +1556,7 @@ function EngagementCell({
auto?: boolean;
}) {
return (
-
+ |
{n > 0 ? (
{status === "active" ? (
@@ -1583,7 +1620,9 @@ function LeadStatusPill({ lead }: { lead?: ContactCampaignProgress | null }) {
) : (
)}
- {meta.label}
+
+ {meta.label}
+
);
}
diff --git a/web/src/lib/helper/titleWhenClipped.test.ts b/web/src/lib/helper/titleWhenClipped.test.ts
new file mode 100644
index 00000000..26580380
--- /dev/null
+++ b/web/src/lib/helper/titleWhenClipped.test.ts
@@ -0,0 +1,55 @@
+// Issue #461: long cell text is truncated, and the full value has to be
+// reachable on hover — but only where it was actually cut off.
+
+import { describe, it, expect } from "vitest";
+import clippedTitle from "./titleWhenClipped";
+
+function span(text: string, scrollWidth: number, clientWidth: number) {
+ const el = document.createElement("span");
+ el.textContent = text;
+ Object.defineProperty(el, "scrollWidth", { value: scrollWidth });
+ Object.defineProperty(el, "clientWidth", { value: clientWidth });
+ return el;
+}
+
+type Handler = (typeof clippedTitle)["onMouseEnter"];
+const fire = (h: Handler, el: HTMLElement) =>
+ h({ currentTarget: el } as unknown as Parameters[0]);
+
+describe("clippedTitle", () => {
+ it("titles text that overflows its column", () => {
+ const el = span("Some Extremely Long Company Name Ltd", 420, 160);
+ fire(clippedTitle.onMouseEnter, el);
+ expect(el.getAttribute("title")).toBe("Some Extremely Long Company Name Ltd");
+ });
+
+ it("leaves text that fits untitled", () => {
+ const el = span("Acme", 40, 160);
+ fire(clippedTitle.onMouseEnter, el);
+ expect(el.hasAttribute("title")).toBe(false);
+ });
+
+ it("drops a stale title once the column is wide enough", () => {
+ const el = span("Acme", 40, 160);
+ el.title = "Acme Corporation Holdings International";
+ fire(clippedTitle.onMouseEnter, el);
+ expect(el.hasAttribute("title")).toBe(false);
+ });
+
+ it("says nothing for an empty cell", () => {
+ const el = span("", 0, 0);
+ fire(clippedTitle.onMouseEnter, el);
+ expect(el.hasAttribute("title")).toBe(false);
+ });
+
+ // 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.
+ it("takes the title back off on the way out", () => {
+ const el = span("Some Extremely Long Company Name Ltd", 420, 160);
+ fire(clippedTitle.onMouseEnter, el);
+ expect(el.hasAttribute("title")).toBe(true);
+ fire(clippedTitle.onMouseLeave, el);
+ expect(el.hasAttribute("title")).toBe(false);
+ });
+});
diff --git a/web/src/lib/helper/titleWhenClipped.ts b/web/src/lib/helper/titleWhenClipped.ts
new file mode 100644
index 00000000..be20891c
--- /dev/null
+++ b/web/src/lib/helper/titleWhenClipped.ts
@@ -0,0 +1,27 @@
+// Spread `clippedTitle` onto a `truncate`d element: it attaches the full text as
+// a native tooltip only where the text is actually cut off, so a column that
+// fits stays quiet. Hover is early enough; the browser reads `title` when it
+// decides to show the tip, long after mouseenter fires. The title is dropped
+// again on the way out, because React does not manage an attribute it was never
+// given and would otherwise leave a stale one behind on the next render.
+
+import type { MouseEvent } from "react";
+
+function titleWhenClipped(e: MouseEvent) {
+ const el = e.currentTarget;
+ const text = el.textContent ?? "";
+ if (text && el.scrollWidth > el.clientWidth) el.title = text;
+ else el.removeAttribute("title");
+}
+
+function clearTitle(e: MouseEvent) {
+ e.currentTarget.removeAttribute("title");
+}
+
+// One frozen object so spreading it in a 50-row table allocates nothing.
+const clippedTitle = Object.freeze({
+ onMouseEnter: titleWhenClipped,
+ onMouseLeave: clearTitle,
+});
+
+export default clippedTitle;
|