feat(grid): support Home/End/PageUp/PageDown keyboard navigation

This commit is contained in:
monellin authored and GitHub committed 2026-08-07 02:19:45 +08:00
1 parent 4402faac1c
commit 4a0de8c586
3 files changed
+260 -41

No files matched your search

+131 -38
View File
@@ -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<HTMLElement>(`[data-row-index="${rowIndex}"]`);
const cellEl = rowEl?.querySelector<HTMLElement>(`[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<HTMLElement>(`[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<ContextMenuItem[]>(() => {
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"