Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
129 changes: 129 additions & 0 deletions src/bases/TaskListView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export class TaskListView extends BasesViewBase {
private containerListenersRegistered = false;
private virtualScroller: VirtualScroller<TaskListVirtualItem> | 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<string>(); // Track collapsed group keys
private collapsedSubGroups = new Set<string>(); // Track collapsed sub-group keys
private subGroupPropertyId: string | null = null; // Property ID for sub-grouping
Expand Down Expand Up @@ -1823,6 +1827,7 @@ export class TaskListView extends BasesViewBase {
): Promise<void> {
// Populate group key lookup for cross-group drag detection
this.syncGroupedDragMetadata(items);
this.virtualStickyHeaderIndices = this.collectVirtualStickyHeaderIndices(items);

if (!this.virtualScroller) {
this.virtualScroller = new VirtualScroller<TaskListVirtualItem>({
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2848,6 +2976,7 @@ export class TaskListView extends BasesViewBase {
this.virtualScroller.destroy();
this.virtualScroller = null;
}
this.clearVirtualStickyHeaderPin();
this.lastVirtualItems = [];
}

Expand Down
29 changes: 29 additions & 0 deletions src/utils/VirtualScroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export interface VirtualScrollerOptions<T> {
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 {
Expand Down Expand Up @@ -78,6 +83,7 @@ export class VirtualScroller<T> {
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,
Expand Down Expand Up @@ -105,6 +111,7 @@ export class VirtualScroller<T> {
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();
Expand Down Expand Up @@ -487,6 +494,7 @@ export class VirtualScroller<T> {

this.scrollRAF = window.requestAnimationFrame(() => {
this.updateVisibleRange();
this.onScroll?.(this.scrollContainer.scrollTop);
this.scrollRAF = null;
});
};
Expand Down Expand Up @@ -867,6 +875,27 @@ export class VirtualScroller<T> {
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
*/
Expand Down
53 changes: 52 additions & 1 deletion styles/bases-views.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion styles/task-list-view.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading