diff --git a/src/Spreadsheet.test.tsx b/src/Spreadsheet.test.tsx index 42051f73..ec498ee5 100644 --- a/src/Spreadsheet.test.tsx +++ b/src/Spreadsheet.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; -import Spreadsheet, { Props } from "./Spreadsheet"; +import Spreadsheet, { Props, SpreadsheetRef } from "./Spreadsheet"; import * as Matrix from "./matrix"; import * as Types from "./types"; import * as Point from "./point"; @@ -478,6 +478,76 @@ describe("", () => { }); }); +describe("Spreadsheet Ref Methods", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("ref.activate activates the specified cell", () => { + const onActivate = jest.fn(); + const ref = React.createRef(); + + render( + + ); + + // Ensure ref is defined + expect(ref.current).not.toBeNull(); + + // Call activate method via ref + const targetPoint = { row: 1, column: 1 }; + React.act(() => { + ref.current?.activate(targetPoint); + }); + + // Verify onActivate was called with correct point + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onActivate).toHaveBeenCalledWith(targetPoint); + }); + + test("ref methods are memoized and stable between renders", () => { + const ref = React.createRef(); + const { rerender } = render(); + + // Store initial methods + const initialActivate = ref.current?.activate; + + // Trigger re-render + rerender(); + + // Methods should be referentially stable + expect(ref.current?.activate).toBe(initialActivate); + }); + + test("activate method handles invalid points gracefully", () => { + const onActivate = jest.fn(); + const ref = React.createRef(); + + render( + + ); + + // Try to activate cell outside grid bounds + const invalidPoint = { row: ROWS + 1, column: COLUMNS + 1 }; + React.act(() => { + ref.current?.activate(invalidPoint); + }); + + // Should still call onActivate with the provided point + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onActivate).toHaveBeenCalledWith(invalidPoint); + }); + + test("ref is properly typed as SpreadsheetRef", () => { + const ref = React.createRef(); + + render(); + + // TypeScript compilation would fail if ref typing is incorrect + expect(typeof ref.current?.activate).toBe("function"); + }); +}); + /** Like .querySelector() but throws for no match */ function safeQuerySelector( node: ParentNode, diff --git a/src/Spreadsheet.tsx b/src/Spreadsheet.tsx index 58bacc1a..f791fd55 100644 --- a/src/Spreadsheet.tsx +++ b/src/Spreadsheet.tsx @@ -123,11 +123,24 @@ export type Props = { onEvaluatedDataChange?: (data: Matrix.Matrix) => void; }; +/** + * The Spreadsheet Ref Type + */ + +export type SpreadsheetRef = { + /** + * Pass the desired point as a prop to specify which one should be activated. + */ + activate: (point: Point.Point) => void; +}; + /** * The Spreadsheet component */ -const Spreadsheet = ( - props: Props + +const Spreadsheet = ( + props: Props, + ref: React.ForwardedRef ): React.ReactElement => { const { className, @@ -198,6 +211,23 @@ const Spreadsheet = ( const setCreateFormulaParser = useAction(Actions.setCreateFormulaParser); const blur = useAction(Actions.blur); const setSelection = useAction(Actions.setSelection); + const activate = useAction(Actions.activate); + + // Memoize methods to be exposed via ref + const methods = React.useMemo( + () => ({ + activate: (point: Point.Point) => { + activate(point); + }, + }), + [] + ); + + // Expose methods to parent via ref + React.useImperativeHandle( + ref, + () => methods as SpreadsheetRef + ); // Track active const prevActiveRef = React.useRef(state.active); @@ -557,4 +587,4 @@ const Spreadsheet = ( ); }; -export default Spreadsheet; +export default React.forwardRef(Spreadsheet); diff --git a/src/index.ts b/src/index.ts index c489b829..f2680331 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ -import Spreadsheet from "./Spreadsheet"; +import Spreadsheet, { SpreadsheetRef } from "./Spreadsheet"; import DataEditor from "./DataEditor"; import DataViewer from "./DataViewer"; export default Spreadsheet; -export { Spreadsheet, DataEditor, DataViewer }; +export { Spreadsheet, DataEditor, DataViewer, SpreadsheetRef }; export type { Props } from "./Spreadsheet"; export { createEmpty as createEmptyMatrix } from "./matrix"; export type { Matrix } from "./matrix"; diff --git a/src/stories/Spreadsheet.stories.tsx b/src/stories/Spreadsheet.stories.tsx index 5fcdebce..8dbc9e10 100644 --- a/src/stories/Spreadsheet.stories.tsx +++ b/src/stories/Spreadsheet.stories.tsx @@ -10,6 +10,8 @@ import { EntireRowsSelection, EntireColumnsSelection, EmptySelection, + Point, + SpreadsheetRef, } from ".."; import * as Matrix from "../matrix"; import { AsyncCellDataEditor, AsyncCellDataViewer } from "./AsyncCellData"; @@ -17,7 +19,6 @@ import CustomCell from "./CustomCell"; import { RangeEdit, RangeView } from "./RangeDataComponents"; import { SelectEdit, SelectView } from "./SelectDataComponents"; import { CustomCornerIndicator } from "./CustomCornerIndicator"; - type StringCell = CellBase; type NumberCell = CellBase; @@ -305,3 +306,49 @@ export const ControlledSelection: StoryFn> = (props) => { ); }; + +export const ControlledActivation: StoryFn> = (props) => { + const spreadsheetRef = React.useRef(); + + const [activationPoint, setActivationPoint] = React.useState({ + row: 0, + column: 0, + }); + + const handleActivate = React.useCallback(() => { + spreadsheetRef.current?.activate(activationPoint); + }, [activationPoint]); + + return ( +
+
+ + setActivationPoint(() => ({ + ...activationPoint, + row: Number(e.target.value), + })) + } + /> + + setActivationPoint(() => ({ + ...activationPoint, + column: Number(e.target.value), + })) + } + /> + +
+ ; +
+ ); +};