mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
fix(composer): wrap Buzz chip labels without orphaning icons (#6581)
**Category:** fix **User Impact:** Long Buzz link chips now wrap without overflowing in composers and sent messages; icon-bearing stubs use at most five graphemes when no earlier separator exists, so some sent chips attach the icon to a shorter prefix than before. Labels over 48 graphemes are visibly truncated in the composer while their full identity remains available to assistive technology and in the tooltip. **Problem:** Long repository, issue, pull request, and channel chip labels could orphan their icon or overflow narrow composers and sent messages. **Solution:** Keep the icon with a bounded, grapheme-safe leading fragment while allowing the remaining text to break anywhere; cap visible labels at 48 graphemes without changing the full tooltip or accessible identity. <details> <summary>File changes</summary> **desktop/src/features/messages/lib/composerMessageLinkNode.ts** Splits composer chip content into an icon-bearing leading fragment and a freely wrapping remainder while preserving the semantic label and link metadata. **desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs** Updates renderer assertions for the fragment structure and verifies every supported Buzz link kind retains the intended visible label. **desktop/src/shared/styles/globals/composer.css** Keeps ordinary composer mention decorations inline while relying on the existing shared markdown chip wrapping rules for Buzz links. **desktop/src/shared/ui/mentionChip.ts** Centralizes grapheme-aware leading-fragment boundaries and label truncation so composer and sent chips share the same visible identity. **desktop/src/shared/ui/markdown/BuzzLinkChip.tsx** Uses the shared grapheme-aware boundary when rendering sent-message chip fragments. **desktop/tests/e2e/navigation.spec.ts** Covers increasing wrap depth across constrained widths, icon attachment, the sent-message wrap, accessible labeling, and tooltip positioning over both edge fragments. </details> ## Reproduction steps 1. Open a desktop channel and paste a Buzz link with a long repository or channel name into the composer. 2. Narrow the composer until the chip spans two or more lines. 3. Confirm the label breaks mid-string while the icon remains attached to the first label fragment. 4. Send the message, hover both the first and last rendered fragments, and confirm the tooltip follows the hovered fragment. ## Screenshots **Before — the icon drops onto a separate line from its chip label**  **After — the icon stays attached while the remaining label wraps** The same long repository chip at three composer widths. Its label gains line breaks as space contracts, while the icon remains attached to the leading fragment. **420px — one line**  **210px — two lines**  **150px — three lines**  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -19,6 +19,7 @@ const CHANNEL_MESSAGE_ID = "a".repeat(64);
|
||||
const CHANNEL_MESSAGE_HREF = `buzz://channel/${CHANNEL_ID}/${CHANNEL_MESSAGE_ID}`;
|
||||
const OWNER = "a".repeat(64);
|
||||
const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`;
|
||||
const PROJECT_HREF = `buzz://project?owner=${OWNER}&d=buzz-world`;
|
||||
const ISSUE_ID = "b".repeat(64);
|
||||
const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`;
|
||||
const PR_ID = "c".repeat(64);
|
||||
@@ -188,6 +189,10 @@ test("markdown parsing stops message links before emphasis delimiters", () => {
|
||||
assert.deepEqual(token.meta, { channelName: "general", href: HREF });
|
||||
});
|
||||
|
||||
function renderedChipLabel(rendered) {
|
||||
return `${rendered[2][2]}${rendered[3]}`;
|
||||
}
|
||||
|
||||
test("composer node uses the sent-message chip presentation", () => {
|
||||
const node = {
|
||||
attrs: { channelName: "general", href: HREF },
|
||||
@@ -209,7 +214,44 @@ test("composer node uses the sent-message chip presentation", () => {
|
||||
assert.equal(rendered[1]["data-buzz-link"], "");
|
||||
// Channel label only — no event hash, so the chip does not change width when
|
||||
// the draft is sent and the rendered chip resolves its metadata.
|
||||
assert.equal(rendered[2], "general");
|
||||
assert.match(rendered[1].class, /wrapping-inline-chip/);
|
||||
assert.match(rendered[2][1].class, /inline-chip-leading-fragment/);
|
||||
assert.equal(renderedChipLabel(rendered), "general");
|
||||
});
|
||||
|
||||
test("composer node truncates and preserves grapheme-safe leading fragments", () => {
|
||||
const render = ComposerMessageLinkNode.configure({
|
||||
resolveChannelName: () => undefined,
|
||||
}).config.renderHTML;
|
||||
assert.ok(render);
|
||||
|
||||
const longName = `relay-${"observability".repeat(5)}`;
|
||||
const longRendered = render.call(
|
||||
{ options: { resolveChannelName: () => undefined } },
|
||||
{
|
||||
node: { attrs: { channelName: longName, href: HREF } },
|
||||
HTMLAttributes: {},
|
||||
},
|
||||
);
|
||||
assert.equal(renderedChipLabel(longRendered), `${longName.slice(0, 47)}…`);
|
||||
|
||||
for (const [label, expectedLeading] of [
|
||||
["🇺🇸channel", "🇺🇸chan"],
|
||||
["e\u0301quipe", "e\u0301quip"],
|
||||
["relaytoolsobservabilityconsole-main", "relay"],
|
||||
[" leading-space", ""],
|
||||
]) {
|
||||
const rendered = render.call(
|
||||
{ options: { resolveChannelName: () => undefined } },
|
||||
{
|
||||
node: { attrs: { channelName: label, href: HREF } },
|
||||
HTMLAttributes: {},
|
||||
},
|
||||
);
|
||||
assert.equal(rendered[2][2], expectedLeading);
|
||||
assert.match(rendered[2][1].class, /inline-chip-with-icon/);
|
||||
assert.equal(renderedChipLabel(rendered), label);
|
||||
}
|
||||
});
|
||||
|
||||
test("composer node renders channel and entity chip presentations", () => {
|
||||
@@ -227,24 +269,29 @@ test("composer node renders channel and entity chip presentations", () => {
|
||||
const channel = render(CHANNEL_HREF);
|
||||
assert.equal(channel[1]["data-channel-deep-link"], "");
|
||||
assert.match(channel[1].class, /inline-chip-icon-channel/);
|
||||
assert.equal(channel[2], "general");
|
||||
assert.equal(renderedChipLabel(channel), "general");
|
||||
|
||||
const repo = render(REPO_HREF);
|
||||
assert.equal(repo[1]["data-buzz-link-kind"], "repo");
|
||||
assert.match(repo[1].class, /inline-chip-icon-repo/);
|
||||
assert.equal(repo[2], "buzz-world");
|
||||
assert.equal(renderedChipLabel(repo), "buzz-world");
|
||||
|
||||
const project = render(PROJECT_HREF);
|
||||
assert.equal(project[1]["data-buzz-link-kind"], "project");
|
||||
assert.match(project[1].class, /inline-chip-icon-project/);
|
||||
assert.equal(renderedChipLabel(project), "buzz-world");
|
||||
|
||||
const issue = render(ISSUE_HREF);
|
||||
assert.equal(issue[1]["data-buzz-link-kind"], "issue");
|
||||
assert.match(issue[1].class, /inline-chip-icon-issue/);
|
||||
// Repository name only — the rendered chip never widens into the issue
|
||||
// title, so the composer must not widen into the event hash either.
|
||||
assert.equal(issue[2], "buzz-world");
|
||||
assert.equal(renderedChipLabel(issue), "buzz-world");
|
||||
|
||||
const pullRequest = render(PR_HREF);
|
||||
assert.equal(pullRequest[1]["data-buzz-link-kind"], "pr");
|
||||
assert.match(pullRequest[1].class, /inline-chip-icon-pr/);
|
||||
assert.equal(pullRequest[2], "buzz-world");
|
||||
assert.equal(renderedChipLabel(pullRequest), "buzz-world");
|
||||
});
|
||||
|
||||
test("markdown rendering stores identity in attributes, not visible id text", () => {
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
} from "@/shared/lib/entityLink";
|
||||
import {
|
||||
inlineChipIconClasses,
|
||||
inlineChipLeadingEnd,
|
||||
type InlineChipIconKind,
|
||||
MENTION_CHIP_BASE_CLASSES,
|
||||
truncateInlineChipLabel,
|
||||
WRAPPING_INLINE_CHIP_CLASSES,
|
||||
} from "@/shared/ui/mentionChip";
|
||||
import { buildChannelLink, parseChannelLink } from "./channelLink";
|
||||
import { getMessageLinkLabel } from "./messageLinkLabel";
|
||||
@@ -285,6 +288,38 @@ function composerLinkPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
function wrappingComposerChipContent(
|
||||
label: string,
|
||||
icon: InlineChipIconKind,
|
||||
): { leading: [string, Record<string, string>, string]; remainder: string } {
|
||||
const leadingEnd = inlineChipLeadingEnd(label);
|
||||
if (!leadingEnd) {
|
||||
return {
|
||||
leading: [
|
||||
"span",
|
||||
{
|
||||
"aria-hidden": "true",
|
||||
class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`,
|
||||
},
|
||||
"",
|
||||
],
|
||||
remainder: label,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
leading: [
|
||||
"span",
|
||||
{
|
||||
"aria-hidden": "true",
|
||||
class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`,
|
||||
},
|
||||
label.slice(0, leadingEnd),
|
||||
],
|
||||
remainder: label.slice(leadingEnd),
|
||||
};
|
||||
}
|
||||
|
||||
export const ComposerMessageLinkNode =
|
||||
Node.create<ComposerMessageLinkNodeOptions>({
|
||||
name: COMPOSER_MESSAGE_LINK_NODE_NAME,
|
||||
@@ -328,11 +363,16 @@ export const ComposerMessageLinkNode =
|
||||
String(node.attrs.channelName ?? ""),
|
||||
this.options.resolveChannelName,
|
||||
);
|
||||
const visibleLabel = truncateInlineChipLabel(presentation.label);
|
||||
const content = wrappingComposerChipContent(
|
||||
visibleLabel,
|
||||
presentation.icon,
|
||||
);
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"aria-label": presentation.ariaLabel,
|
||||
class: `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`,
|
||||
class: `${MENTION_CHIP_BASE_CLASSES} ${WRAPPING_INLINE_CHIP_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`,
|
||||
"data-buzz-link": "",
|
||||
"data-channel-name": presentation.channelName,
|
||||
"data-composer-buzz-link": "",
|
||||
@@ -340,7 +380,8 @@ export const ComposerMessageLinkNode =
|
||||
...presentation.dataAttributes,
|
||||
title: presentation.ariaLabel,
|
||||
}),
|
||||
presentation.label,
|
||||
content.leading,
|
||||
content.remainder,
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -256,11 +256,11 @@
|
||||
Body copy is text-sm; inline code labels sit one step down (text-xs).
|
||||
All chip variants share one box height so mono labels stay balanced.
|
||||
|
||||
Rendered-message chips use inline-flex. The same decoration in the
|
||||
composer pulls the caret into the chip and swallows the trailing
|
||||
space after an @mention, so typing "hello" after @quinn becomes
|
||||
"@quinnhello". Keep composer chips inline; leave ::before icons in
|
||||
place. */
|
||||
Plain-text mention decorations stay inline because inline-flex pulls the
|
||||
caret into their decorated range and swallows the trailing space after an
|
||||
@mention. Composer Buzz links are atom nodes, but their labels may still
|
||||
fragment between characters; only the icon and leading label fragment stay
|
||||
together. */
|
||||
.rich-text-composer .tiptap .mention-chip {
|
||||
display: inline;
|
||||
min-height: 0;
|
||||
|
||||
@@ -211,6 +211,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-markdown
|
||||
.inline-chip-leading-fragment.inline-chip-with-icon:empty::after {
|
||||
content: "\200b";
|
||||
}
|
||||
|
||||
.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon::before {
|
||||
display: block;
|
||||
left: 0;
|
||||
|
||||
@@ -1121,7 +1121,7 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => {
|
||||
assert.equal((html.match(/data-channel-deep-link=""/g) ?? []).length, 1);
|
||||
assert.match(html, /inline-chip-icon-channel/);
|
||||
assert.match(html, /wrapping-inline-chip/);
|
||||
assert.match(html, /inline-chip-leading-fragment[^>]*>e</);
|
||||
assert.match(html, /inline-chip-leading-fragment[^>]*>engin</);
|
||||
assert.match(html, /inline-chip-icon-pr/);
|
||||
assert.match(html, /inline-chip-icon-issue/);
|
||||
assert.match(html, /inline-chip-icon-repo/);
|
||||
@@ -1299,7 +1299,7 @@ test("bare Buzz permalinks shorten unavailable channel identifiers", () => {
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(html.match(/inline-chip-leading-fragment[^>]*>5<\/span>80ca78b/g) ?? [])
|
||||
(html.match(/inline-chip-leading-fragment[^>]*>580ca<\/span>78b/g) ?? [])
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
@@ -1332,7 +1332,7 @@ test("channel references replace the authored hash with the channel icon", () =>
|
||||
|
||||
assert.match(html, /inline-chip-icon-channel/);
|
||||
assert.match(html, /wrapping-inline-chip/);
|
||||
assert.match(html, /inline-chip-leading-fragment[^>]*>e</);
|
||||
assert.match(html, /inline-chip-leading-fragment[^>]*>engin</);
|
||||
assert.match(html.replace(/<[^>]+>/g, ""), /engineering/);
|
||||
assert.doesNotMatch(html, />#engineering</);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import {
|
||||
inlineChipIconClasses,
|
||||
inlineChipLeadingEnd,
|
||||
type InlineChipIconKind,
|
||||
truncateInlineChipLabel,
|
||||
WRAPPING_INLINE_CHIP_CLASSES,
|
||||
@@ -72,12 +73,21 @@ function wrappingChipContent(
|
||||
): React.ReactNode {
|
||||
if (typeof children !== "string" || children.length === 0) return children;
|
||||
|
||||
const separatorIndex = children.search(/[-\s]/u);
|
||||
const leadingEnd =
|
||||
separatorIndex < 0
|
||||
? Array.from(children)[0]?.length
|
||||
: separatorIndex + (children[separatorIndex] === "-" ? 1 : 0);
|
||||
if (!leadingEnd) return children;
|
||||
const leadingEnd = inlineChipLeadingEnd(children);
|
||||
if (!leadingEnd) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-chip-leading-fragment",
|
||||
inlineChipIconClasses(icon),
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
147
desktop/src/shared/ui/markdown/useInlineTooltipPosition.test.mjs
Normal file
147
desktop/src/shared/ui/markdown/useInlineTooltipPosition.test.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
async function withHookHarness(run) {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
const originals = {
|
||||
window: globalThis.window,
|
||||
document: globalThis.document,
|
||||
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
||||
MutationObserver: globalThis.MutationObserver,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||
act: globalThis.IS_REACT_ACT_ENVIRONMENT,
|
||||
};
|
||||
const frames = [];
|
||||
const requestAnimationFrame = (callback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
};
|
||||
const cancelAnimationFrame = (id) => {
|
||||
frames[id - 1] = null;
|
||||
};
|
||||
Object.defineProperty(dom.window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
dom.window.requestAnimationFrame = requestAnimationFrame;
|
||||
dom.window.cancelAnimationFrame = cancelAnimationFrame;
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.requestAnimationFrame = requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = cancelAnimationFrame;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
});
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
try {
|
||||
const React = (await import("react")).default;
|
||||
const { act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { useInlineTooltipPosition } = await import(
|
||||
"./useInlineTooltipPosition.ts"
|
||||
);
|
||||
let controls;
|
||||
function Probe() {
|
||||
controls = useInlineTooltipPosition();
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(
|
||||
dom.window.document.body.appendChild(
|
||||
dom.window.document.createElement("div"),
|
||||
),
|
||||
);
|
||||
await act(async () => root.render(React.createElement(Probe)));
|
||||
const flushFrames = async () => {
|
||||
const pending = frames.splice(0, frames.length).filter(Boolean);
|
||||
await act(async () => {
|
||||
for (const callback of pending) callback();
|
||||
});
|
||||
};
|
||||
const flushMutations = async () => {
|
||||
await act(async () => Promise.resolve());
|
||||
};
|
||||
|
||||
await run({ act, controls, dom, flushFrames, flushMutations });
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
globalThis.window = originals.window;
|
||||
globalThis.document = originals.document;
|
||||
globalThis.MutationObserver = originals.MutationObserver;
|
||||
globalThis.requestAnimationFrame = originals.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = originals.cancelAnimationFrame;
|
||||
if (originals.navigator) {
|
||||
Object.defineProperty(globalThis, "navigator", originals.navigator);
|
||||
} else {
|
||||
delete globalThis.navigator;
|
||||
}
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = originals.act;
|
||||
}
|
||||
}
|
||||
|
||||
function translateX(styleValue) {
|
||||
return (
|
||||
Number.parseFloat(styleValue.match(/translate(?:3d)?\(([-\d.]+)px/)?.[1]) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
test("follows Radix wrapper placement without mutating the wrapper", async () => {
|
||||
await withHookHarness(
|
||||
async ({ controls, dom, flushFrames, flushMutations }) => {
|
||||
const wrapper = dom.window.document.createElement("div");
|
||||
const content = dom.window.document.createElement("div");
|
||||
wrapper.append(content);
|
||||
dom.window.document.body.append(wrapper);
|
||||
wrapper.style.transform = "translate(400px, 20px)";
|
||||
content.getBoundingClientRect = () => {
|
||||
const left =
|
||||
translateX(wrapper.style.transform) +
|
||||
(Number.parseFloat(wrapper.style.marginLeft) || 0) +
|
||||
(Number.parseFloat(content.style.translate) || 0);
|
||||
return {
|
||||
bottom: 50,
|
||||
height: 30,
|
||||
left,
|
||||
right: left + 100,
|
||||
top: 20,
|
||||
width: 100,
|
||||
x: left,
|
||||
y: 20,
|
||||
toJSON() {},
|
||||
};
|
||||
};
|
||||
const target = dom.window.document.createElement("span");
|
||||
target.getClientRects = () => [
|
||||
{ bottom: 20, height: 20, left: 360, right: 430, top: 0, width: 70 },
|
||||
{ bottom: 40, height: 20, left: 450, right: 600, top: 20, width: 150 },
|
||||
];
|
||||
|
||||
controls.contentRef(content);
|
||||
controls.onPointerMove({
|
||||
clientX: 395,
|
||||
clientY: 10,
|
||||
currentTarget: target,
|
||||
});
|
||||
await flushFrames();
|
||||
assert.equal(wrapper.style.marginLeft, "");
|
||||
assert.equal(content.getBoundingClientRect().left, 345);
|
||||
|
||||
// Radix may place the popper wrapper after the pointer lifecycle. The
|
||||
// hook must follow that external placement while leaving its style owned
|
||||
// by Radix; mutating the wrapper is what made this ordering stale.
|
||||
wrapper.style.transform = "translate(480px, 20px)";
|
||||
await flushMutations();
|
||||
await flushFrames();
|
||||
|
||||
assert.equal(wrapper.style.marginLeft, "");
|
||||
assert.equal(content.getBoundingClientRect().left, 345);
|
||||
assert.equal(content.getBoundingClientRect().right, 445);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export function useInlineTooltipPosition() {
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const fragmentCenterXRef = React.useRef<number | null>(null);
|
||||
const placementObserverRef = React.useRef<MutationObserver | null>(null);
|
||||
const placementFrameRef = React.useRef<number | null>(null);
|
||||
const placedRef = React.useRef(false);
|
||||
|
||||
const positionContent = React.useCallback(() => {
|
||||
@@ -32,14 +33,18 @@ export function useInlineTooltipPosition() {
|
||||
);
|
||||
const correction = desiredLeft - rect.left;
|
||||
if (Math.abs(correction) < 0.5) return;
|
||||
const currentOffset = Number.parseFloat(wrapper.style.marginLeft) || 0;
|
||||
wrapper.style.marginLeft = `${currentOffset + correction}px`;
|
||||
const currentOffset = Number.parseFloat(content.style.translate) || 0;
|
||||
content.style.translate = `${currentOffset + correction}px`;
|
||||
}, []);
|
||||
|
||||
const setContentRef = React.useCallback(
|
||||
(content: HTMLDivElement | null) => {
|
||||
placementObserverRef.current?.disconnect();
|
||||
placementObserverRef.current = null;
|
||||
if (placementFrameRef.current !== null) {
|
||||
cancelAnimationFrame(placementFrameRef.current);
|
||||
placementFrameRef.current = null;
|
||||
}
|
||||
contentRef.current = content;
|
||||
placedRef.current = false;
|
||||
const wrapper = content?.parentElement;
|
||||
@@ -48,27 +53,43 @@ export function useInlineTooltipPosition() {
|
||||
let transform = wrapper.style.transform;
|
||||
const handlePlacement = () => {
|
||||
const nextTransform = wrapper.style.transform;
|
||||
if (nextTransform === transform) return;
|
||||
transform = nextTransform;
|
||||
if (nextTransform !== transform) transform = nextTransform;
|
||||
placedRef.current = true;
|
||||
positionContent();
|
||||
if (placementFrameRef.current !== null) {
|
||||
cancelAnimationFrame(placementFrameRef.current);
|
||||
}
|
||||
placementFrameRef.current = requestAnimationFrame(() => {
|
||||
placementFrameRef.current = null;
|
||||
positionContent();
|
||||
});
|
||||
};
|
||||
const observer = new MutationObserver(handlePlacement);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (wrapper.style.transform !== transform) handlePlacement();
|
||||
});
|
||||
observer.observe(wrapper, {
|
||||
attributeFilter: ["style"],
|
||||
attributes: true,
|
||||
});
|
||||
placementObserverRef.current = observer;
|
||||
requestAnimationFrame(() => {
|
||||
placementFrameRef.current = requestAnimationFrame(() => {
|
||||
placementFrameRef.current = null;
|
||||
if (contentRef.current !== content) return;
|
||||
placedRef.current = true;
|
||||
positionContent();
|
||||
handlePlacement();
|
||||
});
|
||||
},
|
||||
[positionContent],
|
||||
);
|
||||
|
||||
React.useEffect(() => () => placementObserverRef.current?.disconnect(), []);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
placementObserverRef.current?.disconnect();
|
||||
if (placementFrameRef.current !== null) {
|
||||
cancelAnimationFrame(placementFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPointerMove = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
@@ -77,7 +98,7 @@ export function useInlineTooltipPosition() {
|
||||
);
|
||||
if (rects.length <= 1) {
|
||||
fragmentCenterXRef.current = null;
|
||||
contentRef.current?.parentElement?.style.removeProperty("margin-left");
|
||||
contentRef.current?.style.removeProperty("translate");
|
||||
return;
|
||||
}
|
||||
const fragment = rects.reduce<DOMRect | null>((nearest, rect) => {
|
||||
@@ -90,6 +111,14 @@ export function useInlineTooltipPosition() {
|
||||
if (!fragment) return;
|
||||
fragmentCenterXRef.current = fragment.left + fragment.width / 2;
|
||||
positionContent();
|
||||
if (placementFrameRef.current !== null) {
|
||||
cancelAnimationFrame(placementFrameRef.current);
|
||||
}
|
||||
placementFrameRef.current = requestAnimationFrame(() => {
|
||||
placementFrameRef.current = null;
|
||||
placedRef.current = true;
|
||||
positionContent();
|
||||
});
|
||||
},
|
||||
[positionContent],
|
||||
);
|
||||
|
||||
@@ -3,14 +3,45 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip";
|
||||
export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover";
|
||||
|
||||
const INLINE_CHIP_LABEL_MAX_CHARACTERS = 48;
|
||||
const INLINE_CHIP_LEADING_MAX_GRAPHEMES = 5;
|
||||
|
||||
const inlineChipGraphemeSegmenter =
|
||||
typeof Intl.Segmenter === "function"
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: null;
|
||||
|
||||
function inlineChipGraphemes(label: string): string[] {
|
||||
return inlineChipGraphemeSegmenter
|
||||
? Array.from(
|
||||
inlineChipGraphemeSegmenter.segment(label),
|
||||
({ segment }) => segment,
|
||||
)
|
||||
: Array.from(label);
|
||||
}
|
||||
|
||||
/** Caps a fragmentable chip label without changing its underlying metadata. */
|
||||
export function truncateInlineChipLabel(label: string): string {
|
||||
const characters = Array.from(label);
|
||||
if (characters.length <= INLINE_CHIP_LABEL_MAX_CHARACTERS) return label;
|
||||
return `${characters
|
||||
.slice(0, INLINE_CHIP_LABEL_MAX_CHARACTERS - 1)
|
||||
.join("")}…`;
|
||||
const graphemes = inlineChipGraphemes(label);
|
||||
if (graphemes.length <= INLINE_CHIP_LABEL_MAX_CHARACTERS) return label;
|
||||
return `${graphemes.slice(0, INLINE_CHIP_LABEL_MAX_CHARACTERS - 1).join("")}…`;
|
||||
}
|
||||
|
||||
/** Returns a bounded boundary after the icon-bearing prefix. */
|
||||
export function inlineChipLeadingEnd(label: string): number {
|
||||
const graphemes = inlineChipGraphemes(label);
|
||||
if (graphemes.length === 0) return 0;
|
||||
|
||||
let offset = 0;
|
||||
for (const grapheme of graphemes.slice(
|
||||
0,
|
||||
INLINE_CHIP_LEADING_MAX_GRAPHEMES,
|
||||
)) {
|
||||
const nextOffset = offset + grapheme.length;
|
||||
if (/\s/u.test(grapheme)) return offset;
|
||||
offset = nextOffset;
|
||||
if (grapheme === "-") return offset;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
/** Allows a long chip to fragment into separately decorated line boxes. */
|
||||
|
||||
@@ -445,6 +445,204 @@ test("mixed Buzz permalinks render as chips in the composer", async ({
|
||||
await expect(composerInput).not.toContainText("buzz://");
|
||||
});
|
||||
|
||||
test("composer Buzz chip labels wrap without orphaning their icons", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
|
||||
{
|
||||
id: "mock-project-relaytoolsobservabilityconsole-main",
|
||||
kind: 30617,
|
||||
pubkey:
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
created_at: Math.floor(Date.now() / 1000) - 60,
|
||||
content:
|
||||
"Operator tooling and observability console for relay deployments.",
|
||||
tags: [
|
||||
["d", "relaytoolsobservabilityconsole-main"],
|
||||
["name", "relaytoolsobservabilityconsole-main"],
|
||||
[
|
||||
"description",
|
||||
"Operator tooling and observability console for relay deployments.",
|
||||
],
|
||||
["clone", "https://github.com/block/relay-tools.git"],
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
const composerInput = page.getByTestId("message-input");
|
||||
const repoLink =
|
||||
"buzz://repo?owner=953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f&d=relaytoolsobservabilityconsole-main";
|
||||
await composerInput.evaluate((element, text) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData("text/plain", text);
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}),
|
||||
);
|
||||
}, repoLink);
|
||||
|
||||
const chip = composerInput.locator('[data-composer-buzz-link=""]');
|
||||
const leadingFragment = chip.locator(".inline-chip-leading-fragment");
|
||||
await expect(chip).toHaveText("relaytoolsobservabilityconsole-main");
|
||||
const emptyLeadingFragmentHeight = await composerInput.evaluate((element) => {
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "mention-chip wrapping-inline-chip";
|
||||
const leading = document.createElement("span");
|
||||
leading.className =
|
||||
"inline-chip-leading-fragment inline-chip-with-icon inline-chip-icon-repo";
|
||||
const remainder = document.createElement("span");
|
||||
remainder.textContent = " leading-space";
|
||||
probe.append(leading, remainder);
|
||||
element.append(probe);
|
||||
const height = leading.getBoundingClientRect().height;
|
||||
probe.remove();
|
||||
return height;
|
||||
});
|
||||
expect(emptyLeadingFragmentHeight).toBeGreaterThan(0);
|
||||
const chipHeightAtWidth = async (width: number) => {
|
||||
await composerInput.evaluate((element, nextWidth) => {
|
||||
element.style.width = `${nextWidth}px`;
|
||||
}, width);
|
||||
return chip.evaluate((element) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const rects = Array.from(range.getClientRects()).filter(
|
||||
(rect) => rect.width > 0 && rect.height > 0,
|
||||
);
|
||||
return (
|
||||
Math.max(...rects.map((rect) => rect.bottom)) -
|
||||
Math.min(...rects.map((rect) => rect.top))
|
||||
);
|
||||
});
|
||||
};
|
||||
const wideHeight = await chipHeightAtWidth(420);
|
||||
const mediumHeight = await chipHeightAtWidth(210);
|
||||
const narrowHeight = await chipHeightAtWidth(90);
|
||||
expect(mediumHeight).toBeGreaterThan(wideHeight);
|
||||
expect(narrowHeight).toBeGreaterThan(mediumHeight);
|
||||
await expect(chip).toHaveCSS("display", "inline");
|
||||
await expect(chip).toHaveCSS("overflow-wrap", "anywhere");
|
||||
await expect(leadingFragment).toHaveText("relay");
|
||||
await expect
|
||||
.poll(() =>
|
||||
chip.evaluate((element) => {
|
||||
const chipRange = document.createRange();
|
||||
chipRange.selectNodeContents(element);
|
||||
const leading = element.querySelector(".inline-chip-leading-fragment");
|
||||
const composerBounds = element
|
||||
.closest('[data-testid="message-input"]')
|
||||
?.getBoundingClientRect();
|
||||
const leadingBounds = leading?.getBoundingClientRect();
|
||||
return {
|
||||
chipWraps:
|
||||
new Set(
|
||||
Array.from(chipRange.getClientRects(), (rect) =>
|
||||
Math.round(rect.y),
|
||||
),
|
||||
).size >= 2,
|
||||
leadingContained:
|
||||
Boolean(composerBounds && leadingBounds) &&
|
||||
leadingBounds.right <= composerBounds.right,
|
||||
leadingLineBoxes: leading?.getClientRects().length ?? 0,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.toMatchObject({
|
||||
chipWraps: true,
|
||||
leadingContained: true,
|
||||
leadingLineBoxes: 1,
|
||||
});
|
||||
|
||||
await expect(chip).toHaveAttribute(
|
||||
"title",
|
||||
"Open repository relaytoolsobservabilityconsole-main",
|
||||
);
|
||||
await chipHeightAtWidth(420);
|
||||
await composerInput.press("End");
|
||||
await composerInput.pressSequentially(" after");
|
||||
await expect(composerInput).toContainText(
|
||||
"relaytoolsobservabilityconsole-main after",
|
||||
);
|
||||
await expect(chip).toHaveText("relaytoolsobservabilityconsole-main");
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const sentChip = page.getByTestId("message-row").last().getByRole("button", {
|
||||
name: "Open repository relaytoolsobservabilityconsole-main",
|
||||
});
|
||||
await expect(sentChip).toBeVisible();
|
||||
await sentChip.evaluate((element) => {
|
||||
const container = element.parentElement;
|
||||
if (container) container.style.width = "220px";
|
||||
});
|
||||
const fragmentRects = await sentChip.evaluate((element) =>
|
||||
Array.from(element.getClientRects(), (rect) => ({
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
})).filter((rect) => rect.width > 0 && rect.height > 0),
|
||||
);
|
||||
expect(fragmentRects.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const tooltip = page.getByRole("tooltip");
|
||||
await sentChip.focus();
|
||||
await expect(tooltip).toBeVisible();
|
||||
const positionOverFragment = async (index: number) => {
|
||||
const fragment = await sentChip.evaluate((element, fragmentIndex) => {
|
||||
const rects = Array.from(element.getClientRects()).filter(
|
||||
(rect) => rect.width > 0 && rect.height > 0,
|
||||
);
|
||||
const rect = rects.at(fragmentIndex);
|
||||
if (!rect) return null;
|
||||
return {
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
};
|
||||
}, index);
|
||||
if (!fragment) throw new Error(`Expected chip fragment ${index}`);
|
||||
const cursorX = fragment.left + fragment.width / 2;
|
||||
const cursorY = fragment.top + fragment.height / 2;
|
||||
await page.mouse.move(cursorX, cursorY);
|
||||
await expect(tooltip).toBeVisible();
|
||||
let tooltipCenter = Number.NaN;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tooltipBox = await tooltip.boundingBox();
|
||||
if (!tooltipBox) return Number.POSITIVE_INFINITY;
|
||||
tooltipCenter = tooltipBox.x + tooltipBox.width / 2;
|
||||
return Math.abs(tooltipCenter - cursorX);
|
||||
})
|
||||
.toBeLessThan(1);
|
||||
return {
|
||||
cursorX,
|
||||
tooltipCenter,
|
||||
};
|
||||
};
|
||||
|
||||
const firstTooltip = await positionOverFragment(0);
|
||||
const lastTooltip = await positionOverFragment(-1);
|
||||
expect(
|
||||
Math.abs(firstTooltip.tooltipCenter - firstTooltip.cursorX),
|
||||
).toBeLessThan(1);
|
||||
expect(
|
||||
Math.abs(lastTooltip.tooltipCenter - lastTooltip.cursorX),
|
||||
).toBeLessThan(1);
|
||||
expect(
|
||||
Math.abs(firstTooltip.tooltipCenter - lastTooltip.tooltipCenter),
|
||||
).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("message links to visible root messages open the thread panel", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -593,7 +791,7 @@ test("message links to visible root messages open the thread panel", async ({
|
||||
await expect(randomChannelLink).toHaveCSS("display", "inline");
|
||||
await expect(
|
||||
randomChannelLink.locator(".inline-chip-leading-fragment"),
|
||||
).toHaveText("r");
|
||||
).toHaveText("rando");
|
||||
await expect(randomChannelLink).not.toHaveAttribute("title");
|
||||
await randomChannelLink.hover();
|
||||
const channelTooltip = page.getByRole("tooltip");
|
||||
|
||||
Reference in New Issue
Block a user