From 7d6bf376c310fc58b3e111500811934839af0b88 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Wed, 17 Jun 2026 14:32:14 -0400 Subject: [PATCH] Add opt-in sticky section headers to VirtualizedTable Necessary for https://github.com/zotero/zotero/pull/5954#issuecomment-4724330966 --- .../zotero/components/virtualized-table.jsx | 169 +++++++++++++++++- scss/components/_virtualized-table.scss | 23 +++ 2 files changed, 189 insertions(+), 3 deletions(-) diff --git a/chrome/content/zotero/components/virtualized-table.jsx b/chrome/content/zotero/components/virtualized-table.jsx index 5539bbba1f..92b1d27238 100644 --- a/chrome/content/zotero/components/virtualized-table.jsx +++ b/chrome/content/zotero/components/virtualized-table.jsx @@ -383,6 +383,13 @@ class VirtualizedTable extends React.Component { // never becomes empty through user action requireSelection: false, + // When true, the header row of the section currently at the top of the view is + // pinned to the top while scrolling (see isSectionHeader) + stickySectionHeaders: false, + // Returns whether the row at the given index begins a section, i.e., should be + // pinned to the top while its section is scrolled through + isSectionHeader: () => false, + onSelectionChange: noop, // The below are for arrow-key navigation @@ -449,6 +456,9 @@ class VirtualizedTable extends React.Component { requireSelection: PropTypes.bool, + stickySectionHeaders: PropTypes.bool, + isSectionHeader: PropTypes.func, + onSelectionChange: PropTypes.func, // The below are for arrow-key navigation @@ -1084,9 +1094,17 @@ class VirtualizedTable extends React.Component { window.addEventListener("resize", () => { this._debouncedRerender(); }); + + if (this.props.stickySectionHeaders) { + this._jsWindow.targetElement.addEventListener('scroll', this._updateStickySectionHeader, { passive: true }); + this._updateStickySectionHeader(); + } } - + componentWillUnmount() { + if (this.props.stickySectionHeaders && this._jsWindow) { + this._jsWindow.targetElement.removeEventListener('scroll', this._updateStickySectionHeader); + } this._jsWindow.destroy(); } @@ -1159,8 +1177,12 @@ class VirtualizedTable extends React.Component { } node.style.height = (index in this._customRowHeightMap ? this._customRowHeightMap[index] : this._rowHeight) + 'px'; node.id = this.props.id + "-row-" + index; - node.classList.toggle('odd', index % 2 == 1); - node.classList.toggle('even', index % 2 == 0); + // Row striping restarts at each section header, so every section's first row is + // the same shade (see _sectionRelativeIndex); without section headers this is just + // the row index + let stripeIndex = this._sectionRelativeIndex(index); + node.classList.toggle('odd', stripeIndex % 2 == 1); + node.classList.toggle('even', stripeIndex % 2 == 0); if (!node.hasAttribute('role')) { node.setAttribute('role', 'row'); } @@ -1304,6 +1326,17 @@ class VirtualizedTable extends React.Component { {columnDragMarker} {header}
+ {this.props.stickySectionHeaders + &&
this._stickyHeader = ref} + aria-hidden="true" + > +
this._stickyHeaderContent = ref} + /> +
}
); } @@ -1315,6 +1348,7 @@ class VirtualizedTable extends React.Component { if (!this._jsWindow) return; this._jsWindow.invalidate(); this._updateWidth(); + this._refreshStickySectionHeader(); } /** @@ -1325,6 +1359,135 @@ class VirtualizedTable extends React.Component { if (!this._jsWindow) return; this._jsWindow.render(); this._updateWidth(); + this._refreshStickySectionHeader(); + } + + // ------------------------ Sticky Section Headers ------------------------ // + + /** + * The set of section-header rows can change whenever the row model changes, so drop + * the cached indices and repin. Called after the list is invalidated/rerendered. + */ + _refreshStickySectionHeader() { + if (!this.props.stickySectionHeaders) return; + this._sectionHeaderIndices = null; + this._stickyHeaderIndex = null; + this._updateStickySectionHeader(); + } + + /** + * Index of a row within its section (counting from the row after the preceding section + * header), so striping restarts at each header. Without section headers this is just + * the row index, preserving the normal whole-list striping. + */ + _sectionRelativeIndex(index) { + let base = -1; + for (let headerIndex of this._getSectionHeaderIndices()) { + if (headerIndex <= index) { + base = headerIndex; + } + else { + break; + } + } + return index - base - 1; + } + + /** + * Indices of all section-header rows, ascending. Cached until the row model changes. + */ + _getSectionHeaderIndices() { + // Only trees that opt into section headers have them + if (!this.props.stickySectionHeaders) { + return []; + } + if (this._sectionHeaderIndices) { + return this._sectionHeaderIndices; + } + let indices = []; + let count = this.props.getRowCount(); + for (let i = 0; i < count; i++) { + if (this.props.isSectionHeader(i)) { + indices.push(i); + } + } + this._sectionHeaderIndices = indices; + return indices; + } + + /** + * Pin the header of the section currently at the top of the view, pushing it up as + * the next section's header scrolls into it. The pinned header lives outside the + * scrolling body (so it doesn't scroll itself) and reuses the consumer's renderItem + * to match the real header row's appearance. + * + * The outer element is a fixed clip region anchored to the top of the body; the inner + * (opaque) element holds the rendered header and is the part that translates, so the + * push is clipped at the top of the body rather than spilling over the column header. + */ + _updateStickySectionHeader = () => { + if (!this.props.stickySectionHeaders || !this._stickyHeader || !this._jsWindow) { + return; + } + let clip = this._stickyHeader; + let content = this._stickyHeaderContent; + let headerIndices = this._getSectionHeaderIndices(); + let scrollTop = this._jsWindow.targetElement.scrollTop; + // The section in view is the last header at or above the top of the view; the + // next header (if any) is what pushes it up + let currentIndex = -1; + let nextIndex = -1; + for (let index of headerIndices) { + if (this._jsWindow._getItemPosition(index) <= scrollTop) { + currentIndex = index; + } + else { + nextIndex = index; + break; + } + } + // Nothing to pin if there are no headers or the view is scrolled above the first + if (currentIndex == -1) { + clip.style.display = 'none'; + this._stickyHeaderIndex = null; + return; + } + clip.style.display = ''; + // Mark the header "stuck" once its section has scrolled up under it, so the + // consumer can show a divider (hairline) only while scrolling + clip.classList.toggle('stuck', scrollTop > this._jsWindow._getItemPosition(currentIndex)); + // Re-render only when the pinned section changes. Use _renderItem (not the raw + // renderItem prop) so the pinned copy gets the same post-processing as a real row + // (e.g. the tree's indent/twisty spacer), then drop its id to avoid duplicating the + // real row's. + if (this._stickyHeaderIndex !== currentIndex) { + this._stickyHeaderIndex = currentIndex; + let node = this._renderItem(currentIndex); + node.removeAttribute('id'); + content.textContent = ''; + content.appendChild(node); + } + // Anchor the clip region over the top of the list body (below the column header, + // inside the scrollbar). Its height covers the pinned header plus the body's top + // padding, so a header pushed up is clipped at the body's top edge. Use the body's + // sub-pixel top (not the integer-rounded offsetTop) so the overlay lands exactly at + // the body's edge and never creeps over the column header's bottom divider. + let body = this._jsWindow.targetElement; + let insetTop = parseFloat(window.getComputedStyle(body).paddingTop) || 0; + let bodyTop = body.getBoundingClientRect().top - this._topDiv.getBoundingClientRect().top; + clip.style.top = bodyTop + 'px'; + clip.style.left = body.offsetLeft + 'px'; + clip.style.width = body.clientWidth + 'px'; + clip.style.height = (this._rowHeight + insetTop) + 'px'; + // Push the pinned header up as the next section's header approaches the top + let translateY = 0; + if (nextIndex != -1) { + let nextTop = this._jsWindow._getItemPosition(nextIndex) - scrollTop; + if (nextTop < this._rowHeight) { + translateY = nextTop - this._rowHeight; + } + } + content.style.transform = `translateY(${translateY}px)`; } updateFontSize = () => { diff --git a/scss/components/_virtualized-table.scss b/scss/components/_virtualized-table.scss index 215a751ad5..6d964e595b 100644 --- a/scss/components/_virtualized-table.scss +++ b/scss/components/_virtualized-table.scss @@ -358,6 +358,29 @@ } } +// Pinned copy of the current section's header row. The outer element is a fixed clip +// region anchored over the top of the body, aligned to the rows' container so the pinned +// row lines up with the real rows (positioned/sized in virtualized-table.jsx). The inner +// element is opaque and translates, so a header pushed up by the next section is clipped +// at the body's top edge instead of spilling over the column header. +.virtualized-table-sticky-section-header { + position: absolute; + z-index: 1; + overflow: hidden; + pointer-events: none; + + .virtualized-table-sticky-section-header-content { + box-sizing: border-box; + padding: 4px 8px 0; + // Opaque so the pinned header occludes the rows (and the real header row) beneath it + background-color: var(--material-background); + + .row { + position: static; + } + } +} + .virtualized-table-body, .drag-image-container { flex: 1 0; max-width: 100%;