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

Configure Feed

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

feat: add status bar, formula autocomplete, and cell notes

Three new sheets UX features:

- Status bar: shows SUM, AVG, COUNT, MIN, MAX for multi-cell selections,
styled as a subtle right-aligned bar between the grid and sheet tabs
- Formula autocomplete: dropdown with function suggestions and signatures
when typing formulas (=), with keyboard navigation (arrows/Tab/Enter/Esc)
- Cell notes: plain text annotations with triangle indicator, hover tooltips,
right-click context menu, and Yjs collaboration sync

All features include dark mode support and 62 new tests (964 total passing).

+1349 -1
+159
src/css/app.css
··· 3710 3710 .editor-wrapper { 3711 3711 transition: all 200ms ease-out; 3712 3712 } 3713 + 3714 + /* ======================================================== 3715 + Status Bar (selection aggregate stats) 3716 + ======================================================== */ 3717 + 3718 + .status-bar { 3719 + display: flex; 3720 + align-items: center; 3721 + justify-content: flex-end; 3722 + padding: 2px var(--space-sm); 3723 + border-top: 1px solid var(--color-border); 3724 + background: var(--color-surface); 3725 + flex-shrink: 0; 3726 + min-height: 22px; 3727 + transition: background-color 200ms ease, border-color 200ms ease; 3728 + } 3729 + 3730 + .status-bar-stats { 3731 + display: flex; 3732 + align-items: center; 3733 + gap: var(--space-md); 3734 + font-family: var(--font-body); 3735 + font-size: 0.7rem; 3736 + color: var(--color-text-muted); 3737 + user-select: text; 3738 + } 3739 + 3740 + .status-bar-stat { 3741 + display: inline-flex; 3742 + align-items: center; 3743 + gap: 3px; 3744 + white-space: nowrap; 3745 + } 3746 + 3747 + .status-bar-stat-label { 3748 + font-weight: 500; 3749 + color: var(--color-text-faint); 3750 + text-transform: uppercase; 3751 + font-size: 0.6rem; 3752 + letter-spacing: 0.04em; 3753 + } 3754 + 3755 + .status-bar-stat-value { 3756 + font-family: var(--font-mono); 3757 + font-size: 0.7rem; 3758 + color: var(--color-text); 3759 + } 3760 + 3761 + [data-theme="dark"] .status-bar { 3762 + background: var(--color-surface); 3763 + border-top-color: var(--color-border); 3764 + } 3765 + 3766 + /* ======================================================== 3767 + Formula Auto-Complete 3768 + ======================================================== */ 3769 + 3770 + .formula-autocomplete { 3771 + position: absolute; 3772 + z-index: 1000; 3773 + min-width: 240px; 3774 + max-width: 380px; 3775 + max-height: 260px; 3776 + overflow-y: auto; 3777 + background: var(--color-bg); 3778 + border: 1px solid var(--color-border-strong); 3779 + border-radius: var(--radius-md); 3780 + box-shadow: var(--shadow-md); 3781 + padding: 2px 0; 3782 + font-family: var(--font-body); 3783 + } 3784 + 3785 + .formula-autocomplete-item { 3786 + display: flex; 3787 + flex-direction: column; 3788 + padding: 4px 10px; 3789 + cursor: pointer; 3790 + transition: background-color 80ms ease; 3791 + } 3792 + 3793 + .formula-autocomplete-item:hover, 3794 + .formula-autocomplete-item.selected { 3795 + background: var(--color-hover); 3796 + } 3797 + 3798 + .formula-autocomplete-item.selected { 3799 + background: var(--color-teal-light); 3800 + } 3801 + 3802 + .formula-autocomplete-name { 3803 + font-weight: 600; 3804 + font-size: 0.8rem; 3805 + color: var(--color-text); 3806 + } 3807 + 3808 + .formula-autocomplete-signature { 3809 + font-family: var(--font-mono); 3810 + font-size: 0.7rem; 3811 + color: var(--color-text-muted); 3812 + margin-top: 1px; 3813 + } 3814 + 3815 + [data-theme="dark"] .formula-autocomplete { 3816 + background: var(--color-surface); 3817 + border-color: var(--color-border-strong); 3818 + } 3819 + 3820 + [data-theme="dark"] .formula-autocomplete-item.selected { 3821 + background: var(--color-teal-light); 3822 + } 3823 + 3824 + /* ======================================================== 3825 + Cell Notes 3826 + ======================================================== */ 3827 + 3828 + .cell-note-indicator { 3829 + position: absolute; 3830 + top: 0; 3831 + right: 0; 3832 + width: 0; 3833 + height: 0; 3834 + border-style: solid; 3835 + border-width: 0 7px 7px 0; 3836 + border-color: transparent var(--color-teal) transparent transparent; 3837 + pointer-events: none; 3838 + z-index: 1; 3839 + } 3840 + 3841 + .cell-note-tooltip { 3842 + position: absolute; 3843 + z-index: 1001; 3844 + max-width: 280px; 3845 + padding: 6px 10px; 3846 + background: var(--color-surface-alt); 3847 + border: 1px solid var(--color-border-strong); 3848 + border-radius: var(--radius-md); 3849 + box-shadow: var(--shadow-md); 3850 + font-family: var(--font-body); 3851 + font-size: 0.75rem; 3852 + color: var(--color-text); 3853 + line-height: 1.4; 3854 + white-space: pre-wrap; 3855 + word-wrap: break-word; 3856 + pointer-events: none; 3857 + } 3858 + 3859 + [data-theme="dark"] .cell-note-indicator { 3860 + border-color: transparent var(--color-teal) transparent transparent; 3861 + } 3862 + 3863 + [data-theme="dark"] .cell-note-tooltip { 3864 + background: var(--color-surface-alt); 3865 + border-color: var(--color-border-strong); 3866 + } 3867 + 3868 + /* Cell with note needs relative positioning for the indicator */ 3869 + td[data-id].has-note { 3870 + position: relative; 3871 + }
+93
src/sheets/cell-notes.js
··· 1 + /** 2 + * Cell Notes — plain text annotations on individual cells. 3 + * 4 + * Notes are displayed as hover tooltips with a small triangle 5 + * indicator in the top-right corner of the cell. In the real app, 6 + * the notes object is backed by a Yjs Map for collaboration sync. 7 + * 8 + * These functions operate on a plain object { cellId: noteText } 9 + * and return a new object (immutable pattern for testability). 10 + */ 11 + 12 + /** 13 + * Create or set a note on a cell. Trims whitespace. 14 + * If text is empty/null, the note is not created. 15 + * 16 + * @param {Object} notes - Current notes map { cellId: text } 17 + * @param {string} cellId - Cell identifier (e.g., 'A1') 18 + * @param {string|null} text - Note text 19 + * @returns {Object} Updated notes map 20 + */ 21 + export function createNote(notes, cellId, text) { 22 + const result = { ...notes }; 23 + if (text === null || text === undefined) return result; 24 + const trimmed = text.trim(); 25 + if (trimmed === '') return result; 26 + result[cellId] = trimmed; 27 + return result; 28 + } 29 + 30 + /** 31 + * Update an existing note (or create if missing). 32 + * If the updated text is empty, the note is deleted. 33 + * 34 + * @param {Object} notes - Current notes map 35 + * @param {string} cellId - Cell identifier 36 + * @param {string} text - New note text 37 + * @returns {Object} Updated notes map 38 + */ 39 + export function updateNote(notes, cellId, text) { 40 + const result = { ...notes }; 41 + const trimmed = (text || '').trim(); 42 + if (trimmed === '') { 43 + delete result[cellId]; 44 + return result; 45 + } 46 + result[cellId] = trimmed; 47 + return result; 48 + } 49 + 50 + /** 51 + * Delete a note from a cell. 52 + * 53 + * @param {Object} notes - Current notes map 54 + * @param {string} cellId - Cell identifier 55 + * @returns {Object} Updated notes map 56 + */ 57 + export function deleteNote(notes, cellId) { 58 + const result = { ...notes }; 59 + delete result[cellId]; 60 + return result; 61 + } 62 + 63 + /** 64 + * Get the note text for a cell. 65 + * 66 + * @param {Object} notes - Notes map 67 + * @param {string} cellId - Cell identifier 68 + * @returns {string|null} Note text or null 69 + */ 70 + export function getNote(notes, cellId) { 71 + return notes[cellId] ?? null; 72 + } 73 + 74 + /** 75 + * Check if a cell has a note. 76 + * 77 + * @param {Object} notes - Notes map 78 + * @param {string} cellId - Cell identifier 79 + * @returns {boolean} 80 + */ 81 + export function hasNote(notes, cellId) { 82 + return cellId in notes; 83 + } 84 + 85 + /** 86 + * Get all cell IDs that have notes. 87 + * 88 + * @param {Object} notes - Notes map 89 + * @returns {string[]} Array of cell IDs 90 + */ 91 + export function getAllNotes(notes) { 92 + return Object.keys(notes); 93 + }
+135
src/sheets/formula-autocomplete.js
··· 1 + /** 2 + * Formula Auto-Complete — dropdown suggestions when typing formulas. 3 + * 4 + * Provides a list of all supported formula functions with their signatures, 5 + * filtering logic, and keyboard navigation helpers. 6 + */ 7 + 8 + /** 9 + * Complete list of formula functions supported by the formula engine. 10 + * Each entry has a name (uppercase) and a signature hint. 11 + */ 12 + export const FORMULA_FUNCTIONS = [ 13 + // Math & Stats 14 + { name: 'SUM', signature: 'SUM(range1, [range2], ...)' }, 15 + { name: 'AVERAGE', signature: 'AVERAGE(range1, [range2], ...)' }, 16 + { name: 'COUNT', signature: 'COUNT(range1, [range2], ...)' }, 17 + { name: 'COUNTA', signature: 'COUNTA(range1, [range2], ...)' }, 18 + { name: 'MIN', signature: 'MIN(range1, [range2], ...)' }, 19 + { name: 'MAX', signature: 'MAX(range1, [range2], ...)' }, 20 + { name: 'MEDIAN', signature: 'MEDIAN(range1, [range2], ...)' }, 21 + { name: 'STDEV', signature: 'STDEV(range1, [range2], ...)' }, 22 + { name: 'ABS', signature: 'ABS(number)' }, 23 + { name: 'ROUND', signature: 'ROUND(number, [num_digits])' }, 24 + { name: 'ROUNDUP', signature: 'ROUNDUP(number, [num_digits])' }, 25 + { name: 'ROUNDDOWN', signature: 'ROUNDDOWN(number, [num_digits])' }, 26 + { name: 'INT', signature: 'INT(number)' }, 27 + { name: 'MOD', signature: 'MOD(number, divisor)' }, 28 + { name: 'POWER', signature: 'POWER(number, power)' }, 29 + { name: 'SQRT', signature: 'SQRT(number)' }, 30 + { name: 'LOG', signature: 'LOG(number, [base])' }, 31 + { name: 'LN', signature: 'LN(number)' }, 32 + { name: 'EXP', signature: 'EXP(number)' }, 33 + { name: 'PI', signature: 'PI()' }, 34 + { name: 'RAND', signature: 'RAND()' }, 35 + 36 + // Logical 37 + { name: 'IF', signature: 'IF(condition, value_if_true, [value_if_false])' }, 38 + { name: 'AND', signature: 'AND(logical1, [logical2], ...)' }, 39 + { name: 'OR', signature: 'OR(logical1, [logical2], ...)' }, 40 + { name: 'NOT', signature: 'NOT(logical)' }, 41 + { name: 'IFERROR', signature: 'IFERROR(value, value_if_error)' }, 42 + 43 + // Text 44 + { name: 'CONCATENATE', signature: 'CONCATENATE(text1, [text2], ...)' }, 45 + { name: 'LEN', signature: 'LEN(text)' }, 46 + { name: 'LEFT', signature: 'LEFT(text, [num_chars])' }, 47 + { name: 'RIGHT', signature: 'RIGHT(text, [num_chars])' }, 48 + { name: 'MID', signature: 'MID(text, start_num, num_chars)' }, 49 + { name: 'UPPER', signature: 'UPPER(text)' }, 50 + { name: 'LOWER', signature: 'LOWER(text)' }, 51 + { name: 'TRIM', signature: 'TRIM(text)' }, 52 + { name: 'SUBSTITUTE', signature: 'SUBSTITUTE(text, old_text, new_text, [instance])' }, 53 + { name: 'FIND', signature: 'FIND(find_text, within_text, [start_num])' }, 54 + { name: 'SEARCH', signature: 'SEARCH(find_text, within_text, [start_num])' }, 55 + { name: 'TEXT', signature: 'TEXT(value, format_text)' }, 56 + { name: 'VALUE', signature: 'VALUE(text)' }, 57 + 58 + // Date 59 + { name: 'NOW', signature: 'NOW()' }, 60 + { name: 'TODAY', signature: 'TODAY()' }, 61 + { name: 'DATE', signature: 'DATE(year, month, day)' }, 62 + { name: 'YEAR', signature: 'YEAR(date)' }, 63 + { name: 'MONTH', signature: 'MONTH(date)' }, 64 + { name: 'DAY', signature: 'DAY(date)' }, 65 + 66 + // Lookup 67 + { name: 'VLOOKUP', signature: 'VLOOKUP(lookup_value, table_array, col_index, [range_lookup])' }, 68 + { name: 'HLOOKUP', signature: 'HLOOKUP(lookup_value, table_array, row_index, [range_lookup])' }, 69 + { name: 'INDEX', signature: 'INDEX(array, row_num, [col_num])' }, 70 + { name: 'MATCH', signature: 'MATCH(lookup_value, lookup_array, [match_type])' }, 71 + 72 + // Conditional 73 + { name: 'SUMIF', signature: 'SUMIF(range, criteria, [sum_range])' }, 74 + { name: 'COUNTIF', signature: 'COUNTIF(range, criteria)' }, 75 + { name: 'AVERAGEIF', signature: 'AVERAGEIF(range, criteria, [average_range])' }, 76 + ]; 77 + 78 + /** 79 + * Filter the function list based on a partial query string. 80 + * Case insensitive prefix matching. Results are sorted so that 81 + * exact matches and shorter names appear first. 82 + * 83 + * @param {string} query - The partial function name typed by the user 84 + * @returns {Array<{name: string, signature: string}>} 85 + */ 86 + export function filterFunctions(query) { 87 + if (!query) return [...FORMULA_FUNCTIONS]; 88 + 89 + const upper = query.toUpperCase(); 90 + const matches = FORMULA_FUNCTIONS.filter(fn => 91 + fn.name.startsWith(upper) 92 + ); 93 + 94 + // Sort: exact match first, then by name length (shorter = more relevant) 95 + matches.sort((a, b) => { 96 + if (a.name === upper && b.name !== upper) return -1; 97 + if (b.name === upper && a.name !== upper) return 1; 98 + return a.name.length - b.name.length; 99 + }); 100 + 101 + return matches; 102 + } 103 + 104 + /** 105 + * Navigate the autocomplete dropdown with arrow keys. 106 + * Wraps around at boundaries. 107 + * 108 + * @param {number} currentIndex - Current selected index (-1 = none) 109 + * @param {number} itemCount - Total number of items 110 + * @param {'up'|'down'} direction 111 + * @returns {number} New selected index 112 + */ 113 + export function navigateAutocomplete(currentIndex, itemCount, direction) { 114 + if (itemCount === 0) return -1; 115 + 116 + if (direction === 'down') { 117 + if (currentIndex < 0) return 0; 118 + return (currentIndex + 1) % itemCount; 119 + } else { 120 + if (currentIndex <= 0) return itemCount - 1; 121 + return currentIndex - 1; 122 + } 123 + } 124 + 125 + /** 126 + * Get the selected function from the filtered list. 127 + * 128 + * @param {number} index - Selected index 129 + * @param {Array} items - Filtered function list 130 + * @returns {{ name: string, signature: string } | null} 131 + */ 132 + export function getSelectedFunction(index, items) { 133 + if (index < 0 || index >= items.length) return null; 134 + return items[index]; 135 + }
+11
src/sheets/index.html
··· 198 198 <!-- Charts section --> 199 199 <div class="charts-section" id="charts-section"></div> 200 200 201 + <!-- Status bar (selection stats) --> 202 + <div class="status-bar" id="status-bar" style="display:none"> 203 + <div class="status-bar-stats" id="status-bar-stats"></div> 204 + </div> 205 + 201 206 <!-- Sheet tabs --> 202 207 <div class="sheet-tabs" id="sheet-tabs"> 203 208 <button class="sheet-tab active" data-sheet="0">Sheet 1</button> 204 209 <button class="sheet-tab-add" id="add-sheet" title="Add sheet">+</button> 205 210 </div> 211 + 212 + <!-- Formula autocomplete dropdown --> 213 + <div class="formula-autocomplete" id="formula-autocomplete" style="display:none"></div> 214 + 215 + <!-- Cell note tooltip --> 216 + <div class="cell-note-tooltip" id="cell-note-tooltip" style="display:none"></div> 206 217 </div> 207 218 208 219 <script type="module" src="./main.js"></script>
+397 -1
src/sheets/main.js
··· 16 16 import { evaluateRules, buildCfStyle } from './conditional-format.js'; 17 17 import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 18 18 import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js'; 19 + import { computeSelectionStats, formatStatValue } from './status-bar.js'; 20 + import { FORMULA_FUNCTIONS, filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js'; 21 + import { createNote, updateNote, deleteNote, getNote, hasNote, getAllNotes } from './cell-notes.js'; 19 22 20 23 // --- Constants --- 21 24 const DEFAULT_ROWS = 100; ··· 352 355 attachGridEvents(); 353 356 updateSelectionVisuals(); 354 357 updateFreezeToolbarState(); 358 + renderNoteIndicators(); 355 359 } 356 360 357 361 function getCellData(id) { ··· 650 654 input.select(); 651 655 formulaInput.value = value; 652 656 input.addEventListener('keydown', onEditKeyDown); 653 - input.addEventListener('blur', () => commitEdit()); 657 + input.addEventListener('blur', () => { hideAutocomplete(); commitEdit(); }); 658 + // Attach formula autocomplete to cell editor 659 + attachCellEditorAutocomplete(input); 654 660 } 655 661 656 662 function commitEdit() { ··· 843 849 cellAddressInput.value = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 844 850 } 845 851 } 852 + updateStatusBar(); 846 853 } 847 854 848 855 function updateFormulaBar() { ··· 2321 2328 document.getElementById('tb-striped').classList.toggle('active', getStripedRows()); 2322 2329 } 2323 2330 2331 + // ======================================================== 2332 + // Status Bar (SUM/AVG/COUNT/MIN/MAX of selection) 2333 + // ======================================================== 2334 + 2335 + const statusBar = document.getElementById('status-bar'); 2336 + const statusBarStats = document.getElementById('status-bar-stats'); 2337 + 2338 + function updateStatusBar() { 2339 + if (!selectionRange) { 2340 + statusBar.style.display = 'none'; 2341 + return; 2342 + } 2343 + const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 2344 + const isMultiCell = startCol !== endCol || startRow !== endRow; 2345 + if (!isMultiCell) { 2346 + statusBar.style.display = 'none'; 2347 + return; 2348 + } 2349 + 2350 + // Collect cell values from the selection 2351 + const values = []; 2352 + for (let r = startRow; r <= endRow; r++) { 2353 + for (let c = startCol; c <= endCol; c++) { 2354 + const id = cellId(c, r); 2355 + const cellData = getCellData(id); 2356 + const display = computeDisplayValue(id, cellData); 2357 + values.push(display); 2358 + } 2359 + } 2360 + 2361 + const stats = computeSelectionStats(values); 2362 + if (!stats) { 2363 + statusBar.style.display = 'none'; 2364 + return; 2365 + } 2366 + 2367 + statusBar.style.display = ''; 2368 + let html = ''; 2369 + if (stats.count > 0) { 2370 + 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>'; 2371 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Avg</span><span class="status-bar-stat-value">' + formatStatValue(stats.average) + '</span></span>'; 2372 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Count</span><span class="status-bar-stat-value">' + stats.count + '</span></span>'; 2373 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Min</span><span class="status-bar-stat-value">' + formatStatValue(stats.min) + '</span></span>'; 2374 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Max</span><span class="status-bar-stat-value">' + formatStatValue(stats.max) + '</span></span>'; 2375 + } else { 2376 + html += '<span class="status-bar-stat"><span class="status-bar-stat-label">Count</span><span class="status-bar-stat-value">0</span></span>'; 2377 + } 2378 + statusBarStats.innerHTML = html; 2379 + } 2380 + 2381 + // ======================================================== 2382 + // Formula Auto-Complete 2383 + // ======================================================== 2384 + 2385 + const autocompleteEl = document.getElementById('formula-autocomplete'); 2386 + let acFiltered = []; 2387 + let acSelectedIndex = -1; 2388 + let acActive = false; 2389 + 2390 + function showAutocomplete(inputEl, query) { 2391 + acFiltered = filterFunctions(query); 2392 + if (acFiltered.length === 0) { 2393 + hideAutocomplete(); 2394 + return; 2395 + } 2396 + acSelectedIndex = -1; 2397 + acActive = true; 2398 + renderAutocompleteList(); 2399 + positionAutocomplete(inputEl); 2400 + autocompleteEl.style.display = ''; 2401 + } 2402 + 2403 + function hideAutocomplete() { 2404 + acActive = false; 2405 + acFiltered = []; 2406 + acSelectedIndex = -1; 2407 + autocompleteEl.style.display = 'none'; 2408 + } 2409 + 2410 + function renderAutocompleteList() { 2411 + let html = ''; 2412 + acFiltered.forEach((fn, i) => { 2413 + const cls = i === acSelectedIndex ? 'formula-autocomplete-item selected' : 'formula-autocomplete-item'; 2414 + html += '<div class="' + cls + '" data-ac-index="' + i + '">' 2415 + + '<span class="formula-autocomplete-name">' + fn.name + '</span>' 2416 + + '<span class="formula-autocomplete-signature">' + fn.signature + '</span>' 2417 + + '</div>'; 2418 + }); 2419 + autocompleteEl.innerHTML = html; 2420 + 2421 + // Scroll selected item into view 2422 + const selected = autocompleteEl.querySelector('.selected'); 2423 + if (selected) selected.scrollIntoView({ block: 'nearest' }); 2424 + } 2425 + 2426 + function positionAutocomplete(inputEl) { 2427 + const rect = inputEl.getBoundingClientRect(); 2428 + autocompleteEl.style.left = rect.left + 'px'; 2429 + autocompleteEl.style.top = (rect.bottom + 2) + 'px'; 2430 + } 2431 + 2432 + function acceptAutocomplete(inputEl) { 2433 + const fn = getSelectedFunction(acSelectedIndex, acFiltered); 2434 + if (!fn) { 2435 + hideAutocomplete(); 2436 + return; 2437 + } 2438 + // Insert function name + opening paren into the input 2439 + const value = inputEl.value; 2440 + // Find the start of the function name being typed (after = or last operator) 2441 + const eqIdx = value.lastIndexOf('='); 2442 + const prefix = eqIdx >= 0 ? value.slice(0, eqIdx + 1) : '='; 2443 + inputEl.value = prefix + fn.name + '('; 2444 + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); 2445 + hideAutocomplete(); 2446 + } 2447 + 2448 + // Attach autocomplete to formula bar input 2449 + function handleFormulaAutocompleteKeyDown(e, inputEl) { 2450 + if (!acActive) return false; 2451 + 2452 + if (e.key === 'ArrowDown') { 2453 + e.preventDefault(); 2454 + acSelectedIndex = navigateAutocomplete(acSelectedIndex, acFiltered.length, 'down'); 2455 + renderAutocompleteList(); 2456 + return true; 2457 + } 2458 + if (e.key === 'ArrowUp') { 2459 + e.preventDefault(); 2460 + acSelectedIndex = navigateAutocomplete(acSelectedIndex, acFiltered.length, 'up'); 2461 + renderAutocompleteList(); 2462 + return true; 2463 + } 2464 + if (e.key === 'Tab' || e.key === 'Enter') { 2465 + if (acSelectedIndex >= 0) { 2466 + e.preventDefault(); 2467 + acceptAutocomplete(inputEl); 2468 + return true; 2469 + } 2470 + } 2471 + if (e.key === 'Escape') { 2472 + e.preventDefault(); 2473 + hideAutocomplete(); 2474 + return true; 2475 + } 2476 + return false; 2477 + } 2478 + 2479 + function handleFormulaAutocompleteInput(inputEl) { 2480 + const value = inputEl.value; 2481 + // Only activate after `=` 2482 + if (!value.includes('=')) { 2483 + hideAutocomplete(); 2484 + return; 2485 + } 2486 + // Extract the function name being typed (last token after = or operator) 2487 + const afterEq = value.slice(value.lastIndexOf('=') + 1); 2488 + // Get the last word-like token being typed 2489 + const match = afterEq.match(/([A-Za-z]+)$/); 2490 + if (match) { 2491 + showAutocomplete(inputEl, match[1]); 2492 + } else if (afterEq === '') { 2493 + // Just typed `=`, show all functions 2494 + showAutocomplete(inputEl, ''); 2495 + } else { 2496 + hideAutocomplete(); 2497 + } 2498 + } 2499 + 2500 + // Wire autocomplete into formula bar 2501 + formulaInput.addEventListener('input', () => handleFormulaAutocompleteInput(formulaInput)); 2502 + 2503 + // Intercept keydown on formula bar for autocomplete nav 2504 + const origFormulaKeydown = formulaInput.onkeydown; 2505 + formulaInput.addEventListener('keydown', (e) => { 2506 + if (handleFormulaAutocompleteKeyDown(e, formulaInput)) return; 2507 + }, true); 2508 + 2509 + // Wire autocomplete into cell editor inputs 2510 + function attachCellEditorAutocomplete(inputEl) { 2511 + inputEl.addEventListener('input', () => handleFormulaAutocompleteInput(inputEl)); 2512 + inputEl.addEventListener('keydown', (e) => { 2513 + if (handleFormulaAutocompleteKeyDown(e, inputEl)) return; 2514 + }, true); 2515 + } 2516 + 2517 + // Click on autocomplete items 2518 + autocompleteEl.addEventListener('mousedown', (e) => { 2519 + const item = e.target.closest('.formula-autocomplete-item'); 2520 + if (!item) return; 2521 + e.preventDefault(); 2522 + acSelectedIndex = parseInt(item.dataset.acIndex); 2523 + // Find the active input 2524 + const activeInput = document.activeElement; 2525 + if (activeInput && (activeInput === formulaInput || activeInput.classList.contains('cell-editor'))) { 2526 + acceptAutocomplete(activeInput); 2527 + } 2528 + }); 2529 + 2530 + // Hide autocomplete on blur 2531 + document.addEventListener('click', (e) => { 2532 + if (!autocompleteEl.contains(e.target) && e.target !== formulaInput && !e.target.classList.contains('cell-editor')) { 2533 + hideAutocomplete(); 2534 + } 2535 + }); 2536 + 2537 + // ======================================================== 2538 + // Cell Notes 2539 + // ======================================================== 2540 + 2541 + const noteTooltip = document.getElementById('cell-note-tooltip'); 2542 + 2543 + function getNotesMap() { 2544 + const sheet = getActiveSheet(); 2545 + if (!sheet.has('notes')) sheet.set('notes', new Y.Map()); 2546 + return sheet.get('notes'); 2547 + } 2548 + 2549 + function getNotesObject() { 2550 + const yNotes = getNotesMap(); 2551 + const obj = {}; 2552 + yNotes.forEach((val, key) => { obj[key] = val; }); 2553 + return obj; 2554 + } 2555 + 2556 + function setNoteInYjs(cellIdStr, text) { 2557 + const yNotes = getNotesMap(); 2558 + if (text) { 2559 + yNotes.set(cellIdStr, text); 2560 + } else { 2561 + if (yNotes.has(cellIdStr)) yNotes.delete(cellIdStr); 2562 + } 2563 + } 2564 + 2565 + function showNoteTooltip(cellIdStr, td) { 2566 + const notes = getNotesObject(); 2567 + const text = getNote(notes, cellIdStr); 2568 + if (!text) return; 2569 + 2570 + noteTooltip.textContent = text; 2571 + noteTooltip.style.display = ''; 2572 + 2573 + const rect = td.getBoundingClientRect(); 2574 + noteTooltip.style.left = (rect.right + 4) + 'px'; 2575 + noteTooltip.style.top = rect.top + 'px'; 2576 + 2577 + // Adjust if tooltip goes off-screen 2578 + requestAnimationFrame(() => { 2579 + const ttRect = noteTooltip.getBoundingClientRect(); 2580 + if (ttRect.right > window.innerWidth) { 2581 + noteTooltip.style.left = (rect.left - ttRect.width - 4) + 'px'; 2582 + } 2583 + if (ttRect.bottom > window.innerHeight) { 2584 + noteTooltip.style.top = (rect.bottom - ttRect.height) + 'px'; 2585 + } 2586 + }); 2587 + } 2588 + 2589 + function hideNoteTooltip() { 2590 + noteTooltip.style.display = 'none'; 2591 + } 2592 + 2593 + function showNoteDialog(cellIdStr) { 2594 + const notes = getNotesObject(); 2595 + const existing = getNote(notes, cellIdStr); 2596 + 2597 + const overlay = document.createElement('div'); 2598 + overlay.className = 'sheet-dialog-overlay'; 2599 + overlay.innerHTML = '<div class="sheet-dialog">' 2600 + + '<h3>' + (existing ? 'Edit' : 'Add') + ' Note for ' + cellIdStr + '</h3>' 2601 + + '<textarea id="note-text" rows="4" style="width:100%;resize:vertical;font-family:var(--font-body);font-size:0.85rem;padding:6px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);">' + (existing || '') + '</textarea>' 2602 + + '<div class="sheet-dialog-actions">' 2603 + + (existing ? '<button id="note-delete" class="btn-danger" style="margin-right:auto;">Delete</button>' : '') 2604 + + '<button id="note-cancel">Cancel</button>' 2605 + + '<button id="note-save" class="btn-primary">Save</button>' 2606 + + '</div></div>'; 2607 + 2608 + document.body.appendChild(overlay); 2609 + 2610 + const textarea = overlay.querySelector('#note-text'); 2611 + setTimeout(() => textarea.focus(), 50); 2612 + 2613 + overlay.querySelector('#note-cancel').addEventListener('click', () => overlay.remove()); 2614 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 2615 + 2616 + overlay.querySelector('#note-save').addEventListener('click', () => { 2617 + const text = textarea.value.trim(); 2618 + if (text) { 2619 + setNoteInYjs(cellIdStr, text); 2620 + } else { 2621 + setNoteInYjs(cellIdStr, null); 2622 + } 2623 + overlay.remove(); 2624 + renderNoteIndicators(); 2625 + }); 2626 + 2627 + if (existing) { 2628 + overlay.querySelector('#note-delete').addEventListener('click', () => { 2629 + setNoteInYjs(cellIdStr, null); 2630 + overlay.remove(); 2631 + renderNoteIndicators(); 2632 + }); 2633 + } 2634 + } 2635 + 2636 + function renderNoteIndicators() { 2637 + // Remove existing indicators 2638 + grid.querySelectorAll('.cell-note-indicator').forEach(el => el.remove()); 2639 + grid.querySelectorAll('.has-note').forEach(el => el.classList.remove('has-note')); 2640 + 2641 + const notes = getNotesObject(); 2642 + const cellIds = getAllNotes(notes); 2643 + for (const cid of cellIds) { 2644 + const td = grid.querySelector('td[data-id="' + cid + '"]'); 2645 + if (td) { 2646 + td.classList.add('has-note'); 2647 + const indicator = document.createElement('div'); 2648 + indicator.className = 'cell-note-indicator'; 2649 + td.appendChild(indicator); 2650 + } 2651 + } 2652 + } 2653 + 2654 + // Show note tooltip on hover 2655 + grid.addEventListener('mouseover', (e) => { 2656 + const td = e.target.closest('td[data-id]'); 2657 + if (!td) return; 2658 + const cid = td.dataset.id; 2659 + const notes = getNotesObject(); 2660 + if (hasNote(notes, cid)) { 2661 + showNoteTooltip(cid, td); 2662 + } 2663 + }); 2664 + 2665 + grid.addEventListener('mouseout', (e) => { 2666 + const td = e.target.closest('td[data-id]'); 2667 + if (td) hideNoteTooltip(); 2668 + }); 2669 + 2670 + // Right-click context menu for notes 2671 + grid.addEventListener('contextmenu', (e) => { 2672 + const td = e.target.closest('td[data-id]'); 2673 + if (!td) return; 2674 + e.preventDefault(); 2675 + 2676 + const cid = td.dataset.id; 2677 + const notes = getNotesObject(); 2678 + const noteExists = hasNote(notes, cid); 2679 + 2680 + // Create context menu 2681 + const existing = document.querySelector('.cell-context-menu'); 2682 + if (existing) existing.remove(); 2683 + 2684 + const menu = document.createElement('div'); 2685 + menu.className = 'cell-context-menu'; 2686 + menu.style.cssText = 'position:fixed;z-index:1002;background:var(--color-bg);border:1px solid var(--color-border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-md);padding:2px 0;min-width:140px;'; 2687 + menu.innerHTML = '<button class="toolbar-dropdown-item" data-action="add-note">' 2688 + + '<span class="item-label">' + (noteExists ? 'Edit note' : 'Add note') + '</span></button>' 2689 + + (noteExists ? '<button class="toolbar-dropdown-item" data-action="delete-note"><span class="item-label">Delete note</span></button>' : ''); 2690 + 2691 + menu.style.left = e.clientX + 'px'; 2692 + menu.style.top = e.clientY + 'px'; 2693 + document.body.appendChild(menu); 2694 + 2695 + menu.addEventListener('click', (ev) => { 2696 + const action = ev.target.closest('[data-action]')?.dataset.action; 2697 + if (action === 'add-note') { 2698 + showNoteDialog(cid); 2699 + } else if (action === 'delete-note') { 2700 + setNoteInYjs(cid, null); 2701 + renderNoteIndicators(); 2702 + } 2703 + menu.remove(); 2704 + }); 2705 + 2706 + // Close on click outside 2707 + const closeMenu = (ev) => { 2708 + if (!menu.contains(ev.target)) { 2709 + menu.remove(); 2710 + document.removeEventListener('click', closeMenu); 2711 + } 2712 + }; 2713 + setTimeout(() => document.addEventListener('click', closeMenu), 0); 2714 + }); 2715 + 2716 + // Observe notes changes from collaborators 2717 + getNotesMap().observe(() => renderNoteIndicators()); 2718 + 2324 2719 // --- Initial render --- 2325 2720 ensureSheet(0); 2326 2721 renderGrid(); ··· 2329 2724 updateMergeButtonState(); 2330 2725 renderCharts(); 2331 2726 updateStripedButtonState(); 2727 + renderNoteIndicators();
+67
src/sheets/status-bar.js
··· 1 + /** 2 + * Status Bar — aggregate stats for the current selection. 3 + * 4 + * Shows SUM, AVERAGE, COUNT, MIN, MAX when 2+ cells are selected. 5 + * Only counts numeric values for stats computation. 6 + */ 7 + 8 + /** 9 + * Convert a value to a number, returning NaN for non-numeric values. 10 + * Booleans count as numbers (true=1, false=0). 11 + */ 12 + function toNumeric(v) { 13 + if (v === '' || v === null || v === undefined) return NaN; 14 + if (typeof v === 'boolean') return v ? 1 : 0; 15 + if (typeof v === 'number') return v; 16 + const n = Number(v); 17 + return n; 18 + } 19 + 20 + /** 21 + * Compute aggregate statistics for an array of cell values. 22 + * Returns null if fewer than 2 values (single-cell selection). 23 + * 24 + * @param {Array} values - Array of cell display values 25 + * @returns {{ sum: number, average: number, count: number, min: number, max: number } | null} 26 + */ 27 + export function computeSelectionStats(values) { 28 + if (!values || values.length < 2) return null; 29 + 30 + const nums = values.map(toNumeric).filter(n => !isNaN(n)); 31 + 32 + if (nums.length === 0) { 33 + return { sum: 0, average: 0, count: 0, min: 0, max: 0 }; 34 + } 35 + 36 + const sum = nums.reduce((a, b) => a + b, 0); 37 + const average = sum / nums.length; 38 + const min = Math.min(...nums); 39 + const max = Math.max(...nums); 40 + 41 + return { 42 + sum, 43 + average, 44 + count: nums.length, 45 + min, 46 + max, 47 + }; 48 + } 49 + 50 + /** 51 + * Format a stat value for display in the status bar. 52 + * Integers show without decimals; floats show up to 2 decimal places. 53 + * 54 + * @param {number} value 55 + * @returns {string} 56 + */ 57 + export function formatStatValue(value) { 58 + if (value === 0) return '0'; 59 + // Round to 2 decimal places first 60 + const rounded = Math.round(value * 100) / 100; 61 + if (Number.isInteger(rounded)) return rounded.toLocaleString(); 62 + // Remove trailing zeros after decimal point 63 + return rounded.toLocaleString(undefined, { 64 + minimumFractionDigits: 0, 65 + maximumFractionDigits: 2, 66 + }); 67 + }
+151
tests/cell-notes.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createNote, 4 + updateNote, 5 + deleteNote, 6 + getNote, 7 + hasNote, 8 + getAllNotes, 9 + } from '../src/sheets/cell-notes.js'; 10 + 11 + /** 12 + * Tests for Cell Notes feature. 13 + * 14 + * Cell notes are plain text annotations attached to individual cells. 15 + * They show a small triangle indicator in the top-right corner and 16 + * display on hover as a tooltip. 17 + * 18 + * Notes are stored as a simple Map-like structure that will be synced 19 + * via Yjs in the real app. 20 + */ 21 + 22 + describe('createNote', () => { 23 + it('creates a note for a cell', () => { 24 + const notes = {}; 25 + const result = createNote(notes, 'A1', 'This is a note'); 26 + expect(result['A1']).toBe('This is a note'); 27 + }); 28 + 29 + it('overwrites existing note', () => { 30 + const notes = { A1: 'old note' }; 31 + const result = createNote(notes, 'A1', 'new note'); 32 + expect(result['A1']).toBe('new note'); 33 + }); 34 + 35 + it('preserves other notes', () => { 36 + const notes = { B2: 'existing note' }; 37 + const result = createNote(notes, 'A1', 'new note'); 38 + expect(result['A1']).toBe('new note'); 39 + expect(result['B2']).toBe('existing note'); 40 + }); 41 + 42 + it('trims whitespace from note text', () => { 43 + const notes = {}; 44 + const result = createNote(notes, 'A1', ' hello world '); 45 + expect(result['A1']).toBe('hello world'); 46 + }); 47 + 48 + it('returns notes unchanged if text is empty after trimming', () => { 49 + const notes = {}; 50 + const result = createNote(notes, 'A1', ' '); 51 + expect(result['A1']).toBeUndefined(); 52 + }); 53 + 54 + it('returns notes unchanged if text is null', () => { 55 + const notes = {}; 56 + const result = createNote(notes, 'A1', null); 57 + expect(result['A1']).toBeUndefined(); 58 + }); 59 + }); 60 + 61 + describe('updateNote', () => { 62 + it('updates an existing note', () => { 63 + const notes = { A1: 'old text' }; 64 + const result = updateNote(notes, 'A1', 'new text'); 65 + expect(result['A1']).toBe('new text'); 66 + }); 67 + 68 + it('creates note if it does not exist', () => { 69 + const notes = {}; 70 + const result = updateNote(notes, 'A1', 'new text'); 71 + expect(result['A1']).toBe('new text'); 72 + }); 73 + 74 + it('deletes note if updated with empty string', () => { 75 + const notes = { A1: 'existing note' }; 76 + const result = updateNote(notes, 'A1', ''); 77 + expect(result['A1']).toBeUndefined(); 78 + }); 79 + 80 + it('trims text before storing', () => { 81 + const notes = {}; 82 + const result = updateNote(notes, 'C3', ' trimmed '); 83 + expect(result['C3']).toBe('trimmed'); 84 + }); 85 + }); 86 + 87 + describe('deleteNote', () => { 88 + it('deletes an existing note', () => { 89 + const notes = { A1: 'note to delete', B2: 'keep me' }; 90 + const result = deleteNote(notes, 'A1'); 91 + expect(result['A1']).toBeUndefined(); 92 + expect(result['B2']).toBe('keep me'); 93 + }); 94 + 95 + it('is a no-op for non-existent note', () => { 96 + const notes = { B2: 'keep me' }; 97 + const result = deleteNote(notes, 'A1'); 98 + expect(result['B2']).toBe('keep me'); 99 + expect(Object.keys(result)).toEqual(['B2']); 100 + }); 101 + }); 102 + 103 + describe('getNote', () => { 104 + it('returns the note text for a cell', () => { 105 + const notes = { A1: 'hello', B2: 'world' }; 106 + expect(getNote(notes, 'A1')).toBe('hello'); 107 + expect(getNote(notes, 'B2')).toBe('world'); 108 + }); 109 + 110 + it('returns null for cell without a note', () => { 111 + const notes = { A1: 'hello' }; 112 + expect(getNote(notes, 'C3')).toBeNull(); 113 + }); 114 + 115 + it('returns null for empty notes object', () => { 116 + expect(getNote({}, 'A1')).toBeNull(); 117 + }); 118 + }); 119 + 120 + describe('hasNote', () => { 121 + it('returns true for cell with a note', () => { 122 + const notes = { A1: 'hello' }; 123 + expect(hasNote(notes, 'A1')).toBe(true); 124 + }); 125 + 126 + it('returns false for cell without a note', () => { 127 + const notes = { A1: 'hello' }; 128 + expect(hasNote(notes, 'B2')).toBe(false); 129 + }); 130 + 131 + it('returns false for empty notes object', () => { 132 + expect(hasNote({}, 'A1')).toBe(false); 133 + }); 134 + }); 135 + 136 + describe('getAllNotes', () => { 137 + it('returns all cell IDs that have notes', () => { 138 + const notes = { A1: 'note 1', B2: 'note 2', C3: 'note 3' }; 139 + const result = getAllNotes(notes); 140 + expect(result.sort()).toEqual(['A1', 'B2', 'C3']); 141 + }); 142 + 143 + it('returns empty array for empty notes', () => { 144 + expect(getAllNotes({})).toEqual([]); 145 + }); 146 + 147 + it('returns correct count', () => { 148 + const notes = { A1: 'a', B1: 'b' }; 149 + expect(getAllNotes(notes).length).toBe(2); 150 + }); 151 + });
+179
tests/formula-autocomplete.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + FORMULA_FUNCTIONS, 4 + filterFunctions, 5 + getSelectedFunction, 6 + navigateAutocomplete, 7 + } from '../src/sheets/formula-autocomplete.js'; 8 + 9 + /** 10 + * Tests for Formula Auto-Complete feature. 11 + * 12 + * When a user types `=` in a cell or formula bar, an auto-complete dropdown 13 + * should appear showing matching function names with signature hints. 14 + * Arrow keys navigate, Tab/Enter accept, Escape dismisses. 15 + */ 16 + 17 + describe('FORMULA_FUNCTIONS', () => { 18 + it('is an array of function definitions', () => { 19 + expect(Array.isArray(FORMULA_FUNCTIONS)).toBe(true); 20 + expect(FORMULA_FUNCTIONS.length).toBeGreaterThan(0); 21 + }); 22 + 23 + it('each function has name and signature', () => { 24 + for (const fn of FORMULA_FUNCTIONS) { 25 + expect(fn).toHaveProperty('name'); 26 + expect(fn).toHaveProperty('signature'); 27 + expect(typeof fn.name).toBe('string'); 28 + expect(typeof fn.signature).toBe('string'); 29 + // Name should be uppercase 30 + expect(fn.name).toBe(fn.name.toUpperCase()); 31 + } 32 + }); 33 + 34 + it('includes SUM, AVERAGE, COUNT, IF, VLOOKUP', () => { 35 + const names = FORMULA_FUNCTIONS.map(f => f.name); 36 + expect(names).toContain('SUM'); 37 + expect(names).toContain('AVERAGE'); 38 + expect(names).toContain('COUNT'); 39 + expect(names).toContain('IF'); 40 + expect(names).toContain('VLOOKUP'); 41 + }); 42 + 43 + it('includes all functions from the formula engine', () => { 44 + // These are the functions defined in formulas.js callFunction 45 + const expectedNames = [ 46 + 'SUM', 'AVERAGE', 'COUNT', 'COUNTA', 'MIN', 'MAX', 47 + 'ABS', 'ROUND', 'ROUNDUP', 'ROUNDDOWN', 'INT', 'MOD', 'POWER', 'SQRT', 48 + 'LOG', 'LN', 'EXP', 'PI', 'RAND', 49 + 'IF', 'AND', 'OR', 'NOT', 'IFERROR', 50 + 'CONCATENATE', 'LEN', 'LEFT', 'RIGHT', 'MID', 51 + 'UPPER', 'LOWER', 'TRIM', 'SUBSTITUTE', 'FIND', 'SEARCH', 52 + 'TEXT', 'VALUE', 53 + 'NOW', 'TODAY', 'DATE', 'YEAR', 'MONTH', 'DAY', 54 + 'VLOOKUP', 'HLOOKUP', 'INDEX', 'MATCH', 55 + 'SUMIF', 'COUNTIF', 'AVERAGEIF', 56 + 'MEDIAN', 'STDEV', 57 + ]; 58 + const names = FORMULA_FUNCTIONS.map(f => f.name); 59 + for (const name of expectedNames) { 60 + expect(names).toContain(name); 61 + } 62 + }); 63 + 64 + it('signature includes the function name with parentheses', () => { 65 + for (const fn of FORMULA_FUNCTIONS) { 66 + expect(fn.signature).toContain(fn.name); 67 + expect(fn.signature).toContain('('); 68 + expect(fn.signature).toContain(')'); 69 + } 70 + }); 71 + }); 72 + 73 + describe('filterFunctions', () => { 74 + it('returns all functions when query is empty', () => { 75 + const results = filterFunctions(''); 76 + expect(results.length).toBe(FORMULA_FUNCTIONS.length); 77 + }); 78 + 79 + it('filters by prefix (case insensitive)', () => { 80 + const results = filterFunctions('su'); 81 + const names = results.map(f => f.name); 82 + expect(names).toContain('SUM'); 83 + expect(names).toContain('SUMIF'); 84 + expect(names).toContain('SUBSTITUTE'); 85 + // Should not contain unrelated functions 86 + expect(names).not.toContain('AVERAGE'); 87 + }); 88 + 89 + it('handles uppercase query', () => { 90 + const results = filterFunctions('SU'); 91 + const names = results.map(f => f.name); 92 + expect(names).toContain('SUM'); 93 + expect(names).toContain('SUMIF'); 94 + }); 95 + 96 + it('returns empty array for no matches', () => { 97 + const results = filterFunctions('zzzzz'); 98 + expect(results).toEqual([]); 99 + }); 100 + 101 + it('matches single character', () => { 102 + const results = filterFunctions('V'); 103 + const names = results.map(f => f.name); 104 + expect(names).toContain('VLOOKUP'); 105 + expect(names).toContain('VALUE'); 106 + }); 107 + 108 + it('matches full function name', () => { 109 + const results = filterFunctions('AVERAGE'); 110 + expect(results.length).toBeGreaterThanOrEqual(1); 111 + expect(results[0].name).toBe('AVERAGE'); 112 + }); 113 + 114 + it('prioritizes exact prefix matches', () => { 115 + const results = filterFunctions('SUM'); 116 + // SUM should come before SUMIF 117 + const sumIndex = results.findIndex(f => f.name === 'SUM'); 118 + const sumifIndex = results.findIndex(f => f.name === 'SUMIF'); 119 + expect(sumIndex).toBeLessThan(sumifIndex); 120 + }); 121 + }); 122 + 123 + describe('navigateAutocomplete', () => { 124 + const items = [ 125 + { name: 'SUM', signature: 'SUM(range1, [range2], ...)' }, 126 + { name: 'SUMIF', signature: 'SUMIF(range, criteria, [sum_range])' }, 127 + { name: 'SUBSTITUTE', signature: 'SUBSTITUTE(text, old, new, [instance])' }, 128 + ]; 129 + 130 + it('moves down from -1 to 0', () => { 131 + expect(navigateAutocomplete(-1, items.length, 'down')).toBe(0); 132 + }); 133 + 134 + it('moves down wrapping around', () => { 135 + expect(navigateAutocomplete(2, items.length, 'down')).toBe(0); 136 + }); 137 + 138 + it('moves up from 0 wraps to last', () => { 139 + expect(navigateAutocomplete(0, items.length, 'up')).toBe(2); 140 + }); 141 + 142 + it('moves up from middle', () => { 143 + expect(navigateAutocomplete(2, items.length, 'up')).toBe(1); 144 + }); 145 + 146 + it('handles single item list', () => { 147 + expect(navigateAutocomplete(0, 1, 'down')).toBe(0); 148 + expect(navigateAutocomplete(0, 1, 'up')).toBe(0); 149 + }); 150 + 151 + it('handles empty list', () => { 152 + expect(navigateAutocomplete(-1, 0, 'down')).toBe(-1); 153 + expect(navigateAutocomplete(-1, 0, 'up')).toBe(-1); 154 + }); 155 + }); 156 + 157 + describe('getSelectedFunction', () => { 158 + const items = [ 159 + { name: 'SUM', signature: 'SUM(range1, [range2], ...)' }, 160 + { name: 'SUMIF', signature: 'SUMIF(range, criteria, [sum_range])' }, 161 + ]; 162 + 163 + it('returns the selected function by index', () => { 164 + expect(getSelectedFunction(0, items)).toEqual(items[0]); 165 + expect(getSelectedFunction(1, items)).toEqual(items[1]); 166 + }); 167 + 168 + it('returns null for -1 index', () => { 169 + expect(getSelectedFunction(-1, items)).toBeNull(); 170 + }); 171 + 172 + it('returns null for out-of-bounds index', () => { 173 + expect(getSelectedFunction(5, items)).toBeNull(); 174 + }); 175 + 176 + it('returns null for empty items', () => { 177 + expect(getSelectedFunction(0, [])).toBeNull(); 178 + }); 179 + });
+157
tests/status-bar.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { computeSelectionStats, formatStatValue } from '../src/sheets/status-bar.js'; 3 + 4 + /** 5 + * Tests for the Status Bar feature. 6 + * 7 + * The status bar shows aggregate stats (SUM, AVERAGE, COUNT, MIN, MAX) 8 + * for the currently selected range. It only appears when 2+ cells are selected. 9 + */ 10 + 11 + describe('computeSelectionStats', () => { 12 + it('returns null for empty values array', () => { 13 + expect(computeSelectionStats([])).toBeNull(); 14 + }); 15 + 16 + it('returns null for single cell selection', () => { 17 + expect(computeSelectionStats([42])).toBeNull(); 18 + }); 19 + 20 + it('computes correct stats for numeric values', () => { 21 + const values = [10, 20, 30, 40, 50]; 22 + const stats = computeSelectionStats(values); 23 + expect(stats).not.toBeNull(); 24 + expect(stats.sum).toBe(150); 25 + expect(stats.average).toBe(30); 26 + expect(stats.count).toBe(5); 27 + expect(stats.min).toBe(10); 28 + expect(stats.max).toBe(50); 29 + }); 30 + 31 + it('ignores empty strings in values', () => { 32 + const values = [10, '', 20, '', 30]; 33 + const stats = computeSelectionStats(values); 34 + expect(stats.sum).toBe(60); 35 + expect(stats.average).toBe(20); 36 + expect(stats.count).toBe(3); 37 + expect(stats.min).toBe(10); 38 + expect(stats.max).toBe(30); 39 + }); 40 + 41 + it('ignores null and undefined values', () => { 42 + const values = [5, null, 15, undefined, 25]; 43 + const stats = computeSelectionStats(values); 44 + expect(stats.sum).toBe(45); 45 + expect(stats.count).toBe(3); 46 + expect(stats.average).toBe(15); 47 + }); 48 + 49 + it('ignores non-numeric strings', () => { 50 + const values = ['hello', 10, 'world', 20, 'foo']; 51 + const stats = computeSelectionStats(values); 52 + expect(stats.sum).toBe(30); 53 + expect(stats.count).toBe(2); 54 + expect(stats.average).toBe(15); 55 + expect(stats.min).toBe(10); 56 + expect(stats.max).toBe(20); 57 + }); 58 + 59 + it('handles all non-numeric values (no count)', () => { 60 + const values = ['hello', 'world', '', null]; 61 + const stats = computeSelectionStats(values); 62 + // Still returns stats object since 2+ cells, but count is 0 63 + expect(stats).not.toBeNull(); 64 + expect(stats.count).toBe(0); 65 + expect(stats.sum).toBe(0); 66 + expect(stats.average).toBe(0); 67 + expect(stats.min).toBe(0); 68 + expect(stats.max).toBe(0); 69 + }); 70 + 71 + it('treats numeric strings as numbers', () => { 72 + const values = ['10', '20', '30']; 73 + const stats = computeSelectionStats(values); 74 + expect(stats.sum).toBe(60); 75 + expect(stats.count).toBe(3); 76 + expect(stats.average).toBe(20); 77 + }); 78 + 79 + it('handles negative numbers', () => { 80 + const values = [-10, 0, 10]; 81 + const stats = computeSelectionStats(values); 82 + expect(stats.sum).toBe(0); 83 + expect(stats.average).toBeCloseTo(0); 84 + expect(stats.min).toBe(-10); 85 + expect(stats.max).toBe(10); 86 + expect(stats.count).toBe(3); 87 + }); 88 + 89 + it('handles single numeric among many empty/text (2+ total cells)', () => { 90 + const values = ['', 42, '']; 91 + const stats = computeSelectionStats(values); 92 + expect(stats.sum).toBe(42); 93 + expect(stats.count).toBe(1); 94 + expect(stats.average).toBe(42); 95 + expect(stats.min).toBe(42); 96 + expect(stats.max).toBe(42); 97 + }); 98 + 99 + it('handles floating point values', () => { 100 + const values = [1.5, 2.5, 3.0]; 101 + const stats = computeSelectionStats(values); 102 + expect(stats.sum).toBeCloseTo(7.0); 103 + expect(stats.average).toBeCloseTo(7 / 3); 104 + expect(stats.min).toBe(1.5); 105 + expect(stats.max).toBe(3.0); 106 + expect(stats.count).toBe(3); 107 + }); 108 + 109 + it('handles boolean values (true=1, false=0)', () => { 110 + const values = [true, false, 10]; 111 + const stats = computeSelectionStats(values); 112 + expect(stats.sum).toBe(11); 113 + expect(stats.count).toBe(3); 114 + expect(stats.min).toBe(0); 115 + expect(stats.max).toBe(10); 116 + }); 117 + 118 + it('requires at least 2 values in the array for stats', () => { 119 + // single-cell selections should not trigger the status bar 120 + expect(computeSelectionStats([100])).toBeNull(); 121 + // but two values should work 122 + const stats = computeSelectionStats([100, 200]); 123 + expect(stats).not.toBeNull(); 124 + expect(stats.sum).toBe(300); 125 + }); 126 + }); 127 + 128 + describe('formatStatValue', () => { 129 + it('formats integers without decimals', () => { 130 + expect(formatStatValue(100)).toBe('100'); 131 + }); 132 + 133 + it('formats floats to 2 decimal places', () => { 134 + expect(formatStatValue(33.333333)).toBe('33.33'); 135 + }); 136 + 137 + it('formats zero', () => { 138 + expect(formatStatValue(0)).toBe('0'); 139 + }); 140 + 141 + it('formats negative values', () => { 142 + expect(formatStatValue(-5.5)).toBe('-5.5'); 143 + }); 144 + 145 + it('formats large numbers with commas', () => { 146 + // Locale-dependent, but should at least not crash 147 + const result = formatStatValue(1000000); 148 + expect(result).toBeTruthy(); 149 + expect(typeof result).toBe('string'); 150 + }); 151 + 152 + it('rounds to at most 2 decimal places', () => { 153 + expect(formatStatValue(1.999)).toBe('2'); 154 + expect(formatStatValue(3.456)).toBe('3.46'); 155 + expect(formatStatValue(10.129)).toBe('10.13'); 156 + }); 157 + });