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 'feat(sheets): fit-and-finish — auto-format, color palette, color sync, horizontal fill (v0.58.0)' (#405) from feat/v0.58.0-sheets-fit-finish into main

scott b912ee78 539a9122

+1078 -21
+5
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Added 11 + - Sheets fit-and-finish (v0.58.0) — four Excel/Sheets-parity wins for daily spreadsheet work: 12 + - **Auto-format on entry**: typing `$1,234.56`, `75%`, `2025-03-15`, or `1,234` in a cell now stores the parsed numeric value and stamps an appropriate format (`currency`, `percent`, `date`, `number`) instead of keeping the raw string. Preserves existing cell formats so explicit user choices aren't clobbered. New `src/sheets/auto-format.ts` module with 30 unit tests. 13 + - **Preset color palette**: both the text-color and background-color pickers now have a ▾ dropdown beside the native `<input type="color">` showing 14 curated swatches (black/grays/white + 9 accent colors in a 7×2 grid). The native picker still works; the palette short-circuits it for the common case. New `src/sheets/color-palette.ts` module with 15 jsdom tests covering render, open/close, click-outside, and Escape. 14 + - **Toolbar color sync**: selecting a styled cell now reflects its `s.color` and `s.bg` values in the toolbar color pickers (both the `<input>` value and the visible swatch). Previously the toolbar always showed black/white regardless of the cell's actual colors. New `updateColorPickerState()` in `toolbar-wiring.ts`, called on every selection change; normalizes 3-char hex and passes through non-hex (oklch, rgb) verbatim. 8 new jsdom tests. 15 + - **Horizontal drag-to-fill**: the fill handle now supports left/right drag in addition to up/down. Axis is picked by whichever direction is further out of the source range (mirrors Excel/Sheets). Arithmetic sequences, text repeat, and formula column references all propagate correctly; `adjustFormulaRef` is called with `dCol` instead of `dRow` on the horizontal branch. Auto-scroll near the grid's left/right edges added. 8 new jsdom tests pin the contract (axis detection, value propagation, formula dCol adjustment, post-fill selection range). (#117) 11 16 - A11y: modal focus trap — `modalPrompt`/`modalConfirm` now wrap Tab/Shift+Tab within the dialog so keyboard users cannot tab out to background elements while a modal is open. A new exported `handleFocusTrap(event, dialog)` helper wraps focus between first/last focusable children and pulls focus back into the dialog if it escapes (e.g. user tabs to a background link). 8 new jsdom tests pin the contract (Tab wrap, Shift+Tab wrap, escape recovery, mid-tab no-op, non-Tab no-op, integration with modalPrompt + modalConfirm). Closes the last major keyboard-accessibility gap in the shared modal helper. (#690) 12 17 - A11y: landing-page icon-only buttons (theme toggle, user badge, search clear) now carry `aria-label` so screen readers can announce them — previously only `title` was set, which is unreliable for AT. The user badge also gets `role="button"` + `tabindex="0"` so keyboard users can reach it. Landing modals (username, folder, move) now declare `aria-labelledby` pointing at their title heading so modal opens announce context. (#690) 13 18 - A11y: slides Notes/Animations panel tabs now carry the full tablist semantics — `role="tablist"` on the container, `role="tab"` + `aria-selected` + `aria-controls` on each button, and `role="tabpanel"` + `aria-labelledby` on each content pane. The click handler keeps `aria-selected` in sync when tabs switch. 2 new jsdom regression tests pin the invariant. (#690)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.57.0", 3 + "version": "0.58.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+58
src/css/app.css
··· 2144 2144 background: var(--color-highlight-default); 2145 2145 } 2146 2146 2147 + /* Color palette dropdown (v0.58.0) — preset colors beside each picker */ 2148 + .tb-color-palette-toggle { 2149 + position: absolute; 2150 + right: -8px; 2151 + bottom: 0; 2152 + width: 12px; 2153 + height: 12px; 2154 + padding: 0; 2155 + border: none; 2156 + background: transparent; 2157 + color: var(--color-text-faint); 2158 + font-size: 10px; 2159 + line-height: 1; 2160 + cursor: pointer; 2161 + border-radius: 2px; 2162 + z-index: 2; 2163 + } 2164 + .tb-color-palette-toggle:hover { 2165 + color: var(--color-text); 2166 + background: var(--color-bg-hover, oklch(0.92 0 0)); 2167 + } 2168 + [data-theme="dark"] .tb-color-palette-toggle:hover { 2169 + background: oklch(0.3 0 0); 2170 + } 2171 + .tb-color-palette { 2172 + position: absolute; 2173 + top: 100%; 2174 + left: 0; 2175 + margin-top: 4px; 2176 + padding: 6px; 2177 + background: var(--color-bg); 2178 + border: 1px solid var(--color-border); 2179 + border-radius: var(--radius-md); 2180 + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); 2181 + display: grid; 2182 + grid-template-columns: repeat(7, 20px); 2183 + gap: 4px; 2184 + z-index: 30; 2185 + } 2186 + .tb-color-palette[hidden] { 2187 + display: none; 2188 + } 2189 + .tb-color-palette-swatch { 2190 + width: 20px; 2191 + height: 20px; 2192 + padding: 0; 2193 + border: 1px solid var(--color-border); 2194 + border-radius: 3px; 2195 + cursor: pointer; 2196 + transition: transform 0.08s ease-out; 2197 + } 2198 + .tb-color-palette-swatch:hover, 2199 + .tb-color-palette-swatch:focus-visible { 2200 + transform: scale(1.15); 2201 + outline: 2px solid var(--color-accent, oklch(0.62 0.18 240)); 2202 + outline-offset: 1px; 2203 + } 2204 + 2147 2205 /* Inline SVG toolbar icons */ 2148 2206 .tb-icon { 2149 2207 width: 16px;
+79
src/sheets/auto-format.ts
··· 1 + /** 2 + * Auto-format — detect + parse cell entries into a value + format pair. 3 + * 4 + * When a user types "$10", "10%", or "2025-03-15" into a sheet cell, the 5 + * intent is almost always to create a formatted number (currency, percentage, 6 + * date) rather than a raw string. This module centralises that detection so 7 + * `commitEdit()` applies the matching style flag and stores a numeric value 8 + * that participates in formulas correctly. 9 + * 10 + * This runs ONLY when the target cell has no explicit user-set format, so 11 + * typing "5" into a currency-formatted cell still lands as a plain number and 12 + * the existing format wins. Users who want a literal string like "$hi" get 13 + * it naturally: the regex requires digits. 14 + */ 15 + 16 + /** Recognised auto-format kinds. `undefined` = no auto-format applied. */ 17 + export type AutoFormatKind = 'currency' | 'percent' | 'date' | 'number' | undefined; 18 + 19 + export interface AutoFormatResult { 20 + /** Parsed value. Currency/percent/date produce numbers. Falls back to raw. */ 21 + value: string | number; 22 + /** Format flag to set on cell `s.format`, or undefined to leave alone. */ 23 + format: AutoFormatKind; 24 + } 25 + 26 + const CURRENCY_RE = /^([$€£¥])\s*(-?\d[\d,]*(?:\.\d+)?)$/; 27 + const PERCENT_RE = /^(-?\d+(?:\.\d+)?)%$/; 28 + const THOUSANDS_NUMBER_RE = /^-?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/; 29 + const ISO_DATE_RE = /^\d{4}-\d{1,2}-\d{1,2}$/; 30 + const SLASH_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/; 31 + 32 + /** 33 + * Inspect a raw user-entered string and return a parsed value + format hint. 34 + * 35 + * Empty strings and formulas are passed through unchanged — the caller handles 36 + * those earlier. Anything not matching a known pattern returns with 37 + * `format: undefined` so the caller can apply its existing numeric-coercion 38 + * fallback. 39 + */ 40 + export function detectAndParseEntry(raw: string): AutoFormatResult { 41 + const trimmed = raw.trim(); 42 + if (trimmed === '') return { value: '', format: undefined }; 43 + 44 + // Currency: "$1,234.56", "€100", "£-50" 45 + const currencyMatch = trimmed.match(CURRENCY_RE); 46 + if (currencyMatch) { 47 + const num = Number(currencyMatch[2]!.replace(/,/g, '')); 48 + if (!isNaN(num)) return { value: num, format: 'currency' }; 49 + } 50 + 51 + // Percent: "75%", "-12.5%" 52 + const percentMatch = trimmed.match(PERCENT_RE); 53 + if (percentMatch) { 54 + const num = Number(percentMatch[1]!) / 100; 55 + if (!isNaN(num)) return { value: num, format: 'percent' }; 56 + } 57 + 58 + // ISO date: "2025-03-15" 59 + if (ISO_DATE_RE.test(trimmed)) { 60 + const parsed = Date.parse(trimmed); 61 + if (!isNaN(parsed)) return { value: parsed, format: 'date' }; 62 + } 63 + 64 + // Slash date: "3/15/2025" or "3/15/25" 65 + if (SLASH_DATE_RE.test(trimmed)) { 66 + const parsed = Date.parse(trimmed); 67 + if (!isNaN(parsed)) return { value: parsed, format: 'date' }; 68 + } 69 + 70 + // Thousands-separated number: "1,234", "1,234.56" 71 + if (THOUSANDS_NUMBER_RE.test(trimmed)) { 72 + const num = Number(trimmed.replace(/,/g, '')); 73 + if (!isNaN(num)) return { value: num, format: 'number' }; 74 + } 75 + 76 + // Plain number: let the caller's existing Number()-check handle it, but we 77 + // still strip the format slot so the cell keeps whatever format it had. 78 + return { value: raw, format: undefined }; 79 + }
+32 -4
src/sheets/cell-editing.ts
··· 9 9 import { clearGridHighlights } from './range-highlight.js'; 10 10 import { hideTooltip } from './formula-tooltip.js'; 11 11 import { parseDateValue, showDatePicker } from './date-picker.js'; 12 + import { detectAndParseEntry } from './auto-format.js'; 12 13 13 14 // ── Types ─────────────────────────────────────────────────── 14 15 ··· 104 105 setCellData(id, { v: '', f: raw.slice(1) }); 105 106 } else { 106 107 const existingData = getCellData(id); 107 - const numVal = Number(raw); 108 - let value: string | number = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 108 + const existingFormat = existingData?.s?.format; 109 + let value: string | number; 110 + let autoFormat: string | undefined; 111 + 112 + // Auto-detect format for fresh entries ("$10" → currency, "75%" → percent, 113 + // "2025-03-15" → date). Skip when the cell already has a user-set format 114 + // so "5" typed into a currency cell stays a plain number. 115 + if (!existingFormat) { 116 + const detected = detectAndParseEntry(raw); 117 + value = detected.value; 118 + autoFormat = detected.format; 119 + } else { 120 + const numVal = Number(raw); 121 + value = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 122 + } 123 + 124 + // If no auto-format fired and no existing format, fall back to numeric 125 + // coercion so "42" still lands as number 42. 126 + if (autoFormat === undefined && typeof value === 'string' && value !== '') { 127 + const numVal = Number(value); 128 + if (!isNaN(numVal) && value.trim() !== '') value = numVal; 129 + } 130 + 109 131 // If cell has date format and user typed a date string, parse it back to timestamp 110 - if (typeof value === 'string' && value !== '' && existingData?.s?.format === 'date') { 132 + if (typeof value === 'string' && value !== '' && existingFormat === 'date') { 111 133 const parsed = Date.parse(value); 112 134 if (!isNaN(parsed)) value = parsed; 113 135 } 114 - setCellData(id, { v: value, f: '' }); 136 + 137 + if (autoFormat) { 138 + const existingStyle = existingData?.s || {}; 139 + setCellData(id, { v: value, f: '', s: { ...existingStyle, format: autoFormat } }); 140 + } else { 141 + setCellData(id, { v: value, f: '' }); 142 + } 115 143 } 116 144 input.remove(); 117 145 }
+103
src/sheets/color-palette.ts
··· 1 + /** 2 + * Color Palette — preset color grid for the text-color and bg-color pickers. 3 + * 4 + * The native `<input type="color">` opens a full OS/browser picker which is 5 + * overkill when users usually just want one of ~15 reasonable defaults. 6 + * This module renders a small grid of presets above the picker, mirroring 7 + * the pattern used in Google Sheets / Excel / Docs. 8 + */ 9 + 10 + /** 11 + * Curated list of common spreadsheet colors. Kept minimal (14 entries so the 12 + * grid renders 7×2) and theme-neutral so the same palette works in light and 13 + * dark mode. Users who need something else can still use the native picker. 14 + */ 15 + export const PRESET_COLORS: ReadonlyArray<{ name: string; hex: string }> = [ 16 + { name: 'Black', hex: '#000000' }, 17 + { name: 'Dark gray', hex: '#4d4d4d' }, 18 + { name: 'Gray', hex: '#999999' }, 19 + { name: 'Light gray', hex: '#e6e6e6' }, 20 + { name: 'White', hex: '#ffffff' }, 21 + { name: 'Red', hex: '#e53935' }, 22 + { name: 'Orange', hex: '#fb8c00' }, 23 + { name: 'Yellow', hex: '#fdd835' }, 24 + { name: 'Green', hex: '#43a047' }, 25 + { name: 'Teal', hex: '#00acc1' }, 26 + { name: 'Blue', hex: '#1e88e5' }, 27 + { name: 'Indigo', hex: '#3949ab' }, 28 + { name: 'Purple', hex: '#8e24aa' }, 29 + { name: 'Pink', hex: '#d81b60' }, 30 + ]; 31 + 32 + export interface RenderPaletteDeps { 33 + /** Called with the hex value when a user clicks a preset. */ 34 + onSelect: (hex: string) => void; 35 + } 36 + 37 + /** 38 + * Populate a `.tb-color-palette` container with preset swatches. 39 + * Safe to call repeatedly — rebuilds from scratch each time. 40 + */ 41 + export function renderColorPalette(container: HTMLElement, deps: RenderPaletteDeps): void { 42 + container.innerHTML = ''; 43 + for (const { name, hex } of PRESET_COLORS) { 44 + const btn = document.createElement('button'); 45 + btn.type = 'button'; 46 + btn.className = 'tb-color-palette-swatch'; 47 + btn.style.background = hex; 48 + btn.dataset.color = hex; 49 + btn.title = name; 50 + btn.setAttribute('aria-label', name); 51 + btn.setAttribute('role', 'menuitem'); 52 + btn.addEventListener('click', () => deps.onSelect(hex)); 53 + container.appendChild(btn); 54 + } 55 + } 56 + 57 + /** Show the palette container. */ 58 + export function openPalette(container: HTMLElement, toggle: HTMLElement): void { 59 + container.hidden = false; 60 + toggle.setAttribute('aria-expanded', 'true'); 61 + } 62 + 63 + /** Hide the palette container. */ 64 + export function closePalette(container: HTMLElement, toggle: HTMLElement): void { 65 + container.hidden = true; 66 + toggle.setAttribute('aria-expanded', 'false'); 67 + } 68 + 69 + /** 70 + * Wire a toggle button + palette container pair. The toggle opens/closes the 71 + * palette; clicking a preset applies it and closes the palette; clicking 72 + * outside the palette closes it. 73 + */ 74 + export function wireColorPalette(opts: { 75 + toggle: HTMLElement; 76 + container: HTMLElement; 77 + onSelect: (hex: string) => void; 78 + }): void { 79 + const { toggle, container, onSelect } = opts; 80 + renderColorPalette(container, { 81 + onSelect: (hex) => { 82 + onSelect(hex); 83 + closePalette(container, toggle); 84 + }, 85 + }); 86 + toggle.addEventListener('click', (e) => { 87 + e.stopPropagation(); 88 + if (container.hidden) openPalette(container, toggle); 89 + else closePalette(container, toggle); 90 + }); 91 + // Close when clicking outside. 92 + document.addEventListener('click', (e) => { 93 + if (container.hidden) return; 94 + const target = e.target as HTMLElement; 95 + if (target === toggle || toggle.contains(target)) return; 96 + if (container.contains(target)) return; 97 + closePalette(container, toggle); 98 + }); 99 + // Close on Escape. 100 + document.addEventListener('keydown', (e) => { 101 + if (e.key === 'Escape' && !container.hidden) closePalette(container, toggle); 102 + }); 103 + }
+112 -16
src/sheets/fill-handlers.ts
··· 29 29 const moveTd = (ev.target as HTMLElement).closest('td[data-id]') as HTMLElement; 30 30 if (moveTd) { 31 31 const targetRow = parseInt(moveTd.dataset.row!); 32 - if (targetRow > sourceRange.endRow) { 33 - setFillPreviewRange({ startCol: sourceRange.startCol, startRow: sourceRange.endRow + 1, endCol: sourceRange.endCol, endRow: targetRow }); 34 - } else if (targetRow < sourceRange.startRow) { 35 - setFillPreviewRange({ startCol: sourceRange.startCol, startRow: targetRow, endCol: sourceRange.endCol, endRow: sourceRange.startRow - 1 }); 36 - } else { 32 + const targetCol = parseInt(moveTd.dataset.col!); 33 + // Decide axis by picking whichever direction is further out of the 34 + // source range. This mirrors Excel/Sheets: the first axis you drag off 35 + // the source wins, and dominant motion controls the preview. 36 + const dRow = targetRow > sourceRange.endRow ? targetRow - sourceRange.endRow 37 + : targetRow < sourceRange.startRow ? sourceRange.startRow - targetRow 38 + : 0; 39 + const dCol = targetCol > sourceRange.endCol ? targetCol - sourceRange.endCol 40 + : targetCol < sourceRange.startCol ? sourceRange.startCol - targetCol 41 + : 0; 42 + if (dRow === 0 && dCol === 0) { 37 43 setFillPreviewRange(null); 44 + } else if (dRow >= dCol) { 45 + // Vertical fill (preserved default behaviour) 46 + if (targetRow > sourceRange.endRow) { 47 + setFillPreviewRange({ startCol: sourceRange.startCol, startRow: sourceRange.endRow + 1, endCol: sourceRange.endCol, endRow: targetRow }); 48 + } else { 49 + setFillPreviewRange({ startCol: sourceRange.startCol, startRow: targetRow, endCol: sourceRange.endCol, endRow: sourceRange.startRow - 1 }); 50 + } 51 + } else { 52 + // Horizontal fill (v0.58.0) 53 + if (targetCol > sourceRange.endCol) { 54 + setFillPreviewRange({ startCol: sourceRange.endCol + 1, startRow: sourceRange.startRow, endCol: targetCol, endRow: sourceRange.endRow }); 55 + } else { 56 + setFillPreviewRange({ startCol: targetCol, startRow: sourceRange.startRow, endCol: sourceRange.startCol - 1, endRow: sourceRange.endRow }); 57 + } 38 58 } 39 59 updateFillPreviewVisuals(deps); 40 60 } 41 61 42 - // Auto-scroll near edges 62 + // Auto-scroll near edges (vertical) 43 63 const container = sheetContainer; 44 64 if (!container) return; 45 65 const rect = container.getBoundingClientRect(); 46 66 const nearBottom = ev.clientY > rect.bottom - SCROLL_EDGE; 47 67 const nearTop = ev.clientY < rect.top + SCROLL_EDGE; 48 - if (nearBottom || nearTop) { 68 + const nearRight = ev.clientX > rect.right - SCROLL_EDGE; 69 + const nearLeft = ev.clientX < rect.left + SCROLL_EDGE; 70 + if (nearBottom || nearTop || nearRight || nearLeft) { 49 71 if (!_fillScrollTimer) { 50 72 _fillScrollTimer = setInterval(() => { 51 - container.scrollTop += nearBottom ? SCROLL_SPEED : -SCROLL_SPEED; 73 + if (nearBottom) container.scrollTop += SCROLL_SPEED; 74 + else if (nearTop) container.scrollTop -= SCROLL_SPEED; 75 + if (nearRight) container.scrollLeft += SCROLL_SPEED; 76 + else if (nearLeft) container.scrollLeft -= SCROLL_SPEED; 52 77 }, 16); 53 78 } 54 79 } else if (_fillScrollTimer) { ··· 103 128 evalCache, clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 104 129 updateSelectionVisuals, updateFormulaBar } = deps; 105 130 131 + // Determine axis: if the preview moved in columns, this is a horizontal fill. 132 + const isHorizontal = 133 + targetRange.startCol > sourceRange.endCol || 134 + targetRange.endCol < sourceRange.startCol; 135 + 136 + if (isHorizontal) { 137 + executeHorizontalFill(deps, sourceRange, targetRange); 138 + } else { 139 + executeVerticalFill(deps, sourceRange, targetRange); 140 + } 141 + 142 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 143 + refreshVisibleCells(); 144 + 145 + const newEndRow = Math.max(sourceRange.endRow, targetRange.endRow); 146 + const newStartRow = Math.min(sourceRange.startRow, targetRange.startRow); 147 + const newEndCol = Math.max(sourceRange.endCol, targetRange.endCol); 148 + const newStartCol = Math.min(sourceRange.startCol, targetRange.startCol); 149 + setSelectionRange({ startCol: newStartCol, startRow: newStartRow, endCol: newEndCol, endRow: newEndRow }); 150 + updateSelectionVisuals(); 151 + updateFormulaBar(); 152 + const fillCount = isHorizontal 153 + ? (targetRange.endCol - targetRange.startCol + 1) 154 + : (targetRange.endRow - targetRange.startRow + 1); 155 + showToast(`Filled ${fillCount} cell${fillCount > 1 ? 's' : ''}`); 156 + } 157 + 158 + function executeVerticalFill(deps: MouseEventsDeps, sourceRange: any, targetRange: any): void { 159 + const { ydoc, getCellData, setCellData } = deps; 106 160 const direction = targetRange.startRow > sourceRange.endRow ? 'forward' : 'backward'; 107 161 const fillCount = targetRange.endRow - targetRange.startRow + 1; 108 162 if (fillCount <= 0) return; ··· 146 200 } 147 201 } 148 202 }); 203 + } 149 204 150 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 151 - refreshVisibleCells(); 205 + /** 206 + * v0.58.0: Horizontal fill — extend source values across columns. 207 + * Mirrors executeVerticalFill but iterates rows outer, columns inner, and 208 + * adjusts formulas by dCol instead of dRow. 209 + */ 210 + function executeHorizontalFill(deps: MouseEventsDeps, sourceRange: any, targetRange: any): void { 211 + const { ydoc, getCellData, setCellData } = deps; 212 + const direction = targetRange.startCol > sourceRange.endCol ? 'forward' : 'backward'; 213 + const fillCount = targetRange.endCol - targetRange.startCol + 1; 214 + if (fillCount <= 0) return; 152 215 153 - const newEndRow = Math.max(sourceRange.endRow, targetRange.endRow); 154 - const newStartRow = Math.min(sourceRange.startRow, targetRange.startRow); 155 - setSelectionRange({ startCol: sourceRange.startCol, startRow: newStartRow, endCol: sourceRange.endCol, endRow: newEndRow }); 156 - updateSelectionVisuals(); 157 - updateFormulaBar(); 158 - showToast(`Filled ${fillCount} cell${fillCount > 1 ? 's' : ''}`); 216 + ydoc.transact(() => { 217 + for (let r = sourceRange.startRow; r <= sourceRange.endRow; r++) { 218 + const sourceValues: any[] = []; 219 + for (let c = sourceRange.startCol; c <= sourceRange.endCol; c++) { 220 + const id = cellId(c, r); 221 + const cellData = getCellData(id); 222 + if (cellData?.f) { 223 + sourceValues.push({ f: cellData.f, v: cellData.v }); 224 + } else if (cellData?.v !== undefined && cellData?.v !== '') { 225 + sourceValues.push(cellData.v); 226 + } else { 227 + sourceValues.push(''); 228 + } 229 + } 230 + 231 + const pattern = detectPattern(sourceValues); 232 + const fillValues = generateFillValues(sourceValues, pattern, fillCount, direction); 233 + 234 + for (let i = 0; i < fillCount; i++) { 235 + const targetCol = targetRange.startCol + i; 236 + const id = cellId(targetCol, r); 237 + 238 + if (pattern.type === PATTERN_TYPES.FORMULA_ADJUST) { 239 + const sourceIdx = i % (sourceRange.endCol - sourceRange.startCol + 1); 240 + const sourceCol = sourceRange.startCol + sourceIdx; 241 + const sourceId = cellId(sourceCol, r); 242 + const sourceCellData = getCellData(sourceId); 243 + if (sourceCellData?.f) { 244 + const dCol = targetCol - sourceCol; 245 + const newFormula = adjustFormulaRef(sourceCellData.f, dCol, 0); 246 + setCellData(id, { f: newFormula, v: '' }); 247 + } 248 + } else { 249 + const val = fillValues[i]; 250 + setCellData(id, { v: val, f: '' }); 251 + } 252 + } 253 + } 254 + }); 159 255 }
+4
src/sheets/index.html
··· 124 124 <input type="color" class="tb-color" id="tb-text-color" value="#1a1815" title="Text color" aria-label="Text color"> 125 125 <span class="tb-color-label">A</span> 126 126 <span class="tb-color-swatch" id="tb-text-color-swatch"></span> 127 + <button class="tb-color-palette-toggle" id="tb-text-color-palette-toggle" type="button" aria-label="Text color palette" aria-haspopup="true" aria-expanded="false" title="Text color presets">&#9662;</button> 128 + <div class="tb-color-palette" id="tb-text-color-palette" role="menu" hidden></div> 127 129 </div> 128 130 <div class="tb-color-wrap toolbar-mobile-hide"> 129 131 <input type="color" class="tb-color" id="tb-bg-color" value="#ffffff" title="Cell background" aria-label="Cell background"> 130 132 <span class="tb-color-label"><svg class="tb-icon" viewBox="0 0 16 16" style="width:12px;height:12px"><path d="M10 2L3.5 8.5 7.5 12.5 14 6z"/><path d="M3.5 8.5L2 14l5.5-1.5"/></svg></span> 131 133 <span class="tb-color-swatch tb-color-swatch-highlight" id="tb-bg-color-swatch"></span> 134 + <button class="tb-color-palette-toggle" id="tb-bg-color-palette-toggle" type="button" aria-label="Cell background palette" aria-haspopup="true" aria-expanded="false" title="Background color presets">&#9662;</button> 135 + <div class="tb-color-palette" id="tb-bg-color-palette" role="menu" hidden></div> 132 136 </div> 133 137 <span class="toolbar-sep toolbar-mobile-hide"></span> 134 138
+3
src/sheets/main.ts
··· 41 41 updateUnderlineButtonState as _updateUnderlineButtonState, updateStrikethroughButtonState as _updateStrikethroughButtonState, 42 42 updateFontSizeSelect as _updateFontSizeSelect, updateFontFamilySelect as _updateFontFamilySelect, 43 43 updateVerticalAlignButton as _updateVerticalAlignButton, 44 + updateColorPickerState as _updateColorPickerState, 44 45 getFormatPainterFormat as _getFormatPainterFormat, applyFormatPainterToCell as _applyFormatPainterToCell, 45 46 wireToolbar as _wireToolbar, 46 47 } from './toolbar-wiring.js'; ··· 213 214 updateWrapButtonState, updateBoldButtonState, updateItalicButtonState, 214 215 updateUnderlineButtonState, updateStrikethroughButtonState, 215 216 updateFontSizeSelect, updateFontFamilySelect, updateVerticalAlignButton, 217 + updateColorPickerState, 216 218 ROW_HEADER_WIDTH, DEFAULT_COLS, DEFAULT_ROWS, 217 219 }; 218 220 } ··· 392 394 function updateFontSizeSelect() { _updateFontSizeSelect(_toolbarDeps()); } 393 395 function updateFontFamilySelect() { _updateFontFamilySelect(_toolbarDeps()); } 394 396 function updateVerticalAlignButton() { _updateVerticalAlignButton(_toolbarDeps()); } 397 + function updateColorPickerState() { _updateColorPickerState(_toolbarDeps()); } 395 398 396 399 // ── Status bar ───────────────────────────────────────────── 397 400 function updateStatusBar() { _updateStatusBarUI(_statusBarDeps()); }
+3
src/sheets/selection-navigation.ts
··· 32 32 updateFontSizeSelect: () => void; 33 33 updateFontFamilySelect: () => void; 34 34 updateVerticalAlignButton: () => void; 35 + updateColorPickerState: () => void; 35 36 ROW_HEADER_WIDTH: number; 36 37 DEFAULT_COLS: number; 37 38 DEFAULT_ROWS: number; ··· 79 80 deps.updateFontSizeSelect(); 80 81 deps.updateFontFamilySelect(); 81 82 deps.updateVerticalAlignButton(); 83 + deps.updateColorPickerState(); 82 84 scrollCellIntoView(deps, newCol, newRow); 83 85 } 84 86 ··· 109 111 deps.updateStrikethroughButtonState(); 110 112 deps.updateFontSizeSelect(); 111 113 deps.updateFontFamilySelect(); 114 + deps.updateColorPickerState(); 112 115 scrollCellIntoView(deps, col, row); 113 116 } 114 117
+71
src/sheets/toolbar-wiring.ts
··· 11 11 import { applyBorderPreset } from './cell-styles.js'; 12 12 import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 13 13 import { showToast } from './import-export.js'; 14 + import { wireColorPalette } from './color-palette.js'; 14 15 15 16 // ── Types ─────────────────────────────────────────────────── 16 17 ··· 260 261 if (sel) sel.value = fontFamily; 261 262 } 262 263 264 + /** 265 + * Sync the text-color and cell-background pickers + swatches to the currently 266 + * selected cell's style. Call on every selection change so the toolbar always 267 + * reflects what's under the cursor (matches Docs/Slides' toolbar-state pattern). 268 + * 269 + * When the selected cell has no explicit color, the pickers keep their 270 + * theme-default values (set up in wireToolbar) so authoring new colors still 271 + * starts from a sensible baseline. 272 + */ 273 + export function updateColorPickerState(deps: ToolbarDeps): void { 274 + const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 275 + const s = deps.getCellData(id)?.s; 276 + const textEl = document.getElementById('tb-text-color') as HTMLInputElement | null; 277 + const textSwatch = document.getElementById('tb-text-color-swatch') as HTMLElement | null; 278 + const bgEl = document.getElementById('tb-bg-color') as HTMLInputElement | null; 279 + const bgSwatch = document.getElementById('tb-bg-color-swatch') as HTMLElement | null; 280 + if (textEl && s?.color) { 281 + textEl.value = normalizeColorHex(s.color); 282 + // Use the original color for the swatch so non-hex values (oklch, rgb, 283 + // named) still render correctly even if the native input rejects them. 284 + if (textSwatch) textSwatch.style.background = s.color; 285 + } 286 + if (bgEl && s?.bg) { 287 + bgEl.value = normalizeColorHex(s.bg); 288 + if (bgSwatch) bgSwatch.style.background = s.bg; 289 + } 290 + } 291 + 292 + /** 293 + * Normalise any CSS color string to a 7-char hex (#rrggbb) so the native 294 + * `<input type="color">` accepts it. Non-hex values (rgb(), oklch(), named) 295 + * are passed through unchanged; browsers fall back to black silently, but the 296 + * swatch will still reflect the original via its inline background. 297 + */ 298 + function normalizeColorHex(color: string): string { 299 + if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase(); 300 + if (/^#[0-9a-f]{3}$/i.test(color)) { 301 + return '#' + color.slice(1).split('').map(c => c + c).join('').toLowerCase(); 302 + } 303 + return color; 304 + } 305 + 263 306 export function updateVerticalAlignButton(deps: ToolbarDeps): void { 264 307 const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); 265 308 const va = deps.getCellData(id)?.s?.verticalAlign || 'top'; ··· 477 520 478 521 sheetsTextColorInput.addEventListener('input', (e) => { _apply('color', (e.target as HTMLInputElement).value); updateSheetsColorSwatches(); }); 479 522 sheetsBgColorInput.addEventListener('input', (e) => { _apply('bg', (e.target as HTMLInputElement).value); updateSheetsColorSwatches(); }); 523 + 524 + // Preset color palettes (next to each native picker) 525 + const textPaletteToggle = document.getElementById('tb-text-color-palette-toggle'); 526 + const textPaletteContainer = document.getElementById('tb-text-color-palette'); 527 + if (textPaletteToggle && textPaletteContainer) { 528 + wireColorPalette({ 529 + toggle: textPaletteToggle, 530 + container: textPaletteContainer, 531 + onSelect: (hex) => { 532 + sheetsTextColorInput.value = hex; 533 + _apply('color', hex); 534 + updateSheetsColorSwatches(); 535 + }, 536 + }); 537 + } 538 + const bgPaletteToggle = document.getElementById('tb-bg-color-palette-toggle'); 539 + const bgPaletteContainer = document.getElementById('tb-bg-color-palette'); 540 + if (bgPaletteToggle && bgPaletteContainer) { 541 + wireColorPalette({ 542 + toggle: bgPaletteToggle, 543 + container: bgPaletteContainer, 544 + onSelect: (hex) => { 545 + sheetsBgColorInput.value = hex; 546 + _apply('bg', hex); 547 + updateSheetsColorSwatches(); 548 + }, 549 + }); 550 + } 480 551 document.getElementById('tb-format')?.addEventListener('change', (e) => _apply('format', (e.target as HTMLSelectElement).value)); 481 552 482 553 // Inline format shortcuts
+157
tests/sheets-auto-format.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { detectAndParseEntry } from '../src/sheets/auto-format.js'; 3 + 4 + describe('detectAndParseEntry', () => { 5 + describe('currency', () => { 6 + it('detects $100', () => { 7 + expect(detectAndParseEntry('$100')).toEqual({ value: 100, format: 'currency' }); 8 + }); 9 + 10 + it('detects $1,234.56 with thousands separator and decimals', () => { 11 + expect(detectAndParseEntry('$1,234.56')).toEqual({ value: 1234.56, format: 'currency' }); 12 + }); 13 + 14 + it('detects €100 euro symbol', () => { 15 + expect(detectAndParseEntry('€100')).toEqual({ value: 100, format: 'currency' }); 16 + }); 17 + 18 + it('detects £50 pound symbol', () => { 19 + expect(detectAndParseEntry('£50')).toEqual({ value: 50, format: 'currency' }); 20 + }); 21 + 22 + it('detects ¥1000 yen symbol', () => { 23 + expect(detectAndParseEntry('¥1000')).toEqual({ value: 1000, format: 'currency' }); 24 + }); 25 + 26 + it('detects negative currency $-50', () => { 27 + expect(detectAndParseEntry('$-50')).toEqual({ value: -50, format: 'currency' }); 28 + }); 29 + 30 + it('handles space after symbol: $ 100', () => { 31 + expect(detectAndParseEntry('$ 100')).toEqual({ value: 100, format: 'currency' }); 32 + }); 33 + 34 + it('does NOT match $abc (no digits)', () => { 35 + const r = detectAndParseEntry('$abc'); 36 + expect(r.format).toBeUndefined(); 37 + }); 38 + }); 39 + 40 + describe('percentage', () => { 41 + it('detects 75%', () => { 42 + expect(detectAndParseEntry('75%')).toEqual({ value: 0.75, format: 'percent' }); 43 + }); 44 + 45 + it('detects 12.5%', () => { 46 + expect(detectAndParseEntry('12.5%')).toEqual({ value: 0.125, format: 'percent' }); 47 + }); 48 + 49 + it('detects -10%', () => { 50 + expect(detectAndParseEntry('-10%')).toEqual({ value: -0.1, format: 'percent' }); 51 + }); 52 + 53 + it('detects 100%', () => { 54 + expect(detectAndParseEntry('100%')).toEqual({ value: 1, format: 'percent' }); 55 + }); 56 + 57 + it('does NOT match bare "abc%"', () => { 58 + const r = detectAndParseEntry('abc%'); 59 + expect(r.format).toBeUndefined(); 60 + }); 61 + }); 62 + 63 + describe('date', () => { 64 + it('detects ISO date 2025-03-15', () => { 65 + const r = detectAndParseEntry('2025-03-15'); 66 + expect(r.format).toBe('date'); 67 + expect(typeof r.value).toBe('number'); 68 + }); 69 + 70 + it('detects single-digit month/day 2025-3-5', () => { 71 + const r = detectAndParseEntry('2025-3-5'); 72 + expect(r.format).toBe('date'); 73 + }); 74 + 75 + it('detects US slash date 3/15/2025', () => { 76 + const r = detectAndParseEntry('3/15/2025'); 77 + expect(r.format).toBe('date'); 78 + expect(typeof r.value).toBe('number'); 79 + }); 80 + 81 + it('detects 2-digit year 3/15/25', () => { 82 + const r = detectAndParseEntry('3/15/25'); 83 + expect(r.format).toBe('date'); 84 + }); 85 + 86 + it('does NOT match invalid date 2025-99-99', () => { 87 + const r = detectAndParseEntry('2025-99-99'); 88 + // JS's Date.parse('2025-99-99') returns NaN — auto-format falls through 89 + expect(r.format).toBeUndefined(); 90 + }); 91 + }); 92 + 93 + describe('thousands-separated number', () => { 94 + it('detects 1,234', () => { 95 + expect(detectAndParseEntry('1,234')).toEqual({ value: 1234, format: 'number' }); 96 + }); 97 + 98 + it('detects 1,234.56', () => { 99 + expect(detectAndParseEntry('1,234.56')).toEqual({ value: 1234.56, format: 'number' }); 100 + }); 101 + 102 + it('detects 1,000,000', () => { 103 + expect(detectAndParseEntry('1,000,000')).toEqual({ value: 1000000, format: 'number' }); 104 + }); 105 + 106 + it('does NOT match plain "1234" (let caller handle it)', () => { 107 + // Plain numbers without thousands separators fall through to the caller's 108 + // existing Number() coercion. detectAndParseEntry only claims ownership 109 + // when the format is unambiguous. 110 + const r = detectAndParseEntry('1234'); 111 + expect(r.format).toBeUndefined(); 112 + }); 113 + 114 + it('does NOT match malformed thousands "12,34"', () => { 115 + const r = detectAndParseEntry('12,34'); 116 + expect(r.format).toBeUndefined(); 117 + }); 118 + }); 119 + 120 + describe('passthrough', () => { 121 + it('returns empty for empty string', () => { 122 + expect(detectAndParseEntry('')).toEqual({ value: '', format: undefined }); 123 + }); 124 + 125 + it('returns whitespace-only as empty after trim', () => { 126 + expect(detectAndParseEntry(' ')).toEqual({ value: '', format: undefined }); 127 + }); 128 + 129 + it('returns raw string for non-matching input', () => { 130 + const r = detectAndParseEntry('hello world'); 131 + expect(r.value).toBe('hello world'); 132 + expect(r.format).toBeUndefined(); 133 + }); 134 + 135 + it('returns raw for formula-like "=SUM(A1:A5)" — caller handles formulas', () => { 136 + const r = detectAndParseEntry('=SUM(A1:A5)'); 137 + expect(r.format).toBeUndefined(); 138 + }); 139 + }); 140 + 141 + describe('edge cases that should not false-match', () => { 142 + it('does not match ambiguous "50$" (symbol after number)', () => { 143 + const r = detectAndParseEntry('50$'); 144 + expect(r.format).toBeUndefined(); 145 + }); 146 + 147 + it('does not match empty percent "%" alone', () => { 148 + const r = detectAndParseEntry('%'); 149 + expect(r.format).toBeUndefined(); 150 + }); 151 + 152 + it('does not match alpha-numeric "abc123"', () => { 153 + const r = detectAndParseEntry('abc123'); 154 + expect(r.format).toBeUndefined(); 155 + }); 156 + }); 157 + });
+164
tests/sheets-color-palette.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * v0.58.0 — preset color palette dropdown beside the native color pickers. 4 + * Users most often want common colors (black, red, blue, etc.) — the native 5 + * browser picker is overkill for that. The palette short-circuits it. 6 + */ 7 + import { describe, it, expect, beforeEach } from 'vitest'; 8 + import { 9 + PRESET_COLORS, 10 + renderColorPalette, 11 + openPalette, 12 + closePalette, 13 + wireColorPalette, 14 + } from '../src/sheets/color-palette.js'; 15 + 16 + function setup() { 17 + document.body.innerHTML = ` 18 + <button id="toggle">toggle</button> 19 + <div id="palette" hidden></div> 20 + `; 21 + return { 22 + toggle: document.getElementById('toggle') as HTMLButtonElement, 23 + container: document.getElementById('palette') as HTMLElement, 24 + }; 25 + } 26 + 27 + describe('PRESET_COLORS', () => { 28 + it('exposes a non-empty list of preset colors', () => { 29 + expect(PRESET_COLORS.length).toBeGreaterThan(0); 30 + }); 31 + 32 + it('each preset has a name and a 7-char hex', () => { 33 + for (const { name, hex } of PRESET_COLORS) { 34 + expect(typeof name).toBe('string'); 35 + expect(name.length).toBeGreaterThan(0); 36 + expect(hex).toMatch(/^#[0-9a-f]{6}$/i); 37 + } 38 + }); 39 + 40 + it('hex values are unique', () => { 41 + const set = new Set(PRESET_COLORS.map((p) => p.hex.toLowerCase())); 42 + expect(set.size).toBe(PRESET_COLORS.length); 43 + }); 44 + }); 45 + 46 + describe('renderColorPalette', () => { 47 + beforeEach(() => { 48 + document.body.innerHTML = ''; 49 + }); 50 + 51 + it('renders one swatch per preset color', () => { 52 + const { container } = setup(); 53 + renderColorPalette(container, { onSelect: () => {} }); 54 + const swatches = container.querySelectorAll('.tb-color-palette-swatch'); 55 + expect(swatches.length).toBe(PRESET_COLORS.length); 56 + }); 57 + 58 + it('each swatch has data-color, aria-label, role=menuitem', () => { 59 + const { container } = setup(); 60 + renderColorPalette(container, { onSelect: () => {} }); 61 + const first = container.querySelector<HTMLButtonElement>('.tb-color-palette-swatch')!; 62 + expect(first.dataset.color).toBe(PRESET_COLORS[0]!.hex); 63 + expect(first.getAttribute('aria-label')).toBe(PRESET_COLORS[0]!.name); 64 + expect(first.getAttribute('role')).toBe('menuitem'); 65 + }); 66 + 67 + it('clicking a swatch fires onSelect with the hex', () => { 68 + const { container } = setup(); 69 + const calls: string[] = []; 70 + renderColorPalette(container, { onSelect: (hex) => calls.push(hex) }); 71 + const red = Array.from(container.querySelectorAll<HTMLButtonElement>('.tb-color-palette-swatch')) 72 + .find((b) => b.dataset.color === '#e53935')!; 73 + red.click(); 74 + expect(calls).toEqual(['#e53935']); 75 + }); 76 + 77 + it('rebuilds on repeat call (does not accumulate swatches)', () => { 78 + const { container } = setup(); 79 + renderColorPalette(container, { onSelect: () => {} }); 80 + renderColorPalette(container, { onSelect: () => {} }); 81 + expect(container.querySelectorAll('.tb-color-palette-swatch').length).toBe(PRESET_COLORS.length); 82 + }); 83 + }); 84 + 85 + describe('openPalette / closePalette', () => { 86 + beforeEach(() => { 87 + document.body.innerHTML = ''; 88 + }); 89 + 90 + it('openPalette shows the container and sets aria-expanded=true', () => { 91 + const { toggle, container } = setup(); 92 + openPalette(container, toggle); 93 + expect(container.hidden).toBe(false); 94 + expect(toggle.getAttribute('aria-expanded')).toBe('true'); 95 + }); 96 + 97 + it('closePalette hides the container and sets aria-expanded=false', () => { 98 + const { toggle, container } = setup(); 99 + openPalette(container, toggle); 100 + closePalette(container, toggle); 101 + expect(container.hidden).toBe(true); 102 + expect(toggle.getAttribute('aria-expanded')).toBe('false'); 103 + }); 104 + }); 105 + 106 + describe('wireColorPalette', () => { 107 + beforeEach(() => { 108 + document.body.innerHTML = ''; 109 + }); 110 + 111 + it('toggle click opens the palette', () => { 112 + const { toggle, container } = setup(); 113 + wireColorPalette({ toggle, container, onSelect: () => {} }); 114 + toggle.click(); 115 + expect(container.hidden).toBe(false); 116 + }); 117 + 118 + it('toggle click while open closes the palette', () => { 119 + const { toggle, container } = setup(); 120 + wireColorPalette({ toggle, container, onSelect: () => {} }); 121 + toggle.click(); // open 122 + toggle.click(); // close 123 + expect(container.hidden).toBe(true); 124 + }); 125 + 126 + it('selecting a preset fires onSelect and closes the palette', () => { 127 + const { toggle, container } = setup(); 128 + const calls: string[] = []; 129 + wireColorPalette({ toggle, container, onSelect: (hex) => calls.push(hex) }); 130 + toggle.click(); // open 131 + const swatch = container.querySelector<HTMLButtonElement>('.tb-color-palette-swatch')!; 132 + swatch.click(); 133 + expect(calls).toEqual([PRESET_COLORS[0]!.hex]); 134 + expect(container.hidden).toBe(true); 135 + }); 136 + 137 + it('clicking outside the palette closes it', () => { 138 + const { toggle, container } = setup(); 139 + wireColorPalette({ toggle, container, onSelect: () => {} }); 140 + toggle.click(); // open 141 + expect(container.hidden).toBe(false); 142 + 143 + const outside = document.createElement('div'); 144 + document.body.appendChild(outside); 145 + outside.click(); 146 + expect(container.hidden).toBe(true); 147 + }); 148 + 149 + it('Escape key closes an open palette', () => { 150 + const { toggle, container } = setup(); 151 + wireColorPalette({ toggle, container, onSelect: () => {} }); 152 + toggle.click(); // open 153 + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); 154 + expect(container.hidden).toBe(true); 155 + }); 156 + 157 + it('Escape on closed palette is a no-op (does not throw)', () => { 158 + const { toggle, container } = setup(); 159 + wireColorPalette({ toggle, container, onSelect: () => {} }); 160 + expect(() => 161 + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })), 162 + ).not.toThrow(); 163 + }); 164 + });
+94
tests/sheets-color-picker-sync.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * Regression: toolbar color pickers must sync to the currently selected cell's 4 + * style. Previously the pickers were write-only — clicking a colored cell 5 + * didn't show the cell's color in the toolbar, so users had no way to tell 6 + * what was already applied. 7 + */ 8 + import { describe, it, expect, beforeEach } from 'vitest'; 9 + import { updateColorPickerState } from '../src/sheets/toolbar-wiring.js'; 10 + 11 + function mountToolbar() { 12 + document.body.innerHTML = ` 13 + <div> 14 + <input type="color" id="tb-text-color" value="#000000"> 15 + <span id="tb-text-color-swatch"></span> 16 + <input type="color" id="tb-bg-color" value="#ffffff"> 17 + <span id="tb-bg-color-swatch"></span> 18 + </div> 19 + `; 20 + } 21 + 22 + function makeDeps(cellStyle: Record<string, any> | null) { 23 + // Minimal stub — updateColorPickerState only reads selected cell + cell data. 24 + return { 25 + getSelectedCell: () => ({ col: 1, row: 1 }), 26 + getCellData: (_id: string) => (cellStyle ? { s: cellStyle } : undefined), 27 + } as any; 28 + } 29 + 30 + describe('updateColorPickerState', () => { 31 + beforeEach(() => { 32 + document.body.innerHTML = ''; 33 + }); 34 + 35 + it('syncs the text-color picker to the selected cell\'s color', () => { 36 + mountToolbar(); 37 + updateColorPickerState(makeDeps({ color: '#ff0000' })); 38 + expect((document.getElementById('tb-text-color') as HTMLInputElement).value).toBe('#ff0000'); 39 + expect((document.getElementById('tb-text-color-swatch') as HTMLElement).style.background).toContain('rgb(255, 0, 0)'); 40 + }); 41 + 42 + it('syncs the bg-color picker to the selected cell\'s background', () => { 43 + mountToolbar(); 44 + updateColorPickerState(makeDeps({ bg: '#00ff00' })); 45 + expect((document.getElementById('tb-bg-color') as HTMLInputElement).value).toBe('#00ff00'); 46 + expect((document.getElementById('tb-bg-color-swatch') as HTMLElement).style.background).toContain('rgb(0, 255, 0)'); 47 + }); 48 + 49 + it('syncs both text and bg when cell has both', () => { 50 + mountToolbar(); 51 + updateColorPickerState(makeDeps({ color: '#123456', bg: '#abcdef' })); 52 + expect((document.getElementById('tb-text-color') as HTMLInputElement).value).toBe('#123456'); 53 + expect((document.getElementById('tb-bg-color') as HTMLInputElement).value).toBe('#abcdef'); 54 + }); 55 + 56 + it('leaves pickers unchanged when cell has no color (keeps theme default)', () => { 57 + mountToolbar(); 58 + (document.getElementById('tb-text-color') as HTMLInputElement).value = '#111111'; 59 + (document.getElementById('tb-bg-color') as HTMLInputElement).value = '#eeeeee'; 60 + updateColorPickerState(makeDeps(null)); 61 + expect((document.getElementById('tb-text-color') as HTMLInputElement).value).toBe('#111111'); 62 + expect((document.getElementById('tb-bg-color') as HTMLInputElement).value).toBe('#eeeeee'); 63 + }); 64 + 65 + it('leaves text picker unchanged when cell only has bg', () => { 66 + mountToolbar(); 67 + (document.getElementById('tb-text-color') as HTMLInputElement).value = '#123123'; 68 + updateColorPickerState(makeDeps({ bg: '#ff0000' })); 69 + expect((document.getElementById('tb-text-color') as HTMLInputElement).value).toBe('#123123'); 70 + expect((document.getElementById('tb-bg-color') as HTMLInputElement).value).toBe('#ff0000'); 71 + }); 72 + 73 + it('expands 3-char hex shorthand (#f00) to 6-char (#ff0000) for native picker', () => { 74 + mountToolbar(); 75 + updateColorPickerState(makeDeps({ color: '#f0a' })); 76 + expect((document.getElementById('tb-text-color') as HTMLInputElement).value).toBe('#ff00aa'); 77 + }); 78 + 79 + it('gracefully handles non-hex colors (leaves picker value alone)', () => { 80 + mountToolbar(); 81 + (document.getElementById('tb-text-color') as HTMLInputElement).value = '#000000'; 82 + // Non-hex color gets passed through (jsdom may reject it silently, but 83 + // no crash is expected and the swatch still reflects the raw value). 84 + updateColorPickerState(makeDeps({ color: 'oklch(0.7 0.2 30)' })); 85 + // The swatch should show the raw color string. 86 + const swatchBg = (document.getElementById('tb-text-color-swatch') as HTMLElement).style.background; 87 + expect(swatchBg).toContain('oklch'); 88 + }); 89 + 90 + it('no-ops when toolbar DOM is not present', () => { 91 + // No mount — DOM is empty. 92 + expect(() => updateColorPickerState(makeDeps({ color: '#ff0000' }))).not.toThrow(); 93 + }); 94 + });
+192
tests/sheets-fill-horizontal.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * v0.58.0 — horizontal drag-to-fill. 4 + * 5 + * The fill handle used to only support vertical fills (extending a 6 + * selection downward). Users expect Excel/Sheets parity: dragging the 7 + * handle sideways should extend the source values across columns. 8 + * 9 + * These tests cover executeFill's axis detection and the horizontal 10 + * branch's value propagation (constants, sequences, and formula ref 11 + * adjustment by dCol). 12 + */ 13 + import { describe, it, expect, beforeEach } from 'vitest'; 14 + import { executeFill } from '../src/sheets/fill-handlers.js'; 15 + import { cellId } from '../src/sheets/formulas.js'; 16 + import type { MouseEventsDeps } from '../src/sheets/mouse-events.js'; 17 + 18 + function makeDeps(): { 19 + deps: MouseEventsDeps; 20 + cells: Map<string, any>; 21 + selection: { value: any }; 22 + } { 23 + const cells = new Map<string, any>(); 24 + const selection: { value: any } = { value: null }; 25 + const deps = { 26 + grid: document.createElement('div'), 27 + sheetContainer: document.createElement('div'), 28 + measureCtx: null, 29 + getActiveSheet: () => ({}), 30 + getSelectedCell: () => ({ col: 0, row: 0 }), 31 + setSelectedCell: () => {}, 32 + getSelectionRange: () => selection.value, 33 + setSelectionRange: (r: any) => { 34 + selection.value = r; 35 + }, 36 + setIsSelecting: () => {}, 37 + getEditingCell: () => null, 38 + commitEdit: () => {}, 39 + startEditing: () => {}, 40 + getCellData: (id: string) => cells.get(id), 41 + setCellData: (id: string, data: any) => cells.set(id, data), 42 + computeDisplayValue: (_id: string, data: any) => data?.v, 43 + getColWidth: () => 80, 44 + setColWidth: () => {}, 45 + getRowHeight: () => 24, 46 + setRowHeight: () => {}, 47 + getCellEl: () => null, 48 + getFormatPainterFormat: () => null, 49 + applyFormatPainterToCell: () => {}, 50 + updateSelectionVisuals: () => {}, 51 + updateFormulaBar: () => {}, 52 + updateMergeButtonState: () => {}, 53 + unhideAdjacentRows: () => {}, 54 + unhideAdjacentCols: () => {}, 55 + renderGrid: () => {}, 56 + refreshVisibleCells: () => {}, 57 + autoFitColumn: () => {}, 58 + autoFitRow: () => {}, 59 + ydoc: { transact: (fn: () => void) => fn() }, 60 + evalCache: { clear: () => {} }, 61 + clearSpillMaps: () => {}, 62 + invalidateRecalcEngine: () => {}, 63 + getFillPreviewRange: () => null, 64 + setFillPreviewRange: () => {}, 65 + setIsFillDragging: () => {}, 66 + DEFAULT_ROWS: 100, 67 + DEFAULT_COLS: 26, 68 + MIN_COL_WIDTH: 40, 69 + } as unknown as MouseEventsDeps; 70 + return { deps, cells, selection }; 71 + } 72 + 73 + describe('executeFill — horizontal axis detection', () => { 74 + it('dispatches horizontal fill when targetRange extends past endCol', () => { 75 + const { deps, cells } = makeDeps(); 76 + cells.set(cellId(0, 0), { v: 10 }); 77 + cells.set(cellId(1, 0), { v: 20 }); 78 + // Source A1:B1, target C1:E1 (extend rightwards) 79 + const sourceRange = { startCol: 0, startRow: 0, endCol: 1, endRow: 0 }; 80 + const targetRange = { startCol: 2, startRow: 0, endCol: 4, endRow: 0 }; 81 + executeFill(deps, sourceRange, targetRange); 82 + // Arithmetic sequence step=10: 30, 40, 50 83 + expect(cells.get(cellId(2, 0))?.v).toBe(30); 84 + expect(cells.get(cellId(3, 0))?.v).toBe(40); 85 + expect(cells.get(cellId(4, 0))?.v).toBe(50); 86 + }); 87 + 88 + it('dispatches horizontal fill backward (extend leftwards)', () => { 89 + const { deps, cells } = makeDeps(); 90 + cells.set(cellId(3, 0), { v: 100 }); 91 + cells.set(cellId(4, 0), { v: 110 }); 92 + // Source D1:E1, target A1:C1 (extend leftwards) 93 + const sourceRange = { startCol: 3, startRow: 0, endCol: 4, endRow: 0 }; 94 + const targetRange = { startCol: 0, startRow: 0, endCol: 2, endRow: 0 }; 95 + executeFill(deps, sourceRange, targetRange); 96 + // Backward arithmetic: 70, 80, 90 (step=10, extending before 100) 97 + expect(cells.get(cellId(0, 0))?.v).toBe(70); 98 + expect(cells.get(cellId(1, 0))?.v).toBe(80); 99 + expect(cells.get(cellId(2, 0))?.v).toBe(90); 100 + }); 101 + }); 102 + 103 + describe('executeFill — vertical axis still works (regression)', () => { 104 + it('fills values downward for a single-column source', () => { 105 + const { deps, cells } = makeDeps(); 106 + cells.set(cellId(0, 0), { v: 1 }); 107 + cells.set(cellId(0, 1), { v: 2 }); 108 + const sourceRange = { startCol: 0, startRow: 0, endCol: 0, endRow: 1 }; 109 + const targetRange = { startCol: 0, startRow: 2, endCol: 0, endRow: 4 }; 110 + executeFill(deps, sourceRange, targetRange); 111 + expect(cells.get(cellId(0, 2))?.v).toBe(3); 112 + expect(cells.get(cellId(0, 3))?.v).toBe(4); 113 + expect(cells.get(cellId(0, 4))?.v).toBe(5); 114 + }); 115 + }); 116 + 117 + describe('executeHorizontalFill — value propagation', () => { 118 + beforeEach(() => {}); 119 + 120 + it('repeats text values across columns', () => { 121 + const { deps, cells } = makeDeps(); 122 + cells.set(cellId(0, 0), { v: 'hello' }); 123 + const sourceRange = { startCol: 0, startRow: 0, endCol: 0, endRow: 0 }; 124 + const targetRange = { startCol: 1, startRow: 0, endCol: 3, endRow: 0 }; 125 + executeFill(deps, sourceRange, targetRange); 126 + expect(cells.get(cellId(1, 0))?.v).toBe('hello'); 127 + expect(cells.get(cellId(2, 0))?.v).toBe('hello'); 128 + expect(cells.get(cellId(3, 0))?.v).toBe('hello'); 129 + }); 130 + 131 + it('propagates across multiple rows independently', () => { 132 + const { deps, cells } = makeDeps(); 133 + // Row 0: 1, 2 → fill should produce 3,4,5 134 + // Row 1: 10, 20 → fill should produce 30,40,50 135 + cells.set(cellId(0, 0), { v: 1 }); 136 + cells.set(cellId(1, 0), { v: 2 }); 137 + cells.set(cellId(0, 1), { v: 10 }); 138 + cells.set(cellId(1, 1), { v: 20 }); 139 + const sourceRange = { startCol: 0, startRow: 0, endCol: 1, endRow: 1 }; 140 + const targetRange = { startCol: 2, startRow: 0, endCol: 4, endRow: 1 }; 141 + executeFill(deps, sourceRange, targetRange); 142 + expect(cells.get(cellId(2, 0))?.v).toBe(3); 143 + expect(cells.get(cellId(3, 0))?.v).toBe(4); 144 + expect(cells.get(cellId(4, 0))?.v).toBe(5); 145 + expect(cells.get(cellId(2, 1))?.v).toBe(30); 146 + expect(cells.get(cellId(3, 1))?.v).toBe(40); 147 + expect(cells.get(cellId(4, 1))?.v).toBe(50); 148 + }); 149 + }); 150 + 151 + describe('executeHorizontalFill — formula dCol adjustment', () => { 152 + it('adjusts formula column references by dCol when filling right', () => { 153 + const { deps, cells } = makeDeps(); 154 + // A1 = =B1+C1, fill right to B1, C1, D1. 155 + // Expected: B1 = =C1+D1, C1 = =D1+E1, D1 = =E1+F1. 156 + cells.set(cellId(0, 0), { f: '=B1+C1', v: '' }); 157 + const sourceRange = { startCol: 0, startRow: 0, endCol: 0, endRow: 0 }; 158 + const targetRange = { startCol: 1, startRow: 0, endCol: 3, endRow: 0 }; 159 + executeFill(deps, sourceRange, targetRange); 160 + expect(cells.get(cellId(1, 0))?.f).toBe('=C1+D1'); 161 + expect(cells.get(cellId(2, 0))?.f).toBe('=D1+E1'); 162 + expect(cells.get(cellId(3, 0))?.f).toBe('=E1+F1'); 163 + }); 164 + 165 + it('leaves row references unchanged when filling horizontally', () => { 166 + const { deps, cells } = makeDeps(); 167 + // A1 = =A$2+B$3 with absolute rows — dCol shifts cols but rows stay. 168 + cells.set(cellId(0, 0), { f: '=A$2+B$3', v: '' }); 169 + const sourceRange = { startCol: 0, startRow: 0, endCol: 0, endRow: 0 }; 170 + const targetRange = { startCol: 1, startRow: 0, endCol: 2, endRow: 0 }; 171 + executeFill(deps, sourceRange, targetRange); 172 + // Column portion shifts (A→B, B→C) but rows stay $2/$3 173 + expect(cells.get(cellId(1, 0))?.f).toBe('=B$2+C$3'); 174 + expect(cells.get(cellId(2, 0))?.f).toBe('=C$2+D$3'); 175 + }); 176 + }); 177 + 178 + describe('executeFill — post-fill selection range', () => { 179 + it('selects the union of source and target after horizontal fill', () => { 180 + const { deps, cells, selection } = makeDeps(); 181 + cells.set(cellId(0, 0), { v: 1 }); 182 + const sourceRange = { startCol: 0, startRow: 0, endCol: 0, endRow: 0 }; 183 + const targetRange = { startCol: 1, startRow: 0, endCol: 3, endRow: 0 }; 184 + executeFill(deps, sourceRange, targetRange); 185 + expect(selection.value).toEqual({ 186 + startCol: 0, 187 + startRow: 0, 188 + endCol: 3, 189 + endRow: 0, 190 + }); 191 + }); 192 + });