diff --git a/src/Aspire.Dashboard/wwwroot/css/controls.css b/src/Aspire.Dashboard/wwwroot/css/controls.css index ed95a26294a..3624429b71c 100644 --- a/src/Aspire.Dashboard/wwwroot/css/controls.css +++ b/src/Aspire.Dashboard/wwwroot/css/controls.css @@ -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; } diff --git a/src/Aspire.Dashboard/wwwroot/js/app-tooltips.js b/src/Aspire.Dashboard/wwwroot/js/app-tooltips.js index 1c0ce786b36..494475d2ead 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-tooltips.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-tooltips.js @@ -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(); @@ -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; diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcesTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcesTests.cs index d5d3b5e946a..cc095369cf4 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcesTests.cs @@ -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()