Skip to main content

max / goingson

Virtualize the contacts grid by row The contacts grid rendered every card, and the shelved patch for it only worked by collapsing the multi-column layout to a single bounded column. Add CardGridScroller, a wrapper over VirtualScroller that chunks cards into groups of N and windows a grid row rather than a card, so the multi-column layout survives. N is measured from a hidden probe carrying the .cards-grid class rather than recomputed in JS, leaving the stylesheet authoritative for column count across the 1400px breakpoint and the mobile one-column rule. Bound the grid so it scrolls under the filter row instead of scrolling the page. The tab group is a plain block, which leaves every descendant's flex:1 inert, so it becomes a flex column only while Contacts is the visible subview; Emails keeps the page-scroll behaviour it ships with today. Row gaps move into the row's padding-bottom, since the scroller sizes its spacers from offsetHeight, which counts padding and ignores margin.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-28 18:09 UTC
Signed with PGP, not checked
Commit: 4678f520ce13718db96835375de9b4f686c8a17f
Parent: bb46e03
5 files changed, +254 insertions, -2 deletions
@@ -283,6 +283,7 @@
283 283 │ ├── selection-manager.js # Multi-select with shift/ctrl
284 284 │ ├── pagination-manager.js # Page navigation
285 285 │ ├── virtual-scroller.js # Virtual scrolling for large lists
286 + │ ├── card-grid-scroller.js # Virtual scrolling for multi-column card grids
286 287 │ ├── context-menus.js # Right-click context menus
287 288 │ ├── bulk-actions.js # Multi-select bulk operations
288 289 │ ├── touch.js # Touch event handling
@@ -531,7 +531,7 @@
531 531 <button class="btn btn-sm btn-danger" data-act="contacts.bulkDelete">Delete</button>
532 532 <button class="btn btn-sm" data-act="contacts.clearSelection">Cancel</button>
533 533 </div>
534 - <div class="cards-grid" id="contacts-grid">
534 + <div class="cards-grid-scroll" id="contacts-grid">
535 535 <div class="skeleton-shimmer" aria-label="Loading contacts">
536 536 <div class="skeleton-row"><div class="skeleton-avatar"></div><div class="skeleton-lines"><div class="skeleton-line long"></div><div class="skeleton-line short"></div></div></div>
537 537 <div class="skeleton-row"><div class="skeleton-avatar"></div><div class="skeleton-lines"><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div></div>
@@ -667,6 +667,7 @@
667 667 <script src="js/selection-manager.js"></script>
668 668 <script src="js/pagination-manager.js"></script>
669 669 <script src="js/virtual-scroller.js"></script>
670 + <script src="js/card-grid-scroller.js"></script>
670 671
671 672 <!-- Features (no domain dependencies) -->
672 673 <script src="js/router.js"></script>
@@ -716,6 +716,25 @@
716 716 gap: var(--gap-group);
717 717 }
718 718
719 + /* Virtualized card grid. The container itself scrolls and holds the scroller's
720 + spacers, so it cannot be the grid: each windowed row is its own grid element.
721 + `.cards-grid` still defines the column count -- CardGridScroller measures a
722 + hidden probe carrying that class rather than recomputing the auto-fill math,
723 + so the media queries below stay authoritative. Row gaps are padding, not
724 + `gap`, because the scroller sizes spacers from offsetHeight. */
725 + .cards-grid-scroll {
726 + flex: 1;
727 + min-height: 0;
728 + overflow-y: auto;
729 + position: relative;
730 + }
731 +
732 + .cards-grid-row {
733 + display: grid;
734 + column-gap: var(--gap-group);
735 + padding-bottom: var(--gap-group);
736 + }
737 +
719 738 .cards-grid-note,
720 739 .projects-retired-toggle {
721 740 grid-column: 1 / -1;
@@ -5309,6 +5328,30 @@
5309 5328 flex-shrink: 0;
5310 5329 }
5311 5330
5331 + /* Contacts view fills available height, so the card grid scrolls under a
5332 + fixed filter row instead of scrolling the page. The tab group is a plain
5333 + block by default, which would leave every descendant's `flex: 1` inert; it
5334 + only becomes a flex column while Contacts is the visible subview, so the
5335 + Emails subview keeps the page-scroll behaviour it ships with today. */
5336 + #messages-view:has(> #contacts-view:not(.hidden)) {
5337 + display: flex;
5338 + flex-direction: column;
5339 + flex: 1;
5340 + min-height: 0;
5341 + }
5342 +
5343 + #contacts-view {
5344 + display: flex;
5345 + flex-direction: column;
5346 + flex: 1;
5347 + min-height: 0;
5348 + }
5349 +
5350 + #contacts-view .page-header,
5351 + #contacts-view .bulk-actions-bar {
5352 + flex-shrink: 0;
5353 + }
5354 +
5312 5355 /* 46. Saved Views Sidebar (desktop UI only, hidden on mobile) */
5313 5356 .ui-mode-desktop .saved-views-sidebar {
5314 5357 width: 200px;
@@ -572,6 +572,7 @@
572 572 render(contacts);
573 573 GoingsOn.cache.markLoaded('contacts');
574 574 } catch (err) {
575 + teardownScroller();
575 576 GoingsOn.utils.showError(grid, err, 'Failed to load contacts');
576 577 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load contacts'), 'error', {
577 578 action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('contacts'); load(); } },
@@ -580,6 +581,20 @@
580 581 }
581 582 }
582 583
584 + /**
585 + * Card grid virtualizer. Only alive while there are contacts to show: the
586 + * empty and error states write straight into the grid, which would fight
587 + * the scroller over ownership of the container.
588 + * @type {Object|null}
589 + */
590 + let gridScroller = null;
591 +
592 + function teardownScroller() {
593 + if (!gridScroller) return;
594 + gridScroller.destroy();
595 + gridScroller = null;
596 + }
597 +
583 598 function render(contacts) {
584 599 const grid = document.getElementById('contacts-grid');
585 600 if (!contacts) contacts = GoingsOn.state.contacts || [];
@@ -587,6 +602,7 @@
587 602 const filtered = contacts;
588 603
589 604 if (filtered.length === 0) {
605 + teardownScroller();
590 606 if (contacts.length === 0) {
591 607 grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts yet.', 'Add Contact', 'contacts.openNew', 'contacts');
592 608 } else {
@@ -595,7 +611,21 @@
595 611 return;
596 612 }
597 613
598 - grid.innerHTML = filtered.map(renderCard).join('');
614 + if (!gridScroller) {
615 + gridScroller = new GoingsOn.CardGridScroller({
616 + container: grid,
617 + getItems: () => GoingsOn.state.contacts || [],
618 + renderCard,
619 + probeClass: 'cards-grid',
620 + rowClass: 'cards-grid-row',
621 + estimatedRowHeight: 150,
622 + // Selection lives outside the DOM, so checkboxes that scroll
623 + // back into view have to be re-checked from it.
624 + onRender: updateSelectionUI,
625 + });
626 + } else {
627 + gridScroller.refresh();
628 + }
599 629 }
600 630
601 631 // CRUD
@@ -1,0 +1,177 @@
1 + /**
2 + * GoingsOn - CardGridScroller
3 + * Virtual scrolling for multi-column card grids.
4 + *
5 + * VirtualScroller windows a single stack of rows, so pointing it straight at a
6 + * card grid would window one card at a time and collapse the grid to a column.
7 + * This wraps it and windows a grid ROW instead: cards are chunked into groups
8 + * of N, each group renders as one grid element, and the scroller windows those.
9 + * The multi-column layout survives untouched.
10 + *
11 + * N is measured, not recomputed. A hidden probe carrying the grid's own class
12 + * is laid out and its resolved `grid-template-columns` counted, so the
13 + * stylesheet stays the single source of truth for column count -- media
14 + * queries and the mobile one-column rule included -- instead of a JS copy of
15 + * the auto-fill math that drifts the first time a breakpoint moves.
16 + *
17 + * Row gaps live in the row's padding-bottom rather than the grid `gap`, because
18 + * the scroller sizes its spacers from `offsetHeight`, which counts padding and
19 + * ignores margin.
20 + */
21 +
22 + (function() {
23 + 'use strict';
24 +
25 + class CardGridScroller {
26 + /**
27 + * @param {Object} config
28 + * @param {HTMLElement} config.container - Scrollable grid container
29 + * @param {Function} config.getItems - () => array of items (cards, not rows)
30 + * @param {Function} config.renderCard - (item, index) => HTML string for one card
31 + * @param {string} config.probeClass - Class whose grid-template-columns defines the layout
32 + * @param {string} config.rowClass - Class applied to each rendered grid row
33 + * @param {number} config.estimatedRowHeight - Starting guess for a row's height in px
34 + * @param {number} [config.overscan] - Buffer rows above/below the viewport
35 + * @param {Function} [config.onRender] - Called after each render, no arguments
36 + */
37 + constructor(config) {
38 + this.container = config.container;
39 + this.getItems = config.getItems;
40 + this.renderCard = config.renderCard;
41 + this.probeClass = config.probeClass;
42 + this.rowClass = config.rowClass;
43 + this.onRender = config.onRender || null;
44 +
45 + this.columns = 1;
46 + this.rows = [];
47 + this.isDestroyed = false;
48 + this._lastWidth = this.container.clientWidth;
49 +
50 + this._chunk();
51 +
52 + this.scroller = new GoingsOn.VirtualScroller({
53 + container: this.container,
54 + renderItem: (row) => this._renderRow(row),
55 + getItems: () => this.rows,
56 + rowHeight: { estimated: config.estimatedRowHeight || 150, measure: true },
57 + overscan: config.overscan ?? 2,
58 + onRender: () => { if (this.onRender) this.onRender(); },
59 + });
60 +
61 + // Width changes can change the column count, which re-chunks every row.
62 + // The scroller's own observer only cares about height.
63 + this._handleResize = this._handleResize.bind(this);
64 + this.resizeObserver = new ResizeObserver(this._handleResize);
65 + this.resizeObserver.observe(this.container);
66 + }
67 +
68 + /**
69 + * Count the columns the stylesheet would lay out at the current width.
70 + * @private
71 + * @returns {number|null} Column count, or null when the grid is not
72 + * laid out (hidden view, zero width) and the answer is meaningless.
73 + */
74 + _measureColumns() {
75 + if (this.container.clientWidth === 0) return null;
76 +
77 + // Measured in normal flow, so the probe's content width is the width
78 + // the rows themselves get, scrollbar gutter and all. An empty grid has
79 + // no height and the probe is gone before the frame paints.
80 + const probe = document.createElement('div');
81 + probe.className = this.probeClass;
82 + probe.style.visibility = 'hidden';
83 + this.container.appendChild(probe);
84 + const tracks = getComputedStyle(probe).gridTemplateColumns;
85 + probe.remove();
86 +
87 + // An unlaid-out grid reports the specified value (`repeat(auto-fill,
88 + // minmax(...))`) rather than a resolved track list. Counting words in
89 + // that would invent a column count, so treat it as unmeasurable.
90 + if (!tracks || tracks === 'none' || tracks.includes('(')) return null;
91 +
92 + return Math.max(1, tracks.split(/\s+/).filter(Boolean).length);
93 + }
94 +
95 + /**
96 + * Group the flat item list into rows of `columns` items.
97 + * @private
98 + */
99 + _chunk() {
100 + const measured = this._measureColumns();
101 + if (measured !== null) this.columns = measured;
102 +
103 + const items = this.getItems() || [];
104 + const rows = [];
105 + for (let i = 0; i < items.length; i += this.columns) {
106 + const cards = items.slice(i, i + this.columns);
107 + rows.push({
108 + // Row identity keys the scroller's height cache. The first
109 + // card's id is stable while the column count holds; a column
110 + // change re-chunks every row and clears the cache anyway.
111 + id: `row:${cards[0].id ?? i}`,
112 + cards,
113 + startIndex: i,
114 + });
115 + }
116 + this.rows = rows;
117 + }
118 +
119 + /**
120 + * @private
121 + * @param {{cards: Array, startIndex: number}} row
122 + * @returns {string} HTML for one grid row
123 + */
124 + _renderRow(row) {
125 + const cards = row.cards
126 + .map((item, i) => this.renderCard(item, row.startIndex + i))
127 + .join('');
128 + // A partial last row keeps the full track template so its cards match
129 + // the width of the cards above them instead of stretching.
130 + return `<div class="${this.rowClass}" style="grid-template-columns: repeat(${this.columns}, minmax(0, 1fr));">${cards}</div>`;
131 + }
132 +
133 + /**
134 + * @private
135 + * @param {ResizeObserverEntry[]} entries
136 + */
137 + _handleResize(entries) {
138 + if (this.isDestroyed) return;
139 +
140 + const width = entries[0] ? entries[0].contentRect.width : this.container.clientWidth;
141 + if (width === this._lastWidth) return;
142 + this._lastWidth = width;
143 +
144 + // Card height depends on width (wrapped text), and a column change
145 + // rewrites every row outright, so no cached height survives this.
146 + this._chunk();
147 + this.scroller.clearHeightCache();
148 + }
149 +
150 + /**
151 + * Re-read the items and re-render. Call after the data changes.
152 + */
153 + refresh() {
154 + if (this.isDestroyed) return;
155 + this._chunk();
156 + this.scroller.refresh();
157 + }
158 +
159 + /**
160 + * Tear down and hand the container back empty.
161 + */
162 + destroy() {
163 + this.isDestroyed = true;
164 + if (this.resizeObserver) this.resizeObserver.disconnect();
165 + this.scroller.destroy();
166 + this.rows = [];
167 + this.container.innerHTML = '';
168 + }
169 + }
170 +
171 + // Populate GoingsOn Namespace
172 +
173 + if (window.GoingsOn) {
174 + GoingsOn.CardGridScroller = CardGridScroller;
175 + }
176 +
177 + })();