feat(ui): animate sidebar and toolbar interaction feedback

This commit is contained in:
zipg authored and GitHub committed 2026-08-31 16:37:51 +08:00
1 parent bcea953a50
commit 99c3bd0f42
14 files changed
+689 -100

No files matched your search

@@ -4800,7 +4800,7 @@ onUnmounted(() => {
<div class="settings-appearance-theme-grid">
<div class="settings-appearance-group min-w-0">
<Label>{{ t("settings.theme") }}</Label>
<div class="settings-appearance-button-row flex flex-wrap gap-2">
<div class="settings-appearance-button-row flex gap-2">
<Button v-for="option in appThemeModeOptions" :key="option.value" type="button" variant="outline" size="sm" class="settings-choice-button h-8 gap-1.5 px-3" :class="themeMode === option.value ? 'dbx-choice-selected' : 'text-foreground'" @click="setThemeMode(option.value)">
<component :is="option.icon" class="h-3.5 w-3.5" />
{{ option.label }}
@@ -4826,38 +4826,6 @@ onUnmounted(() => {
</Button>
</div>
</div>
<div class="settings-appearance-group min-w-0">
<Label>{{ t("settings.iconTheme") }}</Label>
<div class="settings-appearance-button-row flex flex-wrap gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button type="button" variant="outline" size="sm" class="settings-choice-button h-8 gap-1.5 px-3" :class="editIconTheme === 'default' ? 'dbx-choice-selected' : 'text-foreground'" @click="setIconTheme('default')">
<img :src="webPath('/icon-preview-default.png')" alt="DBX" class="h-7 w-7 shrink-0" />
{{ t("settings.iconThemeDefault") }}
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed">
{{ t("settings.iconThemeDefaultDescription") }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button type="button" variant="outline" size="sm" class="settings-choice-button h-8 gap-1.5 px-3" :class="editIconTheme === 'black' ? 'dbx-choice-selected' : 'text-foreground'" @click="setIconTheme('black')">
<img :src="webPath('/icon-preview-black.png')" alt="DBX" class="h-7 w-7 shrink-0" />
{{ t("settings.iconThemeBlack") }}
</Button>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed">
{{ iconThemeBlackDescriptionText }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<Separator />
@@ -4987,6 +4955,38 @@ onUnmounted(() => {
</div>
</div>
<div class="settings-appearance-group" data-icon-theme-settings>
<Label>{{ t("settings.iconTheme") }}</Label>
<div class="settings-appearance-choice-grid settings-icon-theme-grid">
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editIconTheme === 'default' ? 'dbx-choice-selected' : ''" @click="setIconTheme('default')">
<div class="flex w-full min-w-0 items-center gap-3 text-left">
<img :src="webPath('/icon-preview-default.png')" alt="DBX" class="h-12 w-12 shrink-0" />
<div class="min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.iconThemeDefault") }}
</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ t("settings.iconThemeDefaultDescription") }}
</div>
</div>
</div>
</Button>
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editIconTheme === 'black' ? 'dbx-choice-selected' : ''" @click="setIconTheme('black')">
<div class="flex w-full min-w-0 items-center gap-3 text-left">
<img :src="webPath('/icon-preview-black.png')" alt="DBX" class="h-12 w-12 shrink-0" />
<div class="min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.iconThemeBlack") }}
</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ iconThemeBlackDescriptionText }}
</div>
</div>
</div>
</Button>
</div>
</div>
<Separator />
<div class="space-y-2">
@@ -7898,6 +7898,10 @@ onUnmounted(() => {
row-gap: 1rem;
}
.settings-appearance-section {
container-type: inline-size;
}
.settings-appearance-field > * + * {
margin-top: 0.5rem;
}
@@ -7920,10 +7924,22 @@ onUnmounted(() => {
.settings-appearance-theme-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@container (max-width: 34rem) {
.settings-appearance-theme-grid {
grid-template-columns: 1fr;
}
}
@container (max-width: 36rem) {
.settings-icon-theme-grid {
grid-template-columns: 1fr;
}
}
.settings-option-stack > * + * {
margin-top: 0.625rem;
}
@@ -118,12 +118,13 @@ describe("EditorSettingsDialog choice card containment", () => {
}
});
it("keeps icon theme choices compact beside the corner style controls", () => {
expect(dialogSource).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
it("restores large icon theme choices below the debug log controls", () => {
expect(sourceIndexForKey("iconTheme")).toBeGreaterThan(sourceIndexForKey("debugLoggingEnabled"));
expect(templateSource).toContain("data-icon-theme-settings");
for (const key of ["iconThemeDefault", "iconThemeBlack"] as const) {
const block = buttonBlockForKey(key);
expectClassTokens(classNameFromTag(openingTag(block, "Button")), ["settings-choice-button", "h-8"]);
expect(block).toContain('class="h-7 w-7 shrink-0"');
expectClassTokens(classNameFromTag(openingTag(block, "Button")), ["settings-choice-card", "h-auto", "min-w-0", "whitespace-normal", "overflow-hidden"]);
expect(block).toContain('class="h-12 w-12 shrink-0"');
}
});
});
@@ -268,26 +268,28 @@ defineExpose({ focusSearch, locateTabInSidebar });
</LightTooltip>
</template>
<template v-else>
<LightTooltip :text="t('sidebar.collapseAll')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="collapseAllTreeNodes">
<ChevronsDownUp class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('connectionGroup.createGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="createNewGroup">
<FolderPlus class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('contextMenu.refreshChildren')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="refreshTree">
<RefreshCw class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('sidebar.collapse')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('collapse')">
<ChevronsLeft class="h-3.5 w-3.5" />
</Button>
</LightTooltip>
<span data-sidebar-toolbar-actions class="flex shrink-0 items-center gap-0.5">
<LightTooltip :text="t('sidebar.collapseAll')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="collapseAllTreeNodes">
<ChevronsDownUp class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('connectionGroup.createGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="createNewGroup">
<FolderPlus class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('contextMenu.refreshChildren')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="refreshTree">
<RefreshCw class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('sidebar.collapse')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('collapse')">
<ChevronsLeft class="h-3.5 w-3.5" />
</Button>
</LightTooltip>
</span>
</template>
</div>
<div class="flex-1 min-h-0">
@@ -2,12 +2,13 @@
import { computed, ref, onMounted, onBeforeUnmount, h, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { invoke } from "@tauri-apps/api/core";
import { DatabaseZap, FilePlus2, Loader2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, FileDown, FolderTree } from "@lucide/vue";
import { DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, FileDown, FolderTree } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import WindowControls from "@/components/layout/WindowControls.vue";
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import ToolbarUpdateIcon from "@/components/layout/ToolbarUpdateIcon.vue";
import { MAC_TRAFFIC_LIGHT_X, macTrafficLightInsetPaddingForScale, shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
import { useToast } from "@/composables/useToast";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -584,9 +585,8 @@ const toolbarStyle = computed(() => {
<template v-if="toolbarItems.checkUpdates">
<Tooltip>
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('checkUpdates')" variant="ghost" size="icon" class="relative h-8 w-8 shrink-0" :disabled="checkingUpdates" @click="emit('check-updates')">
<Loader2 v-if="checkingUpdates" class="h-4 w-4 animate-spin" />
<CloudDownload v-else class="h-4 w-4" />
<Button v-show="isRightItemVisible('checkUpdates')" data-toolbar-update-trigger variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :disabled="checkingUpdates" @click="emit('check-updates')">
<ToolbarUpdateIcon :loading="checkingUpdates" />
<span v-if="hasUpdateAvailable" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
</Button>
</TooltipTrigger>
@@ -600,7 +600,15 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.sqlLibrary">
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('sqlLibrary')" data-sql-library-trigger variant="ghost" size="icon" class="relative h-8 w-8 shrink-0" :class="{ 'bg-accent': showSqlLibrary, 'sql-library-save-feedback': sqlLibrarySaveFeedbackActive }" @click="emit('toggle-sql-library')">
<Button
v-show="isRightItemVisible('sqlLibrary')"
data-sql-library-trigger
variant="ghost"
size="icon"
class="toolbar-action-button relative h-8 w-8 shrink-0"
:class="{ 'toolbar-action-button--active bg-accent': showSqlLibrary, 'sql-library-save-feedback': sqlLibrarySaveFeedbackActive }"
@click="emit('toggle-sql-library')"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
@@ -612,8 +620,8 @@ const toolbarStyle = computed(() => {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="sql-library-icon h-4 w-4"
:class="{ 'sql-library-save-feedback-icon text-primary': sqlLibrarySaveFeedbackActive }"
class="sql-library-icon toolbar-action-icon h-4 w-4"
:class="{ 'toolbar-action-icon--active': showSqlLibrary, 'sql-library-save-feedback-icon text-primary': sqlLibrarySaveFeedbackActive }"
>
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
<path ref="sqlLibraryIconPathRef" :d="SQL_LIBRARY_BOOKMARK_PATH">
@@ -621,6 +629,7 @@ const toolbarStyle = computed(() => {
<animate ref="sqlLibraryMorphToBookmarkRef" attributeName="d" :values="`${SQL_LIBRARY_CHECK_PATH};${SQL_LIBRARY_BOOKMARK_PATH}`" dur="180ms" begin="indefinite" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.22 1 0.36 1" />
</path>
</svg>
<span v-if="showSqlLibrary" class="toolbar-panel-status" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("sqlLibrary.title") }}</TooltipContent>
@@ -628,8 +637,9 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.sqlFileTree">
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('sqlFileTree')" variant="ghost" size="icon" class="h-8 w-8 shrink-0" :class="{ 'bg-accent': showSqlFilePanel }" @click="emit('toggle-sql-file-panel')">
<FolderTree class="h-4 w-4" />
<Button v-show="isRightItemVisible('sqlFileTree')" variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :class="{ 'toolbar-action-button--active bg-accent': showSqlFilePanel }" @click="emit('toggle-sql-file-panel')">
<FolderTree class="toolbar-action-icon h-4 w-4" :class="{ 'toolbar-action-icon--active': showSqlFilePanel }" />
<span v-if="showSqlFilePanel" class="toolbar-panel-status" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("sqlFileTree.title") }}</TooltipContent>
@@ -637,8 +647,9 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.history">
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('history')" variant="ghost" size="icon" class="h-8 w-8 shrink-0" :class="{ 'bg-accent': showHistory }" @click="emit('toggle-history')">
<History class="h-4 w-4" />
<Button v-show="isRightItemVisible('history')" variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :class="{ 'toolbar-action-button--active bg-accent': showHistory }" @click="emit('toggle-history')">
<History class="toolbar-action-icon h-4 w-4" :class="{ 'toolbar-action-icon--active': showHistory }" />
<span v-if="showHistory" class="toolbar-panel-status" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("history.title") }}</TooltipContent>
@@ -646,8 +657,8 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.ai">
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('ai')" variant="ghost" size="icon" class="relative h-8 w-8 shrink-0" :class="{ 'bg-accent': showAiPanel }" @click="emit('toggle-ai')">
<Bot class="h-4 w-4" />
<Button v-show="isRightItemVisible('ai')" variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :class="{ 'toolbar-action-button--active bg-accent': showAiPanel }" @click="emit('toggle-ai')">
<Bot class="toolbar-action-icon h-4 w-4" :class="{ 'toolbar-action-icon--active': showAiPanel }" />
<span
v-if="awaitingAiRunCount > 0"
class="absolute right-0.5 top-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[8px] font-semibold leading-none text-white"
@@ -659,6 +670,7 @@ const toolbarStyle = computed(() => {
<span v-else-if="activeAiRunCount > 0" class="absolute right-0.5 top-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-primary px-0.5 text-[8px] font-semibold leading-none text-primary-foreground">
{{ activeAiRunCount > 9 ? "9+" : activeAiRunCount }}
</span>
<span v-if="showAiPanel" class="toolbar-panel-status" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>AI</TooltipContent>
@@ -666,8 +678,8 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.theme">
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('theme')" variant="ghost" size="icon" class="h-8 w-8 shrink-0" :aria-label="t('toolbar.theme')" @click="cycleThemeMode">
<component :is="themeTriggerIcon" class="h-4 w-4" />
<Button v-show="isRightItemVisible('theme')" variant="ghost" size="icon" class="toolbar-action-button h-8 w-8 shrink-0" :aria-label="t('toolbar.theme')" @click="cycleThemeMode">
<component :is="themeTriggerIcon" :key="themeMode" class="toolbar-action-icon toolbar-theme-icon h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.theme") }}</TooltipContent>
@@ -675,8 +687,8 @@ const toolbarStyle = computed(() => {
<Tooltip v-if="toolbarItems.github">
<TooltipTrigger as-child>
<Button v-show="isRightItemVisible('github')" variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="emit('open-github')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<Button v-show="isRightItemVisible('github')" variant="ghost" size="icon" class="toolbar-action-button h-8 w-8 shrink-0" @click="emit('open-github')">
<svg class="toolbar-action-icon h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z"
/>
@@ -690,8 +702,9 @@ const toolbarStyle = computed(() => {
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="relative h-8 w-8 shrink-0" :class="{ 'bg-accent': showSettingsPage }" @click="emit('open-settings')">
<Settings class="h-4 w-4" />
<Button variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :class="{ 'toolbar-action-button--active bg-accent': showSettingsPage }" @click="emit('open-settings')">
<Settings class="toolbar-action-icon h-4 w-4" :class="{ 'toolbar-action-icon--active': showSettingsPage }" />
<span v-if="showSettingsPage" class="toolbar-panel-status" aria-hidden="true" />
<span v-if="hasMcpUpdateAvailable" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" :aria-label="t('toolbar.mcpUpdateAvailable')" :title="t('toolbar.mcpUpdateAvailable')" />
</Button>
</TooltipTrigger>
@@ -703,6 +716,64 @@ const toolbarStyle = computed(() => {
</template>
<style scoped>
.toolbar-action-icon {
transform: translateY(0) scale(1);
transition:
color 180ms ease,
transform 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.toolbar-action-button:active:not(:disabled) .toolbar-action-icon,
.toolbar-action-button:active:not(:disabled) :deep([data-toolbar-update-icon]) {
transform: scale(0.82);
}
.toolbar-action-icon--active {
color: var(--primary);
transform: translateY(-1px) scale(1.06);
}
.toolbar-panel-status {
position: absolute;
bottom: 3px;
left: 50%;
width: 3px;
height: 3px;
border-radius: 9999px;
background: var(--primary);
transform: translateX(-50%);
animation: toolbar-panel-status-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toolbar-theme-icon {
animation: toolbar-theme-icon-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes toolbar-panel-status-in {
0% {
opacity: 0;
transform: translateX(-50%) translateY(2px) scale(0);
}
70% {
transform: translateX(-50%) translateY(0) scale(1.3);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
@keyframes toolbar-theme-icon-in {
0% {
opacity: 0;
transform: rotate(-18deg) scale(0.72);
}
100% {
opacity: 1;
transform: rotate(0) scale(1);
}
}
@keyframes sql-library-save-confirm-button {
0%,
100% {
@@ -746,6 +817,13 @@ const toolbarStyle = computed(() => {
}
@media (prefers-reduced-motion: reduce) {
.toolbar-action-icon,
.toolbar-panel-status,
.toolbar-theme-icon {
animation: none;
transition: none;
}
.sql-library-save-feedback,
.sql-library-save-feedback-icon {
animation: none;
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { Cloud, CloudDownload } from "@lucide/vue";
defineProps<{
loading: boolean;
}>();
</script>
<template>
<span data-toolbar-update-icon class="relative block h-4 w-4" :class="{ 'toolbar-update-icon--loading': loading }" aria-hidden="true">
<CloudDownload v-if="!loading" data-toolbar-update-idle class="toolbar-update-idle h-4 w-4" />
<template v-else>
<Cloud data-toolbar-update-cloud class="toolbar-update-cloud absolute inset-0 h-4 w-4" />
<Cloud data-toolbar-update-scan class="toolbar-update-scan absolute inset-0 h-4 w-4" />
</template>
</span>
</template>
<style scoped>
.toolbar-update-cloud {
opacity: 0.38;
}
.toolbar-update-scan {
color: #000;
stroke-dasharray: 7 36;
stroke-dashoffset: 0;
animation: toolbar-update-outline-scan 1100ms linear infinite;
}
:global(.dark) .toolbar-update-scan {
color: #fff;
}
@keyframes toolbar-update-outline-scan {
to {
stroke-dashoffset: -43;
}
}
@media (prefers-reduced-motion: reduce) {
.toolbar-update-scan {
animation: none;
stroke-dasharray: none;
}
}
</style>
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import ToolbarUpdateIcon from "../ToolbarUpdateIcon.vue";
const mountedApps: App[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("ToolbarUpdateIcon", () => {
it("uses cloud download while idle and a cloud outline scan while checking", async () => {
const state = reactive({ loading: false });
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () => h(ToolbarUpdateIcon, { loading: state.loading }),
}),
);
mountedApps.push(app);
app.mount(container);
await nextTick();
expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
state.loading = true;
await nextTick();
expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
expect(container.querySelector("[data-toolbar-update-cloud]")).not.toBeNull();
expect(container.querySelector("[data-toolbar-update-scan]")).not.toBeNull();
});
});
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, SlidersHorizontal, ListOrdered, ArrowDownAZ, ArrowUpZA, LocateFixed, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
import { Search, X, ListOrdered, ArrowDownAZ, ArrowUpZA, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -56,6 +56,9 @@ import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } fro
import { createSidebarTableSearchDebouncer, invalidateSidebarTableSearchBuild, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex";
import TreeItem from "./TreeItem.vue";
import ActiveConnectionFilterButton from "./ActiveConnectionFilterButton.vue";
import SidebarListOptionsIcon from "./SidebarListOptionsIcon.vue";
import SidebarLocateButton from "./SidebarLocateButton.vue";
import SidebarRegexToggleButton from "./SidebarRegexToggleButton.vue";
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
@@ -2430,16 +2433,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
<X class="h-3 w-3" />
</button>
<LightTooltip :text="t('sidebar.regexSearchTooltip')" side="top" :delay="300">
<button
type="button"
class="flex h-5 min-w-5 items-center justify-center rounded-sm px-0.5 text-[10px] font-mono text-muted-foreground hover:bg-accent hover:text-foreground"
:class="{ 'text-primary bg-primary/10': regexMode, 'text-destructive': regexMode && compileSearchRegex(searchQuery).invalid }"
:aria-label="t('sidebar.regexSearch')"
:aria-pressed="regexMode"
@click="regexMode = !regexMode"
>
.*
</button>
<SidebarRegexToggleButton :label="t('sidebar.regexSearch')" :pressed="regexMode" :invalid="regexMode && compileSearchRegex(searchQuery).invalid" @toggle="regexMode = !regexMode" />
</LightTooltip>
<LightTooltip :text="t('sidebar.globalLocalSearchTooltip')" side="top" :delay="300">
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarGlobalSearchLocal" :disabled="regexMode" :aria-label="t('sidebar.globalLocalSearch')" @update:model-value="settingsStore.updateEditorSettings({ sidebarGlobalSearchLocal: Boolean($event) })" />
@@ -2447,9 +2441,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
</div>
</div>
<LightTooltip :text="t('sidebar.locateActiveTab')" side="top" :delay="300" nowrap>
<button type="button" class="flex h-6 w-6 shrink-0 items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground" :aria-label="t('sidebar.locateActiveTab')" @click="locateActiveTabInSidebar">
<LocateFixed class="h-3.5 w-3.5" />
</button>
<SidebarLocateButton :label="t('sidebar.locateActiveTab')" @locate="locateActiveTabInSidebar" />
</LightTooltip>
<LightTooltip :text="sidebarListOptionsLabel" side="top" :delay="300" nowrap>
<span class="inline-flex">
@@ -2458,9 +2450,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
:items="sidebarListOptionItems"
:selected-values="selectedSidebarListOptions"
:aria-label="sidebarListOptionsLabel"
:trigger-icon="SlidersHorizontal"
:trigger-class="['shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent', hasCustomSidebarListOptions ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'].join(' ')"
trigger-icon-class="h-3.5 w-3.5"
item-icon-class="h-3.5 w-3.5"
content-class="w-max min-w-0"
selected-item-class="bg-primary/10 text-primary"
@@ -2470,7 +2460,11 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
:close-on-select="false"
align="end"
@update:model-value="selectSidebarListOption"
/>
>
<template #trigger-icon="{ open }">
<SidebarListOptionsIcon :filtered="hasSearchScopeFilter" :open="open" />
</template>
</LightDropdown>
</span>
</LightTooltip>
<ActiveConnectionFilterButton :active-connection-count="store.connectedIds.size" :pressed="showConnectedConnectionsOnly" @toggle="showConnectedConnectionsOnly = !showConnectedConnectionsOnly" />
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ListFilter, SlidersHorizontal } from "@lucide/vue";
defineProps<{
filtered: boolean;
open: boolean;
}>();
</script>
<template>
<span data-sidebar-list-options-icon class="sidebar-list-options-icon relative block h-3.5 w-3.5" :class="{ 'sidebar-list-options-icon--filtered': filtered, 'sidebar-list-options-icon--open': open }" aria-hidden="true">
<Transition name="sidebar-filter-symbol">
<ListFilter v-if="filtered" key="filtered" data-sidebar-filtered-icon class="sidebar-filter-symbol absolute inset-0 h-3.5 w-3.5" />
<SlidersHorizontal v-else key="unfiltered" data-sidebar-unfiltered-icon class="sidebar-filter-symbol absolute inset-0 h-3.5 w-3.5" />
</Transition>
<span v-if="filtered" class="sidebar-filter-status-dot absolute -right-0.5 -top-0.5 h-1 w-1 rounded-full bg-primary" />
</span>
</template>
<style scoped>
.sidebar-list-options-icon {
transform: scale(1);
}
.sidebar-list-options-icon--open {
animation: sidebar-list-options-press 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sidebar-list-options-icon--filtered {
color: var(--primary);
}
.sidebar-filter-symbol-enter-active,
.sidebar-filter-symbol-leave-active {
transition:
opacity 180ms ease,
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sidebar-filter-symbol-enter-from {
opacity: 0;
transform: translateY(2px) scale(0.72);
}
.sidebar-filter-symbol-leave-to {
opacity: 0;
transform: translateY(-2px) scale(0.82);
}
.sidebar-filter-status-dot {
animation: sidebar-filter-dot-in 260ms cubic-bezier(0.2, 0.9, 0.25, 1.3) both;
box-shadow: 0 0 0 1px var(--background);
}
@keyframes sidebar-filter-dot-in {
0% {
opacity: 0;
transform: scale(0);
}
70% {
transform: scale(1.35);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes sidebar-list-options-press {
0% {
transform: scale(1);
}
48% {
transform: scale(0.8);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar-list-options-icon,
.sidebar-filter-symbol-enter-active,
.sidebar-filter-symbol-leave-active,
.sidebar-filter-status-dot {
animation: none;
transition: none;
transform: none;
}
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from "vue";
import { LocateFixed } from "@lucide/vue";
defineProps<{
label: string;
}>();
const emit = defineEmits<{
locate: [];
}>();
const animationId = ref(0);
function locate() {
animationId.value += 1;
emit("locate");
}
</script>
<template>
<button type="button" data-sidebar-locate-button class="sidebar-locate-button relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground" :aria-label="label" @click="locate">
<LocateFixed :key="`locate-icon-${animationId}`" class="h-3.5 w-3.5" :class="{ 'sidebar-locate-icon--active': animationId > 0 }" />
<span v-if="animationId > 0" :key="`locate-pulse-${animationId}`" data-sidebar-locate-pulse class="sidebar-locate-pulse pointer-events-none absolute left-1/2 top-1/2 h-1.5 w-1.5 rounded-full border border-primary" aria-hidden="true" />
</button>
</template>
<style scoped>
.sidebar-locate-button:active {
transform: scale(0.94);
}
.sidebar-locate-icon--active {
animation: sidebar-locate-settle 420ms cubic-bezier(0.2, 0.85, 0.25, 1);
}
.sidebar-locate-pulse {
animation: sidebar-locate-pulse 440ms ease-out both;
}
@keyframes sidebar-locate-settle {
0% {
transform: rotate(-18deg) scale(0.78);
}
58% {
transform: rotate(5deg) scale(1.12);
color: var(--primary);
}
100% {
transform: rotate(0) scale(1);
}
}
@keyframes sidebar-locate-pulse {
0% {
opacity: 0.75;
transform: translate(-50%, -50%) scale(0.4);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(3.2);
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar-locate-button,
.sidebar-locate-icon--active,
.sidebar-locate-pulse {
animation: none;
transition: none;
transform: none;
}
.sidebar-locate-pulse {
display: none;
}
}
</style>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref } from "vue";
defineProps<{
label: string;
pressed: boolean;
invalid: boolean;
}>();
const emit = defineEmits<{
toggle: [];
}>();
const animationId = ref(0);
function toggle() {
animationId.value += 1;
emit("toggle");
}
</script>
<template>
<button
type="button"
data-sidebar-regex-toggle
class="sidebar-regex-toggle relative flex h-5 min-w-5 items-center justify-center overflow-hidden rounded-sm px-0.5 font-mono text-[10px] text-muted-foreground hover:bg-accent hover:text-foreground"
:class="{ 'sidebar-regex-toggle--pressed text-primary bg-primary/10': pressed, 'text-destructive': invalid }"
:aria-label="label"
:aria-pressed="pressed"
@click="toggle"
>
<span :key="animationId" data-sidebar-regex-glyph class="sidebar-regex-glyph relative inline-flex h-3.5 w-4 items-center justify-center leading-none" :class="{ 'sidebar-regex-glyph--pressed': pressed, 'sidebar-regex-glyph--animated': animationId > 0 }" aria-hidden="true">.*</span>
</button>
</template>
<style scoped>
.sidebar-regex-toggle {
letter-spacing: 0;
transition:
color 160ms ease,
background-color 160ms ease,
transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sidebar-regex-toggle:active {
transform: scale(0.9);
}
.sidebar-regex-glyph {
font-weight: 400;
transform: scale(1);
transition:
font-weight 180ms ease,
transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sidebar-regex-glyph--pressed {
font-weight: 700;
transform: scale(1.08);
}
.sidebar-regex-glyph--animated.sidebar-regex-glyph--pressed {
animation: sidebar-regex-enable 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.sidebar-regex-glyph--animated:not(.sidebar-regex-glyph--pressed) {
animation: sidebar-regex-disable 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes sidebar-regex-enable {
0% {
font-weight: 400;
transform: scale(0.92);
}
56% {
font-weight: 700;
transform: scale(1.3);
}
100% {
font-weight: 700;
transform: scale(1.08);
}
}
@keyframes sidebar-regex-disable {
0% {
font-weight: 700;
transform: scale(1.08);
}
56% {
font-weight: 700;
transform: scale(1.26);
}
100% {
font-weight: 400;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar-regex-toggle,
.sidebar-regex-glyph {
animation: none;
transition: none;
}
}
</style>
@@ -1478,7 +1478,19 @@ function onKeydown(event: KeyboardEvent) {
@keydown.escape.prevent="cancelRename"
@click.stop
/>
<span v-else ref="labelRef" :class="[labelWidthClass, { 'flex-1': node.type === 'connection' && !trailingComment, 'font-semibold': isLoginUserNode }]">{{ visibleLabel(node) }}</span>
<span
v-else
ref="labelRef"
:class="[
labelWidthClass,
{
'flex-1': node.type === 'connection' && !trailingComment,
'tree-connection-label': node.type === 'connection' || node.type === 'connection-group',
'font-semibold': isLoginUserNode,
},
]"
>{{ visibleLabel(node) }}</span
>
<span v-if="treeNodeSecondaryValue(node)" class="min-w-0 max-w-[55%] shrink truncate text-xs text-muted-foreground" :title="treeNodeSecondaryValue(node)">{{ treeNodeSecondaryValue(node) }}</span>
<button
v-if="canDragPinnedOrder()"
@@ -1635,6 +1647,10 @@ function onKeydown(event: KeyboardEvent) {
opacity: 1;
}
.tree-connection-label {
font-weight: 350;
}
.tree-item-connection-tint {
isolation: isolate;
background-color: transparent !important;
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import SidebarListOptionsIcon from "../SidebarListOptionsIcon.vue";
import SidebarLocateButton from "../SidebarLocateButton.vue";
import SidebarRegexToggleButton from "../SidebarRegexToggleButton.vue";
const mountedApps: App[] = [];
async function mount(component: Component) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(component);
mountedApps.push(app);
app.mount(container);
await nextTick();
return container;
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("sidebar toolbar animations", () => {
it("restarts the locate pulse on every click without changing the button size", async () => {
const locate = vi.fn();
const container = await mount(
defineComponent({
setup: () => () => h(SidebarLocateButton, { label: "Locate", onLocate: locate }),
}),
);
const button = container.querySelector("[data-sidebar-locate-button]");
if (!(button instanceof HTMLButtonElement)) throw new Error("Locate button was not rendered");
expect(button.className).toContain("h-6 w-6");
expect(button.querySelector("[data-sidebar-locate-pulse]")).toBeNull();
button.click();
await nextTick();
const firstPulse = button.querySelector("[data-sidebar-locate-pulse]");
expect(firstPulse).not.toBeNull();
expect(locate).toHaveBeenCalledTimes(1);
button.click();
await nextTick();
expect(button.querySelector("[data-sidebar-locate-pulse]")).not.toBe(firstPulse);
expect(locate).toHaveBeenCalledTimes(2);
});
it("morphs the list options icon between unfiltered and filtered states", async () => {
const state = reactive({ filtered: false, open: false });
const container = await mount(
defineComponent({
setup: () => () => h(SidebarListOptionsIcon, { filtered: state.filtered, open: state.open }),
}),
);
const icon = container.querySelector("[data-sidebar-list-options-icon]");
expect(icon?.querySelector("[data-sidebar-unfiltered-icon]")).not.toBeNull();
expect(icon?.className).not.toContain("sidebar-list-options-icon--filtered");
state.filtered = true;
state.open = true;
await nextTick();
expect(icon?.querySelector("[data-sidebar-filtered-icon]")).not.toBeNull();
expect(icon?.className).toContain("sidebar-list-options-icon--filtered");
expect(icon?.className).toContain("sidebar-list-options-icon--open");
state.filtered = false;
state.open = false;
await nextTick();
expect(icon?.querySelector("[data-sidebar-unfiltered-icon]")).not.toBeNull();
expect(icon?.className).not.toContain("sidebar-list-options-icon--filtered");
});
it("replays a bold scale animation in both JavaScript regex states", async () => {
const state = reactive({ pressed: false, invalid: false });
const container = await mount(
defineComponent({
setup: () => () =>
h(SidebarRegexToggleButton, {
label: "JavaScript regex",
pressed: state.pressed,
invalid: state.invalid,
onToggle: () => {
state.pressed = !state.pressed;
},
}),
}),
);
const button = container.querySelector("[data-sidebar-regex-toggle]");
if (!(button instanceof HTMLButtonElement)) throw new Error("Regex button was not rendered");
expect(button.getAttribute("aria-pressed")).toBe("false");
expect(button.querySelector("[data-sidebar-regex-indicator]")).toBeNull();
const initialGlyph = button.querySelector("[data-sidebar-regex-glyph]");
button.click();
await nextTick();
expect(button.getAttribute("aria-pressed")).toBe("true");
expect(button.className).toContain("sidebar-regex-toggle--pressed");
const pressedGlyph = button.querySelector("[data-sidebar-regex-glyph]");
expect(pressedGlyph).not.toBe(initialGlyph);
expect(pressedGlyph?.className).toContain("sidebar-regex-glyph--pressed");
expect(pressedGlyph?.className).toContain("sidebar-regex-glyph--animated");
button.click();
await nextTick();
expect(button.getAttribute("aria-pressed")).toBe("false");
expect(button.className).not.toContain("sidebar-regex-toggle--pressed");
const releasedGlyph = button.querySelector("[data-sidebar-regex-glyph]");
expect(releasedGlyph).not.toBe(pressedGlyph);
expect(releasedGlyph?.className).not.toContain("sidebar-regex-glyph--pressed");
expect(releasedGlyph?.className).toContain("sidebar-regex-glyph--animated");
});
});
@@ -173,7 +173,9 @@ onBeforeUnmount(close);
<template>
<button ref="triggerRef" type="button" :class="triggerClass" :title="triggerTitle ?? selectedItem?.title" :aria-label="ariaLabel" :aria-expanded="open" :disabled="disabled" @click="toggle">
<component :is="triggerIcon" v-if="triggerIcon" :class="triggerIconClass" />
<slot name="trigger-icon" :open="open">
<component :is="triggerIcon" v-if="triggerIcon" :class="triggerIconClass" />
</slot>
<span v-if="showTriggerLabel">{{ triggerLabel ?? selectedItem?.label }}</span>
<ChevronDown v-if="showChevron" class="h-3 w-3 opacity-50" />
</button>