mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): let inbox title and message author names truncate under narrow panes (#7550)
## Summary Fixes two instances of the same dead-truncate pattern in the desktop app, where a flex item's implicit `min-width: auto` prevented `truncate` from engaging, so long text painted over adjacent controls instead of ellipsizing: - **Inbox detail title** (`InboxDetailPane.tsx`): the clickable context-title button sized to its text instead of shrinking with the pane, overlapping the header controls (open-in-channel, members, huddle, more menu). Fixed by adding `max-w-full`. - **Message author names** (`MessageHeader.tsx` / `UserProfilePopover.tsx`): the `UserProfilePopover` inline-flex trigger wrapper refused to shrink below the name's nowrap width, running long author names under the hover action bar and off the pane edge. Fixed by adding a `triggerClassName` prop to `UserProfilePopover` and passing `min-w-0 max-w-full` at the author call site. Two other suspected instances (project file breadcrumb, drafts pane title) were stress-tested and already truncate correctly — no change. ### Related issue N/A — none found. ### Testing - New Playwright regression tests for both fixes (`inbox-title-overlap.spec.ts`, `message-author-overlap.spec.ts`, registered in the smoke project), each proven to discriminate: they fail with the fix reverted (real measured overlap) and assert the ellipsis actually engages with non-zero title width, so they can't pass vacuously. - Typecheck, lint, and full desktop unit suite green (pre-push hooks); full desktop e2e smoke suite run earlier: 1402 passed, 3 pre-existing unrelated failures (each fails identically with the fix reverted). **Inbox title — before** (long title paints under the header controls):  **Inbox title — after** (truncates with ellipsis, controls stay clear):  **Author name — before** (long name runs past the header row edge):  **Author name — after** (clean cutoff):  🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: cynfria <yescynthia@gmail.com> Signed-off-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -142,6 +142,8 @@ export default defineConfig({
|
||||
"**/drafts-screenshots.spec.ts",
|
||||
"**/drafts-all-fix-screenshots.spec.ts",
|
||||
"**/inbox-refactor-screenshots.spec.ts",
|
||||
"**/inbox-title-overlap.spec.ts",
|
||||
"**/message-author-overlap.spec.ts",
|
||||
"**/buzz-theme-screenshots.spec.ts",
|
||||
"**/appearance-previews.spec.ts",
|
||||
"**/channel-sort.spec.ts",
|
||||
|
||||
@@ -567,7 +567,7 @@ function InboxMessageDetailPane({
|
||||
{canOpenChannel && contextChannelId ? (
|
||||
<h2 className="min-w-0">
|
||||
<button
|
||||
className="block min-w-0 text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
className="block min-w-0 max-w-full text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-testid="home-inbox-context-title"
|
||||
onClick={() =>
|
||||
onOpenContext(
|
||||
|
||||
@@ -396,6 +396,7 @@ function isCustomEmojiShortcode(emoji: string) {
|
||||
export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
channelId,
|
||||
message,
|
||||
ref,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onFollowThread,
|
||||
@@ -417,6 +418,9 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
* action is hidden (callers like the home inbox that lack the context). */
|
||||
channelId?: string | null;
|
||||
message: TimelineMessage;
|
||||
/** Attached to the root element so hosts can measure the rail's rendered
|
||||
* footprint (e.g. to reserve its width in the message-header layout). */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onFollowThread?: (message: TimelineMessage) => void;
|
||||
@@ -514,6 +518,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
: "",
|
||||
)}
|
||||
data-testid={`message-action-bar-${message.id}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85">
|
||||
<div className="flex items-center gap-0.5 p-1">
|
||||
|
||||
@@ -129,6 +129,7 @@ export function MessageAuthorIdentity({
|
||||
pubkey={pubkey}
|
||||
role={role}
|
||||
botIdenticonValue={displayName}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
>
|
||||
<button
|
||||
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@/shared/constants/kinds";
|
||||
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
@@ -214,6 +215,19 @@ export const MessageRow = React.memo(
|
||||
} = useReactionHandler(message, onToggleReaction);
|
||||
const { openReminder, activeReminderEventIds } = useRemindLater();
|
||||
const hasActiveReminder = activeReminderEventIds.has(message.id);
|
||||
// The hover/focus action rail is absolutely positioned over the row, so it
|
||||
// takes no layout space of its own. Measure its rendered footprint and
|
||||
// reserve it in the message-header row (see `headerNode`) so a long author
|
||||
// name ellipsizes before the rail instead of painting underneath it. The
|
||||
// reservation is unconditional — the rail appears on hover AND
|
||||
// focus-within, and padding must not reflow the header mid-interaction.
|
||||
const articleRef = React.useRef<HTMLElement | null>(null);
|
||||
const actionRailMeasureRef = useMeasuredCssVariable({
|
||||
cssVariable: "--message-action-rail-width",
|
||||
dimension: "inline",
|
||||
resetValue: "0px",
|
||||
targetRef: articleRef,
|
||||
});
|
||||
const handleRemindLater = React.useCallback(
|
||||
(msg: TimelineMessage) => {
|
||||
openReminder({
|
||||
@@ -565,6 +579,7 @@ export const MessageRow = React.memo(
|
||||
>
|
||||
<MessageActionBar
|
||||
channelId={channelId}
|
||||
ref={actionRailMeasureRef}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isUnread={isUnread}
|
||||
message={message}
|
||||
@@ -639,7 +654,9 @@ export const MessageRow = React.memo(
|
||||
) : null;
|
||||
|
||||
const headerNode = isDisplayedAsContinuation ? null : (
|
||||
<MessageHeaderRow>
|
||||
// pe reserves the measured action-rail footprint (0px until measured) so
|
||||
// header content ends before the rail's left edge in every rail state.
|
||||
<MessageHeaderRow className="pe-[var(--message-action-rail-width,0px)]">
|
||||
{message.pubkey ? (
|
||||
<MessageAuthorWithIndicators
|
||||
authorName={message.author}
|
||||
@@ -892,6 +909,7 @@ export const MessageRow = React.memo(
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
onAnimationEnd={handleEntranceAnimationEnd}
|
||||
ref={articleRef}
|
||||
>
|
||||
{isThreadReplyLayout ? (
|
||||
<>
|
||||
|
||||
@@ -49,6 +49,13 @@ type UserProfilePopoverProps = {
|
||||
children: React.ReactNode;
|
||||
pubkey: string;
|
||||
triggerElement?: "div" | "span";
|
||||
/**
|
||||
* Extra classes for the inline-flex trigger wrapper. The wrapper is a flex
|
||||
* item at most call sites, and its `min-width: auto` refuses to shrink below
|
||||
* the nowrap width of truncating children — pass `min-w-0 max-w-full` when
|
||||
* the trigger wraps truncating text so the ellipsis can engage.
|
||||
*/
|
||||
triggerClassName?: string;
|
||||
/** Accessible name for interactive trigger content that is visually hidden. */
|
||||
triggerAriaLabel?: string;
|
||||
/** Set false when the trigger is inside another interactive control. */
|
||||
@@ -129,6 +136,7 @@ export function UserProfilePopover({
|
||||
children,
|
||||
pubkey,
|
||||
triggerElement = "div",
|
||||
triggerClassName,
|
||||
triggerAriaLabel,
|
||||
enableProfilePanel = true,
|
||||
enableHoverPopover = true,
|
||||
@@ -213,6 +221,7 @@ export function UserProfilePopover({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"inline-flex",
|
||||
triggerClassName,
|
||||
canOpenProfilePanel && "cursor-pointer [&_*]:cursor-pointer",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -29,3 +29,35 @@ export function observeElementBlockSize(
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the inline size from a ResizeObserver entry, preferring border-box
|
||||
* size when available.
|
||||
*/
|
||||
export function readElementInlineSize(entry: ResizeObserverEntry): number {
|
||||
return entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes `onSize` immediately and whenever the observed element's inline size
|
||||
* changes. Returns a cleanup function that disconnects the observer.
|
||||
*/
|
||||
export function observeElementInlineSize(
|
||||
element: HTMLElement,
|
||||
onSize: (size: number) => void,
|
||||
): () => void {
|
||||
onSize(element.getBoundingClientRect().width);
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
onSize(readElementInlineSize(entry));
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { observeElementBlockSize } from "./observeElementBlockSize";
|
||||
import {
|
||||
observeElementBlockSize,
|
||||
observeElementInlineSize,
|
||||
} from "./observeElementBlockSize";
|
||||
|
||||
type UseMeasuredCssVariableArgs = {
|
||||
cssVariable: string;
|
||||
/** Which box dimension to observe. Defaults to `"block"` (height). */
|
||||
dimension?: "block" | "inline";
|
||||
enabled?: boolean;
|
||||
resetKey?: unknown;
|
||||
resetValue: string;
|
||||
@@ -11,9 +16,9 @@ type UseMeasuredCssVariableArgs = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Observes an element's block size and writes it as a CSS custom property on a
|
||||
* target element. Uses `useLayoutEffect` so the first measurement happens
|
||||
* before paint.
|
||||
* Observes an element's block (or inline) size and writes it as a CSS custom
|
||||
* property on a target element. Uses `useLayoutEffect` so the first
|
||||
* measurement happens before paint.
|
||||
*
|
||||
* Returns a callback ref for the source element. Attach it instead of a ref
|
||||
* object so the measurement re-runs when the source mounts later than this
|
||||
@@ -22,6 +27,7 @@ type UseMeasuredCssVariableArgs = {
|
||||
export function useMeasuredCssVariable({
|
||||
targetRef,
|
||||
cssVariable,
|
||||
dimension = "block",
|
||||
resetValue,
|
||||
resetKey,
|
||||
enabled = true,
|
||||
@@ -53,13 +59,25 @@ export function useMeasuredCssVariable({
|
||||
targetEl.style.setProperty(cssVariable, `${px}px`);
|
||||
};
|
||||
|
||||
const disconnect = observeElementBlockSize(sourceEl, applySize);
|
||||
const observe =
|
||||
dimension === "inline"
|
||||
? observeElementInlineSize
|
||||
: observeElementBlockSize;
|
||||
const disconnect = observe(sourceEl, applySize);
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
targetEl.style.setProperty(cssVariable, resetValue);
|
||||
};
|
||||
}, [sourceEl, targetRef, cssVariable, resetValue, resetKey, enabled]);
|
||||
}, [
|
||||
sourceEl,
|
||||
targetRef,
|
||||
cssVariable,
|
||||
dimension,
|
||||
resetValue,
|
||||
resetKey,
|
||||
enabled,
|
||||
]);
|
||||
|
||||
return setSourceEl;
|
||||
}
|
||||
|
||||
146
desktop/tests/e2e/inbox-title-overlap.spec.ts
Normal file
146
desktop/tests/e2e/inbox-title-overlap.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Regression test: a long inbox detail title must truncate instead of
|
||||
* running underneath the header controls (open-in-channel, members,
|
||||
* huddle, more menu) when the detail pane is narrow.
|
||||
*
|
||||
* Run: pnpm build:e2e && pnpm exec playwright test --project=smoke \
|
||||
* tests/e2e/inbox-title-overlap.spec.ts
|
||||
* Output: test-results/inbox-title-overlap/
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import type { RelayEvent } from "../../src/shared/api/types";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/inbox-title-overlap";
|
||||
|
||||
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
|
||||
|
||||
type MockWindow = Window & {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
pubkey?: string;
|
||||
mentionPubkeys?: string[];
|
||||
id?: string;
|
||||
}) => RelayEvent;
|
||||
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
|
||||
category: "mention" | "needs_action" | "activity" | "agent_activity";
|
||||
channel_id: string | null;
|
||||
channel_name: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
id: string;
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
tags: string[][];
|
||||
}) => unknown;
|
||||
};
|
||||
|
||||
test.describe("inbox detail title overflow", () => {
|
||||
// Narrow window matching the report; the list pane is widened to its
|
||||
// resizable maximum so the detail pane is squeezed to its 300px minimum
|
||||
// and "Message in #engineering" cannot fit next to the header controls.
|
||||
test.use({ viewport: { width: 1046, height: 838 } });
|
||||
|
||||
test("long title truncates instead of overlapping header controls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
window.sessionStorage.setItem(
|
||||
"buzz.desktop.home-inbox-list-width",
|
||||
"520",
|
||||
);
|
||||
});
|
||||
await installMockBridge(page, { mode: "mock" });
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("home-inbox-list")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const win = window as MockWindow;
|
||||
return (
|
||||
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
||||
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
|
||||
);
|
||||
});
|
||||
|
||||
const mentionId = "ab".repeat(32);
|
||||
await page.evaluate(
|
||||
({ channelId, currentPubkey, id, senderPubkey }) => {
|
||||
const win = window as MockWindow;
|
||||
const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||||
const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
|
||||
if (!emit || !push) {
|
||||
throw new Error("Mock bridge helpers are not installed.");
|
||||
}
|
||||
const event = emit({
|
||||
channelName: "engineering",
|
||||
content:
|
||||
"Hey, can you look at the release checklist before tomorrow?",
|
||||
id,
|
||||
mentionPubkeys: [currentPubkey],
|
||||
pubkey: senderPubkey,
|
||||
});
|
||||
push({
|
||||
category: "mention",
|
||||
channel_id: channelId,
|
||||
channel_name: "engineering",
|
||||
content: event.content,
|
||||
created_at: event.created_at,
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
tags: event.tags,
|
||||
});
|
||||
},
|
||||
{
|
||||
channelId: ENGINEERING_CHANNEL_ID,
|
||||
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
|
||||
id: mentionId,
|
||||
senderPubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
},
|
||||
);
|
||||
|
||||
const row = page.getByTestId(`home-inbox-item-${mentionId}`);
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
|
||||
const detail = page.getByTestId("home-inbox-detail");
|
||||
await expect(detail).toBeVisible();
|
||||
const title = detail.getByTestId("home-inbox-context-title");
|
||||
await expect(title).toHaveText("Message in #engineering");
|
||||
await waitForAnimations(page);
|
||||
|
||||
const titleBox = await title.boundingBox();
|
||||
const controlsBox = await detail
|
||||
.getByTestId("home-inbox-open-context")
|
||||
.boundingBox();
|
||||
if (!titleBox || !controlsBox) {
|
||||
throw new Error("Header title or controls not rendered.");
|
||||
}
|
||||
|
||||
// Guard against a vacuous pass: the title must still render with real
|
||||
// width, and the squeeze must actually engage — the inner span's
|
||||
// ellipsis is active only when its content overflows. If layout
|
||||
// constants or fonts ever change so the full title fits, this fails
|
||||
// loudly instead of silently no longer testing truncation.
|
||||
expect(titleBox.width).toBeGreaterThan(0);
|
||||
const isTruncated = await title.evaluate((element) => {
|
||||
const span = element.querySelector("span");
|
||||
return span ? span.scrollWidth > span.clientWidth : false;
|
||||
});
|
||||
expect(isTruncated).toBe(true);
|
||||
|
||||
await page.screenshot({
|
||||
clip: { height: 96, width: 1046, x: 0, y: 0 },
|
||||
path: `${SHOTS}/01-header.png`,
|
||||
});
|
||||
|
||||
// The title's right edge must stop short of the first header control.
|
||||
expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(controlsBox.x + 1);
|
||||
});
|
||||
});
|
||||
159
desktop/tests/e2e/message-author-overlap.spec.ts
Normal file
159
desktop/tests/e2e/message-author-overlap.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Regression test: a long message-author display name must truncate instead
|
||||
* of running past the message header into the timeline edge and the hover
|
||||
* action bar. The author button sits inside the UserProfilePopover trigger
|
||||
* wrapper — a flex item whose `min-width: auto` refused to shrink below the
|
||||
* nowrap width of the name, so the button's `truncate` never engaged.
|
||||
*
|
||||
* Run: pnpm build:e2e && pnpm exec playwright test --project=smoke \
|
||||
* tests/e2e/message-author-overlap.spec.ts
|
||||
* Output: test-results/message-author-overlap/
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/message-author-overlap";
|
||||
|
||||
// Long single-word name: no break opportunities, so the nowrap min-content
|
||||
// width far exceeds the header row at a 900px viewport.
|
||||
const LONG_AUTHOR_NAME =
|
||||
"Alexandrina-Wolfeschlegelsteinhausenbergerdorff-Keihanaikukauakahihuliheekahaunaele-The-Magnificent-Third";
|
||||
|
||||
const MESSAGE_CONTENT = "Author-name truncation regression probe";
|
||||
|
||||
test.describe("message author name overflow", () => {
|
||||
test.use({ viewport: { width: 900, height: 700 } });
|
||||
|
||||
test("long author name truncates instead of overflowing the header row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
mode: "mock",
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
displayName: LONG_AUTHOR_NAME,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
await page.evaluate(
|
||||
({ pubkey, content }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content,
|
||||
pubkey,
|
||||
});
|
||||
},
|
||||
{ content: MESSAGE_CONTENT, pubkey: TEST_IDENTITIES.alice.pubkey },
|
||||
);
|
||||
|
||||
const row = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: MESSAGE_CONTENT });
|
||||
await expect(row).toBeVisible();
|
||||
const author = row.getByTestId("message-author");
|
||||
await expect(author).toHaveText(LONG_AUTHOR_NAME);
|
||||
await waitForAnimations(page);
|
||||
|
||||
// The ellipsis carrier is the popover-trigger <button> around the author
|
||||
// span — `truncate` on the inline span itself cannot clip.
|
||||
const button = author.locator("xpath=ancestor::button[1]");
|
||||
await expect(button).toBeVisible();
|
||||
|
||||
const buttonBox = await button.boundingBox();
|
||||
const headerBox = await row.getByTestId("message-header").boundingBox();
|
||||
if (!buttonBox || !headerBox) {
|
||||
throw new Error("Author button or message header not rendered.");
|
||||
}
|
||||
|
||||
// Guard against a vacuous pass: the author must still render with real
|
||||
// width, and the squeeze must actually engage — the button's ellipsis is
|
||||
// active only when its content overflows. If layout constants or fonts
|
||||
// ever change so the full name fits, this fails loudly instead of
|
||||
// silently no longer testing truncation.
|
||||
expect(buttonBox.width).toBeGreaterThan(0);
|
||||
const isTruncated = await button.evaluate(
|
||||
(element) => element.scrollWidth > element.clientWidth,
|
||||
);
|
||||
expect(isTruncated).toBe(true);
|
||||
|
||||
await page.screenshot({
|
||||
clip: {
|
||||
height: 72,
|
||||
width: 900,
|
||||
x: 0,
|
||||
y: Math.max(0, headerBox.y - 12),
|
||||
},
|
||||
path: `${SHOTS}/01-author-header.png`,
|
||||
});
|
||||
|
||||
// The name's right edge must stay inside the header row instead of
|
||||
// running under the pane edge / hover action bar.
|
||||
expect(buttonBox.x + buttonBox.width).toBeLessThanOrEqual(
|
||||
headerBox.x + headerBox.width + 1,
|
||||
);
|
||||
|
||||
// The action rail (reactions/reply controls) is absolutely positioned OVER
|
||||
// the row and appears on hover and on focus-within. In both states the
|
||||
// author's painted box must end before the rail's left edge — a name that
|
||||
// merely stays inside the header can still be painted over by the rail.
|
||||
const actionBar = row.locator('[data-testid^="message-action-bar-"]');
|
||||
|
||||
const expectAuthorClearOfActionBar = async (state: string) => {
|
||||
// Playwright's toBeVisible() passes at opacity 0; the rail's shown/hidden
|
||||
// states are driven by opacity, so assert the computed style directly.
|
||||
await expect(actionBar).toHaveCSS("opacity", "1");
|
||||
const authorBox = await button.boundingBox();
|
||||
const actionBarBox = await actionBar.boundingBox();
|
||||
if (!authorBox || !actionBarBox) {
|
||||
throw new Error(`Author or action bar not rendered on ${state}.`);
|
||||
}
|
||||
expect(actionBarBox.width).toBeGreaterThan(0);
|
||||
// The squeeze must still be a real ellipsis, not a vacuous fit.
|
||||
const stillTruncated = await button.evaluate(
|
||||
(element) => element.scrollWidth > element.clientWidth,
|
||||
);
|
||||
expect(stillTruncated).toBe(true);
|
||||
expect(authorBox.x + authorBox.width).toBeLessThanOrEqual(actionBarBox.x);
|
||||
};
|
||||
|
||||
// Pointer hover.
|
||||
await row.hover();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
clip: {
|
||||
height: 72,
|
||||
width: 900,
|
||||
x: 0,
|
||||
y: Math.max(0, headerBox.y - 12),
|
||||
},
|
||||
path: `${SHOTS}/02-author-header-hovered.png`,
|
||||
});
|
||||
await expectAuthorClearOfActionBar("hover");
|
||||
|
||||
// Reset the pointer, then re-reveal the rail via keyboard focus-within.
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actionBar).toHaveCSS("opacity", "0");
|
||||
await button.focus();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
clip: {
|
||||
height: 72,
|
||||
width: 900,
|
||||
x: 0,
|
||||
y: Math.max(0, headerBox.y - 12),
|
||||
},
|
||||
path: `${SHOTS}/03-author-header-focused.png`,
|
||||
});
|
||||
await expectAuthorClearOfActionBar("focus-within");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user