feat(types): add database type help
This commit is contained in:
30 files changed
+2006
-79
No files matched your search
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { OptionHelpPanel } from "@/components/ui/option-help-panel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
@@ -33,6 +34,8 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
|
||||
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -100,6 +103,12 @@ const createKeyRawMode = ref(false);
|
||||
const createKeyEntryId = ref("*");
|
||||
const jsonModuleAvailable = ref<boolean | null>(null);
|
||||
const checkingJsonModule = ref(false);
|
||||
const activeCreateKeyTypeHelp = ref<RedisCreateKeyType>();
|
||||
const createKeyTypeKeyboardNavigation = ref(false);
|
||||
const createKeyTypeOpenedByArrow = ref(false);
|
||||
const createKeyTypeListCard = ref<HTMLElement>();
|
||||
const createKeyTypeHelpPanel = ref<{ element?: HTMLElement }>();
|
||||
const createKeyTypeHelpOffsetTop = ref(0);
|
||||
let nextEntryId = 0;
|
||||
let searchRequestId = 0;
|
||||
let redisBrowserIsActive = true;
|
||||
@@ -166,6 +175,67 @@ const createKeyTypeOptions = computed<{ value: RedisCreateKeyType; label: string
|
||||
{ value: "stream", label: "Stream" },
|
||||
{ value: "json", label: "JSON" },
|
||||
]);
|
||||
function createKeyTypeTooltip(type: RedisCreateKeyType): string | undefined {
|
||||
const help = getRedisCreateKeyTypeHelp(type);
|
||||
return help ? t(`redis.createKeyTypeHelp.${help.key}`) : undefined;
|
||||
}
|
||||
const activeCreateKeyTypeHelpContent = computed(() => (activeCreateKeyTypeHelp.value ? createKeyTypeTooltip(activeCreateKeyTypeHelp.value) : undefined));
|
||||
|
||||
function activateCreateKeyTypeHelp(type: RedisCreateKeyType) {
|
||||
activeCreateKeyTypeHelp.value = createKeyTypeTooltip(type) ? type : undefined;
|
||||
}
|
||||
|
||||
function onCreateKeyTypeSelectOpen(open: boolean) {
|
||||
if (open) {
|
||||
activeCreateKeyTypeHelp.value = redisCreateKeyTypeHelpOptionOnOpen(createKeyType.value);
|
||||
return;
|
||||
}
|
||||
activeCreateKeyTypeHelp.value = undefined;
|
||||
createKeyTypeKeyboardNavigation.value = false;
|
||||
createKeyTypeOpenedByArrow.value = false;
|
||||
}
|
||||
|
||||
function onCreateKeyTypeTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
createKeyTypeOpenedByArrow.value = true;
|
||||
createKeyTypeKeyboardNavigation.value = true;
|
||||
}
|
||||
|
||||
function onCreateKeyTypeSelectKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
createKeyTypeKeyboardNavigation.value = true;
|
||||
}
|
||||
|
||||
function onCreateKeyTypeOptionFocus(type: RedisCreateKeyType) {
|
||||
if (!shouldActivateRedisCreateKeyTypeHelpOnFocus({ openedByArrow: createKeyTypeOpenedByArrow.value, keyboardNavigating: createKeyTypeKeyboardNavigation.value })) return;
|
||||
activateCreateKeyTypeHelp(type);
|
||||
createKeyTypeOpenedByArrow.value = false;
|
||||
createKeyTypeKeyboardNavigation.value = false;
|
||||
}
|
||||
|
||||
async function updateCreateKeyTypeHelpOffset() {
|
||||
if (!activeCreateKeyTypeHelp.value) {
|
||||
createKeyTypeHelpOffsetTop.value = 0;
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const card = createKeyTypeListCard.value;
|
||||
const panel = createKeyTypeHelpPanel.value?.element;
|
||||
const option = card?.querySelector<HTMLElement>(`[data-option-help-value="${activeCreateKeyTypeHelp.value}"]`);
|
||||
if (!card || !panel || !option) {
|
||||
createKeyTypeHelpOffsetTop.value = 0;
|
||||
return;
|
||||
}
|
||||
createKeyTypeHelpOffsetTop.value = optionHelpPanelOffsetTop({
|
||||
activeItemTop: option.getBoundingClientRect().top - card.getBoundingClientRect().top,
|
||||
listCardHeight: card.clientHeight,
|
||||
panelHeight: panel.clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
watch(activeCreateKeyTypeHelp, () => {
|
||||
void updateCreateKeyTypeHelpOffset();
|
||||
});
|
||||
const visibleRows = computed(() => {
|
||||
const rows = useFlatKeySearchRows.value ? flatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value);
|
||||
return rows.map((row) => ({
|
||||
@@ -632,6 +702,9 @@ function resetCreateKeyForm() {
|
||||
createKeyEntryId.value = "*";
|
||||
jsonModuleAvailable.value = null;
|
||||
checkingJsonModule.value = false;
|
||||
activeCreateKeyTypeHelp.value = undefined;
|
||||
createKeyTypeKeyboardNavigation.value = false;
|
||||
createKeyTypeOpenedByArrow.value = false;
|
||||
resetEntries();
|
||||
}
|
||||
|
||||
@@ -640,6 +713,9 @@ function onCreateKeyTypeChange(type: any) {
|
||||
createKeyRawMode.value = false;
|
||||
jsonModuleAvailable.value = null;
|
||||
checkingJsonModule.value = false;
|
||||
activeCreateKeyTypeHelp.value = undefined;
|
||||
createKeyTypeKeyboardNavigation.value = false;
|
||||
createKeyTypeOpenedByArrow.value = false;
|
||||
resetEntries();
|
||||
if (createKeyType.value === "json") {
|
||||
createKeyError.value = "";
|
||||
@@ -1267,14 +1343,19 @@ defineExpose({ focusSearch });
|
||||
|
||||
<label class="grid gap-1.5 text-xs font-medium">
|
||||
<span>{{ t("redis.createKeyType") }}</span>
|
||||
<Select :model-value="createKeyType" @update:model-value="onCreateKeyTypeChange">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<Select :model-value="createKeyType" @update:open="onCreateKeyTypeSelectOpen" @update:model-value="onCreateKeyTypeChange">
|
||||
<SelectTrigger class="h-8 text-xs" @keydown.capture="onCreateKeyTypeTriggerKeydown">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in createKeyTypeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
<SelectContent data-naked-surface class="w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0">
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start" @keydown.capture="onCreateKeyTypeSelectKeydown">
|
||||
<div ref="createKeyTypeListCard" class="min-w-40 rounded-md border bg-popover p-1 shadow-md" @scroll="updateCreateKeyTypeHelpOffset">
|
||||
<SelectItem v-for="option in createKeyTypeOptions" :key="option.value" :value="option.value" :data-option-help-value="option.value" @pointerenter="activateCreateKeyTypeHelp(option.value)" @focus="onCreateKeyTypeOptionFocus(option.value)">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</div>
|
||||
<OptionHelpPanel v-if="activeCreateKeyTypeHelpContent" ref="createKeyTypeHelpPanel" :content="activeCreateKeyTypeHelpContent" :offset-top="createKeyTypeHelpOffsetTop" />
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
@@ -27,6 +27,9 @@ import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql";
|
||||
import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates";
|
||||
import { getMysqlDataTypeHelp } from "@/lib/table/mysqlDataTypeHelp";
|
||||
import { getPostgresDataTypeHelp } from "@/lib/table/postgresDataTypeHelp";
|
||||
import { getSqliteDataTypeHelp } from "@/lib/table/sqliteDataTypeHelp";
|
||||
import { getTableMetadataCapabilities, firstStructureMetadataTab, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities";
|
||||
import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities";
|
||||
import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
|
||||
@@ -43,6 +46,7 @@ import {
|
||||
createIndexDrafts,
|
||||
createTriggerDrafts,
|
||||
dataTypeLengthInputValue,
|
||||
defaultNewColumnDataType,
|
||||
generateIndexName,
|
||||
generateUniqueIndexName,
|
||||
getColumnEditorControls,
|
||||
@@ -599,6 +603,7 @@ function onIndexColResize(e: MouseEvent, col: number) {
|
||||
|
||||
const connection = computed(() => (props.connectionId ? store.getConfig(props.connectionId) : undefined));
|
||||
const databaseType = computed(() => tableStructureDatabaseTypeForConnection(connection.value));
|
||||
const usesMysql8SafeDefaults = computed(() => databaseType.value === "mysql" && connection.value?.db_type === "mysql" && connection.value.driver_profile === "mysql");
|
||||
const structureCapabilities = computed(() => getTableStructureCapabilities(databaseType.value, connection.value?.db_type));
|
||||
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(databaseType.value));
|
||||
const structureDialect = computed(() => structureCapabilities.value.dialect);
|
||||
@@ -1008,6 +1013,32 @@ function mergeDataTypeOptions(primary: readonly string[], fallback: readonly str
|
||||
return result;
|
||||
}
|
||||
|
||||
function mysqlDataTypeTooltip(option: string): string | undefined {
|
||||
if (databaseType.value !== "mysql") return undefined;
|
||||
const product = connection.value?.driver_profile === "mariadb" ? "mariadb" : connection.value?.driver_profile === "mysql" ? "mysql" : undefined;
|
||||
const help = getMysqlDataTypeHelp(option, { product });
|
||||
return help ? [help.key, ...(help.warningKeys ?? [])].map((key) => t(`structureEditor.mysqlDataTypeHelp.${key}`)).join("\n\n") : undefined;
|
||||
}
|
||||
|
||||
function postgresDataTypeTooltip(option: string): string | undefined {
|
||||
if (databaseType.value !== "postgres") return undefined;
|
||||
const help = getPostgresDataTypeHelp(option);
|
||||
return help ? t(`structureEditor.postgresDataTypeHelp.${help.key}`) : undefined;
|
||||
}
|
||||
|
||||
function sqliteDataTypeTooltip(option: string): string | undefined {
|
||||
if (databaseType.value !== "sqlite") return undefined;
|
||||
const help = getSqliteDataTypeHelp(option);
|
||||
return help ? t(`structureEditor.sqliteDataTypeHelp.${help.key}`) : undefined;
|
||||
}
|
||||
|
||||
function dataTypeTooltip(option: string): string | undefined {
|
||||
if (databaseType.value === "mysql") return mysqlDataTypeTooltip(option);
|
||||
if (databaseType.value === "postgres") return postgresDataTypeTooltip(option);
|
||||
if (databaseType.value === "sqlite") return sqliteDataTypeTooltip(option);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function loadDynamicDataTypeOptions() {
|
||||
const requestId = ++dataTypeOptionsRequestId;
|
||||
const connectionId = props.connectionId;
|
||||
@@ -1278,7 +1309,7 @@ async function refreshStructureAfterSave(scope: StructureRefreshScope) {
|
||||
async function addColumn() {
|
||||
if (!canAddColumn.value) return;
|
||||
activeTab.value = "columns";
|
||||
const dataType = databaseType.value === "manticoresearch" ? combineDataTypeForDatabase(databaseType.value, dataTypeOptions.value[0] ?? "text", getDefaultLengthForType(databaseType.value, dataTypeOptions.value[0] ?? "text")) : "varchar(255)";
|
||||
const dataType = defaultNewColumnDataType(databaseType.value, dataTypeOptions.value);
|
||||
const column: EditableStructureColumn = {
|
||||
id: `new:${uuid()}`,
|
||||
name: "",
|
||||
@@ -1517,7 +1548,7 @@ function updateColumnDataType(column: EditableStructureColumn, baseType: string)
|
||||
if (!column.enumValues?.length) column.enumValues = [""];
|
||||
column.dataType = mysqlEnumDataType(column.enumValues);
|
||||
} else {
|
||||
column.dataType = combineDataTypeForDatabase(databaseType.value, baseType, getDefaultLengthForType(databaseType.value, baseType));
|
||||
column.dataType = combineDataTypeForDatabase(databaseType.value, baseType, getDefaultLengthForType(databaseType.value, baseType, { omitMysqlDeprecatedDefaults: usesMysql8SafeDefaults.value }));
|
||||
}
|
||||
syncSqlServerIdentityForDataType(column);
|
||||
syncDamengIdentityForDataType(column);
|
||||
@@ -2567,6 +2598,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
:empty-text="t('structureEditor.noMatchingType')"
|
||||
:loading-text="t('common.loading')"
|
||||
:allow-custom="true"
|
||||
:option-tooltip="dataTypeTooltip"
|
||||
:trigger-class="[structureMonoControlClass, 'w-full']"
|
||||
@update:model-value="(v: string) => updateColumnDataType(column, v)"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { OPTION_HELP_PANEL_CLASS } from "./optionHelpPanel";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string;
|
||||
offsetTop?: number;
|
||||
}>(),
|
||||
{ offsetTop: 0 },
|
||||
);
|
||||
|
||||
const element = ref<HTMLElement>();
|
||||
defineExpose({ element });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside ref="element" data-slot="option-help-panel" role="note" :class="OPTION_HELP_PANEL_CLASS" :style="{ '--option-help-offset': `${offsetTop}px` }">
|
||||
{{ content }}
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OptionHelpPanel } from "./OptionHelpPanel.vue";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OPTION_HELP_PANEL_CLASS } from "./optionHelpPanel";
|
||||
|
||||
describe("OptionHelpPanel layout", () => {
|
||||
it("keeps the panel compact at the top and scrolls long help content", () => {
|
||||
expect(OPTION_HELP_PANEL_CLASS).toContain("self-start");
|
||||
expect(OPTION_HELP_PANEL_CLASS).toContain("max-h-64");
|
||||
expect(OPTION_HELP_PANEL_CLASS).toContain("overflow-y-auto");
|
||||
expect(OPTION_HELP_PANEL_CLASS).toContain("rounded-md");
|
||||
expect(OPTION_HELP_PANEL_CLASS).toContain("shadow-md");
|
||||
expect(OPTION_HELP_PANEL_CLASS).toContain("sm:mt-[var(--option-help-offset)]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const OPTION_HELP_PANEL_CLASS = "w-full shrink-0 self-start max-h-64 overflow-y-auto rounded-md border bg-popover px-3 py-2 text-xs leading-relaxed text-muted-foreground shadow-md sm:mt-[var(--option-help-offset)] sm:w-[min(26rem,calc(100vw-5rem))]";
|
||||
@@ -5,9 +5,12 @@ import { Check, ChevronDown, Search, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ButtonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { OptionHelpPanel } from "@/components/ui/option-help-panel";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { filterDatabaseOptions } from "@/lib/database/databaseOptionSearch";
|
||||
import { cn } from "@/lib/common/utils";
|
||||
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
|
||||
import { SEARCHABLE_SELECT_HELP_PANEL_ALIGN, searchableSelectKeyboardTooltipOption, searchableSelectSelectedOrFirstHelpOption } from "@/lib/common/searchableSelectTooltip";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -26,6 +29,7 @@ const props = withDefaults(
|
||||
contentClass?: HTMLAttributes["class"];
|
||||
itemClass?: HTMLAttributes["class"];
|
||||
displayName?: (option: string) => string;
|
||||
optionTooltip?: (option: string) => string | undefined;
|
||||
normalizeCustom?: (value: string) => string;
|
||||
clearable?: boolean;
|
||||
}>(),
|
||||
@@ -38,6 +42,7 @@ const props = withDefaults(
|
||||
triggerVariant: "ghost",
|
||||
triggerIconClass: "h-3 w-3",
|
||||
displayName: (option: string) => option,
|
||||
optionTooltip: () => undefined,
|
||||
normalizeCustom: (value: string) => value,
|
||||
},
|
||||
);
|
||||
@@ -57,7 +62,11 @@ const open = ref(false);
|
||||
const searchText = ref("");
|
||||
const searchInput = ref<InstanceType<typeof Input>>();
|
||||
const listContainer = ref<HTMLDivElement>();
|
||||
const listCard = ref<HTMLElement>();
|
||||
const helpPanel = ref<{ element?: HTMLElement }>();
|
||||
const highlightIndex = ref(-1);
|
||||
const activeHelpOption = ref<string>();
|
||||
const helpPanelOffsetTop = ref(0);
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (!props.modelValue && !props.options.includes("")) return props.placeholder;
|
||||
@@ -86,6 +95,7 @@ async function scrollHighlightedOptionIntoView() {
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const target = buttons[highlightIndex.value];
|
||||
target?.scrollIntoView({ block: "nearest" });
|
||||
void updateHelpPanelOffset();
|
||||
}
|
||||
|
||||
function highlightAndScrollSelectedOption() {
|
||||
@@ -93,33 +103,79 @@ function highlightAndScrollSelectedOption() {
|
||||
void scrollHighlightedOptionIntoView();
|
||||
}
|
||||
|
||||
function activateInitialHelpOption() {
|
||||
activeHelpOption.value = searchableSelectSelectedOrFirstHelpOption(filteredOptions.value, props.modelValue, props.optionTooltip);
|
||||
}
|
||||
|
||||
watch(open, async (value) => {
|
||||
emit("update:open", value);
|
||||
if (!value) {
|
||||
searchText.value = "";
|
||||
highlightIndex.value = -1;
|
||||
activeHelpOption.value = undefined;
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const input = searchInput.value?.$el as HTMLInputElement | undefined;
|
||||
input?.focus();
|
||||
highlightAndScrollSelectedOption();
|
||||
activateInitialHelpOption();
|
||||
});
|
||||
|
||||
watch(searchText, () => {
|
||||
highlightIndex.value = 0;
|
||||
activeHelpOption.value = searchableSelectKeyboardTooltipOption(filteredOptions.value, 0, props.optionTooltip);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.options],
|
||||
() => {
|
||||
if (!open.value || searchText.value) return;
|
||||
activeHelpOption.value = undefined;
|
||||
highlightAndScrollSelectedOption();
|
||||
activateInitialHelpOption();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(highlightIndex, scrollHighlightedOptionIntoView);
|
||||
watch([highlightIndex, filteredOptions], () => {
|
||||
void scrollHighlightedOptionIntoView();
|
||||
});
|
||||
|
||||
const activeHelpContent = computed(() => (activeHelpOption.value ? props.optionTooltip(activeHelpOption.value) : undefined));
|
||||
|
||||
function activateHelpForHighlightedOption() {
|
||||
activeHelpOption.value = searchableSelectKeyboardTooltipOption(filteredOptions.value, highlightIndex.value, props.optionTooltip);
|
||||
}
|
||||
|
||||
function activateHelpForOption(option: string) {
|
||||
activeHelpOption.value = props.optionTooltip(option) ? option : undefined;
|
||||
}
|
||||
|
||||
async function updateHelpPanelOffset() {
|
||||
if (!activeHelpOption.value) {
|
||||
helpPanelOffsetTop.value = 0;
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const card = listCard.value;
|
||||
const panel = helpPanel.value?.element;
|
||||
const optionIndex = filteredOptions.value.indexOf(activeHelpOption.value);
|
||||
const option = optionIndex >= 0 ? listContainer.value?.querySelectorAll("button")[optionIndex] : undefined;
|
||||
if (!card || !panel || !option) {
|
||||
helpPanelOffsetTop.value = 0;
|
||||
return;
|
||||
}
|
||||
helpPanelOffsetTop.value = optionHelpPanelOffsetTop({
|
||||
activeItemTop: option.getBoundingClientRect().top - card.getBoundingClientRect().top,
|
||||
listCardHeight: card.clientHeight,
|
||||
panelHeight: panel.clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
watch([activeHelpOption, filteredOptions], () => {
|
||||
void updateHelpPanelOffset();
|
||||
});
|
||||
|
||||
function selectOption(option: string) {
|
||||
emit("update:modelValue", option);
|
||||
@@ -146,11 +202,13 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
const total = optionCount();
|
||||
if (total === 0) return;
|
||||
highlightIndex.value = highlightIndex.value < total - 1 ? highlightIndex.value + 1 : 0;
|
||||
activateHelpForHighlightedOption();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const total = optionCount();
|
||||
if (total === 0) return;
|
||||
highlightIndex.value = highlightIndex.value > 0 ? highlightIndex.value - 1 : total - 1;
|
||||
activateHelpForHighlightedOption();
|
||||
} else if (event.key === "Enter") {
|
||||
if (highlightIndex.value < 0 || highlightIndex.value >= optionCount()) return;
|
||||
event.preventDefault();
|
||||
@@ -176,76 +234,84 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
<ChevronDown v-else :class="cn('shrink-0 opacity-60', triggerIconClass)" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" :class="cn('w-52 gap-1 p-1.5', contentClass)">
|
||||
<div class="relative rounded-sm border bg-background">
|
||||
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<span v-if="!searchText" class="pointer-events-none absolute left-[25px] top-1/2 -translate-y-1/2 text-sm text-muted-foreground">{{ searchPlaceholder }}</span>
|
||||
<Input ref="searchInput" :model-value="searchText" class="h-6 border-0 pl-6 pr-2 text-sm caret-foreground shadow-none focus-visible:ring-0" @update:model-value="(value) => (searchText = String(value))" @keydown="handleKeydown" />
|
||||
</div>
|
||||
<div ref="listContainer" class="max-h-64 overflow-y-auto py-1">
|
||||
<div v-if="loading" class="px-2 py-2 text-sm text-muted-foreground">
|
||||
{{ loadingText }}
|
||||
</div>
|
||||
<template v-else-if="filteredOptions.length">
|
||||
<button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option"
|
||||
type="button"
|
||||
:title="optionTitle(option)"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
index === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<Check :class="cn('h-3.5 w-3.5 shrink-0', option === modelValue ? 'opacity-100' : 'opacity-0')" />
|
||||
<slot name="option-label" :option="option" :label="displayName?.(option)">
|
||||
<span class="truncate">{{ displayName?.(option) }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
<button
|
||||
v-if="canSelectCustom"
|
||||
type="button"
|
||||
:title="customOptionValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
filteredOptions.length === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@click="selectCustomOption"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
|
||||
<slot name="custom-option-label" :value="customOptionValue">
|
||||
<span class="truncate">{{ customOptionValue }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="canSelectCustom"
|
||||
type="button"
|
||||
:title="customOptionValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
0 === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@click="selectCustomOption"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
|
||||
<slot name="custom-option-label" :value="customOptionValue">
|
||||
<span class="truncate">{{ customOptionValue }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
<div v-else class="px-2 py-2 text-sm text-muted-foreground">
|
||||
{{ emptyText }}
|
||||
<PopoverContent :align="SEARCHABLE_SELECT_HELP_PANEL_ALIGN" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)">
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<div ref="listCard" class="w-52 shrink-0 rounded-md border bg-popover p-1.5 shadow-md">
|
||||
<div class="relative rounded-sm border bg-background">
|
||||
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<span v-if="!searchText" class="pointer-events-none absolute left-[25px] top-1/2 -translate-y-1/2 text-sm text-muted-foreground">{{ searchPlaceholder }}</span>
|
||||
<Input ref="searchInput" :model-value="searchText" class="h-6 border-0 pl-6 pr-2 text-sm caret-foreground shadow-none focus-visible:ring-0" @update:model-value="(value) => (searchText = String(value))" @keydown="handleKeydown" />
|
||||
</div>
|
||||
<div ref="listContainer" class="max-h-64 overflow-y-auto py-1" @scroll="updateHelpPanelOffset">
|
||||
<div v-if="loading" class="px-2 py-2 text-sm text-muted-foreground">
|
||||
{{ loadingText }}
|
||||
</div>
|
||||
<template v-else-if="filteredOptions.length">
|
||||
<button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option"
|
||||
type="button"
|
||||
:title="optionTooltip(option) ? undefined : optionTitle(option)"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
index === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@pointerenter="activateHelpForOption(option)"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<Check :class="cn('h-3.5 w-3.5 shrink-0', option === modelValue ? 'opacity-100' : 'opacity-0')" />
|
||||
<slot name="option-label" :option="option" :label="displayName?.(option)">
|
||||
<span class="truncate">{{ displayName?.(option) }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
<button
|
||||
v-if="canSelectCustom"
|
||||
type="button"
|
||||
:title="customOptionValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
filteredOptions.length === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@pointerenter="activateHelpForOption(customOptionValue)"
|
||||
@click="selectCustomOption"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
|
||||
<slot name="custom-option-label" :value="customOptionValue">
|
||||
<span class="truncate">{{ customOptionValue }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="canSelectCustom"
|
||||
type="button"
|
||||
:title="customOptionValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
0 === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@pointerenter="activateHelpForOption(customOptionValue)"
|
||||
@click="selectCustomOption"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
|
||||
<slot name="custom-option-label" :value="customOptionValue">
|
||||
<span class="truncate">{{ customOptionValue }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
<div v-else class="px-2 py-2 text-sm text-muted-foreground">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OptionHelpPanel v-if="activeHelpContent" ref="helpPanel" :content="activeHelpContent" :offset-top="helpPanelOffsetTop" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
Reference in new issue
Block a user