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): date picker widget for date cells' (#108) from feat/date-picker into main

scott cc52af92 69479fee

+380
+103
src/css/app.css
··· 5191 5191 } 5192 5192 5193 5193 /* ======================================================== 5194 + Date Picker (#123) 5195 + ======================================================== */ 5196 + 5197 + .date-picker { 5198 + background: var(--color-surface); 5199 + border: 1px solid var(--color-border-strong); 5200 + border-radius: var(--radius-md); 5201 + box-shadow: var(--shadow-lg); 5202 + padding: 8px; 5203 + width: 224px; 5204 + font-family: var(--font-body); 5205 + font-size: 0.75rem; 5206 + } 5207 + 5208 + .dp-header { 5209 + display: flex; 5210 + align-items: center; 5211 + justify-content: space-between; 5212 + margin-bottom: 6px; 5213 + } 5214 + 5215 + .dp-title { 5216 + font-weight: 600; 5217 + font-size: 0.8rem; 5218 + } 5219 + 5220 + .dp-nav { 5221 + background: none; 5222 + border: none; 5223 + cursor: pointer; 5224 + padding: 2px 6px; 5225 + border-radius: var(--radius-sm); 5226 + color: var(--color-text); 5227 + font-size: 0.65rem; 5228 + } 5229 + 5230 + .dp-nav:hover { 5231 + background: var(--color-surface-alt); 5232 + } 5233 + 5234 + .dp-grid { 5235 + display: grid; 5236 + grid-template-columns: repeat(7, 1fr); 5237 + gap: 1px; 5238 + text-align: center; 5239 + } 5240 + 5241 + .dp-day-header { 5242 + font-weight: 600; 5243 + color: var(--color-text-faint); 5244 + padding: 4px 0; 5245 + font-size: 0.65rem; 5246 + } 5247 + 5248 + .dp-empty { 5249 + padding: 4px; 5250 + } 5251 + 5252 + .dp-day { 5253 + background: none; 5254 + border: none; 5255 + padding: 4px; 5256 + cursor: pointer; 5257 + border-radius: var(--radius-sm); 5258 + color: var(--color-text); 5259 + font-size: 0.75rem; 5260 + line-height: 1.6; 5261 + } 5262 + 5263 + .dp-day:hover { 5264 + background: var(--color-surface-alt); 5265 + } 5266 + 5267 + .dp-today { 5268 + font-weight: 700; 5269 + outline: 1px solid var(--color-accent); 5270 + outline-offset: -1px; 5271 + } 5272 + 5273 + .dp-selected { 5274 + background: var(--color-accent) !important; 5275 + color: white; 5276 + } 5277 + 5278 + .dp-footer { 5279 + margin-top: 6px; 5280 + text-align: center; 5281 + } 5282 + 5283 + .dp-today-btn { 5284 + background: none; 5285 + border: none; 5286 + cursor: pointer; 5287 + color: var(--color-accent); 5288 + font-size: 0.7rem; 5289 + font-weight: 600; 5290 + } 5291 + 5292 + .dp-today-btn:hover { 5293 + text-decoration: underline; 5294 + } 5295 + 5296 + /* ======================================================== 5194 5297 Onboarding Tooltip 5195 5298 ======================================================== */ 5196 5299
+176
src/sheets/date-picker.ts
··· 1 + /** 2 + * Inline Date Picker Widget for Sheets (#123) 3 + * 4 + * Shows a calendar popup when editing a date-formatted cell. 5 + * Designed for mouse-click selection; keyboard nav handled by 6 + * the main grid shortcuts. 7 + */ 8 + 9 + export interface DatePickerOptions { 10 + onSelect: (dateStr: string) => void; 11 + onClose: () => void; 12 + } 13 + 14 + const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; 15 + const MONTHS = [ 16 + 'January', 'February', 'March', 'April', 'May', 'June', 17 + 'July', 'August', 'September', 'October', 'November', 'December', 18 + ]; 19 + 20 + /** 21 + * Try to parse a cell value as a date (YYYY-MM-DD or similar). 22 + * Returns a Date if valid, null otherwise. 23 + */ 24 + export function parseDateValue(value: string): Date | null { 25 + if (!value || typeof value !== 'string') return null; 26 + // ISO format YYYY-MM-DD 27 + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value.trim()); 28 + if (iso) { 29 + const d = new Date(parseInt(iso[1]), parseInt(iso[2]) - 1, parseInt(iso[3])); 30 + if (!isNaN(d.getTime())) return d; 31 + } 32 + // MM/DD/YYYY 33 + const us = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value.trim()); 34 + if (us) { 35 + const d = new Date(parseInt(us[3]), parseInt(us[1]) - 1, parseInt(us[2])); 36 + if (!isNaN(d.getTime())) return d; 37 + } 38 + return null; 39 + } 40 + 41 + /** Format date as YYYY-MM-DD. */ 42 + export function formatDate(d: Date): string { 43 + const y = d.getFullYear(); 44 + const m = String(d.getMonth() + 1).padStart(2, '0'); 45 + const day = String(d.getDate()).padStart(2, '0'); 46 + return `${y}-${m}-${day}`; 47 + } 48 + 49 + /** 50 + * Create and show a date picker popup anchored to a cell element. 51 + * Returns a cleanup function to remove the popup. 52 + */ 53 + export function showDatePicker( 54 + anchorEl: HTMLElement, 55 + initialValue: string, 56 + opts: DatePickerOptions, 57 + ): () => void { 58 + const parsed = parseDateValue(initialValue); 59 + let viewDate = parsed || new Date(); 60 + const selectedDate = parsed ? new Date(parsed) : null; 61 + 62 + const container = document.createElement('div'); 63 + container.className = 'date-picker'; 64 + container.setAttribute('role', 'dialog'); 65 + container.setAttribute('aria-label', 'Date picker'); 66 + 67 + function render() { 68 + const year = viewDate.getFullYear(); 69 + const month = viewDate.getMonth(); 70 + const firstDay = new Date(year, month, 1).getDay(); 71 + const daysInMonth = new Date(year, month + 1, 0).getDate(); 72 + const today = new Date(); 73 + 74 + let html = '<div class="dp-header">'; 75 + html += `<button class="dp-nav" data-dir="-1" aria-label="Previous month">\u25C0</button>`; 76 + html += `<span class="dp-title">${MONTHS[month]} ${year}</span>`; 77 + html += `<button class="dp-nav" data-dir="1" aria-label="Next month">\u25B6</button>`; 78 + html += '</div>'; 79 + 80 + html += '<div class="dp-grid">'; 81 + for (const day of DAYS) { 82 + html += `<span class="dp-day-header">${day}</span>`; 83 + } 84 + 85 + // Empty cells before first day 86 + for (let i = 0; i < firstDay; i++) { 87 + html += '<span class="dp-empty"></span>'; 88 + } 89 + 90 + for (let d = 1; d <= daysInMonth; d++) { 91 + const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear(); 92 + const isSelected = selectedDate && d === selectedDate.getDate() && month === selectedDate.getMonth() && year === selectedDate.getFullYear(); 93 + const cls = ['dp-day']; 94 + if (isToday) cls.push('dp-today'); 95 + if (isSelected) cls.push('dp-selected'); 96 + html += `<button class="${cls.join(' ')}" data-day="${d}">${d}</button>`; 97 + } 98 + 99 + html += '</div>'; 100 + 101 + // Today button 102 + html += '<div class="dp-footer"><button class="dp-today-btn">Today</button></div>'; 103 + 104 + container.innerHTML = html; 105 + } 106 + 107 + render(); 108 + 109 + // Position near the anchor cell 110 + const rect = anchorEl.getBoundingClientRect(); 111 + container.style.position = 'absolute'; 112 + container.style.left = rect.left + 'px'; 113 + container.style.top = (rect.bottom + 2) + 'px'; 114 + container.style.zIndex = '1003'; 115 + document.body.appendChild(container); 116 + 117 + // Adjust if off-screen 118 + requestAnimationFrame(() => { 119 + const cr = container.getBoundingClientRect(); 120 + if (cr.right > window.innerWidth) { 121 + container.style.left = (window.innerWidth - cr.width - 8) + 'px'; 122 + } 123 + if (cr.bottom > window.innerHeight) { 124 + container.style.top = (rect.top - cr.height - 2) + 'px'; 125 + } 126 + }); 127 + 128 + // Event handlers 129 + function handleClick(e: MouseEvent) { 130 + const target = e.target as HTMLElement; 131 + 132 + // Navigation 133 + const nav = target.closest('.dp-nav') as HTMLElement | null; 134 + if (nav) { 135 + const dir = parseInt(nav.dataset.dir || '0'); 136 + viewDate = new Date(viewDate.getFullYear(), viewDate.getMonth() + dir, 1); 137 + render(); 138 + return; 139 + } 140 + 141 + // Day selection 142 + const dayBtn = target.closest('.dp-day') as HTMLElement | null; 143 + if (dayBtn && dayBtn.dataset.day) { 144 + const d = new Date(viewDate.getFullYear(), viewDate.getMonth(), parseInt(dayBtn.dataset.day)); 145 + opts.onSelect(formatDate(d)); 146 + cleanup(); 147 + return; 148 + } 149 + 150 + // Today button 151 + if (target.closest('.dp-today-btn')) { 152 + opts.onSelect(formatDate(new Date())); 153 + cleanup(); 154 + return; 155 + } 156 + } 157 + 158 + function handleOutsideClick(e: MouseEvent) { 159 + if (!container.contains(e.target as Node)) { 160 + cleanup(); 161 + } 162 + } 163 + 164 + container.addEventListener('click', handleClick); 165 + // Delay the outside click listener to avoid immediate close 166 + setTimeout(() => document.addEventListener('click', handleOutsideClick), 0); 167 + 168 + function cleanup() { 169 + container.removeEventListener('click', handleClick); 170 + document.removeEventListener('click', handleOutsideClick); 171 + container.remove(); 172 + opts.onClose(); 173 + } 174 + 175 + return cleanup; 176 + }
+13
src/sheets/main.ts
··· 19 19 import { multiColumnSort } from './sort.js'; 20 20 import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 21 21 import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 22 + import { parseDateValue, showDatePicker } from './date-picker.js'; 22 23 import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 23 24 import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js'; 24 25 import { computeSelectionStats, formatStatValue } from './status-bar.js'; ··· 1360 1361 // Initial highlight/range update (with range colors — editing active) 1361 1362 updateFormulaHighlight(value, true); 1362 1363 updateFormulaRangeHighlights(value); 1364 + 1365 + // Show date picker for date-like values (#123) 1366 + if (!cellData?.f && parseDateValue(String(value))) { 1367 + showDatePicker(td as HTMLElement, String(value), { 1368 + onSelect: (dateStr) => { 1369 + input.value = dateStr; 1370 + formulaInput.value = dateStr; 1371 + commitEdit(); 1372 + }, 1373 + onClose: () => {}, 1374 + }); 1375 + } 1363 1376 } 1364 1377 1365 1378 function commitEdit() {
+88
tests/date-picker.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseDateValue, formatDate } from '../src/sheets/date-picker.js'; 3 + 4 + describe('parseDateValue', () => { 5 + it('parses YYYY-MM-DD format', () => { 6 + const d = parseDateValue('2026-03-15'); 7 + expect(d).not.toBeNull(); 8 + expect(d!.getFullYear()).toBe(2026); 9 + expect(d!.getMonth()).toBe(2); // 0-indexed 10 + expect(d!.getDate()).toBe(15); 11 + }); 12 + 13 + it('parses single-digit month and day', () => { 14 + const d = parseDateValue('2026-1-5'); 15 + expect(d).not.toBeNull(); 16 + expect(d!.getMonth()).toBe(0); 17 + expect(d!.getDate()).toBe(5); 18 + }); 19 + 20 + it('parses MM/DD/YYYY format', () => { 21 + const d = parseDateValue('03/15/2026'); 22 + expect(d).not.toBeNull(); 23 + expect(d!.getFullYear()).toBe(2026); 24 + expect(d!.getMonth()).toBe(2); 25 + expect(d!.getDate()).toBe(15); 26 + }); 27 + 28 + it('parses single-digit MM/DD/YYYY', () => { 29 + const d = parseDateValue('1/5/2026'); 30 + expect(d).not.toBeNull(); 31 + expect(d!.getMonth()).toBe(0); 32 + expect(d!.getDate()).toBe(5); 33 + }); 34 + 35 + it('trims whitespace', () => { 36 + const d = parseDateValue(' 2026-03-15 '); 37 + expect(d).not.toBeNull(); 38 + expect(d!.getDate()).toBe(15); 39 + }); 40 + 41 + it('returns null for empty string', () => { 42 + expect(parseDateValue('')).toBeNull(); 43 + }); 44 + 45 + it('returns null for non-date string', () => { 46 + expect(parseDateValue('hello')).toBeNull(); 47 + }); 48 + 49 + it('returns null for number string', () => { 50 + expect(parseDateValue('12345')).toBeNull(); 51 + }); 52 + 53 + it('returns null for null', () => { 54 + expect(parseDateValue(null as unknown as string)).toBeNull(); 55 + }); 56 + 57 + it('returns null for undefined', () => { 58 + expect(parseDateValue(undefined as unknown as string)).toBeNull(); 59 + }); 60 + 61 + it('returns null for partial date', () => { 62 + expect(parseDateValue('2026-03')).toBeNull(); 63 + }); 64 + }); 65 + 66 + describe('formatDate', () => { 67 + it('formats a date as YYYY-MM-DD', () => { 68 + const d = new Date(2026, 2, 15); // March 15, 2026 69 + expect(formatDate(d)).toBe('2026-03-15'); 70 + }); 71 + 72 + it('pads single-digit month and day', () => { 73 + const d = new Date(2026, 0, 5); // January 5, 2026 74 + expect(formatDate(d)).toBe('2026-01-05'); 75 + }); 76 + 77 + it('handles December 31', () => { 78 + const d = new Date(2026, 11, 31); 79 + expect(formatDate(d)).toBe('2026-12-31'); 80 + }); 81 + 82 + it('roundtrips with parseDateValue', () => { 83 + const original = '2026-06-20'; 84 + const parsed = parseDateValue(original); 85 + expect(parsed).not.toBeNull(); 86 + expect(formatDate(parsed!)).toBe(original); 87 + }); 88 + });