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

Configure Feed

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

feat(sheets): add copy/paste feedback, row/col selection, undo/redo states, error tooltips

- Copy/paste operations now show toast with cell count (e.g., "Copied 3 × 5 cells")
- Shift+Space selects entire row, Ctrl+Space selects entire column
- Undo/redo buttons show disabled state when no history available, with stack count in title
- Formula error cells (#REF!, #VALUE!, #NAME?, #DIV/0!, etc.) show explanatory tooltip on hover
- Toolbar buttons, selects, and color inputs get focus-visible outlines for keyboard accessibility
- Added btn-disabled CSS class for visually disabled toolbar buttons

+233 -3
+17
src/css/app.css
··· 321 321 } 322 322 .btn-icon:hover { color: var(--color-text); background: var(--color-hover); } 323 323 .btn-icon.active { color: var(--color-accent); background: var(--color-btn-active-bg); } 324 + .btn-icon:focus-visible { 325 + outline: 2px solid var(--color-accent); 326 + outline-offset: 1px; 327 + } 328 + .btn-icon.btn-disabled { 329 + opacity: 0.35; 330 + cursor: default; 331 + pointer-events: none; 332 + } 324 333 325 334 /* --- Landing Page --- */ 326 335 .landing { ··· 1274 1283 cursor: pointer; 1275 1284 } 1276 1285 .toolbar select:hover { border-color: var(--color-border-strong); } 1286 + .toolbar select:focus-visible { 1287 + outline: 2px solid var(--color-accent); 1288 + outline-offset: 1px; 1289 + } 1277 1290 1278 1291 .toolbar input[type="color"] { 1279 1292 width: 24px; ··· 1282 1295 border-radius: var(--radius-sm); 1283 1296 cursor: pointer; 1284 1297 padding: 1px; 1298 + } 1299 + .toolbar input[type="color"]:focus-visible { 1300 + outline: 2px solid var(--color-accent); 1301 + outline-offset: 1px; 1285 1302 } 1286 1303 1287 1304 /* --- Docs Editor --- */
+75 -3
src/sheets/main.ts
··· 571 571 // Wrap text class 572 572 const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; 573 573 574 - html += '<div class="cell-display' + wrapClass + '" style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 574 + // Formula error tooltip 575 + const errTooltip = getFormulaErrorTooltip(String(displayValue)); 576 + const titleAttr = errTooltip ? ' title="' + escapeHtml(errTooltip) + '"' : ''; 577 + 578 + html += '<div class="cell-display' + wrapClass + '"' + titleAttr + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 575 579 } 576 580 577 581 // Dropdown arrow for list validation ··· 640 644 if (data.v !== undefined) cell.set('v', data.v); 641 645 if (data.f !== undefined) cell.set('f', data.f); 642 646 if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 647 + } 648 + 649 + function getFormulaErrorTooltip(value: string): string | null { 650 + if (typeof value !== 'string') return null; 651 + if (value.startsWith('#REF!')) return 'Reference error: a cell or range reference is invalid or refers to a deleted cell'; 652 + if (value.startsWith('#VALUE!')) return 'Value error: a function argument is the wrong type (e.g., text where a number is expected)'; 653 + if (value.startsWith('#NAME?')) return 'Name error: unrecognized function or named range'; 654 + if (value.startsWith('#DIV/0!')) return 'Division by zero: a formula divides by zero or an empty cell'; 655 + if (value.startsWith('#CIRCULAR!')) return 'Circular reference: this formula refers back to its own cell'; 656 + if (value.startsWith('#ERROR!')) return 'Error: the formula could not be evaluated'; 657 + if (value.startsWith('#N/A')) return 'Not available: no value found (e.g., VLOOKUP found no match)'; 658 + if (value.startsWith('#NUM!')) return 'Number error: invalid numeric value in calculation'; 659 + return null; 643 660 } 644 661 645 662 function computeDisplayValue(id, cellData) { ··· 1133 1150 moveSelectionTo(extent.col, extent.row); 1134 1151 } 1135 1152 } 1153 + // Select entire row: Shift+Space 1154 + else if (key === ' ' && e.shiftKey && !e.ctrlKey && !e.metaKey) { 1155 + e.preventDefault(); 1156 + const sheet = getActiveSheet(); 1157 + const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1158 + selectionRange = { startCol: 1, startRow: selectedCell.row, endCol: maxCol, endRow: selectedCell.row }; 1159 + updateSelectionVisuals(); 1160 + } 1161 + // Select entire column: Ctrl+Space 1162 + else if (key === ' ' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { 1163 + e.preventDefault(); 1164 + const sheet = getActiveSheet(); 1165 + const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1166 + selectionRange = { startCol: selectedCell.col, startRow: 1, endCol: selectedCell.col, endRow: maxRow }; 1167 + updateSelectionVisuals(); 1168 + } 1136 1169 else if (key === 'Tab') { e.preventDefault(); moveSelection(e.shiftKey ? -1 : 1, 0); } 1137 1170 else if (key === 'Enter') { e.preventDefault(); startEditing(selectedCell.col, selectedCell.row); } 1138 1171 else if (key === 'Delete' || key === 'Backspace') { e.preventDefault(); deleteSelectedCells(); } ··· 1187 1220 } 1188 1221 if (parsed) { 1189 1222 pasteRowsAtSelection(parsed.rows); 1223 + const rows = parsed.rows.length; 1224 + const cols = parsed.rows[0]?.length || 0; 1225 + if (rows === 1 && cols === 1) { 1226 + showToast('Pasted cell'); 1227 + } else { 1228 + showToast('Pasted ' + rows + ' \u00d7 ' + cols + ' cells'); 1229 + } 1190 1230 } 1191 1231 }); 1192 1232 ··· 1315 1355 } catch { 1316 1356 // ClipboardItem not supported -- fallback to plain text 1317 1357 navigator.clipboard.writeText(tsv).catch(() => {}); 1358 + } 1359 + 1360 + // Show feedback toast 1361 + const norm2 = normalizeRange(selectionRange); 1362 + const rows = norm2.endRow - norm2.startRow + 1; 1363 + const cols = norm2.endCol - norm2.startCol + 1; 1364 + if (rows === 1 && cols === 1) { 1365 + showToast('Copied cell'); 1366 + } else { 1367 + showToast('Copied ' + rows + ' \u00d7 ' + cols + ' cells'); 1318 1368 } 1319 1369 } 1320 1370 ··· 1746 1796 } 1747 1797 1748 1798 // Undo/Redo toolbar buttons 1799 + function updateUndoRedoState() { 1800 + const undoBtn = document.getElementById('tb-undo'); 1801 + const redoBtn = document.getElementById('tb-redo'); 1802 + if (undoBtn) { 1803 + const canUndo = undoManager && undoManager.undoStack.length > 0; 1804 + undoBtn.classList.toggle('btn-disabled', !canUndo); 1805 + undoBtn.setAttribute('aria-disabled', String(!canUndo)); 1806 + undoBtn.title = canUndo ? 'Undo (' + undoManager.undoStack.length + ')' : 'Nothing to undo'; 1807 + } 1808 + if (redoBtn) { 1809 + const canRedo = undoManager && undoManager.redoStack.length > 0; 1810 + redoBtn.classList.toggle('btn-disabled', !canRedo); 1811 + redoBtn.setAttribute('aria-disabled', String(!canRedo)); 1812 + redoBtn.title = canRedo ? 'Redo (' + undoManager.redoStack.length + ')' : 'Nothing to redo'; 1813 + } 1814 + } 1749 1815 document.getElementById('tb-undo').addEventListener('click', () => { 1750 - if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1816 + if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 1751 1817 }); 1752 1818 document.getElementById('tb-redo').addEventListener('click', () => { 1753 - if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1819 + if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 1754 1820 }); 1821 + // Update undo/redo state whenever stacks change 1822 + if (undoManager) { 1823 + undoManager.on('stack-item-added', updateUndoRedoState); 1824 + undoManager.on('stack-item-popped', updateUndoRedoState); 1825 + } 1826 + updateUndoRedoState(); 1755 1827 1756 1828 document.getElementById('tb-bold').addEventListener('click', () => { 1757 1829 const id = cellId(selectedCell.col, selectedCell.row);
+141
tests/ux-iteration-2.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + describe('formula error tooltips', () => { 4 + // Test the tooltip mapping logic (same pattern as getFormulaErrorTooltip in main.ts) 5 + function getFormulaErrorTooltip(value: string): string | null { 6 + if (typeof value !== 'string') return null; 7 + if (value.startsWith('#REF!')) return 'Reference error: a cell or range reference is invalid or refers to a deleted cell'; 8 + if (value.startsWith('#VALUE!')) return 'Value error: a function argument is the wrong type (e.g., text where a number is expected)'; 9 + if (value.startsWith('#NAME?')) return 'Name error: unrecognized function or named range'; 10 + if (value.startsWith('#DIV/0!')) return 'Division by zero: a formula divides by zero or an empty cell'; 11 + if (value.startsWith('#CIRCULAR!')) return 'Circular reference: this formula refers back to its own cell'; 12 + if (value.startsWith('#ERROR!')) return 'Error: the formula could not be evaluated'; 13 + if (value.startsWith('#N/A')) return 'Not available: no value found (e.g., VLOOKUP found no match)'; 14 + if (value.startsWith('#NUM!')) return 'Number error: invalid numeric value in calculation'; 15 + return null; 16 + } 17 + 18 + it('returns tooltip for #REF!', () => { 19 + expect(getFormulaErrorTooltip('#REF!')).toContain('Reference error'); 20 + }); 21 + 22 + it('returns tooltip for #VALUE!', () => { 23 + expect(getFormulaErrorTooltip('#VALUE!')).toContain('wrong type'); 24 + }); 25 + 26 + it('returns tooltip for #NAME?', () => { 27 + expect(getFormulaErrorTooltip('#NAME? (FOO)')).toContain('unrecognized'); 28 + }); 29 + 30 + it('returns tooltip for #DIV/0!', () => { 31 + expect(getFormulaErrorTooltip('#DIV/0!')).toContain('zero'); 32 + }); 33 + 34 + it('returns tooltip for #CIRCULAR!', () => { 35 + expect(getFormulaErrorTooltip('#CIRCULAR!')).toContain('Circular'); 36 + }); 37 + 38 + it('returns tooltip for #N/A', () => { 39 + expect(getFormulaErrorTooltip('#N/A')).toContain('Not available'); 40 + }); 41 + 42 + it('returns tooltip for #NUM!', () => { 43 + expect(getFormulaErrorTooltip('#NUM!')).toContain('numeric'); 44 + }); 45 + 46 + it('returns tooltip for #ERROR!', () => { 47 + expect(getFormulaErrorTooltip('#ERROR!')).toContain('could not be evaluated'); 48 + }); 49 + 50 + it('returns null for normal values', () => { 51 + expect(getFormulaErrorTooltip('42')).toBeNull(); 52 + expect(getFormulaErrorTooltip('Hello')).toBeNull(); 53 + expect(getFormulaErrorTooltip('')).toBeNull(); 54 + }); 55 + }); 56 + 57 + describe('select entire row/column logic', () => { 58 + it('Shift+Space selects entire row', () => { 59 + const selectedCell = { col: 3, row: 5 }; 60 + const maxCol = 26; 61 + const selectionRange = { 62 + startCol: 1, 63 + startRow: selectedCell.row, 64 + endCol: maxCol, 65 + endRow: selectedCell.row, 66 + }; 67 + expect(selectionRange.startCol).toBe(1); 68 + expect(selectionRange.endCol).toBe(26); 69 + expect(selectionRange.startRow).toBe(5); 70 + expect(selectionRange.endRow).toBe(5); 71 + }); 72 + 73 + it('Ctrl+Space selects entire column', () => { 74 + const selectedCell = { col: 3, row: 5 }; 75 + const maxRow = 100; 76 + const selectionRange = { 77 + startCol: selectedCell.col, 78 + startRow: 1, 79 + endCol: selectedCell.col, 80 + endRow: maxRow, 81 + }; 82 + expect(selectionRange.startCol).toBe(3); 83 + expect(selectionRange.endCol).toBe(3); 84 + expect(selectionRange.startRow).toBe(1); 85 + expect(selectionRange.endRow).toBe(100); 86 + }); 87 + }); 88 + 89 + describe('copy/paste feedback toast messages', () => { 90 + function getCopyToastMessage(rows: number, cols: number): string { 91 + if (rows === 1 && cols === 1) return 'Copied cell'; 92 + return 'Copied ' + rows + ' \u00d7 ' + cols + ' cells'; 93 + } 94 + 95 + function getPasteToastMessage(rows: number, cols: number): string { 96 + if (rows === 1 && cols === 1) return 'Pasted cell'; 97 + return 'Pasted ' + rows + ' \u00d7 ' + cols + ' cells'; 98 + } 99 + 100 + it('single cell copy message', () => { 101 + expect(getCopyToastMessage(1, 1)).toBe('Copied cell'); 102 + }); 103 + 104 + it('multi-cell copy message', () => { 105 + expect(getCopyToastMessage(3, 5)).toBe('Copied 3 \u00d7 5 cells'); 106 + }); 107 + 108 + it('single cell paste message', () => { 109 + expect(getPasteToastMessage(1, 1)).toBe('Pasted cell'); 110 + }); 111 + 112 + it('multi-cell paste message', () => { 113 + expect(getPasteToastMessage(10, 3)).toBe('Pasted 10 \u00d7 3 cells'); 114 + }); 115 + }); 116 + 117 + describe('undo/redo button state logic', () => { 118 + it('determines disabled state from stack length', () => { 119 + const canUndo = (stackLength: number) => stackLength > 0; 120 + const canRedo = (stackLength: number) => stackLength > 0; 121 + 122 + expect(canUndo(0)).toBe(false); 123 + expect(canUndo(1)).toBe(true); 124 + expect(canUndo(5)).toBe(true); 125 + 126 + expect(canRedo(0)).toBe(false); 127 + expect(canRedo(1)).toBe(true); 128 + }); 129 + 130 + it('generates correct title text', () => { 131 + const undoTitle = (stackLength: number) => 132 + stackLength > 0 ? 'Undo (' + stackLength + ')' : 'Nothing to undo'; 133 + const redoTitle = (stackLength: number) => 134 + stackLength > 0 ? 'Redo (' + stackLength + ')' : 'Nothing to redo'; 135 + 136 + expect(undoTitle(0)).toBe('Nothing to undo'); 137 + expect(undoTitle(3)).toBe('Undo (3)'); 138 + expect(redoTitle(0)).toBe('Nothing to redo'); 139 + expect(redoTitle(1)).toBe('Redo (1)'); 140 + }); 141 + });