fix(ui): refine tab bar group layout and focus fallback
This commit is contained in:
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,
|
||||
},
|
||||
|
||||
+49
-8
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
+5
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user