Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src demo",
"test": "vitest run",
"test:engine": "tsx --test \"tests/engine/test_*.ts\"",
"test:engine": "tsx --test \"tests/**/test_*.ts\"",
"test:watch": "vitest",
"test:fixtures": "tsx tests/engine/run-all-fixtures.ts",
"test:calculator": "tsx tests/engine/run-calculator-tests.ts",
Expand Down
87 changes: 13 additions & 74 deletions src/vue/View.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ import {
columnToIndex,
indexToColumn,
} from "../engine";
import { applyCellHighlights, clearCellHighlights } from "./cellHighlights";
import { getArrowKeyOffset, isWithinSheetBounds } from "./keyboardNav";

// Import all spreadsheet functions to populate the function registry
import "../engine/functions";
Expand Down Expand Up @@ -752,47 +754,13 @@ watch(
watch(
[miniEditorOpen, miniEditorCell, referencedCells, renderedHtml],
() => {
// Remove previous highlights
const prevEditingCell =
tableContainer.value?.querySelector(".cell-editing");
if (prevEditingCell) {
prevEditingCell.classList.remove("cell-editing");
}

const prevReferencedCells =
tableContainer.value?.querySelectorAll(".cell-referenced");
if (prevReferencedCells) {
prevReferencedCells.forEach((cell) =>
cell.classList.remove("cell-referenced"),
);
}

if (miniEditorOpen.value && tableContainer.value) {
const table = tableContainer.value.querySelector("#spreadsheet-table");
if (table) {
// Highlight the selected cell
if (miniEditorCell.value) {
const row = table.querySelectorAll("tr")[miniEditorCell.value.row];
if (row) {
const cell = row.querySelectorAll("td")[miniEditorCell.value.col];
if (cell) {
cell.classList.add("cell-editing");
}
}
}

// Highlight referenced cells
for (const ref of referencedCells.value) {
const row = table.querySelectorAll("tr")[ref.row];
if (row) {
const cell = row.querySelectorAll("td")[ref.col];
if (cell) {
cell.classList.add("cell-referenced");
}
}
}
}
}
clearCellHighlights(tableContainer.value);
if (!miniEditorOpen.value) return;
applyCellHighlights(
tableContainer.value,
miniEditorCell.value,
referencedCells.value,
);
},
{ flush: "post" },
);
Expand All @@ -813,50 +781,21 @@ function handleKeyboardNavigation(event: KeyboardEvent) {
}

const { row, col } = miniEditorCell.value;
let newRow = row;
let newCol = col;

// Determine new position based on arrow key
switch (event.key) {
case "ArrowUp":
newRow = Math.max(0, row - 1);
break;
case "ArrowDown":
newRow = row + 1;
break;
case "ArrowLeft":
newCol = Math.max(0, col - 1);
break;
case "ArrowRight":
newCol = col + 1;
break;
default:
return; // Not an arrow key, ignore
}
const nextCell = getArrowKeyOffset(event.key, row, col);
if (!nextCell) return; // Not an arrow key, ignore

// Get current sheet data to validate bounds
try {
const sheets = JSON.parse(editableData.value);
const currentSheet = sheets[activeSheetIndex.value];

if (!currentSheet || !currentSheet.data) return;

// Validate new position is within bounds
if (
newRow < 0 ||
newRow >= currentSheet.data.length ||
newCol < 0 ||
!currentSheet.data[newRow] ||
newCol >= currentSheet.data[newRow].length
) {
if (!isWithinSheetBounds(sheets[activeSheetIndex.value], nextCell.row, nextCell.col)) {
return; // Out of bounds, ignore
}

// Prevent default scrolling behavior
event.preventDefault();

// Move to new cell
openMiniEditor(newRow, newCol);
openMiniEditor(nextCell.row, nextCell.col);
} catch (error) {
console.error("Failed to navigate cells:", error);
}
Expand Down
78 changes: 78 additions & 0 deletions src/vue/cellHighlights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* DOM helpers for the mini-editor cell-highlight pass. Extracted from
* the post-flush watch in `src/vue/View.vue`, which
* had a cognitive complexity of 30 driven by four levels of nested
* optional chaining + loops.
*
* These helpers are side-effectful by nature (they add/remove CSS
* classes on DOM nodes) but each one is small enough that its
* behaviour is obvious. Unit-testable with a minimal mock DOM; see
* `tests/vue/test_cellHighlights.ts`.
*/

/** Minimal DOM surface the helpers need. Defined here so tests can
* pass plain objects without pulling in jsdom. */
export interface HighlightableElement {
classList: { add: (cls: string) => void; remove: (cls: string) => void };
}

export interface HighlightableRow {
querySelectorAll: (selector: string) => ArrayLike<HighlightableElement>;
}

export interface HighlightableTable {
querySelectorAll: (selector: string) => ArrayLike<HighlightableRow>;
}

export interface HighlightableContainer {
// Overload: the spreadsheet root container is known to return a
// table when asked for the table id, so callers can keep the
// result strongly typed without casting.
querySelector: ((selector: "#spreadsheet-table") => HighlightableTable | null) & ((selector: string) => HighlightableElement | null);
querySelectorAll: (selector: string) => ArrayLike<HighlightableElement> & Iterable<HighlightableElement>;
}

export interface CellCoord {
row: number;
col: number;
}

const CELL_EDITING = "cell-editing";
const CELL_REFERENCED = "cell-referenced";

/** Remove both kinds of highlight classes from the container. */
export function clearCellHighlights(container: HighlightableContainer | null | undefined): void {
if (!container) return;
container.querySelector(`.${CELL_EDITING}`)?.classList.remove(CELL_EDITING);
for (const cell of container.querySelectorAll(`.${CELL_REFERENCED}`)) {
cell.classList.remove(CELL_REFERENCED);
}
}

/** Add `className` to the <td> at (row, col) of the given table.
* No-op if the row or cell doesn't exist. */
export function highlightCell(table: HighlightableTable | null | undefined, coord: CellCoord, className: string): void {
if (!table) return;
const rows = table.querySelectorAll("tr");
const row = rows[coord.row];
if (!row) return;
const cells = row.querySelectorAll("td");
const cell = cells[coord.col];
if (!cell) return;
cell.classList.add(className);
}

/** Apply the editing cell + referenced cells highlights. Looks up
* the #spreadsheet-table inside the container and no-ops if the
* table hasn't rendered yet. */
export function applyCellHighlights(
container: HighlightableContainer | null | undefined,
editingCell: CellCoord | null,
references: readonly CellCoord[],
): void {
if (!container) return;
const table = container.querySelector("#spreadsheet-table");
if (!table) return;
if (editingCell) highlightCell(table, editingCell, CELL_EDITING);
for (const ref of references) highlightCell(table, ref, CELL_REFERENCED);
}
38 changes: 38 additions & 0 deletions src/vue/keyboardNav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Pure helpers behind the spreadsheet mini-editor's arrow-key
// navigation. Lifted out of View.vue so each rule can be unit-tested
// without spinning up Vue or a DOM.

export interface CellPosition {
row: number;
col: number;
}

// Sheet shape we actually rely on — the mini-editor only reads
// `data` as a 2D array, so the type is intentionally loose to match
// what arrives from `JSON.parse(editableData.value)`.
export interface SheetLike {
data?: unknown[][];
}

export function getArrowKeyOffset(key: string, row: number, col: number): CellPosition | null {
switch (key) {
case "ArrowUp":
return { row: Math.max(0, row - 1), col };
case "ArrowDown":
return { row: row + 1, col };
case "ArrowLeft":
return { row, col: Math.max(0, col - 1) };
case "ArrowRight":
return { row, col: col + 1 };
default:
return null;
}
}

export function isWithinSheetBounds(sheet: SheetLike | null | undefined, row: number, col: number): boolean {
if (!sheet?.data) return false;
if (row < 0 || row >= sheet.data.length) return false;
const rowData = sheet.data[row];
if (!rowData) return false;
return col >= 0 && col < rowData.length;
}
Loading
Loading