feat(ui): animate sidebar and toolbar interaction feedback
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
+116
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user