From 42b95f70b8e5fae2fe0f87c7ddffa94f4c7527d2 Mon Sep 17 00:00:00 2001 From: tyengibaryan Date: Wed, 29 Oct 2025 17:33:25 +0400 Subject: [PATCH 1/5] feat: add onKeyDown event on custom cell renderer --- packages/core/src/cells/cell-types.ts | 16 + packages/core/src/data-editor/data-editor.tsx | 37 ++ packages/core/test/data-editor.test.tsx | 476 ++++++++++++++++++ 3 files changed, 529 insertions(+) diff --git a/packages/core/src/cells/cell-types.ts b/packages/core/src/cells/cell-types.ts index b5f05239b..d7fd81461 100644 --- a/packages/core/src/cells/cell-types.ts +++ b/packages/core/src/cells/cell-types.ts @@ -91,6 +91,22 @@ interface BaseCellRenderer { } & BaseGridMouseEventArgs ) => void; readonly onDelete?: (cell: T) => T | undefined; + + readonly onKeyDown?: ( + args: { + readonly cell: T; + readonly bounds: Rectangle; + readonly location: Item; + readonly theme: FullTheme; + readonly preventDefault: () => void; + readonly key: string; + readonly keyCode: number; + readonly altKey: boolean; + readonly shiftKey: boolean; + readonly ctrlKey: boolean; + readonly metaKey: boolean; + } + ) => T | undefined; } /** @category Renderers */ diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 6674c6f7f..7c270a894 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3498,6 +3498,43 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + prevented = true; + }, + key: event.key, + keyCode: event.keyCode, + altKey: event.altKey, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }); + + if (prevented) { + event.preventDefault(); + event.stopPropagation(); + } + + if (newVal !== undefined && !isInnerOnlyCell(newVal) && isEditableGridCell(newVal) && newVal.readonly !== true) { + mangledOnCellsEdited([{ location: event.location, value: newVal }]); + gridRef.current?.damage([{ cell: event.location }]); + } + + if (prevented) return; + } + } + if (handleFixedKeybindings(event)) return; if (gridSelection.current === undefined) return; diff --git a/packages/core/test/data-editor.test.tsx b/packages/core/test/data-editor.test.tsx index 4a0d97369..1644b1978 100644 --- a/packages/core/test/data-editor.test.tsx +++ b/packages/core/test/data-editor.test.tsx @@ -29,6 +29,7 @@ import { Context, standardBeforeEach, standardAfterEach, + makeCell, } from "./test-utils.js"; describe("data-editor", () => { @@ -4942,4 +4943,479 @@ describe("data-editor", () => { current: undefined, }); }); + + test("Cell renderer onKeyDown should be called with correct parameters", async () => { + const mockOnKeyDown = vi.fn(); + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + }; + + vi.useFakeTimers(); + render( + { + return { + kind: GridCellKind.Custom, + allowOverlay: false, + copyData: "", + data: { value: 'custom-cell' }, + }; + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + // Click on cell [1, 1] + sendClick(canvas, { + clientX: 300, // Col B (index 1) + clientY: 36 + 32 + 16, // Row 1 + }); + + act(() => { + vi.runAllTimers(); + }); + + // Press a key + fireEvent.keyDown(canvas, { + key: "a", + keyCode: 65, + altKey: false, + shiftKey: false, + ctrlKey: false, + metaKey: false, + }); + + expect(mockOnKeyDown).toHaveBeenCalledWith( + expect.objectContaining({ + cell: expect.objectContaining({ + kind: GridCellKind.Custom, + data: { value: 'custom-cell' }, + }), + bounds: expect.any(Object), + location: [1, 1], // Without row marker offset + theme: expect.any(Object), + preventDefault: expect.any(Function), + key: "a", + keyCode: 65, + altKey: false, + shiftKey: false, + ctrlKey: false, + metaKey: false, + }) + ); + }); + + test("Cell renderer onKeyDown preventDefault should stop event propagation", async () => { + const mockOnKeyDown = vi.fn((args) => { + args.preventDefault(); + return undefined; + }); + + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + }; + + const mockDataEditorOnKeyDown = vi.fn(); + + vi.useFakeTimers(); + render( + { + if (col === 1 && row === 1) { + return { + kind: GridCellKind.Custom, + allowOverlay: false, + copyData: "", + data: { value: "custom-cell" }, + }; + } + return makeCell([col, row]); + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + sendClick(canvas, { + clientX: 300, + clientY: 36 + 32 + 16, + }); + + act(() => { + vi.runAllTimers(); + }); + + const mockPreventDefault = vi.fn(); + const mockStopPropagation = vi.fn(); + + const evt = createEvent.keyDown(canvas, { key: 'ArrowDown', code: 'ArrowDown' }); + evt.preventDefault = mockPreventDefault + evt.stopPropagation = mockStopPropagation + fireEvent(canvas, evt); + + // Renderer's onKeyDown should have been called + expect(mockOnKeyDown).toHaveBeenCalled(); + + // Event should be prevented and stopped + expect(mockPreventDefault).toHaveBeenCalled(); + expect(mockStopPropagation).toHaveBeenCalled(); + }); + + test("Cell renderer onKeyDown can return new cell value", async () => { + const mockOnCellsEdited = vi.fn(); + + const mockOnKeyDown = vi.fn((args) => { + if (args.key === "x") { + return { + ...args.cell, + data: { ...args.cell.data, modified: true }, + }; + } + return undefined; + }); + + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + }; + + vi.useFakeTimers(); + render( + { + if (col === 1 && row === 1) { + return { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: "", + data: { value: 'custom-cell' }, + }; + } + return makeCell([col, row]); + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + sendClick(canvas, { + clientX: 300, + clientY: 36 + 32 + 16, + }); + + act(() => { + vi.runAllTimers(); + }); + + fireEvent.keyDown(canvas, { + key: "x", + keyCode: 88, + }); + + act(() => { + vi.runAllTimers(); + }); + + // onCellsEdited should be called with the new value + expect(mockOnCellsEdited).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + location: [1, 1], + value: expect.objectContaining({ + data: { value: 'custom-cell', modified: true }, + }), + }), + ]) + ); + }); + + test("Cell renderer onKeyDown should not save readonly cells", async () => { + const mockOnCellsEdited = vi.fn(); + + const mockOnKeyDown = vi.fn((args) => { + return { + ...args.cell, + data: { ...args.cell.data, modified: true }, + }; + }); + + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + }; + + vi.useFakeTimers(); + render( + { + if (col === 1 && row === 1) { + return { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: "", + readonly: true, // Cell is readonly + data: { value: 'custom-cell', modified: false }, + }; + } + return makeCell([col, row]); + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + sendClick(canvas, { + clientX: 300, + clientY: 36 + 32 + 16, + }); + + act(() => { + vi.runAllTimers(); + }); + + fireEvent.keyDown(canvas, { + key: "x", + keyCode: 88, + }); + + act(() => { + vi.runAllTimers(); + }); + + // onCellsEdited should NOT be called for readonly cells + expect(mockOnCellsEdited).not.toHaveBeenCalled(); + }); + + test("Cell renderer onKeyDown with preventDefault should not continue to default keybindings", async () => { + const mockOnGridSelectionChange = vi.fn(); + + const mockOnKeyDown = vi.fn((args) => { + if (args.key === "ArrowDown") { + args.preventDefault(); + } + return undefined; + }); + + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + }; + + vi.useFakeTimers(); + render( + { + if (col === 1 && row === 1) { + return { + kind: GridCellKind.Custom, + allowOverlay: false, + copyData: "", + data: { value: 'custom-cell' }, + }; + } + return makeCell([col, row]); + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + sendClick(canvas, { + clientX: 300, + clientY: 36 + 32 + 16, + }); + + act(() => { + vi.runAllTimers(); + }); + + mockOnGridSelectionChange.mockClear(); + + // Press ArrowDown which would normally move selection down + fireEvent.keyDown(canvas, { + key: "ArrowDown", + keyCode: 40, + }); + + act(() => { + vi.runAllTimers(); + }); + + // Selection should NOT change because preventDefault was called + expect(mockOnGridSelectionChange).not.toHaveBeenCalled(); + }); + + test("Cell renderer onKeyDown should not be called when overlay is open", async () => { + const mockOnKeyDown = vi.fn(); + + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + provideEditor: () => () => , + }; + + vi.useFakeTimers(); + render( + { + if (col === 1 && row === 1) { + return { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: "", + data: { value: 'custom-cell' }, + }; + } + return makeCell([col, row]); + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + sendClick(canvas, { + clientX: 300, + clientY: 36 + 32 + 16, + }); + + act(() => { + vi.runAllTimers(); + }); + + // Open the overlay by double-clicking + sendClick(canvas, { + clientX: 300, + clientY: 36 + 32 + 16, + }); + + act(() => { + vi.runAllTimers(); + }); + + mockOnKeyDown.mockClear(); + + // Press a key while overlay is open + fireEvent.keyDown(canvas, { + key: "a", + keyCode: 65, + }); + + // onKeyDown should NOT be called when overlay is open + expect(mockOnKeyDown).not.toHaveBeenCalled(); + }); + + test("Cell renderer onKeyDown should handle both preventDefault and return value", async () => { + const mockOnCellsEdited = vi.fn(); + + const mockOnKeyDown = vi.fn((args) => { + if (args.key === "x") { + args.preventDefault(); // Both prevent default AND return new value + return { + ...args.cell, + data: { value: 'modified' }, + }; + } + return undefined; + }); + + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + }; + + vi.useFakeTimers(); + render( + { + if (col === 1 && row === 1) { + return { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: "", + data: { value: 'custom-cell' }, + }; + } + return makeCell([col, row]); + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + sendClick(canvas, { + clientX: 300, + clientY: 36 + 32 + 16, + }); + + act(() => { + vi.runAllTimers(); + }); + + const mockPreventDefault = vi.fn(); + const mockStopPropagation = vi.fn(); + + const evt = createEvent.keyDown(canvas, { key: 'x' }); + evt.preventDefault = mockPreventDefault + evt.stopPropagation = mockStopPropagation + fireEvent(canvas, evt); + + act(() => { + vi.runAllTimers(); + }); + + // Should both save the new value AND prevent default + expect(mockOnCellsEdited).toHaveBeenCalled(); + expect(mockPreventDefault).toHaveBeenCalled(); + expect(mockStopPropagation).toHaveBeenCalled(); + }); }); From d7ba839ca8c2d8c0bc880eac14b43398385f8f76 Mon Sep 17 00:00:00 2001 From: tyengibaryan Date: Thu, 30 Oct 2025 14:20:37 +0400 Subject: [PATCH 2/5] fix: destructure event instead of manual --- packages/core/src/data-editor/data-editor.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 7c270a894..e11c348cd 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3506,19 +3506,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction { prevented = true; }, - key: event.key, - keyCode: event.keyCode, - altKey: event.altKey, - shiftKey: event.shiftKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, }); if (prevented) { From 21342504c69bba9276ad0a290e90093ea65660b6 Mon Sep 17 00:00:00 2001 From: tyengibaryan Date: Thu, 30 Oct 2025 14:26:03 +0400 Subject: [PATCH 3/5] fix: give bounds manually --- packages/core/src/data-editor/data-editor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index e11c348cd..44b99a336 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3507,6 +3507,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction Date: Wed, 4 Feb 2026 12:28:46 +0400 Subject: [PATCH 4/5] fix: pr comments --- packages/core/src/data-editor/data-editor.tsx | 8 ++- packages/core/test/data-editor.test.tsx | 67 +++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 44b99a336..831b86b20 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3521,7 +3521,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction { const mockStopPropagation = vi.fn(); const evt = createEvent.keyDown(canvas, { key: 'ArrowDown', code: 'ArrowDown' }); - evt.preventDefault = mockPreventDefault - evt.stopPropagation = mockStopPropagation + evt.preventDefault = mockPreventDefault; + evt.stopPropagation = mockStopPropagation; fireEvent(canvas, evt); // Renderer's onKeyDown should have been called @@ -5405,8 +5405,8 @@ describe("data-editor", () => { const mockStopPropagation = vi.fn(); const evt = createEvent.keyDown(canvas, { key: 'x' }); - evt.preventDefault = mockPreventDefault - evt.stopPropagation = mockStopPropagation + evt.preventDefault = mockPreventDefault; + evt.stopPropagation = mockStopPropagation; fireEvent(canvas, evt); act(() => { @@ -5418,4 +5418,63 @@ describe("data-editor", () => { expect(mockPreventDefault).toHaveBeenCalled(); expect(mockStopPropagation).toHaveBeenCalled(); }); + + test("Cell renderer onKeyDown location should be adjusted when row markers are enabled", async () => { + const mockOnKeyDown = vi.fn(); + const customRenderer = { + kind: GridCellKind.Custom, + isMatch: (c: GridCell): c is CustomCell => c.kind === GridCellKind.Custom, + draw: () => true, + onKeyDown: mockOnKeyDown, + }; + + vi.useFakeTimers(); + render( + { + return { + kind: GridCellKind.Custom, + allowOverlay: false, + copyData: "", + data: { value: "custom-cell" }, + }; + }} + />, + { wrapper: Context } + ); + prep(false); + + const canvas = screen.getByTestId("data-grid-canvas"); + + // Click on cell at visual position [2, 1] (which is col B when row markers are enabled) + // With row markers, column 0 is the row marker, so visual col 2 is data col 1 + sendClick(canvas, { + clientX: 320, // Adjusted for row marker width + clientY: 36 + 32 + 16, // Row 1 + }); + + act(() => { + vi.runAllTimers(); + }); + + // Press a key + fireEvent.keyDown(canvas, { + key: "a", + keyCode: 65, + }); + + // The location should be adjusted: internal grid col 2 minus rowMarkerOffset (1) = [1, 1] + expect(mockOnKeyDown).toHaveBeenCalledWith( + expect.objectContaining({ + cell: expect.objectContaining({ + kind: GridCellKind.Custom, + }), + location: [1, 1], // Adjusted for row marker offset + key: "a", + }) + ); + }); }); From bda81e3fb0425482e0bc3b9f8e5fa4f1b37caeb5 Mon Sep 17 00:00:00 2001 From: tyengibaryan Date: Wed, 4 Feb 2026 12:34:59 +0400 Subject: [PATCH 5/5] fix: failing build job --- packages/core/src/data-editor/data-editor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 831b86b20..b3d1fa834 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -3525,6 +3525,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction