From 2d550eb35c7b103416fb42694c2814fdf327b102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 23 Sep 2026 10:30:30 -0400 Subject: [PATCH] fix(studio): keep the shown tab in view when its strip narrows Focusing a panel draws its strip actions, which could hide the shown tab of a strip that only just fit. The load smoke checks move into their own function to keep the smoke runner simple. --- .../src/components/dock/DockTabs.test.tsx | 25 +++++++++- .../studio/src/components/dock/dockTabFill.ts | 13 ++++- scripts/studio-runtime-smoke.mjs | 49 +++++++++++-------- 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/packages/studio/src/components/dock/DockTabs.test.tsx b/packages/studio/src/components/dock/DockTabs.test.tsx index 0a9ae5cd51..c552e6922e 100644 --- a/packages/studio/src/components/dock/DockTabs.test.tsx +++ b/packages/studio/src/components/dock/DockTabs.test.tsx @@ -11,11 +11,23 @@ import { PANEL_IDS, type PanelId } from "./panelRegistry"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +// happy-dom never resizes anything; a test calls `resizeAll` to stand in for a real resize. +const liveObservers = new Set(); class ResizeObserverStub { + readonly callback: () => void; + constructor(callback: () => void) { + this.callback = callback; + liveObservers.add(this); + } observe() {} unobserve() {} - disconnect() {} + disconnect() { + liveObservers.delete(this); + } } +const resizeAll = () => { + for (const observer of liveObservers) observer.callback(); +}; (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub; // happy-dom has no layout: a tab sits at 100px per position and is 90px wide. @@ -160,4 +172,15 @@ describe("dock tab fill", () => { expect(stripOf("design")?.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1); expect(fill()?.style.transform).toBe(`translateX(${2 * TAB_STEP}px)`); }); + + it("keeps the shown tab whole when its strip narrows, as when the strip's actions appear", async () => { + const strip = stripOf("design"); + if (!strip) throw new Error("no design strip"); + await activate("variables"); + // The four tabs fit, so the strip is unscrolled. + strip.scrollLeft = 0; + Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true }); + act(() => resizeAll()); + expect(strip.scrollLeft).toBe(3 * TAB_STEP + TAB_WIDTH + 1 - 250); + }); }); diff --git a/packages/studio/src/components/dock/dockTabFill.ts b/packages/studio/src/components/dock/dockTabFill.ts index ebb1e0e03f..7b6c3d5721 100644 --- a/packages/studio/src/components/dock/dockTabFill.ts +++ b/packages/studio/src/components/dock/dockTabFill.ts @@ -15,16 +15,26 @@ function fillOf(list: HTMLElement): HTMLElement { return fill; } +/** The scroll that shows the whole shown tab, e.g. after the strip narrows because its actions appeared. */ +function revealedScroll(box: { left: number; width: number }, scrollLeft: number, width: number) { + if (box.left < scrollLeft) return box.left; + // Offset sizes are rounded while the tab's edge is fractional; a pixel over is clamped at the end. + return Math.max(scrollLeft, box.left + box.width + 1 - width); +} + /** Reads where the shown tab sits; the write happens after every strip is read, so layout runs once. */ function measureFill(list: HTMLElement): () => void { const fill = fillOf(list); const tab = list.querySelector(":scope > .dv-active-tab"); const box = tab ? { width: tab.offsetWidth, left: tab.offsetLeft } : null; + const scrollLeft = list.scrollLeft; + const target = box ? revealedScroll(box, scrollLeft, list.clientWidth) : scrollLeft; return () => { fill.hidden = !box; if (!box) return; fill.style.width = `${box.width}px`; fill.style.transform = `translateX(${box.left}px)`; + if (target !== scrollLeft) list.scrollLeft = target; }; } @@ -39,7 +49,8 @@ function markClippedEdges(list: HTMLElement) { } /** - * Slides one fill per tab strip under its shown tab, and keeps the clipped-edge marks current. + * Slides one fill per tab strip under its shown tab, keeps that tab in view, and keeps the + * clipped-edge marks current. * Returns a disposer. */ export function installTabFill(api: DockviewApi, root: HTMLElement): () => void { diff --git a/scripts/studio-runtime-smoke.mjs b/scripts/studio-runtime-smoke.mjs index 1840a5bc8d..cc524702bf 100644 --- a/scripts/studio-runtime-smoke.mjs +++ b/scripts/studio-runtime-smoke.mjs @@ -114,6 +114,33 @@ function clippedStrip(index, allowance) { return `${labels.join(", ")}: ${strip.scrollWidth}px of tabs in ${Math.floor(room)}px`; } +/** Every dock strip gives its tabs the whole width it can, and fits them while its group is active. */ +async function dockStripErrors(page) { + const found = []; + const reservedSlots = await page.$$eval( + ".dv-groupview.dv-inactive-group .dv-right-actions-container", + (slots) => slots.filter((slot) => slot.getBoundingClientRect().width > 0).length, + ); + if (reservedSlots > 0) { + found.push(`${reservedSlots} inactive dock strips hold width for actions they do not draw`); + } + const shownTabs = await page.$$(".dv-tabs-container .dv-active-tab"); + for (const [index, tab] of shownTabs.entries()) { + // An active group draws its strip actions, so this is the least room its tabs get. + await tab.click(); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const clipped = await page.evaluate(clippedStrip, index, MAC_FONT_ALLOWANCE_PX); + if (clipped) { + found.push( + `Dock tab strip clips a label at the default layout (${MAC_FONT_ALLOWANCE_PX}px macOS allowance): ${clipped}`, + ); + } + } + return found; +} + export function isExpectedStudioSmokeError(message) { return message.includes("favicon.ico"); } @@ -162,27 +189,7 @@ export async function runStudioRuntimeSmoke(targetUrl) { return textContent.includes("Something went wrong") ? textContent : null; }); if (errorBoundary) errors.push(`React error boundary triggered: ${errorBoundary}`); - const reservedSlots = await page.$$eval( - ".dv-groupview.dv-inactive-group .dv-right-actions-container", - (slots) => slots.filter((slot) => slot.getBoundingClientRect().width > 0).length, - ); - if (reservedSlots > 0) { - errors.push(`${reservedSlots} inactive dock strips hold width for actions they do not draw`); - } - const shownTabs = await page.$$(".dv-tabs-container .dv-active-tab"); - for (const [index, tab] of shownTabs.entries()) { - // An active group draws its strip actions, so this is the least room its tabs get. - await tab.click(); - await page.evaluate( - () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), - ); - const clipped = await page.evaluate(clippedStrip, index, MAC_FONT_ALLOWANCE_PX); - if (clipped) { - errors.push( - `Dock tab strip clips a label at the default layout (${MAC_FONT_ALLOWANCE_PX}px macOS allowance): ${clipped}`, - ); - } - } + errors.push(...(await dockStripErrors(page))); } finally { await browser.close(); }