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

Configure Feed

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

Merge pull request 'v0.7.0: Sheets UX iteration 2 — feedback, borders, accessibility, polish' (#64) from feat/sheets-ux-iteration-2 into main

scott 36a48c35 622130e6

+540 -34
+28
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.7.0] — 2026-03-19 11 + 12 + ### Added 13 + - **Copy/paste feedback toasts**: shows cell count after copy ("Copied 3 × 5 cells") and paste operations 14 + - **Select entire row/column**: Shift+Space selects entire row, Ctrl+Space selects entire column 15 + - **Select all**: Ctrl+A / Cmd+A selects all cells in the sheet 16 + - **Formula error tooltips**: hover over error cells (#REF!, #VALUE!, #DIV/0!, etc.) for plain-English explanations 17 + - **Validation error tooltips**: hover invalid cells to see why (e.g., "Value must be one of: A, B, C") 18 + - **Status bar cell reference**: always shows current cell or selection range with dimensions (e.g., "B3:E10 8R × 4C") 19 + - **Freeze pane indicator**: status bar shows "Frozen: 2 rows, 3 cols" with click-to-unfreeze 20 + - **Hidden row click-to-unhide**: click the teal indicator line to unhide rows directly 21 + 22 + ### Fixed 23 + - **Grid borders between colored cells**: switched to `border-separate` so borders render distinctly even with cell backgrounds 24 + - **Consistent cell sizing**: cells pinned to 26px height with `box-sizing: border-box` 25 + - **Bold/italic button states**: toolbar buttons now reflect current cell formatting when navigating 26 + - **Toolbar focus indicators**: all buttons, selects, and color inputs get `:focus-visible` outlines for keyboard accessibility 27 + - **Undo/redo disabled states**: buttons show visual disabled state when no history, with stack count in title 28 + 29 + ### Improved 30 + - **Hidden row indicators**: enlarged from 3px to 4px with hover expand to 6px and "Click to unhide" tooltip 31 + - **Grid border styling**: new CSS custom properties `--color-grid-line` and `--color-grid-header-line` with dark mode variants 32 + - **moveSelectionTo**: now updates all toolbar states (bold, italic, underline, strikethrough, font size/family) 33 + 34 + ### Tests 35 + - 3010 unit tests across 100 test files (+24 from v0.6.1) 36 + 10 37 ## [0.6.1] — 2026-03-19 11 38 12 39 ### Fixed ··· 19 46 - **Scroll render cache**: reset `_lastRenderedRange` on direct `renderGrid()` calls to prevent stale renders 20 47 21 48 ### Added 49 + - Toolbar: grouped dropdowns for alignment and list buttons (#2) 22 50 - **Context menu freeze/unfreeze**: right-click row/column headers to freeze or unfreeze panes 23 51 - **Context menu hide/unhide**: right-click headers to hide rows/columns, with unhide at boundaries 24 52 - **Drag-select auto-scroll**: holding mouse near container edges during selection automatically scrolls
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.6.1", 3 + "version": "0.7.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+127 -10
src/css/app.css
··· 18 18 --color-teal-light: oklch(0.88 0.04 195); 19 19 --color-border: oklch(0.87 0.008 75); 20 20 --color-border-strong:oklch(0.78 0.01 75); 21 + --color-grid-line: oklch(0.84 0.01 75); 22 + --color-grid-header-line: oklch(0.82 0.012 75); 21 23 --color-hover: oklch(0.92 0.01 75); 22 24 --color-focus: oklch(0.48 0.1 195 / 0.3); 23 25 --color-success: oklch(0.6 0.14 155); ··· 93 95 --color-teal-light: oklch(0.28 0.04 195); 94 96 --color-border: oklch(0.28 0.008 75); 95 97 --color-border-strong:oklch(0.38 0.01 75); 98 + --color-grid-line: oklch(0.26 0.008 75); 99 + --color-grid-header-line: oklch(0.30 0.01 75); 96 100 --color-hover: oklch(0.23 0.01 75); 97 101 --color-focus: oklch(0.60 0.1 195 / 0.3); 98 102 --color-success: oklch(0.65 0.14 155); ··· 139 143 --color-teal-light: oklch(0.28 0.04 195); 140 144 --color-border: oklch(0.28 0.008 75); 141 145 --color-border-strong:oklch(0.38 0.01 75); 146 + --color-grid-line: oklch(0.26 0.008 75); 147 + --color-grid-header-line: oklch(0.30 0.01 75); 142 148 --color-hover: oklch(0.23 0.01 75); 143 149 --color-focus: oklch(0.60 0.1 195 / 0.3); 144 150 --color-success: oklch(0.65 0.14 155); ··· 321 327 } 322 328 .btn-icon:hover { color: var(--color-text); background: var(--color-hover); } 323 329 .btn-icon.active { color: var(--color-accent); background: var(--color-btn-active-bg); } 330 + .btn-icon:focus-visible { 331 + outline: 2px solid var(--color-accent); 332 + outline-offset: 1px; 333 + } 334 + .btn-icon.btn-disabled { 335 + opacity: 0.35; 336 + cursor: default; 337 + pointer-events: none; 338 + } 324 339 325 340 /* --- Landing Page --- */ 326 341 .landing { ··· 1274 1289 cursor: pointer; 1275 1290 } 1276 1291 .toolbar select:hover { border-color: var(--color-border-strong); } 1292 + .toolbar select:focus-visible { 1293 + outline: 2px solid var(--color-accent); 1294 + outline-offset: 1px; 1295 + } 1277 1296 1278 1297 .toolbar input[type="color"] { 1279 1298 width: 24px; ··· 1282 1301 border-radius: var(--radius-sm); 1283 1302 cursor: pointer; 1284 1303 padding: 1px; 1304 + } 1305 + .toolbar input[type="color"]:focus-visible { 1306 + outline: 2px solid var(--color-accent); 1307 + outline-offset: 1px; 1285 1308 } 1286 1309 1287 1310 /* --- Docs Editor --- */ ··· 1586 1609 } 1587 1610 1588 1611 .sheet-grid { 1589 - border-collapse: collapse; 1612 + border-collapse: separate; 1613 + border-spacing: 0; 1590 1614 table-layout: fixed; 1591 1615 font-family: var(--font-mono); 1592 1616 font-size: 0.8rem; ··· 1596 1620 1597 1621 .sheet-grid th { 1598 1622 background: var(--color-surface); 1599 - border: 1px solid var(--color-border); 1623 + border-right: 1px solid var(--color-grid-header-line); 1624 + border-bottom: 1px solid var(--color-grid-header-line); 1625 + border-left: none; 1626 + border-top: none; 1600 1627 padding: 0.25rem 0.5rem; 1601 1628 font-weight: 500; 1602 1629 font-size: 0.7rem; ··· 1605 1632 z-index: 2; 1606 1633 text-align: center; 1607 1634 min-width: 3rem; 1635 + height: 26px; 1636 + box-sizing: border-box; 1608 1637 } 1609 1638 1610 1639 /* Column resize handle in header */ ··· 1649 1678 position: sticky; 1650 1679 z-index: 3; 1651 1680 background: var(--color-bg-subtle); 1681 + border-top: 1px solid var(--color-grid-header-line); 1682 + } 1683 + 1684 + /* First column gets left border (border-separate: no shared borders) */ 1685 + .sheet-grid td:first-child, 1686 + .sheet-grid th:first-child { 1687 + border-left: 1px solid var(--color-grid-header-line); 1688 + } 1689 + .sheet-grid tbody tr:first-child td { 1690 + border-top: 1px solid var(--color-grid-line); 1652 1691 } 1653 1692 1654 1693 /* Column header resize handle */ ··· 1708 1747 } 1709 1748 1710 1749 /* Hidden row/column indicators */ 1711 - .hidden-row-indicator { height: 4px; } 1750 + .hidden-row-indicator { height: 6px; } 1712 1751 .hidden-row-indicator td { padding: 0 !important; border: none !important; } 1713 1752 .hidden-row-indicator-line { 1714 - height: 3px; 1753 + height: 4px; 1715 1754 background: var(--color-teal); 1716 1755 opacity: 0.5; 1717 1756 cursor: pointer; 1718 - border-radius: 1px; 1757 + border-radius: 2px; 1758 + transition: opacity 150ms ease, height 150ms ease; 1759 + position: relative; 1760 + } 1761 + .hidden-row-indicator-line:hover { 1762 + opacity: 0.85; 1763 + height: 6px; 1719 1764 } 1720 - .hidden-row-indicator-line:hover { opacity: 0.8; } 1721 - .hidden-col-boundary { border-right: 2px solid var(--color-teal) !important; } 1722 - .hidden-row-boundary { border-bottom: 2px solid var(--color-teal) !important; } 1765 + .hidden-row-indicator-line::after { 1766 + content: 'Click to unhide'; 1767 + position: absolute; 1768 + left: 50%; 1769 + top: -22px; 1770 + transform: translateX(-50%); 1771 + font-size: 0.6rem; 1772 + font-family: var(--font-body); 1773 + color: var(--color-bg); 1774 + background: var(--color-text); 1775 + padding: 2px 6px; 1776 + border-radius: 3px; 1777 + white-space: nowrap; 1778 + opacity: 0; 1779 + pointer-events: none; 1780 + transition: opacity 150ms ease; 1781 + } 1782 + .hidden-row-indicator-line:hover::after { opacity: 1; } 1783 + .hidden-col-boundary { 1784 + border-right: 2px solid var(--color-teal) !important; 1785 + position: relative; 1786 + } 1787 + .hidden-row-boundary { 1788 + border-bottom: 2px solid var(--color-teal) !important; 1789 + position: relative; 1790 + } 1723 1791 1724 1792 .sheet-grid .row-header { 1725 1793 position: sticky; ··· 1731 1799 } 1732 1800 1733 1801 .sheet-grid td { 1734 - border: 1px solid var(--color-border); 1802 + border-right: 1px solid var(--color-grid-line); 1803 + border-bottom: 1px solid var(--color-grid-line); 1804 + border-left: none; 1805 + border-top: none; 1735 1806 padding: 0; 1736 - height: 1.6rem; 1807 + height: 26px; 1808 + min-height: 26px; 1809 + box-sizing: border-box; 1737 1810 position: relative; 1738 1811 overflow: hidden; 1739 1812 } ··· 4483 4556 font-family: var(--font-mono); 4484 4557 font-size: 0.65rem; 4485 4558 color: var(--color-text-faint); 4559 + } 4560 + 4561 + .status-bar-cell-ref { 4562 + padding: 1px 4px; 4563 + border-radius: 2px; 4564 + background: var(--color-surface-alt); 4565 + color: var(--color-text-muted); 4566 + font-weight: 500; 4567 + } 4568 + 4569 + .status-bar-dim { 4570 + color: var(--color-text-faint); 4571 + } 4572 + 4573 + .status-bar-freeze { 4574 + padding: 1px 6px; 4575 + border-radius: 2px; 4576 + background: oklch(0.75 0.1 195 / 0.15); 4577 + color: oklch(0.45 0.12 195); 4578 + font-weight: 500; 4579 + font-family: var(--font-body); 4580 + font-size: 0.65rem; 4581 + cursor: pointer; 4582 + transition: background 150ms ease; 4583 + } 4584 + .status-bar-freeze:hover { 4585 + background: oklch(0.75 0.1 195 / 0.25); 4586 + } 4587 + 4588 + [data-theme="dark"] .status-bar-freeze { 4589 + background: oklch(0.35 0.08 195 / 0.3); 4590 + color: oklch(0.7 0.1 195); 4591 + } 4592 + [data-theme="dark"] .status-bar-freeze:hover { 4593 + background: oklch(0.35 0.08 195 / 0.45); 4594 + } 4595 + @media (prefers-color-scheme: dark) { 4596 + :root:not([data-theme="light"]) .status-bar-freeze { 4597 + background: oklch(0.35 0.08 195 / 0.3); 4598 + color: oklch(0.7 0.1 195); 4599 + } 4600 + :root:not([data-theme="light"]) .status-bar-freeze:hover { 4601 + background: oklch(0.35 0.08 195 / 0.45); 4602 + } 4486 4603 } 4487 4604 4488 4605 .status-bar-range {
+1 -1
src/docs/index.html
··· 433 433 updateIcon(); 434 434 })(); 435 435 </script> 436 - <div class="version-badge">v0.6.1</div> 436 + <div class="version-badge">v0.7.0</div> 437 437 </body> 438 438 </html>
+1 -1
src/index.html
··· 126 126 </div> 127 127 </div> 128 128 129 - <div class="version-badge">v0.6.1</div> 129 + <div class="version-badge">v0.7.0</div> 130 130 131 131 <!-- Drag-and-drop import overlay --> 132 132 <div class="drop-overlay" id="drop-overlay" style="display:none;">
+4 -3
src/sheets/index.html
··· 275 275 <!-- Charts section --> 276 276 <div class="charts-section" id="charts-section"></div> 277 277 278 - <!-- Status bar (selection stats) --> 279 - <div class="status-bar" id="status-bar" style="display:none"> 278 + <!-- Status bar --> 279 + <div class="status-bar" id="status-bar"> 280 + <div class="status-bar-info" id="status-bar-info"></div> 280 281 <div class="status-bar-stats" id="status-bar-stats"></div> 281 282 </div> 282 283 ··· 363 364 navigator.serviceWorker.register('/sw.js').catch(function() {}); 364 365 } 365 366 </script> 366 - <div class="version-badge">v0.6.1</div> 367 + <div class="version-badge">v0.7.0</div> 367 368 </body> 368 369 </html>
+173 -18
src/sheets/main.ts
··· 537 537 538 538 // Data validation — check for invalid and dropdown 539 539 const validation = getValidationForCell(id); 540 + let validationMsg = ''; 540 541 if (validation && cellData) { 541 542 const valResult = validateCell(displayValue, validation); 542 - if (!valResult.valid) tdCls.push('validation-invalid'); 543 + if (!valResult.valid) { 544 + tdCls.push('validation-invalid'); 545 + validationMsg = valResult.message || 'Invalid value'; 546 + } 543 547 } 544 548 545 549 // Find & replace highlighting ··· 558 562 if (mergeInfo.colspan > 1) spanAttrs += ' colspan="' + mergeInfo.colspan + '"'; 559 563 if (mergeInfo.rowspan > 1) spanAttrs += ' rowspan="' + mergeInfo.rowspan + '"'; 560 564 } 561 - html += '<td data-col="' + c + '" data-row="' + r + '" data-id="' + id + '" class="' + tdCls.join(' ') + '"' + styleAttr + spanAttrs + '>'; 565 + const valTitleAttr = validationMsg ? ' title="' + escapeHtml(validationMsg) + '"' : ''; 566 + html += '<td data-col="' + c + '" data-row="' + r + '" data-id="' + id + '" class="' + tdCls.join(' ') + '"' + styleAttr + spanAttrs + valTitleAttr + '>'; 562 567 563 568 // Sparkline: render canvas instead of text 564 569 if (isSparklineResult(displayValue)) { ··· 571 576 // Wrap text class 572 577 const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; 573 578 574 - html += '<div class="cell-display' + wrapClass + '" style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 579 + // Formula error tooltip 580 + const errTooltip = getFormulaErrorTooltip(String(displayValue)); 581 + const titleAttr = errTooltip ? ' title="' + escapeHtml(errTooltip) + '"' : ''; 582 + 583 + html += '<div class="cell-display' + wrapClass + '"' + titleAttr + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 575 584 } 576 585 577 586 // Dropdown arrow for list validation ··· 640 649 if (data.v !== undefined) cell.set('v', data.v); 641 650 if (data.f !== undefined) cell.set('f', data.f); 642 651 if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 652 + } 653 + 654 + function getFormulaErrorTooltip(value: string): string | null { 655 + if (typeof value !== 'string') return null; 656 + if (value.startsWith('#REF!')) return 'Reference error: a cell or range reference is invalid or refers to a deleted cell'; 657 + if (value.startsWith('#VALUE!')) return 'Value error: a function argument is the wrong type (e.g., text where a number is expected)'; 658 + if (value.startsWith('#NAME?')) return 'Name error: unrecognized function or named range'; 659 + if (value.startsWith('#DIV/0!')) return 'Division by zero: a formula divides by zero or an empty cell'; 660 + if (value.startsWith('#CIRCULAR!')) return 'Circular reference: this formula refers back to its own cell'; 661 + if (value.startsWith('#ERROR!')) return 'Error: the formula could not be evaluated'; 662 + if (value.startsWith('#N/A')) return 'Not available: no value found (e.g., VLOOKUP found no match)'; 663 + if (value.startsWith('#NUM!')) return 'Number error: invalid numeric value in calculation'; 664 + return null; 643 665 } 644 666 645 667 function computeDisplayValue(id, cellData) { ··· 801 823 } 802 824 803 825 function onGridMouseDown(e) { 826 + // Click hidden-row indicator to unhide 827 + const hiddenIndicator = e.target.closest('.hidden-row-indicator-line'); 828 + if (hiddenIndicator) { 829 + e.preventDefault(); 830 + const indicatorRow = hiddenIndicator.closest('.hidden-row-indicator'); 831 + // Find adjacent visible row above the indicator 832 + const prevRow = indicatorRow?.previousElementSibling; 833 + if (prevRow) { 834 + const lastTh = prevRow.querySelector('th[data-row]'); 835 + if (lastTh) { 836 + const row = parseInt(lastTh.dataset.row); 837 + unhideAdjacentRows(row); 838 + showToast('Rows unhidden'); 839 + } 840 + } 841 + return; 842 + } 804 843 const handle = e.target.closest('.col-resize-handle'); 805 844 if (handle) { 806 845 e.preventDefault(); ··· 1132 1171 const extent = getDataExtent(); 1133 1172 moveSelectionTo(extent.col, extent.row); 1134 1173 } 1174 + } 1175 + // Select entire row: Shift+Space 1176 + else if (key === ' ' && e.shiftKey && !e.ctrlKey && !e.metaKey) { 1177 + e.preventDefault(); 1178 + const sheet = getActiveSheet(); 1179 + const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1180 + selectionRange = { startCol: 1, startRow: selectedCell.row, endCol: maxCol, endRow: selectedCell.row }; 1181 + updateSelectionVisuals(); 1182 + } 1183 + // Select entire column: Ctrl+Space 1184 + else if (key === ' ' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { 1185 + e.preventDefault(); 1186 + const sheet = getActiveSheet(); 1187 + const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1188 + selectionRange = { startCol: selectedCell.col, startRow: 1, endCol: selectedCell.col, endRow: maxRow }; 1189 + updateSelectionVisuals(); 1135 1190 } 1136 1191 else if (key === 'Tab') { e.preventDefault(); moveSelection(e.shiftKey ? -1 : 1, 0); } 1137 1192 else if (key === 'Enter') { e.preventDefault(); startEditing(selectedCell.col, selectedCell.row); } ··· 1170 1225 if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === '0' || key === ')')) { e.preventDefault(); unhideAdjacentCols(selectedCell.col); } 1171 1226 // Clear formatting: Cmd+Backslash 1172 1227 if ((e.metaKey || e.ctrlKey) && key === '\\') { e.preventDefault(); clearFormattingSelection(); } 1228 + // Select all: Cmd+A 1229 + if ((e.metaKey || e.ctrlKey) && key === 'a') { 1230 + e.preventDefault(); 1231 + const sheet = getActiveSheet(); 1232 + const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1233 + const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1234 + selectionRange = { startCol: 1, startRow: 1, endCol: maxCol, endRow: maxRow }; 1235 + updateSelectionVisuals(); 1236 + updateStatusBar(); 1237 + } 1173 1238 }); 1174 1239 1175 1240 document.addEventListener('paste', (e) => { ··· 1187 1252 } 1188 1253 if (parsed) { 1189 1254 pasteRowsAtSelection(parsed.rows); 1255 + const rows = parsed.rows.length; 1256 + const cols = parsed.rows[0]?.length || 0; 1257 + if (rows === 1 && cols === 1) { 1258 + showToast('Pasted cell'); 1259 + } else { 1260 + showToast('Pasted ' + rows + ' \u00d7 ' + cols + ' cells'); 1261 + } 1190 1262 } 1191 1263 }); 1192 1264 ··· 1202 1274 updateFormulaBar(); 1203 1275 updateMergeButtonState(); 1204 1276 updateWrapButtonState(); 1277 + updateBoldButtonState(); 1278 + updateItalicButtonState(); 1205 1279 updateUnderlineButtonState(); 1206 1280 updateStrikethroughButtonState(); 1207 1281 updateFontSizeSelect(); ··· 1228 1302 selectionRange = { startCol: col, startRow: row, endCol: col, endRow: row }; 1229 1303 updateSelectionVisuals(); 1230 1304 updateFormulaBar(); 1305 + updateBoldButtonState(); 1306 + updateItalicButtonState(); 1307 + updateUnderlineButtonState(); 1308 + updateStrikethroughButtonState(); 1309 + updateFontSizeSelect(); 1310 + updateFontFamilySelect(); 1231 1311 scrollCellIntoView(col, row); 1232 1312 } 1233 1313 ··· 1315 1395 } catch { 1316 1396 // ClipboardItem not supported -- fallback to plain text 1317 1397 navigator.clipboard.writeText(tsv).catch(() => {}); 1398 + } 1399 + 1400 + // Show feedback toast 1401 + const norm2 = normalizeRange(selectionRange); 1402 + const rows = norm2.endRow - norm2.startRow + 1; 1403 + const cols = norm2.endCol - norm2.startCol + 1; 1404 + if (rows === 1 && cols === 1) { 1405 + showToast('Copied cell'); 1406 + } else { 1407 + showToast('Copied ' + rows + ' \u00d7 ' + cols + ' cells'); 1318 1408 } 1319 1409 } 1320 1410 ··· 1746 1836 } 1747 1837 1748 1838 // Undo/Redo toolbar buttons 1839 + function updateUndoRedoState() { 1840 + const undoBtn = document.getElementById('tb-undo'); 1841 + const redoBtn = document.getElementById('tb-redo'); 1842 + if (undoBtn) { 1843 + const canUndo = undoManager && undoManager.undoStack.length > 0; 1844 + undoBtn.classList.toggle('btn-disabled', !canUndo); 1845 + undoBtn.setAttribute('aria-disabled', String(!canUndo)); 1846 + undoBtn.title = canUndo ? 'Undo (' + undoManager.undoStack.length + ')' : 'Nothing to undo'; 1847 + } 1848 + if (redoBtn) { 1849 + const canRedo = undoManager && undoManager.redoStack.length > 0; 1850 + redoBtn.classList.toggle('btn-disabled', !canRedo); 1851 + redoBtn.setAttribute('aria-disabled', String(!canRedo)); 1852 + redoBtn.title = canRedo ? 'Redo (' + undoManager.redoStack.length + ')' : 'Nothing to redo'; 1853 + } 1854 + } 1749 1855 document.getElementById('tb-undo').addEventListener('click', () => { 1750 - if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1856 + if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 1751 1857 }); 1752 1858 document.getElementById('tb-redo').addEventListener('click', () => { 1753 - if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1859 + if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 1754 1860 }); 1861 + // Update undo/redo state whenever stacks change 1862 + if (undoManager) { 1863 + undoManager.on('stack-item-added', updateUndoRedoState); 1864 + undoManager.on('stack-item-popped', updateUndoRedoState); 1865 + } 1866 + updateUndoRedoState(); 1755 1867 1756 1868 document.getElementById('tb-bold').addEventListener('click', () => { 1757 1869 const id = cellId(selectedCell.col, selectedCell.row); ··· 3778 3890 document.getElementById('tb-striped').classList.toggle('active', getStripedRows()); 3779 3891 } 3780 3892 3893 + function updateBoldButtonState() { 3894 + const id = cellId(selectedCell.col, selectedCell.row); 3895 + const isBold = getCellData(id)?.s?.bold; 3896 + const el = document.getElementById('tb-bold'); 3897 + if (el) el.classList.toggle('active', !!isBold); 3898 + } 3899 + 3900 + function updateItalicButtonState() { 3901 + const id = cellId(selectedCell.col, selectedCell.row); 3902 + const isItalic = getCellData(id)?.s?.italic; 3903 + const el = document.getElementById('tb-italic'); 3904 + if (el) el.classList.toggle('active', !!isItalic); 3905 + } 3906 + 3781 3907 function updateUnderlineButtonState() { 3782 3908 const id = cellId(selectedCell.col, selectedCell.row); 3783 3909 const isUnderline = getCellData(id)?.s?.underline; ··· 3824 3950 3825 3951 const statusBar = document.getElementById('status-bar'); 3826 3952 const statusBarStats = document.getElementById('status-bar-stats'); 3953 + const statusBarInfo = document.getElementById('status-bar-info'); 3827 3954 3828 3955 function updateStatusBar() { 3829 - if (!selectionRange) { 3830 - statusBar.style.display = 'none'; 3831 - return; 3956 + // Left side: cell reference + freeze indicator 3957 + let infoHtml = ''; 3958 + if (selectionRange) { 3959 + const norm = normalizeRange(selectionRange); 3960 + const isMulti = norm.startCol !== norm.endCol || norm.startRow !== norm.endRow; 3961 + if (isMulti) { 3962 + infoHtml += '<span class="status-bar-cell-ref">' + colToLetter(norm.startCol) + norm.startRow + ':' + colToLetter(norm.endCol) + norm.endRow + '</span>'; 3963 + const rows = norm.endRow - norm.startRow + 1; 3964 + const cols = norm.endCol - norm.startCol + 1; 3965 + infoHtml += '<span class="status-bar-dim">' + rows + 'R \u00d7 ' + cols + 'C</span>'; 3966 + } else { 3967 + infoHtml += '<span class="status-bar-cell-ref">' + colToLetter(selectedCell.col) + selectedCell.row + '</span>'; 3968 + } 3969 + } 3970 + const fr = getFreezeRows(); 3971 + const fc = getFreezeCols(); 3972 + if (fr > 0 || fc > 0) { 3973 + const parts = []; 3974 + if (fr > 0) parts.push(fr + ' row' + (fr > 1 ? 's' : '')); 3975 + if (fc > 0) parts.push(fc + ' col' + (fc > 1 ? 's' : '')); 3976 + infoHtml += '<span class="status-bar-freeze" title="Click to unfreeze">Frozen: ' + parts.join(', ') + '</span>'; 3832 3977 } 3978 + if (statusBarInfo) statusBarInfo.innerHTML = infoHtml; 3979 + 3980 + // Right side: selection stats (only for multi-cell) 3981 + if (!selectionRange) { statusBarStats.innerHTML = ''; return; } 3833 3982 const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 3834 3983 const isMultiCell = startCol !== endCol || startRow !== endRow; 3835 - if (!isMultiCell) { 3836 - statusBar.style.display = 'none'; 3837 - return; 3838 - } 3984 + if (!isMultiCell) { statusBarStats.innerHTML = ''; return; } 3839 3985 3840 - // Collect cell values from the selection 3841 3986 const values = []; 3842 3987 for (let r = startRow; r <= endRow; r++) { 3843 3988 for (let c = startCol; c <= endCol; c++) { ··· 3849 3994 } 3850 3995 3851 3996 const stats = computeSelectionStats(values); 3852 - if (!stats) { 3853 - statusBar.style.display = 'none'; 3854 - return; 3855 - } 3997 + if (!stats) { statusBarStats.innerHTML = ''; return; } 3856 3998 3857 - statusBar.style.display = ''; 3858 3999 let html = ''; 3859 4000 if (stats.count > 0) { 3860 4001 html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Sum</span><span class="status-bar-stat-value">' + formatStatValue(stats.sum) + '</span></span>'; ··· 3866 4007 html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Count</span><span class="status-bar-stat-value">0</span></span>'; 3867 4008 } 3868 4009 statusBarStats.innerHTML = html; 4010 + } 4011 + 4012 + // Click on freeze indicator to unfreeze 4013 + if (statusBarInfo) { 4014 + statusBarInfo.addEventListener('click', (e) => { 4015 + const freezeEl = (e.target as HTMLElement).closest('.status-bar-freeze'); 4016 + if (freezeEl) { 4017 + setFreezeRows(0); 4018 + setFreezeCols(0); 4019 + renderGrid(); 4020 + updateStatusBar(); 4021 + showToast('Panes unfrozen'); 4022 + } 4023 + }); 3869 4024 } 3870 4025 3871 4026 // ========================================================
+205
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 + }); 142 + 143 + describe('select all logic', () => { 144 + it('Ctrl+A selects entire sheet', () => { 145 + const maxCol = 26; 146 + const maxRow = 100; 147 + const selectionRange = { startCol: 1, startRow: 1, endCol: maxCol, endRow: maxRow }; 148 + expect(selectionRange.startCol).toBe(1); 149 + expect(selectionRange.startRow).toBe(1); 150 + expect(selectionRange.endCol).toBe(26); 151 + expect(selectionRange.endRow).toBe(100); 152 + }); 153 + }); 154 + 155 + describe('status bar info generation', () => { 156 + it('shows cell reference for single cell', () => { 157 + const colToLetter = (c: number) => String.fromCharCode(64 + c); 158 + const selectedCell = { col: 3, row: 5 }; 159 + const ref = colToLetter(selectedCell.col) + selectedCell.row; 160 + expect(ref).toBe('C5'); 161 + }); 162 + 163 + it('shows range and dimensions for multi-cell', () => { 164 + const colToLetter = (c: number) => String.fromCharCode(64 + c); 165 + const norm = { startCol: 2, startRow: 3, endCol: 5, endRow: 10 }; 166 + const range = colToLetter(norm.startCol) + norm.startRow + ':' + colToLetter(norm.endCol) + norm.endRow; 167 + expect(range).toBe('B3:E10'); 168 + const rows = norm.endRow - norm.startRow + 1; 169 + const cols = norm.endCol - norm.startCol + 1; 170 + expect(rows).toBe(8); 171 + expect(cols).toBe(4); 172 + }); 173 + 174 + it('shows freeze indicator text', () => { 175 + const fr = 2, fc = 3; 176 + const parts: string[] = []; 177 + if (fr > 0) parts.push(fr + ' row' + (fr > 1 ? 's' : '')); 178 + if (fc > 0) parts.push(fc + ' col' + (fc > 1 ? 's' : '')); 179 + expect(parts.join(', ')).toBe('2 rows, 3 cols'); 180 + }); 181 + 182 + it('shows singular for 1 row frozen', () => { 183 + const fr = 1, fc = 0; 184 + const parts: string[] = []; 185 + if (fr > 0) parts.push(fr + ' row' + (fr > 1 ? 's' : '')); 186 + if (fc > 0) parts.push(fc + ' col' + (fc > 1 ? 's' : '')); 187 + expect(parts.join(', ')).toBe('1 row'); 188 + }); 189 + }); 190 + 191 + describe('toolbar button state sync', () => { 192 + it('bold state reflects cell data', () => { 193 + const cellStyle = { bold: true, italic: false, underline: false, strikethrough: false }; 194 + expect(!!cellStyle.bold).toBe(true); 195 + expect(!!cellStyle.italic).toBe(false); 196 + }); 197 + 198 + it('all style states reflect cell data', () => { 199 + const cellStyle = { bold: false, italic: true, underline: true, strikethrough: true }; 200 + expect(!!cellStyle.bold).toBe(false); 201 + expect(!!cellStyle.italic).toBe(true); 202 + expect(!!cellStyle.underline).toBe(true); 203 + expect(!!cellStyle.strikethrough).toBe(true); 204 + }); 205 + });