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

@@ -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>