Skip to content

Commit 2d550eb

Browse files
committed
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.
1 parent 2398864 commit 2d550eb

3 files changed

Lines changed: 64 additions & 23 deletions

File tree

‎packages/studio/src/components/dock/DockTabs.test.tsx‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,23 @@ import { PANEL_IDS, type PanelId } from "./panelRegistry";
1111

1212
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1313

14+
// happy-dom never resizes anything; a test calls `resizeAll` to stand in for a real resize.
15+
const liveObservers = new Set<ResizeObserverStub>();
1416
class ResizeObserverStub {
17+
readonly callback: () => void;
18+
constructor(callback: () => void) {
19+
this.callback = callback;
20+
liveObservers.add(this);
21+
}
1522
observe() {}
1623
unobserve() {}
17-
disconnect() {}
24+
disconnect() {
25+
liveObservers.delete(this);
26+
}
1827
}
28+
const resizeAll = () => {
29+
for (const observer of liveObservers) observer.callback();
30+
};
1931
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
2032

2133
// happy-dom has no layout: a tab sits at 100px per position and is 90px wide.
@@ -160,4 +172,15 @@ describe("dock tab fill", () => {
160172
expect(stripOf("design")?.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1);
161173
expect(fill()?.style.transform).toBe(`translateX(${2 * TAB_STEP}px)`);
162174
});
175+
176+
it("keeps the shown tab whole when its strip narrows, as when the strip's actions appear", async () => {
177+
const strip = stripOf("design");
178+
if (!strip) throw new Error("no design strip");
179+
await activate("variables");
180+
// The four tabs fit, so the strip is unscrolled.
181+
strip.scrollLeft = 0;
182+
Object.defineProperty(strip, "clientWidth", { value: 250, configurable: true });
183+
act(() => resizeAll());
184+
expect(strip.scrollLeft).toBe(3 * TAB_STEP + TAB_WIDTH + 1 - 250);
185+
});
163186
});

‎packages/studio/src/components/dock/dockTabFill.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,26 @@ function fillOf(list: HTMLElement): HTMLElement {
1515
return fill;
1616
}
1717

18+
/** The scroll that shows the whole shown tab, e.g. after the strip narrows because its actions appeared. */
19+
function revealedScroll(box: { left: number; width: number }, scrollLeft: number, width: number) {
20+
if (box.left < scrollLeft) return box.left;
21+
// Offset sizes are rounded while the tab's edge is fractional; a pixel over is clamped at the end.
22+
return Math.max(scrollLeft, box.left + box.width + 1 - width);
23+
}
24+
1825
/** Reads where the shown tab sits; the write happens after every strip is read, so layout runs once. */
1926
function measureFill(list: HTMLElement): () => void {
2027
const fill = fillOf(list);
2128
const tab = list.querySelector<HTMLElement>(":scope > .dv-active-tab");
2229
const box = tab ? { width: tab.offsetWidth, left: tab.offsetLeft } : null;
30+
const scrollLeft = list.scrollLeft;
31+
const target = box ? revealedScroll(box, scrollLeft, list.clientWidth) : scrollLeft;
2332
return () => {
2433
fill.hidden = !box;
2534
if (!box) return;
2635
fill.style.width = `${box.width}px`;
2736
fill.style.transform = `translateX(${box.left}px)`;
37+
if (target !== scrollLeft) list.scrollLeft = target;
2838
};
2939
}
3040

@@ -39,7 +49,8 @@ function markClippedEdges(list: HTMLElement) {
3949
}
4050

4151
/**
42-
* Slides one fill per tab strip under its shown tab, and keeps the clipped-edge marks current.
52+
* Slides one fill per tab strip under its shown tab, keeps that tab in view, and keeps the
53+
* clipped-edge marks current.
4354
* Returns a disposer.
4455
*/
4556
export function installTabFill(api: DockviewApi, root: HTMLElement): () => void {

‎scripts/studio-runtime-smoke.mjs‎

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,33 @@ function clippedStrip(index, allowance) {
114114
return `${labels.join(", ")}: ${strip.scrollWidth}px of tabs in ${Math.floor(room)}px`;
115115
}
116116

117+
/** Every dock strip gives its tabs the whole width it can, and fits them while its group is active. */
118+
async function dockStripErrors(page) {
119+
const found = [];
120+
const reservedSlots = await page.$$eval(
121+
".dv-groupview.dv-inactive-group .dv-right-actions-container",
122+
(slots) => slots.filter((slot) => slot.getBoundingClientRect().width > 0).length,
123+
);
124+
if (reservedSlots > 0) {
125+
found.push(`${reservedSlots} inactive dock strips hold width for actions they do not draw`);
126+
}
127+
const shownTabs = await page.$$(".dv-tabs-container .dv-active-tab");
128+
for (const [index, tab] of shownTabs.entries()) {
129+
// An active group draws its strip actions, so this is the least room its tabs get.
130+
await tab.click();
131+
await page.evaluate(
132+
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
133+
);
134+
const clipped = await page.evaluate(clippedStrip, index, MAC_FONT_ALLOWANCE_PX);
135+
if (clipped) {
136+
found.push(
137+
`Dock tab strip clips a label at the default layout (${MAC_FONT_ALLOWANCE_PX}px macOS allowance): ${clipped}`,
138+
);
139+
}
140+
}
141+
return found;
142+
}
143+
117144
export function isExpectedStudioSmokeError(message) {
118145
return message.includes("favicon.ico");
119146
}
@@ -162,27 +189,7 @@ export async function runStudioRuntimeSmoke(targetUrl) {
162189
return textContent.includes("Something went wrong") ? textContent : null;
163190
});
164191
if (errorBoundary) errors.push(`React error boundary triggered: ${errorBoundary}`);
165-
const reservedSlots = await page.$$eval(
166-
".dv-groupview.dv-inactive-group .dv-right-actions-container",
167-
(slots) => slots.filter((slot) => slot.getBoundingClientRect().width > 0).length,
168-
);
169-
if (reservedSlots > 0) {
170-
errors.push(`${reservedSlots} inactive dock strips hold width for actions they do not draw`);
171-
}
172-
const shownTabs = await page.$$(".dv-tabs-container .dv-active-tab");
173-
for (const [index, tab] of shownTabs.entries()) {
174-
// An active group draws its strip actions, so this is the least room its tabs get.
175-
await tab.click();
176-
await page.evaluate(
177-
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
178-
);
179-
const clipped = await page.evaluate(clippedStrip, index, MAC_FONT_ALLOWANCE_PX);
180-
if (clipped) {
181-
errors.push(
182-
`Dock tab strip clips a label at the default layout (${MAC_FONT_ALLOWANCE_PX}px macOS allowance): ${clipped}`,
183-
);
184-
}
185-
}
192+
errors.push(...(await dockStripErrors(page)));
186193
} finally {
187194
await browser.close();
188195
}

0 commit comments

Comments
 (0)