feat(grid): support custom page navigation
This commit is contained in:
11 files changed
+126
-2
No files matched your search
@@ -2543,6 +2543,11 @@ const canGoNextPage = computed(() => {
|
||||
allRowsLoaded: allRowsLoaded.value,
|
||||
});
|
||||
});
|
||||
const maximumPage = computed(() => {
|
||||
const total = hasKnownPaginationTotalRowCount.value ? paginationTotalRowCount.value : allRowsLoaded.value ? props.result.rows.length : undefined;
|
||||
if (typeof total === "number" && Number.isFinite(total) && total >= 0) return Math.max(1, Math.ceil(total / pageSize.value));
|
||||
return canGoNextPage.value ? undefined : currentPage.value;
|
||||
});
|
||||
const canFetchNextInfiniteScrollSegment = computed(() =>
|
||||
canFetchNextDataGridSegment({
|
||||
hasMore: props.result.has_more,
|
||||
@@ -2713,6 +2718,21 @@ function nextPage() {
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
}
|
||||
|
||||
function jumpPage(page: number) {
|
||||
if (gridSurfaceBusy.value || infiniteScrollEnabled.value || !Number.isSafeInteger(page) || page < 1) return;
|
||||
const targetPage = Math.min(page, maximumPage.value ?? page);
|
||||
if (targetPage === currentPage.value) return;
|
||||
if (allRowsLoaded.value) {
|
||||
currentPage.value = targetPage;
|
||||
resetGridVerticalScroll(true);
|
||||
return;
|
||||
}
|
||||
const offset = (targetPage - 1) * pageSize.value;
|
||||
if (!Number.isSafeInteger(offset)) return;
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", offset, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
}
|
||||
|
||||
function infiniteScrollNextPage() {
|
||||
if (infiniteScrollLoading.value || props.loading) return;
|
||||
if (!canFetchNextInfiniteScrollSegment.value) {
|
||||
@@ -10014,6 +10034,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
:page-size-menu-items="pageSizeMenuItems"
|
||||
:export-menu-items="exportMenuItems"
|
||||
:current-page="currentPage"
|
||||
:max-page="maximumPage"
|
||||
:can-go-next-page="canGoNextPage"
|
||||
:can-jump-last-page="canJumpLastPage"
|
||||
@select-page-size="selectPageSizeMenuItem"
|
||||
@@ -10021,6 +10042,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
@first-page="firstPage"
|
||||
@previous-page="prevPage"
|
||||
@next-page="nextPage"
|
||||
@jump-page="jumpPage"
|
||||
@last-page="lastPage"
|
||||
@select-export="selectExportMenuItem"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -10,7 +11,7 @@ import DataGridExportMenu from "@/components/grid/DataGridExportMenu.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
paginationEnabled?: boolean;
|
||||
selectionSummary: { cellCount: number; rowCount: number } | null;
|
||||
@@ -22,6 +23,7 @@ withDefaults(
|
||||
pageSizeMenuItems: LightDropdownItem[];
|
||||
exportMenuItems: LightDropdownItem[];
|
||||
currentPage: number;
|
||||
maxPage?: number;
|
||||
canGoNextPage: boolean;
|
||||
canJumpLastPage: boolean;
|
||||
}>(),
|
||||
@@ -29,6 +31,13 @@ withDefaults(
|
||||
);
|
||||
|
||||
const customPageSizeInput = defineModel<string>("customPageSizeInput", { default: "" });
|
||||
const pageInput = ref(String(props.currentPage));
|
||||
const maximumInputPage = computed(() => {
|
||||
const pageSize = Math.max(1, Math.trunc(props.pageSize));
|
||||
const maximumSafeOffsetPage = Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1);
|
||||
if (typeof props.maxPage !== "number" || !Number.isFinite(props.maxPage) || props.maxPage < 1) return maximumSafeOffsetPage;
|
||||
return Math.min(maximumSafeOffsetPage, Math.floor(props.maxPage));
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectPageSize: [value: string];
|
||||
@@ -36,9 +45,41 @@ const emit = defineEmits<{
|
||||
firstPage: [];
|
||||
previousPage: [];
|
||||
nextPage: [];
|
||||
jumpPage: [page: number];
|
||||
lastPage: [];
|
||||
selectExport: [value: string];
|
||||
}>();
|
||||
|
||||
watch(
|
||||
() => props.currentPage,
|
||||
(page) => {
|
||||
pageInput.value = String(page);
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading, wasLoading) => {
|
||||
if (wasLoading && !loading) pageInput.value = String(props.currentPage);
|
||||
},
|
||||
);
|
||||
|
||||
function applyPageInput() {
|
||||
const page = Number(pageInput.value);
|
||||
if (!Number.isSafeInteger(page) || page < 1) {
|
||||
pageInput.value = String(props.currentPage);
|
||||
return;
|
||||
}
|
||||
const targetPage = Math.min(page, maximumInputPage.value);
|
||||
pageInput.value = String(targetPage);
|
||||
if (targetPage !== props.currentPage) emit("jumpPage", targetPage);
|
||||
}
|
||||
|
||||
function handlePageInputKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation();
|
||||
if (event.key !== "Enter" || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
applyPageInput();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,7 +129,17 @@ const emit = defineEmits<{
|
||||
</LightDropdown>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
|
||||
<span class="shrink-0 tabular-nums">{{ currentPage }}</span>
|
||||
<Input
|
||||
v-model="pageInput"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
:min="1"
|
||||
:max="maximumInputPage"
|
||||
:disabled="loading"
|
||||
:aria-label="t('grid.jumpToPage')"
|
||||
class="h-5 w-14 shrink-0 px-1 text-center text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@keydown="handlePageInputKeydown"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
|
||||
</template>
|
||||
|
||||
@@ -222,6 +222,49 @@ describe("DataGridPagination", () => {
|
||||
await mounted.setProps({ loading: true });
|
||||
const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
|
||||
expect(busyNavigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
|
||||
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage").props.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("jumps to an entered page and enforces page input boundaries", async () => {
|
||||
const jumpPage = vi.fn();
|
||||
const mounted = mountComponent(DataGridPagination, {
|
||||
selectionSummary: null,
|
||||
selectionSummarySumText: "",
|
||||
loading: false,
|
||||
infiniteScrollEnabled: false,
|
||||
infiniteScrollAllLoaded: false,
|
||||
pageSize: 100,
|
||||
customPageSizeInput: "",
|
||||
pageSizeMenuItems: [],
|
||||
exportMenuItems: [],
|
||||
currentPage: 3,
|
||||
maxPage: 12,
|
||||
canGoNextPage: true,
|
||||
canJumpLastPage: true,
|
||||
onJumpPage: jumpPage,
|
||||
});
|
||||
const pageInput = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage");
|
||||
|
||||
expect(pageInput.props.modelValue).toBe("3");
|
||||
pageInput.props["onUpdate:modelValue"]("8");
|
||||
await nextTick();
|
||||
const enter = dispatch(pageInput, "keydown", { key: "Enter" });
|
||||
expect(enter.defaultPrevented).toBe(true);
|
||||
expect(enter.propagationStopped).toBe(true);
|
||||
expect(jumpPage).toHaveBeenLastCalledWith(8);
|
||||
|
||||
pageInput.props["onUpdate:modelValue"]("99");
|
||||
await nextTick();
|
||||
dispatch(pageInput, "keydown", { key: "Enter" });
|
||||
expect(jumpPage).toHaveBeenLastCalledWith(12);
|
||||
|
||||
pageInput.props["onUpdate:modelValue"]("0");
|
||||
await nextTick();
|
||||
dispatch(pageInput, "keydown", { key: "Enter" });
|
||||
await nextTick();
|
||||
const resetPageInput = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage");
|
||||
expect(jumpPage).toHaveBeenCalledTimes(2);
|
||||
expect(resetPageInput.props.modelValue).toBe("3");
|
||||
});
|
||||
|
||||
it("hides pagination controls when the data source does not support paging", () => {
|
||||
|
||||
Reference in new issue
Block a user