max / goingson
10 files changed,
+198 insertions,
-30 deletions
| @@ -116,6 +116,20 @@ Run before every release. Sign-off table at the bottom. | |||
| 116 | 116 | - [ ] Linux: Secret Service available; AppImage launches without missing libs | |
| 117 | 117 | - [ ] Windows: Credential Manager works; .msi installs and uninstalls cleanly | |
| 118 | 118 | ||
| 119 | + | ### Mobile (iOS + Android, pre-TestFlight/Play submission) | |
| 120 | + | ||
| 121 | + | Polish landed 2026-05-16 in `src-tauri/frontend/css/styles.css`, `js/mobile.js`, `js/app.js`, `js/virtual-scroller.js`, `js/components.js`, `js/navigation.js`. Verify on real devices before submission. | |
| 122 | + | ||
| 123 | + | - [ ] **Safe areas — notched portrait**: tab bar bottom edge clears the home indicator; header isn't clipped by notch | |
| 124 | + | - [ ] **Safe areas — landscape**: content not clipped on notched side; tab bar buttons aren't under the notch | |
| 125 | + | - [ ] **Timer widget**: starts a timer, switches to a tab where the timer widget shows — widget sits above tab bar (not behind it) on notched iPhones | |
| 126 | + | - [ ] **Keyboard scroll**: open a modal with a form (e.g. New Task), tap a field near the bottom — field scrolls into view above the keyboard | |
| 127 | + | - [ ] **Background/foreground**: open app, switch away for >30s, return — view refreshes, sync indicator updates, no stale "current time" line on day plan | |
| 128 | + | - [ ] **Virtual scroller**: scroll through a long task or email list — smooth momentum, no jank or flashing rows | |
| 129 | + | - [ ] **VoiceOver (iOS) / TalkBack (Android)**: tab bar reads as tablist with selected state; "More" button announces expanded/collapsed; action sheet announces as modal dialog; focus returns to trigger on close | |
| 130 | + | - [ ] **Escape key (Bluetooth keyboard)**: closes the action sheet | |
| 131 | + | - [ ] **Long-press menu**: long-press a task row opens the action sheet; cancel returns to list | |
| 132 | + | ||
| 119 | 133 | ### Robustness | |
| 120 | 134 | ||
| 121 | 135 | - [ ] Quit during sync — relaunch resumes, no data corruption |
| @@ -6,14 +6,37 @@ Completed items: [todo_done.md](./todo_done.md) | |||
| 6 | 6 | ||
| 7 | 7 | --- | |
| 8 | 8 | ||
| 9 | - | ## Sprint: Launch Blockers | |
| 9 | + | ## Launch (Locked Scope, 2026-05-16) | |
| 10 | 10 | ||
| 11 | - | Must be fixed before testers touch the app. Includes Run 24 critical/serious findings. | |
| 11 | + | Public-launch blockers. Everything else in this file is post-launch. Do not promote items into this section without explicit user decision. | |
| 12 | 12 | ||
| 13 | + | ### Core | |
| 13 | 14 | - [ ] Test full checkout flow against live Stripe (subscribe → webhook → sync gate passes) | |
| 14 | 15 | - [x] Test full sync flow against live MNW server — working (2026-05-11, API key fixed) | |
| 15 | 16 | - [ ] OAuth provider registration: Fastmail (pending), Google (test), Microsoft (test) | |
| 16 | 17 | ||
| 18 | + | ### Desktop builds | |
| 19 | + | - [ ] Windows: `cargo tauri build` → verify `.msi`, code-sign with Authenticode, test on Windows | |
| 20 | + | - [ ] Linux: AppImage (x86_64 + aarch64) | |
| 21 | + | ||
| 22 | + | ### Mobile (iOS TestFlight — keep in scope, near completion) | |
| 23 | + | See `todo_mobile.md` for full breakdown. Launch requires: | |
| 24 | + | - [ ] Android emulator smoke (`cargo tauri android dev`) + CRUD verified on mobile WebView | |
| 25 | + | - [ ] Physical device testing (iOS + Android) | |
| 26 | + | - [ ] Safe area insets across device models, keyboard-doesn't-obscure-inputs, background/foreground transitions | |
| 27 | + | - [ ] iOS internal testing: invite phone Apple ID, add to TestFlight Internal group, install + smoke-test | |
| 28 | + | - [ ] Add Privacy Policy page to GoingsOn project on MNW (URL: `https://makenot.work/p/goingson#section-privacy-policy`) | |
| 29 | + | - [ ] iOS External Testing: add build, fill Beta App Information, submit for Beta App Review, enable Public Link | |
| 30 | + | - [ ] Android release: Google Play Developer account ($25), release AAB, Play Console listing, submit | |
| 31 | + | ||
| 32 | + | Explicitly **out of launch scope** (defer until after public launch): | |
| 33 | + | - All of Sprint: Backup & Export (current backup works for primary data types) | |
| 34 | + | - All of Sprint: Email Hardening except OAuth registration above | |
| 35 | + | - All of Sprint: Events, Bulk Actions, Task Row Actions, Timer & Focus, Discoverability & Onboarding, Review Polish, Contacts Cleanup, Settings & Sync UX, Notifications & Reminders, Power User Features | |
| 36 | + | - Package managers (Homebrew/Flatpak/winget) — post-beta | |
| 37 | + | - Mobile platform features (push notifications, haptics, share extension, widgets, biometric) | |
| 38 | + | - All Sprint: Data Validation (LOW-severity) | |
| 39 | + | ||
| 17 | 40 | --- | |
| 18 | 41 | ||
| 19 | 42 | ## Sprint: Backup & Export |
| @@ -12,11 +12,11 @@ Done: Phases 1-7 (CSS, touch, navigation, views, build config, tab bar, distribu | |||
| 12 | 12 | ||
| 13 | 13 | ### Polish | |
| 14 | 14 | - [ ] Physical device testing (iOS + Android) | |
| 15 | - | - [ ] Safe area insets on various device models (notched, non-notched) | |
| 16 | - | - [ ] Virtual scroller performance on mobile | |
| 17 | - | - [ ] VoiceOver / TalkBack accessibility | |
| 18 | - | - [ ] Keyboard doesn't obscure inputs in modals | |
| 19 | - | - [ ] Background/foreground transitions work correctly | |
| 15 | + | - [x] Safe area insets on various device models (notched, non-notched) — 2026-05-16: timer-widget bottom now uses `calc(52px + env(safe-area-inset-bottom))`; body + fixed UI respect `safe-area-inset-left/right` for landscape. Verify on device. | |
| 16 | + | - [x] Virtual scroller performance on mobile — 2026-05-16: short-circuit re-renders when visible range unchanged; removed redundant O(N) walks per scroll tick. Verify on device with long lists. | |
| 17 | + | - [x] VoiceOver / TalkBack accessibility — 2026-05-16: `mobile-more-btn` gets `aria-expanded`/`aria-haspopup`/`aria-controls`; popover is `role="menu"` with `aria-hidden` toggle; action sheet now traps focus + restores on close + closes on Escape. Spot-check with VoiceOver. | |
| 18 | + | - [x] Keyboard doesn't obscure inputs in modals — 2026-05-16: `wireKeyboardScrollIntoView()` in `mobile.js` uses `visualViewport` to scroll focused inputs into view on keyboard show. Verify on device. | |
| 19 | + | - [x] Background/foreground transitions work correctly — 2026-05-16: `visibilitychange` handler in `app.js` invalidates caches and refreshes view/sync indicator/day-plan time when app was hidden >30s. Verify resume behavior on device. | |
| 20 | 20 | ||
| 21 | 21 | ### Platform Features (Future) | |
| 22 | 22 | - [ ] Push notifications via Tauri plugin |
| @@ -6253,6 +6253,16 @@ button.milestone-reorder-btn.btn { | |||
| 6253 | 6253 | body { | |
| 6254 | 6254 | padding-top: env(safe-area-inset-top, 0px); | |
| 6255 | 6255 | padding-bottom: calc(52px + env(safe-area-inset-bottom, 0px)); | |
| 6256 | + | padding-left: env(safe-area-inset-left, 0px); | |
| 6257 | + | padding-right: env(safe-area-inset-right, 0px); | |
| 6258 | + | } | |
| 6259 | + | ||
| 6260 | + | /* Fixed-position UI must extend edge-to-edge under the notch in landscape */ | |
| 6261 | + | .mobile-tab-bar, | |
| 6262 | + | .timer-widget, | |
| 6263 | + | .mobile-more-popover { | |
| 6264 | + | padding-left: env(safe-area-inset-left, 0px); | |
| 6265 | + | padding-right: env(safe-area-inset-right, 0px); | |
| 6256 | 6266 | } | |
| 6257 | 6267 | ||
| 6258 | 6268 | .mobile-tab-bar { | |
| @@ -7230,7 +7240,7 @@ button.milestone-reorder-btn.btn { | |||
| 7230 | 7240 | /* Timer widget mobile adjustment */ | |
| 7231 | 7241 | @media (max-width: 768px) { | |
| 7232 | 7242 | .timer-widget { | |
| 7233 | - | bottom: 60px; /* Above mobile tab bar */ | |
| 7243 | + | bottom: calc(52px + env(safe-area-inset-bottom, 0px)); | |
| 7234 | 7244 | } | |
| 7235 | 7245 | .focus-countdown { | |
| 7236 | 7246 | font-size: 3rem; |
| @@ -568,13 +568,13 @@ | |||
| 568 | 568 | <span class="mobile-tab-label">Messages</span> | |
| 569 | 569 | </button> | |
| 570 | 570 | <button class="mobile-tab mobile-tab-create" id="mobile-create-btn" aria-label="Create new item">+</button> | |
| 571 | - | <button class="mobile-tab mobile-tab-more" id="mobile-more-btn" aria-label="More options"> | |
| 571 | + | <button class="mobile-tab mobile-tab-more" id="mobile-more-btn" aria-label="More options" aria-haspopup="menu" aria-expanded="false" aria-controls="mobile-more-popover"> | |
| 572 | 572 | <span class="mobile-tab-label">More</span> | |
| 573 | 573 | </button> | |
| 574 | 574 | </nav> | |
| 575 | - | <div id="mobile-more-popover" class="mobile-more-popover"> | |
| 576 | - | <button onclick="GoingsOn.settings.open(); GoingsOn.navigation.closeMorePopover();">Settings</button> | |
| 577 | - | <button onclick="GoingsOn.keyboard.showShortcuts(); GoingsOn.navigation.closeMorePopover();">Shortcuts</button> | |
| 575 | + | <div id="mobile-more-popover" class="mobile-more-popover" role="menu" aria-hidden="true" aria-label="More options"> | |
| 576 | + | <button role="menuitem" onclick="GoingsOn.settings.open(); GoingsOn.navigation.closeMorePopover();">Settings</button> | |
| 577 | + | <button role="menuitem" onclick="GoingsOn.keyboard.showShortcuts(); GoingsOn.navigation.closeMorePopover();">Shortcuts</button> | |
| 578 | 578 | </div> | |
| 579 | 579 | ||
| 580 | 580 | <!-- Action Bottom Sheet (mobile context menus) --> |
| @@ -307,4 +307,35 @@ GoingsOn.app = { | |||
| 307 | 307 | showHint, | |
| 308 | 308 | }; | |
| 309 | 309 | ||
| 310 | + | // ============ Background/Foreground Transitions ============ | |
| 311 | + | // On mobile (and laptop sleep/wake), the app can sit hidden for arbitrary | |
| 312 | + | // durations. Browsers throttle JS while hidden; the bigger issue is that | |
| 313 | + | // rendered state (current time, sync status, lists) is stale on resume. | |
| 314 | + | // If the app was hidden long enough that anything time-sensitive could have | |
| 315 | + | // shifted, refresh on visibility return. | |
| 316 | + | (function wireVisibilityRefresh() { | |
| 317 | + | const STALE_THRESHOLD_MS = 30_000; | |
| 318 | + | let hiddenAt = null; | |
| 319 | + | ||
| 320 | + | document.addEventListener('visibilitychange', () => { | |
| 321 | + | if (document.hidden) { | |
| 322 | + | hiddenAt = Date.now(); | |
| 323 | + | return; | |
| 324 | + | } | |
| 325 | + | if (hiddenAt == null) return; | |
| 326 | + | const hiddenFor = Date.now() - hiddenAt; | |
| 327 | + | hiddenAt = null; | |
| 328 | + | if (hiddenFor < STALE_THRESHOLD_MS) return; | |
| 329 | + | ||
| 330 | + | // Refresh data and time-sensitive UI. Skip if a modal is open — | |
| 331 | + | // refreshCurrentViewData already guards against that. | |
| 332 | + | GoingsOn.cache?.invalidateAll?.(); | |
| 333 | + | refreshCurrentViewData(); | |
| 334 | + | GoingsOn.settings?.refreshSyncIndicator?.(); | |
| 335 | + | if (GoingsOn.dayPlanning?.updateCurrentTimeIndicator) { | |
| 336 | + | GoingsOn.dayPlanning.updateCurrentTimeIndicator(true); | |
| 337 | + | } | |
| 338 | + | }); | |
| 339 | + | })(); | |
| 340 | + | ||
| 310 | 341 | })(); |
| @@ -304,6 +304,10 @@ function getProjectContextMenuItems(projectId) { | |||
| 304 | 304 | * Show an action sheet (mobile alternative to context menus). | |
| 305 | 305 | * @param {Array} items - Same format as showContextMenu items | |
| 306 | 306 | */ | |
| 307 | + | // Remember which element triggered the sheet so focus can be restored on close. | |
| 308 | + | let actionSheetReturnFocus = null; | |
| 309 | + | let actionSheetEscHandler = null; | |
| 310 | + | ||
| 307 | 311 | function showActionSheet(items) { | |
| 308 | 312 | const sheet = document.getElementById('action-sheet'); | |
| 309 | 313 | const content = document.getElementById('action-sheet-content'); | |
| @@ -334,6 +338,17 @@ function showActionSheet(items) { | |||
| 334 | 338 | ||
| 335 | 339 | sheet.classList.remove('hidden'); | |
| 336 | 340 | ||
| 341 | + | // Remember focus + move it into the sheet for screen-reader/keyboard users. | |
| 342 | + | actionSheetReturnFocus = document.activeElement; | |
| 343 | + | const firstButton = content.querySelector('button'); | |
| 344 | + | if (firstButton) firstButton.focus(); | |
| 345 | + | ||
| 346 | + | // Close on Escape (matches modal convention). | |
| 347 | + | actionSheetEscHandler = (e) => { | |
| 348 | + | if (e.key === 'Escape') hideActionSheet(); | |
| 349 | + | }; | |
| 350 | + | document.addEventListener('keydown', actionSheetEscHandler); | |
| 351 | + | ||
| 337 | 352 | // Close on backdrop tap | |
| 338 | 353 | const backdrop = sheet.querySelector('.action-sheet-backdrop'); | |
| 339 | 354 | function onBackdropClick() { | |
| @@ -355,6 +370,14 @@ function showActionSheet(items) { | |||
| 355 | 370 | function hideActionSheet() { | |
| 356 | 371 | const sheet = document.getElementById('action-sheet'); | |
| 357 | 372 | if (sheet) sheet.classList.add('hidden'); | |
| 373 | + | if (actionSheetEscHandler) { | |
| 374 | + | document.removeEventListener('keydown', actionSheetEscHandler); | |
| 375 | + | actionSheetEscHandler = null; | |
| 376 | + | } | |
| 377 | + | if (actionSheetReturnFocus && typeof actionSheetReturnFocus.focus === 'function') { | |
| 378 | + | actionSheetReturnFocus.focus(); | |
| 379 | + | } | |
| 380 | + | actionSheetReturnFocus = null; | |
| 358 | 381 | } | |
| 359 | 382 | ||
| 360 | 383 | /** |
| @@ -176,6 +176,37 @@ | |||
| 176 | 176 | wirePullToRefreshReviews(); | |
| 177 | 177 | wireTimeViewSwipe(); | |
| 178 | 178 | wireLongPress(); | |
| 179 | + | wireKeyboardScrollIntoView(); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // ============ Keyboard Avoidance ============ | |
| 183 | + | // When the virtual keyboard opens on mobile, focused inputs near the | |
| 184 | + | // bottom of the screen get obscured. visualViewport reports the visible | |
| 185 | + | // area after keyboard inset; if the focused element overlaps that bottom | |
| 186 | + | // edge, scroll it into view. | |
| 187 | + | function wireKeyboardScrollIntoView() { | |
| 188 | + | const vv = window.visualViewport; | |
| 189 | + | if (!vv) return; | |
| 190 | + | ||
| 191 | + | function maybeScrollFocused() { | |
| 192 | + | const el = document.activeElement; | |
| 193 | + | if (!el) return; | |
| 194 | + | const tag = el.tagName; | |
| 195 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return; | |
| 196 | + | // visualViewport.height shrinks when keyboard is open. | |
| 197 | + | const rect = el.getBoundingClientRect(); | |
| 198 | + | const visibleBottom = vv.height + vv.offsetTop; | |
| 199 | + | const margin = 24; | |
| 200 | + | if (rect.bottom > visibleBottom - margin) { | |
| 201 | + | el.scrollIntoView({ block: 'center', behavior: 'smooth' }); | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | document.addEventListener('focusin', () => { | |
| 206 | + | // Defer so visualViewport has updated after keyboard animates in. | |
| 207 | + | setTimeout(maybeScrollFocused, 250); | |
| 208 | + | }); | |
| 209 | + | vv.addEventListener('resize', maybeScrollFocused); | |
| 179 | 210 | } | |
| 180 | 211 | ||
| 181 | 212 | // --- Tasks --- |
| @@ -301,17 +301,28 @@ let morePopoverOpen = false; | |||
| 301 | 301 | ||
| 302 | 302 | function toggleMorePopover() { | |
| 303 | 303 | const popover = document.getElementById('mobile-more-popover'); | |
| 304 | + | const trigger = document.getElementById('mobile-more-btn'); | |
| 304 | 305 | if (!popover) return; | |
| 305 | 306 | morePopoverOpen = !morePopoverOpen; | |
| 306 | 307 | popover.classList.toggle('visible', morePopoverOpen); | |
| 308 | + | popover.setAttribute('aria-hidden', morePopoverOpen ? 'false' : 'true'); | |
| 309 | + | if (trigger) trigger.setAttribute('aria-expanded', morePopoverOpen ? 'true' : 'false'); | |
| 307 | 310 | if (morePopoverOpen) { | |
| 308 | 311 | document.addEventListener('click', closeMorePopover, { once: true }); | |
| 312 | + | // Move focus into the popover so screen-reader/keyboard users land there. | |
| 313 | + | const firstItem = popover.querySelector('button'); | |
| 314 | + | if (firstItem) firstItem.focus(); | |
| 309 | 315 | } | |
| 310 | 316 | } | |
| 311 | 317 | ||
| 312 | 318 | function closeMorePopover() { | |
| 313 | 319 | const popover = document.getElementById('mobile-more-popover'); | |
| 314 | - | if (popover) popover.classList.remove('visible'); | |
| 320 | + | const trigger = document.getElementById('mobile-more-btn'); | |
| 321 | + | if (popover) { | |
| 322 | + | popover.classList.remove('visible'); | |
| 323 | + | popover.setAttribute('aria-hidden', 'true'); | |
| 324 | + | } | |
| 325 | + | if (trigger) trigger.setAttribute('aria-expanded', 'false'); | |
| 315 | 326 | morePopoverOpen = false; | |
| 316 | 327 | } | |
| 317 | 328 |
| @@ -169,11 +169,13 @@ class VirtualScroller { | |||
| 169 | 169 | /** | |
| 170 | 170 | * Measure rendered items and update height cache. | |
| 171 | 171 | * @private | |
| 172 | + | * @returns {boolean} true if any cached height changed (caller should recompute spacers) | |
| 172 | 173 | */ | |
| 173 | 174 | _measureRenderedItems() { | |
| 174 | - | if (!this.shouldMeasure) return; | |
| 175 | + | if (!this.shouldMeasure) return false; | |
| 175 | 176 | ||
| 176 | 177 | const rows = this.content.children; | |
| 178 | + | let changed = false; | |
| 177 | 179 | for (let i = 0; i < rows.length; i++) { | |
| 178 | 180 | const row = rows[i]; | |
| 179 | 181 | const itemIndex = this.startIndex + i; | |
| @@ -184,16 +186,22 @@ class VirtualScroller { | |||
| 184 | 186 | const height = row.offsetHeight; | |
| 185 | 187 | ||
| 186 | 188 | if (height > 0) { | |
| 187 | - | this.heightCache.set(id, height); | |
| 189 | + | const prev = this.heightCache.get(id); | |
| 190 | + | if (prev !== height) { | |
| 191 | + | this.heightCache.set(id, height); | |
| 192 | + | changed = true; | |
| 193 | + | } | |
| 188 | 194 | } | |
| 189 | 195 | } | |
| 196 | + | return changed; | |
| 190 | 197 | } | |
| 191 | 198 | ||
| 192 | 199 | /** | |
| 193 | 200 | * Render visible items. | |
| 194 | 201 | * @private | |
| 202 | + | * @param {boolean} forceRender - Skip the range-unchanged short-circuit (use after data refresh) | |
| 195 | 203 | */ | |
| 196 | - | _render() { | |
| 204 | + | _render(forceRender = false) { | |
| 197 | 205 | if (this.isDestroyed) return; | |
| 198 | 206 | ||
| 199 | 207 | this.items = this.getItems() || []; | |
| @@ -202,10 +210,20 @@ class VirtualScroller { | |||
| 202 | 210 | this.topSpacer.style.height = '0px'; | |
| 203 | 211 | this.bottomSpacer.style.height = '0px'; | |
| 204 | 212 | this.content.innerHTML = '<div class="virtual-scroller-empty">No items to display</div>'; | |
| 213 | + | this.startIndex = 0; | |
| 214 | + | this.endIndex = 0; | |
| 205 | 215 | return; | |
| 206 | 216 | } | |
| 207 | 217 | ||
| 208 | 218 | const { startIndex, endIndex, topOffset } = this._calculateVisibleRange(); | |
| 219 | + | ||
| 220 | + | // Short-circuit: if the visible range hasn't changed since last render, | |
| 221 | + | // skip the innerHTML thrash. This is the hot path on touch scroll — | |
| 222 | + | // scroll events fire at 60Hz+ but most don't cross row boundaries. | |
| 223 | + | if (!forceRender && startIndex === this.startIndex && endIndex === this.endIndex && this._hasRendered) { | |
| 224 | + | return; | |
| 225 | + | } | |
| 226 | + | ||
| 209 | 227 | this.startIndex = startIndex; | |
| 210 | 228 | this.endIndex = endIndex; | |
| 211 | 229 | ||
| @@ -215,16 +233,25 @@ class VirtualScroller { | |||
| 215 | 233 | html.push(this.renderItem(this.items[i], i)); | |
| 216 | 234 | } | |
| 217 | 235 | this.content.innerHTML = html.join(''); | |
| 236 | + | this._hasRendered = true; | |
| 218 | 237 | ||
| 219 | - | // Measure after render | |
| 238 | + | // Set spacers from the values we just computed — no need to recompute. | |
| 239 | + | this.topSpacer.style.height = `${topOffset}px`; | |
| 240 | + | let bottomHeight = 0; | |
| 241 | + | for (let i = endIndex; i < this.items.length; i++) { | |
| 242 | + | bottomHeight += this._getItemHeight(this.items[i], i); | |
| 243 | + | } | |
| 244 | + | this.bottomSpacer.style.height = `${bottomHeight}px`; | |
| 245 | + | ||
| 246 | + | // Measure after layout. Only re-update spacers if measurement | |
| 247 | + | // actually changed cached heights for items above the viewport | |
| 248 | + | // (those shift topOffset and need correction). | |
| 220 | 249 | requestAnimationFrame(() => { | |
| 221 | - | this._measureRenderedItems(); | |
| 222 | - | this._updateSpacers(); | |
| 250 | + | if (this.isDestroyed) return; | |
| 251 | + | const changed = this._measureRenderedItems(); | |
| 252 | + | if (changed) this._updateSpacers(); | |
| 223 | 253 | }); | |
| 224 | 254 | ||
| 225 | - | // Update spacers with current estimates | |
| 226 | - | this._updateSpacers(); | |
| 227 | - | ||
| 228 | 255 | // Callback | |
| 229 | 256 | if (this.onRender) { | |
| 230 | 257 | const visibleItems = this.items.slice(startIndex, endIndex); | |
| @@ -233,22 +260,19 @@ class VirtualScroller { | |||
| 233 | 260 | } | |
| 234 | 261 | ||
| 235 | 262 | /** | |
| 236 | - | * Update spacer heights based on measured/estimated heights. | |
| 263 | + | * Recompute and apply spacer heights. Called after measurement if a row's | |
| 264 | + | * height changed from its previous cached/estimated value. | |
| 237 | 265 | * @private | |
| 238 | 266 | */ | |
| 239 | 267 | _updateSpacers() { | |
| 240 | - | const { startIndex, endIndex, topOffset } = this._calculateVisibleRange(); | |
| 241 | - | ||
| 242 | - | // Top spacer height | |
| 243 | 268 | let topHeight = 0; | |
| 244 | - | for (let i = 0; i < startIndex; i++) { | |
| 269 | + | for (let i = 0; i < this.startIndex; i++) { | |
| 245 | 270 | topHeight += this._getItemHeight(this.items[i], i); | |
| 246 | 271 | } | |
| 247 | 272 | this.topSpacer.style.height = `${topHeight}px`; | |
| 248 | 273 | ||
| 249 | - | // Bottom spacer height | |
| 250 | 274 | let bottomHeight = 0; | |
| 251 | - | for (let i = endIndex; i < this.items.length; i++) { | |
| 275 | + | for (let i = this.endIndex; i < this.items.length; i++) { | |
| 252 | 276 | bottomHeight += this._getItemHeight(this.items[i], i); | |
| 253 | 277 | } | |
| 254 | 278 | this.bottomSpacer.style.height = `${bottomHeight}px`; | |
| @@ -290,7 +314,7 @@ class VirtualScroller { | |||
| 290 | 314 | */ | |
| 291 | 315 | refresh() { | |
| 292 | 316 | if (this.isDestroyed) return; | |
| 293 | - | this._render(); | |
| 317 | + | this._render(true); | |
| 294 | 318 | } | |
| 295 | 319 | ||
| 296 | 320 | /** | |
| @@ -346,6 +370,7 @@ class VirtualScroller { | |||
| 346 | 370 | */ | |
| 347 | 371 | clearHeightCache() { | |
| 348 | 372 | this.heightCache.clear(); | |
| 373 | + | this._hasRendered = false; | |
| 349 | 374 | this.refresh(); | |
| 350 | 375 | } | |
| 351 | 376 |