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.
This commit is contained in:
Miguel Ángel
2026-09-23 10:30:30 -04:00
parent 23988644f6
commit 2d550eb35c
3 changed files with 64 additions and 23 deletions
@@ -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<ResizeObserverStub>();
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);
});
});
@@ -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<HTMLElement>(":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 {
+28 -21
View File
@@ -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();
}