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): rich error tooltip popups on hover

Replace browser title-attribute tooltips with structured popup tooltips
for formula errors. Each error type shows title, description, and
actionable hint. Extracted error descriptions into a dedicated module
with 22 unit tests.

Closes #208

+356 -13
+39
src/css/app.css
··· 5128 5128 } 5129 5129 5130 5130 /* ======================================================== 5131 + Error Tooltips (#208) 5132 + ======================================================== */ 5133 + 5134 + .cell-error { 5135 + color: var(--color-red) !important; 5136 + } 5137 + 5138 + .error-tooltip { 5139 + position: absolute; 5140 + z-index: 1002; 5141 + max-width: 300px; 5142 + padding: 8px 12px; 5143 + background: var(--color-surface-alt); 5144 + border: 1px solid var(--color-red); 5145 + border-radius: var(--radius-md); 5146 + box-shadow: var(--shadow-md); 5147 + font-family: var(--font-body); 5148 + font-size: 0.75rem; 5149 + color: var(--color-text); 5150 + line-height: 1.4; 5151 + pointer-events: none; 5152 + } 5153 + 5154 + .error-tooltip-title { 5155 + font-weight: 700; 5156 + color: var(--color-red); 5157 + margin-bottom: 2px; 5158 + } 5159 + 5160 + .error-tooltip-desc { 5161 + margin-bottom: 4px; 5162 + } 5163 + 5164 + .error-tooltip-hint { 5165 + font-style: italic; 5166 + opacity: 0.8; 5167 + } 5168 + 5169 + /* ======================================================== 5131 5170 Onboarding Tooltip 5132 5171 ======================================================== */ 5133 5172
+95
src/sheets/error-tooltips.ts
··· 1 + /** 2 + * Error Tooltips — structured descriptions for spreadsheet formula errors. 3 + * 4 + * Each error type gets a title, description, and actionable hint. 5 + * Used by the grid renderer to show rich tooltips on hover and 6 + * to apply the `cell-error` CSS class for visual treatment. 7 + */ 8 + 9 + export interface ErrorInfo { 10 + title: string; 11 + description: string; 12 + hint: string; 13 + } 14 + 15 + export const ERROR_DESCRIPTIONS: Record<string, ErrorInfo> = { 16 + '#DIV/0!': { 17 + title: 'Division by Zero', 18 + description: 'A formula is trying to divide by zero or an empty cell.', 19 + hint: 'Check the divisor in your formula. Use IF() to handle zero values.', 20 + }, 21 + '#REF!': { 22 + title: 'Invalid Reference', 23 + description: 'A formula refers to a cell that has been deleted or is out of range.', 24 + hint: 'Check if referenced cells were deleted or moved.', 25 + }, 26 + '#VALUE!': { 27 + title: 'Wrong Value Type', 28 + description: 'A formula has the wrong type of argument (e.g., text instead of number).', 29 + hint: 'Ensure arguments match the expected types. Use VALUE() to convert text.', 30 + }, 31 + '#NAME?': { 32 + title: 'Unrecognized Name', 33 + description: 'The formula contains a name that is not recognized.', 34 + hint: 'Check for typos in function names or named ranges.', 35 + }, 36 + '#NULL!': { 37 + title: 'Null Intersection', 38 + description: 'Two ranges do not intersect.', 39 + hint: 'Check the range references in your formula.', 40 + }, 41 + '#N/A': { 42 + title: 'Value Not Available', 43 + description: 'A lookup function could not find a matching value.', 44 + hint: 'Check the lookup value and range. Use IFERROR() to handle missing values.', 45 + }, 46 + '#ERROR!': { 47 + title: 'Formula Error', 48 + description: 'The formula could not be evaluated.', 49 + hint: 'Check the formula syntax and references.', 50 + }, 51 + '#CIRCULAR!': { 52 + title: 'Circular Reference', 53 + description: 'The formula refers to its own cell, directly or indirectly.', 54 + hint: 'Remove the self-reference or restructure the formula.', 55 + }, 56 + '#NUM!': { 57 + title: 'Invalid Number', 58 + description: 'A formula produced a number that is too large, too small, or invalid.', 59 + hint: 'Check for operations that produce infinity or invalid results.', 60 + }, 61 + }; 62 + 63 + /** 64 + * Check whether a display value is a formula error. 65 + * Errors start with `#` and end with `!` or `?`. 66 + */ 67 + export function isErrorValue(value: string): boolean { 68 + if (typeof value !== 'string') return false; 69 + if (!value.startsWith('#')) return false; 70 + // Match known patterns: ends with ! or ? (possibly with extra info like "#NAME? (foo)") 71 + return value.endsWith('!') || value.includes('?') || value === '#N/A'; 72 + } 73 + 74 + /** 75 + * Look up structured error info for a given error value. 76 + * Returns null for unrecognized errors. 77 + */ 78 + export function getErrorInfo(value: string): ErrorInfo | null { 79 + if (typeof value !== 'string') return null; 80 + // Direct match first 81 + if (ERROR_DESCRIPTIONS[value]) return ERROR_DESCRIPTIONS[value]; 82 + // Prefix match for errors with extra info (e.g. "#NAME? (UNKNOWNFUNC)") 83 + for (const key of Object.keys(ERROR_DESCRIPTIONS)) { 84 + if (value.startsWith(key)) return ERROR_DESCRIPTIONS[key]; 85 + } 86 + return null; 87 + } 88 + 89 + /** 90 + * Format error info into a plain-text tooltip string. 91 + * Used as fallback for `title` attributes and accessibility. 92 + */ 93 + export function formatErrorTooltip(info: ErrorInfo): string { 94 + return `${info.title}: ${info.description}\nHint: ${info.hint}`; 95 + }
+7
src/sheets/index.html
··· 323 323 324 324 <!-- Cell note tooltip --> 325 325 <div class="cell-note-tooltip" id="cell-note-tooltip" style="display:none"></div> 326 + 327 + <!-- Error tooltip popup --> 328 + <div class="error-tooltip" id="error-tooltip" style="display:none" role="tooltip"> 329 + <div class="error-tooltip-title"></div> 330 + <div class="error-tooltip-desc"></div> 331 + <div class="error-tooltip-hint"></div> 332 + </div> 326 333 </div> 327 334 328 335 <script type="module" src="./main.js"></script>
+50 -13
src/sheets/main.ts
··· 18 18 import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js'; 19 19 import { multiColumnSort } from './sort.js'; 20 20 import { evaluateRules, buildCfStyle } from './conditional-format.js'; 21 + import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 21 22 import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 22 23 import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js'; 23 24 import { computeSelectionStats, formatStatValue } from './status-bar.js'; ··· 567 568 const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; 568 569 569 570 // Formula error tooltip 570 - const errTooltip = getFormulaErrorTooltip(String(displayValue)); 571 - const titleAttr = errTooltip ? ' title="' + escapeHtml(errTooltip) + '"' : ''; 571 + const errInfo = getErrorInfo(String(displayValue)); 572 + const errClass = errInfo ? ' cell-error' : ''; 573 + const errData = errInfo ? ' data-error-title="' + escapeHtml(errInfo.title) + '" data-error-desc="' + escapeHtml(errInfo.description) + '" data-error-hint="' + escapeHtml(errInfo.hint) + '"' : ''; 572 574 573 - tbodyHtml += '<div class="cell-display' + wrapClass + '"' + titleAttr + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 575 + tbodyHtml += '<div class="cell-display' + wrapClass + errClass + '"' + errData + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 574 576 } 575 577 576 578 // Dropdown arrow for list validation ··· 638 640 } 639 641 640 642 function getFormulaErrorTooltip(value: string): string | null { 641 - if (typeof value !== 'string') return null; 642 - if (value.startsWith('#REF!')) return 'Reference error: a cell or range reference is invalid or refers to a deleted cell'; 643 - if (value.startsWith('#VALUE!')) return 'Value error: a function argument is the wrong type (e.g., text where a number is expected)'; 644 - if (value.startsWith('#NAME?')) return 'Name error: unrecognized function or named range'; 645 - if (value.startsWith('#DIV/0!')) return 'Division by zero: a formula divides by zero or an empty cell'; 646 - if (value.startsWith('#CIRCULAR!')) return 'Circular reference: this formula refers back to its own cell'; 647 - if (value.startsWith('#ERROR!')) return 'Error: the formula could not be evaluated'; 648 - if (value.startsWith('#N/A')) return 'Not available: no value found (e.g., VLOOKUP found no match)'; 649 - if (value.startsWith('#NUM!')) return 'Number error: invalid numeric value in calculation'; 650 - return null; 643 + const info = getErrorInfo(value); 644 + return info ? formatErrorTooltip(info) : null; 651 645 } 652 646 653 647 function computeDisplayValue(id, cellData) { ··· 4806 4800 grid.addEventListener('mouseout', (e) => { 4807 4801 const td = e.target.closest('td[data-id]'); 4808 4802 if (td) hideNoteTooltip(); 4803 + }); 4804 + 4805 + // --- Error tooltip popup (#208) --- 4806 + const errorTooltipEl = document.getElementById('error-tooltip'); 4807 + const errorTitleEl = errorTooltipEl.querySelector('.error-tooltip-title'); 4808 + const errorDescEl = errorTooltipEl.querySelector('.error-tooltip-desc'); 4809 + const errorHintEl = errorTooltipEl.querySelector('.error-tooltip-hint'); 4810 + 4811 + function showErrorTooltip(cellDiv, td) { 4812 + const title = cellDiv.dataset.errorTitle; 4813 + if (!title) return; 4814 + errorTitleEl.textContent = title; 4815 + errorDescEl.textContent = cellDiv.dataset.errorDesc || ''; 4816 + errorHintEl.textContent = cellDiv.dataset.errorHint ? 'Hint: ' + cellDiv.dataset.errorHint : ''; 4817 + errorTooltipEl.style.display = ''; 4818 + const rect = td.getBoundingClientRect(); 4819 + errorTooltipEl.style.left = (rect.left) + 'px'; 4820 + errorTooltipEl.style.top = (rect.bottom + 4) + 'px'; 4821 + requestAnimationFrame(() => { 4822 + const ttRect = errorTooltipEl.getBoundingClientRect(); 4823 + if (ttRect.right > window.innerWidth) { 4824 + errorTooltipEl.style.left = (window.innerWidth - ttRect.width - 8) + 'px'; 4825 + } 4826 + if (ttRect.bottom > window.innerHeight) { 4827 + errorTooltipEl.style.top = (rect.top - ttRect.height - 4) + 'px'; 4828 + } 4829 + }); 4830 + } 4831 + 4832 + function hideErrorTooltip() { 4833 + errorTooltipEl.style.display = 'none'; 4834 + } 4835 + 4836 + grid.addEventListener('mouseover', (e) => { 4837 + const cellDiv = e.target.closest('.cell-error[data-error-title]'); 4838 + if (!cellDiv) return; 4839 + const td = cellDiv.closest('td[data-id]'); 4840 + if (td) showErrorTooltip(cellDiv, td); 4841 + }); 4842 + 4843 + grid.addEventListener('mouseout', (e) => { 4844 + const cellDiv = e.target.closest('.cell-error[data-error-title]'); 4845 + if (cellDiv) hideErrorTooltip(); 4809 4846 }); 4810 4847 4811 4848 // Right-click context menu (#149 — wired-up actions, #113 — row/col insert/delete)
+165
tests/error-tooltips.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + isErrorValue, 4 + getErrorInfo, 5 + formatErrorTooltip, 6 + ERROR_DESCRIPTIONS, 7 + } from '../src/sheets/error-tooltips.js'; 8 + 9 + describe('isErrorValue', () => { 10 + it('recognizes standard error values ending with !', () => { 11 + expect(isErrorValue('#DIV/0!')).toBe(true); 12 + expect(isErrorValue('#REF!')).toBe(true); 13 + expect(isErrorValue('#VALUE!')).toBe(true); 14 + expect(isErrorValue('#NULL!')).toBe(true); 15 + expect(isErrorValue('#ERROR!')).toBe(true); 16 + expect(isErrorValue('#CIRCULAR!')).toBe(true); 17 + expect(isErrorValue('#NUM!')).toBe(true); 18 + }); 19 + 20 + it('recognizes #NAME? error', () => { 21 + expect(isErrorValue('#NAME?')).toBe(true); 22 + }); 23 + 24 + it('recognizes #NAME? with extra info', () => { 25 + expect(isErrorValue('#NAME? (UNKNOWNFUNC)')).toBe(true); 26 + }); 27 + 28 + it('recognizes #N/A (no trailing ! or ?)', () => { 29 + expect(isErrorValue('#N/A')).toBe(true); 30 + }); 31 + 32 + it('rejects non-error values', () => { 33 + expect(isErrorValue('hello')).toBe(false); 34 + expect(isErrorValue('123')).toBe(false); 35 + expect(isErrorValue('')).toBe(false); 36 + expect(isErrorValue('#hashtag')).toBe(false); 37 + expect(isErrorValue('# heading')).toBe(false); 38 + }); 39 + 40 + it('handles non-string input gracefully', () => { 41 + // TypeScript won't normally allow this, but at runtime it could happen 42 + expect(isErrorValue(null as unknown as string)).toBe(false); 43 + expect(isErrorValue(undefined as unknown as string)).toBe(false); 44 + expect(isErrorValue(42 as unknown as string)).toBe(false); 45 + }); 46 + }); 47 + 48 + describe('getErrorInfo', () => { 49 + it('returns info for #DIV/0!', () => { 50 + const info = getErrorInfo('#DIV/0!'); 51 + expect(info).not.toBeNull(); 52 + expect(info!.title).toBe('Division by Zero'); 53 + expect(info!.description).toContain('divide by zero'); 54 + expect(info!.hint).toContain('IF()'); 55 + }); 56 + 57 + it('returns info for #REF!', () => { 58 + const info = getErrorInfo('#REF!'); 59 + expect(info).not.toBeNull(); 60 + expect(info!.title).toBe('Invalid Reference'); 61 + }); 62 + 63 + it('returns info for #VALUE!', () => { 64 + const info = getErrorInfo('#VALUE!'); 65 + expect(info).not.toBeNull(); 66 + expect(info!.title).toBe('Wrong Value Type'); 67 + }); 68 + 69 + it('returns info for #NAME?', () => { 70 + const info = getErrorInfo('#NAME?'); 71 + expect(info).not.toBeNull(); 72 + expect(info!.title).toBe('Unrecognized Name'); 73 + }); 74 + 75 + it('returns info for #NAME? with extra info via prefix match', () => { 76 + const info = getErrorInfo('#NAME? (UNKNOWNFUNC)'); 77 + expect(info).not.toBeNull(); 78 + expect(info!.title).toBe('Unrecognized Name'); 79 + }); 80 + 81 + it('returns info for #NULL!', () => { 82 + const info = getErrorInfo('#NULL!'); 83 + expect(info).not.toBeNull(); 84 + expect(info!.title).toBe('Null Intersection'); 85 + }); 86 + 87 + it('returns info for #N/A', () => { 88 + const info = getErrorInfo('#N/A'); 89 + expect(info).not.toBeNull(); 90 + expect(info!.title).toBe('Value Not Available'); 91 + expect(info!.hint).toContain('IFERROR()'); 92 + }); 93 + 94 + it('returns info for #ERROR!', () => { 95 + const info = getErrorInfo('#ERROR!'); 96 + expect(info).not.toBeNull(); 97 + expect(info!.title).toBe('Formula Error'); 98 + }); 99 + 100 + it('returns info for #CIRCULAR!', () => { 101 + const info = getErrorInfo('#CIRCULAR!'); 102 + expect(info).not.toBeNull(); 103 + expect(info!.title).toBe('Circular Reference'); 104 + }); 105 + 106 + it('returns info for #NUM!', () => { 107 + const info = getErrorInfo('#NUM!'); 108 + expect(info).not.toBeNull(); 109 + expect(info!.title).toBe('Invalid Number'); 110 + }); 111 + 112 + it('returns null for unknown error values', () => { 113 + expect(getErrorInfo('#UNKNOWN!')).toBeNull(); 114 + expect(getErrorInfo('hello')).toBeNull(); 115 + expect(getErrorInfo('')).toBeNull(); 116 + }); 117 + 118 + it('returns null for non-string input', () => { 119 + expect(getErrorInfo(null as unknown as string)).toBeNull(); 120 + expect(getErrorInfo(undefined as unknown as string)).toBeNull(); 121 + }); 122 + }); 123 + 124 + describe('formatErrorTooltip', () => { 125 + it('formats error info into a readable string', () => { 126 + const info = getErrorInfo('#DIV/0!')!; 127 + const tooltip = formatErrorTooltip(info); 128 + expect(tooltip).toContain('Division by Zero'); 129 + expect(tooltip).toContain('divide by zero'); 130 + expect(tooltip).toContain('Hint:'); 131 + expect(tooltip).toContain('IF()'); 132 + }); 133 + 134 + it('includes title, description, and hint on separate concepts', () => { 135 + const info = getErrorInfo('#REF!')!; 136 + const tooltip = formatErrorTooltip(info); 137 + // Title: description on first line 138 + expect(tooltip).toMatch(/^Invalid Reference:/); 139 + // Hint on second line 140 + expect(tooltip).toContain('\nHint:'); 141 + }); 142 + }); 143 + 144 + describe('ERROR_DESCRIPTIONS coverage', () => { 145 + it('has entries for all standard spreadsheet errors', () => { 146 + const expectedErrors = [ 147 + '#DIV/0!', '#REF!', '#VALUE!', '#NAME?', '#NULL!', 148 + '#N/A', '#ERROR!', '#CIRCULAR!', '#NUM!', 149 + ]; 150 + for (const err of expectedErrors) { 151 + expect(ERROR_DESCRIPTIONS[err]).toBeDefined(); 152 + expect(ERROR_DESCRIPTIONS[err].title).toBeTruthy(); 153 + expect(ERROR_DESCRIPTIONS[err].description).toBeTruthy(); 154 + expect(ERROR_DESCRIPTIONS[err].hint).toBeTruthy(); 155 + } 156 + }); 157 + 158 + it('every entry has non-empty title, description, and hint', () => { 159 + for (const [key, info] of Object.entries(ERROR_DESCRIPTIONS)) { 160 + expect(info.title.length, `${key} title`).toBeGreaterThan(0); 161 + expect(info.description.length, `${key} description`).toBeGreaterThan(0); 162 + expect(info.hint.length, `${key} hint`).toBeGreaterThan(0); 163 + } 164 + }); 165 + });