feat(types): add database type help
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user