mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
improve(ui): reveal only clipped session titles
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type {
|
||||
SessionCatalog,
|
||||
SessionCatalogHost,
|
||||
@@ -11,6 +12,7 @@ import { t } from "../i18n/index.ts";
|
||||
import { formatUiError } from "../lib/format-error.ts";
|
||||
import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts";
|
||||
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
|
||||
import { createOverflowFadeRef } from "../lib/overflow-fade.ts";
|
||||
import type { CatalogSessionKey } from "../lib/sessions/catalog-key.ts";
|
||||
import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts";
|
||||
import {
|
||||
@@ -483,7 +485,9 @@ function renderCatalogSessionRow(
|
||||
>
|
||||
<span class="sidebar-session-indicator"></span>
|
||||
<span class="sidebar-recent-session__text">
|
||||
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
|
||||
<span class="sidebar-recent-session__name" ${ref(createOverflowFadeRef())}
|
||||
><span class="sidebar-recent-session__name-content">${label}</span></span
|
||||
>
|
||||
<span class="sidebar-recent-session__details">
|
||||
<span class="sidebar-recent-session__details-endcap">
|
||||
${renderSessionRowBadges({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { NavigationRouteId } from "../app-navigation.ts";
|
||||
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
|
||||
@@ -9,8 +10,8 @@ import { resolveControlUiAuthCandidates } from "../app/control-ui-auth.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { sessionHasBoard } from "../lib/board/provider.ts";
|
||||
import { formatDurationCompact } from "../lib/format.ts";
|
||||
import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts";
|
||||
import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts";
|
||||
import { createOverflowFadeRef } from "../lib/overflow-fade.ts";
|
||||
import { projectPresencePayload } from "../lib/presence-users.ts";
|
||||
import { writeSessionDragData } from "../lib/sessions/drag.ts";
|
||||
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
|
||||
@@ -287,8 +288,6 @@ export function renderRecentSession(params: {
|
||||
}}
|
||||
@contextmenu=${openMenuFromEvent}
|
||||
@keydown=${openMenuFromEvent}
|
||||
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
|
||||
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
|
||||
>
|
||||
<a
|
||||
href=${session.href}
|
||||
@@ -307,22 +306,24 @@ export function renderRecentSession(params: {
|
||||
: nothing}</span
|
||||
>
|
||||
<span class="sidebar-recent-session__text">
|
||||
<span class="sidebar-recent-session__name hover-marquee"
|
||||
>${session.archived
|
||||
? html`<span
|
||||
class="sidebar-session__archive-glyph"
|
||||
aria-label=${t("sessionsView.archived")}
|
||||
title=${t("sessionsView.archived")}
|
||||
>${icons.archive}</span
|
||||
>`
|
||||
: nothing}${session.forkSource
|
||||
? html`<span
|
||||
class="sidebar-session-fork-indicator"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.forkedSession")}
|
||||
>${icons.gitFork}</span
|
||||
>`
|
||||
: nothing}${label}</span
|
||||
<span class="sidebar-recent-session__name" ${ref(createOverflowFadeRef())}
|
||||
><span class="sidebar-recent-session__name-content"
|
||||
>${session.archived
|
||||
? html`<span
|
||||
class="sidebar-session__archive-glyph"
|
||||
aria-label=${t("sessionsView.archived")}
|
||||
title=${t("sessionsView.archived")}
|
||||
>${icons.archive}</span
|
||||
>`
|
||||
: nothing}${session.forkSource
|
||||
? html`<span
|
||||
class="sidebar-session-fork-indicator"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.forkedSession")}
|
||||
>${icons.gitFork}</span
|
||||
>`
|
||||
: nothing}${label}</span
|
||||
></span
|
||||
>
|
||||
<span class="sidebar-recent-session__details">
|
||||
${renderSidebarSessionSubtitle({ subtitle, narration })}
|
||||
@@ -446,7 +447,7 @@ export function renderRecentSession(params: {
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
// Marquee state mutates the row DOM; keying prevents cross-session reuse.
|
||||
// The overflow ref owns per-title measurement state, so DOM cannot be reused across sessions.
|
||||
return keyed(session.key, row);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createChatFlowE2eSuite,
|
||||
expectDefined,
|
||||
installMockGateway,
|
||||
pauseVirtualClock,
|
||||
requireRecord,
|
||||
} from "./chat-flow.test-support.ts";
|
||||
|
||||
@@ -226,14 +225,13 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps long sidebar labels clipped after a session switch", async () => {
|
||||
it("reveals only clipped sidebar titles without moving row chrome", async () => {
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.clock.install();
|
||||
const sessions = chatSessionListResponse();
|
||||
const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture");
|
||||
const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture");
|
||||
@@ -242,6 +240,8 @@ suite.define(() => {
|
||||
"Review and repair the intentionally overlong sidebar session title before navigation ".repeat(
|
||||
4,
|
||||
);
|
||||
secondSession.hasAutomation = true;
|
||||
secondSession.pinned = true;
|
||||
await installMockGateway(page, {
|
||||
methodResponses: { "sessions.list": sessions },
|
||||
sessionKey: "agent:main:session-a",
|
||||
@@ -252,51 +252,98 @@ suite.define(() => {
|
||||
const recentRow = page.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
|
||||
);
|
||||
const shortRow = page.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:session-a"]',
|
||||
);
|
||||
const recentLabel = recentRow.locator(".sidebar-recent-session__name");
|
||||
const recentContent = recentLabel.locator(".sidebar-recent-session__name-content");
|
||||
await recentLabel.waitFor({ state: "visible", timeout: 10_000 });
|
||||
const layout = await recentLabel.evaluate((label) => ({
|
||||
clientWidth: label.clientWidth,
|
||||
linkWidth: label.parentElement?.clientWidth ?? 0,
|
||||
rowWidth: label.closest<HTMLElement>(".sidebar-recent-session")?.clientWidth ?? 0,
|
||||
scrollWidth: label.scrollWidth,
|
||||
text: label.textContent,
|
||||
}));
|
||||
expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth);
|
||||
await shortRow.locator(".sidebar-recent-session__name").waitFor({ state: "visible" });
|
||||
await expect
|
||||
.poll(() => page.locator(".shell").evaluate((shell) => getComputedStyle(shell).transform))
|
||||
.toBe("none");
|
||||
|
||||
// Freeze the clock so the 500ms hover-intent delay elapses only via
|
||||
// runFor; a ticking clock let slow runners start the marquee before the
|
||||
// "not yet scrolling" asserts below.
|
||||
await pauseVirtualClock(page);
|
||||
await recentRow.dispatchEvent("mouseenter");
|
||||
await page.clock.runFor(250);
|
||||
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
|
||||
"hover-marquee--scrolling",
|
||||
const shortTitle = shortRow.locator(".sidebar-recent-session__name");
|
||||
const shortContent = shortTitle.locator(".sidebar-recent-session__name-content");
|
||||
const shortTransform = await shortContent.evaluate(
|
||||
(content) => getComputedStyle(content).transform,
|
||||
);
|
||||
await recentRow.dispatchEvent("mouseleave");
|
||||
// 250 + 300 exceeds the hover delay: only the leave-cancel keeps it off.
|
||||
await page.clock.runFor(300);
|
||||
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
|
||||
"hover-marquee--scrolling",
|
||||
expect(await shortTitle.getAttribute("data-overflow-fade")).toBeNull();
|
||||
expect(await shortTitle.getAttribute("data-overflow-reveal")).toBeNull();
|
||||
await shortRow.hover();
|
||||
expect(await shortContent.evaluate((content) => getComputedStyle(content).transform)).toBe(
|
||||
shortTransform,
|
||||
);
|
||||
await recentRow.dispatchEvent("mouseenter");
|
||||
await page.clock.runFor(500);
|
||||
|
||||
const readLongTitleState = () =>
|
||||
recentRow.evaluate((row) => {
|
||||
const rect = (selector: string) => {
|
||||
const element = row.querySelector<HTMLElement>(selector);
|
||||
if (!element) {
|
||||
throw new Error(`Missing clipped-title fixture ${selector}`);
|
||||
}
|
||||
const box = element.getBoundingClientRect();
|
||||
return { bottom: box.bottom, left: box.left, right: box.right, top: box.top };
|
||||
};
|
||||
const title = row.querySelector<HTMLElement>(".sidebar-recent-session__name");
|
||||
const content = row.querySelector<HTMLElement>(".sidebar-recent-session__name-content");
|
||||
if (!title || !content) {
|
||||
throw new Error("Missing clipped-title content");
|
||||
}
|
||||
return {
|
||||
badge: rect(".session-row-badges"),
|
||||
contentTransform: getComputedStyle(content).transform,
|
||||
fade: title.hasAttribute("data-overflow-fade"),
|
||||
menu: rect("[data-session-menu]"),
|
||||
pin: rect("[data-sidebar-session-pin]"),
|
||||
reveal: title.hasAttribute("data-overflow-reveal"),
|
||||
revealTranslate: title.style.getPropertyValue("--overflow-reveal-translate"),
|
||||
title: rect(".sidebar-recent-session__name"),
|
||||
titleClientWidth: title.clientWidth,
|
||||
titleScrollWidth: content.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
const resting = await readLongTitleState();
|
||||
expect(resting.titleScrollWidth).toBeGreaterThan(resting.titleClientWidth);
|
||||
expect(resting.fade).toBe(true);
|
||||
expect(resting.reveal).toBe(true);
|
||||
expect(Number.parseFloat(resting.revealTranslate)).toBeLessThan(0);
|
||||
|
||||
await recentRow.hover();
|
||||
await expect
|
||||
.poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 })
|
||||
.toContain("hover-marquee--scrolling");
|
||||
// Resume real time: the snap-back below is a compositor-driven CSS
|
||||
// transition, not a fake-timer callback.
|
||||
await page.clock.resume();
|
||||
await recentRow.dispatchEvent("mouseleave");
|
||||
.poll(() => recentContent.evaluate((content) => getComputedStyle(content).transform))
|
||||
.not.toBe(resting.contentTransform);
|
||||
const hovered = await readLongTitleState();
|
||||
expect({
|
||||
badge: hovered.badge,
|
||||
menu: hovered.menu,
|
||||
pin: hovered.pin,
|
||||
title: hovered.title,
|
||||
}).toEqual({
|
||||
badge: resting.badge,
|
||||
menu: resting.menu,
|
||||
pin: resting.pin,
|
||||
title: resting.title,
|
||||
});
|
||||
|
||||
await recentRow.locator("a.sidebar-recent-session__link").focus();
|
||||
await page.mouse.move(1_000, 100);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
recentLabel.evaluate((label) => ({
|
||||
textIndent: getComputedStyle(label).textIndent,
|
||||
textOverflow: getComputedStyle(label).textOverflow,
|
||||
})),
|
||||
{ timeout: 1_500 },
|
||||
)
|
||||
.toEqual({ textIndent: "0px", textOverflow: "ellipsis" });
|
||||
.poll(() => recentContent.evaluate((content) => getComputedStyle(content).transform))
|
||||
.not.toBe(resting.contentTransform);
|
||||
const focused = await readLongTitleState();
|
||||
expect({
|
||||
badge: focused.badge,
|
||||
menu: focused.menu,
|
||||
pin: focused.pin,
|
||||
title: focused.title,
|
||||
}).toEqual({
|
||||
badge: resting.badge,
|
||||
menu: resting.menu,
|
||||
pin: resting.pin,
|
||||
title: resting.title,
|
||||
});
|
||||
|
||||
await recentRow.locator("a.sidebar-recent-session__link").dispatchEvent("click", {
|
||||
button: 0,
|
||||
@@ -308,12 +355,13 @@ suite.define(() => {
|
||||
const activeRow = page.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:session-b"]',
|
||||
);
|
||||
expect(
|
||||
await activeRow.locator(".sidebar-recent-session__name").evaluate((label) => ({
|
||||
textIndent: getComputedStyle(label).textIndent,
|
||||
textOverflow: getComputedStyle(label).textOverflow,
|
||||
})),
|
||||
).toEqual({ textIndent: "0px", textOverflow: "ellipsis" });
|
||||
await expect
|
||||
.poll(() =>
|
||||
activeRow
|
||||
.locator(".sidebar-recent-session__name")
|
||||
.evaluate((title) => title.hasAttribute("data-overflow-fade")),
|
||||
)
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { startHoverMarquee, stopHoverMarquee } from "./hover-marquee.ts";
|
||||
|
||||
function buildRow(params: { textWidth: number; labelWidth: number }) {
|
||||
const row = document.createElement("div");
|
||||
const label = document.createElement("span");
|
||||
label.className = "hover-marquee";
|
||||
label.textContent = "Fix stale iMessage group-allowlist warning copy";
|
||||
row.append(label);
|
||||
document.body.append(row);
|
||||
Object.defineProperty(label, "clientWidth", { value: params.labelWidth });
|
||||
Object.defineProperty(label, "scrollWidth", { value: params.textWidth });
|
||||
return { row, label };
|
||||
}
|
||||
|
||||
describe("hover marquee", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("waits before scrolling overflowing labels by the clipped distance", () => {
|
||||
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px");
|
||||
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("1750ms");
|
||||
vi.advanceTimersByTime(499);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
|
||||
stopHoverMarquee(row);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels the delayed scroll when hover ends early", () => {
|
||||
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
vi.advanceTimersByTime(250);
|
||||
stopHoverMarquee(row);
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps short scroll distances readable with a minimum duration", () => {
|
||||
const { row, label } = buildRow({ textWidth: 190, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-10px");
|
||||
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("300ms");
|
||||
});
|
||||
|
||||
it("leaves labels that fit untouched", () => {
|
||||
const { row, label } = buildRow({ textWidth: 120, labelWidth: 180 });
|
||||
startHoverMarquee(row);
|
||||
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
|
||||
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
|
||||
});
|
||||
|
||||
it("ignores hosts without a marquee label", () => {
|
||||
const row = document.createElement("div");
|
||||
expect(() => {
|
||||
startHoverMarquee(row);
|
||||
stopHoverMarquee(row);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
// Hover marquee for truncated single-line labels: on pointer enter, animate
|
||||
// text-indent to slide the clipped tail into view; on leave, the base
|
||||
// transition in styles/components.css (.hover-marquee) snaps it back quickly.
|
||||
// text-indent (not an inner transform wrapper) because text-overflow renders
|
||||
// no ellipsis for atomic inline children, which would lose the resting "…".
|
||||
const MARQUEE_SPEED_PX_PER_SEC = 80;
|
||||
const MARQUEE_MIN_DURATION_MS = 300;
|
||||
const MARQUEE_HOVER_DELAY_MS = 500;
|
||||
const pendingMarquees = new WeakMap<HTMLElement, number>();
|
||||
|
||||
function findMarqueeLabel(host: HTMLElement): HTMLElement | null {
|
||||
return host.classList.contains("hover-marquee")
|
||||
? host
|
||||
: host.querySelector<HTMLElement>(".hover-marquee");
|
||||
}
|
||||
|
||||
function clearPendingMarquee(label: HTMLElement): void {
|
||||
const pending = pendingMarquees.get(label);
|
||||
if (pending === undefined) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(pending);
|
||||
pendingMarquees.delete(label);
|
||||
}
|
||||
|
||||
export function startHoverMarquee(host: HTMLElement): void {
|
||||
const label = findMarqueeLabel(host);
|
||||
if (!label || label.classList.contains("hover-marquee--scrolling")) {
|
||||
return;
|
||||
}
|
||||
clearPendingMarquee(label);
|
||||
// Measure at hover time: labels resize with the sidebar and with hover-only
|
||||
// row actions, so a cached width would drift. A negative mid-transition
|
||||
// indent (re-hover while snapping back) shrinks scrollWidth; add it back.
|
||||
const indent = Number.parseFloat(getComputedStyle(label).textIndent) || 0;
|
||||
const shift = label.scrollWidth - indent - label.clientWidth;
|
||||
if (shift <= 1) {
|
||||
return;
|
||||
}
|
||||
const durationMs = Math.max(
|
||||
MARQUEE_MIN_DURATION_MS,
|
||||
Math.round((shift / MARQUEE_SPEED_PX_PER_SEC) * 1000),
|
||||
);
|
||||
label.style.setProperty("--hover-marquee-shift", `${-shift}px`);
|
||||
label.style.setProperty("--hover-marquee-duration", `${durationMs}ms`);
|
||||
// Keep quick pointer passes quiet; leaving before the timer fires cancels it.
|
||||
pendingMarquees.set(
|
||||
label,
|
||||
window.setTimeout(() => {
|
||||
pendingMarquees.delete(label);
|
||||
label.classList.add("hover-marquee--scrolling");
|
||||
}, MARQUEE_HOVER_DELAY_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export function stopHoverMarquee(host: HTMLElement): void {
|
||||
const label = findMarqueeLabel(host);
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
clearPendingMarquee(label);
|
||||
label.classList.remove("hover-marquee--scrolling");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createOverflowFadeRef } from "./overflow-fade.ts";
|
||||
|
||||
function buildRow(params: { textWidth: number; titleWidth: number; direction?: "ltr" | "rtl" }) {
|
||||
const row = document.createElement("div");
|
||||
const title = document.createElement("span");
|
||||
title.className = "sidebar-recent-session__name";
|
||||
title.style.direction = params.direction ?? "ltr";
|
||||
const content = document.createElement("span");
|
||||
content.className = "sidebar-recent-session__name-content";
|
||||
content.textContent = "Fix stale iMessage group-allowlist warning copy";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "session-row-badge";
|
||||
title.append(content);
|
||||
row.append(title, badge);
|
||||
document.body.append(row);
|
||||
Object.defineProperty(title, "clientWidth", { value: params.titleWidth });
|
||||
Object.defineProperty(content, "scrollWidth", { value: params.textWidth });
|
||||
return { row, title, content, badge };
|
||||
}
|
||||
|
||||
describe("overflow fade", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("marks only genuinely clipped titles for a resting fade", () => {
|
||||
const clipped = buildRow({ textWidth: 320, titleWidth: 180 });
|
||||
createOverflowFadeRef()(clipped.title);
|
||||
expect(clipped.title.hasAttribute("data-overflow-fade")).toBe(true);
|
||||
|
||||
const fitting = buildRow({ textWidth: 120, titleWidth: 180 });
|
||||
createOverflowFadeRef()(fitting.title);
|
||||
expect(fitting.title.hasAttribute("data-overflow-fade")).toBe(false);
|
||||
});
|
||||
|
||||
it("reveals only the hidden tail without changing badges", () => {
|
||||
const { title, badge } = buildRow({ textWidth: 320, titleWidth: 180 });
|
||||
const badgeBefore = badge.outerHTML;
|
||||
|
||||
createOverflowFadeRef()(title);
|
||||
|
||||
expect(title.hasAttribute("data-overflow-reveal")).toBe(true);
|
||||
expect(title.style.getPropertyValue("--overflow-reveal-translate")).toBe("-140px");
|
||||
expect(title.style.getPropertyValue("--overflow-reveal-duration")).toBe("2240ms");
|
||||
expect(badge.outerHTML).toBe(badgeBefore);
|
||||
});
|
||||
|
||||
it("leaves fitting titles untouched", () => {
|
||||
const { title } = buildRow({ textWidth: 120, titleWidth: 180 });
|
||||
createOverflowFadeRef()(title);
|
||||
expect(title.hasAttribute("data-overflow-reveal")).toBe(false);
|
||||
expect(title.style.getPropertyValue("--overflow-reveal-translate")).toBe("");
|
||||
});
|
||||
|
||||
it("bounds reveal duration and reverses travel for RTL", () => {
|
||||
const short = buildRow({ textWidth: 190, titleWidth: 180 });
|
||||
createOverflowFadeRef()(short.title);
|
||||
expect(short.title.style.getPropertyValue("--overflow-reveal-duration")).toBe("1200ms");
|
||||
|
||||
const long = buildRow({ textWidth: 900, titleWidth: 180, direction: "rtl" });
|
||||
createOverflowFadeRef()(long.title);
|
||||
expect(long.title.style.getPropertyValue("--overflow-reveal-translate")).toBe("720px");
|
||||
expect(long.title.style.getPropertyValue("--overflow-reveal-duration")).toBe("8000ms");
|
||||
});
|
||||
|
||||
it("ignores detached refs", () => {
|
||||
expect(() => createOverflowFadeRef()(undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Ref callback that marks single-line text only while it is genuinely clipped. */
|
||||
export function createOverflowFadeRef() {
|
||||
let target: HTMLElement | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
const sync = () => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const content = target.querySelector<HTMLElement>(".sidebar-recent-session__name-content");
|
||||
const contentWidth = content?.scrollWidth ?? target.scrollWidth;
|
||||
const revealDistance = Math.max(0, contentWidth - target.clientWidth);
|
||||
target.toggleAttribute("data-overflow-fade", revealDistance > 1);
|
||||
target.toggleAttribute("data-overflow-reveal", revealDistance > 1);
|
||||
if (revealDistance <= 1) {
|
||||
target.style.removeProperty("--overflow-reveal-translate");
|
||||
target.style.removeProperty("--overflow-reveal-duration");
|
||||
return;
|
||||
}
|
||||
const direction = getComputedStyle(target).direction === "rtl" ? 1 : -1;
|
||||
target.style.setProperty("--overflow-reveal-translate", `${direction * revealDistance}px`);
|
||||
const revealDuration = Math.min(8_000, Math.max(1_200, revealDistance * 16));
|
||||
target.style.setProperty("--overflow-reveal-duration", `${revealDuration}ms`);
|
||||
};
|
||||
|
||||
return (element?: Element) => {
|
||||
const next = element instanceof HTMLElement ? element : null;
|
||||
if (next === target) {
|
||||
sync();
|
||||
return;
|
||||
}
|
||||
observer?.disconnect();
|
||||
target = next;
|
||||
observer = null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
sync();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
observer = new ResizeObserver(sync);
|
||||
observer.observe(target);
|
||||
}
|
||||
queueMicrotask(sync);
|
||||
};
|
||||
}
|
||||
@@ -5963,40 +5963,6 @@ td.data-table-key-col {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Hover Marquee
|
||||
=========================================== */
|
||||
|
||||
/* Truncated single-line labels that slide on hover to reveal the clipped
|
||||
tail. lib/hover-marquee.ts measures the overflow and sets the custom
|
||||
properties; without it the label stays a plain ellipsized span. Animates
|
||||
text-indent so the resting ellipsis keeps rendering (text-overflow shows
|
||||
no "…" for atomic inline children, ruling out a transformed wrapper). */
|
||||
.hover-marquee {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: text-indent 180ms ease-out;
|
||||
}
|
||||
|
||||
.hover-marquee--scrolling {
|
||||
text-overflow: clip;
|
||||
text-indent: var(--hover-marquee-shift, 0);
|
||||
transition-duration: var(--hover-marquee-duration, 600ms);
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hover-marquee {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hover-marquee--scrolling {
|
||||
text-overflow: ellipsis;
|
||||
text-indent: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Plugin tab embeds (plugin-served panels) ---- */
|
||||
|
||||
.plugin-tab-embed {
|
||||
|
||||
+50
-10
@@ -2291,6 +2291,53 @@ body.update-dialog-open .sidebar-update-card__status {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-recent-session__name-content {
|
||||
display: inline-block;
|
||||
min-width: max-content;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* The mask exists only when the measured title is clipped; short titles keep
|
||||
their full opacity. Revealing transforms only the title content, so row
|
||||
controls and badges retain their positions. */
|
||||
.sidebar-recent-session__name[data-overflow-fade] {
|
||||
-webkit-mask-image: linear-gradient(to right, black 0, black calc(100% - 10px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, black 0, black calc(100% - 10px), transparent 100%);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.sidebar-recent-session__name[data-overflow-fade]:dir(rtl) {
|
||||
-webkit-mask-image: linear-gradient(to left, black 0, black calc(100% - 10px), transparent 100%);
|
||||
mask-image: linear-gradient(to left, black 0, black calc(100% - 10px), transparent 100%);
|
||||
}
|
||||
|
||||
.session-row-host:is(:hover, :focus-within) .sidebar-recent-session__name[data-overflow-reveal] {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, black 10px, black 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0, black 10px, black 100%);
|
||||
}
|
||||
|
||||
.session-row-host:is(:hover, :focus-within)
|
||||
.sidebar-recent-session__name[data-overflow-reveal]:dir(rtl) {
|
||||
-webkit-mask-image: linear-gradient(to left, transparent 0, black 10px, black 100%);
|
||||
mask-image: linear-gradient(to left, transparent 0, black 10px, black 100%);
|
||||
}
|
||||
|
||||
.session-row-host:is(:hover, :focus-within)
|
||||
.sidebar-recent-session__name[data-overflow-reveal]
|
||||
.sidebar-recent-session__name-content {
|
||||
transform: translateX(var(--overflow-reveal-translate, 0));
|
||||
transition: transform var(--overflow-reveal-duration, 900ms) linear 180ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-recent-session__name-content {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-recent-session--child {
|
||||
min-height: 30px;
|
||||
}
|
||||
@@ -2375,9 +2422,9 @@ body.update-dialog-open .sidebar-update-card__status {
|
||||
right: calc(var(--sidebar-child-session-toggle-width) + 4px);
|
||||
}
|
||||
|
||||
/* Centered actions overlap both lines, so the full text column yields their width. */
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child):is(:hover, :focus-within)
|
||||
.sidebar-recent-session__text {
|
||||
/* Centered actions overlap both lines, so reserve their width in every state.
|
||||
Hover and keyboard focus then reveal chrome without moving titles or badges. */
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text {
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
@@ -2387,13 +2434,6 @@ body.update-dialog-open .sidebar-update-card__status {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Touch keeps the actions permanently visible beside the persistent endcap. */
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text {
|
||||
padding-right: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-collapse-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
Reference in New Issue
Block a user