From 4a0de8c586dac326ec426647d302a110dedf99e4 Mon Sep 17 00:00:00 2001 From: monellin Date: Fri, 7 Aug 2026 03:19:45 +0900 Subject: [PATCH] feat(grid): support Home/End/PageUp/PageDown keyboard navigation --- apps/desktop/src/components/grid/DataGrid.vue | 169 ++++++++++++++---- .../dataGrid/dataGridNavigation.spec.ts | 73 +++++++- .../src/lib/dataGrid/dataGridNavigation.ts | 59 +++++- 3 files changed, 260 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 43c4cfc975..6338e6e7b4 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -197,7 +197,8 @@ import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composa import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime"; import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; -import { moveDataGridCell } from "@/lib/dataGrid/dataGridNavigation"; +import { dataGridNavigationOrigin, dataGridPageScrollTop, dataGridRowScrollTop, moveDataGridCell, navigateDataGridCell, type DataGridNavigationDirection, type DataGridScrollAlignment } from "@/lib/dataGrid/dataGridNavigation"; +import type { CellPosition } from "@/lib/dataGrid/gridSelection"; import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; import { useDataGridEditor } from "@/composables/useDataGridEditor"; import { useDataGridSort } from "@/composables/useDataGridSort"; @@ -3801,6 +3802,7 @@ const { selectColumn, selectAllCells, extendCellSelectionTo, + selectionFocus, finishCellSelection, extendCellSelection, cellIsSelected, @@ -6263,7 +6265,9 @@ function currentSelectedCellPosition() { return { rowIndex: range.startRow, colIndex: range.startCol }; } -function scrollCellIntoView(rowIndex: number, colIndex: number) { +const DOM_DATA_GRID_ROW_HEIGHT = 26; + +function scrollCellIntoView(rowIndex: number, colIndex: number, block: DataGridScrollAlignment = "nearest", previousPageRowIndex?: number) { if (isTransposeMode.value) { nextTick(() => { const scroller = transposeScrollRef.value; @@ -6277,15 +6281,17 @@ function scrollCellIntoView(rowIndex: number, colIndex: number) { return; } nextTick(() => { - scrollGridColumnIntoView(colIndex); if (useCanvasGridRows.value) { - scrollCanvasRowIntoView(rowIndex, "nearest"); + scrollGridColumnIntoView(colIndex); + scrollCanvasRowIntoView(rowIndex, block, previousPageRowIndex); return; } - nextTick(() => { + scrollGridColumnIntoView(colIndex); + scrollDomRowIntoView(rowIndex, block, previousPageRowIndex); + requestAnimationFrame(() => { const rowEl = gridRef.value?.querySelector(`[data-row-index="${rowIndex}"]`); const cellEl = rowEl?.querySelector(`[data-visible-col-index="${colIndex}"]`); - (cellEl ?? rowEl)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + (cellEl ?? rowEl)?.scrollIntoView({ block: previousPageRowIndex === undefined ? block : "nearest", inline: "nearest" }); }); }); } @@ -6312,20 +6318,57 @@ function scrollGridColumnIntoView(visibleColIdx: number) { if (useCanvasGridRows.value) syncCanvasViewport(); } -function scrollCanvasRowIntoView(rowIndex: number, block: "nearest" | "start") { +function scrollCanvasRowIntoView(rowIndex: number, block: DataGridScrollAlignment, previousPageRowIndex?: number) { const target = Math.max(0, Math.min(displayRowCount.value - 1, rowIndex)); const scroller = canvasScrollerElement(); if (!scroller) return; - const rowTop = target * CANVAS_DATA_GRID_ROW_HEIGHT; - const rowBottom = rowTop + CANVAS_DATA_GRID_ROW_HEIGHT; - if (block === "start" || rowTop < scroller.scrollTop) { - scroller.scrollTop = rowTop; - } else if (rowBottom > scroller.scrollTop + scroller.clientHeight) { - scroller.scrollTop = Math.max(0, rowBottom - scroller.clientHeight); - } + scroller.scrollTop = + previousPageRowIndex === undefined + ? dataGridRowScrollTop({ + rowIndex: target, + rowHeight: CANVAS_DATA_GRID_ROW_HEIGHT, + viewportHeight: scroller.clientHeight, + currentScrollTop: scroller.scrollTop, + alignment: block, + }) + : dataGridPageScrollTop({ + previousRowIndex: previousPageRowIndex, + rowIndex: target, + rowHeight: CANVAS_DATA_GRID_ROW_HEIGHT, + currentScrollTop: scroller.scrollTop, + maximumScrollTop: scroller.scrollHeight - scroller.clientHeight, + }); syncCanvasViewport(); } +function scrollDomRowIntoView(rowIndex: number, block: DataGridScrollAlignment, previousPageRowIndex?: number) { + const target = Math.max(0, Math.min(displayRowCount.value - 1, rowIndex)); + const scroller = gridScrollerElement(); + if (!scroller) return; + const nextScrollTop = + previousPageRowIndex === undefined + ? dataGridRowScrollTop({ + rowIndex: target, + rowHeight: DOM_DATA_GRID_ROW_HEIGHT, + viewportHeight: scroller.clientHeight, + currentScrollTop: scroller.scrollTop, + alignment: block, + }) + : dataGridPageScrollTop({ + previousRowIndex: previousPageRowIndex, + rowIndex: target, + rowHeight: DOM_DATA_GRID_ROW_HEIGHT, + currentScrollTop: scroller.scrollTop, + maximumScrollTop: scroller.scrollHeight - scroller.clientHeight, + }); + const virtualScroller = scrollerRef.value; + if (virtualScroller && !(virtualScroller instanceof HTMLElement)) { + virtualScroller.scrollToPosition?.(nextScrollTop); + } else { + scroller.scrollTop = nextScrollTop; + } +} + function scrollGridRowIntoView(rowIndex: number) { const target = Math.max(0, Math.min(displayRowCount.value - 1, rowIndex)); nextTick(() => { @@ -6333,13 +6376,7 @@ function scrollGridRowIntoView(rowIndex: number) { scrollCanvasRowIntoView(target, "start"); return; } - const scroller = scrollerRef.value; - if (scroller && !(scroller instanceof HTMLElement)) { - scroller.scrollToItem?.(target); - scroller.scrollToPosition?.(target * 26); - } else if (scroller instanceof HTMLElement) { - scroller.scrollTop = target * 26; - } + scrollDomRowIntoView(target, "start"); requestAnimationFrame(() => { const rowEl = gridRef.value?.querySelector(`[data-row-index="${target}"]`); rowEl?.scrollIntoView({ block: "nearest", inline: "nearest" }); @@ -6395,20 +6432,58 @@ function toggleKeyboardTranspose(): boolean { return true; } -function moveSelectedCell(rowDelta: number, colDelta: number): boolean { - const position = currentSelectedCellPosition(); +// Shared path that selects or extends to nextPosition and scrolls it into view. +// Used by both moveSelectedCell (relative steps) and navigateSelectedCell (absolute/page jumps). +function applyCellNavigation(nextPosition: CellPosition, extend = false, block: DataGridScrollAlignment = "nearest", previousPageRowIndex?: number): boolean { + invalidateSyntheticContextSelection(); + if (extend) extendCellSelectionTo(nextPosition.rowIndex, nextPosition.colIndex); + else selectSingleCell(nextPosition.rowIndex, nextPosition.colIndex); + clearRowSelection(); + if (showTranspose.value) transposeRowIndex.value = nextPosition.rowIndex; + scrollCellIntoView(nextPosition.rowIndex, nextPosition.colIndex, block, previousPageRowIndex); + return true; +} + +function moveSelectedCell(rowDelta: number, colDelta: number, extend = false): boolean { + const position = dataGridNavigationOrigin(currentSelectedCellPosition(), selectionFocus.value, extend); if (!position || editingCell.value || displayRowCount.value === 0 || visibleColumnIndexes.value.length === 0) return false; const nextPosition = moveDataGridCell(position, rowDelta, colDelta, { rowCount: displayRowCount.value, visibleColumnCount: visibleColumnIndexes.value.length, }); if (!nextPosition) return false; - invalidateSyntheticContextSelection(); - selectSingleCell(nextPosition.rowIndex, nextPosition.colIndex); - clearRowSelection(); - if (showTranspose.value) transposeRowIndex.value = nextPosition.rowIndex; - scrollCellIntoView(nextPosition.rowIndex, nextPosition.colIndex); - return true; + return applyCellNavigation(nextPosition, extend); +} + +// Rows moved by a single PageUp/PageDown, i.e. one viewport worth of rows. +// Canvas mode computes this from its fixed row height; the DOM virtual scroller reuses the same +// approximate row height (26px) that scrollGridRowIntoView relies on. +function gridPageRowCount(): number { + const canvasMode = useCanvasGridRows.value; + const scroller = canvasMode ? canvasScrollerElement() : gridScrollerElement(); + if (!scroller) return 1; + const rowHeight = canvasMode ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT; + if (rowHeight <= 0) return 1; + return Math.max(1, Math.floor(scroller.clientHeight / rowHeight)); +} + +// Direction-based absolute movement for Home/End/PageUp/PageDown, including their Ctrl combinations. +// Transpose mode is handled by a dedicated branch in onGridKeydown, so this always assumes the normal grid. +// When extend is true (Shift combinations) the anchor stays put and only the focus moves, growing the range. +function navigateSelectedCell(direction: DataGridNavigationDirection, extend = false): boolean { + // While extending, move from the focus end of the range; fall back to the range start for a single selection. + const position = dataGridNavigationOrigin(currentSelectedCellPosition(), selectionFocus.value, extend); + if (!position || editingCell.value || displayRowCount.value === 0 || visibleColumnIndexes.value.length === 0) return false; + const nextPosition = navigateDataGridCell(position, direction, { + rowCount: displayRowCount.value, + visibleColumnCount: visibleColumnIndexes.value.length, + pageRowCount: gridPageRowCount(), + }); + if (!nextPosition) return false; + // docHome/docEnd must reach the very start/end of the grid even if the target row is already visible, so "nearest" is not used. + const block: DataGridScrollAlignment = direction === "docHome" ? "start" : direction === "docEnd" ? "end" : "nearest"; + const previousPageRowIndex = direction === "pageUp" || direction === "pageDown" ? position.rowIndex : undefined; + return applyCellNavigation(nextPosition, extend, block, previousPageRowIndex); } function editSelectedCell(): boolean { @@ -6609,36 +6684,54 @@ async function onGridKeydown(event: KeyboardEvent) { return; } if (isTransposeMode.value) { - if (event.key === "ArrowUp" && moveSelectedCell(0, -1)) { + if (event.key === "ArrowUp" && moveSelectedCell(0, -1, event.shiftKey)) { event.preventDefault(); return; } - if (event.key === "ArrowDown" && moveSelectedCell(0, 1)) { + if (event.key === "ArrowDown" && moveSelectedCell(0, 1, event.shiftKey)) { event.preventDefault(); return; } - if (event.key === "ArrowLeft" && (moveSelectedCell(-1, 0) || moveTransposeRecordSelection(-1))) { + if (event.key === "ArrowLeft" && (moveSelectedCell(-1, 0, event.shiftKey) || moveTransposeRecordSelection(-1))) { event.preventDefault(); return; } - if (event.key === "ArrowRight" && (moveSelectedCell(1, 0) || moveTransposeRecordSelection(1))) { + if (event.key === "ArrowRight" && (moveSelectedCell(1, 0, event.shiftKey) || moveTransposeRecordSelection(1))) { event.preventDefault(); return; } } - if (event.key === "ArrowUp" && moveSelectedCell(-1, 0)) { + if (event.key === "ArrowUp" && moveSelectedCell(-1, 0, event.shiftKey)) { event.preventDefault(); return; } - if (event.key === "ArrowDown" && moveSelectedCell(1, 0)) { + if (event.key === "ArrowDown" && moveSelectedCell(1, 0, event.shiftKey)) { event.preventDefault(); return; } - if (event.key === "ArrowLeft" && moveSelectedCell(0, -1)) { + if (event.key === "ArrowLeft" && moveSelectedCell(0, -1, event.shiftKey)) { event.preventDefault(); return; } - if (event.key === "ArrowRight" && moveSelectedCell(0, 1)) { + if (event.key === "ArrowRight" && moveSelectedCell(0, 1, event.shiftKey)) { + event.preventDefault(); + return; + } + // Home / End / Ctrl+Home / Ctrl+End: transpose mode is served by the dedicated branch above, + // so these only apply to the normal grid. + if (!isTransposeMode.value && (event.key === "Home" || event.key === "End")) { + const docJump = event.metaKey || event.ctrlKey; + const direction: DataGridNavigationDirection = event.key === "Home" ? (docJump ? "docHome" : "home") : docJump ? "docEnd" : "end"; + if (navigateSelectedCell(direction, event.shiftKey)) { + event.preventDefault(); + return; + } + } + if (!isTransposeMode.value && event.key === "PageUp" && navigateSelectedCell("pageUp", event.shiftKey)) { + event.preventDefault(); + return; + } + if (!isTransposeMode.value && event.key === "PageDown" && navigateSelectedCell("pageDown", event.shiftKey)) { event.preventDefault(); return; } @@ -9508,7 +9601,7 @@ const gridContextMenuItems = computed(() => { class="data-grid-scroller dbx-data-grid-font-family flex-1 overflow-x-auto overscroll-none" :class="{ 'is-scrolling': isScrolling, 'has-horizontal-scrollbar': hasGridHorizontalOverflow }" :items="displayItems" - :item-size="26" + :item-size="DOM_DATA_GRID_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id" diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts index ca18a427fb..1213acaa83 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridNavigation.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { moveDataGridCell, navigateDataGridCell } from "@/lib/dataGrid/dataGridNavigation"; +import { CANVAS_DATA_GRID_ROW_HEIGHT } from "@/lib/dataGrid/canvasDataGridRenderer"; +import { dataGridNavigationOrigin, dataGridPageScrollTop, dataGridRowScrollTop, moveDataGridCell, navigateDataGridCell } from "@/lib/dataGrid/dataGridNavigation"; const bounds = { rowCount: 3, visibleColumnCount: 4 }; @@ -10,6 +11,15 @@ describe("dataGridNavigation", () => { expect(moveDataGridCell({ rowIndex: 1, colIndex: 2 }, -1, 1, bounds)).toEqual({ rowIndex: 0, colIndex: 3 }); }); + it("continues Shift navigation from the selection focus", () => { + const rangeStart = { rowIndex: 1, colIndex: 1 }; + const selectionFocus = { rowIndex: 3, colIndex: 2 }; + + expect(dataGridNavigationOrigin(rangeStart, selectionFocus, true)).toEqual(selectionFocus); + expect(dataGridNavigationOrigin(rangeStart, selectionFocus, false)).toEqual(rangeStart); + expect(dataGridNavigationOrigin(rangeStart, null, true)).toEqual(rangeStart); + }); + it("supports home and end navigation without changing the row", () => { expect(navigateDataGridCell({ rowIndex: 2, colIndex: 2 }, "home", bounds)).toEqual({ rowIndex: 2, colIndex: 0 }); expect(navigateDataGridCell({ rowIndex: 2, colIndex: 0 }, "end", bounds)).toEqual({ rowIndex: 2, colIndex: 3 }); @@ -18,4 +28,65 @@ describe("dataGridNavigation", () => { it("returns no target for an empty grid", () => { expect(navigateDataGridCell({ rowIndex: 0, colIndex: 0 }, "down", { rowCount: 0, visibleColumnCount: 4 })).toBeNull(); }); + + it("supports page up and page down by the configured page row count", () => { + const pageBounds = { rowCount: 10, visibleColumnCount: 4, pageRowCount: 5 }; + expect(navigateDataGridCell({ rowIndex: 7, colIndex: 2 }, "pageUp", pageBounds)).toEqual({ rowIndex: 2, colIndex: 2 }); + expect(navigateDataGridCell({ rowIndex: 2, colIndex: 2 }, "pageDown", pageBounds)).toEqual({ rowIndex: 7, colIndex: 2 }); + }); + + it("continues Shift+Page navigation from the range focus", () => { + const pageBounds = { rowCount: 30, visibleColumnCount: 4, pageRowCount: 10 }; + const anchor = { rowIndex: 5, colIndex: 2 }; + const firstFocus = navigateDataGridCell(dataGridNavigationOrigin(anchor, null, true)!, "pageDown", pageBounds); + const secondFocus = navigateDataGridCell(dataGridNavigationOrigin(anchor, firstFocus, true)!, "pageDown", pageBounds); + + expect(firstFocus).toEqual({ rowIndex: 15, colIndex: 2 }); + expect(secondFocus).toEqual({ rowIndex: 25, colIndex: 2 }); + }); + + it("clamps page navigation at the grid boundaries", () => { + const pageBounds = { rowCount: 10, visibleColumnCount: 4, pageRowCount: 5 }; + expect(navigateDataGridCell({ rowIndex: 1, colIndex: 2 }, "pageUp", pageBounds)).toEqual({ rowIndex: 0, colIndex: 2 }); + expect(navigateDataGridCell({ rowIndex: 8, colIndex: 2 }, "pageDown", pageBounds)).toEqual({ rowIndex: 9, colIndex: 2 }); + }); + + it("falls back to a single row when pageRowCount is omitted", () => { + expect(navigateDataGridCell({ rowIndex: 1, colIndex: 2 }, "pageUp", bounds)).toEqual({ rowIndex: 0, colIndex: 2 }); + expect(navigateDataGridCell({ rowIndex: 1, colIndex: 2 }, "pageDown", bounds)).toEqual({ rowIndex: 2, colIndex: 2 }); + }); + + it("jumps to the first and last cell with docHome and docEnd", () => { + expect(navigateDataGridCell({ rowIndex: 2, colIndex: 3 }, "docHome", bounds)).toEqual({ rowIndex: 0, colIndex: 0 }); + expect(navigateDataGridCell({ rowIndex: 0, colIndex: 0 }, "docEnd", bounds)).toEqual({ rowIndex: 2, colIndex: 3 }); + }); + + it("aligns document jumps to the first and last grid rows", () => { + expect(dataGridRowScrollTop({ rowIndex: 0, rowHeight: 26, viewportHeight: 260, currentScrollTop: 1200, alignment: "start" })).toBe(0); + expect(dataGridRowScrollTop({ rowIndex: 99, rowHeight: 26, viewportHeight: 260, currentScrollTop: 0, alignment: "end" })).toBe(2340); + }); + + it("keeps a visible row in place and minimally reveals a row outside the viewport", () => { + expect(dataGridRowScrollTop({ rowIndex: 12, rowHeight: 26, viewportHeight: 260, currentScrollTop: 260, alignment: "nearest" })).toBe(260); + expect(dataGridRowScrollTop({ rowIndex: 9, rowHeight: 26, viewportHeight: 260, currentScrollTop: 260, alignment: "nearest" })).toBe(234); + expect(dataGridRowScrollTop({ rowIndex: 20, rowHeight: 26, viewportHeight: 260, currentScrollTop: 260, alignment: "nearest" })).toBe(286); + }); + + describe.each([ + ["DOM", 26], + ["Canvas", CANVAS_DATA_GRID_ROW_HEIGHT], + ])("%s PageUp/PageDown scrolling", (_renderMode, rowHeight) => { + const maximumScrollTop = 100 * rowHeight - 260; + + it("keeps the focused row at the same viewport-relative position", () => { + expect(dataGridPageScrollTop({ previousRowIndex: 5, rowIndex: 15, rowHeight, currentScrollTop: 0, maximumScrollTop })).toBe(260); + expect(dataGridPageScrollTop({ previousRowIndex: 15, rowIndex: 5, rowHeight, currentScrollTop: 260, maximumScrollTop })).toBe(0); + expect(dataGridPageScrollTop({ previousRowIndex: 10, rowIndex: 20, rowHeight, currentScrollTop: 117, maximumScrollTop })).toBe(377); + }); + + it("clamps scrolling at the upper and lower boundaries", () => { + expect(dataGridPageScrollTop({ previousRowIndex: 2, rowIndex: 0, rowHeight, currentScrollTop: 0, maximumScrollTop })).toBe(0); + expect(dataGridPageScrollTop({ previousRowIndex: 97, rowIndex: 99, rowHeight, currentScrollTop: maximumScrollTop, maximumScrollTop })).toBe(maximumScrollTop); + }); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridNavigation.ts b/apps/desktop/src/lib/dataGrid/dataGridNavigation.ts index a9f35e4862..9c655a5539 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridNavigation.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridNavigation.ts @@ -1,16 +1,55 @@ import type { CellPosition } from "@/lib/dataGrid/gridSelection"; -export type DataGridNavigationDirection = "up" | "down" | "left" | "right" | "home" | "end"; +export type DataGridNavigationDirection = "up" | "down" | "left" | "right" | "home" | "end" | "pageUp" | "pageDown" | "docHome" | "docEnd"; export interface DataGridNavigationBounds { rowCount: number; visibleColumnCount: number; + /** Rows moved by a single PageUp / PageDown. Defaults to 1 when omitted. */ + pageRowCount?: number; +} + +export type DataGridScrollAlignment = "nearest" | "start" | "end"; + +export interface DataGridRowScrollOptions { + rowIndex: number; + rowHeight: number; + viewportHeight: number; + currentScrollTop: number; + alignment: DataGridScrollAlignment; +} + +export interface DataGridPageScrollOptions { + previousRowIndex: number; + rowIndex: number; + rowHeight: number; + currentScrollTop: number; + maximumScrollTop: number; } function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)); } +export function dataGridNavigationOrigin(position: CellPosition | null, selectionFocus: CellPosition | null, extend: boolean): CellPosition | null { + return extend ? (selectionFocus ?? position) : position; +} + +export function dataGridRowScrollTop(options: DataGridRowScrollOptions): number { + const rowTop = options.rowIndex * options.rowHeight; + const rowBottom = rowTop + options.rowHeight; + if (options.alignment === "start" || rowTop < options.currentScrollTop) return Math.max(0, rowTop); + if (options.alignment === "end" || rowBottom > options.currentScrollTop + options.viewportHeight) { + return Math.max(0, rowBottom - options.viewportHeight); + } + return options.currentScrollTop; +} + +export function dataGridPageScrollTop(options: DataGridPageScrollOptions): number { + const rowOffset = (options.rowIndex - options.previousRowIndex) * options.rowHeight; + return clamp(options.currentScrollTop + rowOffset, 0, Math.max(0, options.maximumScrollTop)); +} + export function moveDataGridCell(position: CellPosition, rowDelta: number, columnDelta: number, bounds: DataGridNavigationBounds): CellPosition | null { if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null; return { @@ -21,6 +60,10 @@ export function moveDataGridCell(position: CellPosition, rowDelta: number, colum export function navigateDataGridCell(position: CellPosition, direction: DataGridNavigationDirection, bounds: DataGridNavigationBounds): CellPosition | null { if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null; + const lastRowIndex = bounds.rowCount - 1; + const lastColIndex = bounds.visibleColumnCount - 1; + // PageUp/PageDown step follows the viewport row count, with a one-row floor. + const pageRowCount = Math.max(1, Math.floor(bounds.pageRowCount ?? 1)); switch (direction) { case "up": return moveDataGridCell(position, -1, 0, bounds); @@ -31,8 +74,20 @@ export function navigateDataGridCell(position: CellPosition, direction: DataGrid case "right": return moveDataGridCell(position, 0, 1, bounds); case "home": + // First visible column of the current row return { rowIndex: position.rowIndex, colIndex: 0 }; case "end": - return { rowIndex: position.rowIndex, colIndex: bounds.visibleColumnCount - 1 }; + // Last visible column of the current row + return { rowIndex: position.rowIndex, colIndex: lastColIndex }; + case "pageUp": + return { rowIndex: clamp(position.rowIndex - pageRowCount, 0, lastRowIndex), colIndex: position.colIndex }; + case "pageDown": + return { rowIndex: clamp(position.rowIndex + pageRowCount, 0, lastRowIndex), colIndex: position.colIndex }; + case "docHome": + // First cell of the whole grid (Ctrl/Cmd + Home) + return { rowIndex: 0, colIndex: 0 }; + case "docEnd": + // Last cell of the whole grid (Ctrl/Cmd + End) + return { rowIndex: lastRowIndex, colIndex: lastColIndex }; } }