diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index ca144a1ba..aafe64b33 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -73,6 +73,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l default still leaves converted notes unscheduled. See [Task Properties](https://tasknotes.dev/settings/task-properties/#scheduled-date). Thanks to @e-zz for suggesting this. +- Task list group headers now stay visible at the top of the list while scrolling ## Fixed diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index deb182074..aeda61c4b 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -142,6 +142,10 @@ export class TaskListView extends BasesViewBase { private containerListenersRegistered = false; private virtualScroller: VirtualScroller | null = null; // Can render TaskInfo or group headers private useVirtualScrolling = false; + /** Pinned group header overlay for virtualized lists (CSS sticky cannot work under transform). */ + private virtualStickyHeaderPin: HTMLElement | null = null; + private virtualStickyHeaderKey: string | null = null; + private virtualStickyHeaderIndices: number[] = []; private collapsedGroups = new Set(); // Track collapsed group keys private collapsedSubGroups = new Set(); // Track collapsed sub-group keys private subGroupPropertyId: string | null = null; // Property ID for sub-grouping @@ -1823,6 +1827,7 @@ export class TaskListView extends BasesViewBase { ): Promise { // Populate group key lookup for cross-group drag detection this.syncGroupedDragMetadata(items); + this.virtualStickyHeaderIndices = this.collectVirtualStickyHeaderIndices(items); if (!this.virtualScroller) { this.virtualScroller = new VirtualScroller({ @@ -1865,10 +1870,15 @@ export class TaskListView extends BasesViewBase { return item.task.path; } }, + onScroll: () => this.updateVirtualStickyHeader(), }); + // VirtualScroller empties the container on setup — mount the pin after that. + this.ensureVirtualStickyHeaderPin(); + window.setTimeout(() => { this.virtualScroller?.recalculate(); + this.updateVirtualStickyHeader(); }, 0); } else { this.resetVirtualScrollerIfCardRenderChanged( @@ -1879,6 +1889,12 @@ export class TaskListView extends BasesViewBase { return; } this.virtualScroller.updateItems(items); + // Assign before refreshing the pin so it reads the current items, not stale data + // from before this collapse/expand or data update. + this.lastVirtualItems = items; + // updateItems clears container content but keeps structure; re-ensure pin. + this.ensureVirtualStickyHeaderPin(); + this.updateVirtualStickyHeader(); } this.lastVirtualItems = items; this.lastCardRenderSignature = this.buildCardRenderSignature( @@ -1887,6 +1903,112 @@ export class TaskListView extends BasesViewBase { ); } + private collectVirtualStickyHeaderIndices(items: readonly TaskListRenderItem[]): number[] { + const indices: number[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type === "primary-header" || item.type === "sub-header") { + indices.push(i); + } + } + return indices; + } + + private ensureVirtualStickyHeaderPin(): void { + if (!this.itemsContainer) return; + if (this.virtualStickyHeaderPin?.isConnected) return; + + const doc = this.containerEl.ownerDocument; + const pin = doc.createElement("div"); + pin.className = "task-list-virtual-sticky-header"; + pin.setAttribute("aria-hidden", "true"); + this.itemsContainer.prepend(pin); + this.virtualStickyHeaderPin = pin; + this.virtualStickyHeaderKey = null; + } + + private clearVirtualStickyHeaderPin(): void { + this.virtualStickyHeaderPin?.remove(); + this.virtualStickyHeaderPin = null; + this.virtualStickyHeaderKey = null; + this.virtualStickyHeaderIndices = []; + } + + private updateVirtualStickyHeader(): void { + const pin = this.virtualStickyHeaderPin; + const scroller = this.virtualScroller; + if (!pin || !scroller || this.virtualStickyHeaderIndices.length === 0) { + if (pin) { + pin.empty(); + pin.classList.add("is-empty"); + } + this.virtualStickyHeaderKey = null; + return; + } + + const scrollTop = scroller.getScrollTop(); + let activeIndex = -1; + for (const index of this.virtualStickyHeaderIndices) { + // Strictly past the header so we don't double up with the in-list header at rest. + if (scroller.getItemOffset(index) < scrollTop) { + activeIndex = index; + } else { + break; + } + } + + if (activeIndex < 0) { + pin.empty(); + pin.classList.add("is-empty"); + pin.style.removeProperty("transform"); + this.virtualStickyHeaderKey = null; + return; + } + + const items = this.lastVirtualItems; + const activeItem = items[activeIndex]; + if (!activeItem || !("type" in activeItem)) { + return; + } + if (activeItem.type !== "primary-header" && activeItem.type !== "sub-header") { + return; + } + + const headerKey = + activeItem.type === "primary-header" + ? `primary-${activeItem.groupKey}` + : `sub-${activeItem.groupKey}:${activeItem.subGroupKey}`; + + if (this.virtualStickyHeaderKey !== headerKey) { + pin.empty(); + const headerEl = this.createGroupHeader(activeItem); + headerEl.classList.add("task-list-virtual-sticky-header__group"); + pin.appendChild(headerEl); + pin.classList.remove("is-empty"); + pin.removeAttribute("aria-hidden"); + this.virtualStickyHeaderKey = headerKey; + } + + // Push the sticky header up as the next group header approaches (classic sticky behavior). + const activePos = this.virtualStickyHeaderIndices.indexOf(activeIndex); + const nextHeaderIndex = + activePos >= 0 && activePos < this.virtualStickyHeaderIndices.length - 1 + ? this.virtualStickyHeaderIndices[activePos + 1] + : -1; + + const pinHeight = pin.getBoundingClientRect().height || scroller.getMeasuredItemHeight(activeIndex); + if (nextHeaderIndex >= 0) { + const distanceToNext = scroller.getItemOffset(nextHeaderIndex) - scrollTop; + if (distanceToNext < pinHeight) { + pin.style.transform = `translateY(${distanceToNext - pinHeight}px)`; + } else { + pin.style.removeProperty("transform"); + } + } else { + pin.style.removeProperty("transform"); + } + } + private async renderGroupedNormal( items: TaskListRenderItem[], visibleProperties: string[] | undefined, @@ -2451,6 +2573,12 @@ export class TaskListView extends BasesViewBase { if (this.useVirtualScrolling && this.virtualScroller) { this.syncGroupedDragMetadata(items); this.virtualScroller.updateItems(items); + // Recompute before refreshing the pin so header indices and the item + // array they reference agree — otherwise the pin can briefly show + // header content from the pre-toggle layout (wrong group/count). + this.virtualStickyHeaderIndices = this.collectVirtualStickyHeaderIndices(items); + this.lastVirtualItems = items; + this.updateVirtualStickyHeader(); } else { // If not using virtual scrolling, do full render await this.render(); @@ -2848,6 +2976,7 @@ export class TaskListView extends BasesViewBase { this.virtualScroller.destroy(); this.virtualScroller = null; } + this.clearVirtualStickyHeaderPin(); this.lastVirtualItems = []; } diff --git a/src/utils/VirtualScroller.ts b/src/utils/VirtualScroller.ts index ed3e5c76b..3b009a2d8 100644 --- a/src/utils/VirtualScroller.ts +++ b/src/utils/VirtualScroller.ts @@ -25,6 +25,11 @@ export interface VirtualScrollerOptions { renderItem: (item: T, index: number) => HTMLElement; /** Optional function to get unique key for item */ getItemKey?: (item: T, index: number) => string; + /** + * Called on scroll (RAF-throttled) with the current scrollTop. + * Fires even when the visible item range does not change — useful for sticky headers. + */ + onScroll?: (scrollTop: number) => void; } export interface VirtualScrollState { @@ -78,6 +83,7 @@ export class VirtualScroller { private overscan: number; private renderItem: (item: T, index: number) => HTMLElement; private getItemKey: (item: T, index: number) => string; + private onScroll: ((scrollTop: number) => void) | null; private state: VirtualScrollState = { startIndex: 0, @@ -105,6 +111,7 @@ export class VirtualScroller { this.overscan = options.overscan ?? 5; this.renderItem = options.renderItem; this.getItemKey = options.getItemKey ?? ((item, index) => String(index)); + this.onScroll = options.onScroll ?? null; this.setupDOM(); this.attachScrollListener(); @@ -487,6 +494,7 @@ export class VirtualScroller { this.scrollRAF = window.requestAnimationFrame(() => { this.updateVisibleRange(); + this.onScroll?.(this.scrollContainer.scrollTop); this.scrollRAF = null; }); }; @@ -867,6 +875,27 @@ export class VirtualScroller { return { ...this.state }; } + /** + * Top offset of an item within the virtual list (pixels from content start). + */ + getItemOffset(index: number): number { + return this.getItemPosition(index); + } + + /** + * Measured or estimated height of an item at the given index. + */ + getMeasuredItemHeight(index: number): number { + return this.getItemHeight(index); + } + + /** + * Current scroll offset of the scroll container. + */ + getScrollTop(): number { + return this.scrollContainer.scrollTop; + } + /** * Clean up event listeners */ diff --git a/styles/bases-views.css b/styles/bases-views.css index 6212e0c18..4877b71b1 100644 --- a/styles/bases-views.css +++ b/styles/bases-views.css @@ -85,7 +85,10 @@ padding: var(--tn-spacing-xs) var(--tn-spacing-md) var(--tn-spacing-xs) var(--tn-spacing-sm); margin-bottom: var(--tn-spacing-sm); border-bottom: 1px solid var(--tn-border-color); - position: relative; + position: sticky; + top: 0; + z-index: var(--tn-z-sticky); + background: var(--tn-bg-primary); font-size: var(--tn-font-size-lg); font-weight: var(--tn-font-weight-medium); color: var(--tn-text-normal); @@ -112,6 +115,54 @@ min-width: 0; } +/* Own the vertical scroll so group headers can stick relative to this container. + Embed overrides below set overflow-y: visible when a parent note scroller should win. */ +.tn-tasknotesTaskList .tn-bases-items-container { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +/* Flat grouped list: headers are siblings of task cards (not wrapping them), so + sticky must be on the section wrapper. Sticky on the inner h3 fails because its + containing block is only as tall as the header itself. */ +.tn-tasknotesTaskList .tn-bases-items-container > .task-section.task-group { + position: sticky; + top: 0; + z-index: var(--tn-z-sticky); + background: var(--tn-bg-primary); +} + +.tn-tasknotesTaskList .tn-bases-items-container > .task-section.task-group[data-level="sub"] { + top: calc(var(--tn-spacing-md) + var(--tn-spacing-sm) + (1.4 * var(--tn-font-size-lg)) + 1px); + z-index: calc(var(--tn-z-sticky) - 1); +} + +/* + * Virtualized lists position rows with transform, which breaks CSS sticky. + * A zero-height sticky pin overlays the active group header instead. + */ +.tn-tasknotesTaskList .task-list-virtual-sticky-header { + position: sticky; + top: 0; + z-index: calc(var(--tn-z-sticky) + 1); + height: 0; + overflow: visible; + pointer-events: none; +} + +.tn-tasknotesTaskList .task-list-virtual-sticky-header.is-empty { + display: none; +} + +.tn-tasknotesTaskList .task-list-virtual-sticky-header__group { + pointer-events: auto; + background: var(--tn-bg-primary); + position: relative; + top: auto; + z-index: auto; +} + /* Toggle button for groups */ .tn-bases-tasknotes-list .task-group-toggle { appearance: none; diff --git a/styles/task-list-view.css b/styles/task-list-view.css index 8af6b69f9..2404a7ff0 100644 --- a/styles/task-list-view.css +++ b/styles/task-list-view.css @@ -109,7 +109,8 @@ margin-bottom: var(--tn-spacing-md); } -.tasknotes-plugin .task-list-view__group-header { +.tasknotes-plugin .task-list-view__group-header, +.tasknotes-plugin .task-group-header { display: flex; align-items: center; gap: var(--tn-spacing-sm);