feat(grid): support custom page navigation

This commit is contained in:
ssy
2026-08-06 12:41:23 +08:00
committed by GitHub
parent c754b85295
commit 61e5073d25
11 changed files with 126 additions and 2 deletions
@@ -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", () => {
+1
View File
@@ -1377,6 +1377,7 @@ export default {
filterAllRows: "All rows",
filterChangedRows: "Changed",
page: "Page {page}",
jumpToPage: "Jump to page",
rowsPerPage: "Rows per page",
customRowsPerPage: "Custom rows",
applyPageSize: "Apply",
+1
View File
@@ -1515,6 +1515,7 @@ export default withEnglishFallback({
xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:",
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
xlsxHeaderComment: "Encabezado usando comentarios",
jumpToPage: "Ir a la página",
},
exportProgress: {
streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.",
+1
View File
@@ -1513,6 +1513,7 @@ export default withEnglishFallback({
xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:",
xlsxHeaderOriginal: "Intestazione con nome campo",
xlsxHeaderComment: "Intestazione con commento",
jumpToPage: "Vai alla pagina",
},
exportProgress: {
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",
+1
View File
@@ -1540,6 +1540,7 @@ export default withEnglishFallback({
searchModeFilter: "フィルター",
searchModeHighlight: "ハイライト",
emptyStringValue: "空文字",
jumpToPage: "ページ番号に移動",
},
exportProgress: {
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
+1
View File
@@ -1282,6 +1282,7 @@ export default withEnglishFallback({
filterAllRows: "전체 행",
filterChangedRows: "변경됨",
page: "{page}페이지",
jumpToPage: "{page}페이지로 이동",
rowsPerPage: "페이지당 행",
customRowsPerPage: "사용자 지정 행",
applyPageSize: "적용",
+1
View File
@@ -1515,6 +1515,7 @@ export default withEnglishFallback({
xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:",
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
xlsxHeaderComment: "Cabeçalho usa comentário",
jumpToPage: "Ir para a página",
},
exportProgress: {
streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.",
+1
View File
@@ -1378,6 +1378,7 @@ export default withEnglishFallback({
filterAllRows: "全部行",
filterChangedRows: "变更项",
page: "第 {page} 页",
jumpToPage: "跳转到页码",
rowsPerPage: "每页行数",
customRowsPerPage: "自定义行数",
applyPageSize: "应用",
+1
View File
@@ -1257,6 +1257,7 @@ export default withEnglishFallback({
filterAllRows: "全部列",
filterChangedRows: "變更列",
page: "第 {page} 頁",
jumpToPage: "跳至頁碼",
rowsPerPage: "每頁筆數",
customRowsPerPage: "自訂筆數",
applyPageSize: "套用",