From 3caa4f2b897b39fa36f3388174cd355c57703901 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 9 Sep 2026 22:44:56 +0800 Subject: [PATCH] fix(ui): refine tab bar group layout and focus fallback --- apps/desktop/src/App.vue | 11 + .../components/layout/EditorGroupTabBar.vue | 96 ++++++- .../EditorGroupTabBar.groups.spec.ts | 57 +++- .../__tests__/EditorGroupTabBar.spec.ts | 3 +- .../__tests__/SqlEditorWorkspace.spec.ts | 3 +- .../appContentSurfaces.contract.spec.ts | 5 + .../src/components/layout/appTabBar.css | 244 +++++++++++------- 7 files changed, 304 insertions(+), 115 deletions(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index d3a3ff3c21..4d70aa467a 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -969,6 +969,16 @@ function activateQuerySurface() { activateMainContentSurface("query"); } +function activateOpenSpecialPageFallback() { + if (settingsPageTabOpen.value) { + activateMainContentSurface("settings"); + return; + } + if (driverStoreTabOpen.value) { + activateMainContentSurface("driverStore"); + } +} + function closeSettingsPage() { settingsPageTabOpen.value = false; if (settingsReturnSurface.value === "driverStore" && driverStoreTabOpen.value) { @@ -1179,6 +1189,7 @@ watch( } if (id) newQueryContextSource.value = "tab"; if (id) activateQuerySurface(); + else if (previousId) activateOpenSpecialPageFallback(); selectedSql.value = ""; activeOutputView.value = "result"; if (id) queryStore.reloadEvictedTab(id); diff --git a/apps/desktop/src/components/layout/EditorGroupTabBar.vue b/apps/desktop/src/components/layout/EditorGroupTabBar.vue index 7fc06e1508..e31e87b34c 100644 --- a/apps/desktop/src/components/layout/EditorGroupTabBar.vue +++ b/apps/desktop/src/components/layout/EditorGroupTabBar.vue @@ -421,6 +421,9 @@ const sortedPinnedTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab const sortedRegularTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab) => !tab.pinned))); const collapsedTabGroups = ref>(new Set()); +const collapsingTabGroups = ref>(new Set()); +const tabGroupCollapseTimers = new Map(); +const TAB_GROUP_COLLAPSE_MS = 140; const pendingTabScrollRestore = ref<{ fixed: number; regular: number } | null>(null); const tabGroupPalette = ["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"]; const tabGroupEditorOpen = ref(false); @@ -490,6 +493,53 @@ function restoreTabScrollPosition(position: { fixed: number; regular: number }) if (regularTabsRowRef.value) regularTabsRowRef.value.scrollLeft = position.regular; } +function captureExpandedTabGroupWidths(groupIds: Set) { + if (isWrapLayout.value || isVerticalLayout.value) return; + tabsContainerRef.value?.querySelectorAll(".tab-group-entry[data-tab-group-id]").forEach((entry) => { + const groupId = entry.dataset.tabGroupId; + if (!groupId || !groupIds.has(groupId) || entry.classList.contains("tab-group-entry--collapsed")) return; + const width = entry.getBoundingClientRect().width; + if (width <= 0) return; + entry.style.setProperty("--tab-group-entry-expanded-width", String(width) + "px"); + }); +} + +function cancelTabGroupCollapse(groupId: string) { + const timer = tabGroupCollapseTimers.get(groupId); + if (timer !== undefined) window.clearTimeout(timer); + tabGroupCollapseTimers.delete(groupId); + if (!collapsingTabGroups.value.has(groupId)) return; + const next = new Set(collapsingTabGroups.value); + next.delete(groupId); + collapsingTabGroups.value = next; +} + +function beginTabGroupCollapse(groupIds: Set) { + if (isWrapLayout.value) { + collapsedTabGroups.value = new Set([...collapsedTabGroups.value, ...groupIds]); + nextTick(refreshHorizontalTabOverflow); + return; + } + + const pending = [...groupIds].filter((groupId) => !collapsedTabGroups.value.has(groupId) && !collapsingTabGroups.value.has(groupId)); + if (!pending.length) return; + captureExpandedTabGroupWidths(new Set(pending)); + collapsingTabGroups.value = new Set([...collapsingTabGroups.value, ...pending]); + collapsedTabGroups.value = new Set([...collapsedTabGroups.value, ...pending]); + pending.forEach((groupId) => { + tabGroupCollapseTimers.set( + groupId, + window.setTimeout(() => { + tabGroupCollapseTimers.delete(groupId); + const collapsing = new Set(collapsingTabGroups.value); + collapsing.delete(groupId); + collapsingTabGroups.value = collapsing; + nextTick(refreshHorizontalTabOverflow); + }, TAB_GROUP_COLLAPSE_MS), + ); + }); +} + function refreshHorizontalTabOverflow() { updateScrollButtons(); fixedTabsScroll.updateScrollButtons(); @@ -499,17 +549,27 @@ function refreshHorizontalTabOverflow() { function handleTabGroupTransitionEnd(event: TransitionEvent) { if (event.propertyName !== "max-width") return; + const entry = event.currentTarget as HTMLElement; + if (!entry.classList.contains("tab-group-entry--collapsed")) { + entry.style.removeProperty("--tab-group-entry-expanded-width"); + } refreshHorizontalTabOverflow(); } function toggleTabGroup(tab: QueryTab) { preserveTabScrollPosition(); const groupId = tabGroupId(tab); + if (collapsingTabGroups.value.has(groupId)) { + cancelTabGroupCollapse(groupId); + } const next = new Set(collapsedTabGroups.value); - if (next.has(groupId)) next.delete(groupId); - else next.add(groupId); - collapsedTabGroups.value = next; - nextTick(refreshHorizontalTabOverflow); + if (next.has(groupId)) { + next.delete(groupId); + collapsedTabGroups.value = next; + nextTick(refreshHorizontalTabOverflow); + return; + } + beginTabGroupCollapse(new Set([groupId])); } function tabGroupIdsInPane() { @@ -519,12 +579,12 @@ function tabGroupIdsInPane() { function collapseAllTabGroups() { preserveTabScrollPosition(); - collapsedTabGroups.value = new Set(tabGroupIdsInPane()); - nextTick(refreshHorizontalTabOverflow); + beginTabGroupCollapse(new Set(tabGroupIdsInPane())); } function expandAllTabGroups() { preserveTabScrollPosition(); + [...tabGroupCollapseTimers.keys()].forEach(cancelTabGroupCollapse); collapsedTabGroups.value = new Set(); nextTick(refreshHorizontalTabOverflow); } @@ -534,6 +594,7 @@ function expandTabGroupForTab(tabId: string | null) { const tab = queryStore.tabs.find((item) => item.id === tabId); if (!tab) return; const groupId = tabGroupId(tab); + cancelTabGroupCollapse(groupId); if (!collapsedTabGroups.value.has(groupId)) return; const next = new Set(collapsedTabGroups.value); next.delete(groupId); @@ -1215,7 +1276,11 @@ function handleTabPointerUp(event: PointerEvent) { queryStore.moveTabToGroup(payload.tabId, targetGroupId, index); } -onUnmounted(cleanupTabDrag); +onUnmounted(() => { + cleanupTabDrag(); + tabGroupCollapseTimers.forEach((timer) => window.clearTimeout(timer)); + tabGroupCollapseTimers.clear(); +}); const tabScrollBehavior = ref("smooth"); @@ -1230,7 +1295,7 @@ watch( if (container) { const activeEl = container.querySelector('[data-active-tab="true"]'); if (activeEl) { - activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: "center" }); + activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: isVerticalLayout.value ? "nearest" : "center" }); } } } @@ -1305,10 +1370,10 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs? :data-placement="settingsStore.editorSettings.tabPlacement" > -
+
- +
props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
props.specialPageTabs?.settingsActive, () => props.specialPageTabs? : ['h-7 rounded-md border', isTabActive(entry.tab) ? 'text-foreground font-medium' : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90'], { 'tab-group-tab': entry.grouping, - 'tab-group-tab--collapsed': entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab), 'tab-group-tab--first': entry.grouping && entry.groupFirst, 'tab-group-tab--last': entry.grouping && entry.groupLast, }, diff --git a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts index 174a4d0e4b..ad882da194 100644 --- a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.groups.spec.ts @@ -99,12 +99,14 @@ describe("EditorGroupTabBar semantic tab groups", () => { }); it("applies the group rail classes to clustered pills", () => { - // The horizontal underline and vertical indent rails only render when the - // pill carries tab-group-tab with its --first/--last markers. + // Grouped pills carry tab-group-tab for the entry underline and the + // vertical rail, with first/last markers available for side layouts. expect(source).toContain("'tab-group-tab': entry.grouping,"); expect(source).toContain("'tab-group-tab--first': entry.grouping && entry.groupFirst,"); expect(source).toContain("'tab-group-tab--last': entry.grouping && entry.groupLast,"); - expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab::after"); + expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\) \.tab-group-tab::after\s*\{[^}]*bottom:\s*-0\.5px;/s); + expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after"); + expect(sharedStyles).toMatch(/\.tab-group-tab\[data-active-tab="true"\]::after,[\s\S]*?\.tab-group-entry:has\(\.tab-group-tab\[data-active-tab="true"\]\)::after\s*\{[^}]*z-index:\s*2;[^}]*var\(--foreground\) 72%/s); }); it("hides collapsed cluster pills but reveals them while searching", () => { @@ -114,6 +116,16 @@ describe("EditorGroupTabBar semantic tab groups", () => { it("remeasures overflow after a single-row group expansion finishes", () => { expect(source).toContain('@transitionend.self="handleTabGroupTransitionEnd"'); + expect(source).toContain(':data-tab-group-id="entry.grouping ? tabGroupId(entry.tab) : undefined"'); + expect(source).toContain("captureExpandedTabGroupWidths(new Set(pending))"); + expect(source).toContain("'tab-group-entry--collapsing'"); + expect(source).toContain("window.setTimeout(() =>"); + expect(sharedStyles).toContain("@keyframes tab-group-collapse"); + expect(sharedStyles).toMatch(/\.tab-group-entry--collapsing\s*\{[^}]*animation:\s*tab-group-collapse 140ms ease forwards;/s); + expect(source).toContain('entry.style.removeProperty("--tab-group-entry-expanded-width")'); + expect(sharedStyles).toContain("max-width: var(--tab-group-entry-expanded-width, 100%)"); + expect(sharedStyles).toMatch(/\.tab-group-entry\[data-tab-group-id\] > \.tab-group-tab\s*\{[^}]*width:\s*var\(--tab-group-entry-expanded-width, auto\);[^}]*min-width:\s*var\(--tab-group-entry-expanded-width, max-content\) !important;[^}]*flex:\s*none;/s); + expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\) \.tab-group-entry\s*\{[^}]*min-width:\s*0;/s); const handler = sourceBetween("function handleTabGroupTransitionEnd", "function toggleTabGroup"); expect(handler).toContain('event.propertyName !== "max-width"'); expect(handler).toContain("refreshHorizontalTabOverflow();"); @@ -145,10 +157,17 @@ describe("EditorGroupTabBar semantic tab groups", () => { expect(source).toContain(':data-placement="settingsStore.editorSettings.tabPlacement"'); expect(source).toContain(':data-group-mode="settingsStore.editorSettings.tabGroupMode"'); expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header"); + expect(sharedStyles).toMatch(/\.app-tab-bar\.classic-tab-layout:not\(\.vertical-tab-layout\) \.tab-group-header-content\s*\{[^}]*height:\s*100%;[^}]*border-radius:\s*0;/s); + expect(sharedStyles).toMatch(/\.tab-overflow-control\s*\{[^}]*width:\s*34px;[^}]*flex:\s*0 0 34px;/s); + expect(sharedStyles).toContain("padding-inline-end: 2.125rem;"); + expect(sharedStyles).toContain("inset-inline-end: 2.125rem;"); + expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\)\[data-placement="top"\] \.tab-overflow-control::before\s*\{[^}]*inset-inline:\s*-0\.25rem 0;/s); + expect(sharedStyles).toMatch(/\.app-tab-scroll\.wrap-mode\.classic-wrap \.tab-group-header,[\s\S]*?\.tab-group-header-content\s*\{[^}]*height:\s*2rem !important;/s); + expect(sharedStyles).toMatch(/\.app-tab-scroll\.wrap-mode\.classic-wrap \.tab-group-header:not\(\.tab-group-header--collapsed\)::after\s*\{[^}]*right:\s*-1px;/s); expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after"); + expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\) \.tab-group-header::after\s*\{[^}]*bottom:\s*0;/s); expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after"); - expect(sharedStyles).toContain('.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after'); - expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after"); + expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\)\[data-placement="bottom"\] \.tab-group-tab::after\s*\{[^}]*top:\s*-0\.5px;/s); expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode.classic-wrap .tab-section--horizontal > .app-tab-pill"); expect(sharedStyles).toContain(".app-tab-scroll.wrap-mode:not(.classic-wrap) .tab-section--horizontal > .app-tab-pill"); expect(sharedStyles).toContain("row-gap: 0.375rem;"); @@ -158,8 +177,9 @@ describe("EditorGroupTabBar semantic tab groups", () => { expect(sharedStyles).toContain(".app-tab-bar.separated-tab-layout.horizontal-fixed-tabs .app-tab-scroll:not(.wrap-mode)"); expect(sharedStyles).toContain(".horizontal-fixed-tabs-scroll.wrap-mode"); expect(sharedStyles).toContain("row-gap: 0.375rem !important;"); - expect(sharedStyles).toContain("bottom: 0.375rem;"); - expect(sharedStyles).toContain("bottom: 0.25rem;"); + expect(sharedStyles).toMatch(/\.horizontal-fixed-tabs-scroll\.wrap-mode\.classic-wrap\s*\{[^}]*row-gap:\s*0\.25rem !important;/s); + expect(sharedStyles).toMatch(/\.app-tab-bar\.classic-tab-layout:not\(\.vertical-tab-layout\):not\(:has\(\.wrap-mode\)\) \.tab-group-entry\s*\{[^}]*height:\s*100%;[^}]*max-height:\s*none;/s); + expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\) \.tab-group-entry:has\(\.tab-group-tab\)::after\s*\{[^}]*bottom:\s*0;/s); expect(sharedStyles).toContain("scroll-margin-inline-end: 1px;"); }); @@ -184,6 +204,26 @@ describe("EditorGroupTabBar vertical placement", () => { expect(source).toContain("isVerticalLayout ? 'flex-col items-stretch overflow-y-auto overflow-x-hidden py-1'"); }); + it("keeps vertical rows fixed and aligns the sidebar toolbar with content headers", () => { + expect(sharedStyles).toMatch(/\.vertical-tab-layout\s*\{[^}]*--tab-group-root-rail-x:\s*1\.16rem;[^}]*--tab-group-tab-inset:\s*1\.8rem;[^}]*--tab-group-branch-width:\s*0\.5rem;/s); + expect(sharedStyles).toContain("width: calc(100% - var(--tab-group-tab-inset) - 0.25rem);"); + expect(sharedStyles).toContain("margin-inline: var(--tab-group-tab-inset) 0.25rem;"); + expect(sharedStyles).toContain("padding-inline: 0.2rem 0.25rem !important;"); + expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-entry\s*\{[^}]*flex:\s*none;/s); + expect(sharedStyles).toMatch(/\.vertical-tab-layout \.app-tab-scroll\s*\{[^}]*overflow-x:\s*hidden;/s); + expect(sharedStyles).toMatch(/\.vertical-tab-layout \.app-tab-pill\s*\{[^}]*border-radius:\s*var\(--dbx-radius-md\);/s); + expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-entry\s*\{[^}]*max-height:\s*2rem;/s); + expect(sharedStyles).toContain("max-height 140ms ease,"); + expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-entry--collapsed\s*\{[^}]*max-height:\s*0;[^}]*opacity:\s*0;/s); + expect(sharedStyles).toMatch(/\.vertical-tab-layout--collapsed \.tab-group-entry\s*\{[^}]*max-height:\s*2\.5rem;[^}]*overflow:\s*visible;/s); + expect(sharedStyles).toMatch(/\.vertical-tab-layout--collapsed \.tab-group-entry--collapsed\s*\{[^}]*max-height:\s*0;[^}]*overflow:\s*hidden;/s); + expect(source).toContain("if (isWrapLayout.value || isVerticalLayout.value) return;"); + expect(source).toContain("if (isWrapLayout.value) {"); + expect(source).toContain('inline: isVerticalLayout.value ? "nearest" : "center"'); + expect(source).toContain('class="flex h-9 shrink-0 items-center gap-0.5 border-b p-1"'); + expect(source).toContain('class="h-7 w-full pl-7 text-sm"'); + }); + it("positions each pane's bar by the global placement without mixing directions", () => { expect(groupSource).toContain('case "bottom":'); expect(groupSource).toContain('return "flex-col-reverse";'); @@ -215,10 +255,11 @@ describe("EditorGroupTabBar vertical placement", () => { it("uses the sidebar rail and soft active shadow for vertical pills", () => { expect(sharedStyles).toContain(".vertical-tab-layout .tab-group-tab::before"); + expect(sharedStyles).toMatch(/\.vertical-tab-layout \.tab-group-tab::before\s*\{[^}]*top:\s*-0\.25rem;[^}]*bottom:\s*-0\.25rem;/s); expect(sharedStyles).toContain('.vertical-tab-layout .app-tab-pill[data-active-tab="true"]'); expect(sharedStyles).toContain("inset 0 0 0 1px color-mix"); expect(sharedStyles).toContain(".vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed)::after"); - expect(sharedStyles).toContain("margin-inline: var(--tab-group-tab-inset) 0.5rem;"); + expect(sharedStyles).toContain("margin-inline: var(--tab-group-tab-inset) 0.25rem;"); }); }); diff --git a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.spec.ts index 88aefd3b5b..368243cf41 100644 --- a/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/EditorGroupTabBar.spec.ts @@ -28,7 +28,8 @@ describe("EditorGroupTabBar compatibility with AppTabBar", () => { expect(source).toContain("function cleanupTabDrag(event?: Event)"); expect(source).toContain('window.addEventListener("pointercancel", cleanupTabDrag)'); expect(source).toContain('window.addEventListener("blur", cleanupTabDrag)'); - expect(source).toContain("onUnmounted(cleanupTabDrag)"); + expect(source).toContain("onUnmounted(() => {"); + expect(source).toContain("cleanupTabDrag();"); }); it("validates drag source and target groups before moving", () => { diff --git a/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.spec.ts b/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.spec.ts index 0c62a102c7..6be65187ca 100644 --- a/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.spec.ts @@ -69,7 +69,8 @@ describe("SQL editor workspace single-group tracer bullet", () => { expect(tabMenuSource).toContain("visible: options.canRename"); expect(groupTabBarSource).toContain("createRenameDuplicateTabItems"); expect(groupTabBarSource).toContain("cleanupTabDrag"); - expect(groupTabBarSource).toContain("onUnmounted(cleanupTabDrag)"); + expect(groupTabBarSource).toContain("onUnmounted(() => {"); + expect(groupTabBarSource).toContain("cleanupTabDrag();"); expect(groupTabBarSource).toContain("pointercancel"); }); diff --git a/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts b/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts index 2d42342bc0..cfab0ed9b6 100644 --- a/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/appContentSurfaces.contract.spec.ts @@ -28,6 +28,11 @@ describe("App main content surface structure", () => { expect(appSource).not.toContain('v-if="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"'); }); + it("returns to an open special page after the final query tab closes", () => { + expect(appSource).toContain("else if (previousId) activateOpenSpecialPageFallback();"); + expect(appSource).toMatch(/function activateOpenSpecialPageFallback\(\)[\s\S]*?settingsPageTabOpen\.value[\s\S]*?activateMainContentSurface\("settings"\)[\s\S]*?driverStoreTabOpen\.value[\s\S]*?activateMainContentSurface\("driverStore"\)/); + }); + it("anchors the drag-back hit test on every pane strip and the special-surfaces bar", () => { const groupBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8"); const slimBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8"); diff --git a/apps/desktop/src/components/layout/appTabBar.css b/apps/desktop/src/components/layout/appTabBar.css index 235c07bdd2..bae702d027 100644 --- a/apps/desktop/src/components/layout/appTabBar.css +++ b/apps/desktop/src/components/layout/appTabBar.css @@ -184,16 +184,42 @@ /* Keep the overflow affordance out of the measurement box. Otherwise its own * width reduces the scroll viewport and can create a false overflow state. */ .tab-overflow-control { + box-sizing: border-box; + width: 34px; + flex: 0 0 34px; padding: 0; background: transparent; + justify-content: flex-end; } .has-tab-overflow-control .app-tab-strip { - padding-inline-end: 2.5rem; + padding-inline-end: 2.125rem; } .has-tab-overflow-control .app-tab-scrollbar { - inset-inline-end: 2.5rem; + inset-inline-end: 2.125rem; +} + +/* The top-row overflow control overlays a small reserved area. Blend that + * area into the tab strip so the gap before the button does not read as an + * opaque empty block while keeping the button itself fully visible. */ +.app-tab-bar:not(.vertical-tab-layout)[data-placement="top"] .tab-overflow-control { + isolation: isolate; +} + +.app-tab-bar:not(.vertical-tab-layout)[data-placement="top"] .tab-overflow-control::before { + content: ""; + position: absolute; + z-index: -1; + inset-block: 0; + inset-inline: -0.25rem 0; + background: color-mix(in srgb, var(--background) 48%, transparent); + pointer-events: none; +} + +.app-tab-bar:not(.vertical-tab-layout)[data-placement="top"] .tab-overflow-control button { + position: relative; + z-index: 1; } /* In the two-row layout, only the first row shares its right edge with the @@ -247,11 +273,17 @@ /* 经典布局 + 多行模式:优化多行标签显示 */ .app-tab-scroll.classic-wrap { row-gap: 0.25rem; - padding-top: 0.25rem; - padding-bottom: 0.25rem; + padding-top: 0; + padding-bottom: 0; align-items: flex-start; } +/* Match the space between regular and pinned sections to the classic wrap + * strip's 4px outer padding. The shared fixed-row gap is 6px for modern tabs. */ +.horizontal-fixed-tabs-scroll.wrap-mode.classic-wrap { + row-gap: 0.25rem !important; +} + /* 经典布局下 h-full 在 height:auto 容器中失效,改为固定高度 */ .app-tab-scroll.classic-wrap .tab-group-entry { height: 2rem; @@ -512,6 +544,18 @@ min-height: 2rem !important; } +.app-tab-scroll.wrap-mode.classic-wrap .tab-group-header, +.app-tab-scroll.wrap-mode.classic-wrap .tab-group-header-content { + height: 2rem !important; + min-height: 2rem; +} + +/* Classic wrapped headers only need to bridge the subpixel border seam to the + * first member tab. The shared 0.2rem extension visibly overshoots here. */ +.app-tab-scroll.wrap-mode.classic-wrap .tab-group-header:not(.tab-group-header--collapsed)::after { + right: -1px; +} + /* Settings and Driver Manager are rendered directly in the section instead * of inside a tab-group-entry wrapper. Their classic h-full class must size * to one wrapped row, not to the full multi-row section. */ @@ -538,6 +582,11 @@ background: var(--tab-group-soft); } +.app-tab-bar.classic-tab-layout:not(.vertical-tab-layout) .tab-group-header-content { + height: 100%; + border-radius: 0; +} + .app-tab-bar:not(.vertical-tab-layout) .tab-group-header:hover .tab-group-header-content, .app-tab-bar:not(.vertical-tab-layout) .tab-group-header--active .tab-group-header-content { background: color-mix(in srgb, var(--tab-group-color) 18%, transparent); @@ -581,28 +630,13 @@ align-self: stretch; } -/* Separated tabs are 1.75rem tall inside a row with vertical padding. Group - * entries stretch to the row for the continuous accent, so center the pill - * within that wrapper instead of leaving it attached to the row's top edge. */ +/* Keep the separated pill centered inside its group entry. The entry also owns + * the group accent, so its underline shares the header's exact bottom edge. */ .app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab) { display: flex; align-items: center; } -.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)::after { - content: ""; - position: absolute; - z-index: 1; - right: 0; - bottom: 0; - left: 0; - height: 2px; - border-radius: 9999px; - background: var(--tab-group-color); - pointer-events: none; - opacity: 0.82; -} - /* Wrapped group members need the same continuous group rule as the single * row layout. Keep the stripe on the entry so it spans each full tab and * meets the group header stripe without relying on the pill's own height. */ @@ -627,17 +661,16 @@ opacity: 0.82; } -.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab::after { - display: none; -} - +/* Single-row entries clip their contents while animating group expansion. + * Draw the accent on the pill so it remains visible, compensating for the + * pill's 0.5px border to align it with the header accent. */ .app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-tab::after { content: ""; display: block; position: absolute; z-index: 1; right: -0.2rem; - bottom: 0; + bottom: -0.5px; left: -0.2rem; height: 2px; border-radius: 9999px; @@ -646,17 +679,28 @@ opacity: 0.82; } -.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after { - display: none; +/* The group rail sits above the classic active inset shadow. Reuse the same + * rail segment as the active indicator so a colored group cannot hide it. */ +.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-tab[data-active-tab="true"]::after, +.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode) .tab-group-entry:has(.tab-group-tab[data-active-tab="true"])::after { + z-index: 2; + background: color-mix(in srgb, var(--foreground) 72%, transparent); + opacity: 1; } .app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-tab::after { - top: 0; - right: -0.2rem; + top: -0.5px; bottom: auto; +} + +.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--first::after { left: -0.2rem; } +.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after { + right: 0; +} + .app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-entry:has(.tab-group-tab)::after { top: 0; bottom: auto; @@ -667,42 +711,11 @@ bottom: auto; } -/* The separated pill is 1.75rem high and centered in a 2.5rem row. Align the - * group rail with the pill edge, not with the outer row edge. */ -.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-header::after, -.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)::after { - bottom: 0.375rem; -} - -.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-header::after, -.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry:has(.tab-group-tab)::after { - bottom: 0.25rem; -} - -.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-header::after, -.app-tab-bar.separated-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-entry:has(.tab-group-tab)::after { - top: 0.375rem; - bottom: auto; -} - -.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-header::after, -.app-tab-bar.separated-tab-layout.horizontal-fixed-tabs:not(.vertical-tab-layout):not(:has(.wrap-mode))[data-placement="bottom"] .tab-group-entry:has(.tab-group-tab)::after { - top: 0.25rem; - bottom: auto; -} - -.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--first::after { - left: -0.2rem; -} - -.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after { - right: 0; -} - /* Keep collapsed group members in the DOM so the cluster can contract and * expand smoothly instead of appearing and disappearing in one frame. */ -.app-tab-bar:not(:has(.wrap-mode)) .tab-group-entry { - max-width: 100%; +.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry { + min-width: 0; + max-width: var(--tab-group-entry-expanded-width, 100%); max-height: 2rem; overflow: hidden; transition: @@ -712,33 +725,45 @@ opacity 100ms ease; } -.app-tab-bar:not(:has(.wrap-mode)) .tab-group-entry--collapsed { +.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry[data-tab-group-id] > .tab-group-tab { + width: var(--tab-group-entry-expanded-width, auto); + min-width: var(--tab-group-entry-expanded-width, max-content) !important; + flex: none; +} + +/* The classic single-row strip is 2.25rem tall. Keep ordinary tab wrappers + * at the full row height so direct tabs such as Settings do not look taller. */ +.app-tab-bar.classic-tab-layout:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry { + height: 100%; + max-height: none; +} + +@keyframes tab-group-collapse { + from { + max-width: var(--tab-group-entry-expanded-width); + opacity: 1; + } + + to { + max-width: 0; + max-height: 0; + margin-inline: 0; + opacity: 0; + } +} + +.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry--collapsing { + pointer-events: none; + animation: tab-group-collapse 140ms ease forwards; +} + +.app-tab-bar:not(.vertical-tab-layout):not(:has(.wrap-mode)) .tab-group-entry--collapsed:not(.tab-group-entry--collapsing) { max-width: 0 !important; max-height: 0 !important; margin: 0 !important; opacity: 0; overflow: hidden; pointer-events: none; - transition: - max-width 140ms ease, - max-height 140ms ease, - margin 140ms ease, - opacity 100ms ease; -} - -.app-tab-bar:not(:has(.wrap-mode)) .tab-group-entry--collapsed .tab-group-tab--collapsed { - max-width: 0 !important; - max-height: 0 !important; - min-width: 0 !important; - min-height: 0 !important; - padding-inline: 0 !important; - border-width: 0 !important; - overflow: hidden; - transition: - max-width 140ms ease, - max-height 140ms ease, - padding 140ms ease, - border-width 140ms ease; } /* Wrapped tabs must retain their intrinsic row height. Collapsed groups use @@ -816,8 +841,8 @@ flex: 0 0 15rem; --tab-group-header-inset: 0.5rem; --tab-group-root-rail-x: 1.16rem; - --tab-group-tab-inset: 2.5rem; - --tab-group-branch-width: 0.65rem; + --tab-group-tab-inset: 1.8rem; + --tab-group-branch-width: 0.5rem; --tab-group-row-half: 1rem; --tab-group-header-gap: 0.08rem; } @@ -832,6 +857,7 @@ .vertical-tab-layout .app-tab-scroll { min-height: 0; + overflow-x: hidden; } .vertical-tab-layout .app-tab-pill { @@ -840,6 +866,7 @@ min-height: 2rem; height: 2rem !important; border-right: 0; + border-radius: var(--dbx-radius-md); background-color: var(--app-tab-background, transparent); transition: background-color 150ms ease, @@ -876,6 +903,37 @@ flex: none; } +/* The section wrappers use display: contents in vertical mode, so their + * children become the effective flex items. Keep each tab wrapper at its + * intended row height and let the strip scroll instead of compressing rows. */ +.vertical-tab-layout .tab-group-entry { + width: 100%; + min-height: 0; + max-height: 2rem; + flex: none; + overflow: hidden; + opacity: 1; + transition: + max-height 140ms ease, + opacity 100ms ease; +} + +.vertical-tab-layout .tab-group-entry--collapsed { + max-height: 0; + opacity: 0; + pointer-events: none; +} + +.vertical-tab-layout--collapsed .tab-group-entry { + max-height: 2.5rem; + overflow: visible; +} + +.vertical-tab-layout--collapsed .tab-group-entry--collapsed { + max-height: 0; + overflow: hidden; +} + .vertical-tab-layout--collapsed .app-tab-scroll { align-items: center; } @@ -888,7 +946,7 @@ justify-content: center; margin: 0.125rem auto; padding: 0 !important; - border-radius: 0.625rem; + border-radius: var(--dbx-radius-md); } .vertical-tab-layout--collapsed .compact-dirty-tab-marker { @@ -986,17 +1044,17 @@ .vertical-tab-layout .tab-group-tab { position: relative; - width: calc(100% - 3rem); - margin-inline: var(--tab-group-tab-inset) 0.5rem; - padding-inline-start: 0.65rem !important; + width: calc(100% - var(--tab-group-tab-inset) - 0.25rem); + margin-inline: var(--tab-group-tab-inset) 0.25rem; + padding-inline: 0.2rem 0.25rem !important; border-color: transparent; } .vertical-tab-layout .tab-group-tab::before { content: ""; position: absolute; - top: 0; - bottom: 0; + top: -0.25rem; + bottom: -0.25rem; left: calc(0rem - var(--tab-group-branch-width)); width: 1px; border-radius: 9999px;