feat(grid): support Home/End/PageUp/PageDown keyboard navigation
This commit is contained in:
parent
4402faac1c
commit
4a0de8c586
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue