404 lines
17 KiB
Vue
404 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref, watch } from "vue";
|
|
import type { HTMLAttributes } from "vue";
|
|
import { Check, ChevronDown, Search, X } from "@lucide/vue";
|
|
import { useI18n } from "vue-i18n";
|
|
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<{
|
|
modelValue: string;
|
|
options: string[];
|
|
placeholder: string;
|
|
searchPlaceholder: string;
|
|
emptyText: string;
|
|
loadingText?: string;
|
|
loading?: boolean;
|
|
disabled?: boolean;
|
|
allowCustom?: boolean;
|
|
triggerVariant?: ButtonVariants["variant"];
|
|
triggerClass?: HTMLAttributes["class"];
|
|
triggerIconClass?: HTMLAttributes["class"];
|
|
contentClass?: HTMLAttributes["class"];
|
|
contentStyle?: HTMLAttributes["style"];
|
|
listClass?: HTMLAttributes["class"];
|
|
itemClass?: HTMLAttributes["class"];
|
|
displayName?: (option: string) => string;
|
|
optionTooltip?: (option: string) => string | undefined;
|
|
normalizeCustom?: (value: string) => string;
|
|
trimCustom?: boolean;
|
|
clearable?: boolean;
|
|
clearSelectedOption?: boolean;
|
|
}>(),
|
|
{
|
|
loading: false,
|
|
disabled: false,
|
|
allowCustom: false,
|
|
clearable: false,
|
|
clearSelectedOption: false,
|
|
trimCustom: true,
|
|
loadingText: "Loading...",
|
|
triggerVariant: "outline",
|
|
triggerIconClass: "size-4 text-muted-foreground",
|
|
displayName: (option: string) => option,
|
|
optionTooltip: () => undefined,
|
|
normalizeCustom: (value: string) => value,
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: string];
|
|
"update:open": [value: boolean];
|
|
"option-hover": [value: string];
|
|
"option-highlight": [value: string | undefined];
|
|
"option-leave": [];
|
|
}>();
|
|
|
|
defineSlots<{
|
|
"trigger-label"?(props: { value: string; label: string; loading: boolean }): any;
|
|
"option-label"?(props: { option: string; label: string }): any;
|
|
"custom-option-label"?(props: { value: string }): any;
|
|
}>();
|
|
|
|
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 { t } = useI18n();
|
|
|
|
const selectedLabel = computed(() => {
|
|
if (!props.modelValue && !props.options.includes("")) return props.placeholder;
|
|
return props.displayName(props.modelValue);
|
|
});
|
|
|
|
const triggerBaseClass = computed(() =>
|
|
props.triggerVariant === "outline"
|
|
? "dbx-searchable-select-trigger dbx-control-chrome h-8 w-full min-w-0 justify-between gap-1.5 border border-input bg-transparent px-2.5 text-sm font-normal shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
|
: "h-6 w-auto max-w-56 min-w-0 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0",
|
|
);
|
|
|
|
const filteredOptions = computed(() => filterDatabaseOptions(props.options, searchText.value, props.displayName));
|
|
const customOptionValue = computed(() => props.normalizeCustom(props.trimCustom ? searchText.value.trim() : searchText.value));
|
|
const canSelectCustom = computed(() => props.allowCustom && !!customOptionValue.value && !props.options.includes(customOptionValue.value));
|
|
|
|
function highlightSelectedOption() {
|
|
const selectedIndex = filteredOptions.value.findIndex((option) => option === props.modelValue);
|
|
highlightIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
|
}
|
|
|
|
async function scrollHighlightedOptionIntoView() {
|
|
await nextTick();
|
|
const container = listContainer.value;
|
|
if (!container || highlightIndex.value < 0) return;
|
|
const buttons = container.querySelectorAll("button");
|
|
const target = buttons[highlightIndex.value];
|
|
target?.scrollIntoView({ block: "nearest" });
|
|
void updateHelpPanelOffset();
|
|
}
|
|
|
|
function highlightAndScrollSelectedOption() {
|
|
highlightSelectedOption();
|
|
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, filteredOptions], () => {
|
|
void scrollHighlightedOptionIntoView();
|
|
emit("option-highlight", filteredOptions.value[highlightIndex.value]);
|
|
});
|
|
|
|
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;
|
|
emit("option-hover", option);
|
|
}
|
|
|
|
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);
|
|
open.value = false;
|
|
}
|
|
|
|
function selectOrClearOption(option: string) {
|
|
selectOption(props.clearSelectedOption && option === props.modelValue ? "" : option);
|
|
}
|
|
|
|
function selectCustomOption() {
|
|
if (!canSelectCustom.value) return;
|
|
selectOption(customOptionValue.value);
|
|
}
|
|
|
|
function optionTitle(option: string) {
|
|
const label = props.displayName(option);
|
|
return label === option ? option : `${label}\n${option}`;
|
|
}
|
|
|
|
function optionCount() {
|
|
return filteredOptions.value.length + (canSelectCustom.value ? 1 : 0);
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
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();
|
|
if (highlightIndex.value < filteredOptions.value.length) {
|
|
selectOrClearOption(filteredOptions.value[highlightIndex.value]);
|
|
} else {
|
|
selectCustomOption();
|
|
}
|
|
} else if (event.key === "Escape") {
|
|
open.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Popover v-model:open="open">
|
|
<PopoverTrigger as-child>
|
|
<!--
|
|
Keep the trigger Button as the outermost element: triggerClass carries the layout
|
|
utilities consumers depend on (flex-1 / min-w-0 / max-w-* / w-full / h-*), and any
|
|
wrapper element would become the flex item and swallow them. The clearable overlay
|
|
therefore anchors to this button (relative) instead of a wrapper. The overlay is a
|
|
span (not the svg itself) because the Button base sets [&_svg]:pointer-events-none,
|
|
which is also why the old in-trigger @pointerdown.stop never fired; @click.stop
|
|
keeps Reka's bubble-phase trigger onClick (onOpenToggle) from opening the dropdown.
|
|
-->
|
|
<Button type="button" :variant="triggerVariant" :disabled="disabled" :title="selectedLabel" :class="cn('relative', triggerBaseClass, triggerClass)">
|
|
<slot name="trigger-label" :value="modelValue" :label="selectedLabel" :loading="loading">
|
|
<span class="truncate">{{ loading ? loadingText : selectedLabel }}</span>
|
|
</slot>
|
|
<ChevronDown :class="cn('shrink-0 opacity-60', clearable && !disabled && modelValue && 'invisible', triggerIconClass)" />
|
|
<span
|
|
v-if="clearable && !disabled && modelValue"
|
|
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 flex cursor-pointer items-center justify-center opacity-60 hover:opacity-100"
|
|
:aria-label="t('common.clear')"
|
|
:title="t('common.clear')"
|
|
@click.stop.prevent="emit('update:modelValue', '')"
|
|
>
|
|
<X :class="triggerIconClass" />
|
|
</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<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)" :style="contentStyle">
|
|
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start">
|
|
<div ref="listCard" :class="cn('shrink-0 rounded-md border bg-popover p-1.5 shadow-md', listClass)">
|
|
<div class="relative rounded-md 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="dbx-searchable-select-list max-h-64 overflow-y-auto py-1" @scroll="updateHelpPanelOffset" @pointerleave="emit('option-leave')">
|
|
<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(
|
|
'group flex h-8 w-full min-w-0 items-center gap-2 rounded-md 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="selectOrClearOption(option)"
|
|
>
|
|
<span class="relative h-3.5 w-3.5 shrink-0">
|
|
<Check :class="cn('absolute inset-0 h-3.5 w-3.5', option !== modelValue ? 'opacity-0' : clearSelectedOption ? 'opacity-100 group-hover:opacity-0 group-focus-visible:opacity-0' : 'opacity-100')" />
|
|
<X v-if="clearSelectedOption && option === modelValue" class="absolute inset-0 h-3.5 w-3.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
|
</span>
|
|
<!-- Keep custom labels inside the flex row so long content cannot overlap adjacent UI. -->
|
|
<div class="dbx-searchable-select-option-label min-w-0 flex-1 overflow-hidden">
|
|
<slot name="option-label" :option="option" :label="displayName?.(option)">
|
|
<span class="block truncate">{{ displayName?.(option) }}</span>
|
|
</slot>
|
|
</div>
|
|
</button>
|
|
<button
|
|
v-if="canSelectCustom"
|
|
type="button"
|
|
:title="customOptionValue"
|
|
:class="
|
|
cn(
|
|
'flex h-8 w-full min-w-0 items-center gap-2 rounded-md 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" />
|
|
<div class="dbx-searchable-select-option-label min-w-0 flex-1 overflow-hidden">
|
|
<slot name="custom-option-label" :value="customOptionValue">
|
|
<span class="block truncate">{{ customOptionValue }}</span>
|
|
</slot>
|
|
</div>
|
|
</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-md 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" />
|
|
<div class="dbx-searchable-select-option-label min-w-0 flex-1 overflow-hidden">
|
|
<slot name="custom-option-label" :value="customOptionValue">
|
|
<span class="block truncate">{{ customOptionValue }}</span>
|
|
</slot>
|
|
</div>
|
|
</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>
|
|
</template>
|
|
|
|
<style>
|
|
.dbx-searchable-select-list {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: color-mix(in oklch, var(--foreground) 30%, transparent) transparent;
|
|
}
|
|
|
|
.dbx-searchable-select-list::-webkit-scrollbar {
|
|
width: 6px;
|
|
height: 6px;
|
|
}
|
|
|
|
.dbx-searchable-select-list::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.dbx-searchable-select-list::-webkit-scrollbar-thumb {
|
|
border: 1px solid transparent;
|
|
border-radius: 999px;
|
|
background: color-mix(in oklch, var(--foreground) 30%, transparent);
|
|
background-clip: padding-box;
|
|
}
|
|
|
|
.dbx-searchable-select-list:hover::-webkit-scrollbar-thumb {
|
|
border: 0;
|
|
background: color-mix(in oklch, var(--foreground) 48%, transparent);
|
|
}
|
|
|
|
.dark .dbx-searchable-select-list {
|
|
scrollbar-color: rgb(82, 82, 91) transparent;
|
|
}
|
|
|
|
.dark .dbx-searchable-select-list::-webkit-scrollbar-thumb {
|
|
background: rgb(82, 82, 91);
|
|
background-clip: padding-box;
|
|
}
|
|
|
|
.dark .dbx-searchable-select-list:hover::-webkit-scrollbar-thumb {
|
|
background: rgb(113, 113, 122);
|
|
}
|
|
</style>
|