Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/Aspire.Dashboard/wwwroot/css/controls.css
Original file line number Diff line number Diff line change
Expand Up @@ -1323,9 +1323,12 @@ fluent-badge.property-details-accordion-badge {
descriptions and wrap long URLs rather than overflowing the tooltip's default max width. */
.dashboard-tooltip {
width: max-content;
/* Adapt desktop Chrome's half-screen tooltip cap to the viewport, with a 300px floor.
/* Chromium caps tooltip text at 800px or half the display, not half the browser viewport.
Fluent popups must also fit the viewport and the available anchor area. Floor the
area cap at Fluent's usual 240px (less on small screens) so a cramped side triggers
flipping rather than wrapping to just a few characters.
https://source.chromium.org/chromium/chromium/src/+/main:ui/views/corewm/tooltip_aura.cc */
max-width: max(300px, 50vw);
max-width: min(800px, calc(100vw - 16px), max(min(240px, calc(50vw - 16px)), calc(100% - 16px)));
overflow-wrap: anywhere;
pointer-events: none;
}
Expand Down
36 changes: 35 additions & 1 deletion src/Aspire.Dashboard/wwwroot/js/app-tooltips.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class AspireTooltipProvider extends HTMLElement {
#visibilityObserver = null;
#keyboardModality = false;
#focusDismissed = false;
#maxTextLength = 1024;
#wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });

connectedCallback() {
this.#controller = new AbortController();
Expand Down Expand Up @@ -224,12 +226,44 @@ class AspireTooltipProvider extends HTMLElement {
tooltip.replaceChildren(...content.childNodes);
} else {
const content = document.createElement("span");
content.textContent = target.text;
content.textContent = this.#truncateText(target.text);
tooltip.replaceChildren(content);
}
this.#content = target;
}

#truncateText(text) {
if (text.length <= this.#maxTextLength) {
return text;
}

// Use Chromium's 1024-unit Windows limit consistently on all platforms. Reserve one unit
// for an ellipsis, preferring the last ICU word boundary before the limit.
// https://source.chromium.org/chromium/chromium/src/+/main:ui/views/corewm/tooltip_state_manager.cc
// https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/text_elider.cc
let end = this.#maxTextLength - 1;
for (const { index, segment } of this.#wordSegmenter.segment(text)) {
const boundary = index + segment.length;
if (boundary >= this.#maxTextLength) {
break;
}
end = boundary;
}

let prefix = text.slice(0, end).replace(/[\p{White_Space}\p{Cc}\p{Mn}]+$/u, "");
// If the only word boundary was leading whitespace, keep part of the following long word.
if (!prefix && end !== this.#maxTextLength - 1) {
prefix = text.slice(0, this.#maxTextLength - 1);
}
// A character-limit cut inside a long word must not leave an unmatched UTF-16 surrogate.
const lastCodeUnit = prefix.charCodeAt(prefix.length - 1);
if (lastCodeUnit >= 0xD800 && lastCodeUnit <= 0xDBFF) {
prefix = prefix.slice(0, -1);
}

return prefix.trim() + "\u2026";
}

#leave(element) {
if (this.#anchor?.contains(element) || this.#tooltip?.contains(element) || this.#anchor?.matches(":hover")) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,64 @@ public ResourcesTests(ResourcesDashboardServerFixture dashboardServerFixture)
{
}

[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task Tooltips_TruncateLongPlainTextAndPreserveRichContent()
{
await RunTestAsync(async page =>
{
await page.SetViewportSizeAsync(1440, 1200);
await page.GotoAsync("/resources");
await Assertions.Expect(page.Locator("aspire-tooltip-provider")).ToHaveCountAsync(1);
await page.EvaluateAsync("""
() => {
const anchor = document.createElement("button");
anchor.id = "tooltip-test-anchor";
anchor.textContent = "Tooltip anchor";
anchor.style.cssText = "position: fixed; top: 50%; left: 50%;";
document.body.appendChild(anchor);
}
""");
var anchor = page.Locator("#tooltip-test-anchor");
var tooltip = page.Locator("aspire-tooltip-provider fluent-tooltip[aria-hidden='false']");
var maxLength = 1024;
var wordCount = (maxLength - 1) / 5;
var cases = new (string Text, string Expected)[]
{
("First line.\nSecond line.", "First line.\nSecond line."),
(new string('x', maxLength), new string('x', maxLength)),
(new string('x', maxLength + 1), new string('x', maxLength - 1) + "\u2026"),
(string.Concat(Enumerable.Repeat("word ", wordCount + 10)), string.Join(" ", Enumerable.Repeat("word", wordCount)) + "\u2026"),
("short words " + new string('x', maxLength), "short words\u2026"),
(" " + new string('x', maxLength), new string('x', maxLength - 4) + "\u2026"),
(new string('x', maxLength - 2) + "\U00010400tail", new string('x', maxLength - 2) + "\u2026")
};
foreach (var (text, expected) in cases)
{
await anchor.EvaluateAsync("(anchor, text) => anchor.setAttribute('data-tooltip', text)", text);
await anchor.HoverAsync();
await Assertions.Expect(tooltip).ToBeVisibleAsync();
await Assertions.Expect(tooltip).ToHaveJSPropertyAsync("textContent", expected);
await Assertions.Expect(anchor).ToHaveAttributeAsync("data-tooltip", text);
}

var richText = string.Concat(Enumerable.Repeat("rich text ", maxLength / 10 + 10));
await anchor.EvaluateAsync("""
(anchor, text) => {
const source = document.createElement("div");
source.id = "tooltip-test-content";
source.hidden = true;
const strong = document.createElement("strong");
strong.textContent = text;
source.appendChild(strong);
document.body.appendChild(source);
anchor.setAttribute("data-tooltip-content", source.id);
}
""", richText);
await Assertions.Expect(tooltip.Locator("strong")).ToHaveJSPropertyAsync("textContent", richText);
});
}

[Fact]
[OuterloopTest("Resource-intensive Playwright browser test")]
public async Task ResourcePane_GroupOrderSupportsDragKeyboardAndReload()
Expand Down
Loading