@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,100 @@
1
+ // =============================================================================
2
+ // Formula Function-name Autocomplete
3
+ // =============================================================================
4
+ //
5
+ // Given a formula string and cursor position, return matching Excel function
6
+ // names that should be offered as completions. The active token is the
7
+ // alphabetic identifier ending at the cursor; the user is "in formula mode"
8
+ // when the text starts with `=`.
9
+ // =============================================================================
10
+
11
+ const FUNCTION_NAMES = [
12
+ // Math & trig
13
+ "ABS","ACOS","ACOSH","ASIN","ASINH","ATAN","ATAN2","ATANH","CEILING","COS","COSH","DEGREES","EVEN","EXP","FACT","FACTDOUBLE","FLOOR","GCD","INT","LCM","LN","LOG","LOG10","MOD","MROUND","ODD","PI","POWER","PRODUCT","QUOTIENT","RADIANS","RAND","RANDBETWEEN","ROUND","ROUNDDOWN","ROUNDUP","SEC","SECH","SIGN","SIN","SINH","SQRT","SUBTOTAL","SUM","SUMIF","SUMIFS","SUMPRODUCT","SUMSQ","TAN","TANH","TRUNC",
14
+ // Statistical
15
+ "AVERAGE","AVERAGEA","AVERAGEIF","AVERAGEIFS","COUNT","COUNTA","COUNTBLANK","COUNTIF","COUNTIFS","LARGE","MAX","MAXA","MAXIFS","MEDIAN","MIN","MINA","MINIFS","MODE","PERCENTILE","QUARTILE","RANK","SLOPE","SMALL","STDEV","STDEVA","STDEVP","VAR","VARA","VARP",
16
+ // Logical
17
+ "AND","FALSE","IF","IFERROR","IFNA","IFS","NOT","OR","SWITCH","TRUE","XOR",
18
+ // Lookup & reference
19
+ "ADDRESS","CHOOSE","COLUMN","COLUMNS","HLOOKUP","HYPERLINK","INDEX","INDIRECT","LOOKUP","MATCH","OFFSET","ROW","ROWS","TRANSPOSE","VLOOKUP","XLOOKUP","XMATCH",
20
+ // Text
21
+ "CHAR","CLEAN","CODE","CONCAT","CONCATENATE","DOLLAR","EXACT","FIND","FIXED","LEFT","LEN","LOWER","MID","PROPER","REPLACE","REPT","RIGHT","SEARCH","SUBSTITUTE","T","TEXT","TEXTJOIN","TRIM","UNICHAR","UNICODE","UPPER","VALUE",
22
+ // Date & time
23
+ "DATE","DATEDIF","DATEVALUE","DAY","DAYS","DAYS360","EDATE","EOMONTH","HOUR","ISOWEEKNUM","MINUTE","MONTH","NETWORKDAYS","NOW","SECOND","TIME","TIMEVALUE","TODAY","WEEKDAY","WEEKNUM","WORKDAY","YEAR","YEARFRAC",
24
+ // Information
25
+ "CELL","ERROR.TYPE","INFO","ISBLANK","ISERR","ISERROR","ISEVEN","ISFORMULA","ISLOGICAL","ISNA","ISNONTEXT","ISNUMBER","ISODD","ISREF","ISTEXT","N","NA","TYPE",
26
+ // Financial (most common)
27
+ "FV","IRR","NPER","NPV","PMT","PPMT","PV","RATE","XIRR","XNPV",
28
+ // Engineering (common)
29
+ "BIN2DEC","DEC2BIN","DEC2HEX","HEX2DEC",
30
+ // Database
31
+ "DAVERAGE","DCOUNT","DCOUNTA","DGET","DMAX","DMIN","DPRODUCT","DSTDEV","DSUM","DVAR",
32
+ ];
33
+
34
+ export interface FormulaSuggestion {
35
+ /** The full function name (e.g. "VLOOKUP"). */
36
+ name: string;
37
+ /** Substring of `name` that matched the user's input. */
38
+ matchStart: number;
39
+ matchEnd: number;
40
+ }
41
+
42
+ export interface FormulaSuggestResult {
43
+ suggestions: FormulaSuggestion[];
44
+ /** Start index in the input where the active token begins. The caller uses
45
+ * this when accepting a suggestion to splice in the function name. */
46
+ tokenStart: number;
47
+ /** Cursor position (== end of token). */
48
+ tokenEnd: number;
49
+ }
50
+
51
+ /**
52
+ * Compute function-name suggestions based on a partial token at the cursor.
53
+ * Returns an empty list when the text isn't a formula (`=` prefix) or when
54
+ * the cursor is not on an alphabetic identifier.
55
+ */
56
+ export function getFormulaSuggestions(
57
+ text: string,
58
+ cursor: number,
59
+ limit = 8,
60
+ ): FormulaSuggestResult {
61
+ const empty: FormulaSuggestResult = { suggestions: [], tokenStart: cursor, tokenEnd: cursor };
62
+ if (!text.startsWith("=")) return empty;
63
+
64
+ // Walk backwards from the cursor while we see alphabetic chars or '.'
65
+ // (Excel function names like ERROR.TYPE include dot).
66
+ let start = cursor;
67
+ while (start > 0) {
68
+ const c = text[start - 1];
69
+ if (!/[A-Za-z.]/.test(c)) break;
70
+ start--;
71
+ }
72
+ if (start === cursor) return empty;
73
+
74
+ const token = text.slice(start, cursor).toUpperCase();
75
+ if (token.length === 0) return empty;
76
+
77
+ const suggestions: FormulaSuggestion[] = [];
78
+ for (const name of FUNCTION_NAMES) {
79
+ if (name.startsWith(token) && name !== token) {
80
+ suggestions.push({ name, matchStart: 0, matchEnd: token.length });
81
+ if (suggestions.length >= limit) break;
82
+ }
83
+ }
84
+ return { suggestions, tokenStart: start, tokenEnd: cursor };
85
+ }
86
+
87
+ /**
88
+ * Apply a chosen suggestion to the text + cursor, returning the new pair.
89
+ * Inserts an opening parenthesis after the function name and positions the
90
+ * cursor inside it — Excel's behavior on autocomplete acceptance.
91
+ */
92
+ export function applyFormulaSuggestion(
93
+ text: string,
94
+ suggestion: FormulaSuggestion,
95
+ range: { start: number; end: number },
96
+ ): { text: string; cursor: number } {
97
+ const inserted = `${suggestion.name}(`;
98
+ const newText = text.slice(0, range.start) + inserted + text.slice(range.end);
99
+ return { text: newText, cursor: range.start + inserted.length };
100
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseExcelFromZip } from "./excel_parser";
3
+
4
+ const enc = (s: string) => new TextEncoder().encode(s);
5
+
6
+ // Minimal two-sheet workbook: one visible, one hidden.
7
+ const WORKBOOK = `<?xml version="1.0"?>
8
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
9
+ <sheets>
10
+ <sheet name="Visible" sheetId="1" r:id="rId1"/>
11
+ <sheet name="Secret" sheetId="2" state="hidden" r:id="rId2"/>
12
+ </sheets>
13
+ </workbook>`;
14
+
15
+ const RELS = `<?xml version="1.0"?>
16
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
17
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
18
+ <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
19
+ </Relationships>`;
20
+
21
+ const sheetXml = (cell: string) => `<?xml version="1.0"?>
22
+ <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
23
+ <sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>${cell}</t></is></c></row></sheetData>
24
+ </worksheet>`;
25
+
26
+ describe("parseExcelFromZip — hidden sheets", () => {
27
+ const wb = parseExcelFromZip({
28
+ "xl/workbook.xml": enc(WORKBOOK),
29
+ "xl/_rels/workbook.xml.rels": enc(RELS),
30
+ "xl/worksheets/sheet1.xml": enc(sheetXml("public")),
31
+ "xl/worksheets/sheet2.xml": enc(sheetXml("classified")),
32
+ });
33
+
34
+ it("includes hidden sheets instead of dropping them", () => {
35
+ expect(wb.sheets.map((s) => s.name)).toEqual(["Visible", "Secret"]);
36
+ });
37
+
38
+ it("tags only the hidden sheet via the `hidden` flag", () => {
39
+ const visible = wb.sheets.find((s) => s.name === "Visible")!;
40
+ const secret = wb.sheets.find((s) => s.name === "Secret")!;
41
+ expect(visible.hidden).toBeFalsy();
42
+ expect(secret.hidden).toBe(true);
43
+ });
44
+
45
+ it("parses the hidden sheet's cell data", () => {
46
+ const secret = wb.sheets.find((s) => s.name === "Secret")!;
47
+ expect(secret.rows[0]?.cells[0]?.value).toBe("classified");
48
+ });
49
+ });
package/src/index.ts ADDED
@@ -0,0 +1,149 @@
1
+ // =============================================================================
2
+ // @lotics/xlsx — public barrel
3
+ // =============================================================================
4
+ //
5
+ // The curated public surface of the package. New code should import from
6
+ // this barrel; existing subpath imports continue to work for backward
7
+ // compatibility but are considered legacy.
8
+ //
9
+ // Internals NOT re-exported here (canvas renderer, OOXML parser internals,
10
+ // formula engine internals) remain reachable via subpath imports for
11
+ // embedders that explicitly need them, but they are not part of the
12
+ // stability contract.
13
+
14
+ // -----------------------------------------------------------------------------
15
+ // Types
16
+ // -----------------------------------------------------------------------------
17
+
18
+ export type {
19
+ ParsedSpreadsheet,
20
+ ParsedSheet,
21
+ ParsedCell,
22
+ ParsedColumn,
23
+ ParsedRow,
24
+ ParsedImage,
25
+ CellStyle,
26
+ BorderStyle,
27
+ CellContent,
28
+ RichTextPart,
29
+ RichTextFont,
30
+ GradientData,
31
+ MergedCellRange,
32
+ FreezePane,
33
+ PageSetup,
34
+ PrintTitles,
35
+ HeaderFooter,
36
+ } from "./types";
37
+
38
+ export { PasswordProtectedError } from "./types";
39
+
40
+ // -----------------------------------------------------------------------------
41
+ // Workbook model & cell storage
42
+ // -----------------------------------------------------------------------------
43
+
44
+ export {
45
+ WorkbookModel,
46
+ SheetModel,
47
+ StyleRegistry,
48
+ CellMap,
49
+ loadWorkbookFromSnapshot,
50
+ materializeDisplayValue,
51
+ } from "./workbook_model";
52
+
53
+ export type { CellModel, CellValue, ChangeEvent } from "./workbook_model";
54
+
55
+ // -----------------------------------------------------------------------------
56
+ // Color utilities — canonical `#RRGGBB` everywhere
57
+ // -----------------------------------------------------------------------------
58
+
59
+ export {
60
+ normalizeColor,
61
+ hexToArgb,
62
+ hexToOoxmlRgb,
63
+ prefixHex,
64
+ applyTint,
65
+ } from "./ooxml_color";
66
+
67
+ // -----------------------------------------------------------------------------
68
+ // Cell reference / coordinate utilities
69
+ // -----------------------------------------------------------------------------
70
+
71
+ export {
72
+ refToRowCol,
73
+ rowColToRef,
74
+ colLettersToNum,
75
+ colNumToLetters,
76
+ parseNameBoxInput,
77
+ buildNamedRangeExpr,
78
+ packRef,
79
+ unpackRef,
80
+ packRowCol,
81
+ unpackRowCol,
82
+ } from "./excel_utils";
83
+
84
+ export type { ParsedInput } from "./excel_utils";
85
+
86
+ // -----------------------------------------------------------------------------
87
+ // Style helpers
88
+ // -----------------------------------------------------------------------------
89
+
90
+ export { allBorders, bottomBorder, topBorder } from "./style_helpers";
91
+
92
+ // -----------------------------------------------------------------------------
93
+ // I/O — parse from .xlsx, export to .xlsx
94
+ // -----------------------------------------------------------------------------
95
+
96
+ export {
97
+ parseExcelBuffer,
98
+ parseExcelFromZip,
99
+ } from "./excel_parser";
100
+
101
+ export { exportWorkbook } from "./xlsx_writer";
102
+
103
+ export { unzipXlsx } from "./ooxml_zip";
104
+
105
+ // -----------------------------------------------------------------------------
106
+ // Spreadsheet commands (mutations with undo/redo support)
107
+ // -----------------------------------------------------------------------------
108
+
109
+ export {
110
+ setCellValueCommand,
111
+ setCellStyleCommand,
112
+ setRangeStyleCommand,
113
+ setRangeNumFmtCommand,
114
+ deleteCellCommand,
115
+ clearRangeCommand,
116
+ mergeCellsCommand,
117
+ unmergeCellsCommand,
118
+ insertRowsCommand,
119
+ deleteRowsCommand,
120
+ insertColsCommand,
121
+ deleteColsCommand,
122
+ setColWidthCommand,
123
+ setRowHeightCommand,
124
+ setNamedRangeCommand,
125
+ addSheetCommand,
126
+ deleteSheetCommand,
127
+ renameSheetCommand,
128
+ batchCommand,
129
+ setHiddenRowsCommand,
130
+ setHiddenColsCommand,
131
+ sortColumnCommand,
132
+ multiSortCommand,
133
+ setFreezePanesCommand,
134
+ setZoomCommand,
135
+ addConditionalFormatCommand,
136
+ deleteConditionalFormatCommand,
137
+ reorderSheetsCommand,
138
+ insertCellsShiftDownCommand,
139
+ // Merge-range and formula-range adjusters used by the placeholder engine.
140
+ // Public so external template engines can call them; the row/col commands
141
+ // call them internally too.
142
+ adjustMergedCellsForRowInsert,
143
+ adjustMergedCellsForRowDelete,
144
+ adjustMergedCellsForColInsert,
145
+ adjustMergedCellsForColDelete,
146
+ expandFormulaRangesForRowInsert,
147
+ } from "./spreadsheet_commands";
148
+
149
+ export type { SortSpec } from "./spreadsheet_commands";
@@ -0,0 +1,394 @@
1
+ import { SelectionModel } from "./selection_model";
2
+ import { CommandHistory } from "./command_history";
3
+ import { WorkbookModel, SheetModel, StyleRegistry } from "./workbook_model";
4
+ import { handleKeyDown, type KeyboardNavContext } from "./keyboard_nav";
5
+ import type { ClipboardData } from "./clipboard";
6
+
7
+ // =============================================================================
8
+ // Helpers
9
+ // =============================================================================
10
+
11
+ function makeWorkbook(): WorkbookModel {
12
+ const styles = new StyleRegistry();
13
+ const sheet = new SheetModel("Sheet1");
14
+ // Add some test data
15
+ sheet.set("A1", "Hello");
16
+ sheet.set("B1", 42);
17
+ sheet.set("A2", "World");
18
+ sheet.set("C3", 100);
19
+ return new WorkbookModel({
20
+ sheets: [sheet],
21
+ activeSheetIndex: 0,
22
+ styles,
23
+ });
24
+ }
25
+
26
+ function makeContext(overrides?: Partial<KeyboardNavContext>): KeyboardNavContext {
27
+ const workbook = overrides?.workbook ?? makeWorkbook();
28
+ const selection = overrides?.selection ?? new SelectionModel(100, 26);
29
+ return {
30
+ selection,
31
+ workbook,
32
+ sheetIndex: 0,
33
+ history: new CommandHistory(),
34
+ engine: undefined,
35
+ clipboardData: undefined,
36
+ setClipboardData: () => {},
37
+ onStartEdit: () => {},
38
+ onRedraw: () => {},
39
+ pageSize: 20,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function makeKey(key: string, opts?: {
45
+ ctrlKey?: boolean;
46
+ metaKey?: boolean;
47
+ shiftKey?: boolean;
48
+ altKey?: boolean;
49
+ }): KeyboardEvent {
50
+ // Use a plain object — no DOM KeyboardEvent constructor in Node
51
+ return {
52
+ key,
53
+ ctrlKey: opts?.ctrlKey ?? false,
54
+ metaKey: opts?.metaKey ?? false,
55
+ shiftKey: opts?.shiftKey ?? false,
56
+ altKey: opts?.altKey ?? false,
57
+ } as unknown as KeyboardEvent;
58
+ }
59
+
60
+ // =============================================================================
61
+ // Tests
62
+ // =============================================================================
63
+
64
+ describe("handleKeyDown", () => {
65
+ describe("arrow key navigation", () => {
66
+ test("ArrowDown moves active cell down", () => {
67
+ const ctx = makeContext();
68
+ const handled = handleKeyDown(makeKey("ArrowDown"), ctx);
69
+ expect(handled).toBe(true);
70
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
71
+ });
72
+
73
+ test("ArrowUp moves active cell up", () => {
74
+ const ctx = makeContext();
75
+ ctx.selection.click(3, 3);
76
+ const handled = handleKeyDown(makeKey("ArrowUp"), ctx);
77
+ expect(handled).toBe(true);
78
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 3 });
79
+ });
80
+
81
+ test("ArrowRight moves active cell right", () => {
82
+ const ctx = makeContext();
83
+ const handled = handleKeyDown(makeKey("ArrowRight"), ctx);
84
+ expect(handled).toBe(true);
85
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
86
+ });
87
+
88
+ test("ArrowLeft moves active cell left", () => {
89
+ const ctx = makeContext();
90
+ ctx.selection.click(1, 3);
91
+ const handled = handleKeyDown(makeKey("ArrowLeft"), ctx);
92
+ expect(handled).toBe(true);
93
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
94
+ });
95
+
96
+ test("Shift+Arrow extends selection", () => {
97
+ const ctx = makeContext();
98
+ ctx.selection.click(3, 3);
99
+ handleKeyDown(makeKey("ArrowDown", { shiftKey: true }), ctx);
100
+ const range = ctx.selection.getState().ranges[0];
101
+ expect(range.start).toEqual({ row: 3, col: 3 });
102
+ expect(range.end).toEqual({ row: 4, col: 3 });
103
+ });
104
+ });
105
+
106
+ describe("Tab and Enter", () => {
107
+ test("Tab moves right", () => {
108
+ const ctx = makeContext();
109
+ const handled = handleKeyDown(makeKey("Tab"), ctx);
110
+ expect(handled).toBe(true);
111
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
112
+ });
113
+
114
+ test("Shift+Tab moves left", () => {
115
+ const ctx = makeContext();
116
+ ctx.selection.click(1, 3);
117
+ handleKeyDown(makeKey("Tab", { shiftKey: true }), ctx);
118
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
119
+ });
120
+
121
+ test("Enter moves down", () => {
122
+ const ctx = makeContext();
123
+ const handled = handleKeyDown(makeKey("Enter"), ctx);
124
+ expect(handled).toBe(true);
125
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
126
+ });
127
+
128
+ test("Shift+Enter moves up", () => {
129
+ const ctx = makeContext();
130
+ ctx.selection.click(3, 1);
131
+ handleKeyDown(makeKey("Enter", { shiftKey: true }), ctx);
132
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
133
+ });
134
+ });
135
+
136
+ describe("Home key", () => {
137
+ test("Home moves to column 1", () => {
138
+ const ctx = makeContext();
139
+ ctx.selection.click(5, 10);
140
+ handleKeyDown(makeKey("Home"), ctx);
141
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 5, col: 1 });
142
+ });
143
+
144
+ test("Ctrl+Home moves to A1", () => {
145
+ const ctx = makeContext();
146
+ ctx.selection.click(5, 10);
147
+ handleKeyDown(makeKey("Home", { ctrlKey: true }), ctx);
148
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 1 });
149
+ });
150
+ });
151
+
152
+ describe("Page Up/Down", () => {
153
+ test("PageDown moves by pageSize rows", () => {
154
+ const ctx = makeContext({ pageSize: 10 });
155
+ const handled = handleKeyDown(makeKey("PageDown"), ctx);
156
+ expect(handled).toBe(true);
157
+ expect(ctx.selection.getState().activeCell.row).toBe(11);
158
+ });
159
+
160
+ test("PageUp moves up by pageSize rows", () => {
161
+ const ctx = makeContext({ pageSize: 10 });
162
+ ctx.selection.click(20, 1);
163
+ handleKeyDown(makeKey("PageUp"), ctx);
164
+ expect(ctx.selection.getState().activeCell.row).toBe(10);
165
+ });
166
+ });
167
+
168
+ describe("Delete and Backspace", () => {
169
+ test("Delete clears cell value via command", () => {
170
+ const ctx = makeContext();
171
+ ctx.selection.click(1, 1); // A1 has "Hello"
172
+ handleKeyDown(makeKey("Delete"), ctx);
173
+ expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
174
+ });
175
+
176
+ test("Backspace clears cell and starts edit", () => {
177
+ const editCalls: string[] = [];
178
+ const ctx = makeContext({
179
+ onStartEdit: (text) => editCalls.push(text ?? ""),
180
+ });
181
+ ctx.selection.click(1, 1);
182
+ handleKeyDown(makeKey("Backspace"), ctx);
183
+ expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
184
+ expect(editCalls).toContain("");
185
+ });
186
+ });
187
+
188
+ describe("F2 — edit mode", () => {
189
+ test("F2 starts edit with existing cell value", () => {
190
+ const editCalls: string[] = [];
191
+ const ctx = makeContext({
192
+ onStartEdit: (text) => editCalls.push(text ?? ""),
193
+ });
194
+ ctx.selection.click(1, 1); // A1 = "Hello"
195
+ handleKeyDown(makeKey("F2"), ctx);
196
+ expect(editCalls).toContain("Hello");
197
+ });
198
+
199
+ test("F2 on formula cell shows = prefix", () => {
200
+ const workbook = makeWorkbook();
201
+ workbook.sheets[0].set("D1", null, undefined, undefined, "SUM(A1:B1)");
202
+ const editCalls: string[] = [];
203
+ const ctx = makeContext({
204
+ workbook,
205
+ onStartEdit: (text) => editCalls.push(text ?? ""),
206
+ });
207
+ ctx.selection.click(1, 4); // D1
208
+ handleKeyDown(makeKey("F2"), ctx);
209
+ expect(editCalls).toContain("=SUM(A1:B1)");
210
+ });
211
+ });
212
+
213
+ describe("printable character starts edit", () => {
214
+ test("typing a letter starts edit", () => {
215
+ const editCalls: string[] = [];
216
+ const ctx = makeContext({
217
+ onStartEdit: (text) => editCalls.push(text ?? ""),
218
+ });
219
+ handleKeyDown(makeKey("a"), ctx);
220
+ expect(editCalls).toContain("a");
221
+ });
222
+
223
+ test("typing a digit starts edit", () => {
224
+ const editCalls: string[] = [];
225
+ const ctx = makeContext({
226
+ onStartEdit: (text) => editCalls.push(text ?? ""),
227
+ });
228
+ handleKeyDown(makeKey("5"), ctx);
229
+ expect(editCalls).toContain("5");
230
+ });
231
+
232
+ test("ctrl+key is not printable", () => {
233
+ const editCalls: string[] = [];
234
+ const ctx = makeContext({
235
+ onStartEdit: (text) => editCalls.push(text ?? ""),
236
+ });
237
+ handleKeyDown(makeKey("a", { ctrlKey: true }), ctx);
238
+ // Ctrl+A is select all, not printable edit
239
+ expect(editCalls).toHaveLength(0);
240
+ });
241
+ });
242
+
243
+ describe("Ctrl+Z/Y — undo/redo", () => {
244
+ test("Ctrl+Z undoes last command", () => {
245
+ const ctx = makeContext();
246
+ ctx.selection.click(1, 1);
247
+ // Delete cell via command
248
+ handleKeyDown(makeKey("Delete"), ctx);
249
+ expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
250
+ // Undo
251
+ handleKeyDown(makeKey("z", { ctrlKey: true }), ctx);
252
+ expect(ctx.workbook.sheets[0].getCell("A1")).toBeDefined();
253
+ });
254
+
255
+ test("Ctrl+Y redoes undone command", () => {
256
+ const ctx = makeContext();
257
+ ctx.selection.click(1, 1);
258
+ handleKeyDown(makeKey("Delete"), ctx);
259
+ handleKeyDown(makeKey("z", { ctrlKey: true }), ctx);
260
+ expect(ctx.workbook.sheets[0].getCell("A1")).toBeDefined();
261
+ handleKeyDown(makeKey("y", { ctrlKey: true }), ctx);
262
+ expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
263
+ });
264
+
265
+ test("Ctrl+Shift+Z redoes", () => {
266
+ const ctx = makeContext();
267
+ ctx.selection.click(1, 1);
268
+ handleKeyDown(makeKey("Delete"), ctx);
269
+ handleKeyDown(makeKey("z", { ctrlKey: true }), ctx);
270
+ handleKeyDown(makeKey("z", { ctrlKey: true, shiftKey: true }), ctx);
271
+ expect(ctx.workbook.sheets[0].getCell("A1")).toBeUndefined();
272
+ });
273
+ });
274
+
275
+ describe("Ctrl+C/X/V — clipboard", () => {
276
+ test("Ctrl+C copies selection to clipboard buffer", () => {
277
+ let storedClipboard: ClipboardData | undefined;
278
+ const ctx = makeContext({
279
+ setClipboardData: (d) => { storedClipboard = d; },
280
+ });
281
+ ctx.selection.click(1, 1);
282
+ handleKeyDown(makeKey("c", { ctrlKey: true }), ctx);
283
+ expect(storedClipboard).toBeDefined();
284
+ expect(storedClipboard!.cells[0][0]?.displayValue).toBe("Hello");
285
+ expect(storedClipboard!.isCut).toBe(false);
286
+ });
287
+
288
+ test("Ctrl+X sets isCut flag", () => {
289
+ let storedClipboard: ClipboardData | undefined;
290
+ const ctx = makeContext({
291
+ setClipboardData: (d) => { storedClipboard = d; },
292
+ });
293
+ ctx.selection.click(1, 1);
294
+ handleKeyDown(makeKey("x", { ctrlKey: true }), ctx);
295
+ expect(storedClipboard!.isCut).toBe(true);
296
+ });
297
+ });
298
+
299
+ describe("Ctrl+B/I/U — style toggling", () => {
300
+ test("Ctrl+B toggles bold", () => {
301
+ const ctx = makeContext();
302
+ ctx.selection.click(1, 1);
303
+ handleKeyDown(makeKey("b", { ctrlKey: true }), ctx);
304
+ const cell = ctx.workbook.sheets[0].getCell("A1");
305
+ expect(cell).toBeDefined();
306
+ const style = ctx.workbook.styles.get(cell!.styleIndex);
307
+ expect(style.fontBold).toBe(true);
308
+ });
309
+
310
+ test("Ctrl+I toggles italic", () => {
311
+ const ctx = makeContext();
312
+ ctx.selection.click(1, 1);
313
+ handleKeyDown(makeKey("i", { ctrlKey: true }), ctx);
314
+ const cell = ctx.workbook.sheets[0].getCell("A1");
315
+ const style = ctx.workbook.styles.get(cell!.styleIndex);
316
+ expect(style.fontItalic).toBe(true);
317
+ });
318
+ });
319
+
320
+ describe("Ctrl+A — select all", () => {
321
+ test("selects used range", () => {
322
+ const ctx = makeContext();
323
+ handleKeyDown(makeKey("a", { ctrlKey: true }), ctx);
324
+ const range = ctx.selection.getState().ranges[0];
325
+ // Used range is A1:C3 based on test data
326
+ expect(range.start).toEqual({ row: 1, col: 1 });
327
+ expect(range.end).toEqual({ row: 3, col: 3 });
328
+ });
329
+ });
330
+
331
+ describe("editing mode keys", () => {
332
+ test("Enter during edit commits and moves down", () => {
333
+ const ctx = makeContext();
334
+ ctx.selection.startEdit("test");
335
+ expect(ctx.selection.getState().editing).toBe(true);
336
+ handleKeyDown(makeKey("Enter"), ctx);
337
+ expect(ctx.selection.getState().editing).toBe(false);
338
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 2, col: 1 });
339
+ });
340
+
341
+ test("Tab during edit commits and moves right", () => {
342
+ const ctx = makeContext();
343
+ ctx.selection.startEdit("test");
344
+ handleKeyDown(makeKey("Tab"), ctx);
345
+ expect(ctx.selection.getState().editing).toBe(false);
346
+ expect(ctx.selection.getState().activeCell).toEqual({ row: 1, col: 2 });
347
+ });
348
+
349
+ test("Escape during edit cancels", () => {
350
+ const ctx = makeContext();
351
+ ctx.selection.startEdit("test");
352
+ handleKeyDown(makeKey("Escape"), ctx);
353
+ expect(ctx.selection.getState().editing).toBe(false);
354
+ });
355
+
356
+ test("regular key during edit returns false (input handles it)", () => {
357
+ const ctx = makeContext();
358
+ ctx.selection.startEdit("test");
359
+ const handled = handleKeyDown(makeKey("a"), ctx);
360
+ expect(handled).toBe(false);
361
+ });
362
+ });
363
+
364
+ describe("Ctrl+Arrow — jump to data edge", () => {
365
+ test("Ctrl+ArrowDown jumps to last data cell in column", () => {
366
+ const ctx = makeContext();
367
+ ctx.selection.click(1, 1); // A1 has data
368
+ handleKeyDown(makeKey("ArrowDown", { ctrlKey: true }), ctx);
369
+ // A1="Hello", A2="World", A3 is empty → should jump to A2
370
+ expect(ctx.selection.getState().activeCell.row).toBe(2);
371
+ });
372
+
373
+ test("Ctrl+ArrowRight jumps to last data cell in row", () => {
374
+ const ctx = makeContext();
375
+ ctx.selection.click(1, 1); // Row 1: A1, B1 have data
376
+ handleKeyDown(makeKey("ArrowRight", { ctrlKey: true }), ctx);
377
+ expect(ctx.selection.getState().activeCell.col).toBe(2); // B1
378
+ });
379
+ });
380
+
381
+ describe("unhandled keys", () => {
382
+ test("Alt key returns false", () => {
383
+ const ctx = makeContext();
384
+ const handled = handleKeyDown(makeKey("a", { altKey: true }), ctx);
385
+ expect(handled).toBe(false);
386
+ });
387
+
388
+ test("Escape outside editing returns false", () => {
389
+ const ctx = makeContext();
390
+ const handled = handleKeyDown(makeKey("Escape"), ctx);
391
+ expect(handled).toBe(false);
392
+ });
393
+ });
394
+ });