Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(sheets): make grid keyboard-reachable via Tab (#698)

Closes #698

scott e14e769b 6504e942

+253 -3
+1
CHANGELOG.md
··· 14 14 - Docs: TipTap v3 StarterKit config no longer emits duplicate-extension and Collaboration/UndoRedo-conflict warnings (v0.62.5, #710). PR #409 (the v2→v3 migration for #692) passed `history: false` to StarterKit.configure, but v3 renamed the History extension to UndoRedo — `history: false` is a silent no-op, so StarterKit's UndoRedo stayed active and fought Yjs for undo state. StarterKit v3 also vendors Link and Underline, so our explicit `@tiptap/extension-link` + `@tiptap/extension-underline` imports registered duplicates of those extensions. Updated `src/docs/main.ts` to use `StarterKit.configure({ undoRedo: false, codeBlock: false, link: { openOnClick: false } })` and removed the explicit Link/Underline imports. Dropped the two packages from package.json. Added `tests/tiptap-v3-no-warnings.test.ts` (2 tests: jsdom editor-construction with zero TipTap warnings, plus static scan of src/docs/main.ts for deprecated v2 patterns). Caught live by inspecting the browser console on a deployed doc. (#710) 15 15 - Calendar: mini-calendar date cells now expose a full spoken date via `aria-label` (v0.62.3, #696). Previously the `.cal-mini-day` buttons contained only the day number as text, so screen readers announced "button, 29" with no month/year/weekday context — unusable for non-sighted navigation. `renderMiniCalendar` now builds an `aria-label` like `"Tuesday, March 29, 2026"` (plus `, today`, `, selected`, and `, N events` suffixes when applicable) using a new shared `formatLongDate(d)` helper in `src/calendar/helpers.ts` that wraps `toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })`. The today cell also carries `aria-current="date"` and the selected cell carries `aria-pressed="true"` so state is conveyed through standard AT semantics rather than class names alone. 4 regression tests in `tests/calendar-mini-day-aria.test.ts` pin the helper output and verify every emitted `.cal-mini-day` button tag carries an `aria-label=` attribute. (#696) 16 16 - CSP: externalized 3 inline scripts from every page template so the app's `script-src 'self'` CSP stops silently blocking them (v0.62.2, #694). Theme init (FOUC prevention, reads localStorage before paint), theme-toggle click handler, and service-worker update→reload handler now live in `public/theme-init.js`, `public/theme-toggle.js`, and `public/sw-reload.js` respectively, loaded via `<script src="...">` so they satisfy the strict CSP without needing nonces or `unsafe-inline`. Before: the theme toggle button did nothing, dark-mode users saw a flash of light theme on every page load, and users never auto-reloaded when a new version deployed. All 7 HTML templates (landing + 6 editors) had the inline blocks; all 7 are now externalized. Added `tests/csp-no-inline-scripts.test.ts` which scans every template for inline `<script>` blocks and fails if any are reintroduced. Caught live by driving the deployed v0.62.1 app via Playwright MCP. (#694) 17 + - Sheets: the grid is now keyboard-reachable via Tab (v0.62.6, #698). Previously `<table class="sheet-grid">` had no tabindex, so keyboard users could tab through the toolbar and formula bar but never reach the cell grid itself. Added `wireGridFocus(grid)` in `src/sheets/keyboard-handler.ts` which sets `tabindex=0`; added a `.sheet-grid:focus` visible ring in `src/css/app.css`; and updated the Tab handler so Shift+Tab while the grid is focused lets the browser escape focus back to the toolbar instead of being hijacked as selection-navigation. 7 regression tests in `tests/sheets-grid-focus.test.ts`. (#698) 17 18 - Sheets: first printable keystroke against an empty cell no longer duplicates the character (v0.62.1, #693). The grid's keydown handler for printable chars in `src/sheets/keyboard-handler.ts` entered edit mode and set `editor.value = key` but never called `e.preventDefault()`, so the browser's native keypress/input pipeline on the now-focused cell-editor input also inserted the same character — pressing `5` produced `55`, pressing `1`→`0`→Enter produced `110`. Added the missing `preventDefault()` plus a targeted regression test in `tests/sheets-keyboard-handler.test.ts` (3 new tests: preventDefault invariant, single-char-not-doubled invariant, and the pre-existing Cmd+key-skip invariant). Caught live by driving the deployed app in a real browser via Playwright MCP during TipTap v3 post-ship smoke testing. (#693) 18 19 19 20 ### Changed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.62.5", 3 + "version": "0.62.6", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+15
src/css/app.css
··· 2596 2596 width: max-content; 2597 2597 } 2598 2598 2599 + /* 2600 + * #698 — keyboard focus indicator for the grid itself. 2601 + * The grid table carries tabindex=0 so Tab from the toolbar / formula bar 2602 + * lands here. The native outline would draw around the entire table body 2603 + * (visually noisy); the selected cell already has its own teal outline 2604 + * (`.sheet-grid td.selected`), so the grid-level focus ring is scoped to 2605 + * keyboard-only activation (data-a11y-focus is set on Tab-press by the 2606 + * inline script in index.html). 2607 + */ 2608 + .sheet-grid:focus { outline: none; } 2609 + [data-a11y-focus] .sheet-grid:focus-visible { 2610 + outline: 2px solid var(--color-teal); 2611 + outline-offset: -2px; 2612 + } 2613 + 2599 2614 .sheet-grid th { 2600 2615 background: var(--color-surface); 2601 2616 border: none;
+35 -1
src/sheets/keyboard-handler.ts
··· 53 53 DEFAULT_ROWS: number; 54 54 } 55 55 56 + // ── Grid focus wiring ─────────────────────────────────────── 57 + 58 + /** 59 + * Make the grid itself a keyboard tab stop so users can Tab into it from 60 + * the toolbar / formula bar and get a visible focus indicator on the 61 + * currently-selected cell. 62 + * 63 + * Regression: #698 — without `tabindex=0`, `<table class="sheet-grid">` is 64 + * not focusable, so keyboard users Tab through the toolbar and formula bar 65 + * but never reach the grid body and get stuck. 66 + * 67 + * The existing global keydown handler (see `wireKeyboardHandler`) already 68 + * routes arrow keys to `moveSelection()` when `document.activeElement` is 69 + * the grid (it falls through the formula-input / doc-title / find-bar / 70 + * ai-chat-sidebar guards), so no additional event wiring is needed — only 71 + * the `tabindex` attribute plus the matching `:focus` ring in app.css. 72 + * 73 + * Call once during sheets initialization, before `wireKeyboardHandler`. 74 + */ 75 + export function wireGridFocus(grid: HTMLElement): void { 76 + grid.setAttribute('tabindex', '0'); 77 + } 78 + 56 79 // ── Keyboard listener ─────────────────────────────────────── 57 80 58 81 /** ··· 133 156 deps.setSelectionRange({ startCol: deps.getSelectedCell().col, startRow: 1, endCol: deps.getSelectedCell().col, endRow: maxRow }); 134 157 deps.updateSelectionVisuals(); 135 158 } 136 - else if (key === 'Tab') { e.preventDefault(); deps.moveSelection(e.shiftKey ? -1 : 1, 0); } 159 + else if (key === 'Tab') { 160 + // #698 — when the grid itself is focused (user Tabbed in from the 161 + // toolbar / formula bar), Shift+Tab should let the browser move focus 162 + // BACK out to the previous focusable (toolbar, formula bar) instead 163 + // of being hijacked as selection-navigation. Without this escape 164 + // hatch, keyboard users who Tab into the grid can never Tab back out. 165 + if (e.shiftKey && document.activeElement?.classList?.contains('sheet-grid')) { 166 + return; 167 + } 168 + e.preventDefault(); 169 + deps.moveSelection(e.shiftKey ? -1 : 1, 0); 170 + } 137 171 else if (key === 'Enter') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); } 138 172 else if (key === 'Delete' || key === 'Backspace') { e.preventDefault(); deps.deleteSelectedCells(); } 139 173 else if (key === 'F2') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); }
+2 -1
src/sheets/main.ts
··· 22 22 import { updateFormulaHighlight as _updateFormulaHighlight, updateFormulaRangeHighlights as _updateFormulaRangeHighlights, updateFormulaTooltip, onFormulaInputUpdate as _onFormulaInputUpdate, commitFormulaBar as _commitFormulaBar, wireFormulaBarKeys as _wireFormulaBarKeys, refreshVisibleCells as _refreshVisibleCells } from './formula-bar-ui.js'; 23 23 import { hideAutocomplete as _hideAutocomplete, attachCellEditorAutocomplete as _attachCellEditorAutocomplete, wireAutocomplete as _wireAutocomplete } from './formula-autocomplete-ui.js'; 24 24 import { wireShortcutButton } from './shortcuts-modal.js'; 25 - import { wireKeyboardHandler as _wireKeyboardHandler } from './keyboard-handler.js'; 25 + import { wireKeyboardHandler as _wireKeyboardHandler, wireGridFocus as _wireGridFocus } from './keyboard-handler.js'; 26 26 import { moveSelection as _moveSelection, extendSelection as _extendSelection, moveSelectionTo as _moveSelectionTo, getDataExtent as _getDataExtent, scrollCellIntoView as _scrollCellIntoView, updateSelectionVisuals as _updateSelectionVisuals, clearPrevSelection as _clearPrevSelection, getCellEl as _getCellEl } from './selection-navigation.js'; 27 27 import { deleteSelectedCells as _deleteSelectedCells, copySelection as _copySelection, pasteRowsAtSelection as _pasteRowsAtSelection, pasteAtSelection as _pasteAtSelection, showPasteSpecialDialog as _showPasteSpecialDialogCO, wirePasteListener as _wirePasteListener } from './clipboard-operations.js'; 28 28 import { wireSaveStatus } from '../lib/save-status-ui.js'; ··· 465 465 // ── Wire everything ──────────────────────────────────────── 466 466 _wireTouchDoubleTap(_touchEventsDeps()); 467 467 468 + _wireGridFocus(grid); 468 469 _wireKeyboardHandler({ 469 470 grid, formulaInput, sheetContainer, provider, 470 471 getSelectedCell, setSelectedCell,
+199
tests/sheets-grid-focus.test.ts
··· 1 + /** 2 + * @vitest-environment jsdom 3 + * 4 + * Regression coverage for #698 — Sheets grid must be keyboard-reachable. 5 + * 6 + * Before this fix, `.sheet-grid` had no `tabindex`, so it was unreachable via 7 + * Tab from the toolbar/formula bar. Keyboard users could Tab through the 8 + * toolbar and formula input but never land on the grid body; they got stuck. 9 + * 10 + * The fix: 11 + * 1. Set `tabindex="0"` on `.sheet-grid` (see `wireGridFocus` in 12 + * `src/sheets/keyboard-handler.ts`). 13 + * 2. When the grid itself is the active element, arrow keys still route to 14 + * `moveSelection()` (existing handler already treats grid-focus the same 15 + * as body-focus). 16 + * 3. Shift+Tab on the focused grid lets the browser move focus back to the 17 + * previous focusable (toolbar / formula bar) instead of being swallowed 18 + * as a selection-navigation shortcut. 19 + */ 20 + import { describe, it, expect, beforeEach, vi } from 'vitest'; 21 + import { 22 + wireKeyboardHandler, 23 + wireGridFocus, 24 + type KeyboardHandlerDeps, 25 + } from '../src/sheets/keyboard-handler.js'; 26 + 27 + function makeDeps(overrides: Partial<KeyboardHandlerDeps> = {}): KeyboardHandlerDeps { 28 + const grid = document.createElement('table'); 29 + grid.className = 'sheet-grid'; 30 + grid.id = 'sheet-grid'; 31 + document.body.appendChild(grid); 32 + const formulaInput = document.createElement('input'); 33 + formulaInput.className = 'formula-input'; 34 + formulaInput.id = 'formula-input'; 35 + document.body.appendChild(formulaInput); 36 + return { 37 + grid, 38 + formulaInput, 39 + sheetContainer: null, 40 + provider: { _saveSnapshot: () => {} }, 41 + getSelectedCell: () => ({ col: 1, row: 1 }), 42 + setSelectedCell: () => {}, 43 + getSelectionRange: () => null, 44 + setSelectionRange: () => {}, 45 + getActiveSheet: () => ({ get: () => 100 }), 46 + getCellData: () => null, 47 + setCellData: () => {}, 48 + getEditingCell: () => null, 49 + startEditing: () => {}, 50 + moveSelection: () => {}, 51 + extendSelection: () => {}, 52 + moveSelectionTo: () => {}, 53 + getDataExtent: () => ({ col: 1, row: 1 }), 54 + updateSelectionVisuals: () => {}, 55 + updateStatusBar: () => {}, 56 + copySelection: () => {}, 57 + deleteSelectedCells: () => {}, 58 + showPasteSpecialDialog: () => {}, 59 + applyStyleToSelection: () => {}, 60 + clearFormattingSelection: () => {}, 61 + updateUnderlineButtonState: () => {}, 62 + updateStrikethroughButtonState: () => {}, 63 + undoManager: null, 64 + evalCache: { clear: () => {} }, 65 + clearSpillMaps: () => {}, 66 + invalidateRecalcEngine: () => {}, 67 + refreshVisibleCells: () => {}, 68 + renderGrid: () => {}, 69 + hideSelectedRows: () => {}, 70 + hideSelectedCols: () => {}, 71 + unhideAdjacentRows: () => {}, 72 + unhideAdjacentCols: () => {}, 73 + toggleFilterMode: () => {}, 74 + printSheet: () => {}, 75 + showFindReplaceBar: () => {}, 76 + DEFAULT_COLS: 26, 77 + DEFAULT_ROWS: 100, 78 + ...overrides, 79 + }; 80 + } 81 + 82 + describe('sheets grid — keyboard focusability (#698)', () => { 83 + beforeEach(() => { 84 + document.body.innerHTML = ''; 85 + }); 86 + 87 + it('wireGridFocus sets tabindex=0 on the grid so it is reachable via Tab', () => { 88 + const deps = makeDeps(); 89 + expect(deps.grid.getAttribute('tabindex')).toBeNull(); 90 + 91 + wireGridFocus(deps.grid); 92 + 93 + expect(deps.grid.getAttribute('tabindex')).toBe('0'); 94 + }); 95 + 96 + it('focusing the grid directly makes document.activeElement the grid', () => { 97 + const deps = makeDeps(); 98 + wireGridFocus(deps.grid); 99 + 100 + deps.grid.focus(); 101 + 102 + // jsdom honors tabindex=0 for programmatic focus — this mirrors what 103 + // happens when a keyboard user Tabs from the formula bar into the grid. 104 + expect(document.activeElement).toBe(deps.grid); 105 + }); 106 + 107 + it('arrow keys route to moveSelection when the grid is the focused element', () => { 108 + const moveSelection = vi.fn(); 109 + const deps = makeDeps({ moveSelection }); 110 + wireGridFocus(deps.grid); 111 + wireKeyboardHandler(deps); 112 + 113 + deps.grid.focus(); 114 + expect(document.activeElement).toBe(deps.grid); 115 + 116 + document.dispatchEvent( 117 + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }), 118 + ); 119 + 120 + // Moves one column right: dCol=+1, dRow=0 → A1 → B1 121 + expect(moveSelection).toHaveBeenCalledWith(1, 0); 122 + }); 123 + 124 + it('ArrowDown on focused grid moves selection down one row (A1 → A2)', () => { 125 + const moveSelection = vi.fn(); 126 + const deps = makeDeps({ moveSelection }); 127 + wireGridFocus(deps.grid); 128 + wireKeyboardHandler(deps); 129 + 130 + deps.grid.focus(); 131 + document.dispatchEvent( 132 + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), 133 + ); 134 + 135 + expect(moveSelection).toHaveBeenCalledWith(0, 1); 136 + }); 137 + 138 + it('Shift+Tab on focused grid does NOT preventDefault, so focus can escape to the toolbar/formula bar', () => { 139 + const moveSelection = vi.fn(); 140 + const deps = makeDeps({ moveSelection }); 141 + wireGridFocus(deps.grid); 142 + wireKeyboardHandler(deps); 143 + 144 + deps.grid.focus(); 145 + const ev = new KeyboardEvent('keydown', { 146 + key: 'Tab', 147 + shiftKey: true, 148 + bubbles: true, 149 + cancelable: true, 150 + }); 151 + const pd = vi.spyOn(ev, 'preventDefault'); 152 + document.dispatchEvent(ev); 153 + 154 + expect(pd).not.toHaveBeenCalled(); 155 + // Shift+Tab should not be hijacked to move selection backward when the 156 + // grid itself is focused — it should let the browser restore focus to the 157 + // toolbar / formula bar. 158 + expect(moveSelection).not.toHaveBeenCalled(); 159 + }); 160 + 161 + it('Tab (without shift) on focused grid still moves the selection right (spec-preserved behavior)', () => { 162 + const moveSelection = vi.fn(); 163 + const deps = makeDeps({ moveSelection }); 164 + wireGridFocus(deps.grid); 165 + wireKeyboardHandler(deps); 166 + 167 + deps.grid.focus(); 168 + const ev = new KeyboardEvent('keydown', { 169 + key: 'Tab', 170 + bubbles: true, 171 + cancelable: true, 172 + }); 173 + const pd = vi.spyOn(ev, 'preventDefault'); 174 + document.dispatchEvent(ev); 175 + 176 + expect(pd).toHaveBeenCalled(); 177 + expect(moveSelection).toHaveBeenCalledWith(1, 0); 178 + }); 179 + 180 + it('focusing from the formula input then pressing ArrowRight on the grid drives selection', () => { 181 + // Simulates: user Tabs from formula bar → grid, then presses ArrowRight. 182 + const moveSelection = vi.fn(); 183 + const deps = makeDeps({ moveSelection }); 184 + wireGridFocus(deps.grid); 185 + wireKeyboardHandler(deps); 186 + 187 + deps.formulaInput.focus(); 188 + expect(document.activeElement).toBe(deps.formulaInput); 189 + 190 + deps.grid.focus(); 191 + expect(document.activeElement).toBe(deps.grid); 192 + 193 + document.dispatchEvent( 194 + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }), 195 + ); 196 + 197 + expect(moveSelection).toHaveBeenCalledWith(1, 0); 198 + }); 199 + });