fix(tabs): make vertical tab rail width follow mouse while dragging

This commit is contained in:
一颗红心
2026-09-22 17:18:19 +08:00
committed by GitHub
parent f652491198
commit 3c0cce8ce2
4 changed files with 91 additions and 14 deletions
@@ -175,13 +175,12 @@ const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.edi
// The icon-only collapse only exists in the vertical toolbar; horizontal
// placements must ignore the persisted collapse state entirely.
const isTabBarCollapsed = computed(() => isVerticalLayout.value && !!props.tabBarCollapsed);
// Outer [data-workspace-tab-navigation] / [data-special-page-navigation] owns
// the shared vertical rail width; fill that rail so nested pane bars stay in
// sync while the resize handle updates the outer panel (issue #9977).
const tabBarStyle = computed<CSSProperties | undefined>(() => {
if (!isVerticalLayout.value) return undefined;
if (props.tabBarCollapsed) {
return { width: "100%", flex: "0 0 100%" };
}
const width = props.tabBarWidth ?? 240;
return { width: `${width}px`, flex: `0 0 ${width}px` };
return { width: "100%", flex: "0 0 100%" };
});
const tabBarCollapseIcon = computed(() => {
const isLeft = settingsStore.editorSettings.tabPlacement === "left";
@@ -356,11 +356,11 @@ describe("AppTabBar group navigation portal", () => {
const rail = element(host, "[data-workspace-tab-navigation]");
const bar = element(rail, "[data-main-tab-bar]");
expect(rail.style.width).toBe("260px");
expect(bar.style.width).toBe("260px");
expect(bar.style.width).toBe("100%");
navigation.width = 320;
await settle();
expect(rail.style.width).toBe("320px");
expect(bar.style.width).toBe("320px");
expect(bar.style.width).toBe("100%");
navigation.collapsed = true;
await settle();
expect(rail.style.width).toBe("var(--collapsed-tab-rail-width)");
@@ -369,7 +369,7 @@ describe("AppTabBar group navigation portal", () => {
navigation.collapsed = false;
await settle();
expect(rail.style.width).toBe("320px");
expect(bar.style.width).toBe("320px");
expect(bar.style.width).toBe("100%");
expect(warnings).toEqual([]);
});
@@ -145,4 +145,61 @@ describe("usePanelResize", () => {
restored.setTabBarCollapsed(false);
expect(localStorage.getItem("dbx-tab-bar-collapsed")).toBe("false");
});
it("updates the outer workspace tab rail width and flex while dragging (issue #9977)", () => {
localStorage.setItem("dbx-tab-bar-width", "240");
const rail = document.createElement("div");
rail.setAttribute("data-workspace-tab-navigation", "");
rail.style.width = "240px";
rail.style.flex = "0 0 240px";
const target = document.createElement("div");
const tabBar = document.createElement("div");
tabBar.className = "app-tab-bar";
const handle = document.createElement("div");
tabBar.append(handle);
target.append(tabBar);
rail.append(target);
document.body.append(rail);
vi.spyOn(rail, "getBoundingClientRect").mockReturnValue(rect(0, 240));
const { tabBarWidth, startLeftTabBarResize } = usePanelResize();
handle.addEventListener("pointerdown", startLeftTabBarResize);
handle.dispatchEvent(pointerEvent("pointerdown", 240));
document.dispatchEvent(pointerEvent("pointermove", 320));
expect(rail.style.width).toBe("320px");
expect(rail.style.flex).toBe("0 0 320px");
expect(tabBar.style.width).toBe("");
document.dispatchEvent(pointerEvent("pointerup", 320));
expect(tabBarWidth.value).toBe(320);
expect(localStorage.getItem("dbx-tab-bar-width")).toBe("320");
});
it("shrinks a right-side tab rail toward the left edge while dragging", () => {
localStorage.setItem("dbx-tab-bar-width", "300");
const rail = document.createElement("div");
rail.setAttribute("data-special-page-navigation", "");
const tabBar = document.createElement("div");
const handle = document.createElement("div");
tabBar.append(handle);
rail.append(tabBar);
document.body.append(rail);
vi.spyOn(rail, "getBoundingClientRect").mockReturnValue(rect(900, 300));
const { tabBarWidth, startRightTabBarResize } = usePanelResize();
handle.addEventListener("pointerdown", startRightTabBarResize);
handle.dispatchEvent(pointerEvent("pointerdown", 900));
document.dispatchEvent(pointerEvent("pointermove", 960));
expect(rail.style.width).toBe("240px");
expect(rail.style.flex).toBe("0 0 240px");
document.dispatchEvent(pointerEvent("pointerup", 960));
expect(tabBarWidth.value).toBe(240);
});
});
+27 -6
View File
@@ -6,12 +6,33 @@ const PANEL_MIN_WIDTH = 240;
const DEFAULT_PANEL_MAX_WIDTH = 800;
type PanelMaxWidth = number | ((handle: HTMLElement | null) => number);
/** Vertical tab rails size via width + flex-basis; sidebar/AI panels only need width. */
const TAB_NAV_PANEL_SELECTOR = "[data-workspace-tab-navigation], [data-special-page-navigation]";
const RESIZE_PANEL_SELECTOR = `${TAB_NAV_PANEL_SELECTOR}, [data-app-sidebar]`;
function restoredPanelWidth(storageKey: string, fallback: number): number {
return Math.max(PANEL_MIN_WIDTH, Number(safeLocalStorageGet(storageKey)) || fallback);
}
/**
* Prefer the layout-owning panel root over the handle's immediate parent.
* Vertical tab handles live inside a nested `.app-tab-bar`, while width is
* owned by `[data-workspace-tab-navigation]` / `[data-special-page-navigation]`.
*/
function resolveResizePanel(handle: HTMLElement | null): HTMLElement | null {
return handle?.closest(RESIZE_PANEL_SELECTOR) ?? handle?.parentElement ?? null;
}
function applyPanelWidth(panel: HTMLElement | null, width: number) {
if (!panel) return;
panel.style.setProperty("width", `${width}px`);
if (panel.matches(TAB_NAV_PANEL_SELECTOR)) {
panel.style.setProperty("flex", `0 0 ${width}px`);
}
}
function availableAiPanelMaxWidth(handle: HTMLElement | null) {
const panel = handle?.parentElement;
const panel = resolveResizePanel(handle);
const flexibleContent = panel?.previousElementSibling as HTMLElement | null;
if (!panel || !flexibleContent) return DEFAULT_PANEL_MAX_WIDTH;
@@ -49,11 +70,11 @@ export function usePanelResize() {
document.body.append(resizeOverlay);
const resolvedMaxWidth = typeof maxWidth === "function" ? maxWidth(resizeHandle) : maxWidth;
const upperBound = Number.isFinite(resolvedMaxWidth) ? Math.max(PANEL_MIN_WIDTH, resolvedMaxWidth) : DEFAULT_PANEL_MAX_WIDTH;
const renderedWidth = resizeHandle?.parentElement?.getBoundingClientRect().width;
const panelElement = resolveResizePanel(resizeHandle);
const renderedWidth = panelElement?.getBoundingClientRect().width;
const requestedStartWidth = typeof renderedWidth === "number" && Number.isFinite(renderedWidth) && renderedWidth > 0 ? renderedWidth : widthRef.value;
const startWidth = Math.max(PANEL_MIN_WIDTH, Math.min(upperBound, requestedStartWidth));
widthRef.value = startWidth;
const panelElement = resizeHandle?.parentElement;
let currentWidth = startWidth;
let rafId: number | null = null;
@@ -63,12 +84,12 @@ export function usePanelResize() {
// happy-dom unit tests assert styles synchronously (no frame advance),
// so apply immediately under the test mode instead of via rAF.
if (import.meta.env.MODE === "test") {
panelElement?.style.setProperty("width", `${currentWidth}px`);
applyPanelWidth(panelElement, currentWidth);
return;
}
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
panelElement?.style.setProperty("width", `${currentWidth}px`);
applyPanelWidth(panelElement, currentWidth);
rafId = null;
});
};
@@ -78,7 +99,7 @@ export function usePanelResize() {
cancelAnimationFrame(rafId);
rafId = null;
}
panelElement?.style.setProperty("width", `${currentWidth}px`);
applyPanelWidth(panelElement, currentWidth);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", finishResize);
document.removeEventListener("pointercancel", finishResize);