From 7f49fa7f123b30ef1b789e27a2f2c5891534e035 Mon Sep 17 00:00:00 2001 From: tyengibaryan Date: Wed, 29 Oct 2025 17:33:25 +0400 Subject: [PATCH 1/3] 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 b86721f9e..047524c34 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 58b880e7b1c8521ba6ec17a2bc3b8045497614f6 Mon Sep 17 00:00:00 2001 From: tyengibaryan Date: Thu, 30 Oct 2025 14:20:37 +0400 Subject: [PATCH 2/3] 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 047524c34..440f9a6cd 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 ef3c850fe5b69a12b34a18e8053725f281cf518b Mon Sep 17 00:00:00 2001 From: tyengibaryan Date: Thu, 30 Oct 2025 14:26:03 +0400 Subject: [PATCH 3/3] 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 440f9a6cd..a482c02c9 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