230 lines
8.6 KiB
Vue
230 lines
8.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref, watch } from "vue";
|
|
import type { HTMLAttributes } from "vue";
|
|
import { Check, ChevronDown, Search } from "@lucide/vue";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { filterDatabaseOptions } from "@/lib/databaseOptionSearch";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: string;
|
|
options: string[];
|
|
placeholder: string;
|
|
searchPlaceholder: string;
|
|
emptyText: string;
|
|
loadingText: string;
|
|
loading?: boolean;
|
|
allowCustom?: boolean;
|
|
triggerClass?: HTMLAttributes["class"];
|
|
contentClass?: HTMLAttributes["class"];
|
|
itemClass?: HTMLAttributes["class"];
|
|
displayName?: (option: string) => string;
|
|
normalizeCustom?: (value: string) => string;
|
|
}>(),
|
|
{
|
|
loading: false,
|
|
allowCustom: false,
|
|
displayName: (option: string) => option,
|
|
normalizeCustom: (value: string) => value,
|
|
},
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: string];
|
|
"update:open": [value: boolean];
|
|
}>();
|
|
|
|
const open = ref(false);
|
|
const searchText = ref("");
|
|
const searchInput = ref<InstanceType<typeof Input>>();
|
|
const listContainer = ref<HTMLDivElement>();
|
|
const highlightIndex = ref(-1);
|
|
|
|
const selectedLabel = computed(() => {
|
|
if (!props.modelValue && !props.options.includes("")) return props.placeholder;
|
|
return props.displayName(props.modelValue);
|
|
});
|
|
|
|
const filteredOptions = computed(() => filterDatabaseOptions(props.options, searchText.value, props.displayName));
|
|
const customOptionValue = computed(() => props.normalizeCustom(searchText.value.trim()));
|
|
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" });
|
|
}
|
|
|
|
function highlightAndScrollSelectedOption() {
|
|
highlightSelectedOption();
|
|
void scrollHighlightedOptionIntoView();
|
|
}
|
|
|
|
watch(open, async (value) => {
|
|
emit("update:open", value);
|
|
if (!value) {
|
|
searchText.value = "";
|
|
highlightIndex.value = -1;
|
|
return;
|
|
}
|
|
await nextTick();
|
|
const input = searchInput.value?.$el as HTMLInputElement | undefined;
|
|
input?.focus();
|
|
highlightAndScrollSelectedOption();
|
|
});
|
|
|
|
watch(searchText, () => {
|
|
highlightIndex.value = 0;
|
|
});
|
|
|
|
watch(
|
|
() => [props.modelValue, props.options],
|
|
() => {
|
|
if (!open.value || searchText.value) return;
|
|
highlightAndScrollSelectedOption();
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
watch(highlightIndex, scrollHighlightedOptionIntoView);
|
|
|
|
function selectOption(option: string) {
|
|
emit("update:modelValue", option);
|
|
open.value = false;
|
|
}
|
|
|
|
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;
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
const total = optionCount();
|
|
if (total === 0) return;
|
|
highlightIndex.value = highlightIndex.value > 0 ? highlightIndex.value - 1 : total - 1;
|
|
} else if (event.key === "Enter") {
|
|
if (highlightIndex.value < 0 || highlightIndex.value >= optionCount()) return;
|
|
event.preventDefault();
|
|
if (highlightIndex.value < filteredOptions.value.length) {
|
|
selectOption(filteredOptions.value[highlightIndex.value]);
|
|
} else {
|
|
selectCustomOption();
|
|
}
|
|
} else if (event.key === "Escape") {
|
|
open.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Popover v-model:open="open">
|
|
<PopoverTrigger as-child>
|
|
<Button type="button" variant="ghost" :title="selectedLabel" :class="cn('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', triggerClass)">
|
|
<slot name="trigger-label" :value="modelValue" :label="selectedLabel" :loading="loading">
|
|
<span class="truncate">{{ loading ? loadingText : selectedLabel }}</span>
|
|
</slot>
|
|
<ChevronDown class="h-3 w-3 shrink-0 opacity-60" />
|
|
</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 }}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</template>
|