perf(desktop): use lightweight dropdown menus
This commit is contained in:
parent
4d32127522
commit
133f3cfc04
|
|
@ -33,13 +33,8 @@ import {
|
|||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -166,6 +161,27 @@ const activePlaceholder = computed(
|
|||
() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`,
|
||||
);
|
||||
const activeModeHint = computed(() => t(`ai.modeHints.${assistantMode.value}`));
|
||||
const assistantModeItems = computed(() => [
|
||||
{
|
||||
value: "ask",
|
||||
label: t("ai.modes.ask"),
|
||||
title: t("ai.modeHints.ask"),
|
||||
icon: MessageSquarePlus,
|
||||
},
|
||||
{
|
||||
value: "agent",
|
||||
label: t("ai.modes.agent"),
|
||||
title: t("ai.modeHints.agent"),
|
||||
icon: Bot,
|
||||
},
|
||||
]);
|
||||
const actionMenuItems = computed(() =>
|
||||
actionButtons.map((button) => ({
|
||||
value: button.action,
|
||||
label: t(button.key),
|
||||
icon: button.icon,
|
||||
})),
|
||||
);
|
||||
const aiCodeAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
|
||||
const { databaseOptions: allDbOptions, loadDatabaseOptions } = useDatabaseOptions();
|
||||
|
|
@ -956,78 +972,19 @@ const messageRenderer = computed(() => {
|
|||
@keydown="onPromptKeydown"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:title="activeModeHint"
|
||||
>
|
||||
<component :is="assistantMode === 'agent' ? Bot : MessageSquarePlus" class="h-3 w-3" />
|
||||
<span>{{ t(`ai.modes.${assistantMode}`) }}</span>
|
||||
<svg
|
||||
class="h-3 w-3 opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem class="text-xs gap-1.5" :title="t('ai.modeHints.ask')" @click="assistantMode = 'ask'">
|
||||
<Check class="h-3 w-3 shrink-0" :class="{ 'opacity-0': assistantMode !== 'ask' }" />
|
||||
<MessageSquarePlus class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span>{{ t("ai.modes.ask") }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
class="text-xs gap-1.5"
|
||||
:title="t('ai.modeHints.agent')"
|
||||
@click="assistantMode = 'agent'"
|
||||
>
|
||||
<Check class="h-3 w-3 shrink-0" :class="{ 'opacity-0': assistantMode !== 'agent' }" />
|
||||
<Bot class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span>{{ t("ai.modes.agent") }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<component :is="actionButtons.find((b) => b.action === activeAction)?.icon" class="h-3 w-3" />
|
||||
<span>{{ t(`ai.actions.${activeAction}`) }}</span>
|
||||
<svg
|
||||
class="h-3 w-3 opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-max min-w-0">
|
||||
<DropdownMenuItem
|
||||
v-for="btn in actionButtons"
|
||||
:key="btn.action"
|
||||
class="text-xs gap-1.5"
|
||||
@click="selectAction(btn.action)"
|
||||
>
|
||||
<component :is="btn.icon" class="h-3 w-3" />
|
||||
<span>{{ t(btn.key) }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
v-model="assistantMode"
|
||||
:items="assistantModeItems"
|
||||
:aria-label="activeModeHint"
|
||||
item-class="text-xs px-2"
|
||||
/>
|
||||
<LightDropdown
|
||||
:model-value="activeAction"
|
||||
:items="actionMenuItems"
|
||||
content-class="w-max min-w-0"
|
||||
item-class="text-xs px-2"
|
||||
@update:model-value="(value) => selectAction(value as AiAction)"
|
||||
/>
|
||||
<span class="flex-1" />
|
||||
<button
|
||||
v-if="isGenerating"
|
||||
|
|
|
|||
|
|
@ -51,11 +51,10 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -2623,6 +2622,46 @@ const {
|
|||
hasRowSelection,
|
||||
});
|
||||
|
||||
const pageSizeMenuItems = computed(() =>
|
||||
pageSizeOptions.value.map((size) => ({
|
||||
value: String(size),
|
||||
label: `${size} ${t("grid.rowsPerPageShort")}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const exportMenuItems = computed(() => [
|
||||
{ value: "csv", label: t("grid.exportCsv") },
|
||||
{ value: "xlsx", label: t("grid.exportXlsx") },
|
||||
{ value: "json", label: t("grid.exportJson") },
|
||||
{ value: "markdown", label: t("grid.exportMarkdown") },
|
||||
...(isMultiRow.value
|
||||
? [
|
||||
{ value: "selected-csv", label: t("grid.exportSelectedRowsCsv"), separatorBefore: true },
|
||||
{ value: "selected-xlsx", label: t("grid.exportSelectedRowsXlsx") },
|
||||
{ value: "selected-json", label: t("grid.exportSelectedRowsJson") },
|
||||
{ value: "selected-markdown", label: t("grid.exportSelectedRowsMarkdown") },
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
function selectPageSizeMenuItem(value: string) {
|
||||
changePageSize(Number(value));
|
||||
}
|
||||
|
||||
function selectExportMenuItem(value: string) {
|
||||
const actions: Record<string, () => void> = {
|
||||
csv: exportCsv,
|
||||
xlsx: exportXlsx,
|
||||
json: exportJson,
|
||||
markdown: exportMarkdown,
|
||||
"selected-csv": exportSelectedRowsCsv,
|
||||
"selected-xlsx": exportSelectedRowsXlsx,
|
||||
"selected-json": exportSelectedRowsJson,
|
||||
"selected-markdown": exportSelectedRowsMarkdown,
|
||||
};
|
||||
actions[value]?.();
|
||||
}
|
||||
|
||||
// --- Cell selection and detail ---
|
||||
function showCellDetails(rowIndex: number, colIndex: number) {
|
||||
resetDetailEdit();
|
||||
|
|
@ -5728,45 +5767,45 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
<Loader2 v-if="loading" class="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5">
|
||||
{{ pageSize }}{{ t("grid.rowsPerPageShort") }}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-36">
|
||||
<DropdownMenuItem v-for="s in pageSizeOptions" :key="s" @click="changePageSize(s)">
|
||||
{{ s }} {{ t("grid.rowsPerPageShort") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel class="text-xs">{{ t("grid.customRowsPerPage") }}</DropdownMenuLabel>
|
||||
<div class="flex items-center gap-1 px-2 pb-2" @click.stop @keydown.stop>
|
||||
<Input
|
||||
v-model="customPageSizeInput"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
:min="MIN_RESULT_PAGE_SIZE"
|
||||
:max="MAX_RESULT_PAGE_SIZE"
|
||||
class="h-7 w-24 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@keydown.enter.prevent.stop="applyCustomPageSize"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
:aria-label="t('grid.applyPageSize')"
|
||||
@click.stop="applyCustomPageSize"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ t("grid.applyPageSize") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
:model-value="String(pageSize)"
|
||||
:items="pageSizeMenuItems"
|
||||
:trigger-label="`${pageSize}${t('grid.rowsPerPageShort')}`"
|
||||
trigger-class="inline-flex h-5 items-center justify-center rounded-md px-1.5 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
content-class="w-36"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="selectPageSizeMenuItem"
|
||||
>
|
||||
<div class="bg-border -mx-1 my-1 h-px" />
|
||||
<div class="text-muted-foreground px-1.5 py-1 text-xs">{{ t("grid.customRowsPerPage") }}</div>
|
||||
<div class="flex items-center gap-1 px-1.5 pb-1" @click.stop @keydown.stop>
|
||||
<Input
|
||||
v-model="customPageSizeInput"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
:min="MIN_RESULT_PAGE_SIZE"
|
||||
:max="MAX_RESULT_PAGE_SIZE"
|
||||
class="h-6 w-20 px-1.5 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@keydown.enter.prevent.stop="applyCustomPageSize"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0"
|
||||
:aria-label="t('grid.applyPageSize')"
|
||||
@click.stop="applyCustomPageSize"
|
||||
>
|
||||
<Check class="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ t("grid.applyPageSize") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</LightDropdown>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="currentPage <= 1" @click="firstPage">
|
||||
<ChevronsLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -5782,28 +5821,20 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</Button>
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5">
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="exportCsv">{{ t("grid.exportCsv") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportXlsx">{{ t("grid.exportXlsx") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportJson">{{ t("grid.exportJson") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportMarkdown">{{ t("grid.exportMarkdown") }}</DropdownMenuItem>
|
||||
<template v-if="isMultiRow">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="exportSelectedRowsCsv">{{ t("grid.exportSelectedRowsCsv") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportSelectedRowsXlsx">{{ t("grid.exportSelectedRowsXlsx") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportSelectedRowsJson">{{ t("grid.exportSelectedRowsJson") }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportSelectedRowsMarkdown">{{
|
||||
t("grid.exportSelectedRowsMarkdown")
|
||||
}}</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
model-value=""
|
||||
:items="exportMenuItems"
|
||||
:aria-label="t('grid.export')"
|
||||
:trigger-icon="Download"
|
||||
trigger-class="inline-flex h-5 w-5 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
|
||||
trigger-icon-class="h-3 w-3"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="selectExportMenuItem"
|
||||
/>
|
||||
|
||||
<Tooltip v-if="sqlOneLiner">
|
||||
<TooltipTrigger as-child>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { Upload, Download, RefreshCw } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
|
@ -30,6 +25,11 @@ const { t } = useI18n();
|
|||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const connectionTreeRef = ref<InstanceType<typeof ConnectionTree>>();
|
||||
const importSourceItems = computed(() => [
|
||||
{ value: "dbx", label: t("sidebar.importDbx") },
|
||||
{ value: "navicat", label: t("sidebar.importNavicat") },
|
||||
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
|
||||
]);
|
||||
|
||||
async function refreshTree() {
|
||||
try {
|
||||
|
|
@ -69,24 +69,22 @@ defineExpose({ focusSearch });
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("contextMenu.refreshChildren") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('sidebar.import')">
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-44">
|
||||
<DropdownMenuItem @select.prevent="emit('import', 'dbx')">
|
||||
{{ t("sidebar.importDbx") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @select.prevent="emit('import', 'navicat')">
|
||||
{{ t("sidebar.importNavicat") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @select.prevent="emit('import', 'dbeaver')">
|
||||
{{ t("sidebar.importDbeaver") }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
model-value=""
|
||||
:items="importSourceItems"
|
||||
:aria-label="t('sidebar.import')"
|
||||
:trigger-title="t('sidebar.import')"
|
||||
:trigger-icon="Upload"
|
||||
trigger-class="inline-flex h-5 w-5 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
|
||||
trigger-icon-class="h-3 w-3"
|
||||
content-class="w-44"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="(source) => emit('import', source as 'dbx' | 'navicat' | 'dbeaver')"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('export')">
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ import type { CSSProperties } from "vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, Package, Check } from "lucide-vue-next";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -155,6 +150,15 @@ const dataTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "da
|
|||
const showPinnedDataTabsMenu = computed(
|
||||
() => dataTabs.value.length > 0 && (canScrollLeft.value || canScrollRight.value),
|
||||
);
|
||||
const dataTabMenuItems = computed(() =>
|
||||
dataTabs.value.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: tabDisplayTitle(tab),
|
||||
title: tabDisplayTitle(tab),
|
||||
icon: Table2,
|
||||
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||
})),
|
||||
);
|
||||
|
||||
function activateDataTab(tabId: string) {
|
||||
tabScrollBehavior.value = "auto";
|
||||
|
|
@ -166,12 +170,16 @@ const tabsContainerStyle = computed<CSSProperties>(() => ({
|
|||
msOverflowStyle: "none",
|
||||
scrollbarWidth: "none",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
paddingRight: showPinnedDataTabsMenu.value ? "36px" : "0px",
|
||||
paddingRight: showPinnedDataTabsMenu.value && settingsStore.editorSettings.appLayout !== "classic" ? "28px" : "0px",
|
||||
}));
|
||||
|
||||
const tabTailDragRegionClass = computed(() =>
|
||||
showPinnedDataTabsMenu.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
|
||||
);
|
||||
|
||||
const dataTabsMenuContainerClass = computed(() =>
|
||||
settingsStore.editorSettings.appLayout === "classic"
|
||||
? "absolute inset-y-0 right-0 z-30 flex items-stretch"
|
||||
? "relative z-30 flex shrink-0 items-stretch"
|
||||
: "absolute inset-y-0 -right-2 z-30 flex items-stretch",
|
||||
);
|
||||
</script>
|
||||
|
|
@ -301,7 +309,7 @@ const dataTabsMenuContainerClass = computed(() =>
|
|||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-w-8 flex-1 self-stretch" data-tauri-drag-region />
|
||||
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
|
||||
</div>
|
||||
<div
|
||||
v-if="canScrollRight"
|
||||
|
|
@ -309,27 +317,24 @@ const dataTabsMenuContainerClass = computed(() =>
|
|||
aria-hidden="true"
|
||||
/>
|
||||
<div v-if="showPinnedDataTabsMenu" :class="dataTabsMenuContainerClass">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="h-full w-7 rounded-none text-foreground/70 hover:text-foreground inline-flex items-center justify-center bg-background"
|
||||
:aria-label="t('tabs.openDataTabs')"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-auto min-w-36 max-w-60">
|
||||
<DropdownMenuItem
|
||||
v-for="tab in dataTabs"
|
||||
:key="tab.id"
|
||||
class="text-xs max-w-full"
|
||||
@click="activateDataTab(tab.id)"
|
||||
>
|
||||
<Table2 class="w-3.5 h-3.5 mr-2 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<span class="truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
:model-value="queryStore.activeTabId || ''"
|
||||
:items="dataTabMenuItems"
|
||||
:aria-label="t('tabs.openDataTabs')"
|
||||
:trigger-icon="ChevronDown"
|
||||
trigger-class="h-full w-7 rounded-none text-foreground/70 hover:text-foreground inline-flex items-center justify-center bg-background"
|
||||
trigger-icon-class="h-4 w-4"
|
||||
item-icon-class="w-3.5 h-3.5 mr-2"
|
||||
item-class="max-w-full"
|
||||
content-class="w-auto min-w-36 max-w-60"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
:highlight-selected="false"
|
||||
:match-trigger-width="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="activateDataTab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
DatabaseZap,
|
||||
|
|
@ -8,7 +9,6 @@ import {
|
|||
Moon,
|
||||
Sun,
|
||||
SunMoon,
|
||||
Check,
|
||||
History,
|
||||
Bot,
|
||||
ArrowLeftRight,
|
||||
|
|
@ -20,13 +20,8 @@ import {
|
|||
Package,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import WindowControls from "@/components/layout/WindowControls.vue";
|
||||
import { shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
|
||||
import { currentLocale, setLocale, type Locale } from "@/i18n";
|
||||
|
|
@ -38,7 +33,7 @@ const localeOptions: { value: Locale; flag: string; label: string }[] = [
|
|||
{ value: "zh-CN", flag: "🇨🇳", label: "简体中文" },
|
||||
];
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isDark: boolean;
|
||||
themeMode: AppThemeMode;
|
||||
showAiPanel: boolean;
|
||||
|
|
@ -71,6 +66,23 @@ const { t } = useI18n();
|
|||
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } =
|
||||
useWindowControls();
|
||||
|
||||
const themeItems = computed(() => [
|
||||
{ value: "light", label: t("toolbar.themeLight"), icon: Sun },
|
||||
{ value: "dark", label: t("toolbar.themeDark"), icon: Moon },
|
||||
{ value: "system", label: t("toolbar.themeSystem"), icon: SunMoon },
|
||||
]);
|
||||
const localeItems = computed(() =>
|
||||
localeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
leadingText: option.flag,
|
||||
})),
|
||||
);
|
||||
const themeTriggerIcon = computed(() => {
|
||||
if (props.themeMode === "system") return SunMoon;
|
||||
return props.isDark ? Moon : Sun;
|
||||
});
|
||||
|
||||
function onToolbarDblClick(e: MouseEvent) {
|
||||
if (isDesktop) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
|
@ -219,44 +231,20 @@ function onToolbarDblClick(e: MouseEvent) {
|
|||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('toolbar.theme')">
|
||||
<SunMoon v-if="themeMode === 'system'" class="h-4 w-4" />
|
||||
<Moon v-else-if="isDark" class="h-4 w-4" />
|
||||
<Sun v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
class="gap-2"
|
||||
:class="{ 'bg-accent': themeMode === 'light' }"
|
||||
@select="emit('set-theme-mode', 'light')"
|
||||
>
|
||||
<Sun class="h-4 w-4" />
|
||||
{{ t("toolbar.themeLight") }}
|
||||
<Check v-if="themeMode === 'light'" class="ml-auto h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
class="gap-2"
|
||||
:class="{ 'bg-accent': themeMode === 'dark' }"
|
||||
@select="emit('set-theme-mode', 'dark')"
|
||||
>
|
||||
<Moon class="h-4 w-4" />
|
||||
{{ t("toolbar.themeDark") }}
|
||||
<Check v-if="themeMode === 'dark'" class="ml-auto h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
class="gap-2"
|
||||
:class="{ 'bg-accent': themeMode === 'system' }"
|
||||
@select="emit('set-theme-mode', 'system')"
|
||||
>
|
||||
<SunMoon class="h-4 w-4" />
|
||||
{{ t("toolbar.themeSystem") }}
|
||||
<Check v-if="themeMode === 'system'" class="ml-auto h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
:model-value="themeMode"
|
||||
:items="themeItems"
|
||||
:aria-label="t('toolbar.theme')"
|
||||
:trigger-icon="themeTriggerIcon"
|
||||
trigger-class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
|
||||
trigger-icon-class="h-4 w-4"
|
||||
item-icon-class="h-4 w-4"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
check-position="right"
|
||||
align="end"
|
||||
@update:model-value="(value) => emit('set-theme-mode', value as AppThemeMode)"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("toolbar.theme") }}</TooltipContent>
|
||||
|
|
@ -265,25 +253,19 @@ function onToolbarDblClick(e: MouseEvent) {
|
|||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.language')">
|
||||
<Globe class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-for="option in localeOptions"
|
||||
:key="option.value"
|
||||
class="gap-2"
|
||||
:class="{ 'bg-accent': currentLocale() === option.value }"
|
||||
@click="setLocale(option.value)"
|
||||
>
|
||||
<span class="text-base leading-none">{{ option.flag }}</span>
|
||||
<span>{{ option.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
:model-value="currentLocale()"
|
||||
:items="localeItems"
|
||||
:aria-label="t('common.language')"
|
||||
:trigger-icon="Globe"
|
||||
trigger-class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
|
||||
trigger-icon-class="h-4 w-4"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="(value) => setLocale(value as Locale)"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("common.language") }}</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from "vue";
|
||||
import { ref, computed, nextTick, watch, type Component } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
ListFilter,
|
||||
FolderPlus,
|
||||
Server,
|
||||
Database,
|
||||
FolderTree,
|
||||
Table2,
|
||||
Eye,
|
||||
RotateCcw,
|
||||
} from "lucide-vue-next";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -26,14 +37,7 @@ import {
|
|||
import TreeItem from "./TreeItem.vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
|
|
@ -80,13 +84,30 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
|||
|
||||
const searchScopeOptions = computed(() => {
|
||||
return [
|
||||
{ scope: "connection", label: t("sidebar.searchScopeConnection") },
|
||||
{ scope: "database", label: t("sidebar.searchScopeDatabase") },
|
||||
{ scope: "schema", label: t("sidebar.searchScopeSchema") },
|
||||
{ scope: "table", label: t("sidebar.searchScopeTable") },
|
||||
{ scope: "view", label: t("sidebar.searchScopeView") },
|
||||
] as const satisfies ReadonlyArray<{ scope: SearchScope; label: string }>;
|
||||
{ scope: "connection", label: t("sidebar.searchScopeConnection"), icon: Server },
|
||||
{ scope: "database", label: t("sidebar.searchScopeDatabase"), icon: Database },
|
||||
{ scope: "schema", label: t("sidebar.searchScopeSchema"), icon: FolderTree },
|
||||
{ scope: "table", label: t("sidebar.searchScopeTable"), icon: Table2 },
|
||||
{ scope: "view", label: t("sidebar.searchScopeView"), icon: Eye },
|
||||
] as const satisfies ReadonlyArray<{ scope: SearchScope; label: string; icon: Component }>;
|
||||
});
|
||||
const searchScopeMenuItems = computed(() => [
|
||||
...searchScopeOptions.value.map((item) => ({
|
||||
value: item.scope,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
})),
|
||||
...(hasSearchScopeFilter.value
|
||||
? [
|
||||
{
|
||||
value: "__clear",
|
||||
label: t("sidebar.clearFilter"),
|
||||
icon: RotateCcw,
|
||||
separatorBefore: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const hasSearchScopeFilter = computed(() => selectedSearchScopes.value.length > 0);
|
||||
const searchableNodeTypes = computed<Set<TreeNodeType> | undefined>(() => {
|
||||
|
|
@ -100,10 +121,6 @@ const searchableNodeTypes = computed<Set<TreeNodeType> | undefined>(() => {
|
|||
return types;
|
||||
});
|
||||
|
||||
function isSearchScopeSelected(scope: SearchScope) {
|
||||
return selectedSearchScopes.value.includes(scope);
|
||||
}
|
||||
|
||||
function toggleSearchScope(scope: SearchScope) {
|
||||
const idx = selectedSearchScopes.value.indexOf(scope);
|
||||
if (idx >= 0) {
|
||||
|
|
@ -113,6 +130,14 @@ function toggleSearchScope(scope: SearchScope) {
|
|||
}
|
||||
}
|
||||
|
||||
function selectSearchScopeMenuItem(value: string) {
|
||||
if (value === "__clear") {
|
||||
clearSearchScopeFilter();
|
||||
return;
|
||||
}
|
||||
toggleSearchScope(value as SearchScope);
|
||||
}
|
||||
|
||||
function clearSearchScopeFilter() {
|
||||
selectedSearchScopes.value = [];
|
||||
}
|
||||
|
|
@ -268,38 +293,32 @@ defineExpose({ focusSearch });
|
|||
>
|
||||
<FolderPlus class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<DropdownMenu v-if="searchScopeOptions.length > 0">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent"
|
||||
:class="hasSearchScopeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'"
|
||||
:title="t('sidebar.filterByType')"
|
||||
>
|
||||
<ListFilter class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuLabel class="text-xs">{{ t("sidebar.filterByType") }}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-for="item in searchScopeOptions"
|
||||
:key="item.scope"
|
||||
class="gap-2"
|
||||
:class="isSearchScopeSelected(item.scope) ? 'bg-primary/10 text-primary' : ''"
|
||||
@select.prevent="toggleSearchScope(item.scope)"
|
||||
>
|
||||
<Check v-if="isSearchScopeSelected(item.scope)" class="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span v-else class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
<template v-if="hasSearchScopeFilter">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @select.prevent="clearSearchScopeFilter">
|
||||
<span class="text-xs text-muted-foreground">{{ t("sidebar.clearFilter") }}</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LightDropdown
|
||||
v-if="searchScopeOptions.length > 0"
|
||||
model-value=""
|
||||
:items="searchScopeMenuItems"
|
||||
:selected-values="selectedSearchScopes"
|
||||
:aria-label="t('sidebar.filterByType')"
|
||||
:label="t('sidebar.filterByType')"
|
||||
:trigger-title="t('sidebar.filterByType')"
|
||||
:trigger-icon="ListFilter"
|
||||
:trigger-class="
|
||||
[
|
||||
'shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent',
|
||||
hasSearchScopeFilter ? '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"
|
||||
selected-check-class="text-primary"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
:close-on-select="false"
|
||||
align="end"
|
||||
@update:model-value="selectSearchScopeMenuItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RecycleScroller
|
||||
|
|
|
|||
|
|
@ -2629,10 +2629,10 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
}
|
||||
|
||||
/* Focused: soft blue */
|
||||
.sidebar-tree:focus-within .tree-item-active {
|
||||
.tree-item-active:focus {
|
||||
background-color: oklch(0.91 0.03 250) !important;
|
||||
}
|
||||
:root.dark .sidebar-tree:focus-within .tree-item-active {
|
||||
:root.dark .tree-item-active:focus {
|
||||
background-color: oklch(0.35 0.06 250) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, type Component } from "vue";
|
||||
import { Check, ChevronDown } from "lucide-vue-next";
|
||||
|
||||
export interface LightDropdownItem {
|
||||
label: string;
|
||||
value: string;
|
||||
title?: string;
|
||||
icon?: Component;
|
||||
iconClass?: string;
|
||||
leadingText?: string;
|
||||
disabled?: boolean;
|
||||
separatorBefore?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
items: LightDropdownItem[];
|
||||
ariaLabel?: string;
|
||||
contentClass?: string;
|
||||
triggerClass?: string;
|
||||
triggerTitle?: string;
|
||||
triggerIcon?: Component;
|
||||
triggerLabel?: string;
|
||||
showTriggerLabel?: boolean;
|
||||
showChevron?: boolean;
|
||||
checkPosition?: "left" | "right" | "none";
|
||||
align?: "start" | "end";
|
||||
triggerIconClass?: string;
|
||||
itemIconClass?: string;
|
||||
itemClass?: string;
|
||||
labelClass?: string;
|
||||
selectedValues?: string[];
|
||||
highlightSelected?: boolean;
|
||||
selectedItemClass?: string;
|
||||
selectedCheckClass?: string;
|
||||
closeOnSelect?: boolean;
|
||||
label?: string;
|
||||
matchTriggerWidth?: boolean;
|
||||
}>(),
|
||||
{
|
||||
ariaLabel: undefined,
|
||||
contentClass: "",
|
||||
triggerClass:
|
||||
"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
triggerTitle: undefined,
|
||||
triggerIcon: undefined,
|
||||
triggerLabel: undefined,
|
||||
showTriggerLabel: true,
|
||||
showChevron: true,
|
||||
checkPosition: "left",
|
||||
align: "start",
|
||||
triggerIconClass: "h-3 w-3",
|
||||
itemIconClass: "h-3 w-3",
|
||||
itemClass: "",
|
||||
labelClass: "text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||
selectedValues: undefined,
|
||||
highlightSelected: true,
|
||||
selectedItemClass: "bg-accent",
|
||||
selectedCheckClass: "",
|
||||
closeOnSelect: true,
|
||||
label: undefined,
|
||||
matchTriggerWidth: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const triggerRef = ref<HTMLButtonElement>();
|
||||
const menuRef = ref<HTMLElement>();
|
||||
const open = ref(false);
|
||||
const x = ref(0);
|
||||
const y = ref(0);
|
||||
const minWidth = ref(0);
|
||||
|
||||
const selectedItem = computed(() => props.items.find((item) => item.value === props.modelValue));
|
||||
const triggerIcon = computed(() => props.triggerIcon ?? selectedItem.value?.icon);
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${x.value}px`,
|
||||
top: `${y.value}px`,
|
||||
minWidth: props.matchTriggerWidth ? `${minWidth.value}px` : undefined,
|
||||
}));
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
window.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("resize", close);
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
const trigger = triggerRef.value;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
x.value = Math.min(Math.max(8, rect.left), window.innerWidth - 8);
|
||||
y.value = rect.bottom + 4;
|
||||
minWidth.value = rect.width;
|
||||
}
|
||||
|
||||
function fitPositionToViewport() {
|
||||
const trigger = triggerRef.value;
|
||||
const menu = menuRef.value;
|
||||
if (!trigger || !menu) return;
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const margin = 8;
|
||||
const openBelow = triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
|
||||
const preferredX = props.align === "end" ? triggerRect.right - menuRect.width : triggerRect.left;
|
||||
x.value = Math.min(Math.max(margin, preferredX), Math.max(margin, window.innerWidth - menuRect.width - margin));
|
||||
y.value = openBelow ? triggerRect.bottom + gap : Math.max(margin, triggerRect.top - menuRect.height - gap);
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
updatePosition();
|
||||
open.value = true;
|
||||
nextTick(fitPositionToViewport);
|
||||
document.addEventListener("pointerdown", onOutsidePointerDown, true);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
window.addEventListener("scroll", close, true);
|
||||
window.addEventListener("resize", close);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (open.value) {
|
||||
close();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsidePointerDown(event: PointerEvent) {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.value?.contains(target) || menuRef.value?.contains(target)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function isItemSelected(item: LightDropdownItem) {
|
||||
if (props.selectedValues) return props.selectedValues.includes(item.value);
|
||||
return item.value === props.modelValue;
|
||||
}
|
||||
|
||||
function selectItem(item: LightDropdownItem) {
|
||||
if (item.disabled) return;
|
||||
emit("update:modelValue", item.value);
|
||||
if (props.closeOnSelect) close();
|
||||
}
|
||||
|
||||
onBeforeUnmount(close);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
:class="triggerClass"
|
||||
:title="triggerTitle ?? selectedItem?.title"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-expanded="open"
|
||||
@click="toggle"
|
||||
>
|
||||
<component :is="triggerIcon" v-if="triggerIcon" :class="triggerIconClass" />
|
||||
<span v-if="showTriggerLabel">{{ triggerLabel ?? selectedItem?.label }}</span>
|
||||
<ChevronDown v-if="showChevron" class="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
class="fixed z-50 min-w-32 rounded-lg p-1 cn-menu-translucent text-popover-foreground"
|
||||
:class="contentClass"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
>
|
||||
<div v-if="label" :class="labelClass">{{ label }}</div>
|
||||
<div v-if="label" class="bg-border -mx-1 my-1 h-px" />
|
||||
<template v-for="item in items" :key="item.value">
|
||||
<div v-if="item.separatorBefore" class="bg-border -mx-1 my-1 h-px" />
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-sm outline-hidden hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="[itemClass, highlightSelected && isItemSelected(item) ? selectedItemClass : '']"
|
||||
:disabled="item.disabled"
|
||||
:title="item.title"
|
||||
role="menuitem"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<Check
|
||||
v-if="checkPosition === 'left'"
|
||||
class="h-3 w-3 shrink-0"
|
||||
:class="[isItemSelected(item) ? selectedCheckClass : 'opacity-0']"
|
||||
/>
|
||||
<span v-if="item.leadingText" class="text-base leading-none">{{ item.leadingText }}</span>
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
:class="[itemIconClass, 'shrink-0 text-muted-foreground', item.iconClass]"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<Check
|
||||
v-if="checkPosition === 'right' && isItemSelected(item)"
|
||||
class="ml-auto h-4 w-4 shrink-0"
|
||||
:class="selectedCheckClass"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<slot />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Loading…
Reference in New Issue