Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 191 additions & 7 deletions frontend/components/Item/Selector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,30 +39,118 @@
{{ localizedNoResultsText }}
</div>
</CommandEmpty>
<CommandList :key="commandListKey">
<CommandList :key="commandListKey" class="max-h-[20.5rem]">
<CommandGroup>
<CommandItem v-for="item in filtered" :key="itemKey(item)" :value="itemKey(item)" @select="select(item)">
<CommandItem
v-for="item in paginatedItems"
: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>
<div
v-if="showPagination"
class="flex items-center justify-between gap-1 border-t px-2 py-1.5"
@pointerdown="handlePaginationToolbarPointerDown"
>
<div class="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="size-8 p-0"
:disabled="!canGoPrevious"
:aria-label="t('items.first_page')"
@click.stop.prevent="setPage(1)"
>
<ChevronsLeft class="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
class="size-8 p-0"
:disabled="!canGoPrevious"
:aria-label="t('items.prev_page')"
@click.stop.prevent="setPage(currentPage - 1)"
>
<ChevronLeft class="size-4" />
</Button>
</div>

<div class="flex items-center justify-center gap-1 text-xs text-muted-foreground">
<Input
v-model="pageInput"
type="number"
inputmode="numeric"
:min="1"
:max="pageCount"
class="h-8 w-16 px-2 py-1 text-center text-xs md:text-xs"
:aria-label="t('items.pages', { page: currentPage, totalPages: pageCount })"
@click.stop
@keydown.stop
@keydown.enter.prevent="commitPageInput"
/>
<span>/ {{ pageCount }}</span>
<Button
type="button"
variant="ghost"
size="sm"
class="size-8 p-0"
:aria-label="t('global.confirm')"
:disabled="!canConfirmPageInputState"
@click.stop.prevent="commitPageInput"
>
<Check class="size-4" />
</Button>
</div>

<div class="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="size-8 p-0"
:disabled="!canGoNext"
:aria-label="t('items.next_page')"
@click.stop.prevent="setPage(currentPage + 1)"
>
<ChevronRight class="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
class="size-8 p-0"
:disabled="!canGoNext"
:aria-label="t('items.last_page')"
@click.stop.prevent="setPage(pageCount)"
>
<ChevronsRight class="size-4" />
</Button>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
</div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
import { computed, nextTick, ref, watch } from "vue";
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, X } 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 { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { cn } from "~/lib/utils";
Expand All @@ -86,6 +174,7 @@
excludeItems?: ItemsObject[];
isLoading?: boolean;
triggerSearch?: () => Promise<boolean>;
pageSize?: number;
}

const emit = defineEmits(["update:modelValue", "update:search"]);
Expand All @@ -102,13 +191,16 @@
excludeItems: undefined,
isLoading: false,
triggerSearch: undefined,
pageSize: 10,
});

const id = useId();
const open = ref(false);
const search = useVModel(props, "search", emit);
const value = useVModel(props, "modelValue", emit);
const hasInitialSearch = ref(false);
const currentPage = ref(1);
const pageInput = ref<string | number>("1");

const localizedSearchPlaceholder = computed(
() => props.searchPlaceholder ?? t("components.item.selector.search_placeholder")
Expand All @@ -118,13 +210,17 @@

// Trigger search when popover opens for the first time if no results exist
async function handlePopoverOpen() {
resetPaginationState();

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;
await nextTick();
resetPaginationState();
}
// If not successful, leave hasInitialSearch false to allow retries
} catch (err) {
Expand All @@ -137,7 +233,7 @@
() => open.value,
isOpen => {
if (isOpen) {
handlePopoverOpen();
void handlePopoverOpen();
}
}
);
Expand All @@ -157,6 +253,19 @@
return (item[props.itemValue] as string) || displayValue(item);
}

function findPageForSelectedKey(items: Array<string | ItemsObject>, selectedKey: string | null): number | null {
if (!selectedKey) {
return null;
}

const selectedIndex = items.findIndex(item => itemKey(item) === selectedKey);
if (selectedIndex === -1) {
return null;
}

return Math.floor(selectedIndex / props.pageSize) + 1;
}

function isSelected(item: string | ItemsObject): boolean {
if (!value.value) return false;
if (typeof item === "string") return value.value === item;
Expand Down Expand Up @@ -202,8 +311,83 @@
}
});

// Generate a unique key to force CommandList re-render when items change
const pageCount = computed(() => Math.ceil(filtered.value.length / props.pageSize));
const showPagination = computed(() => pageCount.value > 1);
const canGoPrevious = computed(() => currentPage.value > 1);
const canGoNext = computed(() => currentPage.value < pageCount.value);
const canConfirmPageInputState = computed(() => {
const page = Number(pageInput.value);

return Number.isInteger(page) && page >= 1 && page <= pageCount.value && page !== currentPage.value;
});
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * props.pageSize;
return filtered.value.slice(start, start + props.pageSize);
});

function setPage(page: number) {
if (page < 1 || page > pageCount.value) return;
currentPage.value = page;
}

function commitPageInput() {
const page = Number(pageInput.value);

if (!Number.isInteger(page) || page < 1 || page > pageCount.value) {
pageInput.value = String(currentPage.value);
return;
}

setPage(page);
}

// Prevent pagination toolbar clicks from clearing the filter input
function handlePaginationToolbarPointerDown(event: PointerEvent) {
const toolbar = event.currentTarget as HTMLElement | null;
const target = event.target as HTMLElement | null;
if (target?.closest("input")) {
return;
}

event.preventDefault();
const inputElements = toolbar?.parentElement?.querySelectorAll<HTMLInputElement>("input");
inputElements?.forEach(inputElement => {
inputElement.blur();
});
}

function resetPaginationState() {
let selectedKey: string | null = null;
if (value.value) {
selectedKey = typeof value.value === "string" ? value.value : itemKey(value.value);
}

const initialPage = findPageForSelectedKey(filtered.value as Array<string | ItemsObject>, selectedKey) ?? 1;
currentPage.value = initialPage;
pageInput.value = String(initialPage);
}

watch(search, () => {
currentPage.value = 1;
});

watch(pageCount, () => {
if (pageCount.value === 0) {
currentPage.value = 1;
return;
}

if (currentPage.value > pageCount.value) {
currentPage.value = pageCount.value;
}
});

watch(currentPage, page => {
pageInput.value = String(page);
});

// Generate a unique key to force CommandList re-render when visible items change
const commandListKey = computed(() => {
return JSON.stringify(filtered.value.map(item => itemKey(item)));
return JSON.stringify([currentPage.value, paginatedItems.value.map(item => itemKey(item))]);
});
</script>
2 changes: 2 additions & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -526,10 +526,12 @@
"field_selector": "Field Selector",
"field_value": "Field Value",
"first": "First",
"first_page": "First Page",
"include_archive": "Include Archived Items",
"insured": "Insured",
"invalid_asset_id": "Invalid Asset ID",
"last": "Last",
"last_page": "Last Page",
"lifetime_warranty": "Lifetime Warranty",
"location": "Location",
"manual": "Manual",
Expand Down
Loading