feat(types): add database type help

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-12 13:28:46 +08:00
1 parent fdb33ef477
commit 4f1997d8eb
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>