fix(ui): refine tab bar group layout and focus fallback

This commit is contained in:
zipg authored and GitHub committed 2026-09-09 22:44:56 +08:00
1 parent 7ec1d9f6e2
commit 3caa4f2b89
7 files changed
+304 -115

No files matched your search

@@ -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<Set<string>>(new Set());
const collapsingTabGroups = ref<Set<string>>(new Set());
const tabGroupCollapseTimers = new Map<string, number>();
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<string>) {
if (isWrapLayout.value || isVerticalLayout.value) return;
tabsContainerRef.value?.querySelectorAll<HTMLElement>(".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<string>) {
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<ScrollBehavior>("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"
>
<!-- Compact vertical toolbar: search, grouping preference, collapse. -->
<div v-if="isVerticalLayout" class="flex shrink-0 items-center gap-0.5 border-b p-1.5" :class="isTabBarCollapsed ? 'justify-center' : ''">
<div v-if="isVerticalLayout" class="flex h-9 shrink-0 items-center gap-0.5 border-b p-1" :class="isTabBarCollapsed ? 'justify-center' : ''">
<div v-if="!isTabBarCollapsed" class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="tabSearchQuery" type="search" :placeholder="t('tabs.searchOpenTabs')" class="h-8 w-full pl-7 text-sm" />
<Input v-model="tabSearchQuery" type="search" :placeholder="t('tabs.searchOpenTabs')" class="h-7 w-full pl-7 text-sm" />
</div>
<div v-if="!isTabBarCollapsed" class="flex shrink-0 items-center gap-0">
<LightDropdown
@@ -1394,7 +1459,15 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
</CustomContextMenu>
<CustomContextMenu v-else-if="entry.kind === 'tab'" :items="getTabMenuItems(entry.tab)" v-slot="{ onContextMenu }">
<div
:class="['tab-group-entry', isClassicLayout && !isVerticalLayout ? 'h-full' : '', { 'tab-group-entry--collapsed': entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab) }]"
:class="[
'tab-group-entry',
isClassicLayout && !isVerticalLayout ? 'h-full' : '',
{
'tab-group-entry--collapsing': entry.grouping && !tabSearchQuery.trim() && collapsingTabGroups.has(tabGroupId(entry.tab)),
'tab-group-entry--collapsed': entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab),
},
]"
:data-tab-group-id="entry.grouping ? tabGroupId(entry.tab) : undefined"
:aria-hidden="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
:inert="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
@contextmenu="onContextMenu"
@@ -1410,7 +1483,6 @@ watch([() => 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,
},
@@ -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;");
});
});
@@ -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", () => {
@@ -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");
});
@@ -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");
+151 -93
View File
@@ -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;