fix(studio): reveal the shown tab when its strip or position changes

Covers a strip back from hidden (another panel maximised) and a shown tab
moved by a closed or moved neighbour; the user's own scroll still stays put.
This commit is contained in:
Miguel Ángel
2026-09-23 12:17:42 -04:00
parent 6a5edd893a
commit 0fc5c4f4f8
2 changed files with 61 additions and 23 deletions
@@ -198,4 +198,31 @@ describe("dock tab fill", () => {
act(() => resizeAll());
expect(strip.scrollLeft).toBe(0);
});
it("reveals the shown tab again when its strip comes back from hidden", async () => {
const strip = stripOf("design");
if (!strip) throw new Error("no design strip");
await activate("variables");
Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
act(() => resizeAll());
// Another group is maximised, hiding this strip; the browser drops a hidden strip's scroll.
Object.defineProperty(strip, "clientWidth", { value: 0, configurable: true });
act(() => resizeAll());
strip.scrollLeft = 0;
Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
act(() => resizeAll());
expect(strip.scrollLeft).toBe(3 * TAB_STEP + TAB_WIDTH + 1 - 250);
});
it("reveals the shown tab when a panel beside it closes and it moves", async () => {
const strip = stripOf("design");
if (!strip) throw new Error("no design strip");
await activate("variables");
Object.defineProperty(strip, "clientWidth", { value: 150, configurable: true });
act(() => resizeAll());
strip.scrollLeft = 0;
await act(async () => useDockLayoutStore.getState().togglePanel("design"));
// Variables moved from the fourth place to the third; its right edge is at 290.
expect(strip.scrollLeft).toBe(2 * TAB_STEP + TAB_WIDTH + 1 - 150);
});
});
@@ -15,28 +15,45 @@ 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);
}
/** A strip's width and where its shown tab sits, as of the last pass. */
type StripShape = { width: number; tab: HTMLElement | null; left: number; size: number };
/**
* Reads where the shown tab sits; the write happens after every strip is read, so layout runs once.
* Only a strip that got narrower is scrolled, so a strip the user scrolled by hand stays put.
* True when the strip narrowed, came back from hidden, or its shown tab moved or resized. dockview
* reveals a tab only when it is activated, and the user's own scrolling changes none of these.
*/
function measureFill(list: HTMLElement, narrowed: boolean): () => void {
function reshaped(before: StripShape | undefined, now: StripShape) {
if (!before || now.width === 0) return false;
if (now.width < before.width || before.width === 0) return true;
return now.tab !== before.tab || now.left !== before.left || now.size !== before.size;
}
/** The scroll that shows the whole shown tab, e.g. after the strip narrows because its actions appeared. */
function revealedScroll(shape: StripShape, scrollLeft: number) {
if (shape.left < scrollLeft) return shape.left;
// Offset sizes are rounded while the tab's edge is fractional; a pixel over is clamped at the end.
return Math.max(scrollLeft, shape.left + shape.size + 1 - shape.width);
}
/** Reads where the shown tab sits; the write happens after every strip is read, so layout runs once. */
function measureFill(list: HTMLElement, shapes: WeakMap<HTMLElement, StripShape>): () => 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 shape = {
width: list.clientWidth,
tab,
left: tab?.offsetLeft ?? 0,
size: tab?.offsetWidth ?? 0,
};
const reveal = tab !== null && reshaped(shapes.get(list), shape);
shapes.set(list, shape);
const scrollLeft = list.scrollLeft;
const target = box && narrowed ? revealedScroll(box, scrollLeft, list.clientWidth) : scrollLeft;
const target = reveal ? revealedScroll(shape, scrollLeft) : scrollLeft;
return () => {
fill.hidden = !box;
if (!box) return;
fill.style.width = `${box.width}px`;
fill.style.transform = `translateX(${box.left}px)`;
fill.hidden = !tab;
if (!tab) return;
fill.style.width = `${shape.size}px`;
fill.style.transform = `translateX(${shape.left}px)`;
if (target !== scrollLeft) list.scrollLeft = target;
};
}
@@ -73,16 +90,10 @@ export function installTabFill(api: DockviewApi, root: HTMLElement): () => void
observed.add(element);
}
}
const widths = new WeakMap<HTMLElement, number>();
function narrowed(list: HTMLElement) {
const width = list.clientWidth;
const before = widths.get(list) ?? width;
widths.set(list, width);
return width < before;
}
const shapes = new WeakMap<HTMLElement, StripShape>();
function placeAll() {
const strips = [...lists()];
const writes = strips.map((list) => measureFill(list, narrowed(list)));
const writes = strips.map((list) => measureFill(list, shapes));
for (const write of writes) write();
for (const list of strips) markClippedEdges(list);
observe(strips.flatMap((list) => [list, ...list.querySelectorAll(":scope > .dv-tab")]));