feat(tabs): polish tab groups color bars and object icons
This commit is contained in:
6 files changed
+299
-14
No files matched your search
@@ -38,6 +38,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue";
|
||||
import TabModeIcon from "@/components/layout/TabModeIcon.vue";
|
||||
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
|
||||
@@ -51,7 +52,7 @@ import { hexToRgba } from "@/lib/common/color";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { parseTabDragPayload, serializeTabDragPayload } from "@/lib/tabs/tabDrag";
|
||||
import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu";
|
||||
import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation";
|
||||
import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import "./appTabBar.css";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -338,6 +339,9 @@ function updateTabPlacement(value: string) {
|
||||
|
||||
function databaseTabGroupKey(tab: QueryTab) {
|
||||
const database = tab.database || "";
|
||||
if (connectionStore.getConfig(tab.connectionId)?.db_type === "redis") {
|
||||
return JSON.stringify([tab.connectionId, tab.catalog || "", "redis"]);
|
||||
}
|
||||
// A connection-level tab has no database scope, so its catalog cannot split the group.
|
||||
return JSON.stringify([tab.connectionId, database ? tab.catalog || "" : "", database]);
|
||||
}
|
||||
@@ -371,6 +375,7 @@ function tabConnectionTargetLabel(tab: QueryTab) {
|
||||
}
|
||||
|
||||
function databaseTabGroupBaseLabel(tab: QueryTab) {
|
||||
if (connectionStore.getConfig(tab.connectionId)?.db_type === "redis") return tabConnectionLabel(tab);
|
||||
if (!tab.database) return tabConnectionLabel(tab);
|
||||
return [tab.database, ...(tab.catalog ? [tab.catalog] : [])].join(" · ");
|
||||
}
|
||||
@@ -425,6 +430,7 @@ 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 pendingExpandedTabGroupReveal = ref<string | null>(null);
|
||||
const tabGroupPalette = ["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"];
|
||||
const tabGroupEditorOpen = ref(false);
|
||||
const editingTabGroupKey = ref("");
|
||||
@@ -493,6 +499,29 @@ function restoreTabScrollPosition(position: { fixed: number; regular: number })
|
||||
if (regularTabsRowRef.value) regularTabsRowRef.value.scrollLeft = position.regular;
|
||||
}
|
||||
|
||||
function revealExpandedTabGroupStartIfHidden(groupId: string) {
|
||||
if (pendingExpandedTabGroupReveal.value !== groupId) return;
|
||||
pendingExpandedTabGroupReveal.value = null;
|
||||
if (isWrapLayout.value || isVerticalLayout.value) return;
|
||||
|
||||
const entries = Array.from(tabsContainerRef.value?.querySelectorAll<HTMLElement>(".tab-group-entry[data-tab-group-id]") ?? []).filter((entry) => entry.dataset.tabGroupId === groupId && !entry.classList.contains("tab-group-entry--collapsed"));
|
||||
const pills = entries.map((entry) => entry.querySelector<HTMLElement>(".tab-group-tab")).filter((pill): pill is HTMLElement => !!pill);
|
||||
const firstPill = pills[0];
|
||||
if (!firstPill) return;
|
||||
|
||||
const scrollContainer = hasHorizontalFixedRows.value ? firstPill.closest<HTMLElement>(".tab-section--horizontal") : tabsContainerRef.value;
|
||||
if (!scrollContainer) return;
|
||||
const viewport = scrollContainer.getBoundingClientRect();
|
||||
const firstRect = firstPill.getBoundingClientRect();
|
||||
const viewportPadding = 4;
|
||||
const scrollRight = firstRect.right - (viewport.right - viewportPadding);
|
||||
const scrollLeft = firstRect.left - (viewport.left + viewportPadding);
|
||||
const scrollDelta = scrollRight > 0 ? scrollRight : scrollLeft < 0 ? scrollLeft : 0;
|
||||
if (scrollDelta === 0) return;
|
||||
|
||||
scrollContainer.scrollBy({ left: scrollDelta, behavior: tabScrollBehavior.value });
|
||||
}
|
||||
|
||||
function captureExpandedTabGroupWidths(groupIds: Set<string>) {
|
||||
if (isWrapLayout.value || isVerticalLayout.value) return;
|
||||
tabsContainerRef.value?.querySelectorAll<HTMLElement>(".tab-group-entry[data-tab-group-id]").forEach((entry) => {
|
||||
@@ -552,6 +581,8 @@ function handleTabGroupTransitionEnd(event: TransitionEvent) {
|
||||
const entry = event.currentTarget as HTMLElement;
|
||||
if (!entry.classList.contains("tab-group-entry--collapsed")) {
|
||||
entry.style.removeProperty("--tab-group-entry-expanded-width");
|
||||
const groupId = entry.dataset.tabGroupId;
|
||||
if (groupId) revealExpandedTabGroupStartIfHidden(groupId);
|
||||
}
|
||||
refreshHorizontalTabOverflow();
|
||||
}
|
||||
@@ -564,11 +595,13 @@ function toggleTabGroup(tab: QueryTab) {
|
||||
}
|
||||
const next = new Set(collapsedTabGroups.value);
|
||||
if (next.has(groupId)) {
|
||||
pendingExpandedTabGroupReveal.value = !isWrapLayout.value && !isVerticalLayout.value ? groupId : null;
|
||||
next.delete(groupId);
|
||||
collapsedTabGroups.value = next;
|
||||
nextTick(refreshHorizontalTabOverflow);
|
||||
return;
|
||||
}
|
||||
if (pendingExpandedTabGroupReveal.value === groupId) pendingExpandedTabGroupReveal.value = null;
|
||||
beginTabGroupCollapse(new Set([groupId]));
|
||||
}
|
||||
|
||||
@@ -1449,9 +1482,10 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
@contextmenu="openTabGroupContextMenu($event, onContextMenu)"
|
||||
>
|
||||
<span class="tab-group-header-content">
|
||||
<span class="tab-group-marker" aria-hidden="true" />
|
||||
<span v-if="isVerticalLayout" class="tab-group-marker" aria-hidden="true" />
|
||||
<Pin v-if="entry.pinned" class="tab-group-pin" aria-hidden="true" />
|
||||
<ChevronDown class="tab-group-chevron" :class="isTabGroupCollapsed(entry.tab) ? '-rotate-90' : ''" aria-hidden="true" />
|
||||
<ChevronDown class="tab-group-chevron" :class="{ 'tab-group-chevron--collapsed': isTabGroupCollapsed(entry.tab) }" aria-hidden="true" />
|
||||
<DatabaseIcon :db-type="tabDatabaseIconType(entry.tab)" class="tab-group-database-icon" aria-hidden="true" />
|
||||
<span class="tab-group-label">{{ tabGroupLabel(entry.tab) }}</span>
|
||||
<span v-if="isTabGroupCollapsed(entry.tab)" class="tab-group-count">{{ entry.count }}</span>
|
||||
</span>
|
||||
@@ -1635,7 +1669,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
"
|
||||
>
|
||||
<TabExecutionStatus :tab="tab">
|
||||
<TabModeIcon :tab="tab" class="h-3.5 w-3.5 shrink-0" />
|
||||
<TabModeIcon :tab="tab" class="h-3.5 w-3.5 shrink-0" :class="tabIconClass(tab)" />
|
||||
</TabExecutionStatus>
|
||||
<span class="inline-flex min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, CalendarClock, Code2, Database, Eye, FunctionSquare, Gauge, KeyRound, ListOrdered, Network, PencilRuler, ShieldCheck, Table, TableProperties, Zap } from "@lucide/vue";
|
||||
import { Activity, AlertTriangle, Braces, CalendarClock, Clock, Code2, Database, Eye, FileCode, Gauge, KeyRound, Link2, ListTree, Network, Package, PencilRuler, ScrollText, ShieldCheck, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
|
||||
import { isEventObjectBrowserTab, tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
// Mirrors EditorGroupTabBar's per-mode tab icon chain so surfaces outside the
|
||||
@@ -21,12 +21,20 @@ defineProps<{ tab: QueryTab }>();
|
||||
<ShieldCheck v-else-if="tab.mode === 'etcd-access-control'" />
|
||||
<Network v-else-if="tab.mode === 'nacos'" />
|
||||
<Database v-else-if="tab.mode === 'databases'" />
|
||||
<Clock v-else-if="isEventObjectBrowserTab(tab)" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" />
|
||||
<UsersRound v-else-if="tab.mode === 'users'" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" />
|
||||
<FunctionSquare v-else-if="tab.objectSource?.objectType === 'PROCEDURE' || tab.objectSource?.objectType === 'FUNCTION'" />
|
||||
<ScrollText v-else-if="tab.objectSource?.objectType === 'PROCEDURE'" />
|
||||
<Braces v-else-if="tab.objectSource?.objectType === 'FUNCTION'" />
|
||||
<Zap v-else-if="tab.objectSource?.objectType === 'TRIGGER'" />
|
||||
<CalendarClock v-else-if="tab.objectSource?.objectType === 'EVENT' || tab.objectSource?.objectType === 'JOB'" />
|
||||
<ListOrdered v-else-if="tab.objectSource?.objectType === 'SEQUENCE'" />
|
||||
<Clock v-else-if="tab.objectSource?.objectType === 'EVENT' || tab.objectSource?.objectType === 'JOB'" />
|
||||
<ListTree v-else-if="tab.objectSource?.objectType === 'SEQUENCE'" />
|
||||
<Link2 v-else-if="tab.objectSource?.objectType === 'SYNONYM'" />
|
||||
<Package v-else-if="tab.objectSource?.objectType === 'PACKAGE'" />
|
||||
<FileCode v-else-if="tab.objectSource?.objectType === 'PACKAGE_BODY'" />
|
||||
<Braces v-else-if="tab.objectSource?.objectType === 'TYPE'" />
|
||||
<FileCode v-else-if="tab.objectSource?.objectType === 'TYPE_BODY'" />
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" />
|
||||
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" />
|
||||
<Gauge v-else-if="tab.mode === 'dolt-version-control'" />
|
||||
|
||||
+142
@@ -7,8 +7,10 @@ import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { createApp, nextTick, reactive } from "vue";
|
||||
import EditorGroupTabBar from "../EditorGroupTabBar.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: { name: "TooltipStub", template: `<div><slot /></div>` },
|
||||
@@ -171,6 +173,8 @@ describe("EditorGroupTabBar semantic tab groups", () => {
|
||||
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).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\) \.tab-group-tab::after\s*\{[^}]*bottom:\s*-0\.5px;[^}]*background:\s*var\(--tab-group-color\);/s);
|
||||
expect(sharedStyles).toMatch(/\.app-tab-bar:not\(\.vertical-tab-layout\):has\(\.wrap-mode\)\[data-placement="bottom"\] \.tab-group-tab::after\s*\{[^}]*top:\s*-0\.5px;[^}]*bottom:\s*auto;/s);
|
||||
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");
|
||||
@@ -434,6 +438,70 @@ describe("EditorGroupTabBar group behavior", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("only reveals a right-edge group when expansion leaves every member offscreen", async () => {
|
||||
const store = useQueryStore();
|
||||
const settings = useSettingsStore();
|
||||
settings.editorSettings.tabGroupMode = "connection";
|
||||
settings.editorSettings.tabLayout = "scroll";
|
||||
const mysql = store.createTab("mysql-1", "app", "MY 1", "query");
|
||||
store.createTab("pg-1", "app", "PG 1", "query");
|
||||
store.createTab("pg-1", "app", "PG 2", "query");
|
||||
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), mysql, pinia);
|
||||
await settle();
|
||||
|
||||
const headers = Array.from(host.querySelectorAll<HTMLButtonElement>(".tab-group-header"));
|
||||
const pgHeader = headers.find((header) => header.title === "pg-1")!;
|
||||
pgHeader.click();
|
||||
await settle();
|
||||
pgHeader.click();
|
||||
await settle();
|
||||
|
||||
const container = host.querySelector<HTMLElement>(".app-tab-scroll")!;
|
||||
container.getBoundingClientRect = () => ({ left: 0, right: 300 }) as DOMRect;
|
||||
const pgEntries = Array.from(host.querySelectorAll<HTMLElement>('[data-tab-group-id="regular:connection:pg-1"]'));
|
||||
const pgPills = pgEntries.map((entry) => entry.querySelector<HTMLElement>(".tab-group-tab")!);
|
||||
pgPills.forEach((pill, index) => {
|
||||
pill.getBoundingClientRect = () => ({ left: 320 + index * 100, right: 420 + index * 100 }) as DOMRect;
|
||||
});
|
||||
const scrollBy = vi.fn();
|
||||
container.scrollBy = scrollBy;
|
||||
|
||||
const transitionEnd = new Event("transitionend", { bubbles: true });
|
||||
Object.defineProperty(transitionEnd, "propertyName", { value: "max-width" });
|
||||
pgEntries[0]!.dispatchEvent(transitionEnd);
|
||||
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 124, behavior: "smooth" });
|
||||
|
||||
pgHeader.click();
|
||||
await settle();
|
||||
pgHeader.click();
|
||||
await settle();
|
||||
pgPills[0]!.getBoundingClientRect = () => ({ left: 250, right: 350 }) as DOMRect;
|
||||
const partialScrollBy = vi.fn();
|
||||
container.scrollBy = partialScrollBy;
|
||||
const visibleTransitionEnd = new Event("transitionend", { bubbles: true });
|
||||
Object.defineProperty(visibleTransitionEnd, "propertyName", { value: "max-width" });
|
||||
pgEntries[0]!.dispatchEvent(visibleTransitionEnd);
|
||||
|
||||
expect(partialScrollBy).toHaveBeenCalledWith({ left: 54, behavior: "smooth" });
|
||||
|
||||
pgHeader.click();
|
||||
await settle();
|
||||
pgHeader.click();
|
||||
await settle();
|
||||
pgPills[0]!.getBoundingClientRect = () => ({ left: 100, right: 200 }) as DOMRect;
|
||||
const fullyVisibleScrollBy = vi.fn();
|
||||
container.scrollBy = fullyVisibleScrollBy;
|
||||
const fullyVisibleTransitionEnd = new Event("transitionend", { bubbles: true });
|
||||
Object.defineProperty(fullyVisibleTransitionEnd, "propertyName", { value: "max-width" });
|
||||
pgEntries[0]!.dispatchEvent(fullyVisibleTransitionEnd);
|
||||
|
||||
expect(fullyVisibleScrollBy).not.toHaveBeenCalled();
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("groups by database identity, disambiguating same-name databases across connections", async () => {
|
||||
const store = useQueryStore();
|
||||
const settings = useSettingsStore();
|
||||
@@ -461,6 +529,80 @@ describe("EditorGroupTabBar group behavior", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps Redis logical databases in one database group", async () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [{ id: "redis-1", name: "Redis Cache", db_type: "redis", driver_profile: "redis", host: "127.0.0.1", port: 6379, color: "" } as ConnectionConfig];
|
||||
const store = useQueryStore();
|
||||
const settings = useSettingsStore();
|
||||
settings.editorSettings.tabGroupMode = "database";
|
||||
const db0 = store.createTab("redis-1", "0", "Redis 0", "redis");
|
||||
const db1 = store.createTab("redis-1", "1", "Redis 1", "redis");
|
||||
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), db0, pinia);
|
||||
await settle();
|
||||
|
||||
// db0 and db1 are logical databases of one connection, so a single header
|
||||
// labeled by the connection owns both pills instead of one cluster per db.
|
||||
const headers = Array.from(host.querySelectorAll<HTMLButtonElement>(".tab-group-header"));
|
||||
expect(headers.map((header) => header.title)).toEqual(["Redis Cache"]);
|
||||
const groupIds = new Set(Array.from(host.querySelectorAll("[data-tab-group-id]"), (entry) => entry.getAttribute("data-tab-group-id")));
|
||||
expect(groupIds.size).toBe(1);
|
||||
expect(host.querySelectorAll("[data-tab-id]")).toHaveLength(2);
|
||||
expect(host.querySelector(`[data-tab-id="${db0}"]`)?.textContent).toContain("db0");
|
||||
expect(host.querySelector(`[data-tab-id="${db1}"]`)?.textContent).toContain("db1");
|
||||
|
||||
// Collapsing the cluster folds both logical databases into one count badge.
|
||||
headers[0]!.click();
|
||||
await settle();
|
||||
expect(host.querySelector(".tab-group-count")?.textContent).toBe("2");
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("sizes the header chevron and rotates it only while the cluster is collapsed", async () => {
|
||||
const store = useQueryStore();
|
||||
const settings = useSettingsStore();
|
||||
settings.editorSettings.tabGroupMode = "connection";
|
||||
const tabId = store.createTab("pg-1", "app", "PG 1", "query");
|
||||
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), tabId, pinia);
|
||||
const styles = document.createElement("style");
|
||||
styles.textContent = `html { font-size: 16px; }\n${sharedStyles}`;
|
||||
document.head.appendChild(styles);
|
||||
|
||||
try {
|
||||
await settle();
|
||||
const header = host.querySelector<HTMLButtonElement>(".tab-group-header")!;
|
||||
// Horizontal headers lead with the database glyph; the rail marker stays vertical-only.
|
||||
expect(header.querySelector(".tab-group-database-icon")).not.toBeNull();
|
||||
expect(header.querySelector(".tab-group-marker")).toBeNull();
|
||||
const chevron = header.querySelector<HTMLElement>(".tab-group-chevron")!;
|
||||
expect(getComputedStyle(chevron).width).toBe("14px");
|
||||
expect(getComputedStyle(chevron).height).toBe("14px");
|
||||
expect(getComputedStyle(chevron).transition).toContain("transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1)");
|
||||
expect(getComputedStyle(chevron).transform).toBe("rotate(0deg)");
|
||||
|
||||
header.click();
|
||||
await settle();
|
||||
expect(chevron.classList.contains("tab-group-chevron--collapsed")).toBe(true);
|
||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(getComputedStyle(chevron).transform).toBe("rotate(-90deg)");
|
||||
|
||||
header.click();
|
||||
await settle();
|
||||
expect(chevron.classList.contains("tab-group-chevron--collapsed")).toBe(false);
|
||||
expect(getComputedStyle(chevron).transform).toBe("rotate(0deg)");
|
||||
|
||||
// The rail marker appears only under a vertical placement.
|
||||
settings.editorSettings.tabPlacement = "left";
|
||||
await settle();
|
||||
expect(host.querySelector(".tab-group-header .tab-group-marker")).not.toBeNull();
|
||||
} finally {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
styles.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the drag-back hit-test anchor and highlights itself as the detached drop target", async () => {
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "PG 1", "query");
|
||||
|
||||
@@ -483,10 +483,24 @@
|
||||
}
|
||||
|
||||
.tab-group-chevron {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex: none;
|
||||
transform: rotate(0deg);
|
||||
transform-origin: center;
|
||||
transition:
|
||||
transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.tab-group-chevron--collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.tab-group-database-icon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex: none;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.tab-group-pin {
|
||||
@@ -611,6 +625,11 @@
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-group-color) 35%, transparent);
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header:hover .tab-group-chevron,
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--active .tab-group-chevron {
|
||||
color: var(--tab-group-color);
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -676,6 +695,29 @@
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
/* Wrapped group entries can span multiple flex rows. Draw the accent on each
|
||||
* member pill as well so every row keeps its own bottom rule when the entry's
|
||||
* bounding box does not cover the pill's painted height. */
|
||||
.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode) .tab-group-tab::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
bottom: -0.5px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: var(--tab-group-color);
|
||||
pointer-events: none;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.app-tab-bar:not(.vertical-tab-layout):has(.wrap-mode)[data-placement="bottom"] .tab-group-tab::after {
|
||||
top: -0.5px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
Reference in new issue
Block a user