mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
* fix: add focus-triggered preloading to ItemSelector with proper error handling and complete localization * Removed machine translated files --------- Co-authored-by: Tonya <tonya@tokia.dev>
194 lines
6.2 KiB
Vue
194 lines
6.2 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-1">
|
|
<Label :for="id" class="px-1">
|
|
{{ label }}
|
|
</Label>
|
|
<Popover v-model:open="open">
|
|
<PopoverTrigger as-child>
|
|
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
|
<span>
|
|
<slot name="display" v-bind="{ item: value }">
|
|
{{ displayValue(value) || localizedPlaceholder }}
|
|
</slot>
|
|
</span>
|
|
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
|
|
<Command :ignore-filter="true">
|
|
<CommandInput v-model="search" :placeholder="localizedSearchPlaceholder" :display-value="_ => ''" />
|
|
<CommandEmpty>
|
|
<div v-if="isLoading" class="flex items-center justify-center p-4">
|
|
<div class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
|
<span class="ml-2">{{ t("components.item.selector.searching") }}</span>
|
|
</div>
|
|
<div v-else>
|
|
{{ localizedNoResultsText }}
|
|
</div>
|
|
</CommandEmpty>
|
|
<CommandList>
|
|
<CommandGroup>
|
|
<CommandItem v-for="item in filtered" :key="itemKey(item)" :value="itemKey(item)" @select="select(item)">
|
|
<Check :class="cn('mr-2 h-4 w-4', isSelected(item) ? 'opacity-100' : 'opacity-0')" />
|
|
<slot name="display" v-bind="{ item }">
|
|
{{ displayValue(item) }}
|
|
</slot>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from "vue";
|
|
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
|
import fuzzysort from "fuzzysort";
|
|
import { useVModel } from "@vueuse/core";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
|
import { Label } from "~/components/ui/label";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
|
import { cn } from "~/lib/utils";
|
|
|
|
const { t } = useI18n();
|
|
|
|
type ItemsObject = {
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
interface Props {
|
|
label?: string;
|
|
modelValue?: string | ItemsObject | null | undefined;
|
|
items?: ItemsObject[] | string[];
|
|
itemText?: string;
|
|
itemValue?: string;
|
|
search?: string;
|
|
searchPlaceholder?: string;
|
|
noResultsText?: string;
|
|
placeholder?: string;
|
|
excludeItems?: ItemsObject[];
|
|
isLoading?: boolean;
|
|
triggerSearch?: () => Promise<boolean>;
|
|
}
|
|
|
|
const emit = defineEmits(["update:modelValue", "update:search"]);
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
label: "",
|
|
modelValue: "",
|
|
items: () => [],
|
|
itemText: "text",
|
|
itemValue: "value",
|
|
search: "",
|
|
searchPlaceholder: undefined,
|
|
noResultsText: undefined,
|
|
placeholder: undefined,
|
|
excludeItems: undefined,
|
|
isLoading: false,
|
|
triggerSearch: undefined,
|
|
});
|
|
|
|
const id = useId();
|
|
const open = ref(false);
|
|
const search = ref(props.search);
|
|
const value = useVModel(props, "modelValue", emit);
|
|
const hasInitialSearch = ref(false);
|
|
|
|
const localizedSearchPlaceholder = computed(
|
|
() => props.searchPlaceholder ?? t("components.item.selector.search_placeholder")
|
|
);
|
|
const localizedNoResultsText = computed(() => props.noResultsText ?? t("components.item.selector.no_results"));
|
|
const localizedPlaceholder = computed(() => props.placeholder ?? t("components.item.selector.placeholder"));
|
|
|
|
// Trigger search when popover opens for the first time if no results exist
|
|
async function handlePopoverOpen() {
|
|
if (hasInitialSearch.value || props.items.length !== 0 || !props.triggerSearch) return;
|
|
|
|
try {
|
|
const success = await props.triggerSearch();
|
|
if (success) {
|
|
// Only mark as attempted after successful completion
|
|
hasInitialSearch.value = true;
|
|
}
|
|
// If not successful, leave hasInitialSearch false to allow retries
|
|
} catch (err) {
|
|
console.error("triggerSearch failed:", err);
|
|
// Leave hasInitialSearch false to allow retries on subsequent opens
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => open.value,
|
|
isOpen => {
|
|
if (isOpen) {
|
|
handlePopoverOpen();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => props.search,
|
|
val => {
|
|
search.value = val;
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => search.value,
|
|
val => {
|
|
emit("update:search", val);
|
|
}
|
|
);
|
|
|
|
function isStrings(arr: string[] | ItemsObject[]): arr is string[] {
|
|
return arr.length > 0 && typeof arr[0] === "string";
|
|
}
|
|
|
|
function displayValue(item: string | ItemsObject | null | undefined): string {
|
|
if (!item) return "";
|
|
if (typeof item === "string") return item;
|
|
return (item[props.itemText] as string) || "";
|
|
}
|
|
|
|
function itemKey(item: string | ItemsObject): string {
|
|
if (typeof item === "string") return item;
|
|
return (item[props.itemValue] as string) || displayValue(item);
|
|
}
|
|
|
|
function isSelected(item: string | ItemsObject): boolean {
|
|
if (!value.value) return false;
|
|
if (typeof item === "string") return value.value === item;
|
|
if (typeof value.value === "string") return itemKey(item) === value.value;
|
|
return itemKey(item) === itemKey(value.value);
|
|
}
|
|
|
|
function select(item: string | ItemsObject) {
|
|
if (isSelected(item)) {
|
|
value.value = null;
|
|
} else {
|
|
value.value = item;
|
|
}
|
|
open.value = false;
|
|
}
|
|
|
|
const filtered = computed(() => {
|
|
let baseItems = props.items;
|
|
|
|
if (!isStrings(baseItems) && props.excludeItems) {
|
|
const excludeIds = props.excludeItems.map(i => i.id);
|
|
baseItems = baseItems.filter(item => !excludeIds?.includes(item.id));
|
|
}
|
|
if (!search.value) return baseItems;
|
|
|
|
if (isStrings(baseItems)) {
|
|
return baseItems.filter(item => item.toLowerCase().includes(search.value.toLowerCase()));
|
|
} else {
|
|
// Fuzzy search on itemText
|
|
return fuzzysort.go(search.value, baseItems, { key: props.itemText, all: true }).map(i => i.obj);
|
|
}
|
|
});
|
|
</script>
|