From 356ed9015a6519aac52012782fc0a0daa28c0bf9 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Fri, 15 May 2026 22:18:46 -0500 Subject: [PATCH 1/9] Infinite scroll on parent container working --- .../angular/src/lib/SimpleTableComponent.ts | 5 + packages/core/src/core/SimpleTableVanilla.ts | 281 +++++++++++++++++- .../src/core/rendering/RenderOrchestrator.ts | 23 +- packages/core/src/hooks/contentHeight.ts | 14 + .../core/src/managers/DimensionManager.ts | 28 +- packages/core/src/types/SimpleTableConfig.ts | 2 + packages/core/src/types/SimpleTableProps.ts | 2 + packages/core/src/utils/externalScroll.ts | 173 +++++++++++ ...3-CollapseExpandAnimationsTests.stories.ts | 31 +- .../tests/44-ExternalScrollTests.stories.ts | 222 ++++++++++++++ packages/examples/vanilla/src/demo-list.ts | 1 + .../WindowInfiniteScrollDemo.ts | 138 +++++++++ .../window-infinite-scroll.demo-data.ts | 98 ++++++ packages/examples/vanilla/src/main.ts | 4 + 14 files changed, 992 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/utils/externalScroll.ts create mode 100644 packages/core/stories/tests/44-ExternalScrollTests.stories.ts create mode 100644 packages/examples/vanilla/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.ts create mode 100644 packages/examples/vanilla/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts diff --git a/packages/angular/src/lib/SimpleTableComponent.ts b/packages/angular/src/lib/SimpleTableComponent.ts index a5d0f5773..407881428 100644 --- a/packages/angular/src/lib/SimpleTableComponent.ts +++ b/packages/angular/src/lib/SimpleTableComponent.ts @@ -70,6 +70,8 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy { @Input() totalRowCount?: SimpleTableAngularProps["totalRowCount"]; @Input() height?: SimpleTableAngularProps["height"]; @Input() maxHeight?: SimpleTableAngularProps["maxHeight"]; + @Input() scrollParent?: SimpleTableAngularProps["scrollParent"]; + @Input() infiniteScrollThreshold?: SimpleTableAngularProps["infiniteScrollThreshold"]; @Input() columnResizing?: SimpleTableAngularProps["columnResizing"]; @Input() columnReordering?: SimpleTableAngularProps["columnReordering"]; @Input() editColumns?: SimpleTableAngularProps["editColumns"]; @@ -170,6 +172,9 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy { if (this.totalRowCount !== undefined) props.totalRowCount = this.totalRowCount; if (this.height !== undefined) props.height = this.height; if (this.maxHeight !== undefined) props.maxHeight = this.maxHeight; + if (this.scrollParent !== undefined) props.scrollParent = this.scrollParent; + if (this.infiniteScrollThreshold !== undefined) + props.infiniteScrollThreshold = this.infiniteScrollThreshold; if (this.columnResizing !== undefined) props.columnResizing = this.columnResizing; if (this.columnReordering !== undefined) props.columnReordering = this.columnReordering; if (this.editColumns !== undefined) props.editColumns = this.editColumns; diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index cdbfe85d3..bd5d8b94c 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -39,6 +39,12 @@ import { import { DOMManager } from "./dom/DOMManager"; import { RenderOrchestrator, RenderContext, RenderState } from "./rendering/RenderOrchestrator"; import { TableAPIImpl, TableAPIContext } from "./api/TableAPIImpl"; +import { + getExternalScrollMetrics, + isExternalScrollActive, + resolveScrollParent, + type ResolvedScrollParent, +} from "../utils/externalScroll"; import "../styles/all-themes.css"; @@ -107,6 +113,25 @@ export class SimpleTableVanilla { private lastScrollTop: number = 0; private isUpdating: boolean = false; + /** Currently resolved external scroll parent (HTMLElement or window). Null when external scroll mode is inactive. */ + private resolvedScrollParent: ResolvedScrollParent = null; + /** Bound scroll handler attached to the external scroll parent. */ + private externalScrollListener: ((e: Event) => void) | null = null; + /** Bound resize handler attached to window when scrollParent is "window". */ + private externalWindowResizeListener: (() => void) | null = null; + /** ResizeObserver watching the external scroll parent element. */ + private externalParentResizeObserver: ResizeObserver | null = null; + /** Cached visible viewport height of the table inside the external parent. Fed into virtualization. */ + private externalViewportHeight: number = 0; + /** True iff the body-container scroll listener is currently attached. */ + private bodyScrollListenerAttached: boolean = false; + /** Bound mouseleave handler on the body container. */ + private bodyContainerMouseLeaveListener: (() => void) | null = null; + /** Bound scroll handler attached to the body container (internal scroll mode). */ + private bodyContainerScrollListener: ((e: Event) => void) | null = null; + /** One-shot warning for sticky-parents limitation under external scroll. */ + private warnedExternalStickyParents: boolean = false; + /** * Active accordion axis for the next render. Set by row/column collapse- * expand mutators (see {@link beginAccordionAnimation}) and consumed by the @@ -440,7 +465,7 @@ export class SimpleTableVanilla { this.scrollManager = new ScrollManager({ onLoadMore: this.config.onLoadMore, - infiniteScrollThreshold: 200, + infiniteScrollThreshold: this.config.infiniteScrollThreshold ?? 200, }); this.sectionScrollController = new SectionScrollController({ @@ -545,9 +570,217 @@ export class SimpleTableVanilla { const elements = this.domManager.getElements(); if (!elements?.bodyContainer) return; - elements.bodyContainer.addEventListener("scroll", this.handleScroll.bind(this)); - elements.bodyContainer.addEventListener("mouseleave", () => { + this.bodyContainerMouseLeaveListener = () => { this.clearHoveredRows(); + }; + elements.bodyContainer.addEventListener("mouseleave", this.bodyContainerMouseLeaveListener); + + this.syncExternalScrollWiring(); + } + + /** + * Reconciles which element owns the vertical scroll listener based on the + * current `scrollParent` config. Called on mount and whenever `update()` + * could have changed the relevant inputs (`scrollParent` / `height` / + * `maxHeight`). Idempotent — safe to call repeatedly. + */ + private syncExternalScrollWiring(): void { + const elements = this.domManager.getElements(); + if (!elements?.bodyContainer) return; + + const externalActive = isExternalScrollActive( + this.config.scrollParent, + this.config.height, + this.config.maxHeight, + ); + const nextParent: ResolvedScrollParent = externalActive + ? resolveScrollParent(this.config.scrollParent) + : null; + + if (nextParent !== this.resolvedScrollParent) { + this.detachExternalScrollWiring(); + } + + if (nextParent) { + this.attachExternalScrollWiring(nextParent); + this.ensureBodyScrollListenerDetached(elements.bodyContainer); + this.recomputeExternalViewportHeight(); + } else { + this.ensureBodyScrollListenerAttached(elements.bodyContainer); + this.externalViewportHeight = 0; + if (this.dimensionManager) { + this.dimensionManager.updateConfig({ externalViewportHeight: undefined }); + } + } + + this.maybeWarnStickyParentsConflict(externalActive); + } + + private ensureBodyScrollListenerAttached(bodyContainer: HTMLElement): void { + if (this.bodyScrollListenerAttached) return; + this.bodyContainerScrollListener = (e: Event) => this.handleScroll(e); + bodyContainer.addEventListener("scroll", this.bodyContainerScrollListener); + this.bodyScrollListenerAttached = true; + } + + private ensureBodyScrollListenerDetached(bodyContainer: HTMLElement): void { + if (!this.bodyScrollListenerAttached) return; + if (this.bodyContainerScrollListener) { + bodyContainer.removeEventListener("scroll", this.bodyContainerScrollListener); + this.bodyContainerScrollListener = null; + } + this.bodyScrollListenerAttached = false; + } + + private attachExternalScrollWiring(parent: ResolvedScrollParent): void { + if (!parent) return; + this.resolvedScrollParent = parent; + + const handler = (e: Event) => this.handleExternalScroll(e); + this.externalScrollListener = handler; + parent.addEventListener("scroll", handler, { passive: true }); + + if (typeof Window !== "undefined" && parent instanceof Window) { + const resizeHandler = () => this.handleExternalResize(); + this.externalWindowResizeListener = resizeHandler; + parent.addEventListener("resize", resizeHandler, { passive: true }); + } else if (typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(() => this.handleExternalResize()); + ro.observe(parent as HTMLElement); + this.externalParentResizeObserver = ro; + } + } + + private detachExternalScrollWiring(): void { + const parent = this.resolvedScrollParent; + if (parent && this.externalScrollListener) { + parent.removeEventListener("scroll", this.externalScrollListener); + } + this.externalScrollListener = null; + + if (parent && this.externalWindowResizeListener && typeof Window !== "undefined" && parent instanceof Window) { + parent.removeEventListener("resize", this.externalWindowResizeListener); + } + this.externalWindowResizeListener = null; + + if (this.externalParentResizeObserver) { + this.externalParentResizeObserver.disconnect(); + this.externalParentResizeObserver = null; + } + + this.resolvedScrollParent = null; + } + + private maybeWarnStickyParentsConflict(externalActive: boolean): void { + if (!externalActive) return; + if (!this.config.enableStickyParents) return; + if (this.warnedExternalStickyParents) return; + this.warnedExternalStickyParents = true; + console.warn( + "SimpleTable: `enableStickyParents` is not supported when `scrollParent` is in use " + + "(external scroll mode). Sticky parent rows will not stick to the viewport. " + + "Use `height` or `maxHeight` for sticky parent behavior.", + ); + } + + /** + * Recompute the visible portion of the table inside the external scroll + * parent and push it into the DimensionManager so virtualization math + * picks it up. Cheap; called on scroll, on parent/window resize, and on + * every re-render where the resolved parent may have moved. + */ + private recomputeExternalViewportHeight(): void { + if (!this.resolvedScrollParent) return; + const elements = this.domManager.getElements(); + const tableRoot = elements?.rootElement ?? this.container; + const metrics = getExternalScrollMetrics(this.resolvedScrollParent, tableRoot); + if (!metrics) return; + const next = metrics.visibleViewportHeight; + if (next === this.externalViewportHeight) return; + this.externalViewportHeight = next; + if (this.dimensionManager) { + this.dimensionManager.updateConfig({ externalViewportHeight: next }); + } + } + + private handleExternalResize(): void { + this.recomputeExternalViewportHeight(); + this.render("external-scroll-resize"); + } + + private handleExternalScroll(_e: Event): void { + const parent = this.resolvedScrollParent; + if (!parent) return; + const elements = this.domManager.getElements(); + const tableRoot = elements?.rootElement ?? this.container; + const metrics = getExternalScrollMetrics(parent, tableRoot); + if (!metrics) return; + + const newScrollTop = metrics.relativeScrollTop; + + this.isScrolling = true; + + if (this.scrollEndTimeoutId !== null) { + clearTimeout(this.scrollEndTimeoutId); + } + this.scrollEndTimeoutId = window.setTimeout(() => { + this.isScrolling = false; + this.scrollEndTimeoutId = null; + requestAnimationFrame(() => { + this.render("scroll-end"); + }); + }, 150); + + if (this.scrollRafId !== null) { + cancelAnimationFrame(this.scrollRafId); + } + + this.scrollRafId = requestAnimationFrame(() => { + const direction: "up" | "down" | "none" = + newScrollTop > this.lastScrollTop + ? "down" + : newScrollTop < this.lastScrollTop + ? "up" + : "none"; + + this.scrollTop = newScrollTop; + this.scrollDirection = direction; + this.lastScrollTop = newScrollTop; + + // Visible viewport height can change as the table enters / leaves the + // parent's viewport (partial intersection at top or bottom). Push the + // current value into the DimensionManager so contentHeight tracks it. + if (metrics.visibleViewportHeight !== this.externalViewportHeight) { + this.externalViewportHeight = metrics.visibleViewportHeight; + if (this.dimensionManager) { + this.dimensionManager.updateConfig({ + externalViewportHeight: metrics.visibleViewportHeight, + }); + } + } + + if (this.scrollManager) { + if (this.config.onLoadMore) { + // Compare against the table's bottom (not the parent's), so onLoadMore + // fires when the user has scrolled close to the end of the table even + // when the parent has more content below it. + const containerHeight = metrics.visibleViewportHeight; + const contentHeight = + metrics.relativeScrollTop + metrics.visibleViewportHeight + Math.max(0, metrics.distanceFromTableBottom); + this.scrollManager.handleScroll( + newScrollTop, + 0, + containerHeight, + contentHeight, + ); + } else { + this.scrollManager.handleScroll(newScrollTop, 0, 0, 0); + } + } + + this.render("scroll-raf"); + + this.scrollRafId = null; }); } @@ -666,6 +899,10 @@ export class SimpleTableVanilla { rowSelectionManager: this.rowSelectionManager, rowStateMap: this.rowStateMap, positionOnlyBody: this._positionOnlyBody, + externalViewportHeight: + this.resolvedScrollParent && this.externalViewportHeight > 0 + ? this.externalViewportHeight + : undefined, onRender: () => this.render("resizeHandler-onRender"), setIsResizing: (value: boolean) => { this.isResizing = value; @@ -905,6 +1142,31 @@ export class SimpleTableVanilla { }); } + if ( + config.scrollParent !== undefined || + config.height !== undefined || + config.maxHeight !== undefined + ) { + this.syncExternalScrollWiring(); + } + + if ( + (config.onLoadMore !== undefined || config.infiniteScrollThreshold !== undefined) && + this.scrollManager + ) { + this.scrollManager.updateConfig({ + onLoadMore: this.config.onLoadMore, + infiniteScrollThreshold: this.config.infiniteScrollThreshold ?? 200, + }); + } + + if (config.enableStickyParents !== undefined) { + this.warnedExternalStickyParents = false; + this.maybeWarnStickyParentsConflict( + isExternalScrollActive(this.config.scrollParent, this.config.height, this.config.maxHeight), + ); + } + this.isUpdating = false; this.render("update"); } @@ -927,6 +1189,19 @@ export class SimpleTableVanilla { clearTimeout(this.scrollEndTimeoutId); this.scrollEndTimeoutId = null; } + + this.detachExternalScrollWiring(); + const elements = this.domManager.getElements(); + if (elements?.bodyContainer) { + this.ensureBodyScrollListenerDetached(elements.bodyContainer); + if (this.bodyContainerMouseLeaveListener) { + elements.bodyContainer.removeEventListener( + "mouseleave", + this.bodyContainerMouseLeaveListener, + ); + this.bodyContainerMouseLeaveListener = null; + } + } if (this.accordionCleanupTimerId !== null) { window.clearTimeout(this.accordionCleanupTimerId); this.accordionCleanupTimerId = null; diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index 2aeea5a00..1e70487bd 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -87,6 +87,13 @@ export interface RenderContext { sortManager: SortManager | null; /** When true, body cells that stay visible get only position updates (no content/selection recalc). Used during vertical scroll for performance. */ positionOnlyBody?: boolean; + /** + * Visible portion of the table inside an external scroll parent (in pixels). + * Set by {@link SimpleTableVanilla} per render when `config.scrollParent` is + * active and no explicit `height`/`maxHeight` is set. Drives virtualization + * the same way an explicit `height` does, but the scroll source is external. + */ + externalViewportHeight?: number; } export interface RenderState { @@ -391,6 +398,7 @@ export class RenderOrchestrator { !context.config.hideFooter ? context.customTheme.footerHeight : undefined, + externalViewportHeight: context.externalViewportHeight, }); const shouldPaginate = context.config.shouldPaginate ?? false; @@ -407,10 +415,19 @@ export class RenderOrchestrator { } } + // Sticky parents rely on `position: sticky` against the nearest scrolling + // ancestor — i.e. the body container — which is no longer the scroller in + // external scroll mode. Disable the feature when external mode is active + // so we don't render visually-broken sticky rows. A one-shot console.warn + // is emitted from SimpleTableVanilla when both flags collide. + const externalScrollActive = context.externalViewportHeight !== undefined; + const effectiveEnableStickyParents = + !externalScrollActive && (context.config.enableStickyParents ?? false); + const scrollReuseKey = contentHeight === undefined ? "" - : `${canUseCache ? 1 : 0}|${contentHeight}|${state.currentPage}|${rowsPerPage}|${shouldPaginate}|${serverSidePagination}|${context.customTheme.rowHeight}|${calculatedHeaderHeight}|${totalRowCountForHeight}|${context.config.enableStickyParents ?? false}|${rowGroupingKey}|${flattenResult.flattenedRows.length}|${heightOffsetsLen}|${heightOffsetsChecksum}`; + : `${canUseCache ? 1 : 0}|${contentHeight}|${state.currentPage}|${rowsPerPage}|${shouldPaginate}|${serverSidePagination}|${context.customTheme.rowHeight}|${calculatedHeaderHeight}|${totalRowCountForHeight}|${effectiveEnableStickyParents}|${rowGroupingKey}|${flattenResult.flattenedRows.length}|${heightOffsetsLen}|${heightOffsetsChecksum}`; const scrollReuseEligible = Boolean(context.positionOnlyBody) && @@ -427,7 +444,7 @@ export class RenderOrchestrator { rowHeight: context.customTheme.rowHeight, scrollTop: state.scrollTop, scrollDirection: state.scrollDirection, - enableStickyParents: context.config.enableStickyParents ?? false, + enableStickyParents: effectiveEnableStickyParents, rowGrouping: context.config.rowGrouping, }); } else { @@ -445,7 +462,7 @@ export class RenderOrchestrator { scrollDirection: state.scrollDirection, heightOffsets: flattenResult.heightOffsets, customTheme: context.customTheme, - enableStickyParents: context.config.enableStickyParents ?? false, + enableStickyParents: effectiveEnableStickyParents, rowGrouping: context.config.rowGrouping, }); diff --git a/packages/core/src/hooks/contentHeight.ts b/packages/core/src/hooks/contentHeight.ts index 5f1ed308a..f3d222646 100644 --- a/packages/core/src/hooks/contentHeight.ts +++ b/packages/core/src/hooks/contentHeight.ts @@ -7,6 +7,12 @@ export interface ContentHeightConfig { totalRowCount: number; headerHeight?: number; footerHeight?: number; + /** + * Visible portion of the table inside an external scroll parent (in pixels). + * Only consulted when neither `height` nor `maxHeight` is set; enables + * virtualization driven by a window- or element-level scroller. + */ + externalViewportHeight?: number; } /** @@ -54,6 +60,7 @@ export const calculateContentHeight = ({ totalRowCount, headerHeight, footerHeight, + externalViewportHeight, }: ContentHeightConfig): number | undefined => { // If maxHeight is provided, it takes precedence over height if (maxHeight) { @@ -79,6 +86,13 @@ export const calculateContentHeight = ({ return Math.max(0, maxHeightPx - actualHeaderHeight); } + // External scroll mode: a consumer-supplied parent (element or window) drives + // virtualization. Only kicks in when neither height nor maxHeight is set. + if (externalViewportHeight !== undefined && externalViewportHeight > 0) { + const actualHeaderHeight = headerHeight || rowHeight; + return Math.max(0, externalViewportHeight - actualHeaderHeight); + } + // When no height is specified, return undefined to disable virtualization // This allows the table to grow naturally to fit all content (paginated or not) if (!height) return undefined; diff --git a/packages/core/src/managers/DimensionManager.ts b/packages/core/src/managers/DimensionManager.ts index fd89bba3b..343fa4c4c 100644 --- a/packages/core/src/managers/DimensionManager.ts +++ b/packages/core/src/managers/DimensionManager.ts @@ -9,6 +9,11 @@ export interface DimensionManagerConfig { totalRowCount: number; footerHeight?: number; containerElement?: HTMLElement; + /** + * Visible portion of the table inside an external scroll parent (in pixels). + * Drives virtualization when neither `height` nor `maxHeight` is set. + */ + externalViewportHeight?: number; } export interface DimensionManagerState { @@ -96,7 +101,15 @@ export class DimensionManager { } private calculateContentHeight(): number | undefined { - const { height, maxHeight, rowHeight, totalRowCount, headerHeight, footerHeight } = this.config; + const { + height, + maxHeight, + rowHeight, + totalRowCount, + headerHeight, + footerHeight, + externalViewportHeight, + } = this.config; if (maxHeight) { const maxHeightPx = this.convertHeightToPixels(maxHeight); @@ -117,6 +130,12 @@ export class DimensionManager { return Math.max(0, maxHeightPx - actualHeaderHeight); } + // External scroll parent provides the viewport: only when no explicit height. + if (!height && externalViewportHeight !== undefined && externalViewportHeight > 0) { + const actualHeaderHeight = headerHeight || rowHeight; + return Math.max(0, externalViewportHeight - actualHeaderHeight); + } + if (!height) return undefined; const totalHeightPx = this.convertHeightToPixels(height); @@ -197,7 +216,12 @@ export class DimensionManager { needsUpdate = true; } - if (config.height || config.maxHeight || config.totalRowCount !== undefined) { + if ( + config.height || + config.maxHeight || + config.totalRowCount !== undefined || + config.externalViewportHeight !== undefined + ) { const contentHeight = this.calculateContentHeight(); this.state = { ...this.state, diff --git a/packages/core/src/types/SimpleTableConfig.ts b/packages/core/src/types/SimpleTableConfig.ts index 2c7797617..a28414c0f 100644 --- a/packages/core/src/types/SimpleTableConfig.ts +++ b/packages/core/src/types/SimpleTableConfig.ts @@ -68,6 +68,7 @@ export interface SimpleTableConfig { onFilterChange?: (filters: TableFilterState) => void; onGridReady?: () => void; onHeaderEdit?: (header: HeaderObject, newLabel: string) => void; + infiniteScrollThreshold?: number; onLoadMore?: () => void; onNextPage?: OnNextPage; onPageChange?: (page: number) => void | Promise; @@ -80,6 +81,7 @@ export interface SimpleTableConfig { getRowId?: GetRowId; rows: Row[]; rowsPerPage?: number; + scrollParent?: HTMLElement | "window" | (() => HTMLElement | null); selectableCells?: boolean; selectableColumns?: boolean; serverSidePagination?: boolean; diff --git a/packages/core/src/types/SimpleTableProps.ts b/packages/core/src/types/SimpleTableProps.ts index 841754764..d7bd19d40 100644 --- a/packages/core/src/types/SimpleTableProps.ts +++ b/packages/core/src/types/SimpleTableProps.ts @@ -69,6 +69,7 @@ export interface SimpleTableProps { onFilterChange?: (filters: TableFilterState) => void; // Callback when filter is applied onGridReady?: () => void; // Custom handler for when the grid is ready onHeaderEdit?: (header: HeaderObject, newLabel: string) => void; // Callback when a header is edited + infiniteScrollThreshold?: number; // Pixel distance from the bottom of the scrollable area at which `onLoadMore` fires (default: 200) onLoadMore?: () => void; // Callback when user scrolls near bottom to load more data onNextPage?: OnNextPage; // Custom handler for next page onPageChange?: (page: number) => void | Promise; // Callback when page changes (for server-side pagination) @@ -81,6 +82,7 @@ export interface SimpleTableProps { getRowId?: GetRowId; // Function to generate unique row IDs for stable row identification across data changes. Receives row data, depth, index, paths, and grouping key. If not provided, uses index-based IDs. rows: Row[]; // Rows data rowsPerPage?: number; // Rows per page + scrollParent?: HTMLElement | "window" | (() => HTMLElement | null); // External scroll container that drives virtualization and onLoadMore when neither height nor maxHeight is set. Accepts an element, the string "window", or a getter (useful for refs that resolve after first render). selectableCells?: boolean; // Flag if can select cells selectableColumns?: boolean; // Flag for selectable column headers serverSidePagination?: boolean; // Flag to disable internal pagination slicing (for server-side pagination) diff --git a/packages/core/src/utils/externalScroll.ts b/packages/core/src/utils/externalScroll.ts new file mode 100644 index 000000000..c76d2c430 --- /dev/null +++ b/packages/core/src/utils/externalScroll.ts @@ -0,0 +1,173 @@ +/** + * Helpers for the "external scroll parent" virtualization mode. + * + * When a consumer supplies `scrollParent` and the table has neither `height` + * nor `maxHeight`, the table grows to its natural height inside the parent + * and we look at the parent's scroll position / viewport to drive + * virtualization and `onLoadMore`. + */ + +export type ScrollParentValue = + | HTMLElement + | "window" + | (() => HTMLElement | null) + | undefined + | null; + +export type ResolvedScrollParent = HTMLElement | Window | null; + +export interface ExternalScrollMetrics { + /** Scroll offset translated into the table's own coordinate space, clamped to [0, tableTotalHeight]. */ + relativeScrollTop: number; + /** Height of the table portion that is actually visible inside the parent viewport, in pixels. */ + visibleViewportHeight: number; + /** Distance in pixels from the current visible-bottom edge to the table's bottom edge. */ + distanceFromTableBottom: number; + /** Full pixel height of the table root element. */ + tableTotalHeight: number; + /** Width of the parent viewport, in pixels. */ + viewportWidth: number; +} + +/** + * Resolve a `scrollParent` config value to a usable element or window reference. + * Returns `null` if the value cannot be resolved this tick (e.g. a ref that + * has not yet been attached). Never throws. + */ +export const resolveScrollParent = (value: ScrollParentValue): ResolvedScrollParent => { + if (value == null) return null; + + if (typeof value === "function") { + try { + const resolved = value(); + return resolved ?? null; + } catch { + return null; + } + } + + if (value === "window") { + return typeof window !== "undefined" ? window : null; + } + + if (typeof HTMLElement !== "undefined" && value instanceof HTMLElement) { + return value; + } + + // Fallback: anything truthy with addEventListener (defensive against DOM proxies in SSR/tests). + if (typeof (value as { addEventListener?: unknown }).addEventListener === "function") { + return value as HTMLElement; + } + + return null; +}; + +/** + * Returns true if external scroll mode should be active for the given props. + * External mode is only enabled when no explicit height constraint is set; + * `height` / `maxHeight` always win. + */ +export const isExternalScrollActive = ( + scrollParent: ScrollParentValue, + height: string | number | undefined, + maxHeight: string | number | undefined, +): boolean => { + if (height !== undefined && height !== null && height !== "") return false; + if (maxHeight !== undefined && maxHeight !== null && maxHeight !== "") return false; + return resolveScrollParent(scrollParent) !== null; +}; + +interface ViewportRect { + top: number; + bottom: number; + height: number; + width: number; +} + +const getViewportRectFromParent = (parent: ResolvedScrollParent): ViewportRect | null => { + if (!parent) return null; + + if (typeof Window !== "undefined" && parent instanceof Window) { + const height = parent.innerHeight; + return { + top: 0, + bottom: height, + height, + width: parent.innerWidth, + }; + } + + const el = parent as HTMLElement; + const rect = el.getBoundingClientRect(); + return { + top: rect.top, + bottom: rect.bottom, + height: rect.height, + width: rect.width, + }; +}; + +const getScrollTopFromParent = (parent: ResolvedScrollParent): number => { + if (!parent) return 0; + if (typeof Window !== "undefined" && parent instanceof Window) { + return parent.scrollY || parent.pageYOffset || 0; + } + return (parent as HTMLElement).scrollTop; +}; + +/** + * Compute scroll metrics translated into the table's coordinate space. + * + * The math: position the table's bounding rect within the parent's viewport rect, + * then intersect with the visible band of the parent. The intersection's top edge + * (relative to the table) is the effective `scrollTop` for virtualization, and the + * intersection's height is the effective `clientHeight`. + */ +export const getExternalScrollMetrics = ( + parent: ResolvedScrollParent, + tableRoot: HTMLElement | null, +): ExternalScrollMetrics | null => { + if (!parent || !tableRoot) return null; + + const viewport = getViewportRectFromParent(parent); + if (!viewport) return null; + + const tableRect = tableRoot.getBoundingClientRect(); + const tableTotalHeight = tableRect.height; + + if (tableTotalHeight <= 0) { + return { + relativeScrollTop: 0, + visibleViewportHeight: 0, + distanceFromTableBottom: Number.POSITIVE_INFINITY, + tableTotalHeight: 0, + viewportWidth: viewport.width, + }; + } + + const intersectionTop = Math.max(viewport.top, tableRect.top); + const intersectionBottom = Math.min(viewport.bottom, tableRect.bottom); + const visibleViewportHeight = Math.max(0, intersectionBottom - intersectionTop); + + const rawRelativeScrollTop = viewport.top - tableRect.top; + const relativeScrollTop = Math.max( + 0, + Math.min(rawRelativeScrollTop, Math.max(0, tableTotalHeight - visibleViewportHeight)), + ); + + const distanceFromTableBottom = tableRect.bottom - viewport.bottom; + + return { + relativeScrollTop, + visibleViewportHeight, + distanceFromTableBottom, + tableTotalHeight, + viewportWidth: viewport.width, + }; +}; + +/** + * Returns the raw scroll-top of the parent (used for direction tracking only). + */ +export const getParentScrollTop = (parent: ResolvedScrollParent): number => + getScrollTopFromParent(parent); diff --git a/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts b/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts index d10a14b5b..588778dfa 100644 --- a/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts +++ b/packages/core/stories/tests/43-CollapseExpandAnimationsTests.stories.ts @@ -21,7 +21,7 @@ import { renderVanillaTable, addParagraph } from "../utils"; import { waitForTable } from "./testUtils"; const meta: Meta = { - title: "Tests/43 - Collapse/Expand Accordion Animations", + title: "Tests/43 - Collapse Expand Accordion Animations", tags: ["test", "animations", "collapse-expand"], parameters: { layout: "padded", @@ -58,9 +58,7 @@ const findFirstBodyExpandIcon = ( ): HTMLElement | null => { const bodyContainer = canvasElement.querySelector(".st-body-container"); if (!bodyContainer) return null; - const rowCells = bodyContainer.querySelectorAll( - `.st-cell[data-row-index="${rowIndex}"]`, - ); + const rowCells = bodyContainer.querySelectorAll(`.st-cell[data-row-index="${rowIndex}"]`); for (const cell of Array.from(rowCells)) { const icon = cell.querySelector(".st-expand-icon-container"); if (icon && icon.getAttribute("aria-hidden") !== "true") { @@ -274,14 +272,10 @@ export const RowExpand_IncomingCellsStartAtZeroHeight = { play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const bodyContainer = canvasElement.querySelector( - ".st-body-container", - ) as HTMLElement | null; + const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement | null; expect(bodyContainer).toBeTruthy(); - const cellsBefore = bodyContainer!.querySelectorAll( - ".st-cell[data-row-index]", - ); + const cellsBefore = bodyContainer!.querySelectorAll(".st-cell[data-row-index]"); const rowCountBefore = new Set( Array.from(cellsBefore).map((c) => c.getAttribute("data-row-index")), ).size; @@ -293,18 +287,13 @@ export const RowExpand_IncomingCellsStartAtZeroHeight = { // Sample synchronously: new cells exist with 0 height and the // accordion-grow marker, before any rAF has run to write the final size. - const cellsMid = bodyContainer!.querySelectorAll( - ".st-cell[data-row-index]", - ); - const rowCountMid = new Set( - Array.from(cellsMid).map((c) => c.getAttribute("data-row-index")), - ).size; + const cellsMid = bodyContainer!.querySelectorAll(".st-cell[data-row-index]"); + const rowCountMid = new Set(Array.from(cellsMid).map((c) => c.getAttribute("data-row-index"))) + .size; expect(rowCountMid).toBeGreaterThan(rowCountBefore); const growingCells = Array.from( - bodyContainer!.querySelectorAll( - '.st-cell[data-st-accordion-grow="vertical"]', - ), + bodyContainer!.querySelectorAll('.st-cell[data-st-accordion-grow="vertical"]'), ); expect(growingCells.length).toBeGreaterThan(0); for (const c of growingCells) { @@ -352,9 +341,7 @@ export const Disabled_NoAccordionClass = { expect(root!.classList.contains(ACCORDION_CLASS)).toBe(false); // Final state reached immediately. - const q1Header = canvasElement.querySelector( - `.st-header-cell[data-accessor="q1"]`, - ); + const q1Header = canvasElement.querySelector(`.st-header-cell[data-accessor="q1"]`); expect(q1Header).toBeNull(); }, }; diff --git a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts new file mode 100644 index 000000000..977b727c0 --- /dev/null +++ b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts @@ -0,0 +1,222 @@ +/** + * EXTERNAL SCROLL TESTS + * + * Tests for the `scrollParent` prop which lets a consumer point the table at + * an external scroll container (HTMLElement or window). When active and + * neither height nor maxHeight is set, the external parent's scroll drives + * row virtualization and onLoadMore. + */ + +import type { Meta } from "@storybook/html"; +import { expect } from "@storybook/test"; +import { SimpleTableVanilla, HeaderObject } from "../../src/index"; +import { waitForTable, getRowCount } from "./testUtils"; + +const meta: Meta = { + title: "Tests/44 - External Scroll", + parameters: { + layout: "fullscreen", + chromatic: { disableSnapshot: true }, + }, +}; + +export default meta; + +const headers: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "name", label: "Name", width: 200, type: "string" }, + { accessor: "description", label: "Description", width: 300, type: "string" }, +]; + +const createRows = (count: number) => + Array.from({ length: count }, (_, i) => ({ + id: i + 1, + name: `Item ${i + 1}`, + description: `Description for item ${i + 1}`, + })); + +// ============================================================================ +// TEST 1: External element scroll virtualizes a large dataset +// ============================================================================ + +export const ExternalElementVirtualizes = { + tags: ["external-scroll"], + render: () => { + const wrapper = document.createElement("div"); + wrapper.style.padding = "1rem"; + + const scrollContainer = document.createElement("div"); + scrollContainer.id = "external-scroll-host"; + scrollContainer.style.height = "400px"; + scrollContainer.style.overflow = "auto"; + scrollContainer.style.border = "1px solid #ccc"; + wrapper.appendChild(scrollContainer); + + const tableContainer = document.createElement("div"); + scrollContainer.appendChild(tableContainer); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: headers, + rows: createRows(2000), + getRowId: (p) => String((p.row as { id?: number })?.id), + scrollParent: scrollContainer, + }); + table.mount(); + (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + const scrollContainer = canvasElement.querySelector( + "#external-scroll-host", + ) as HTMLElement; + expect(scrollContainer).toBeTruthy(); + + const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement; + expect(tableRoot).toBeTruthy(); + + // Internal body container must NOT be scrollable: the external host owns scroll. + const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement; + expect(bodyContainer.scrollHeight).toBe(bodyContainer.clientHeight); + + // External container, on the other hand, must show a large scrollHeight (table grew). + expect(scrollContainer.scrollHeight).toBeGreaterThan(scrollContainer.clientHeight + 1000); + + // Only a virtualized subset of rows is rendered (must be far less than 2000). + const rendered = getRowCount(canvasElement); + expect(rendered).toBeGreaterThan(0); + expect(rendered).toBeLessThan(200); + }, +}; + +// ============================================================================ +// TEST 2: Scrolling the external container fires onLoadMore near table bottom +// ============================================================================ + +export const ExternalScrollFiresOnLoadMore = { + tags: ["external-scroll"], + render: () => { + const captured: { count: number } = { count: 0 }; + (window as unknown as { __externalLoadMoreCapture?: { count: number } }).__externalLoadMoreCapture = captured; + + const wrapper = document.createElement("div"); + wrapper.style.padding = "1rem"; + + const scrollContainer = document.createElement("div"); + scrollContainer.id = "external-scroll-host-loadmore"; + scrollContainer.style.height = "350px"; + scrollContainer.style.overflow = "auto"; + scrollContainer.style.border = "1px solid #ccc"; + wrapper.appendChild(scrollContainer); + + const tableContainer = document.createElement("div"); + scrollContainer.appendChild(tableContainer); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: headers, + rows: createRows(500), + getRowId: (p) => String((p.row as { id?: number })?.id), + scrollParent: scrollContainer, + infiniteScrollThreshold: 200, + onLoadMore: () => { + captured.count += 1; + }, + }); + table.mount(); + (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const captured = (window as unknown as { __externalLoadMoreCapture?: { count: number } }) + .__externalLoadMoreCapture; + expect(captured).toBeTruthy(); + expect(captured!.count).toBe(0); + + const scrollContainer = canvasElement.querySelector( + "#external-scroll-host-loadmore", + ) as HTMLElement; + expect(scrollContainer).toBeTruthy(); + + // Scroll to near the bottom (within the 200px threshold) — onLoadMore must fire. + scrollContainer.scrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight - 50; + scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((r) => setTimeout(r, 250)); + + expect(captured!.count).toBeGreaterThanOrEqual(1); + }, +}; + +// ============================================================================ +// TEST 3: When neither scrollParent nor height/maxHeight is set, render all rows +// ============================================================================ + +export const NoScrollParentRendersAll = { + tags: ["external-scroll"], + render: () => { + const wrapper = document.createElement("div"); + wrapper.style.padding = "1rem"; + + const tableContainer = document.createElement("div"); + wrapper.appendChild(tableContainer); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: headers, + rows: createRows(50), + getRowId: (p) => String((p.row as { id?: number })?.id), + }); + table.mount(); + (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const rendered = getRowCount(canvasElement); + // Without virtualization, all 50 rows render. + expect(rendered).toBe(50); + }, +}; + +// ============================================================================ +// TEST 4: height takes precedence over scrollParent +// ============================================================================ + +export const HeightOverridesScrollParent = { + tags: ["external-scroll"], + render: () => { + const wrapper = document.createElement("div"); + wrapper.style.padding = "1rem"; + + const scrollContainer = document.createElement("div"); + scrollContainer.id = "ignored-scroll-host"; + scrollContainer.style.height = "800px"; + scrollContainer.style.overflow = "auto"; + wrapper.appendChild(scrollContainer); + + const tableContainer = document.createElement("div"); + scrollContainer.appendChild(tableContainer); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: headers, + rows: createRows(500), + getRowId: (p) => String((p.row as { id?: number })?.id), + height: "300px", + scrollParent: scrollContainer, + }); + table.mount(); + (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + // Internal body container should be the one that's scrollable when height is set. + const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement; + expect(bodyContainer.scrollHeight).toBeGreaterThan(bodyContainer.clientHeight); + + // Virtualization is active via the table's own height, so rendered rows are far fewer than 500. + const rendered = getRowCount(canvasElement); + expect(rendered).toBeLessThan(200); + }, +}; diff --git a/packages/examples/vanilla/src/demo-list.ts b/packages/examples/vanilla/src/demo-list.ts index be2a4bf34..f72ee0c2e 100644 --- a/packages/examples/vanilla/src/demo-list.ts +++ b/packages/examples/vanilla/src/demo-list.ts @@ -22,6 +22,7 @@ export const DEMO_LIST = [ { id: "external-filter", label: "External Filter" }, { id: "loading-state", label: "Loading State" }, { id: "infinite-scroll", label: "Infinite Scroll" }, + { id: "window-infinite-scroll", label: "Window Infinite Scroll" }, { id: "row-selection", label: "Row Selection" }, { id: "csv-export", label: "CSV Export" }, { id: "programmatic-control", label: "Programmatic Control" }, diff --git a/packages/examples/vanilla/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.ts b/packages/examples/vanilla/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.ts new file mode 100644 index 000000000..74231b07f --- /dev/null +++ b/packages/examples/vanilla/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.ts @@ -0,0 +1,138 @@ +import { SimpleTableVanilla } from "simple-table-core"; +import type { Theme, Row } from "simple-table-core"; +import { + windowScrollHeaders, + generateWindowScrollRows, +} from "./window-infinite-scroll.demo-data"; +import "simple-table-core/styles.css"; + +const INITIAL_ROWS = 50; +const BATCH_SIZE = 50; +const MAX_ROWS = 5_000; +const LOAD_DELAY_MS = 350; + +/** + * Window-style infinite scroll demo. + * + * The table is given no `height` / `maxHeight` — it grows to its natural size + * inside whatever scroll parent surrounds it. We pass `scrollParent: container` + * so the table virtualizes rows and triggers `onLoadMore` based on the demo's + * outer scroll area (which is `
` in this shell; + * in a regular app you'd pass `"window"` instead). As the user scrolls toward + * the bottom of the page, more rows are appended. + */ +export function renderWindowInfiniteScrollDemo( + container: HTMLElement, + options?: { height?: string | number; theme?: Theme } +): SimpleTableVanilla { + // The `height` URL param doesn't apply to this demo — the whole point is to + // let the table grow to fit all rows so the parent scrolls. Same for + // `maxHeight`. We accept the option to match the demo registry signature + // but intentionally ignore it. + void options?.height; + + const wrapper = document.createElement("div"); + wrapper.style.maxWidth = "1100px"; + wrapper.style.margin = "0 auto"; + + const heading = document.createElement("h1"); + heading.textContent = "Window-Scroll Infinite Loading"; + Object.assign(heading.style, { + fontSize: "28px", + margin: "0 0 12px 0", + color: "#0f172a", + }); + wrapper.appendChild(heading); + + const intro = document.createElement("p"); + intro.innerHTML = + "This table has no height or maxHeight. It grows to its natural size inside the page, " + + "and uses the outer scroll container (scrollParent) to drive both row virtualization and " + + "onLoadMore. Scroll down — new rows stream in as you approach the bottom."; + Object.assign(intro.style, { + fontSize: "15px", + lineHeight: "1.6", + color: "#475569", + margin: "0 0 16px 0", + }); + wrapper.appendChild(intro); + + const status = document.createElement("div"); + Object.assign(status.style, { + display: "inline-flex", + alignItems: "center", + gap: "8px", + padding: "6px 12px", + marginBottom: "16px", + background: "#eef2ff", + color: "#3730a3", + borderRadius: "999px", + fontSize: "13px", + fontWeight: "500", + }); + wrapper.appendChild(status); + + const tableContainer = document.createElement("div"); + wrapper.appendChild(tableContainer); + + const footer = document.createElement("p"); + footer.textContent = + "End of demo content. Keep scrolling near the bottom and onLoadMore will keep firing until the dataset is exhausted."; + Object.assign(footer.style, { + fontSize: "13px", + color: "#94a3b8", + margin: "24px 0 48px 0", + textAlign: "center", + }); + wrapper.appendChild(footer); + + container.appendChild(wrapper); + + let rows: Row[] = generateWindowScrollRows(0, INITIAL_ROWS); + let loading = false; + let hasMore = true; + + const updateStatus = () => { + const dot = ''; + const label = loading + ? "Loading more rows…" + : hasMore + ? `${rows.length.toLocaleString()} rows loaded · scroll for more` + : `${rows.length.toLocaleString()} rows loaded · end of dataset`; + status.innerHTML = `${dot}${label}`; + }; + updateStatus(); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: windowScrollHeaders, + rows, + theme: options?.theme, + getRowId: (p) => String((p.row as { id?: number })?.id), + // Use the demo container as the scroll parent. In a typical app you would + // pass `"window"` here — the demo shell wraps everything in a scrollable + // `
` so `window` doesn't actually scroll inside this preview. + scrollParent: container, + // Fire onLoadMore a bit earlier than the default so new rows appear before + // the user hits the very bottom. + infiniteScrollThreshold: 400, + onLoadMore: () => { + if (loading || !hasMore) return; + loading = true; + updateStatus(); + + setTimeout(() => { + const next = generateWindowScrollRows(rows.length, BATCH_SIZE); + rows = [...rows, ...next]; + if (rows.length >= MAX_ROWS) { + hasMore = false; + rows = rows.slice(0, MAX_ROWS); + } + loading = false; + table.update({ rows }); + updateStatus(); + }, LOAD_DELAY_MS); + }, + }); + + return table; +} diff --git a/packages/examples/vanilla/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts b/packages/examples/vanilla/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts new file mode 100644 index 000000000..7e3efb1e7 --- /dev/null +++ b/packages/examples/vanilla/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts @@ -0,0 +1,98 @@ +// Self-contained demo data for the window-scroll infinite scroll example. +import type { HeaderObject, Row } from "simple-table-core"; + +const FIRST_NAMES = [ + "Elena", + "Kai", + "Amara", + "Santiago", + "Priya", + "Magnus", + "Zara", + "Luca", + "Sarah", + "Olumide", + "Isabella", + "Dmitri", + "Aiko", + "Mateo", + "Noor", + "Fionn", +]; + +const LAST_NAMES = [ + "Vasquez", + "Tanaka", + "Okafor", + "Rodriguez", + "Chakraborty", + "Eriksson", + "Al-Rashid", + "Rossi", + "Kim", + "Adebayo", + "Chen", + "Volkov", + "Nakamura", + "Silva", + "Hassan", + "O'Brien", +]; + +const DEPARTMENTS = [ + "Engineering", + "AI Research", + "UX Design", + "DevOps", + "Marketing", + "Product", + "Sales", + "Finance", + "Operations", + "Customer Success", +]; + +const STATUSES = ["Active", "On Leave", "Remote", "Onsite"] as const; + +export function generateWindowScrollRows(startIndex: number, count: number): Row[] { + const rows: Row[] = []; + for (let i = 0; i < count; i++) { + const idx = startIndex + i; + const first = FIRST_NAMES[idx % FIRST_NAMES.length]; + const last = LAST_NAMES[(idx * 3) % LAST_NAMES.length]; + rows.push({ + id: idx + 1, + name: `${first} ${last}`, + email: `${first.toLowerCase()}.${last.toLowerCase().replace(/'/g, "")}@example.com`, + department: DEPARTMENTS[idx % DEPARTMENTS.length], + status: STATUSES[idx % STATUSES.length], + tenureYears: 1 + ((idx * 13) % 18), + salary: 60_000 + Math.floor(((idx * 7919) % 120_000)), + }); + } + return rows; +} + +export const windowScrollHeaders: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 80, type: "number", align: "right" }, + { accessor: "name", label: "Name", width: "1fr", minWidth: 160 }, + { accessor: "email", label: "Email", width: 260 }, + { accessor: "department", label: "Department", width: 160 }, + { accessor: "status", label: "Status", width: 120 }, + { + accessor: "tenureYears", + label: "Tenure", + width: 110, + type: "number", + align: "right", + valueFormatter: ({ value }) => `${value} yrs`, + }, + { + accessor: "salary", + label: "Salary", + width: 140, + type: "number", + align: "right", + valueFormatter: ({ value }) => `$${(value as number).toLocaleString()}`, + }, +]; diff --git a/packages/examples/vanilla/src/main.ts b/packages/examples/vanilla/src/main.ts index f5ba7d6b6..f3af8450d 100644 --- a/packages/examples/vanilla/src/main.ts +++ b/packages/examples/vanilla/src/main.ts @@ -111,6 +111,10 @@ const registry: Record< import("./demos/infinite-scroll/InfiniteScrollDemo").then((m) => ({ render: m.renderInfiniteScrollDemo, })), + "window-infinite-scroll": () => + import("./demos/window-infinite-scroll/WindowInfiniteScrollDemo").then((m) => ({ + render: m.renderWindowInfiniteScrollDemo, + })), "row-selection": () => import("./demos/row-selection/RowSelectionDemo").then((m) => ({ render: m.renderRowSelectionDemo, From a65e4fc0d73c200eba99c4cbf7511701664841c1 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Fri, 15 May 2026 22:24:34 -0500 Subject: [PATCH 2/9] Example improvements --- packages/core/stories/SimpleTable.stories.ts | 16 ++ .../stories/examples/WindowInfiniteScroll.ts | 240 ++++++++++++++++++ packages/examples/react/src/demo-list.ts | 1 + .../WindowInfiniteScrollDemo.tsx | 143 +++++++++++ .../window-infinite-scroll.demo-data.ts | 98 +++++++ packages/examples/react/src/registry.ts | 1 + 6 files changed, 499 insertions(+) create mode 100644 packages/core/stories/examples/WindowInfiniteScroll.ts create mode 100644 packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx create mode 100644 packages/examples/react/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts diff --git a/packages/core/stories/SimpleTable.stories.ts b/packages/core/stories/SimpleTable.stories.ts index 35f2d1ab1..3b824ebe6 100644 --- a/packages/core/stories/SimpleTable.stories.ts +++ b/packages/core/stories/SimpleTable.stories.ts @@ -136,6 +136,10 @@ import { renderInfiniteScrollExample, infiniteScrollExampleDefaults, } from "./examples/InfiniteScroll"; +import { + renderWindowInfiniteScrollExample, + windowInfiniteScrollExampleDefaults, +} from "./examples/WindowInfiniteScroll"; import { renderInfrastructureExample, infrastructureExampleDefaults, @@ -427,6 +431,18 @@ export const InfiniteScroll: StoryObj = { docs: { description: { story: "Infinite scroll or load-more for large datasets." } }, }, }; +export const WindowInfiniteScroll: StoryObj = { + ...storyArgs(windowInfiniteScrollExampleDefaults), + render: (args) => renderWindowInfiniteScrollExample(args), + parameters: { + docs: { + description: { + story: + "Window-style infinite scroll: the table has no height/maxHeight and uses the outer page (`scrollParent`) to drive virtualization and onLoadMore.", + }, + }, + }, +}; export const InfrastructureExample: StoryObj = { ...storyArgs(infrastructureExampleDefaults), render: (args) => renderInfrastructureExample(args), diff --git a/packages/core/stories/examples/WindowInfiniteScroll.ts b/packages/core/stories/examples/WindowInfiniteScroll.ts new file mode 100644 index 000000000..bd4bfa8b7 --- /dev/null +++ b/packages/core/stories/examples/WindowInfiniteScroll.ts @@ -0,0 +1,240 @@ +/** + * Window Infinite Scroll Example – demonstrates `scrollParent` driving + * virtualization and `onLoadMore` from an outer (page-like) scroll container. + * + * The table has no `height` / `maxHeight`. It grows to its natural size inside + * an enclosing scroll container, and that container's scroll position is what + * drives row recycling and the load-more callback. In a real app you'd usually + * pass `scrollParent: "window"`; this story uses a fixed-height scrollable + * `
` so the behavior is visible inside the Storybook iframe. + */ +import { SimpleTableVanilla } from "../../src/index"; +import type { HeaderObject, Row } from "../../src/index"; +import { defaultVanillaArgs, type UniversalVanillaArgs } from "../vanillaStoryConfig"; + +const INITIAL_ROWS = 50; +const BATCH_SIZE = 50; +const MAX_ROWS = 5_000; +const LOAD_DELAY_MS = 350; + +const FIRST_NAMES = [ + "Elena", + "Kai", + "Amara", + "Santiago", + "Priya", + "Magnus", + "Zara", + "Luca", + "Sarah", + "Olumide", + "Isabella", + "Dmitri", + "Aiko", + "Mateo", + "Noor", + "Fionn", +]; +const LAST_NAMES = [ + "Vasquez", + "Tanaka", + "Okafor", + "Rodriguez", + "Chakraborty", + "Eriksson", + "Al-Rashid", + "Rossi", + "Kim", + "Adebayo", + "Chen", + "Volkov", +]; +const DEPARTMENTS = [ + "Engineering", + "AI Research", + "UX Design", + "DevOps", + "Marketing", + "Product", + "Sales", + "Finance", + "Operations", +]; +const STATUSES = ["Active", "On Leave", "Remote", "Onsite"]; + +function generateRows(startIndex: number, count: number): Row[] { + const rows: Row[] = []; + for (let i = 0; i < count; i++) { + const idx = startIndex + i; + const first = FIRST_NAMES[idx % FIRST_NAMES.length]; + const last = LAST_NAMES[(idx * 3) % LAST_NAMES.length]; + rows.push({ + id: idx + 1, + name: `${first} ${last}`, + email: `${first.toLowerCase()}.${last.toLowerCase().replace(/'/g, "")}@example.com`, + department: DEPARTMENTS[idx % DEPARTMENTS.length], + status: STATUSES[idx % STATUSES.length], + tenureYears: 1 + ((idx * 13) % 18), + salary: 60_000 + Math.floor(((idx * 7919) % 120_000)), + }); + } + return rows; +} + +const HEADERS: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 80, type: "number", align: "right" }, + { accessor: "name", label: "Name", width: "1fr", minWidth: 160 }, + { accessor: "email", label: "Email", width: 260 }, + { accessor: "department", label: "Department", width: 160 }, + { accessor: "status", label: "Status", width: 120 }, + { + accessor: "tenureYears", + label: "Tenure", + width: 110, + type: "number", + align: "right", + valueFormatter: ({ value }) => `${value} yrs`, + }, + { + accessor: "salary", + label: "Salary", + width: 140, + type: "number", + align: "right", + valueFormatter: ({ value }) => `$${(value as number).toLocaleString()}`, + }, +]; + +export const windowInfiniteScrollExampleDefaults: Partial = { + // `height` / `maxHeight` are intentionally left off — the table grows to its + // natural size inside the outer scrollContainer instead. +}; + +export function renderWindowInfiniteScrollExample( + args?: Partial, +): HTMLElement { + const options = { + ...defaultVanillaArgs, + ...windowInfiniteScrollExampleDefaults, + ...args, + }; + + const wrapper = document.createElement("div"); + + const scrollContainer = document.createElement("div"); + Object.assign(scrollContainer.style, { + height: "640px", + overflow: "auto", + border: "1px solid #e5e7eb", + borderRadius: "8px", + padding: "32px", + background: "#fafafa", + }); + wrapper.appendChild(scrollContainer); + + const inner = document.createElement("div"); + inner.style.maxWidth = "1000px"; + inner.style.margin = "0 auto"; + scrollContainer.appendChild(inner); + + const heading = document.createElement("h1"); + heading.textContent = "Window-Scroll Infinite Loading"; + Object.assign(heading.style, { + fontSize: "28px", + margin: "0 0 12px 0", + color: "#0f172a", + }); + inner.appendChild(heading); + + const intro = document.createElement("p"); + intro.innerHTML = + "This table has no height or maxHeight — it grows to fit its rows. " + + "We pass scrollParent pointing at the outer scrolling box (or " + + "\"window\" in a regular page) so the table virtualizes rows and fires " + + "onLoadMore based on the parent's scroll position. Scroll down to load more."; + Object.assign(intro.style, { + fontSize: "15px", + lineHeight: "1.6", + color: "#475569", + margin: "0 0 16px 0", + }); + inner.appendChild(intro); + + const status = document.createElement("div"); + Object.assign(status.style, { + display: "inline-flex", + alignItems: "center", + gap: "8px", + padding: "6px 12px", + marginBottom: "16px", + background: "#eef2ff", + color: "#3730a3", + borderRadius: "999px", + fontSize: "13px", + fontWeight: "500", + }); + inner.appendChild(status); + + const tableContainer = document.createElement("div"); + inner.appendChild(tableContainer); + + const footer = document.createElement("p"); + footer.textContent = + "End of the demo content. As you scroll near the bottom, onLoadMore keeps firing until the dataset is exhausted."; + Object.assign(footer.style, { + fontSize: "13px", + color: "#94a3b8", + margin: "24px 0 48px 0", + textAlign: "center", + }); + inner.appendChild(footer); + + let rows: Row[] = generateRows(0, INITIAL_ROWS); + let loading = false; + let hasMore = true; + + const updateStatus = () => { + const dot = + ''; + const label = loading + ? "Loading more rows…" + : hasMore + ? `${rows.length.toLocaleString()} rows loaded · scroll for more` + : `${rows.length.toLocaleString()} rows loaded · end of dataset`; + status.innerHTML = `${dot}${label}`; + }; + updateStatus(); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: HEADERS, + rows, + theme: options.theme, + customTheme: options.customTheme, + getRowId: (p: { row?: { id?: unknown } }) => String(p.row?.id), + scrollParent: scrollContainer, + infiniteScrollThreshold: 400, + onLoadMore: () => { + if (loading || !hasMore) return; + loading = true; + updateStatus(); + + setTimeout(() => { + const next = generateRows(rows.length, BATCH_SIZE); + rows = [...rows, ...next]; + if (rows.length >= MAX_ROWS) { + rows = rows.slice(0, MAX_ROWS); + hasMore = false; + } + loading = false; + table.update({ rows }); + updateStatus(); + }, LOAD_DELAY_MS); + }, + }); + table.mount(); + + // Hold a reference so the story container can dispose of the instance. + (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table; + + return wrapper; +} diff --git a/packages/examples/react/src/demo-list.ts b/packages/examples/react/src/demo-list.ts index be2a4bf34..f72ee0c2e 100644 --- a/packages/examples/react/src/demo-list.ts +++ b/packages/examples/react/src/demo-list.ts @@ -22,6 +22,7 @@ export const DEMO_LIST = [ { id: "external-filter", label: "External Filter" }, { id: "loading-state", label: "Loading State" }, { id: "infinite-scroll", label: "Infinite Scroll" }, + { id: "window-infinite-scroll", label: "Window Infinite Scroll" }, { id: "row-selection", label: "Row Selection" }, { id: "csv-export", label: "CSV Export" }, { id: "programmatic-control", label: "Programmatic Control" }, diff --git a/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx b/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx new file mode 100644 index 000000000..8b6b38b48 --- /dev/null +++ b/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx @@ -0,0 +1,143 @@ +import { useCallback, useRef, useState } from "react"; +import { SimpleTable } from "@simple-table/react"; +import type { Row, Theme } from "@simple-table/react"; +import { + generateWindowScrollRows, + windowScrollHeaders, +} from "./window-infinite-scroll.demo-data"; +import "@simple-table/react/styles.css"; + +const INITIAL_ROWS = 50; +const BATCH_SIZE = 50; +const MAX_ROWS = 5_000; +const LOAD_DELAY_MS = 350; + +/** + * Window-style infinite scroll demo. + * + * The table has no `height` / `maxHeight` — it grows to its natural size inside + * whatever scroll parent surrounds it. `scrollParent` is a getter that resolves + * to the nearest scrollable ancestor at render time (the demo shell wraps + * everything in a scrollable `
`, so `window` itself doesn't scroll inside + * this preview). In a typical app you'd just pass `scrollParent="window"`. + * + * As the user scrolls toward the bottom, `onLoadMore` appends a batch of rows + * until the dataset cap is reached. + */ +const WindowInfiniteScrollDemo = ({ + theme, +}: { + // `height` is intentionally ignored — this demo is about *not* setting one. + height?: string | number; + theme?: Theme; +}) => { + const wrapperRef = useRef(null); + + const [rows, setRows] = useState(() => generateWindowScrollRows(0, INITIAL_ROWS)); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const handleLoadMore = useCallback(() => { + if (loading || !hasMore) return; + setLoading(true); + + setTimeout(() => { + setRows((prev) => { + const next = generateWindowScrollRows(prev.length, BATCH_SIZE); + const updated = [...prev, ...next]; + if (updated.length >= MAX_ROWS) { + setHasMore(false); + return updated.slice(0, MAX_ROWS); + } + return updated; + }); + setLoading(false); + }, LOAD_DELAY_MS); + }, [loading, hasMore]); + + const statusLabel = loading + ? "Loading more rows…" + : hasMore + ? `${rows.length.toLocaleString()} rows loaded · scroll for more` + : `${rows.length.toLocaleString()} rows loaded · end of dataset`; + + return ( +
+

+ Window-Scroll Infinite Loading +

+

+ This table has no height or maxHeight. It grows to its natural + size inside the page, and uses the outer scroll container (scrollParent) to + drive both row virtualization and onLoadMore. Scroll down — new rows stream + in as you approach the bottom. +

+ +
+ + {statusLabel} +
+ + String((p.row as { id?: number })?.id)} + // Getter form so we don't capture the wrapper before React attaches the ref. + // In a regular app outside this preview shell, pass scrollParent="window". + scrollParent={() => wrapperRef.current?.parentElement ?? null} + infiniteScrollThreshold={400} + onLoadMore={handleLoadMore} + isLoading={loading && rows.length === 0} + /> + +

+ End of demo content. Keep scrolling near the bottom and onLoadMore will keep firing until + the dataset is exhausted. +

+
+ ); +}; + +export default WindowInfiniteScrollDemo; diff --git a/packages/examples/react/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts b/packages/examples/react/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts new file mode 100644 index 000000000..09a756400 --- /dev/null +++ b/packages/examples/react/src/demos/window-infinite-scroll/window-infinite-scroll.demo-data.ts @@ -0,0 +1,98 @@ +// Self-contained data + headers for the window-scroll infinite scroll example. +import type { ReactHeaderObject, Row } from "@simple-table/react"; + +const FIRST_NAMES = [ + "Elena", + "Kai", + "Amara", + "Santiago", + "Priya", + "Magnus", + "Zara", + "Luca", + "Sarah", + "Olumide", + "Isabella", + "Dmitri", + "Aiko", + "Mateo", + "Noor", + "Fionn", +]; + +const LAST_NAMES = [ + "Vasquez", + "Tanaka", + "Okafor", + "Rodriguez", + "Chakraborty", + "Eriksson", + "Al-Rashid", + "Rossi", + "Kim", + "Adebayo", + "Chen", + "Volkov", + "Nakamura", + "Silva", + "Hassan", + "O'Brien", +]; + +const DEPARTMENTS = [ + "Engineering", + "AI Research", + "UX Design", + "DevOps", + "Marketing", + "Product", + "Sales", + "Finance", + "Operations", + "Customer Success", +]; + +const STATUSES = ["Active", "On Leave", "Remote", "Onsite"] as const; + +export function generateWindowScrollRows(startIndex: number, count: number): Row[] { + const rows: Row[] = []; + for (let i = 0; i < count; i++) { + const idx = startIndex + i; + const first = FIRST_NAMES[idx % FIRST_NAMES.length]; + const last = LAST_NAMES[(idx * 3) % LAST_NAMES.length]; + rows.push({ + id: idx + 1, + name: `${first} ${last}`, + email: `${first.toLowerCase()}.${last.toLowerCase().replace(/'/g, "")}@example.com`, + department: DEPARTMENTS[idx % DEPARTMENTS.length], + status: STATUSES[idx % STATUSES.length], + tenureYears: 1 + ((idx * 13) % 18), + salary: 60_000 + Math.floor(((idx * 7919) % 120_000)), + }); + } + return rows; +} + +export const windowScrollHeaders: ReactHeaderObject[] = [ + { accessor: "id", label: "ID", width: 80, type: "number", align: "right" }, + { accessor: "name", label: "Name", width: "1fr", minWidth: 160 }, + { accessor: "email", label: "Email", width: 260 }, + { accessor: "department", label: "Department", width: 160 }, + { accessor: "status", label: "Status", width: 120 }, + { + accessor: "tenureYears", + label: "Tenure", + width: 110, + type: "number", + align: "right", + valueFormatter: ({ value }) => `${value} yrs`, + }, + { + accessor: "salary", + label: "Salary", + width: 140, + type: "number", + align: "right", + valueFormatter: ({ value }) => `$${(value as number).toLocaleString()}`, + }, +]; diff --git a/packages/examples/react/src/registry.ts b/packages/examples/react/src/registry.ts index 7d59b06ca..cfd140461 100644 --- a/packages/examples/react/src/registry.ts +++ b/packages/examples/react/src/registry.ts @@ -33,6 +33,7 @@ export const registry: DemoRegistry = { "external-filter": () => import("./demos/external-filter/ExternalFilterDemo"), "loading-state": () => import("./demos/loading-state/LoadingStateDemo"), "infinite-scroll": () => import("./demos/infinite-scroll/InfiniteScrollDemo"), + "window-infinite-scroll": () => import("./demos/window-infinite-scroll/WindowInfiniteScrollDemo"), "row-selection": () => import("./demos/row-selection/RowSelectionDemo"), "csv-export": () => import("./demos/csv-export/CsvExportDemo"), "programmatic-control": () => import("./demos/programmatic-control/ProgrammaticControlDemo"), From 8afeefd5aa985fe596d20d11230b8143a63488d2 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Fri, 15 May 2026 23:02:30 -0500 Subject: [PATCH 3/9] header padding fix --- packages/core/src/core/SimpleTableVanilla.ts | 34 ++++++++ .../src/core/rendering/RenderOrchestrator.ts | 10 +++ packages/core/src/styles/base.css | 25 ++++++ .../stories/examples/WindowInfiniteScroll.ts | 3 +- .../tests/44-ExternalScrollTests.stories.ts | 85 ++++++++++++++++++- .../WindowInfiniteScrollDemo.tsx | 5 +- .../WindowInfiniteScrollDemo.ts | 3 +- 7 files changed, 157 insertions(+), 8 deletions(-) diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index bd5d8b94c..75a86fb3d 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -649,6 +649,8 @@ export class SimpleTableVanilla { ro.observe(parent as HTMLElement); this.externalParentResizeObserver = ro; } + + this.recomputeExternalScrollPaddingTop(); } private detachExternalScrollWiring(): void { @@ -669,6 +671,37 @@ export class SimpleTableVanilla { } this.resolvedScrollParent = null; + + // Clear the padding-top CSS variable so a subsequent transition into + // bounded-height mode doesn't leave stale offset state on the root. + const elements = this.domManager.getElements(); + if (elements) { + elements.rootElement.style.removeProperty("--st-external-scroll-padding-top"); + } + } + + /** + * Read the resolved scroll parent's computed `padding-top` and publish it + * as `--st-external-scroll-padding-top` on the table root. The sticky + * header CSS uses `top: calc(-1 * var(...))` so the header pins flush to + * the parent's outer top edge instead of the padding edge, eliminating the + * visible gap that CSS sticky would otherwise produce when the consumer + * gives the scroll parent any top padding. Re-run on layout changes via + * ResizeObserver / window resize. + */ + private recomputeExternalScrollPaddingTop(): void { + const elements = this.domManager.getElements(); + if (!elements) return; + const parent = this.resolvedScrollParent; + let paddingTop = 0; + if (parent && typeof HTMLElement !== "undefined" && parent instanceof HTMLElement) { + const cs = getComputedStyle(parent); + paddingTop = parseFloat(cs.paddingTop) || 0; + } + elements.rootElement.style.setProperty( + "--st-external-scroll-padding-top", + `${paddingTop}px`, + ); } private maybeWarnStickyParentsConflict(externalActive: boolean): void { @@ -705,6 +738,7 @@ export class SimpleTableVanilla { private handleExternalResize(): void { this.recomputeExternalViewportHeight(); + this.recomputeExternalScrollPaddingTop(); this.render("external-scroll-resize"); } diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index 1e70487bd..ec07e4cc8 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -571,6 +571,16 @@ export class RenderOrchestrator { } } + // External scroll mode: toggle the root mode class that opts the header + // into `position: sticky` and relaxes `overflow: hidden` on .st-wrapper + // so the sticky element can escape to the external scroll ancestor. + // Idempotent + re-evaluated each render so flipping between modes (e.g. + // user sets `height` later) cleanly removes the class. + elements.rootElement.classList.toggle( + "st-external-scroll", + context.externalViewportHeight !== undefined, + ); + const { customTheme } = context; rootStyle.setProperty("--st-main-section-width", `${mainSectionContainerWidth}px`); rootStyle.setProperty("--st-scrollbar-width", `${state.scrollbarWidth}px`); diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index 2f16f2082..4cfb2511b 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -161,6 +161,31 @@ input { overflow: hidden; } +/* External scroll mode: the table grows to its natural height inside a + consumer-owned scroll parent (HTMLElement or window). In this mode the + header becomes sticky so it pins to the top of the external scroll + viewport as the user scrolls. Relax overflow:hidden on the root so the + sticky element's containing block can escape to the external scroll + ancestor (CSS `position: sticky` is trapped by the nearest ancestor with + non-visible overflow). Trade-off: body content may visually bleed across + the rounded bottom corners, which is acceptable for natural-height + tables. Consumers wanting strict clipping can set border-radius: 0. */ +.simple-table-root.st-external-scroll { + overflow: visible; +} + +.simple-table-root.st-external-scroll .st-header-container { + position: sticky; + /* When the scroll parent has padding-top, CSS sticky would normally pin the + header to the *padding edge*, leaving a visible gap between the container + top and the header during scroll. We publish --st-external-scroll-padding-top + from SimpleTableVanilla based on the resolved parent's computed padding-top + so the sticky offset compensates and the header pins flush to the parent's + outer top edge. Default 0px keeps the no-padding case unchanged. */ + top: calc(-1 * var(--st-external-scroll-padding-top, 0px)); + z-index: 4; +} + .st-wrapper-container { position: relative; display: flex; diff --git a/packages/core/stories/examples/WindowInfiniteScroll.ts b/packages/core/stories/examples/WindowInfiniteScroll.ts index bd4bfa8b7..01aa750e4 100644 --- a/packages/core/stories/examples/WindowInfiniteScroll.ts +++ b/packages/core/stories/examples/WindowInfiniteScroll.ts @@ -151,7 +151,8 @@ export function renderWindowInfiniteScrollExample( "This table has no height or maxHeight — it grows to fit its rows. " + "We pass scrollParent pointing at the outer scrolling box (or " + "\"window\" in a regular page) so the table virtualizes rows and fires " + - "onLoadMore based on the parent's scroll position. Scroll down to load more."; + "onLoadMore based on the parent's scroll position. The header pins to the " + + "top of the outer scroll viewport as you scroll. Scroll down to load more."; Object.assign(intro.style, { fontSize: "15px", lineHeight: "1.6", diff --git a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts index 977b727c0..c2e1249bd 100644 --- a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts +++ b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts @@ -68,9 +68,7 @@ export const ExternalElementVirtualizes = { play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const scrollContainer = canvasElement.querySelector( - "#external-scroll-host", - ) as HTMLElement; + const scrollContainer = canvasElement.querySelector("#external-scroll-host") as HTMLElement; expect(scrollContainer).toBeTruthy(); const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement; @@ -98,7 +96,9 @@ export const ExternalScrollFiresOnLoadMore = { tags: ["external-scroll"], render: () => { const captured: { count: number } = { count: 0 }; - (window as unknown as { __externalLoadMoreCapture?: { count: number } }).__externalLoadMoreCapture = captured; + ( + window as unknown as { __externalLoadMoreCapture?: { count: number } } + ).__externalLoadMoreCapture = captured; const wrapper = document.createElement("div"); wrapper.style.padding = "1rem"; @@ -218,5 +218,82 @@ export const HeightOverridesScrollParent = { // Virtualization is active via the table's own height, so rendered rows are far fewer than 500. const rendered = getRowCount(canvasElement); expect(rendered).toBeLessThan(200); + + // Sticky header mode is OFF when height takes precedence — no external-scroll class on the root. + const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement; + expect(tableRoot.classList.contains("st-external-scroll")).toBe(false); + }, +}; + +// ============================================================================ +// TEST 5: Header is sticky to the top of the external scroll viewport +// ============================================================================ + +export const StickyHeaderInExternalScroll = { + tags: ["external-scroll"], + render: () => { + const wrapper = document.createElement("div"); + wrapper.style.padding = "1rem"; + + const scrollContainer = document.createElement("div"); + scrollContainer.id = "external-scroll-host-sticky"; + scrollContainer.style.height = "400px"; + scrollContainer.style.overflow = "auto"; + scrollContainer.style.border = "1px solid #ccc"; + scrollContainer.style.padding = "1rem"; + wrapper.appendChild(scrollContainer); + + const tableContainer = document.createElement("div"); + scrollContainer.appendChild(tableContainer); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: headers, + rows: createRows(2000), + getRowId: (p) => String((p.row as { id?: number })?.id), + scrollParent: scrollContainer, + }); + table.mount(); + (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + const scrollContainer = canvasElement.querySelector( + "#external-scroll-host-sticky", + ) as HTMLElement; + expect(scrollContainer).toBeTruthy(); + + const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement; + expect(tableRoot).toBeTruthy(); + + // External scroll mode should opt the root into the sticky-header CSS class. + expect(tableRoot.classList.contains("st-external-scroll")).toBe(true); + + const headerContainer = canvasElement.querySelector(".st-header-container") as HTMLElement; + expect(headerContainer).toBeTruthy(); + + // The CSS class must actually resolve to position: sticky at runtime + // (no ancestor with `overflow: hidden` between header and scrollContainer). + expect(getComputedStyle(headerContainer).position).toBe("sticky"); + + // Before scrolling, the header sits at its natural position inside the + // scroll container — that is, below any padding-top the consumer added. + // (CSS sticky preserves the element's natural in-flow position; only the + // pinned position is offset by our negative-`top` variable.) + const containerTop = scrollContainer.getBoundingClientRect().top; + const paddingTop = parseFloat(getComputedStyle(scrollContainer).paddingTop) || 0; + const headerTopBefore = headerContainer.getBoundingClientRect().top; + expect(Math.abs(headerTopBefore - (containerTop + paddingTop))).toBeLessThan(3); + + // Scroll the external container; the header must stay pinned to the + // *outer* top edge of the scroll container — not the padding edge — so + // there is no visible gap above the header when the parent has padding. + scrollContainer.scrollTop = 500; + scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((r) => setTimeout(r, 100)); + + const headerTopAfter = headerContainer.getBoundingClientRect().top; + expect(Math.abs(headerTopAfter - containerTop)).toBeLessThan(3); }, }; diff --git a/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx b/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx index 8b6b38b48..2a5e26264 100644 --- a/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx +++ b/packages/examples/react/src/demos/window-infinite-scroll/WindowInfiniteScrollDemo.tsx @@ -82,8 +82,9 @@ const WindowInfiniteScrollDemo = ({ > This table has no height or maxHeight. It grows to its natural size inside the page, and uses the outer scroll container (scrollParent) to - drive both row virtualization and onLoadMore. Scroll down — new rows stream - in as you approach the bottom. + drive both row virtualization and onLoadMore. The header pins to the top of + the outer scroll viewport as you scroll. Scroll down — new rows stream in as you approach + the bottom.

scrollParent) to drive both row virtualization and " + - "onLoadMore. Scroll down — new rows stream in as you approach the bottom."; + "onLoadMore. The header pins to the top of the outer scroll viewport as you scroll. " + + "Scroll down — new rows stream in as you approach the bottom."; Object.assign(intro.style, { fontSize: "15px", lineHeight: "1.6", From adb136e9ea6684ecb753caa81c6ee76e5d53fda1 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Fri, 15 May 2026 23:31:19 -0500 Subject: [PATCH 4/9] Changelog --- .../docs-pages/InfiniteScrollContent.tsx | 160 +++++++++++++++++- apps/marketing/src/constants/changelog.ts | 45 +++++ .../propDefinitions/simpleTableProps.ts | 37 ++++ packages/angular/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/core/SimpleTableVanilla.ts | 44 +++++ packages/react/package.json | 2 +- packages/solid/package.json | 2 +- packages/svelte/package.json | 2 +- packages/vue/package.json | 2 +- 10 files changed, 290 insertions(+), 8 deletions(-) diff --git a/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx b/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx index 7932a6d78..0196a956b 100644 --- a/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx +++ b/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx @@ -34,12 +34,51 @@ const INFINITE_SCROLL_PROPS: PropInfo[] = [ name: "height", required: false, description: - "Height of the table container. Required for infinite scroll to work properly as it enables the scroll detection.", + "Height of the table container. Use this OR scrollParent to enable scroll detection. With a fixed height the table's own body scrolls; without height the table grows to fit and scrollParent drives the scroll.", type: "string", example: ``, + }, + { + key: "infiniteScrollThreshold", + name: "infiniteScrollThreshold", + required: false, + description: + "Pixel distance from the bottom at which onLoadMore fires. Defaults to 200. Increase for earlier pre-fetching.", + type: "number", + example: ``, + }, +]; + +const WINDOW_SCROLL_PROPS: PropInfo[] = [ + { + key: "scrollParent", + name: "scrollParent", + required: false, + description: + "Opts the table into window / external scroll mode. When set and neither height nor maxHeight is provided, the table grows to its natural size inside the parent and that parent's scroll position drives both row virtualization and onLoadMore. Accepts an element, the string \"window\", or a getter (useful for refs that resolve after first render). The header automatically pins to the top of the parent's scroll viewport.", + type: 'HTMLElement | "window" | (() => HTMLElement | null)', + example: `// Page-level scroll (most common in real apps) + + +// Custom container (e.g. a side panel with overflow: auto) + containerRef.current} + onLoadMore={handleLoadMore} />`, }, ]; @@ -155,7 +194,11 @@ const InfiniteScrollContent = () => { onLoadMore {" "} - when user scrolls near the bottom (typically 100px before the end) + when user scrolls within{" "} + + infiniteScrollThreshold + {" "} + pixels of the bottom (default 200px)
  • Debouncing - Prevents multiple simultaneous requests by debouncing the @@ -168,6 +211,119 @@ const InfiniteScrollContent = () => { + {/* Window / External Scroll Section */} + + Window / External Scroll Mode + + + +

    + Want the table to behave like a regular page section — growing to its natural height + while the page (or a parent container) scrolls? Drop{" "} + + height + {" "} + /{" "} + + maxHeight + {" "} + and pass{" "} + + scrollParent + + . The table will: +

    + +
      +
    • + Virtualize against the parent - Only the rows visible inside the + parent's viewport are rendered, even with tens of thousands of rows. +
    • +
    • + Fire onLoadMore from the parent's scroll - The threshold check uses the + parent's position relative to the table, not the table's own (non-existent) inner + scroll. +
    • +
    • + Pin the header automatically - The header sticks to the top of the + parent's scroll viewport via CSS{" "} + + position: sticky + {" "} + so it stays visible as users scroll through the rows. +
    • +
    • + Suppress overscroll bounce - Sets{" "} + + overscroll-behavior-y: none + {" "} + on the scroll parent so the rubber-band effect doesn't visually shift the sticky header + off the viewport. Restored on unmount. +
    • +
    + + + +

    + Precedence rules +

    +
      +
    • + + height + {" "} + or{" "} + + maxHeight + {" "} + always win. If either is set,{" "} + + scrollParent + {" "} + is ignored and the table uses its own inner scroll. +
    • +
    • + Without{" "} + + scrollParent + {" "} + and without{" "} + + height + + , all rows render (no virtualization, no infinite scroll). +
    • +
    • + + enableStickyParents + {" "} + (for sticky row-group headers) is not supported in external scroll mode and will be + silently disabled with a one-time warning. +
    • +
    + +

    + Padding on the scroll parent +

    +

    + If your scroll parent has{" "} + + padding-top + + , the table reads it and offsets the sticky header so it pins flush to the parent's outer + top edge instead of sitting beneath the padding. No extra config required. +

    +
    + ); diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index be93319ec..eee386d22 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -10,6 +10,50 @@ export interface ChangelogEntry { link?: string; }[]; } +export const v3_6_0: ChangelogEntry = { + version: "3.6.0", + date: "2026-05-15", + title: "Window / external scroll mode", + description: + "New scrollParent prop lets the table grow to its natural height inside a page-level or custom scroll container, while that parent's scroll drives virtualization and onLoadMore. Header automatically pins to the top of the parent's scroll viewport.", + changes: [ + { + type: "feature", + description: + "New scrollParent prop (HTMLElement | \"window\" | () => HTMLElement | null) opts the table into external scroll mode when no height/maxHeight is set; the parent's scroll drives row virtualization.", + link: "/docs/infinite-scroll", + }, + { + type: "feature", + description: + "onLoadMore now fires based on the external scroll parent's position relative to the table bottom when scrollParent is active.", + link: "/docs/infinite-scroll", + }, + { + type: "feature", + description: + "New infiniteScrollThreshold prop (default 200px) exposes the bottom-distance at which onLoadMore fires.", + link: "/docs/infinite-scroll", + }, + { + type: "feature", + description: + "Header is automatically sticky-pinned to the top of the external scroll parent's viewport in scrollParent mode. Auto-compensates for parent padding-top.", + link: "/docs/infinite-scroll", + }, + { + type: "improvement", + description: + "Suppresses the browser's elastic rubber-band on the scroll parent while external scroll mode is active so the sticky header stays put during overscroll. Restored on detach.", + }, + { + type: "improvement", + description: + "enableStickyParents (sticky row-group rows) is now safely no-op + warn when combined with scrollParent (incompatible CSS containing-block).", + }, + ], +}; + export const v3_5_3: ChangelogEntry = { version: "3.5.3", date: "2026-05-09", @@ -1677,6 +1721,7 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v3_6_0, v3_5_3, v3_5_2, v3_4_2, diff --git a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts index 315885ccd..0949e395d 100644 --- a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts +++ b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts @@ -297,6 +297,43 @@ quickFilter={{ setRows(prevRows => [...prevRows, ...newRows]); }); }}`, + }, + { + key: "scrollParent", + name: "scrollParent", + required: false, + description: + "Opts the table into 'window' / external scroll mode. When set and neither height nor maxHeight is provided, the table grows to its natural height inside the given parent and that parent's scroll position drives both row virtualization and onLoadMore. Accepts an element, the string \"window\", or a getter (useful for React/Angular refs that resolve after first render). The header is automatically pinned to the top of the parent's scroll viewport.", + type: 'HTMLElement | "window" | (() => HTMLElement | null)', + example: `// Page-level scroll (most common in real apps) + + +// Scroll inside a specific container (e.g. a side panel) + containerRef.current} + onLoadMore={handleLoadMore} +/>`, + }, + { + key: "infiniteScrollThreshold", + name: "infiniteScrollThreshold", + required: false, + description: + "Pixel distance from the bottom of the scrollable area at which onLoadMore fires. Defaults to 200. Increase for earlier pre-fetching; decrease to fire only very close to the bottom.", + type: "number", + example: ``, }, { key: "onSortChange", diff --git a/packages/angular/package.json b/packages/angular/package.json index b7a5255f2..da49d30f4 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "3.5.3", + "version": "3.6.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/angular/src/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 41fa1685b..05fa9f2dd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "3.5.3", + "version": "3.6.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/src/index.d.ts", diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 75a86fb3d..d0b49345c 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -131,6 +131,18 @@ export class SimpleTableVanilla { private bodyContainerScrollListener: ((e: Event) => void) | null = null; /** One-shot warning for sticky-parents limitation under external scroll. */ private warnedExternalStickyParents: boolean = false; + /** + * When external scroll mode is active we briefly take control of the scroll + * parent's `overscroll-behavior-y` to neutralize the browser's rubber-band / + * scroll-chaining at the boundaries. Without this, pulling past the top or + * bottom of the scroll parent visually translates the entire scroll content + * layer (including the CSS-sticky header), causing the header to "disappear" + * during overscroll bounces even though its layout position is unchanged. + * We record the previous inline value so {@link detachExternalScrollWiring} + * can restore it cleanly. + */ + private overscrollBehaviorTarget: HTMLElement | null = null; + private overscrollBehaviorPrev: string = ""; /** * Active accordion axis for the next render. Set by row/column collapse- @@ -651,6 +663,7 @@ export class SimpleTableVanilla { } this.recomputeExternalScrollPaddingTop(); + this.applyOverscrollContainment(parent); } private detachExternalScrollWiring(): void { @@ -678,6 +691,37 @@ export class SimpleTableVanilla { if (elements) { elements.rootElement.style.removeProperty("--st-external-scroll-padding-top"); } + + this.restoreOverscrollBehavior(); + } + + /** + * Set `overscroll-behavior-y: none` on the resolved scroll parent (or + * `document.documentElement` for `scrollParent: "window"`). This neutralizes + * the browser's elastic rubber-band at the scroll boundaries, which would + * otherwise translate the entire scroll content layer (including our + * `position: sticky` header) during overscroll bounces — making the header + * visually disappear off the top of the parent. `contain` only stops scroll + * chaining; we need `none` to actually disable the elastic bounce on the + * scroll container itself. Previous inline value is captured so we can + * restore it on detach. + */ + private applyOverscrollContainment(parent: ResolvedScrollParent): void { + const target: HTMLElement | null = + typeof Window !== "undefined" && parent instanceof Window + ? (typeof document !== "undefined" ? document.documentElement : null) + : (parent as HTMLElement | null); + if (!target) return; + this.overscrollBehaviorTarget = target; + this.overscrollBehaviorPrev = target.style.overscrollBehaviorY; + target.style.overscrollBehaviorY = "none"; + } + + private restoreOverscrollBehavior(): void { + if (!this.overscrollBehaviorTarget) return; + this.overscrollBehaviorTarget.style.overscrollBehaviorY = this.overscrollBehaviorPrev; + this.overscrollBehaviorTarget = null; + this.overscrollBehaviorPrev = ""; } /** diff --git a/packages/react/package.json b/packages/react/package.json index 3d253eba8..8c2af6c14 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "3.5.3", + "version": "3.6.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/react/src/index.d.ts", diff --git a/packages/solid/package.json b/packages/solid/package.json index 83427230f..263bc5f88 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "3.5.3", + "version": "3.6.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/solid/src/index.d.ts", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index dea1ee095..d2d2d7d5f 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/svelte", - "version": "3.5.3", + "version": "3.6.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/vue/package.json b/packages/vue/package.json index 78b6c9f96..c6c93eb10 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/vue", - "version": "3.5.3", + "version": "3.6.0", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/vue/src/index.d.ts", From eec8f4ac6a9388708c9c2c2c9a3098bcee670b05 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Fri, 15 May 2026 23:39:39 -0500 Subject: [PATCH 5/9] External scroll with row grouping test --- .../tests/44-ExternalScrollTests.stories.ts | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts index c2e1249bd..7e7cca82f 100644 --- a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts +++ b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts @@ -297,3 +297,166 @@ export const StickyHeaderInExternalScroll = { expect(Math.abs(headerTopAfter - containerTop)).toBeLessThan(3); }, }; + +// ============================================================================ +// TEST 6: Row grouping works in external scroll mode (and enableStickyParents +// is safely no-op + warns, since position: sticky parent rows are incompatible +// with the external scroll containing block). +// ============================================================================ + +const groupedHeaders: HeaderObject[] = [ + // `expandable: true` is required for the column to render the + // expand/collapse chevron next to grouped parent rows. + { accessor: "name", label: "Name", width: 240, expandable: true }, + { accessor: "id", label: "ID", width: 160, type: "string" }, + { accessor: "value", label: "Value", width: 120, type: "number" }, +]; + +const createGroupedRows = (groupCount: number, childrenPerGroup: number) => + Array.from({ length: groupCount }, (_, gi) => ({ + id: `group-${gi + 1}`, + name: `Group ${gi + 1}`, + value: (gi + 1) * 100, + children: Array.from({ length: childrenPerGroup }, (_, ci) => ({ + id: `group-${gi + 1}-child-${ci + 1}`, + name: `Child ${ci + 1} of group ${gi + 1}`, + value: (gi + 1) * 100 + ci, + })), + })); + +export const RowGroupingInExternalScroll = { + tags: ["external-scroll"], + render: () => { + // Capture console.warn so we can assert the enableStickyParents conflict + // warning is emitted exactly once. + const captured: { warnings: string[] } = { warnings: [] }; + ( + window as unknown as { __externalGroupingWarnCapture?: { warnings: string[] } } + ).__externalGroupingWarnCapture = captured; + + const previousWarn = console.warn; + console.warn = (...args: unknown[]) => { + captured.warnings.push(args.map((a) => String(a)).join(" ")); + previousWarn.apply(console, args as []); + }; + ( + window as unknown as { __externalGroupingWarnRestore?: () => void } + ).__externalGroupingWarnRestore = () => { + console.warn = previousWarn; + }; + + const wrapper = document.createElement("div"); + wrapper.style.padding = "1rem"; + + const scrollContainer = document.createElement("div"); + scrollContainer.id = "external-scroll-host-grouping"; + scrollContainer.style.height = "400px"; + scrollContainer.style.overflow = "auto"; + scrollContainer.style.border = "1px solid #ccc"; + wrapper.appendChild(scrollContainer); + + const tableContainer = document.createElement("div"); + scrollContainer.appendChild(tableContainer); + + // 50 groups × 20 children fully expanded = 1050 visible rows — plenty for + // virtualization to kick in inside a 400px viewport. + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: groupedHeaders, + rows: createGroupedRows(50, 20), + getRowId: ({ row }) => String((row as { id?: string }).id), + rowGrouping: ["children"], + expandAll: true, + // Set deliberately to trigger the warn-and-noop path. The table must + // still render grouped rows correctly without any .st-sticky-top + // overlay (sticky parents are incompatible with external scroll mode). + enableStickyParents: true, + scrollParent: scrollContainer, + }); + table.mount(); + (wrapper as unknown as { _table?: SimpleTableVanilla })._table = table; + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + const scrollContainer = canvasElement.querySelector( + "#external-scroll-host-grouping", + ) as HTMLElement; + expect(scrollContainer).toBeTruthy(); + + const tableRoot = canvasElement.querySelector(".simple-table-root") as HTMLElement; + expect(tableRoot).toBeTruthy(); + + // External scroll mode is active even with rowGrouping configured. + expect(tableRoot.classList.contains("st-external-scroll")).toBe(true); + + // Grouping warning was emitted because enableStickyParents was set alongside + // scrollParent. Exactly one warning, with the expected guidance. + const captured = ( + window as unknown as { __externalGroupingWarnCapture?: { warnings: string[] } } + ).__externalGroupingWarnCapture; + expect(captured).toBeTruthy(); + const stickyWarnings = captured!.warnings.filter((w) => + w.includes("`enableStickyParents` is not supported"), + ); + expect(stickyWarnings.length).toBe(1); + + // No sticky-top overlay should be present — the warn-and-noop must skip + // building the sticky parents container in external scroll mode. + const stickyTopOverlay = canvasElement.querySelector(".st-sticky-top"); + expect(stickyTopOverlay).toBeNull(); + + // Virtualization is active: only a subset of the 1050 expanded rows is rendered. + const renderedBeforeScroll = getRowCount(canvasElement); + expect(renderedBeforeScroll).toBeGreaterThan(0); + expect(renderedBeforeScroll).toBeLessThan(200); + + // Group parent rows are present: the first rendered group's parent cell + // ("Group 1") should be in the DOM with the table fully scrolled to top. + const allCells = Array.from(canvasElement.querySelectorAll(".st-cell")); + const hasGroupParent = allCells.some((c) => c.textContent?.includes("Group 1")); + const hasGroupChild = allCells.some((c) => + c.textContent?.includes("Child 1 of group 1"), + ); + expect(hasGroupParent).toBe(true); + expect(hasGroupChild).toBe(true); + + // Expand/collapse chevrons must render on the `expandable` column for the + // parent rows — this is the user-facing affordance for grouped rows. + const bodyContainer = canvasElement.querySelector(".st-body-container") as HTMLElement; + const expandIcons = bodyContainer.querySelectorAll(".st-expand-icon-container"); + expect(expandIcons.length).toBeGreaterThan(0); + + // Scroll the external container; row virtualization must shift the rendered + // window. We expect different row indices to be on screen after a large + // scroll, proving the external parent's scroll drives the body windowing. + const indicesBefore = new Set( + Array.from(canvasElement.querySelectorAll(".st-cell[data-row-index]")).map((c) => + c.getAttribute("data-row-index"), + ), + ); + + scrollContainer.scrollTop = 8000; // well past the first viewport + scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + + const indicesAfter = new Set( + Array.from(canvasElement.querySelectorAll(".st-cell[data-row-index]")).map((c) => + c.getAttribute("data-row-index"), + ), + ); + // The rendered window must have shifted — at least one new row index that + // wasn't visible before scrolling. + const newlyVisible = Array.from(indicesAfter).filter((idx) => !indicesBefore.has(idx)); + expect(newlyVisible.length).toBeGreaterThan(0); + + // Still no sticky parents overlay after scrolling. + expect(canvasElement.querySelector(".st-sticky-top")).toBeNull(); + + // Restore console.warn so we don't leak the wrapper into the next story. + const restore = ( + window as unknown as { __externalGroupingWarnRestore?: () => void } + ).__externalGroupingWarnRestore; + restore?.(); + }, +}; From a2f7d5baa6a4c105c63a3b0a8aafdfd12a706963 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 16 May 2026 08:11:43 -0500 Subject: [PATCH 6/9] Sticky rows improvements --- .../docs-pages/InfiniteScrollContent.tsx | 4 +- apps/marketing/src/constants/changelog.ts | 22 +++++ packages/angular/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/core/SimpleTableVanilla.ts | 23 ------ .../src/core/rendering/RenderOrchestrator.ts | 41 ++++++---- .../core/src/core/rendering/TableRenderer.ts | 40 ++++++++-- packages/core/src/styles/base.css | 15 ++++ .../core/src/utils/stickyParentsRenderer.ts | 25 +++++- .../tests/44-ExternalScrollTests.stories.ts | 80 +++++++------------ packages/react/package.json | 2 +- packages/solid/package.json | 2 +- packages/svelte/package.json | 2 +- packages/vue/package.json | 2 +- 14 files changed, 160 insertions(+), 102 deletions(-) diff --git a/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx b/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx index 0196a956b..2e651d33f 100644 --- a/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx +++ b/apps/marketing/src/components/pages/docs-pages/InfiniteScrollContent.tsx @@ -306,8 +306,8 @@ const InfiniteScrollContent = () => { enableStickyParents {" "} - (for sticky row-group headers) is not supported in external scroll mode and will be - silently disabled with a one-time warning. + (for grouped row parents) works in external scroll mode too — pinned parent rows + stay just under the sticky header as you scroll past their children.
  • diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index eee386d22..9d6ecfee7 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -10,6 +10,27 @@ export interface ChangelogEntry { link?: string; }[]; } +export const v3_6_1: ChangelogEntry = { + version: "3.6.1", + date: "2026-05-16", + title: "Sticky row-group parents in external scroll", + description: + "enableStickyParents now works in external scroll mode. Grouped parent rows pin under the sticky header as you scroll past their children, instead of scrolling away with the table. Removes the warn-and-noop guard added in 3.6.0.", + changes: [ + { + type: "feature", + description: + "enableStickyParents is now supported alongside scrollParent — pinned grouped parents stay flush under the sticky header in external scroll mode.", + link: "/docs/infinite-scroll", + }, + { + type: "improvement", + description: + "Removed the one-shot console.warn that fired when enableStickyParents and scrollParent were combined; the conflict no longer exists.", + }, + ], +}; + export const v3_6_0: ChangelogEntry = { version: "3.6.0", date: "2026-05-15", @@ -1721,6 +1742,7 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v3_6_1, v3_6_0, v3_5_3, v3_5_2, diff --git a/packages/angular/package.json b/packages/angular/package.json index da49d30f4..d75767b3c 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "3.6.0", + "version": "3.6.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/angular/src/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index 05fa9f2dd..0b231dc12 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "3.6.0", + "version": "3.6.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/src/index.d.ts", diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index d0b49345c..f9c9a665f 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -129,8 +129,6 @@ export class SimpleTableVanilla { private bodyContainerMouseLeaveListener: (() => void) | null = null; /** Bound scroll handler attached to the body container (internal scroll mode). */ private bodyContainerScrollListener: ((e: Event) => void) | null = null; - /** One-shot warning for sticky-parents limitation under external scroll. */ - private warnedExternalStickyParents: boolean = false; /** * When external scroll mode is active we briefly take control of the scroll * parent's `overscroll-behavior-y` to neutralize the browser's rubber-band / @@ -624,8 +622,6 @@ export class SimpleTableVanilla { this.dimensionManager.updateConfig({ externalViewportHeight: undefined }); } } - - this.maybeWarnStickyParentsConflict(externalActive); } private ensureBodyScrollListenerAttached(bodyContainer: HTMLElement): void { @@ -748,18 +744,6 @@ export class SimpleTableVanilla { ); } - private maybeWarnStickyParentsConflict(externalActive: boolean): void { - if (!externalActive) return; - if (!this.config.enableStickyParents) return; - if (this.warnedExternalStickyParents) return; - this.warnedExternalStickyParents = true; - console.warn( - "SimpleTable: `enableStickyParents` is not supported when `scrollParent` is in use " + - "(external scroll mode). Sticky parent rows will not stick to the viewport. " + - "Use `height` or `maxHeight` for sticky parent behavior.", - ); - } - /** * Recompute the visible portion of the table inside the external scroll * parent and push it into the DimensionManager so virtualization math @@ -1238,13 +1222,6 @@ export class SimpleTableVanilla { }); } - if (config.enableStickyParents !== undefined) { - this.warnedExternalStickyParents = false; - this.maybeWarnStickyParentsConflict( - isExternalScrollActive(this.config.scrollParent, this.config.height, this.config.maxHeight), - ); - } - this.isUpdating = false; this.render("update"); } diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index ec07e4cc8..890ded7d7 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -415,19 +415,16 @@ export class RenderOrchestrator { } } - // Sticky parents rely on `position: sticky` against the nearest scrolling - // ancestor — i.e. the body container — which is no longer the scroller in - // external scroll mode. Disable the feature when external mode is active - // so we don't render visually-broken sticky rows. A one-shot console.warn - // is emitted from SimpleTableVanilla when both flags collide. - const externalScrollActive = context.externalViewportHeight !== undefined; - const effectiveEnableStickyParents = - !externalScrollActive && (context.config.enableStickyParents ?? false); + // Sticky parents work in both bounded and external-scroll modes. In external + // mode the sticky-parents container's `top` is JS-driven by the externally + // -aware scrollTop (see TableRenderer.renderBody + stickyParentsRenderer), + // so we can pass `enableStickyParents` through unchanged. + const enableStickyParents = context.config.enableStickyParents ?? false; const scrollReuseKey = contentHeight === undefined ? "" - : `${canUseCache ? 1 : 0}|${contentHeight}|${state.currentPage}|${rowsPerPage}|${shouldPaginate}|${serverSidePagination}|${context.customTheme.rowHeight}|${calculatedHeaderHeight}|${totalRowCountForHeight}|${effectiveEnableStickyParents}|${rowGroupingKey}|${flattenResult.flattenedRows.length}|${heightOffsetsLen}|${heightOffsetsChecksum}`; + : `${canUseCache ? 1 : 0}|${contentHeight}|${state.currentPage}|${rowsPerPage}|${shouldPaginate}|${serverSidePagination}|${context.customTheme.rowHeight}|${calculatedHeaderHeight}|${totalRowCountForHeight}|${enableStickyParents}|${rowGroupingKey}|${flattenResult.flattenedRows.length}|${heightOffsetsLen}|${heightOffsetsChecksum}`; const scrollReuseEligible = Boolean(context.positionOnlyBody) && @@ -444,7 +441,7 @@ export class RenderOrchestrator { rowHeight: context.customTheme.rowHeight, scrollTop: state.scrollTop, scrollDirection: state.scrollDirection, - enableStickyParents: effectiveEnableStickyParents, + enableStickyParents, rowGrouping: context.config.rowGrouping, }); } else { @@ -462,7 +459,7 @@ export class RenderOrchestrator { scrollDirection: state.scrollDirection, heightOffsets: flattenResult.heightOffsets, customTheme: context.customTheme, - enableStickyParents: effectiveEnableStickyParents, + enableStickyParents, rowGrouping: context.config.rowGrouping, }); @@ -590,6 +587,12 @@ export class RenderOrchestrator { ); rootStyle.setProperty("--st-border-width", `${customTheme.borderWidth}px`); rootStyle.setProperty("--st-footer-height", `${customTheme.footerHeight}px`); + // Published so the sticky-parents overlay in external scroll mode can + // pin natively (`top: calc(var(--st-calculated-header-height) - var(--st-external-scroll-padding-top))`). + rootStyle.setProperty( + "--st-calculated-header-height", + `${calculatedHeaderHeight}px`, + ); const columnResizing = context.config.columnResizing ?? false; elements.content.className = `st-content ${columnResizing ? "st-resizeable" : "st-not-resizeable"}`; @@ -606,7 +609,7 @@ export class RenderOrchestrator { ); } - this.renderBody(elements.bodyContainer, processedResult, effectiveHeaders, context); + this.renderBody(elements.bodyContainer, processedResult, effectiveHeaders, context, state); if (verticalScrollFastPath) { this.lastScrollRafPaintedRange = { @@ -678,8 +681,9 @@ export class RenderOrchestrator { processedResult: any, effectiveHeaders: HeaderObject[], context: RenderContext, + state: RenderState, ): void { - const deps = this.buildRendererDeps(effectiveHeaders, context); + const deps = this.buildRendererDeps(effectiveHeaders, context, state); this.tableRenderer.renderBody(bodyContainer, processedResult, deps); } @@ -773,9 +777,18 @@ export class RenderOrchestrator { } } - private buildRendererDeps(effectiveHeaders: HeaderObject[], context: RenderContext) { + private buildRendererDeps( + effectiveHeaders: HeaderObject[], + context: RenderContext, + state?: RenderState, + ) { return { accordionAxis: context.accordionAxis, + // External scroll mode hints — used by TableRenderer.renderBody to pick the + // right scrollTop source for the sticky-parents container (the main body + // does not scroll in external mode; the parent does). + externalScrollActive: context.externalViewportHeight !== undefined, + stickyParentsScrollTop: state?.scrollTop, animationCoordinator: context.animationCoordinator, config: context.config, customTheme: context.customTheme, diff --git a/packages/core/src/core/rendering/TableRenderer.ts b/packages/core/src/core/rendering/TableRenderer.ts index 64b6a1c40..4f0cd5d95 100644 --- a/packages/core/src/core/rendering/TableRenderer.ts +++ b/packages/core/src/core/rendering/TableRenderer.ts @@ -33,6 +33,15 @@ export interface TableRendererDeps { /** Accordion animation axis for the in-flight collapse/expand. See {@link RenderContext.accordionAxis}. */ accordionAxis?: AccordionAxis; animationCoordinator?: AnimationCoordinator; + /** + * True when the table is using an external `scrollParent` (no `height`/`maxHeight`). + * In this mode the main body container does not scroll — the parent does — so + * the sticky-parents container reads its scrollTop from `stickyParentsScrollTop` + * (sourced from the table's external-aware state) instead of `mainBodyRef.scrollTop`. + */ + externalScrollActive?: boolean; + /** Externally-tracked scrollTop (already translated into table coordinates). */ + stickyParentsScrollTop?: number; cellRegistry: Map; collapsedHeaders: Set; collapsedRows: Map; @@ -666,8 +675,12 @@ export class TableRenderer { this.stickyParentsContainer = null; } - // Get scroll state - const scrollTop = deps.mainBodyRef.current?.scrollTop ?? 0; + // Get scroll state — in external-scroll mode the body container does not + // scroll (the parent does), so prefer the externally-aware scrollTop + // threaded through deps. Falls back to the body's scrollTop otherwise. + const scrollTop = deps.externalScrollActive + ? (deps.stickyParentsScrollTop ?? 0) + : (deps.mainBodyRef.current?.scrollTop ?? 0); // Vertical scrollbar gutter lives on `.st-body-container`, not `.st-body-main` // (main hides scrollbars and does not reserve the gutter). const scrollbarWidth = container.offsetWidth - container.clientWidth; @@ -693,7 +706,11 @@ export class TableRenderer { stickyBodyRowIndexByRowKey.set(key, rowIndex); }); - // Create sticky parents container + // Create sticky parents container. The overlay uses native CSS + // `position: sticky` in external scroll mode (rule scoped to + // `.simple-table-root.st-external-scroll .st-sticky-top` in base.css), + // so the renderer only needs to flag the mode — no inline `top` + // arithmetic required. this.stickyParentsContainer = createStickyParentsContainer( { calculatedHeaderHeight: dimensionState.calculatedHeaderHeight, @@ -708,6 +725,7 @@ export class TableRenderer { stickyParents: processedResult.stickyParents, stickySectionColStart, stickyBodyRowIndexByRowKey, + externalScrollActive: deps.externalScrollActive, }, { collapsedHeaders: deps.collapsedHeaders, @@ -722,8 +740,20 @@ export class TableRenderer { ); if (this.stickyParentsContainer) { - sectionsToKeep.push(this.stickyParentsContainer); - if (!container.contains(this.stickyParentsContainer)) { + // Append the overlay as a sibling of `.st-body-container` inside + // `.st-content` (flex-direction: column). This is what lets us use + // native `position: sticky` in external scroll mode — the overlay's + // nearest scroll ancestor becomes the external scroll parent, so the + // browser composites it on the same paint as the scroll itself + // (no JS catch-up lag). In bounded mode the overlay remains + // `position: absolute` and the offset parent (`.st-content-wrapper`) + // does not scroll, so its visual position is unchanged. + // `sectionsToKeep` tracks `container`'s children only, so the overlay + // does not need to be in it once it lives outside the body container. + const contentEl = container.parentElement; + if (contentEl && this.stickyParentsContainer.parentElement !== contentEl) { + contentEl.insertBefore(this.stickyParentsContainer, container); + } else if (!contentEl) { container.appendChild(this.stickyParentsContainer); } } diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index 4cfb2511b..e3b3a454b 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -186,6 +186,21 @@ input { z-index: 4; } +/* Sticky-parents overlay in external scroll mode: also a native sticky + element so the browser composites it on the same paint as the parent + scroll (no JS lag). Pinned directly below the sticky header by offsetting + the header height and compensating for the scroll parent's padding-top. + The renderer sets `margin-bottom: calc(-1 * stickyHeightPx)` inline to + neutralize the overlay's contribution to flex column flow so the body + container starts where it naturally would — the overlay then visually sits + on top of the body's virtualized-out top region. */ +.simple-table-root.st-external-scroll .st-sticky-top { + position: sticky; + top: calc( + var(--st-calculated-header-height, 0px) - var(--st-external-scroll-padding-top, 0px) + ); +} + .st-wrapper-container { position: relative; display: flex; diff --git a/packages/core/src/utils/stickyParentsRenderer.ts b/packages/core/src/utils/stickyParentsRenderer.ts index cf0fc21f9..79b6086e1 100644 --- a/packages/core/src/utils/stickyParentsRenderer.ts +++ b/packages/core/src/utils/stickyParentsRenderer.ts @@ -34,6 +34,14 @@ export interface StickyParentsContainerProps { * for the current `rowsToRender` band, so selection matches virtualized body cells. */ stickyBodyRowIndexByRowKey: Map; + /** + * When true, the table is in external-scroll mode (scrollParent in use, no + * fixed height). The overlay uses native CSS `position: sticky` (handled by + * a stylesheet rule) so the browser composites it on the same paint as the + * parent scroll. The renderer only needs this flag to neutralize the + * overlay's flex column flow contribution via a negative bottom margin. + */ + externalScrollActive?: boolean; } export interface StickyParentsRenderContext { @@ -400,7 +408,22 @@ export const createStickyParentsContainer = ( container.className = "st-sticky-top"; container.style.height = `${stickyHeight}px`; container.style.width = containerWidth; - container.style.top = `${props.calculatedHeaderHeight}px`; + if (props.externalScrollActive) { + // External scroll mode: a CSS rule on `.simple-table-root.st-external-scroll + // .st-sticky-top` sets `position: sticky` + `top: calc(headerH - padTop)` + // using CSS variables, so the browser composites the overlay on the same + // paint as the parent scroll (zero JS lag). We don't set inline `top` here + // because that would override the CSS rule. We DO neutralize the overlay's + // contribution to the flex column flow with a negative bottom margin so + // `.st-body-container` (the next sibling) starts where it naturally would. + container.style.marginBottom = `${-stickyHeight}px`; + } else { + // Bounded mode: overlay is removed from flow via `position: absolute` + // (set by the base stylesheet) and pinned below the header relative to + // `.st-content-wrapper` (the nearest positioned ancestor, which does not + // scroll in this mode). + container.style.top = `${props.calculatedHeaderHeight}px`; + } // Get current headers (non-pinned) const currentHeaders = context.headers.filter((header) => !header.pinned); diff --git a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts index 7e7cca82f..1aadc2a68 100644 --- a/packages/core/stories/tests/44-ExternalScrollTests.stories.ts +++ b/packages/core/stories/tests/44-ExternalScrollTests.stories.ts @@ -299,9 +299,9 @@ export const StickyHeaderInExternalScroll = { }; // ============================================================================ -// TEST 6: Row grouping works in external scroll mode (and enableStickyParents -// is safely no-op + warns, since position: sticky parent rows are incompatible -// with the external scroll containing block). +// TEST 6: Row grouping with enableStickyParents works in external scroll mode. +// The sticky-parents container's `top` is JS-driven by the external scrollTop +// so grouped parent rows pin under the sticky header instead of scrolling away. // ============================================================================ const groupedHeaders: HeaderObject[] = [ @@ -327,24 +327,6 @@ const createGroupedRows = (groupCount: number, childrenPerGroup: number) => export const RowGroupingInExternalScroll = { tags: ["external-scroll"], render: () => { - // Capture console.warn so we can assert the enableStickyParents conflict - // warning is emitted exactly once. - const captured: { warnings: string[] } = { warnings: [] }; - ( - window as unknown as { __externalGroupingWarnCapture?: { warnings: string[] } } - ).__externalGroupingWarnCapture = captured; - - const previousWarn = console.warn; - console.warn = (...args: unknown[]) => { - captured.warnings.push(args.map((a) => String(a)).join(" ")); - previousWarn.apply(console, args as []); - }; - ( - window as unknown as { __externalGroupingWarnRestore?: () => void } - ).__externalGroupingWarnRestore = () => { - console.warn = previousWarn; - }; - const wrapper = document.createElement("div"); wrapper.style.padding = "1rem"; @@ -366,9 +348,6 @@ export const RowGroupingInExternalScroll = { getRowId: ({ row }) => String((row as { id?: string }).id), rowGrouping: ["children"], expandAll: true, - // Set deliberately to trigger the warn-and-noop path. The table must - // still render grouped rows correctly without any .st-sticky-top - // overlay (sticky parents are incompatible with external scroll mode). enableStickyParents: true, scrollParent: scrollContainer, }); @@ -390,22 +369,6 @@ export const RowGroupingInExternalScroll = { // External scroll mode is active even with rowGrouping configured. expect(tableRoot.classList.contains("st-external-scroll")).toBe(true); - // Grouping warning was emitted because enableStickyParents was set alongside - // scrollParent. Exactly one warning, with the expected guidance. - const captured = ( - window as unknown as { __externalGroupingWarnCapture?: { warnings: string[] } } - ).__externalGroupingWarnCapture; - expect(captured).toBeTruthy(); - const stickyWarnings = captured!.warnings.filter((w) => - w.includes("`enableStickyParents` is not supported"), - ); - expect(stickyWarnings.length).toBe(1); - - // No sticky-top overlay should be present — the warn-and-noop must skip - // building the sticky parents container in external scroll mode. - const stickyTopOverlay = canvasElement.querySelector(".st-sticky-top"); - expect(stickyTopOverlay).toBeNull(); - // Virtualization is active: only a subset of the 1050 expanded rows is rendered. const renderedBeforeScroll = getRowCount(canvasElement); expect(renderedBeforeScroll).toBeGreaterThan(0); @@ -427,16 +390,15 @@ export const RowGroupingInExternalScroll = { const expandIcons = bodyContainer.querySelectorAll(".st-expand-icon-container"); expect(expandIcons.length).toBeGreaterThan(0); - // Scroll the external container; row virtualization must shift the rendered - // window. We expect different row indices to be on screen after a large - // scroll, proving the external parent's scroll drives the body windowing. + // Scroll the external container deep into the dataset — past the first + // group's children so the grouping renderer must pick a sticky ancestor. const indicesBefore = new Set( Array.from(canvasElement.querySelectorAll(".st-cell[data-row-index]")).map((c) => c.getAttribute("data-row-index"), ), ); - scrollContainer.scrollTop = 8000; // well past the first viewport + scrollContainer.scrollTop = 8000; scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true })); await new Promise((r) => setTimeout(r, 200)); @@ -450,13 +412,29 @@ export const RowGroupingInExternalScroll = { const newlyVisible = Array.from(indicesAfter).filter((idx) => !indicesBefore.has(idx)); expect(newlyVisible.length).toBeGreaterThan(0); - // Still no sticky parents overlay after scrolling. - expect(canvasElement.querySelector(".st-sticky-top")).toBeNull(); + // After scrolling, enableStickyParents must produce the .st-sticky-top + // overlay (this is the user-facing fix — grouped parents pin under the + // sticky header rather than scrolling out of view with the table). + const stickyTop = canvasElement.querySelector(".st-sticky-top") as HTMLElement | null; + expect(stickyTop).not.toBeNull(); - // Restore console.warn so we don't leak the wrapper into the next story. - const restore = ( - window as unknown as { __externalGroupingWarnRestore?: () => void } - ).__externalGroupingWarnRestore; - restore?.(); + // The overlay must pin directly under the sticky header. The header is + // already proven to pin flush to the parent's outer edge (Test 5), so + // the sticky-top's top edge should equal scrollContainerTop + headerHeight + // (within a small layout tolerance). + const headerContainer = canvasElement.querySelector(".st-header-container") as HTMLElement; + const headerRect = headerContainer.getBoundingClientRect(); + const stickyTopRect = stickyTop!.getBoundingClientRect(); + expect(Math.abs(stickyTopRect.top - headerRect.bottom)).toBeLessThan(3); + + // The pinned parent row should belong to a group whose children are + // currently in view at scrollTop ~ 8000. We don't pin the exact group + // number (depends on row height + grouping math), but we *do* assert that + // the sticky-top renders at least one parent cell labelled "Group N". + const stickyParentCells = Array.from(stickyTop!.querySelectorAll(".st-cell")); + const hasPinnedGroupParent = stickyParentCells.some((c) => + /^Group \d+$/.test((c.textContent ?? "").trim()), + ); + expect(hasPinnedGroupParent).toBe(true); }, }; diff --git a/packages/react/package.json b/packages/react/package.json index 8c2af6c14..d86974422 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "3.6.0", + "version": "3.6.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/react/src/index.d.ts", diff --git a/packages/solid/package.json b/packages/solid/package.json index 263bc5f88..3ee4c250f 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "3.6.0", + "version": "3.6.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/solid/src/index.d.ts", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d2d2d7d5f..c25e93e2a 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/svelte", - "version": "3.6.0", + "version": "3.6.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/vue/package.json b/packages/vue/package.json index c6c93eb10..81b298c62 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/vue", - "version": "3.6.0", + "version": "3.6.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/vue/src/index.d.ts", From 60e1cb46d3a3a506a189c945b845094df0aba3bf Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 16 May 2026 08:16:40 -0500 Subject: [PATCH 7/9] Row separator fix --- packages/core/src/utils/bodyCellRenderer.ts | 41 +++++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/core/src/utils/bodyCellRenderer.ts b/packages/core/src/utils/bodyCellRenderer.ts index 98f571862..a48792c3a 100644 --- a/packages/core/src/utils/bodyCellRenderer.ts +++ b/packages/core/src/utils/bodyCellRenderer.ts @@ -276,7 +276,10 @@ const renderRowSeparators = ( }; // Main render function. When allRows is provided, separators are built from the full row list (including nested grid rows). -// When positionOnly is true (e.g. scroll-driven), only positions are updated; content and separators are skipped for performance. +// When positionOnly is true (e.g. scroll-driven), per-cell content/selection +// refresh is skipped for performance, but cell positions AND row separators +// still sync every frame so cells and their bottom borders appear together +// as new rows enter the virtualized band. // // `fullCellLayout` (when provided) maps every cell id this section knows about // — including rows currently outside the virtualized band — to its destination @@ -442,17 +445,21 @@ export const renderBodyCells = ( } }); - if (!positionOnly) { - // Remove separators that are no longer visible (only when doing full render) - const separatorMetadata = getSeparatorMetadata(container); - renderedSeparators.forEach((element, rowIndex) => { - if (!visibleRowIndices.has(rowIndex)) { - element.remove(); - renderedSeparators.delete(rowIndex); - separatorMetadata.delete(rowIndex); - } - }); - } + // Remove separators that are no longer visible. Done unconditionally — + // including on scroll-driven position-only renders — so the separator pass + // stays in lockstep with the cell pass. Without this, separators for rows + // scrolled out of the band would pile up as orphan DOM nodes until the + // scroll-end full render caught up, and separators for never-rendered rows + // scrolled into the band would be missing entirely (rows appearing without + // their bottom border for ~150ms while scrolling fast). + const separatorMetadata = getSeparatorMetadata(container); + renderedSeparators.forEach((element, rowIndex) => { + if (!visibleRowIndices.has(rowIndex)) { + element.remove(); + renderedSeparators.delete(rowIndex); + separatorMetadata.delete(rowIndex); + } + }); // Batch create new cells using DocumentFragment const fragment = document.createDocumentFragment(); @@ -586,8 +593,10 @@ export const renderBodyCells = ( }); } - // Render separators for visible rows (skip when positionOnly; row boundaries unchanged on horizontal scroll) - if (!positionOnly) { - renderRowSeparators(container, cellsToRender, context, renderedSeparators, allRows); - } + // Render separators in the same pass as cells so newly-scrolled-into-view + // rows get their bottom border in the same frame as their cells. The pass + // is cheap on scroll renders: it iterates `allRows` (one viewport-sized + // band) and only touches the DOM for separators whose top/strong-border/ + // section-width actually changed (see `SeparatorMetadata` cache). + renderRowSeparators(container, cellsToRender, context, renderedSeparators, allRows); }; From b7eca28199e22ba2a9ae8de020c4c33fe462ce1e Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 16 May 2026 08:26:17 -0500 Subject: [PATCH 8/9] Infinite demo fix --- .../components/demos/InfiniteScrollDemo.tsx | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/marketing/src/components/demos/InfiniteScrollDemo.tsx b/apps/marketing/src/components/demos/InfiniteScrollDemo.tsx index fbb80b526..0418b7fa2 100644 --- a/apps/marketing/src/components/demos/InfiniteScrollDemo.tsx +++ b/apps/marketing/src/components/demos/InfiniteScrollDemo.tsx @@ -1,6 +1,6 @@ import { SimpleTable } from "@simple-table/react"; import type { ReactHeaderObject, Theme } from "@simple-table/react"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef } from "react"; import "@simple-table/react/styles.css"; // Define headers @@ -101,33 +101,37 @@ const InfiniteScrollDemo = ({ const [rows, setRows] = useState(initialData); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); + // Synchronous re-entry guard. The `loading` state alone can't block + // back-to-back invocations: between the first `setLoading(true)` and React's + // next commit, the callback the table is holding still has `loading=false` + // in its closure, so multiple scroll-RAF ticks would all sneak past the guard. + const loadingRef = useRef(false); - // Simulate loading more data const handleLoadMore = useCallback(async () => { - if (loading || !hasMore) return; - + if (loadingRef.current || !hasMore) return; + loadingRef.current = true; setLoading(true); - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - try { - // Generate next batch of data - const nextStartId = rows.length + 1; - const newData = generateSampleData(nextStartId, 15); + await new Promise((resolve) => setTimeout(resolve, 1000)); - // Stop loading more after 200 records for demo purposes - if (nextStartId > 200) { - setHasMore(false); - } else { - setRows((prevRows) => [...prevRows, ...newData]); - } + // Compute the next id from the live `prev` so duplicate ids can't appear + // even if a stale closure ever runs this path. + setRows((prev) => { + const nextStartId = prev.length + 1; + if (nextStartId > 200) { + setHasMore(false); + return prev; + } + return [...prev, ...generateSampleData(nextStartId, 15)]; + }); } catch (error) { console.error("Failed to load more data:", error); } finally { setLoading(false); + loadingRef.current = false; } - }, [loading, hasMore, rows.length]); + }, [hasMore]); return (
    From 16270e801b4ffc7e8fe10af042bc8f59b4b92e76 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 16 May 2026 08:33:48 -0500 Subject: [PATCH 9/9] resize fix --- packages/core/src/utils/headerCell/styling.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/headerCell/styling.ts b/packages/core/src/utils/headerCell/styling.ts index 3beddc721..501ecfd4b 100644 --- a/packages/core/src/utils/headerCell/styling.ts +++ b/packages/core/src/utils/headerCell/styling.ts @@ -152,7 +152,12 @@ export const createHeaderCellElement = ( const filterIcon = createFilterIcon(header, context); const collapseIcon = createCollapseIcon(header, context); - if (reverse) { + // Right-pinned columns resize from their leading (left) edge so the handle + // sits between the main section and the pinned strip. `reverse` (RTL) flips + // the visual direction, so XOR it with the pinned-right flag. + const placeResizeHandleAtStart = reverse !== (context.pinned === "right"); + + if (placeResizeHandleAtStart) { const resizeHandle = createResizeHandle( header, context, @@ -226,7 +231,7 @@ export const createHeaderCellElement = ( if (sortIcon) cellElement.appendChild(sortIcon); } - if (!reverse) { + if (!placeResizeHandleAtStart) { const resizeHandle = createResizeHandle( header, context, @@ -287,22 +292,26 @@ export const refreshHeaderCellIcons = ( if (sortIcon) cellElement.insertBefore(sortIcon, cellElement.firstChild); } else if (!header.headerRenderer && header.align !== "right") { const resizeHandle = cellElement.querySelector(".st-header-resize-handle-container"); + // In right-pinned cells the resize handle is the FIRST child (leading edge), + // so the trailing icons should just be appended rather than inserted before it. + const resizeHandleIsTrailing = + resizeHandle != null && resizeHandle !== cellElement.firstChild; if (sortIcon) { - if (resizeHandle) { + if (resizeHandleIsTrailing) { cellElement.insertBefore(sortIcon, resizeHandle); } else { cellElement.appendChild(sortIcon); } } if (filterIcon) { - if (resizeHandle) { + if (resizeHandleIsTrailing) { cellElement.insertBefore(filterIcon, resizeHandle); } else { cellElement.appendChild(filterIcon); } } if (collapseIcon) { - if (resizeHandle) { + if (resizeHandleIsTrailing) { cellElement.insertBefore(collapseIcon, resizeHandle); } else { cellElement.appendChild(collapseIcon);