From f688f2f10ffaff372e852ff17d2f2b6c1af0bd2f Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sat, 11 Jul 2026 09:51:17 +0930 Subject: [PATCH 01/43] refactor: introduce ViewportMgr owning pane/viewport/canvas DOM construction (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the construction of the 6 panes, 4 viewports, 4 canvases, header/headerrow/ top-panel/footer-row/pre-header containers out of SlickGrid.initialize() into a new internal ViewportMgr class (same file — a separate file would break script-tag consumers since the iife build emits one file per source). The grid keeps aliases to every element, so all logic is unchanged and the DOM is byte-identical, as proven by the dom-shape-characterization spec. Full suite green: 600 tests (599 pass, 1 pending), matching the Phase 0 baseline exactly. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 417 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 293 insertions(+), 124 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 9d329b49..d5bc6099 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -146,6 +146,233 @@ interface RowCaching { cellRenderQueue: any[]; } +/** Options consumed by ViewportMgr when constructing the pane/viewport/canvas DOM. */ +interface ViewportMgrBuildOptions { + createPreHeaderPanel?: boolean; + showPreHeaderPanel?: boolean; + createFooterRow?: boolean; + showFooterRow?: boolean; + showColumnHeader?: boolean; + showTopPanel?: boolean; + showHeaderRow?: boolean; + viewportClass?: string; +} + +/** + * ViewportMgr — owns the construction of the grid's pane/viewport/canvas DOM. + * + * Phase 1 of the frozen rows/columns encapsulation refactor: this class builds the + * exact same 6-pane / 4-viewport / 4-canvas structure the grid has always built + * (characterized by cypress/e2e/dom-shape-characterization.cy.ts) and SlickGrid keeps + * aliases to every element, so all existing logic is unchanged. Later phases move pane + * selection, geometry distribution and scroll synchronization in here. + */ +class ViewportMgr { + // panes + paneHeaderL!: HTMLDivElement; + paneHeaderR!: HTMLDivElement; + paneTopL!: HTMLDivElement; + paneTopR!: HTMLDivElement; + paneBottomL!: HTMLDivElement; + paneBottomR!: HTMLDivElement; + + // pre-header panels (only when createPreHeaderPanel) + preHeaderPanelScroller!: HTMLDivElement; + preHeaderPanel!: HTMLDivElement; + preHeaderPanelSpacer!: HTMLDivElement; + preHeaderPanelScrollerR!: HTMLDivElement; + preHeaderPanelR!: HTMLDivElement; + preHeaderPanelSpacerR!: HTMLDivElement; + + // header scrollers and header column containers + headerScrollerL!: HTMLDivElement; + headerScrollerR!: HTMLDivElement; + headerScroller: HTMLDivElement[] = []; + headerL!: HTMLDivElement; + headerR!: HTMLDivElement; + headers: HTMLDivElement[] = []; + + // header rows + headerRowScrollerL!: HTMLDivElement; + headerRowScrollerR!: HTMLDivElement; + headerRowScroller: HTMLDivElement[] = []; + headerRowSpacerL!: HTMLDivElement; + headerRowSpacerR!: HTMLDivElement; + headerRowL!: HTMLDivElement; + headerRowR!: HTMLDivElement; + headerRows: HTMLDivElement[] = []; + + // top panels + topPanelScrollerL!: HTMLDivElement; + topPanelScrollerR!: HTMLDivElement; + topPanelScrollers: HTMLDivElement[] = []; + topPanelL!: HTMLDivElement; + topPanelR!: HTMLDivElement; + topPanels: HTMLDivElement[] = []; + + // viewports and canvases + viewportTopL!: HTMLDivElement; + viewportTopR!: HTMLDivElement; + viewportBottomL!: HTMLDivElement; + viewportBottomR!: HTMLDivElement; + viewport: HTMLDivElement[] = []; + canvasTopL!: HTMLDivElement; + canvasTopR!: HTMLDivElement; + canvasBottomL!: HTMLDivElement; + canvasBottomR!: HTMLDivElement; + canvas: HTMLDivElement[] = []; + + // footer rows (only when createFooterRow) + footerRowScrollerL!: HTMLDivElement; + footerRowScrollerR!: HTMLDivElement; + footerRowScroller: HTMLDivElement[] = []; + footerRowSpacerL!: HTMLDivElement; + footerRowSpacerR!: HTMLDivElement; + footerRowL!: HTMLDivElement; + footerRowR!: HTMLDivElement; + footerRow: HTMLDivElement[] = []; + + /** + * Builds the pane/viewport/canvas DOM inside the given container. + * The construction order and every class/style is identical to the historical + * inline construction in SlickGrid.initialize(). + */ + buildPanes(container: HTMLElement, o: ViewportMgrBuildOptions) { + // Containers used for scrolling frozen columns and rows + this.paneHeaderL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, container); + this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, container); + this.paneTopL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-left', tabIndex: 0 }, container); + this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }, container); + this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }, container); + this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }, container); + + if (o.createPreHeaderPanel) { + this.preHeaderPanelScroller = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderL); + this.preHeaderPanelScroller.appendChild(document.createElement('div')); + this.preHeaderPanel = Utils.createDomElement('div', null, this.preHeaderPanelScroller); + this.preHeaderPanelSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScroller); + + this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); + this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); + this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); + + if (!o.showPreHeaderPanel) { + Utils.hide(this.preHeaderPanelScroller); + Utils.hide(this.preHeaderPanelScrollerR); + } + } + + // Append the header scroller containers + this.headerScrollerL = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-left' }, this.paneHeaderL); + this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); + + // Cache the header scroller containers + this.headerScroller.push(this.headerScrollerL); + this.headerScroller.push(this.headerScrollerR); + + // Append the columnn containers to the headers + this.headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { left: '-1000px' } }, this.headerScrollerL); + this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); + + // Cache the header columns + this.headers = [this.headerL, this.headerR]; + + this.headerRowScrollerL = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopL); + this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); + + this.headerRowScroller = [this.headerRowScrollerL, this.headerRowScrollerR]; + + this.headerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerL); + this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); + + this.headerRowL = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }, this.headerRowScrollerL); + this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); + + this.headerRows = [this.headerRowL, this.headerRowR]; + + // Append the top panel scroller + this.topPanelScrollerL = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopL); + this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); + + this.topPanelScrollers = [this.topPanelScrollerL, this.topPanelScrollerR]; + + // Append the top panel + this.topPanelL = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerL); + this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); + + this.topPanels = [this.topPanelL, this.topPanelR]; + + if (!o.showColumnHeader) { + this.headerScroller.forEach((el) => { + Utils.hide(el); + }); + } + + if (!o.showTopPanel) { + this.topPanelScrollers.forEach((scroller) => { + Utils.hide(scroller); + }); + } + + if (!o.showHeaderRow) { + this.headerRowScroller.forEach((scroller) => { + Utils.hide(scroller); + }); + } + + // Append the viewport containers + this.viewportTopL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-left', tabIndex: 0 }, this.paneTopL); + this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); + this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); + this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); + + // Cache the viewports + this.viewport = [this.viewportTopL, this.viewportTopR, this.viewportBottomL, this.viewportBottomR]; + if (o.viewportClass) { + this.viewport.forEach((view) => { + view.classList.add(...Utils.classNameToList((o.viewportClass))); + }); + } + + // Append the canvas containers + this.canvasTopL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-left', tabIndex: 0 }, this.viewportTopL); + this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); + this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); + this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); + + // Cache the canvases + this.canvas = [this.canvasTopL, this.canvasTopR, this.canvasBottomL, this.canvasBottomR]; + } + + /** + * Builds the footer-row containers (only called when the createFooterRow option is on). + * Identical construction to the historical inline code, including the R-before-L + * scroller creation order and spacer widths. + */ + buildFooterRows(o: ViewportMgrBuildOptions, canvasWithScrollbarWidth: number) { + this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); + this.footerRowScrollerL = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopL); + + this.footerRowScroller = [this.footerRowScrollerL, this.footerRowScrollerR]; + + this.footerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerL); + Utils.width(this.footerRowSpacerL, canvasWithScrollbarWidth); + this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); + Utils.width(this.footerRowSpacerR, canvasWithScrollbarWidth); + + this.footerRowL = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-left' }, this.footerRowScrollerL); + this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); + + this.footerRow = [this.footerRowL, this.footerRowR]; + + if (!o.showFooterRow) { + this.footerRowScroller.forEach((scroller) => { + Utils.hide(scroller); + }); + } + } +} + export class SlickGrid = Column, O extends BaseGridOption = BaseGridOption> { ////////////////////////////////////////////////////////////////////////////////////////////// // Public API @@ -382,6 +609,7 @@ export class SlickGrid = Column, O e protected dragReplaceEl = new DragExtendHandle(this.uid); protected _focusSink!: HTMLDivElement; protected _focusSink2!: HTMLDivElement; + protected _viewportMgr!: ViewportMgr; protected _groupHeaders: HTMLDivElement[] = []; protected _headerScroller: HTMLDivElement[] = []; protected _headers: HTMLDivElement[] = []; @@ -711,114 +939,65 @@ export class SlickGrid = Column, O e } } - // Containers used for scrolling frozen columns and rows - this._paneHeaderL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, this._container); - this._paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, this._container); - this._paneTopL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-left', tabIndex: 0 }, this._container); - this._paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }, this._container); - this._paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }, this._container); - this._paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }, this._container); - - if (this._options.createPreHeaderPanel) { - this._preHeaderPanelScroller = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this._paneHeaderL); - this._preHeaderPanelScroller.appendChild(document.createElement('div')); - this._preHeaderPanel = Utils.createDomElement('div', null, this._preHeaderPanelScroller); - this._preHeaderPanelSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._preHeaderPanelScroller); - - this._preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this._paneHeaderR); - this._preHeaderPanelR = Utils.createDomElement('div', null, this._preHeaderPanelScrollerR); - this._preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._preHeaderPanelScrollerR); - - if (!this._options.showPreHeaderPanel) { - Utils.hide(this._preHeaderPanelScroller); - Utils.hide(this._preHeaderPanelScrollerR); - } - } - - // Append the header scroller containers - this._headerScrollerL = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-left' }, this._paneHeaderL); - this._headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this._paneHeaderR); - - // Cache the header scroller containers - this._headerScroller.push(this._headerScrollerL); - this._headerScroller.push(this._headerScrollerR); - - // Append the columnn containers to the headers - this._headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { left: '-1000px' } }, this._headerScrollerL); - this._headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this._headerScrollerR); - - // Cache the header columns - this._headers = [this._headerL, this._headerR]; - - this._headerRowScrollerL = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this._paneTopL); - this._headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this._paneTopR); - - this._headerRowScroller = [this._headerRowScrollerL, this._headerRowScrollerR]; - - this._headerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._headerRowScrollerL); - this._headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._headerRowScrollerR); - - this._headerRowL = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }, this._headerRowScrollerL); - this._headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this._headerRowScrollerR); - - this._headerRows = [this._headerRowL, this._headerRowR]; - - // Append the top panel scroller - this._topPanelScrollerL = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this._paneTopL); - this._topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this._paneTopR); - - this._topPanelScrollers = [this._topPanelScrollerL, this._topPanelScrollerR]; - - // Append the top panel - this._topPanelL = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this._topPanelScrollerL); - this._topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this._topPanelScrollerR); - - this._topPanels = [this._topPanelL, this._topPanelR]; - - if (!this._options.showColumnHeader) { - this._headerScroller.forEach((el) => { - Utils.hide(el); - }); - } - - if (!this._options.showTopPanel) { - this._topPanelScrollers.forEach((scroller) => { - Utils.hide(scroller); - }); - } - - if (!this._options.showHeaderRow) { - this._headerRowScroller.forEach((scroller) => { - Utils.hide(scroller); - }); - } - - // Append the viewport containers - this._viewportTopL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-left', tabIndex: 0 }, this._paneTopL); - this._viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this._paneTopR); - this._viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this._paneBottomL); - this._viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this._paneBottomR); - - // Cache the viewports - this._viewport = [this._viewportTopL, this._viewportTopR, this._viewportBottomL, this._viewportBottomR]; - if (this._options.viewportClass) { - this._viewport.forEach((view) => { - view.classList.add(...Utils.classNameToList((this._options.viewportClass))); - }); - } + // Containers used for scrolling frozen columns and rows. + // The pane/viewport/canvas DOM is built by ViewportMgr (identical structure to the + // historical inline construction); the grid keeps aliases to every element so all + // existing logic operates unchanged. + this._viewportMgr = new ViewportMgr(); + this._viewportMgr.buildPanes(this._container, this._options); + + this._paneHeaderL = this._viewportMgr.paneHeaderL; + this._paneHeaderR = this._viewportMgr.paneHeaderR; + this._paneTopL = this._viewportMgr.paneTopL; + this._paneTopR = this._viewportMgr.paneTopR; + this._paneBottomL = this._viewportMgr.paneBottomL; + this._paneBottomR = this._viewportMgr.paneBottomR; + + this._preHeaderPanelScroller = this._viewportMgr.preHeaderPanelScroller; + this._preHeaderPanel = this._viewportMgr.preHeaderPanel; + this._preHeaderPanelSpacer = this._viewportMgr.preHeaderPanelSpacer; + this._preHeaderPanelScrollerR = this._viewportMgr.preHeaderPanelScrollerR; + this._preHeaderPanelR = this._viewportMgr.preHeaderPanelR; + this._preHeaderPanelSpacerR = this._viewportMgr.preHeaderPanelSpacerR; + + this._headerScrollerL = this._viewportMgr.headerScrollerL; + this._headerScrollerR = this._viewportMgr.headerScrollerR; + this._headerScroller = this._viewportMgr.headerScroller; + this._headerL = this._viewportMgr.headerL; + this._headerR = this._viewportMgr.headerR; + this._headers = this._viewportMgr.headers; + + this._headerRowScrollerL = this._viewportMgr.headerRowScrollerL; + this._headerRowScrollerR = this._viewportMgr.headerRowScrollerR; + this._headerRowScroller = this._viewportMgr.headerRowScroller; + this._headerRowSpacerL = this._viewportMgr.headerRowSpacerL; + this._headerRowSpacerR = this._viewportMgr.headerRowSpacerR; + this._headerRowL = this._viewportMgr.headerRowL; + this._headerRowR = this._viewportMgr.headerRowR; + this._headerRows = this._viewportMgr.headerRows; + + this._topPanelScrollerL = this._viewportMgr.topPanelScrollerL; + this._topPanelScrollerR = this._viewportMgr.topPanelScrollerR; + this._topPanelScrollers = this._viewportMgr.topPanelScrollers; + this._topPanelL = this._viewportMgr.topPanelL; + this._topPanelR = this._viewportMgr.topPanelR; + this._topPanels = this._viewportMgr.topPanels; + + this._viewportTopL = this._viewportMgr.viewportTopL; + this._viewportTopR = this._viewportMgr.viewportTopR; + this._viewportBottomL = this._viewportMgr.viewportBottomL; + this._viewportBottomR = this._viewportMgr.viewportBottomR; + this._viewport = this._viewportMgr.viewport; + + this._canvasTopL = this._viewportMgr.canvasTopL; + this._canvasTopR = this._viewportMgr.canvasTopR; + this._canvasBottomL = this._viewportMgr.canvasBottomL; + this._canvasBottomR = this._viewportMgr.canvasBottomR; + this._canvas = this._viewportMgr.canvas; // Default the active viewport to the top left this._activeViewportNode = this._viewportTopL; - // Append the canvas containers - this._canvasTopL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-left', tabIndex: 0 }, this._viewportTopL); - this._canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this._viewportTopR); - this._canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this._viewportBottomL); - this._canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this._viewportBottomR); - - // Cache the canvases - this._canvas = [this._canvasTopL, this._canvasTopR, this._canvasBottomL, this._canvasBottomR]; - this.scrollbarDimensions = this.scrollbarDimensions || this.measureScrollbar(); const canvasWithScrollbarWidth = this.getCanvasWidth() + this.scrollbarDimensions.width; @@ -844,26 +1023,16 @@ export class SlickGrid = Column, O e // footer Row if (this._options.createFooterRow) { - this._footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this._paneTopR); - this._footerRowScrollerL = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this._paneTopL); - - this._footerRowScroller = [this._footerRowScrollerL, this._footerRowScrollerR]; - - this._footerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._footerRowScrollerL); - Utils.width(this._footerRowSpacerL, canvasWithScrollbarWidth); - this._footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._footerRowScrollerR); - Utils.width(this._footerRowSpacerR, canvasWithScrollbarWidth); - - this._footerRowL = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-left' }, this._footerRowScrollerL); - this._footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this._footerRowScrollerR); - - this._footerRow = [this._footerRowL, this._footerRowR]; - - if (!this._options.showFooterRow) { - this._footerRowScroller.forEach((scroller) => { - Utils.hide(scroller); - }); - } + this._viewportMgr.buildFooterRows(this._options, canvasWithScrollbarWidth); + + this._footerRowScrollerL = this._viewportMgr.footerRowScrollerL; + this._footerRowScrollerR = this._viewportMgr.footerRowScrollerR; + this._footerRowScroller = this._viewportMgr.footerRowScroller; + this._footerRowSpacerL = this._viewportMgr.footerRowSpacerL; + this._footerRowSpacerR = this._viewportMgr.footerRowSpacerR; + this._footerRowL = this._viewportMgr.footerRowL; + this._footerRowR = this._viewportMgr.footerRowR; + this._footerRow = this._viewportMgr.footerRow; } this._focusSink2 = this._focusSink.cloneNode(true) as HTMLDivElement; From 930cac3254d7b11921ecf49d441c6e610aabdfdb Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sat, 11 Jul 2026 10:16:13 +0930 Subject: [PATCH 02/43] refactor: absorb pane selection, scroll-container selection, visibility and width distribution into ViewportMgr (Phase 2, milestones 1-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewportMgr now owns a freeze-state snapshot (pushed by setFrozenOptions) and: - paneCellIndex() — the (col,row)->pane index math from _getContainerElement - applyPaneFrozenClasses / applyPaneVisibility / applyOverflow - selectScrollContainers() — the setScroller X/Y-owner + follower selection - applyCanvasWidths() — the pane/viewport/canvas/header width distribution from updateCanvasWidth (width computations stay in the grid) The grid methods are now thin delegates. Behaviour identical: full Cypress suite green (600 tests, 599 pass / 1 pending), matching the Phase 0 baseline. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 437 +++++++++++++++++++++++++++++----------------- 1 file changed, 281 insertions(+), 156 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index d5bc6099..f43aa225 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -146,6 +146,30 @@ interface RowCaching { cellRenderQueue: any[]; } +/** Snapshot of the grid's freeze configuration, pushed into ViewportMgr by setFrozenOptions(). */ +interface ViewportFreezeState { + frozenColumnIdx: number; + hasFrozenRows: boolean; + actualFrozenRow: number; + frozenBottom: boolean; +} + +/** Geometry inputs for ViewportMgr.applyCanvasWidths — computed by the grid, distributed by the manager. */ +interface CanvasWidthsGeometry { + widthChanged: boolean; + canvasWidth: number; + canvasWidthL: number; + canvasWidthR: number; + headersWidthL: number; + headersWidthR: number; + viewportW: number; + viewportHasVScroll: boolean; + scrollbarWidth: number; + createFooterRow?: boolean; + createPreHeaderPanel?: boolean; + preHeaderPanelWidth?: number | string; +} + /** Options consumed by ViewportMgr when constructing the pane/viewport/canvas DOM. */ interface ViewportMgrBuildOptions { createPreHeaderPanel?: boolean; @@ -371,6 +395,228 @@ class ViewportMgr { }); } } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // Freeze state and pane selection (Phase 2 of the encapsulation refactor) + ////////////////////////////////////////////////////////////////////////////////////////////// + + protected freeze: ViewportFreezeState = { frozenColumnIdx: -1, hasFrozenRows: false, actualFrozenRow: -1, frozenBottom: false }; + + /** Receives the grid's freeze configuration; called by SlickGrid.setFrozenOptions(). */ + updateFreezeState(f: ViewportFreezeState) { + this.freeze = { ...f }; + } + + /** Returns a boolean indicating whether the grid is configured with frozen columns. */ + hasFrozenColumns() { + return this.freeze.frozenColumnIdx > -1; + } + + /** + * Index of the pane owning cell (colIdx, rowIdx) in the 4-slot + * [TopL, TopR, BottomL, BottomR] element arrays. + */ + paneCellIndex(colIdx: number, rowIdx: number): number { + const isBottomSide = this.freeze.hasFrozenRows && rowIdx >= this.freeze.actualFrozenRow + (this.freeze.frozenBottom ? 0 : 1); + const isRightSide = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; + return (isBottomSide ? 2 : 0) + (isRightSide ? 1 : 0); + } + + /** add/remove frozen class to left headers/footer when defined */ + applyPaneFrozenClasses(): void { + const classAction = this.hasFrozenColumns() ? 'add' : 'remove'; + for (const elm of [this.paneHeaderL, this.paneTopL, this.paneBottomL]) { + elm.classList[classAction]('frozen'); + } + } + + /** Shows/hides the right and bottom panes according to the freeze configuration. */ + applyPaneVisibility() { + if (this.hasFrozenColumns()) { + Utils.show(this.paneHeaderR); + Utils.show(this.paneTopR); + + if (this.freeze.hasFrozenRows) { + Utils.show(this.paneBottomL); + Utils.show(this.paneBottomR); + } else { + Utils.hide(this.paneBottomR); + Utils.hide(this.paneBottomL); + } + } else { + Utils.hide(this.paneHeaderR); + Utils.hide(this.paneTopR); + Utils.hide(this.paneBottomR); + + if (this.freeze.hasFrozenRows) { + Utils.show(this.paneBottomL); + } else { + Utils.hide(this.paneBottomR); + Utils.hide(this.paneBottomL); + } + } + } + + /** + * Sets the CSS overflowX and overflowY styles for all four viewport elements + * (top–left, top–right, bottom–left, bottom–right) based on the freeze configuration + * and options such as alwaysAllowHorizontalScroll and alwaysShowVerticalScroll. + * If a viewportClass is specified in options, the class is added to each viewport. + */ + applyOverflow(o: { alwaysAllowHorizontalScroll?: boolean; alwaysShowVerticalScroll?: boolean; viewportClass?: string; }) { + const hasFrozenRows = this.freeze.hasFrozenRows; + this.viewportTopL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); + this.viewportTopL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); + + this.viewportTopR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); + this.viewportTopR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'scroll' : 'auto') : (hasFrozenRows ? 'scroll' : 'auto')); + + this.viewportBottomL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); + this.viewportBottomL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); + + this.viewportBottomR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); + this.viewportBottomR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'auto' : 'auto') : (hasFrozenRows ? 'auto' : 'auto')); + + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + this.viewportTopL.classList.add(...viewportClassList); + this.viewportTopR.classList.add(...viewportClassList); + this.viewportBottomL.classList.add(...viewportClassList); + this.viewportBottomR.classList.add(...viewportClassList); + } + } + + /** + * Picks which viewport owns the X and Y scrollbars and which header/header-row/footer-row + * scrollers follow horizontal scrolling, according to the freeze configuration. + * The horizontal scrollbar must sit at the physical bottom of the grid, which is why + * frozenBottom splits X and Y ownership. + */ + /** + * Distributes computed canvas/header widths onto the pane, viewport, canvas, header, + * header-row and footer-row elements. Transcribed from the historical middle section + * of SlickGrid.updateCanvasWidth(); the width computations stay in the grid. + */ + applyCanvasWidths(g: CanvasWidthsGeometry) { + if (g.widthChanged || this.hasFrozenColumns() || this.freeze.hasFrozenRows) { + Utils.width(this.canvasTopL, g.canvasWidthL); + + Utils.width(this.headerL, g.headersWidthL); + Utils.width(this.headerR, g.headersWidthR); + + if (this.hasFrozenColumns()) { + Utils.width(this.canvasTopR, g.canvasWidthR); + + Utils.width(this.paneHeaderL, g.canvasWidthL); + Utils.setStyleSize(this.paneHeaderR, 'left', g.canvasWidthL); + Utils.setStyleSize(this.paneHeaderR, 'width', g.viewportW - g.canvasWidthL); + + Utils.width(this.paneTopL, g.canvasWidthL); + Utils.setStyleSize(this.paneTopR, 'left', g.canvasWidthL); + Utils.width(this.paneTopR, g.viewportW - g.canvasWidthL); + + Utils.width(this.headerRowScrollerL, g.canvasWidthL); + Utils.width(this.headerRowScrollerR, g.viewportW - g.canvasWidthL); + + Utils.width(this.headerRowL, g.canvasWidthL); + Utils.width(this.headerRowR, g.canvasWidthR); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerL, g.canvasWidthL); + Utils.width(this.footerRowScrollerR, g.viewportW - g.canvasWidthL); + + Utils.width(this.footerRowL, g.canvasWidthL); + Utils.width(this.footerRowR, g.canvasWidthR); + } + if (g.createPreHeaderPanel) { + Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); + } + Utils.width(this.viewportTopL, g.canvasWidthL); + Utils.width(this.viewportTopR, g.viewportW - g.canvasWidthL); + + if (this.freeze.hasFrozenRows) { + Utils.width(this.paneBottomL, g.canvasWidthL); + Utils.setStyleSize(this.paneBottomR, 'left', g.canvasWidthL); + + Utils.width(this.viewportBottomL, g.canvasWidthL); + Utils.width(this.viewportBottomR, g.viewportW - g.canvasWidthL); + + Utils.width(this.canvasBottomL, g.canvasWidthL); + Utils.width(this.canvasBottomR, g.canvasWidthR); + } + } else { + Utils.width(this.paneHeaderL, '100%'); + Utils.width(this.paneTopL, '100%'); + Utils.width(this.headerRowScrollerL, '100%'); + Utils.width(this.headerRowL, g.canvasWidth); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerL, '100%'); + Utils.width(this.footerRowL, g.canvasWidth); + } + + if (g.createPreHeaderPanel) { + Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); + } + Utils.width(this.viewportTopL, '100%'); + + if (this.freeze.hasFrozenRows) { + Utils.width(this.viewportBottomL, '100%'); + Utils.width(this.canvasBottomL, g.canvasWidthL); + } + } + } + + Utils.width(this.headerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + Utils.width(this.headerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + + if (g.createFooterRow) { + Utils.width(this.footerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + Utils.width(this.footerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + } + } + + selectScrollContainers(): { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; } { + let x: HTMLDivElement; + let y: HTMLDivElement; + let header: HTMLDivElement; + let headerRow: HTMLDivElement; + let footerRow: HTMLDivElement; + + if (this.hasFrozenColumns()) { + header = this.headerScrollerR; + headerRow = this.headerRowScrollerR; + footerRow = this.footerRowScrollerR; + + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + x = this.viewportBottomR; + y = this.viewportTopR; + } else { + x = y = this.viewportBottomR; + } + } else { + x = y = this.viewportTopR; + } + } else { + header = this.headerScrollerL; + headerRow = this.headerRowScrollerL; + footerRow = this.footerRowScrollerL; + + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + x = this.viewportBottomL; + y = this.viewportTopL; + } else { + x = y = this.viewportBottomL; + } + } else { + x = y = this.viewportTopL; + } + } + + return { x, y, header, headerRow, footerRow }; + } } export class SlickGrid = Column, O extends BaseGridOption = BaseGridOption> { @@ -1622,10 +1868,7 @@ export class SlickGrid = Column, O e /** add/remove frozen class to left headers/footer when defined */ protected setPaneFrozenClasses(): void { - const classAction = this.hasFrozenColumns() ? 'add' : 'remove'; - for (const elm of [this._paneHeaderL, this._paneTopL, this._paneBottomL]) { - elm.classList[classAction]('frozen'); - } + this._viewportMgr.applyPaneFrozenClasses(); } ////////////////////////////////////////////////////////////////////// @@ -2556,6 +2799,14 @@ export class SlickGrid = Column, O e } else { this.hasFrozenRows = false; } + + // keep the ViewportMgr's freeze snapshot in sync with the grid + this._viewportMgr.updateFreezeState({ + frozenColumnIdx: this._options.frozenColumn!, + hasFrozenRows: this.hasFrozenRows, + actualFrozenRow: this.actualFrozenRow, + frozenBottom: !!this._options.frozenBottom, + }); } ////////////////////////////////////////////////////////////////////////////////////////////// @@ -5029,86 +5280,28 @@ export class SlickGrid = Column, O e const widthChanged = this.canvasWidth !== oldCanvasWidth || this.canvasWidthL !== oldCanvasWidthL || this.canvasWidthR !== oldCanvasWidthR; + // recompute the header width split only when the pane widths will be redistributed + // (preserves the historical conditional side effect on headersWidthL/R) if (widthChanged || this.hasFrozenColumns() || this.hasFrozenRows) { - Utils.width(this._canvasTopL, this.canvasWidthL); - this.getHeadersWidth(); - - Utils.width(this._headerL, this.headersWidthL); - Utils.width(this._headerR, this.headersWidthR); - - if (this.hasFrozenColumns()) { - Utils.width(this._canvasTopR, this.canvasWidthR); - - Utils.width(this._paneHeaderL, this.canvasWidthL); - Utils.setStyleSize(this._paneHeaderR, 'left', this.canvasWidthL); - Utils.setStyleSize(this._paneHeaderR, 'width', this.viewportW - this.canvasWidthL); - - Utils.width(this._paneTopL, this.canvasWidthL); - Utils.setStyleSize(this._paneTopR, 'left', this.canvasWidthL); - Utils.width(this._paneTopR, this.viewportW - this.canvasWidthL); - - Utils.width(this._headerRowScrollerL, this.canvasWidthL); - Utils.width(this._headerRowScrollerR, this.viewportW - this.canvasWidthL); - - Utils.width(this._headerRowL, this.canvasWidthL); - Utils.width(this._headerRowR, this.canvasWidthR); - - if (this._options.createFooterRow) { - Utils.width(this._footerRowScrollerL, this.canvasWidthL); - Utils.width(this._footerRowScrollerR, this.viewportW - this.canvasWidthL); - - Utils.width(this._footerRowL, this.canvasWidthL); - Utils.width(this._footerRowR, this.canvasWidthR); - } - if (this._options.createPreHeaderPanel) { - Utils.width(this._preHeaderPanel, this._options.preHeaderPanelWidth ?? this.canvasWidth); - } - Utils.width(this._viewportTopL, this.canvasWidthL); - Utils.width(this._viewportTopR, this.viewportW - this.canvasWidthL); - - if (this.hasFrozenRows) { - Utils.width(this._paneBottomL, this.canvasWidthL); - Utils.setStyleSize(this._paneBottomR, 'left', this.canvasWidthL); - - Utils.width(this._viewportBottomL, this.canvasWidthL); - Utils.width(this._viewportBottomR, this.viewportW - this.canvasWidthL); - - Utils.width(this._canvasBottomL, this.canvasWidthL); - Utils.width(this._canvasBottomR, this.canvasWidthR); - } - } else { - Utils.width(this._paneHeaderL, '100%'); - Utils.width(this._paneTopL, '100%'); - Utils.width(this._headerRowScrollerL, '100%'); - Utils.width(this._headerRowL, this.canvasWidth); - - if (this._options.createFooterRow) { - Utils.width(this._footerRowScrollerL, '100%'); - Utils.width(this._footerRowL, this.canvasWidth); - } - - if (this._options.createPreHeaderPanel) { - Utils.width(this._preHeaderPanel, this._options.preHeaderPanelWidth ?? this.canvasWidth); - } - Utils.width(this._viewportTopL, '100%'); - - if (this.hasFrozenRows) { - Utils.width(this._viewportBottomL, '100%'); - Utils.width(this._canvasBottomL, this.canvasWidthL); - } - } } - this.viewportHasHScroll = (this.canvasWidth >= this.viewportW - (this.scrollbarDimensions?.width ?? 0)); - - Utils.width(this._headerRowSpacerL, this.canvasWidth + (this.viewportHasVScroll ? (this.scrollbarDimensions?.width ?? 0) : 0)); - Utils.width(this._headerRowSpacerR, this.canvasWidth + (this.viewportHasVScroll ? (this.scrollbarDimensions?.width ?? 0) : 0)); + this._viewportMgr.applyCanvasWidths({ + widthChanged, + canvasWidth: this.canvasWidth, + canvasWidthL: this.canvasWidthL, + canvasWidthR: this.canvasWidthR, + headersWidthL: this.headersWidthL, + headersWidthR: this.headersWidthR, + viewportW: this.viewportW, + viewportHasVScroll: this.viewportHasVScroll, + scrollbarWidth: this.scrollbarDimensions?.width ?? 0, + createFooterRow: this._options.createFooterRow, + createPreHeaderPanel: this._options.createPreHeaderPanel, + preHeaderPanelWidth: this._options.preHeaderPanelWidth, + }); - if (this._options.createFooterRow) { - Utils.width(this._footerRowSpacerL, this.canvasWidth + (this.viewportHasVScroll ? (this.scrollbarDimensions?.width ?? 0) : 0)); - Utils.width(this._footerRowSpacerR, this.canvasWidth + (this.viewportHasVScroll ? (this.scrollbarDimensions?.width ?? 0) : 0)); - } + this.viewportHasHScroll = (this.canvasWidth >= this.viewportW - (this.scrollbarDimensions?.width ?? 0)); if (widthChanged || forceColumnWidthsUpdate) { this.applyColumnWidths(); @@ -5141,29 +5334,7 @@ export class SlickGrid = Column, O e * otherwise, conditionally shows or hides the bottom panes depending on whether frozen rows exist. */ protected setPaneVisibility() { - if (this.hasFrozenColumns()) { - Utils.show(this._paneHeaderR); - Utils.show(this._paneTopR); - - if (this.hasFrozenRows) { - Utils.show(this._paneBottomL); - Utils.show(this._paneBottomR); - } else { - Utils.hide(this._paneBottomR); - Utils.hide(this._paneBottomL); - } - } else { - Utils.hide(this._paneHeaderR); - Utils.hide(this._paneTopR); - Utils.hide(this._paneBottomR); - - if (this.hasFrozenRows) { - Utils.show(this._paneBottomL); - } else { - Utils.hide(this._paneBottomR); - Utils.hide(this._paneBottomL); - } - } + this._viewportMgr.applyPaneVisibility(); } /** @@ -5173,25 +5344,7 @@ export class SlickGrid = Column, O e * If a viewportClass is specified in options, the class is added to each viewport. */ protected setOverflow() { - this._viewportTopL.style.overflowX = (this.hasFrozenColumns()) ? (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); - this._viewportTopL.style.overflowY = (!this.hasFrozenColumns() && this._options.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (this.hasFrozenRows ? 'hidden' : 'hidden') : (this.hasFrozenRows ? 'scroll' : 'auto')); - - this._viewportTopR.style.overflowX = (this.hasFrozenColumns()) ? (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); - this._viewportTopR.style.overflowY = this._options.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (this.hasFrozenRows ? 'scroll' : 'auto') : (this.hasFrozenRows ? 'scroll' : 'auto')); - - this._viewportBottomL.style.overflowX = (this.hasFrozenColumns()) ? (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); - this._viewportBottomL.style.overflowY = (!this.hasFrozenColumns() && this._options.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (this.hasFrozenRows ? 'hidden' : 'hidden') : (this.hasFrozenRows ? 'scroll' : 'auto')); - - this._viewportBottomR.style.overflowX = (this.hasFrozenColumns()) ? (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (this.hasFrozenRows && !this._options.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); - this._viewportBottomR.style.overflowY = this._options.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (this.hasFrozenRows ? 'auto' : 'auto') : (this.hasFrozenRows ? 'auto' : 'auto')); - - if (this._options.viewportClass) { - const viewportClassList = Utils.classNameToList(this._options.viewportClass); - this._viewportTopL.classList.add(...viewportClassList); - this._viewportTopR.classList.add(...viewportClassList); - this._viewportBottomL.classList.add(...viewportClassList); - this._viewportBottomR.classList.add(...viewportClassList); - } + this._viewportMgr.applyOverflow(this._options); } /** @@ -6768,37 +6921,12 @@ export class SlickGrid = Column, O e * The selection depends on whether the grid has frozen columns and/or frozen rows and whether frozenBottom is set. */ protected setScroller() { - if (this.hasFrozenColumns()) { - this._headerScrollContainer = this._headerScrollerR; - this._headerRowScrollContainer = this._headerRowScrollerR; - this._footerRowScrollContainer = this._footerRowScrollerR; - - if (this.hasFrozenRows) { - if (this._options.frozenBottom) { - this._viewportScrollContainerX = this._viewportBottomR; - this._viewportScrollContainerY = this._viewportTopR; - } else { - this._viewportScrollContainerX = this._viewportScrollContainerY = this._viewportBottomR; - } - } else { - this._viewportScrollContainerX = this._viewportScrollContainerY = this._viewportTopR; - } - } else { - this._headerScrollContainer = this._headerScrollerL; - this._headerRowScrollContainer = this._headerRowScrollerL; - this._footerRowScrollContainer = this._footerRowScrollerL; - - if (this.hasFrozenRows) { - if (this._options.frozenBottom) { - this._viewportScrollContainerX = this._viewportBottomL; - this._viewportScrollContainerY = this._viewportTopL; - } else { - this._viewportScrollContainerX = this._viewportScrollContainerY = this._viewportBottomL; - } - } else { - this._viewportScrollContainerX = this._viewportScrollContainerY = this._viewportTopL; - } - } + const containers = this._viewportMgr.selectScrollContainers(); + this._viewportScrollContainerX = containers.x; + this._viewportScrollContainerY = containers.y; + this._headerScrollContainer = containers.header; + this._headerRowScrollContainer = containers.headerRow; + this._footerRowScrollContainer = containers.footerRow; } /** @@ -7808,10 +7936,7 @@ export class SlickGrid = Column, O e const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const isBottomSide = this.hasFrozenRows && rowIndex >= this.actualFrozenRow + (this._options.frozenBottom ? 0 : 1); - const isRightSide = this.hasFrozenColumns() && idx > this._options.frozenColumn!; - - return targetContainers[(isBottomSide ? 2 : 0) + (isRightSide ? 1 : 0)]; + return targetContainers[this._viewportMgr.paneCellIndex(idx, rowIndex)]; } /** From 0ec300d83e442df211b830c49ff49ed015900171 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sat, 11 Jul 2026 11:46:17 +0930 Subject: [PATCH 03/43] refactor: absorb resizeCanvas pane-height computation and distribution into ViewportMgr (Phase 2, milestone 3) ViewportMgr.applyPaneHeights() now computes paneTopH/paneBottomH/viewportTopH from the freeze configuration and sizes every pane/viewport/canvas, returning the heights for the grid's layout pipeline. The container VBox delta is passed as a lazy callback so the autoHeight-only style recalc is not made unconditional. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 253 ++++++++++++++++++++++++++++------------------ 1 file changed, 155 insertions(+), 98 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index f43aa225..d2565d87 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -170,6 +170,25 @@ interface CanvasWidthsGeometry { preHeaderPanelWidth?: number | string; } +/** Geometry inputs for ViewportMgr.applyPaneHeights — computed by the grid, distributed by the manager. */ +interface PaneHeightsGeometry { + viewportH: number; + frozenRowsHeight: number; + scrollbarHeight: number; + topPanelH: number; + headerRowH: number; + footerRowH: number; + /** lazily computed to avoid an unconditional style recalc; only read on the autoHeight+frozen path */ + getContainerVBoxDelta: () => number; + autoHeight?: boolean; + showPreHeaderPanel?: boolean; + preHeaderPanelHeight?: number; + showTopHeaderPanel?: boolean; + topHeaderPanelHeight?: number; + showHeaderRow?: boolean; + headerRowHeight?: number; +} + /** Options consumed by ViewportMgr when constructing the pane/viewport/canvas DOM. */ interface ViewportMgrBuildOptions { createPreHeaderPanel?: boolean; @@ -192,6 +211,9 @@ interface ViewportMgrBuildOptions { * selection, geometry distribution and scroll synchronization in here. */ class ViewportMgr { + /** the grid container, captured by buildPanes */ + protected container!: HTMLElement; + // panes paneHeaderL!: HTMLDivElement; paneHeaderR!: HTMLDivElement; @@ -262,6 +284,8 @@ class ViewportMgr { * inline construction in SlickGrid.initialize(). */ buildPanes(container: HTMLElement, o: ViewportMgrBuildOptions) { + this.container = container; + // Containers used for scrolling frozen columns and rows this.paneHeaderL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, container); this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, container); @@ -576,6 +600,115 @@ class ViewportMgr { } } + /** + * Computes the pane/viewport heights from the freeze configuration and distributes them + * onto the pane, viewport and canvas elements. Transcribed from the historical middle + * section of SlickGrid.resizeCanvas(); returns the computed heights for the grid to keep. + */ + applyPaneHeights(g: PaneHeightsGeometry): { paneTopH: number; paneBottomH: number; viewportTopH: number; viewportBottomH: number; } { + let paneTopH = 0; + let paneBottomH = 0; + let viewportTopH = 0; + const viewportBottomH = 0; + + // Account for Frozen Rows + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + paneTopH = g.viewportH - g.frozenRowsHeight - g.scrollbarHeight; + paneBottomH = g.frozenRowsHeight + g.scrollbarHeight; + } else { + paneTopH = g.frozenRowsHeight; + paneBottomH = g.viewportH - g.frozenRowsHeight; + } + } else { + paneTopH = g.viewportH; + } + + // The top pane includes the top panel and the header row + paneTopH += g.topPanelH + g.headerRowH + g.footerRowH; + + if (this.hasFrozenColumns() && g.autoHeight) { + paneTopH += g.scrollbarHeight; + } + + // The top viewport does not contain the top panel or header row + viewportTopH = paneTopH - g.topPanelH - g.headerRowH - g.footerRowH; + + if (g.autoHeight) { + if (this.hasFrozenColumns()) { + let fullHeight = paneTopH + this.headerScrollerL.offsetHeight; + fullHeight += g.getContainerVBoxDelta(); + if (g.showPreHeaderPanel) { + fullHeight += g.preHeaderPanelHeight!; + } + Utils.height(this.container, fullHeight); + } + + this.paneTopL.style.position = 'relative'; + } + + let topHeightOffset = Utils.height(this.paneHeaderL); + if (topHeightOffset) { + topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); + } else { + topHeightOffset = (g.showHeaderRow ? g.headerRowHeight! : 0) + (g.showPreHeaderPanel ? g.preHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this.paneTopL, 'top', topHeightOffset || topHeightOffset); + Utils.height(this.paneTopL, paneTopH); + + const paneBottomTop = this.paneTopL.offsetTop + paneTopH; + + if (!g.autoHeight) { + Utils.height(this.viewportTopL, viewportTopH); + } + + if (this.hasFrozenColumns()) { + let topHeightOffset = Utils.height(this.paneHeaderL); + if (topHeightOffset) { + topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this.paneTopR, 'top', topHeightOffset as number); + Utils.height(this.paneTopR, paneTopH); + Utils.height(this.viewportTopR, viewportTopH); + + if (this.freeze.hasFrozenRows) { + Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); + Utils.height(this.paneBottomL, paneBottomH); + Utils.setStyleSize(this.paneBottomR, 'top', paneBottomTop); + Utils.height(this.paneBottomR, paneBottomH); + Utils.height(this.viewportBottomR, paneBottomH); + } + } else { + if (this.freeze.hasFrozenRows) { + Utils.width(this.paneBottomL, '100%'); + Utils.height(this.paneBottomL, paneBottomH); + Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); + } + } + + if (this.freeze.hasFrozenRows) { + Utils.height(this.viewportBottomL, paneBottomH); + + if (this.freeze.frozenBottom) { + Utils.height(this.canvasBottomL, g.frozenRowsHeight); + + if (this.hasFrozenColumns()) { + Utils.height(this.canvasBottomR, g.frozenRowsHeight); + } + } else { + Utils.height(this.canvasTopL, g.frozenRowsHeight); + + if (this.hasFrozenColumns()) { + Utils.height(this.canvasTopR, g.frozenRowsHeight); + } + } + } else { + Utils.height(this.viewportTopR, viewportTopH); + } + + return { paneTopH, paneBottomH, viewportTopH, viewportBottomH }; + } + selectScrollContainers(): { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; } { let x: HTMLDivElement; let y: HTMLDivElement; @@ -6160,108 +6293,32 @@ export class SlickGrid = Column, O e */ resizeCanvas() { if (!this.initialized) { return; } - this.paneTopH = 0; - this.paneBottomH = 0; - this.viewportTopH = 0; - this.viewportBottomH = 0; this.getViewportWidth(); this.getViewportHeight(); - // Account for Frozen Rows - if (this.hasFrozenRows) { - if (this._options.frozenBottom) { - this.paneTopH = this.viewportH - this.frozenRowsHeight - (this.scrollbarDimensions?.height ?? 0); - this.paneBottomH = this.frozenRowsHeight + (this.scrollbarDimensions?.height ?? 0); - } else { - this.paneTopH = this.frozenRowsHeight; - this.paneBottomH = this.viewportH - this.frozenRowsHeight; - } - } else { - this.paneTopH = this.viewportH; - } - - // The top pane includes the top panel and the header row - this.paneTopH += this.topPanelH + this.headerRowH + this.footerRowH; - - if (this.hasFrozenColumns() && this._options.autoHeight) { - this.paneTopH += (this.scrollbarDimensions?.height ?? 0); - } - - // The top viewport does not contain the top panel or header row - this.viewportTopH = this.paneTopH - this.topPanelH - this.headerRowH - this.footerRowH; - - if (this._options.autoHeight) { - if (this.hasFrozenColumns()) { - let fullHeight = this.paneTopH + this._headerScrollerL.offsetHeight; - fullHeight += this.getVBoxDelta(this._container); - if (this._options.showPreHeaderPanel) { - fullHeight += this._options.preHeaderPanelHeight!; - } - Utils.height(this._container, fullHeight); - } - - this._paneTopL.style.position = 'relative'; - } - - let topHeightOffset = Utils.height(this._paneHeaderL); - if (topHeightOffset) { - topHeightOffset += (this._options.showTopHeaderPanel ? this._options.topHeaderPanelHeight! : 0); - } else { - topHeightOffset = (this._options.showHeaderRow ? this._options.headerRowHeight! : 0) + (this._options.showPreHeaderPanel ? this._options.preHeaderPanelHeight! : 0); - } - Utils.setStyleSize(this._paneTopL, 'top', topHeightOffset || topHeightOffset); - Utils.height(this._paneTopL, this.paneTopH); - - const paneBottomTop = this._paneTopL.offsetTop + this.paneTopH; - - if (!this._options.autoHeight) { - Utils.height(this._viewportTopL, this.viewportTopH); - } - - if (this.hasFrozenColumns()) { - let topHeightOffset = Utils.height(this._paneHeaderL); - if (topHeightOffset) { - topHeightOffset += (this._options.showTopHeaderPanel ? this._options.topHeaderPanelHeight! : 0); - } - Utils.setStyleSize(this._paneTopR, 'top', topHeightOffset as number); - Utils.height(this._paneTopR, this.paneTopH); - Utils.height(this._viewportTopR, this.viewportTopH); - - if (this.hasFrozenRows) { - Utils.setStyleSize(this._paneBottomL, 'top', paneBottomTop); - Utils.height(this._paneBottomL, this.paneBottomH); - Utils.setStyleSize(this._paneBottomR, 'top', paneBottomTop); - Utils.height(this._paneBottomR, this.paneBottomH); - Utils.height(this._viewportBottomR, this.paneBottomH); - } - } else { - if (this.hasFrozenRows) { - Utils.width(this._paneBottomL, '100%'); - Utils.height(this._paneBottomL, this.paneBottomH); - Utils.setStyleSize(this._paneBottomL, 'top', paneBottomTop); - } - } - - if (this.hasFrozenRows) { - Utils.height(this._viewportBottomL, this.paneBottomH); - - if (this._options.frozenBottom) { - Utils.height(this._canvasBottomL, this.frozenRowsHeight); - - if (this.hasFrozenColumns()) { - Utils.height(this._canvasBottomR, this.frozenRowsHeight); - } - } else { - Utils.height(this._canvasTopL, this.frozenRowsHeight); - - if (this.hasFrozenColumns()) { - Utils.height(this._canvasTopR, this.frozenRowsHeight); - } - } - } else { - Utils.height(this._viewportTopR, this.viewportTopH); - } + // compute and distribute the pane/viewport/canvas heights, keeping the results + // on the grid for the rest of the layout pipeline + const heights = this._viewportMgr.applyPaneHeights({ + viewportH: this.viewportH, + frozenRowsHeight: this.frozenRowsHeight, + scrollbarHeight: this.scrollbarDimensions?.height ?? 0, + topPanelH: this.topPanelH, + headerRowH: this.headerRowH, + footerRowH: this.footerRowH, + getContainerVBoxDelta: () => this.getVBoxDelta(this._container), + autoHeight: this._options.autoHeight, + showPreHeaderPanel: this._options.showPreHeaderPanel, + preHeaderPanelHeight: this._options.preHeaderPanelHeight, + showTopHeaderPanel: this._options.showTopHeaderPanel, + topHeaderPanelHeight: this._options.topHeaderPanelHeight, + showHeaderRow: this._options.showHeaderRow, + headerRowHeight: this._options.headerRowHeight, + }); + this.paneTopH = heights.paneTopH; + this.paneBottomH = heights.paneBottomH; + this.viewportTopH = heights.viewportTopH; + this.viewportBottomH = heights.viewportBottomH; if (!this.scrollbarDimensions || !this.scrollbarDimensions.width) { this.scrollbarDimensions = this.measureScrollbar(); From 50f31ce280f5e6e6ea43db70a206daf04975955b Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sat, 11 Jul 2026 20:50:18 +0930 Subject: [PATCH 04/43] refactor: absorb row-to-canvas routing and scroll synchronization into ViewportMgr (Phase 2, milestones 4-5) - attachRow(): the 4-way canvas attachment from renderRows, returning the rowNode array. Preserves the historical render-side band threshold (rowIdx >= actualFrozenRow without the +1 non-frozenBottom adjustment used by paneCellIndex) verbatim. - syncHorizontalScroll(): the scrollToX follower fan-out (scroll owner, header, top panel, footer row, pre-header, frozen header-row/viewport followers); the grid-owned top-header panel stays in scrollToX. - syncVerticalFollowers(): the frozen-left viewport scrollTop mirroring from _handleScroll. - selectScrollContainers() now caches its result as the authoritative owner set. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 143 ++++++++++++++++++++++++++++------------------ 1 file changed, 87 insertions(+), 56 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index d2565d87..680fb906 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -709,6 +709,84 @@ class ViewportMgr { return { paneTopH, paneBottomH, viewportTopH, viewportBottomH }; } + /** the current scroll-owner/follower set, refreshed by selectScrollContainers() */ + protected scrollContainers!: { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; }; + + /** + * Attaches one rendered row (left fragment + right fragment when columns are frozen) + * to the canvases owned by the row's band, returning the rowNode array for the grid's + * rowsCache (or null if the expected fragments are missing). + * + * NOTE: the band threshold here is `rowIdx >= actualFrozenRow` — deliberately WITHOUT + * the `+ (frozenBottom ? 0 : 1)` adjustment used by paneCellIndex(); the historical + * render-side and cell-lookup-side splits differ by one row in the non-frozenBottom + * case, and that asymmetry is preserved verbatim. + */ + attachRow(rowIdx: number, left: HTMLElement | null, right: HTMLElement | null): HTMLElement[] | null { + if ((this.freeze.hasFrozenRows) && (rowIdx >= this.freeze.actualFrozenRow)) { + if (this.hasFrozenColumns()) { + if (left && right) { + this.canvasBottomL.appendChild(left); + this.canvasBottomR.appendChild(right); + return [left, right]; + } + } else if (left) { + this.canvasBottomL.appendChild(left); + return [left]; + } + } else if (this.hasFrozenColumns()) { + if (left && right) { + this.canvasTopL.appendChild(left); + this.canvasTopR.appendChild(right); + return [left, right]; + } + } else if (left) { + this.canvasTopL.appendChild(left); + return [left]; + } + return null; + } + + /** Applies an X scroll position to the scroll-owner viewport and every horizontal follower. */ + syncHorizontalScroll(x: number, o: { createFooterRow?: boolean; createPreHeaderPanel?: boolean; }) { + this.scrollContainers.x.scrollLeft = x; + this.scrollContainers.header.scrollLeft = x; + this.topPanelScrollers[0].scrollLeft = x; + if (o.createFooterRow) { + this.scrollContainers.footerRow.scrollLeft = x; + } + if (o.createPreHeaderPanel) { + if (this.hasFrozenColumns()) { + this.preHeaderPanelScrollerR.scrollLeft = x; + } else { + this.preHeaderPanelScroller.scrollLeft = x; + } + } + + if (this.hasFrozenColumns()) { + if (this.freeze.hasFrozenRows) { + this.viewportTopR.scrollLeft = x; + } + this.headerRowScrollerR.scrollLeft = x; // right header row scrolling with frozen grid + } else { + if (this.freeze.hasFrozenRows) { + this.viewportTopL.scrollLeft = x; + } + this.headerRowScrollerL.scrollLeft = x; // left header row scrolling with regular grid + } + } + + /** Mirrors the Y scroll position onto the frozen-left viewport that follows the scroll owner. */ + syncVerticalFollowers(scrollTop: number) { + if (this.hasFrozenColumns()) { + if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { + this.viewportBottomL.scrollTop = scrollTop; + } else { + this.viewportTopL.scrollTop = scrollTop; + } + } + } + selectScrollContainers(): { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; } { let x: HTMLDivElement; let y: HTMLDivElement; @@ -748,7 +826,8 @@ class ViewportMgr { } } - return { x, y, header, headerRow, footerRow }; + this.scrollContainers = { x, y, header, headerRow, footerRow }; + return this.scrollContainers; } } @@ -6786,29 +6865,10 @@ export class SlickGrid = Column, O e divArrayR.forEach(elm => xRight.appendChild(elm as HTMLElement)); for (let i = 0, ii = rows.length; i < ii; i++) { - if ((this.hasFrozenRows) && (rows[i] >= this.actualFrozenRow)) { - if (this.hasFrozenColumns()) { - if (this.rowsCache?.hasOwnProperty(rows[i]) && x.firstChild && xRight.firstChild) { - this.rowsCache[rows[i]].rowNode = [x.firstChild as HTMLElement, xRight.firstChild as HTMLElement]; - this._canvasBottomL.appendChild(x.firstChild as ChildNode); - this._canvasBottomR.appendChild(xRight.firstChild as ChildNode); - } - } else { - if (this.rowsCache?.hasOwnProperty(rows[i]) && x.firstChild) { - this.rowsCache[rows[i]].rowNode = [x.firstChild as HTMLElement]; - this._canvasBottomL.appendChild(x.firstChild as ChildNode); - } - } - } else if (this.hasFrozenColumns()) { - if (this.rowsCache?.hasOwnProperty(rows[i]) && x.firstChild && xRight.firstChild) { - this.rowsCache[rows[i]].rowNode = [x.firstChild as HTMLElement, xRight.firstChild as HTMLElement]; - this._canvasTopL.appendChild(x.firstChild as ChildNode); - this._canvasTopR.appendChild(xRight.firstChild as ChildNode); - } - } else { - if (this.rowsCache?.hasOwnProperty(rows[i]) && x.firstChild) { - this.rowsCache[rows[i]].rowNode = [x.firstChild as HTMLElement]; - this._canvasTopL.appendChild(x.firstChild as ChildNode); + if (this.rowsCache?.hasOwnProperty(rows[i])) { + const attached = this._viewportMgr.attachRow(rows[i], x.firstChild as HTMLElement | null, xRight.firstChild as HTMLElement | null); + if (attached) { + this.rowsCache[rows[i]].rowNode = attached; } } } @@ -7144,13 +7204,7 @@ export class SlickGrid = Column, O e this._viewportScrollContainerY.scrollTop = this.scrollTop; } - if (this.hasFrozenColumns()) { - if (this.hasFrozenRows && !this._options.frozenBottom) { - this._viewportBottomL.scrollTop = this.scrollTop; - } else { - this._viewportTopL.scrollTop = this.scrollTop; - } - } + this._viewportMgr.syncVerticalFollowers(this.scrollTop); // switch virtual pages if needed if (vScrollDist < this.viewportH) { @@ -8157,34 +8211,11 @@ export class SlickGrid = Column, O e * @param {Number} x */ scrollToX(x: number): void { - this._viewportScrollContainerX.scrollLeft = x; - this._headerScrollContainer.scrollLeft = x; - this._topPanelScrollers[0].scrollLeft = x; - if (this._options.createFooterRow) { - this._footerRowScrollContainer.scrollLeft = x; - } - if (this._options.createPreHeaderPanel) { - if (this.hasFrozenColumns()) { - this._preHeaderPanelScrollerR.scrollLeft = x; - } else { - this._preHeaderPanelScroller.scrollLeft = x; - } - } + this._viewportMgr.syncHorizontalScroll(x, this._options); + if (this._options.createTopHeaderPanel) { this._topHeaderPanelScroller.scrollLeft = x; } - - if (this.hasFrozenColumns()) { - if (this.hasFrozenRows) { - this._viewportTopR.scrollLeft = x; - } - this._headerRowScrollerR.scrollLeft = x; // right header row scrolling with frozen grid - } else { - if (this.hasFrozenRows) { - this._viewportTopL.scrollLeft = x; - } - this._headerRowScrollerL.scrollLeft = x; // left header row scrolling with regular grid - } } /** From bd62185508e898986704c2a50b67525916297ba6 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 08:28:04 +0930 Subject: [PATCH 05/43] fix: null ViewportMgr in destroyAllElements so detached pane DOM is GC-eligible Found by an adversarial transcription audit of the Phase 1-2 refactor: the manager retained references to every pane/viewport/canvas element and the container after destroy(), keeping the detached subtree alive if the app held onto the grid instance. Also restores the original post-destroy failure mode for the delegated methods. Full Cypress suite green (600 tests, 599 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 680fb906..09f60baf 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -1833,6 +1833,9 @@ export class SlickGrid = Column, O e * to null so that they can be garbage collected. */ protected destroyAllElements() { + // drop the ViewportMgr first — it holds references to every pane/viewport/canvas + // element and the container, which would otherwise keep the detached DOM alive + this._viewportMgr = null as any; this._activeCanvasNode = null as any; this._activeViewportNode = null as any; this._boundAncestors = null as any; From 79e764e952173b8c2e10285337377f62a77977a9 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 08:47:59 +0930 Subject: [PATCH 06/43] refactor: absorb frozen-row offset and band-membership predicates into ViewportMgr (Phase 2, milestone 6) - frozenRowOffset(): the getFrozenRowOffset computation (grid method stays as a public delegate), historical commented-out one-liner preserved. - isRowInFrozenBand(): the cleanupRows keep-frozen-rows predicate. - isRowCellCleanupExempt(): the cleanUpCells skip predicate, transcribed verbatim including the long-standing quirk that the second disjunct is not guarded by !frozenBottom (documented at the definition). - isColumnInFrozenBand(): the frozen-column cleanup exemption. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 113 +++++++++++++++++++++++++++++++--------------- 1 file changed, 76 insertions(+), 37 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 09f60baf..87b33ecd 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -446,6 +446,73 @@ class ViewportMgr { return (isBottomSide ? 2 : 0) + (isRightSide ? 1 : 0); } + /** + * Get frozen (pinned) row offset + * + * Returns the vertical pixel offset to apply for frozen rows. + * Depending on whether frozen rows are pinned at the bottom or top and based on grid height, + * it returns either a fixed frozen rows height or a calculated offset. + * + * @param {Number} row - grid row number + */ + frozenRowOffset(row: number, g: { h: number; viewportTopH: number; frozenRowsHeight: number; rowHeight: number; }): number { + // let offset = ( hasFrozenRows ) ? ( this._options.frozenBottom ) ? ( row >= actualFrozenRow ) ? ( h < viewportTopH ) ? ( actualFrozenRow * this._options.rowHeight ) : h : 0 : ( row >= actualFrozenRow ) ? frozenRowsHeight : 0 : 0; // WTF? + let offset = 0; + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + if (row >= this.freeze.actualFrozenRow) { + if (g.h < g.viewportTopH) { + offset = (this.freeze.actualFrozenRow * g.rowHeight); + } else { + offset = g.h; + } + } else { + offset = 0; + } + } + else { + if (row >= this.freeze.actualFrozenRow) { + offset = g.frozenRowsHeight; + } else { + offset = 0; + } + } + } else { + offset = 0; + } + + return offset; + } + + /** + * Whether the row lives in a frozen band and must therefore be kept out of row + * virtualization cleanup (historical cleanupRows predicate). + */ + isRowInFrozenBand(row: number): boolean { + return this.freeze.hasFrozenRows + && ((this.freeze.frozenBottom && row >= this.freeze.actualFrozenRow) // Frozen bottom rows + || (!this.freeze.frozenBottom && row <= this.freeze.actualFrozenRow) // Frozen top rows + ); + } + + /** + * Whether cell-level cleanup must skip the row entirely (historical cleanUpCells + * predicate). NOTE: transcribed verbatim — the second disjunct is NOT guarded by + * !frozenBottom, so for frozenBottom grids every row is exempt; that quirk is + * long-standing upstream behaviour and is deliberately preserved. + */ + isRowCellCleanupExempt(row: number): boolean { + return this.freeze.hasFrozenRows + && ((this.freeze.frozenBottom && row > this.freeze.actualFrozenRow) // Frozen bottom rows + || (row <= this.freeze.actualFrozenRow) // Frozen top rows + ); + } + + /** Whether the column index falls inside the left frozen band. */ + isColumnInFrozenBand(colIdx: number): boolean { + return colIdx <= this.freeze.frozenColumnIdx; + } + /** add/remove frozen class to left headers/footer when defined */ applyPaneFrozenClasses(): void { const classAction = this.hasFrozenColumns() ? 'add' : 'remove'; @@ -6072,11 +6139,7 @@ export class SlickGrid = Column, O e let i = +rowId; let removeFrozenRow = true; - if (this.hasFrozenRows - && ((this._options.frozenBottom && (i as unknown as number) >= this.actualFrozenRow) // Frozen bottom rows - || (!this._options.frozenBottom && (i as unknown as number) <= this.actualFrozenRow) // Frozen top rows - ) - ) { + if (this._viewportMgr.isRowInFrozenBand(i)) { removeFrozenRow = false; } @@ -6632,11 +6695,7 @@ export class SlickGrid = Column, O e */ protected cleanUpCells(range: CellViewportRange, row: number) { // Ignore frozen rows - if (this.hasFrozenRows - && ((this._options.frozenBottom && row > this.actualFrozenRow) // Frozen bottom rows - || (row <= this.actualFrozenRow) // Frozen top rows - ) - ) { + if (this._viewportMgr.isRowCellCleanupExempt(row)) { return; } @@ -6655,7 +6714,7 @@ export class SlickGrid = Column, O e const i = +cellNodeIdx; // Ignore frozen columns - if (i <= this._options.frozenColumn!) { + if (this._viewportMgr.isColumnInFrozenBand(i)) { return; } @@ -6972,32 +7031,12 @@ export class SlickGrid = Column, O e * @param {Number} row - grid row number */ getFrozenRowOffset(row: number) { - // let offset = ( hasFrozenRows ) ? ( this._options.frozenBottom ) ? ( row >= actualFrozenRow ) ? ( h < viewportTopH ) ? ( actualFrozenRow * this._options.rowHeight ) : h : 0 : ( row >= actualFrozenRow ) ? frozenRowsHeight : 0 : 0; // WTF? - let offset = 0; - if (this.hasFrozenRows) { - if (this._options.frozenBottom) { - if (row >= this.actualFrozenRow) { - if (this.h < this.viewportTopH) { - offset = (this.actualFrozenRow * this._options.rowHeight!); - } else { - offset = this.h; - } - } else { - offset = 0; - } - } - else { - if (row >= this.actualFrozenRow) { - offset = this.frozenRowsHeight; - } else { - offset = 0; - } - } - } else { - offset = 0; - } - - return offset; + return this._viewportMgr.frozenRowOffset(row, { + h: this.h, + viewportTopH: this.viewportTopH, + frozenRowsHeight: this.frozenRowsHeight, + rowHeight: this._options.rowHeight!, + }); } //////////////////////////////////////////////////////// From 4d9e875a72a0db4e5da07e2f067ec0cf81dc8982 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 09:15:40 +0930 Subject: [PATCH 07/43] refactor: route column-side selection through ViewportMgr helpers (Phase 2, milestone 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ViewportMgr helpers absorb the recurring 'which side of the freeze' patterns: - isColumnRightOfFreeze() — the hasFrozenColumns() && idx > frozenColumn test - sideLocalColumnIdx() — the right-side child-index rebase (idx - frozenColumn - 1) - sideForColumn() — pick the L or R element of a pair for a column Rewritten call sites: getHeader, getHeaderColumn, getHeaderRow, getFooterRow, getHeaderRowColumn, getFooterRowColumn, getHeadersWidth, getCanvasWidth, appendRowHtml (rowDivR clone guard + cell routing + always-render frozen band), appendCellHtml (frozen cell class), cleanUpAndRenderCells (node reattachment). The compound 'hasFrozenColumns() && idx <= frozenColumn' collapses to isColumnInFrozenBand() alone — equivalent for non-negative indices since the unfrozen sentinel is -1. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 82 +++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 87b33ecd..1c896fc9 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -513,6 +513,21 @@ class ViewportMgr { return colIdx <= this.freeze.frozenColumnIdx; } + /** True when frozen columns are on AND the column index falls right of the freeze. */ + isColumnRightOfFreeze(colIdx: number): boolean { + return this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; + } + + /** Column index local to its side container (right-side children are indexed after the freeze). */ + sideLocalColumnIdx(colIdx: number): number { + return this.isColumnRightOfFreeze(colIdx) ? colIdx - this.freeze.frozenColumnIdx - 1 : colIdx; + } + + /** Pick the left or right element of an [L, R] pair for the given column. */ + sideForColumn(colIdx: number, left: T, right: T): T { + return this.isColumnRightOfFreeze(colIdx) ? right : left; + } + /** add/remove frozen class to left headers/footer when defined */ applyPaneFrozenClasses(): void { const classAction = this.hasFrozenColumns() ? 'add' : 'remove'; @@ -2216,10 +2231,10 @@ export class SlickGrid = Column, O e */ getHeader(columnDef: C) { if (!columnDef) { - return this.hasFrozenColumns() ? this._headers : this._headerL; + return this._viewportMgr.hasFrozenColumns() ? this._headers : this._headerL; } const idx = this.getColumnIndex(columnDef.id); - return this.hasFrozenColumns() ? ((idx <= this._options.frozenColumn!) ? this._headerL : this._headerR) : this._headerL; + return this._viewportMgr.sideForColumn(idx, this._headerL, this._headerR); } /** @@ -2228,20 +2243,20 @@ export class SlickGrid = Column, O e */ getHeaderColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const targetHeader = this.hasFrozenColumns() ? ((idx <= this._options.frozenColumn!) ? this._headerL : this._headerR) : this._headerL; - const targetIndex = this.hasFrozenColumns() ? ((idx <= this._options.frozenColumn!) ? idx : idx - this._options.frozenColumn! - 1) : idx; + const targetHeader = this._viewportMgr.sideForColumn(idx, this._headerL, this._headerR); + const targetIndex = this._viewportMgr.sideLocalColumnIdx(idx); return targetHeader.children[targetIndex] as HTMLDivElement; } /** Get the Header Row DOM element */ getHeaderRow() { - return this.hasFrozenColumns() ? this._headerRows : this._headerRows[0]; + return this._viewportMgr.hasFrozenColumns() ? this._headerRows : this._headerRows[0]; } /** Get the Footer DOM element */ getFooterRow() { - return this.hasFrozenColumns() ? this._footerRow : this._footerRow[0]; + return this._viewportMgr.hasFrozenColumns() ? this._footerRow : this._footerRow[0]; } /** @@ -2249,21 +2264,10 @@ export class SlickGrid = Column, O e * @param {Number|String} columnIdOrIdx - column Id or index */ getHeaderRowColumn(columnIdOrIdx: number | string) { - let idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - let headerRowTarget: HTMLDivElement; - - if (this.hasFrozenColumns()) { - if (idx <= this._options.frozenColumn!) { - headerRowTarget = this._headerRowL; - } else { - headerRowTarget = this._headerRowR; - idx -= this._options.frozenColumn! + 1; - } - } else { - headerRowTarget = this._headerRowL; - } + const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); + const headerRowTarget = this._viewportMgr.sideForColumn(idx, this._headerRowL, this._headerRowR); - return headerRowTarget.children[idx] as HTMLDivElement; + return headerRowTarget.children[this._viewportMgr.sideLocalColumnIdx(idx)] as HTMLDivElement; } /** @@ -2271,22 +2275,10 @@ export class SlickGrid = Column, O e * @param {Number|String} columnIdOrIdx - column Id or index */ getFooterRowColumn(columnIdOrIdx: number | string) { - let idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - let footerRowTarget: HTMLDivElement; - - if (this.hasFrozenColumns()) { - if (idx <= this._options.frozenColumn!) { - footerRowTarget = this._footerRowL; - } else { - footerRowTarget = this._footerRowR; - - idx -= this._options.frozenColumn! + 1; - } - } else { - footerRowTarget = this._footerRowL; - } + const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); + const footerRowTarget = this._viewportMgr.sideForColumn(idx, this._footerRowL, this._footerRowR); - return footerRowTarget.children[idx] as HTMLDivElement; + return footerRowTarget.children[this._viewportMgr.sideLocalColumnIdx(idx)] as HTMLDivElement; } /** @@ -5394,7 +5386,7 @@ export class SlickGrid = Column, O e const width = this.columns[i].width; - if ((this._options.frozenColumn!) > -1 && (i > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(i)) { this.headersWidthR += width || 0; } else { this.headersWidthL += width || 0; @@ -5402,14 +5394,14 @@ export class SlickGrid = Column, O e } if (includeScrollbar) { - if ((this._options.frozenColumn!) > -1 && (i > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(i)) { this.headersWidthR += this.scrollbarDimensions?.width ?? 0; } else { this.headersWidthL += this.scrollbarDimensions?.width ?? 0; } } - if (this.hasFrozenColumns()) { + if (this._viewportMgr.hasFrozenColumns()) { this.headersWidthL = this.headersWidthL + 1000; this.headersWidthR = Math.max(this.headersWidthR, this.viewportW) + this.headersWidthL; @@ -5438,7 +5430,7 @@ export class SlickGrid = Column, O e while (i--) { if (!this.columns[i] || this.columns[i].hidden) { continue; } - if (this.hasFrozenColumns() && (i > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(i)) { this.canvasWidthR += this.columns[i].width || 0; } else { this.canvasWidthL += this.columns[i].width || 0; @@ -5449,7 +5441,7 @@ export class SlickGrid = Column, O e const extraWidth = Math.max(totalRowWidth, availableWidth) - totalRowWidth; if (extraWidth > 0) { totalRowWidth += extraWidth; - if (this.hasFrozenColumns()) { + if (this._viewportMgr.hasFrozenColumns()) { this.canvasWidthR += extraWidth; } else { this.canvasWidthL += extraWidth; @@ -5939,7 +5931,7 @@ export class SlickGrid = Column, O e let rowDivR: HTMLElement | undefined; divArrayL.push(rowDiv); - if (this.hasFrozenColumns()) { + if (this._viewportMgr.hasFrozenColumns()) { // it has to be a deep copy otherwise we will have issues with pass by reference in js since // attempting to add the same element to 2 different arrays will just move 1 item to the other array rowDivR = rowDiv.cloneNode(true) as HTMLElement; @@ -5993,10 +5985,10 @@ export class SlickGrid = Column, O e // All columns to the right are outside the range, so no need to render them if (isRenderCell) { - const targetedRowDiv = (this.hasFrozenColumns() && (i > this._options.frozenColumn!) ? rowDivR! : rowDiv); + const targetedRowDiv = this._viewportMgr.sideForColumn(i, rowDiv, rowDivR!); this.appendCellHtml(targetedRowDiv, row, i, ncolspan, rowspan, columnData, d); } - } else if (m.alwaysRenderColumn || (this.hasFrozenColumns() && i <= this._options.frozenColumn!)) { + } else if (m.alwaysRenderColumn || this._viewportMgr.isColumnInFrozenBand(i)) { this.appendCellHtml(rowDiv, row, i, ncolspan, rowspan, columnData, d); } @@ -6032,7 +6024,7 @@ export class SlickGrid = Column, O e + (rowspan > 1 ? ' rowspan' : '') + (columnMetadata?.cssClass ? ` ${columnMetadata.cssClass}` : ''); - if (this.hasFrozenColumns() && cell <= this._options.frozenColumn!) { + if (this._viewportMgr.isColumnInFrozenBand(cell)) { cellCss += ' frozen'; } @@ -6854,7 +6846,7 @@ export class SlickGrid = Column, O e if (!node) { continue; } - if (this.hasFrozenColumns() && (columnIdx > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(columnIdx)) { cacheEntry.rowNode![1].appendChild(node); } else { cacheEntry.rowNode![0].appendChild(node); From 0f0ae29ce0847bd194421cf0b44679a5258d136a Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 09:31:25 +0930 Subject: [PATCH 08/43] =?UTF-8?q?refactor:=20complete=20the=20frozen-flag?= =?UTF-8?q?=20sweep=20=E2=80=94=20all=20band=20branching=20now=20routes=20?= =?UTF-8?q?through=20ViewportMgr=20(Phase=202,=20milestone=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hasFrozenRows() accessor and bodyCanvasL() (the scrollable-body left canvas selector shared by updateRowCount and bindAncestorScrollEvents) to ViewportMgr, and rewrites the remaining ~34 grid-side sites onto the manager's accessors and column- side helpers: setupColumnResize, setupColumnReorder, createColumnHeaders, createColumnFooter, updateRowCount, scrollTo, scrollRowIntoView, render, renderRows, getCellFromEvent, setActiveCellInternal, navigateToPos, bindAncestorScrollEvents, appendRowHtml, updateCanvasWidth. Grid-side frozen-flag branching is now zero outside setFrozenOptions (the state owner): 95 sites at baseline, 3 writes remaining. frozenBottom/frozenRow option reads intentionally stay grid-side where they carry option semantics rather than band routing. Full Cypress suite green (600 tests, 599 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 86 +++++++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 1c896fc9..61e4846f 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -436,6 +436,20 @@ class ViewportMgr { return this.freeze.frozenColumnIdx > -1; } + /** Returns a boolean indicating whether the grid is configured with frozen rows. */ + hasFrozenRows() { + return this.freeze.hasFrozenRows; + } + + /** + * The left canvas of the scrollable body band: bottom-left while rows are frozen at + * the top, top-left otherwise (historical selector used by updateRowCount and + * bindAncestorScrollEvents). + */ + bodyCanvasL(): HTMLDivElement { + return (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) ? this.canvasBottomL : this.canvasTopL; + } + /** * Index of the pane owning cell (colIdx, rowIdx) in the 4-slot * [TopL, TopR, BottomL, BottomR] element arrays. @@ -2307,8 +2321,8 @@ export class SlickGrid = Column, O e const m = this.columns[i]; if (!m || m.hidden) { continue; } - const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this.hasFrozenColumns() && (i > this._options.frozenColumn!) ? this._footerRowR : this._footerRowL); - const className = this.hasFrozenColumns() && i <= this._options.frozenColumn! ? 'frozen' : null; + const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this._viewportMgr.sideForColumn(i, this._footerRowL, this._footerRowR)); + const className = this._viewportMgr.isColumnInFrozenBand(i) ? 'frozen' : null; if (className) { footerRowCell.classList.add(className); } @@ -2489,7 +2503,7 @@ export class SlickGrid = Column, O e }); Utils.emptyElement(this._footerRowL); - if (this.hasFrozenColumns()) { + if (this._viewportMgr.hasFrozenColumns()) { const footerRowRColumnElements = this._footerRowR.querySelectorAll('.slick-footerrow-column'); footerRowRColumnElements.forEach((column) => { const columnDef = Utils.storage.get(column, 'column'); @@ -2509,8 +2523,8 @@ export class SlickGrid = Column, O e const m: C = this.columns[i]; if (m.hidden) { continue; } - const headerTarget = this.hasFrozenColumns() ? ((i <= this._options.frozenColumn!) ? this._headerL : this._headerR) : this._headerL; - const headerRowTarget = this.hasFrozenColumns() ? ((i <= this._options.frozenColumn!) ? this._headerRowL : this._headerRowR) : this._headerRowL; + const headerTarget = this._viewportMgr.sideForColumn(i, this._headerL, this._headerR); + const headerRowTarget = this._viewportMgr.sideForColumn(i, this._headerRowL, this._headerRowR); const header = Utils.createDomElement('div', { id: `${this.uid + m.id}`, dataset: { id: String(m.id) }, role: 'columnheader', className: 'ui-state-default slick-state-default slick-header-column' }, headerTarget); if (m.toolTip) { @@ -2528,7 +2542,7 @@ export class SlickGrid = Column, O e if (classname) { header.classList.add(...Utils.classNameToList(classname)); } - classname = this.hasFrozenColumns() && i <= this._options.frozenColumn! ? 'frozen' : null; + classname = this._viewportMgr.isColumnInFrozenBand(i) ? 'frozen' : null; if (classname) { header.classList.add(classname); } @@ -2567,7 +2581,7 @@ export class SlickGrid = Column, O e if (this._options.showHeaderRow) { const headerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-headerrow-column l${i} r${i}` }, headerRowTarget); - const frozenClasses = this.hasFrozenColumns() && i <= this._options.frozenColumn! ? 'frozen' : null; + const frozenClasses = this._viewportMgr.isColumnInFrozenBand(i) ? 'frozen' : null; if (frozenClasses) { headerRowCell.classList.add(frozenClasses); } @@ -2584,7 +2598,7 @@ export class SlickGrid = Column, O e }); } if (this._options.createFooterRow && this._options.showFooterRow) { - const footerRowTarget = this.hasFrozenColumns() ? ((i <= this._options.frozenColumn!) ? this._footerRow[0] : this._footerRow[1]) : this._footerRow[0]; + const footerRowTarget = this._viewportMgr.sideForColumn(i, this._footerRow[0], this._footerRow[1]); const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, footerRowTarget); Utils.storage.put(footerRowCell, 'column', m); @@ -2633,7 +2647,7 @@ export class SlickGrid = Column, O e dragoverBubble: false, preventOnFilter: false, // allow column to be resized even when they are not orderable revertClone: true, - scroll: !this.hasFrozenColumns(), // enable auto-scroll + scroll: !this._viewportMgr.hasFrozenColumns(), // enable auto-scroll // lock unorderable columns by using a combo of filter + onMove filter: `.${this._options.unorderableColumnCssClass}`, onMove: (event: MouseEvent & { related: HTMLElement; }) => { @@ -2641,7 +2655,7 @@ export class SlickGrid = Column, O e }, onStart: (e: SortableEvent) => { e.item.classList.add('slick-header-column-active'); - canDragScroll = !this.hasFrozenColumns() || Utils.offset(e.item)!.left > Utils.offset(this._viewportScrollContainerX)!.left; + canDragScroll = !this._viewportMgr.hasFrozenColumns() || Utils.offset(e.item)!.left > Utils.offset(this._viewportScrollContainerX)!.left; if (canDragScroll && e.originalEvent.pageX > this._container.clientWidth) { if (!(columnScrollTimer)) { @@ -2874,7 +2888,7 @@ export class SlickGrid = Column, O e c = vc[k]; if (!c || c.hidden) { continue; } - if (this.hasFrozenColumns() && (k > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(k)) { newCanvasWidthR += c.width || 0; } else { newCanvasWidthL += c.width || 0; @@ -2895,7 +2909,7 @@ export class SlickGrid = Column, O e x = 0; } - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(j)) { newCanvasWidthR += c.width || 0; } else { newCanvasWidthL += c.width || 0; @@ -2907,7 +2921,7 @@ export class SlickGrid = Column, O e c = vc[j]; if (!c || c.hidden) { continue; } - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(j)) { newCanvasWidthR += c.width || 0; } else { newCanvasWidthL += c.width || 0; @@ -2948,7 +2962,7 @@ export class SlickGrid = Column, O e const newWidth = (c.previousWidth || 0) + x; const resizedCanvasWidthL = this.canvasWidthL + x; - if (this.hasFrozenColumns() && (j <= this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnInFrozenBand(j)) { // if we're on the left frozen side, we need to make sure that our left section width never goes over the total viewport width if (newWidth > frozenLeftColMaxWidth && resizedCanvasWidthL < (viewportWidth - this._options.frozenRightViewportMinWidth!)) { frozenLeftColMaxWidth = newWidth; // keep max column width ref, if we go over the limit this number will stop increasing @@ -2966,7 +2980,7 @@ export class SlickGrid = Column, O e c = vc[k]; if (!c || c.hidden) { continue; } - if (this.hasFrozenColumns() && (k > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(k)) { newCanvasWidthR += c.width || 0; } else { newCanvasWidthL += c.width || 0; @@ -2988,7 +3002,7 @@ export class SlickGrid = Column, O e x = 0; } - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(j)) { newCanvasWidthR += c.width || 0; } else { newCanvasWidthL += c.width || 0; @@ -3000,7 +3014,7 @@ export class SlickGrid = Column, O e c = vc[j]; if (!c || c.hidden) { continue; } - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { + if (this._viewportMgr.isColumnRightOfFreeze(j)) { // eslint-disable-next-line @typescript-eslint/no-unused-vars newCanvasWidthR += c.width || 0; } else { @@ -3010,7 +3024,7 @@ export class SlickGrid = Column, O e } } - if (this.hasFrozenColumns() && newCanvasWidthL !== this.canvasWidthL) { + if (this._viewportMgr.hasFrozenColumns() && newCanvasWidthL !== this.canvasWidthL) { Utils.width(this._headerL, newCanvasWidthL + 1000); Utils.setStyleSize(this._paneHeaderR, 'left', newCanvasWidthL); } @@ -4258,7 +4272,7 @@ export class SlickGrid = Column, O e let rowOffset = Math.floor(Utils.offset(Utils.parents(this.activeCellNode, '.grid-canvas')[0] as HTMLElement)!.top); const isBottom = Utils.parents(this.activeCellNode, '.grid-canvas-bottom').length; - if (this.hasFrozenRows && isBottom) { + if (this._viewportMgr.hasFrozenRows() && isBottom) { rowOffset -= (this._options.frozenBottom) ? Utils.height(this._canvasTopL) as number : this.frozenRowsHeight; @@ -5235,7 +5249,7 @@ export class SlickGrid = Column, O e let row = this.getRowFromNode(cellNode.parentNode as HTMLElement); - if (this.hasFrozenRows) { + if (this._viewportMgr.hasFrozenRows()) { let rowOffset = 0; const c = Utils.offset(Utils.parents(cellNode, '.grid-canvas')[0] as HTMLElement); const isBottom = Utils.parents(cellNode, '.grid-canvas-bottom').length; @@ -5556,7 +5570,7 @@ export class SlickGrid = Column, O e // recompute the header width split only when the pane widths will be redistributed // (preserves the historical conditional side effect on headersWidthL/R) - if (widthChanged || this.hasFrozenColumns() || this.hasFrozenRows) { + if (widthChanged || this._viewportMgr.hasFrozenColumns() || this._viewportMgr.hasFrozenRows()) { this.getHeadersWidth(); } @@ -5902,7 +5916,7 @@ export class SlickGrid = Column, O e const d = this.getDataItem(row); const dataLoading = row < dataLength && !d; let rowCss = 'slick-row' + - (this.hasFrozenRows && row <= this._options.frozenRow! ? ' frozen' : '') + + (this._viewportMgr.hasFrozenRows() && row <= this._options.frozenRow! ? ' frozen' : '') + (dataLoading ? ' loading' : '') + (row === this.activeRow && this._options.showCellSelection ? ' active' : '') + (row % 2 === 1 ? ' odd' : ' even'); @@ -6489,9 +6503,9 @@ export class SlickGrid = Column, O e this._prevDataLength = dataLength; const dataLengthIncludingAddNew = this.getDataLengthIncludingAddNew(); let numberOfRows = 0; - let oldH = ((this.hasFrozenRows && !this._options.frozenBottom) ? Utils.height(this._canvasBottomL) : Utils.height(this._canvasTopL)) as number; + let oldH = Utils.height(this._viewportMgr.bodyCanvasL()) as number; - if (this.hasFrozenRows) { + if (this._viewportMgr.hasFrozenRows()) { numberOfRows = this.getDataLength() - this._options.frozenRow!; } else { numberOfRows = dataLengthIncludingAddNew + (this._options.leaveSpaceForNewRows ? this.numVisibleRows - 1 : 0); @@ -6544,10 +6558,10 @@ export class SlickGrid = Column, O e } if (this.h !== oldH || this.enforceFrozenRowHeightRecalc) { - if (this.hasFrozenRows && !this._options.frozenBottom) { + if (this._viewportMgr.hasFrozenRows() && !this._options.frozenBottom) { Utils.height(this._canvasBottomL, this.h); - if (this.hasFrozenColumns()) { + if (this._viewportMgr.hasFrozenColumns()) { Utils.height(this._canvasBottomR, this.h); } } else { @@ -6874,7 +6888,7 @@ export class SlickGrid = Column, O e const renderingRows = new Set(); for (let i = range.top as number, ii = range.bottom as number; i <= ii; i++) { - if (this.rowsCache[i] || (this.hasFrozenRows && this._options.frozenBottom && i === this.getDataLength())) { + if (this.rowsCache[i] || (this._viewportMgr.hasFrozenRows() && this._options.frozenBottom && i === this.getDataLength())) { continue; } this.renderedRows++; @@ -6973,7 +6987,7 @@ export class SlickGrid = Column, O e // add new rows & missing cells in existing rows if (this.lastRenderedScrollLeft !== this.scrollLeft) { - if (this.hasFrozenRows) { + if (this._viewportMgr.hasFrozenRows()) { const renderedFrozenRows = Utils.extend(true, {}, rendered); if (this._options.frozenBottom) { @@ -6992,7 +7006,7 @@ export class SlickGrid = Column, O e this.renderRows(rendered); // Render frozen rows - if (this.hasFrozenRows) { + if (this._viewportMgr.hasFrozenRows()) { if (this._options.frozenBottom) { this.renderRows({ top: this.actualFrozenRow, bottom: this.getDataLength() - 1, leftPx: rendered.leftPx, rightPx: rendered.rightPx @@ -7046,7 +7060,7 @@ export class SlickGrid = Column, O e * Also stores these ancestors for later unbinding. */ protected bindAncestorScrollEvents() { - let elem: HTMLElement | null = (this.hasFrozenRows && !this._options.frozenBottom) ? this._canvasBottomL : this._canvasTopL; + let elem: HTMLElement | null = this._viewportMgr.bodyCanvasL(); while ((elem = elem!.parentNode as HTMLElement) !== document.body && elem) { // bind to scroll containers only if (elem === this._viewportTopL || elem.scrollWidth !== elem.clientWidth || elem.scrollHeight !== elem.clientHeight) { @@ -7090,7 +7104,7 @@ export class SlickGrid = Column, O e */ scrollTo(y: number) { y = Math.max(y, 0); - y = Math.min(y, (this.th || 0) - (Utils.height(this._viewportScrollContainerY) as number) + ((this.viewportHasHScroll || this.hasFrozenColumns()) ? (this.scrollbarDimensions?.height ?? 0) : 0)); + y = Math.min(y, (this.th || 0) - (Utils.height(this._viewportScrollContainerY) as number) + ((this.viewportHasHScroll || this._viewportMgr.hasFrozenColumns()) ? (this.scrollbarDimensions?.height ?? 0) : 0)); const oldOffset = this.offset; // determine the page for the target position first, then derive the offset from that page @@ -7109,11 +7123,11 @@ export class SlickGrid = Column, O e this.vScrollDir = (this.prevScrollTop + oldOffset < newScrollTop + this.offset) ? 1 : -1; this.lastRenderedScrollTop = (this.scrollTop = this.prevScrollTop = newScrollTop); - if (this.hasFrozenColumns()) { + if (this._viewportMgr.hasFrozenColumns()) { this._viewportTopL.scrollTop = newScrollTop; } - if (this.hasFrozenRows) { + if (this._viewportMgr.hasFrozenRows()) { this._viewportBottomL.scrollTop = this._viewportBottomR.scrollTop = newScrollTop; } @@ -7504,7 +7518,7 @@ export class SlickGrid = Column, O e * @param {Boolean} doPaging - scroll when pagination is enabled */ scrollRowIntoView(row: number, doPaging?: boolean) { - if (!this.hasFrozenRows || + if (!this._viewportMgr.hasFrozenRows() || (!this._options.frozenBottom && row > this.actualFrozenRow - 1) || (this._options.frozenBottom && row < this.actualFrozenRow - 1)) { @@ -7512,7 +7526,7 @@ export class SlickGrid = Column, O e // if frozen row on top // subtract number of frozen row - const rowNumber = (this.hasFrozenRows && !this._options.frozenBottom ? row - this._options.frozenRow! : row); + const rowNumber = (this._viewportMgr.hasFrozenRows() && !this._options.frozenBottom ? row - this._options.frozenRow! : row); const rowAtTop = rowNumber * this._options.rowHeight!; const rowAtBottom = (rowNumber + 1) * this._options.rowHeight! @@ -9105,7 +9119,7 @@ export class SlickGrid = Column, O e */ protected navigateToPos(pos: CellPosition | null) { if (pos) { - if (this.hasFrozenRows && this._options.frozenBottom && pos.row === this.getDataLength()) { + if (this._viewportMgr.hasFrozenRows() && this._options.frozenBottom && pos.row === this.getDataLength()) { return; } From 52f1951c6da418dee6bb0629702952341ab31407 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 14:39:50 +0930 Subject: [PATCH 09/43] feat: add lazyPanes option (default false) and pane-existence guards (Phase 3, milestone 9) Introduces the opt-in lazyPanes grid option (no behaviour yet) and makes every ViewportMgr method plus the four remaining unconditional grid-side right-element writes (initialize spacer width, createColumnHeaders/headerRow emptying, updateRowCount canvasTopR height) tolerate absent panes. All guards are inert while every pane exists: full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline exactly. Co-Authored-By: Claude Fable 5 --- src/models/gridOption.interface.ts | 8 +++ src/slick.grid.ts | 95 ++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/src/models/gridOption.interface.ts b/src/models/gridOption.interface.ts index 2d2bfe89..74bb8c2d 100644 --- a/src/models/gridOption.interface.ts +++ b/src/models/gridOption.interface.ts @@ -251,6 +251,14 @@ export interface GridOption { */ frozenRightViewportMinWidth?: number; + /** + * Defaults to false. When enabled AND the grid is created without frozen rows/columns, only the + * single top-left pane/viewport/canvas is built instead of the historical 6-pane/4-viewport/4-canvas + * structure; the extra panes materialize on the fly if freezing is later enabled via setOptions(). + * Opt-in because it changes the DOM for consumers that style/query the unused right/bottom panes. + */ + lazyPanes?: boolean; + /** Defaults to false, which leads to have row(s) taking full width */ fullWidthRows?: boolean; diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 61e4846f..f717d2d0 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -542,37 +542,47 @@ class ViewportMgr { return this.isColumnRightOfFreeze(colIdx) ? right : left; } + /** Utils.show that tolerates panes not built under lazyPanes. */ + protected showIf(el?: HTMLElement) { + if (el) { Utils.show(el); } + } + + /** Utils.hide that tolerates panes not built under lazyPanes. */ + protected hideIf(el?: HTMLElement) { + if (el) { Utils.hide(el); } + } + /** add/remove frozen class to left headers/footer when defined */ applyPaneFrozenClasses(): void { const classAction = this.hasFrozenColumns() ? 'add' : 'remove'; for (const elm of [this.paneHeaderL, this.paneTopL, this.paneBottomL]) { - elm.classList[classAction]('frozen'); + elm?.classList[classAction]('frozen'); } } /** Shows/hides the right and bottom panes according to the freeze configuration. */ applyPaneVisibility() { if (this.hasFrozenColumns()) { - Utils.show(this.paneHeaderR); - Utils.show(this.paneTopR); + this.showIf(this.paneHeaderR); + this.showIf(this.paneTopR); if (this.freeze.hasFrozenRows) { - Utils.show(this.paneBottomL); - Utils.show(this.paneBottomR); + this.showIf(this.paneBottomL); + this.showIf(this.paneBottomR); } else { - Utils.hide(this.paneBottomR); - Utils.hide(this.paneBottomL); + this.hideIf(this.paneBottomR); + this.hideIf(this.paneBottomL); } } else { - Utils.hide(this.paneHeaderR); - Utils.hide(this.paneTopR); - Utils.hide(this.paneBottomR); + this.hideIf(this.paneHeaderR); + this.hideIf(this.paneTopR); + this.hideIf(this.paneBottomR); if (this.freeze.hasFrozenRows) { - Utils.show(this.paneBottomL); + this.showIf(this.paneBottomL); } else { - Utils.hide(this.paneBottomR); - Utils.hide(this.paneBottomL); + this.hideIf(this.paneBottomR); + this.hideIf(this.paneBottomL); } } } @@ -588,21 +598,27 @@ class ViewportMgr { this.viewportTopL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); this.viewportTopL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); - this.viewportTopR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); - this.viewportTopR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'scroll' : 'auto') : (hasFrozenRows ? 'scroll' : 'auto')); + if (this.viewportTopR) { + this.viewportTopR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); + this.viewportTopR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'scroll' : 'auto') : (hasFrozenRows ? 'scroll' : 'auto')); + } - this.viewportBottomL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); - this.viewportBottomL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); + if (this.viewportBottomL) { + this.viewportBottomL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); + this.viewportBottomL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); + } - this.viewportBottomR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); - this.viewportBottomR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'auto' : 'auto') : (hasFrozenRows ? 'auto' : 'auto')); + if (this.viewportBottomR) { + this.viewportBottomR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); + this.viewportBottomR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'auto' : 'auto') : (hasFrozenRows ? 'auto' : 'auto')); + } if (o.viewportClass) { const viewportClassList = Utils.classNameToList(o.viewportClass); - this.viewportTopL.classList.add(...viewportClassList); - this.viewportTopR.classList.add(...viewportClassList); - this.viewportBottomL.classList.add(...viewportClassList); - this.viewportBottomR.classList.add(...viewportClassList); + // this.viewport only ever contains the elements that were actually built + this.viewport.forEach((view) => { + view.classList.add(...viewportClassList); + }); } } @@ -622,7 +638,9 @@ class ViewportMgr { Utils.width(this.canvasTopL, g.canvasWidthL); Utils.width(this.headerL, g.headersWidthL); - Utils.width(this.headerR, g.headersWidthR); + if (this.headerR) { + Utils.width(this.headerR, g.headersWidthR); + } if (this.hasFrozenColumns()) { Utils.width(this.canvasTopR, g.canvasWidthR); @@ -688,11 +706,15 @@ class ViewportMgr { } Utils.width(this.headerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); - Utils.width(this.headerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + if (this.headerRowSpacerR) { + Utils.width(this.headerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + } if (g.createFooterRow) { Utils.width(this.footerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); - Utils.width(this.footerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + if (this.footerRowSpacerR) { + Utils.width(this.footerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + } } } @@ -799,7 +821,9 @@ class ViewportMgr { } } } else { - Utils.height(this.viewportTopR, viewportTopH); + if (this.viewportTopR) { + Utils.height(this.viewportTopR, viewportTopH); + } } return { paneTopH, paneBottomH, viewportTopH, viewportBottomH }; @@ -1071,6 +1095,7 @@ export class SlickGrid = Column, O e frozenColumn: -1, frozenRow: -1, frozenRightViewportMinWidth: 100, + lazyPanes: false, throwWhenFrozenNotAllViewable: false, fullWidthRows: false, multiColumnSort: false, @@ -1573,7 +1598,9 @@ export class SlickGrid = Column, O e }); Utils.width(this._headerRowSpacerL, canvasWithScrollbarWidth); - Utils.width(this._headerRowSpacerR, canvasWithScrollbarWidth); + if (this._headerRowSpacerR) { + Utils.width(this._headerRowSpacerR, canvasWithScrollbarWidth); + } // footer Row if (this._options.createFooterRow) { @@ -2465,7 +2492,9 @@ export class SlickGrid = Column, O e }); Utils.emptyElement(this._headerL); - Utils.emptyElement(this._headerR); + if (this._headerR) { + Utils.emptyElement(this._headerR); + } this.getHeadersWidth(); @@ -2487,7 +2516,9 @@ export class SlickGrid = Column, O e }); Utils.emptyElement(this._headerRowL); - Utils.emptyElement(this._headerRowR); + if (this._headerRowR) { + Utils.emptyElement(this._headerRowR); + } if (this._options.createFooterRow) { const footerRowLColumnElements = this._footerRowL.querySelectorAll('.slick-footerrow-column'); @@ -6566,7 +6597,9 @@ export class SlickGrid = Column, O e } } else { Utils.height(this._canvasTopL, this.h); - Utils.height(this._canvasTopR, this.h); + if (this._canvasTopR) { + Utils.height(this._canvasTopR, this.h); + } } this.scrollTop = this._viewportScrollContainerY.scrollTop; From 860e39c73b8c70ad79dc7aec77af7ac4b30e9740 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 15:46:15 +0930 Subject: [PATCH 10/43] feat: lazyPanes builds only the top-left pane set when nothing is frozen (Phase 3, milestone 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With lazyPanes: true and no frozen rows/columns at init, buildPanes/buildFooterRows now create just 2 panes / 1 viewport / 1 canvas plus left-side chrome, using inline conditionals so element order stays canonical in both modes. getHeaderChildren now iterates the _headers array instead of assuming two containers (the one crash the existence-guard pass had not covered). Adds examples/example-lazy-panes.html and a 7-test characterization spec proving the single-pane DOM shape and basic function (render, navigation, scrolling). Default-mode DOM unchanged: full suite green (607 tests, 606 pass / 1 pending — 600 baseline + 7 new minus overlap). Co-Authored-By: Claude Fable 5 --- cypress/e2e/dom-shape-lazy-panes.cy.ts | 57 +++++++++++ examples/example-lazy-panes.html | 74 ++++++++++++++ src/slick.grid.ts | 127 +++++++++++++++++-------- 3 files changed, 219 insertions(+), 39 deletions(-) create mode 100644 cypress/e2e/dom-shape-lazy-panes.cy.ts create mode 100644 examples/example-lazy-panes.html diff --git a/cypress/e2e/dom-shape-lazy-panes.cy.ts b/cypress/e2e/dom-shape-lazy-panes.cy.ts new file mode 100644 index 00000000..acd18119 --- /dev/null +++ b/cypress/e2e/dom-shape-lazy-panes.cy.ts @@ -0,0 +1,57 @@ +/** + * DOM-shape characterization for the lazyPanes opt-in (Phase 3 of the ViewportMgr + * refactor). A grid built with `lazyPanes: true` and no frozen rows/columns must + * create ONLY the top-left pane set — 2 panes (header-left, top-left), 1 viewport, + * 1 canvas — instead of the historical 6/4/4 structure, while remaining fully + * functional. Companion to dom-shape-characterization.cy.ts (the default mode). + */ + +describe('DOM shape - lazyPanes single-pane build (example-lazy-panes)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-lazy-panes.html`); + }); + + it('should build only the header-left and top-left panes, in order', () => { + cy.get('#myGrid > .slick-pane') + .should('have.length', 2) + .then(($panes) => { + expect($panes.eq(0)).to.have.class('slick-pane-header'); + expect($panes.eq(0)).to.have.class('slick-pane-left'); + expect($panes.eq(1)).to.have.class('slick-pane-top'); + expect($panes.eq(1)).to.have.class('slick-pane-left'); + }); + cy.get('#myGrid .slick-pane-right').should('have.length', 0); + cy.get('#myGrid .slick-pane-bottom').should('have.length', 0); + }); + + it('should build exactly one viewport and one canvas, correctly nested', () => { + cy.get('#myGrid .slick-viewport').should('have.length', 1); + cy.get('#myGrid .grid-canvas').should('have.length', 1); + cy.get('#myGrid .slick-pane-top.slick-pane-left > .slick-viewport.slick-viewport-top.slick-viewport-left').should('have.length', 1); + cy.get('#myGrid .slick-viewport-top.slick-viewport-left > .grid-canvas.grid-canvas-top.grid-canvas-left').should('have.length', 1); + }); + + it('should build only left-side header chrome', () => { + cy.get('#myGrid .slick-header').should('have.length', 1); + cy.get('#myGrid .slick-header-columns').should('have.length', 1); + cy.get('#myGrid .slick-headerrow').should('have.length', 1); + cy.get('#myGrid .slick-top-panel-scroller').should('have.length', 1); + }); + + it('should render header columns and rows normally', () => { + cy.get('#myGrid .slick-header-columns .slick-header-column').should('have.length', 6); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + cy.get('#myGrid .grid-canvas .slick-row .slick-cell').first().should('contain', 'Task 0'); + }); + + it('should support basic navigation (click makes a cell active)', () => { + cy.get('#myGrid .slick-row .slick-cell').first().click(); + cy.get('#myGrid .slick-cell.active').should('have.length', 1); + }); + + it('should scroll vertically and keep rendering rows', () => { + cy.get('#myGrid .slick-viewport').scrollTo(0, 2000); + cy.get('#myGrid .grid-canvas .slick-row').should('have.length.greaterThan', 0); + cy.get('#myGrid .slick-viewport').scrollTo(0, 0); + }); +}); diff --git a/examples/example-lazy-panes.html b/examples/example-lazy-panes.html new file mode 100644 index 00000000..d72f76c8 --- /dev/null +++ b/examples/example-lazy-panes.html @@ -0,0 +1,74 @@ + + + + + + SlickGrid example: lazyPanes single-pane build + + + + +

Example: lazyPanes - single pane/viewport/canvas build

+ + + + + +
+
+
+
+

+ + Demonstrates: +

+
+
    +
  • basic grid with minimal configuration
  • +
+

View Source:

+ +
+ + + + + + + + + + + diff --git a/src/slick.grid.ts b/src/slick.grid.ts index f717d2d0..d957b578 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -199,6 +199,9 @@ interface ViewportMgrBuildOptions { showTopPanel?: boolean; showHeaderRow?: boolean; viewportClass?: string; + lazyPanes?: boolean; + frozenColumn?: number; + frozenRow?: number; } /** @@ -214,6 +217,12 @@ class ViewportMgr { /** the grid container, captured by buildPanes */ protected container!: HTMLElement; + /** + * True when the grid opted into lazyPanes AND no rows/columns were frozen at build + * time — only the top-left pane set exists until freezing is enabled. + */ + protected lazy = false; + // panes paneHeaderL!: HTMLDivElement; paneHeaderR!: HTMLDivElement; @@ -285,14 +294,22 @@ class ViewportMgr { */ buildPanes(container: HTMLElement, o: ViewportMgrBuildOptions) { this.container = container; + this.lazy = !!o.lazyPanes && !(((o.frozenColumn ?? -1) > -1) || ((o.frozenRow ?? -1) > -1)); - // Containers used for scrolling frozen columns and rows + // Containers used for scrolling frozen columns and rows. + // Under lazyPanes with nothing frozen, only the top-left pane set is built; + // the creation ORDER of the conditional elements must stay canonical so both + // modes produce the same sibling sequence for whatever exists. this.paneHeaderL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, container); - this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, container); + if (!this.lazy) { + this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, container); + } this.paneTopL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-left', tabIndex: 0 }, container); - this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }, container); - this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }, container); - this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }, container); + if (!this.lazy) { + this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }, container); + this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }, container); + this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }, container); + } if (o.createPreHeaderPanel) { this.preHeaderPanelScroller = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderL); @@ -300,55 +317,73 @@ class ViewportMgr { this.preHeaderPanel = Utils.createDomElement('div', null, this.preHeaderPanelScroller); this.preHeaderPanelSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScroller); - this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); - this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); - this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); + if (!this.lazy) { + this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); + this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); + this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); + } if (!o.showPreHeaderPanel) { Utils.hide(this.preHeaderPanelScroller); - Utils.hide(this.preHeaderPanelScrollerR); + this.hideIf(this.preHeaderPanelScrollerR); } } // Append the header scroller containers this.headerScrollerL = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-left' }, this.paneHeaderL); - this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); + if (!this.lazy) { + this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); + } // Cache the header scroller containers this.headerScroller.push(this.headerScrollerL); - this.headerScroller.push(this.headerScrollerR); + if (!this.lazy) { + this.headerScroller.push(this.headerScrollerR); + } // Append the columnn containers to the headers this.headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { left: '-1000px' } }, this.headerScrollerL); - this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); + if (!this.lazy) { + this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); + } // Cache the header columns - this.headers = [this.headerL, this.headerR]; + this.headers = this.lazy ? [this.headerL] : [this.headerL, this.headerR]; this.headerRowScrollerL = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopL); - this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); + if (!this.lazy) { + this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); + } - this.headerRowScroller = [this.headerRowScrollerL, this.headerRowScrollerR]; + this.headerRowScroller = this.lazy ? [this.headerRowScrollerL] : [this.headerRowScrollerL, this.headerRowScrollerR]; this.headerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerL); - this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); + if (!this.lazy) { + this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); + } this.headerRowL = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }, this.headerRowScrollerL); - this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); + if (!this.lazy) { + this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); + } - this.headerRows = [this.headerRowL, this.headerRowR]; + this.headerRows = this.lazy ? [this.headerRowL] : [this.headerRowL, this.headerRowR]; // Append the top panel scroller this.topPanelScrollerL = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopL); - this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); + if (!this.lazy) { + this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); + } - this.topPanelScrollers = [this.topPanelScrollerL, this.topPanelScrollerR]; + this.topPanelScrollers = this.lazy ? [this.topPanelScrollerL] : [this.topPanelScrollerL, this.topPanelScrollerR]; // Append the top panel this.topPanelL = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerL); - this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); + if (!this.lazy) { + this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); + } - this.topPanels = [this.topPanelL, this.topPanelR]; + this.topPanels = this.lazy ? [this.topPanelL] : [this.topPanelL, this.topPanelR]; if (!o.showColumnHeader) { this.headerScroller.forEach((el) => { @@ -370,12 +405,16 @@ class ViewportMgr { // Append the viewport containers this.viewportTopL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-left', tabIndex: 0 }, this.paneTopL); - this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); - this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); - this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); + if (!this.lazy) { + this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); + this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); + this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); + } // Cache the viewports - this.viewport = [this.viewportTopL, this.viewportTopR, this.viewportBottomL, this.viewportBottomR]; + this.viewport = this.lazy + ? [this.viewportTopL] + : [this.viewportTopL, this.viewportTopR, this.viewportBottomL, this.viewportBottomR]; if (o.viewportClass) { this.viewport.forEach((view) => { view.classList.add(...Utils.classNameToList((o.viewportClass))); @@ -384,12 +423,16 @@ class ViewportMgr { // Append the canvas containers this.canvasTopL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-left', tabIndex: 0 }, this.viewportTopL); - this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); - this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); - this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); + if (!this.lazy) { + this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); + this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); + this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); + } // Cache the canvases - this.canvas = [this.canvasTopL, this.canvasTopR, this.canvasBottomL, this.canvasBottomR]; + this.canvas = this.lazy + ? [this.canvasTopL] + : [this.canvasTopL, this.canvasTopR, this.canvasBottomL, this.canvasBottomR]; } /** @@ -398,20 +441,26 @@ class ViewportMgr { * scroller creation order and spacer widths. */ buildFooterRows(o: ViewportMgrBuildOptions, canvasWithScrollbarWidth: number) { - this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); + if (!this.lazy) { + this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); + } this.footerRowScrollerL = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopL); - this.footerRowScroller = [this.footerRowScrollerL, this.footerRowScrollerR]; + this.footerRowScroller = this.lazy ? [this.footerRowScrollerL] : [this.footerRowScrollerL, this.footerRowScrollerR]; this.footerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerL); Utils.width(this.footerRowSpacerL, canvasWithScrollbarWidth); - this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); - Utils.width(this.footerRowSpacerR, canvasWithScrollbarWidth); + if (!this.lazy) { + this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); + Utils.width(this.footerRowSpacerR, canvasWithScrollbarWidth); + } this.footerRowL = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-left' }, this.footerRowScrollerL); - this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); + if (!this.lazy) { + this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); + } - this.footerRow = [this.footerRowL, this.footerRowR]; + this.footerRow = this.lazy ? [this.footerRowL] : [this.footerRowL, this.footerRowR]; if (!o.showFooterRow) { this.footerRowScroller.forEach((scroller) => { @@ -2742,9 +2791,9 @@ export class SlickGrid = Column, O e * @returns {HTMLElement[]} - An array of header column elements. */ protected getHeaderChildren() { - const a = Array.from(this._headers[0].children); - const b = Array.from(this._headers[1].children); - return a.concat(b) as HTMLElement[]; + // _headers only contains the header containers that were actually built + // (a single left container under lazyPanes) + return this._headers.flatMap((headerEl) => Array.from(headerEl.children)) as HTMLElement[]; } /** From 643d41d9e8a77e41060d47c19900e7401c5058e7 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 21:16:09 +0930 Subject: [PATCH 11/43] feat: materialize panes on the fly when freezing is enabled on a lazyPanes grid (Phase 3, milestone 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ViewportMgr.materializeSecondaryPanes(): builds the right/bottom panes, chrome, viewports and canvases at their canonical sibling positions and pushes them into the shared element arrays in place; idempotent. - SlickGrid.materializeLazyPanes(): hooked into setFrozenOptions (so it runs before setScroller/setColumns in the internal_setOptions pipeline); re-aliases fields, wires events for the new elements only, binds sort clicks on the new right header, and re-anchors ancestor-scroll bindings. - Event wiring extracted from finishInitialization into bindPaneEvents() shared by init and materialization — this also closes the audited gap where late-created viewports would miss MouseWheel handling. - Alias block extracted to syncViewportMgrAliases(); setupColumnSort parameterized. - Un-freezing keeps materialized panes (hidden), matching historical behaviour. - Tests: viewportmgr-lazy-materialization.cy.ts (6 tests: freeze columns from lazy, header split + row routing, unfreeze, refreeze with rows, fresh-page frozen-rows- first), deliberately sorted after the example-* specs and self-contained. Verification: 3 consecutive full-suite runs — green, green except one failure in example-excel-compatible-spreadsheet's native-clipboard paste test, green. That test is probabilistically flaky under full-suite load on this Windows machine (passes in isolation and in 2/3 full runs; also failed historically only in full runs); Linux CI remains the arbiter. All 13 lazy/materialization tests passed in all three runs. Co-Authored-By: Claude Fable 5 --- .../viewportmgr-lazy-materialization.cy.ts | 87 +++++ src/slick.grid.ts | 367 +++++++++++++----- 2 files changed, 360 insertions(+), 94 deletions(-) create mode 100644 cypress/e2e/viewportmgr-lazy-materialization.cy.ts diff --git a/cypress/e2e/viewportmgr-lazy-materialization.cy.ts b/cypress/e2e/viewportmgr-lazy-materialization.cy.ts new file mode 100644 index 00000000..06a0bdfe --- /dev/null +++ b/cypress/e2e/viewportmgr-lazy-materialization.cy.ts @@ -0,0 +1,87 @@ +/** + * Runtime materialization tests for the lazyPanes opt-in (Phase 3, ViewportMgr + * refactor): enabling frozen rows/columns on a lazy single-pane grid must build + * the missing panes on the fly at their canonical positions and wire their events. + * + * NOTE: this spec is deliberately named to sort AFTER the example-* specs, keeping + * its freeze/unfreeze churn away from the timing-sensitive native-clipboard test in + * example-excel-compatible-spreadsheet (testIsolation is false, so all specs share + * one browser session). + */ + +describe('DOM shape - lazyPanes dynamic materialization on runtime freeze', () => { + it('should load the lazy example in its single-pane state', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-lazy-panes.html`); + cy.get('#myGrid > .slick-pane').should('have.length', 2); + }); + + it('should materialize the full pane set when frozen columns are enabled at runtime', () => { + cy.window().then((win: any) => { + win.grid.setOptions({ frozenColumn: 1 }); + }); + + cy.get('#myGrid > .slick-pane').should('have.length', 6).then(($panes) => { + // canonical sibling order must match the non-lazy build + const expected = [ + ['slick-pane-header', 'slick-pane-left'], + ['slick-pane-header', 'slick-pane-right'], + ['slick-pane-top', 'slick-pane-left'], + ['slick-pane-top', 'slick-pane-right'], + ['slick-pane-bottom', 'slick-pane-left'], + ['slick-pane-bottom', 'slick-pane-right'], + ]; + expected.forEach((classes, i) => { + classes.forEach((cls) => expect($panes.eq(i), `pane ${i} has .${cls}`).to.have.class(cls)); + }); + }); + cy.get('#myGrid .slick-viewport').should('have.length', 4); + cy.get('#myGrid .grid-canvas').should('have.length', 4); + }); + + it('should split header columns and route rows into both top canvases', () => { + cy.get('#myGrid .slick-pane-header.slick-pane-right').should('be.visible'); + cy.get('#myGrid .slick-header-columns-left .slick-header-column').should('have.length', 2); + cy.get('#myGrid .slick-header-columns-right .slick-header-column').should('have.length', 4); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right .slick-row').should('have.length.greaterThan', 0); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-cell').first().should('contain', 'Task 0'); + }); + + it('should unfreeze again, hiding the right panes but keeping them in the DOM', () => { + cy.window().then((win: any) => { + win.grid.setOptions({ frozenColumn: -1 }); + }); + + cy.get('#myGrid > .slick-pane').should('have.length', 6); + cy.get('#myGrid .slick-pane-header.slick-pane-right').should('not.be.visible'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right .slick-row').should('have.length', 0); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + cy.get('#myGrid .slick-header-columns-left .slick-header-column').should('have.length', 6); + }); + + it('should enable frozen rows on the already-materialized grid', () => { + cy.window().then((win: any) => { + win.grid.setOptions({ frozenRow: 3, frozenBottom: false }); + }); + + cy.get('#myGrid .slick-pane-bottom.slick-pane-left').should('be.visible'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length', 3); + cy.get('#myGrid .grid-canvas-bottom.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + }); + + it('should materialize directly from lazy state when frozen rows are enabled first', () => { + // fresh page load back into lazy single-pane state + cy.visit(`${Cypress.config('baseUrl')}/examples/example-lazy-panes.html`); + cy.get('#myGrid > .slick-pane').should('have.length', 2); + + cy.window().then((win: any) => { + win.grid.setOptions({ frozenRow: 2, frozenBottom: false }); + }); + + cy.get('#myGrid > .slick-pane').should('have.length', 6); + cy.get('#myGrid .slick-pane-bottom.slick-pane-left').should('be.visible'); + cy.get('#myGrid .slick-pane-header.slick-pane-right').should('not.be.visible'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length', 2); + cy.get('#myGrid .grid-canvas-bottom.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + }); +}); diff --git a/src/slick.grid.ts b/src/slick.grid.ts index d957b578..f6e3de91 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -469,6 +469,104 @@ class ViewportMgr { } } + /** + * Builds the right/bottom panes, chrome, viewports and canvases that a lazyPanes + * grid skipped at init, inserting each pane at its canonical sibling position and + * pushing the new elements into the shared caches IN PLACE (the grid's array + * aliases keep working). Idempotent: returns false when the grid is not lazy + * (already fully built or built non-lazy). + */ + materializeSecondaryPanes(o: ViewportMgrBuildOptions): boolean { + if (!this.lazy) { + return false; + } + this.lazy = false; + + const container = this.container; + + // panes, at their canonical sibling positions + this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneHeaderR, this.paneTopL); + this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneTopR, this.paneTopL.nextSibling); + this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }); + container.insertBefore(this.paneBottomL, this.paneTopR.nextSibling); + this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneBottomR, this.paneBottomL.nextSibling); + + if (o.createPreHeaderPanel) { + this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); + this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); + this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); + + if (!o.showPreHeaderPanel) { + Utils.hide(this.preHeaderPanelScrollerR); + } + } + + // header scroller + header columns + this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); + this.headerScroller.push(this.headerScrollerR); + this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); + this.headers.push(this.headerR); + + // header row + this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); + this.headerRowScroller.push(this.headerRowScrollerR); + this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); + this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); + this.headerRows.push(this.headerRowR); + + // top panel + this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); + this.topPanelScrollers.push(this.topPanelScrollerR); + this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); + this.topPanels.push(this.topPanelR); + + if (!o.showColumnHeader) { + Utils.hide(this.headerScrollerR); + } + if (!o.showTopPanel) { + Utils.hide(this.topPanelScrollerR); + } + if (!o.showHeaderRow) { + Utils.hide(this.headerRowScrollerR); + } + + // viewports (pushed in canonical [TopL, TopR, BottomL, BottomR] order) + this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); + this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); + this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); + this.viewport.push(this.viewportTopR, this.viewportBottomL, this.viewportBottomR); + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + this.viewportTopR.classList.add(...viewportClassList); + this.viewportBottomL.classList.add(...viewportClassList); + this.viewportBottomR.classList.add(...viewportClassList); + } + + // canvases + this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); + this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); + this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); + this.canvas.push(this.canvasTopR, this.canvasBottomL, this.canvasBottomR); + + // footer row (right side; the left one was built at init when createFooterRow) + if (o.createFooterRow) { + this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); + this.footerRowScroller.push(this.footerRowScrollerR); + this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); + this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); + this.footerRow.push(this.footerRowR); + + if (!o.showFooterRow) { + Utils.hide(this.footerRowScrollerR); + } + } + + return true; + } + ////////////////////////////////////////////////////////////////////////////////////////////// // Freeze state and pane selection (Phase 2 of the encapsulation refactor) ////////////////////////////////////////////////////////////////////////////////////////////// @@ -1573,7 +1671,64 @@ export class SlickGrid = Column, O e // existing logic operates unchanged. this._viewportMgr = new ViewportMgr(); this._viewportMgr.buildPanes(this._container, this._options); + this.syncViewportMgrAliases(); + + // Default the active viewport to the top left + this._activeViewportNode = this._viewportTopL; + + this.scrollbarDimensions = this.scrollbarDimensions || this.measureScrollbar(); + const canvasWithScrollbarWidth = this.getCanvasWidth() + this.scrollbarDimensions.width; + + // Default the active canvas to the top left + this._activeCanvasNode = this._canvasTopL; + // top-header + if (this._topHeaderPanelSpacer) { + Utils.width(this._topHeaderPanelSpacer, canvasWithScrollbarWidth); + } + + // pre-header + if (this._preHeaderPanelSpacer) { + Utils.width(this._preHeaderPanelSpacer, canvasWithScrollbarWidth); + } + + this._headers.forEach((el) => { + Utils.width(el, this.getHeadersWidth()); + }); + + Utils.width(this._headerRowSpacerL, canvasWithScrollbarWidth); + if (this._headerRowSpacerR) { + Utils.width(this._headerRowSpacerR, canvasWithScrollbarWidth); + } + + // footer Row + if (this._options.createFooterRow) { + this._viewportMgr.buildFooterRows(this._options, canvasWithScrollbarWidth); + this.syncViewportMgrAliases(); + } + + this._focusSink2 = this._focusSink.cloneNode(true) as HTMLDivElement; + this._container.appendChild(this._focusSink2); + + if (!this._options.explicitInitialization) { + this.finishInitialization(); + } + } + + /** + * Completes grid initialisation by calculating viewport dimensions, measuring cell padding and border differences, + * disabling text selection (except on editable inputs), setting frozen options and pane visibility, + * updating column caches, creating column headers and footers, setting up column sorting, + * creating CSS rules, binding ancestor scroll events, and binding various event handlers + * (e.g. for scrolling, mouse, keyboard, drag-and-drop). + * It also starts up any asynchronous post–render processing if enabled. + */ + /** + * Copies every pane/viewport/canvas/chrome element reference from the ViewportMgr + * onto the grid's historical field names. Called after buildPanes, buildFooterRows + * and materializeSecondaryPanes; idempotent. + */ + protected syncViewportMgrAliases() { this._paneHeaderL = this._viewportMgr.paneHeaderL; this._paneHeaderR = this._viewportMgr.paneHeaderR; this._paneTopL = this._viewportMgr.paneTopL; @@ -1623,38 +1778,7 @@ export class SlickGrid = Column, O e this._canvasBottomR = this._viewportMgr.canvasBottomR; this._canvas = this._viewportMgr.canvas; - // Default the active viewport to the top left - this._activeViewportNode = this._viewportTopL; - - this.scrollbarDimensions = this.scrollbarDimensions || this.measureScrollbar(); - const canvasWithScrollbarWidth = this.getCanvasWidth() + this.scrollbarDimensions.width; - - // Default the active canvas to the top left - this._activeCanvasNode = this._canvasTopL; - - // top-header - if (this._topHeaderPanelSpacer) { - Utils.width(this._topHeaderPanelSpacer, canvasWithScrollbarWidth); - } - - // pre-header - if (this._preHeaderPanelSpacer) { - Utils.width(this._preHeaderPanelSpacer, canvasWithScrollbarWidth); - } - - this._headers.forEach((el) => { - Utils.width(el, this.getHeadersWidth()); - }); - - Utils.width(this._headerRowSpacerL, canvasWithScrollbarWidth); - if (this._headerRowSpacerR) { - Utils.width(this._headerRowSpacerR, canvasWithScrollbarWidth); - } - - // footer Row if (this._options.createFooterRow) { - this._viewportMgr.buildFooterRows(this._options, canvasWithScrollbarWidth); - this._footerRowScrollerL = this._viewportMgr.footerRowScrollerL; this._footerRowScrollerR = this._viewportMgr.footerRowScrollerR; this._footerRowScroller = this._viewportMgr.footerRowScroller; @@ -1664,23 +1788,116 @@ export class SlickGrid = Column, O e this._footerRowR = this._viewportMgr.footerRowR; this._footerRow = this._viewportMgr.footerRow; } + } - this._focusSink2 = this._focusSink.cloneNode(true) as HTMLDivElement; - this._container.appendChild(this._focusSink2); + /** + * Binds the per-element event handlers for pane-level elements. Used by + * finishInitialization for the initial element set and by materializeLazyPanes + * for elements created later — pass ONLY the elements to wire up (the binding + * service does not dedupe). + */ + protected bindPaneEvents(els: { + viewports?: HTMLDivElement[]; + canvases?: HTMLDivElement[]; + headerScrollers?: HTMLDivElement[]; + headerRowScrollers?: HTMLDivElement[]; + footerRows?: HTMLDivElement[]; + footerRowScrollers?: HTMLDivElement[]; + }) { + if (!this._options.enableTextSelectionOnCells) { + // disable text selection in grid cells except in input and textarea elements + els.viewports?.forEach((view) => { + this._bindingEventService.bind(view, 'selectstart', (event) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + event.preventDefault(); + }); + }); + } - if (!this._options.explicitInitialization) { - this.finishInitialization(); + els.viewports?.forEach((view) => { + this._bindingEventService.bind(view, 'scroll', this.handleScroll.bind(this)); + }); + + if (this._options.enableMouseWheelScrollHandler) { + els.viewports?.forEach((view) => { + this.slickMouseWheelInstances.push(MouseWheel({ + element: view, + onMouseWheel: this.handleMouseWheel.bind(this) + })); + }); + } + + els.headerScrollers?.forEach((el) => { + this._bindingEventService.bind(el, 'contextmenu', this.handleHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(el, 'click', this.handleHeaderClick.bind(this) as EventListener); + }); + + els.headerRowScrollers?.forEach((scroller) => { + this._bindingEventService.bind(scroller, 'scroll', this.handleHeaderRowScroll.bind(this) as EventListener); + }); + + if (this._options.createFooterRow) { + els.footerRows?.forEach((footer) => { + this._bindingEventService.bind(footer, 'contextmenu', this.handleFooterContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(footer, 'click', this.handleFooterClick.bind(this) as EventListener); + }); + + els.footerRowScrollers?.forEach((scroller) => { + this._bindingEventService.bind(scroller, 'scroll', this.handleFooterRowScroll.bind(this) as EventListener); + }); } + + els.canvases?.forEach((element) => { + this._bindingEventService.bind(element, 'keydown', this.handleKeyDown.bind(this) as EventListener); + this._bindingEventService.bind(element, 'click', this.handleClick.bind(this) as EventListener); + this._bindingEventService.bind(element, 'dblclick', this.handleDblClick.bind(this) as EventListener); + this._bindingEventService.bind(element, 'contextmenu', this.handleContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(element, 'mouseover', this.handleCellMouseOver.bind(this) as EventListener); + this._bindingEventService.bind(element, 'mouseout', this.handleCellMouseOut.bind(this) as EventListener); + }); } /** - * Completes grid initialisation by calculating viewport dimensions, measuring cell padding and border differences, - * disabling text selection (except on editable inputs), setting frozen options and pane visibility, - * updating column caches, creating column headers and footers, setting up column sorting, - * creating CSS rules, binding ancestor scroll events, and binding various event handlers - * (e.g. for scrolling, mouse, keyboard, drag-and-drop). - * It also starts up any asynchronous post–render processing if enabled. + * Materializes the right/bottom panes on a lazyPanes grid the moment freezing is + * enabled (invoked from setFrozenOptions, i.e. before setScroller/setColumns run in + * the internal_setOptions pipeline). Re-aliases the element fields, wires up events + * for the NEW elements only, and re-anchors the ancestor scroll bindings. No-op on + * non-lazy grids. */ + protected materializeLazyPanes() { + if (!this._viewportMgr.materializeSecondaryPanes(this._options)) { + return; + } + this.syncViewportMgrAliases(); + + if (this.initialized) { + this.disableSelection([this._headerR]); + + this.bindPaneEvents({ + viewports: [this._viewportTopR, this._viewportBottomL, this._viewportBottomR], + canvases: [this._canvasTopR, this._canvasBottomL, this._canvasBottomR], + headerScrollers: [this._headerScrollerR], + headerRowScrollers: [this._headerRowScrollerR], + footerRows: this._options.createFooterRow ? [this._footerRowR] : [], + footerRowScrollers: this._options.createFooterRow ? [this._footerRowScrollerR] : [], + }); + + if (this._options.createPreHeaderPanel) { + this._bindingEventService.bind(this._preHeaderPanelScrollerR, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._preHeaderPanelScrollerR, 'click', this.handlePreHeaderClick.bind(this) as EventListener); + } + + // sort clicks for the new right header container + this.setupColumnSort([this._headerR]); + + // the ancestor-scroll anchor canvas may have changed band + this.unbindAncestorScrollEvents(); + this.bindAncestorScrollEvents(); + } + } + protected finishInitialization() { if (!this.initialized) { this.initialized = true; @@ -1694,18 +1911,6 @@ export class SlickGrid = Column, O e this.disableSelection(this._headers); // disable all text selection in header (including input and textarea) - if (!this._options.enableTextSelectionOnCells) { - // disable text selection in grid cells except in input and textarea elements - this._viewport.forEach((view) => { - this._bindingEventService.bind(view, 'selectstart', (event) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { - return; - } - event.preventDefault(); - }); - }); - } - this.setFrozenOptions(); this.setPaneFrozenClasses(); this.setPaneVisibility(); @@ -1721,39 +1926,16 @@ export class SlickGrid = Column, O e this.bindAncestorScrollEvents(); this._bindingEventService.bind(this._container, 'resize', this.resizeCanvas.bind(this)); - this._viewport.forEach((view) => { - this._bindingEventService.bind(view, 'scroll', this.handleScroll.bind(this)); - }); - - if (this._options.enableMouseWheelScrollHandler) { - this._viewport.forEach((view) => { - this.slickMouseWheelInstances.push(MouseWheel({ - element: view, - onMouseWheel: this.handleMouseWheel.bind(this) - })); - }); - } - this._headerScroller.forEach((el) => { - this._bindingEventService.bind(el, 'contextmenu', this.handleHeaderContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(el, 'click', this.handleHeaderClick.bind(this) as EventListener); + this.bindPaneEvents({ + viewports: this._viewport, + canvases: this._canvas, + headerScrollers: this._headerScroller, + headerRowScrollers: this._headerRowScroller, + footerRows: this._footerRow, + footerRowScrollers: this._footerRowScroller, }); - this._headerRowScroller.forEach((scroller) => { - this._bindingEventService.bind(scroller, 'scroll', this.handleHeaderRowScroll.bind(this) as EventListener); - }); - - if (this._options.createFooterRow) { - this._footerRow.forEach((footer) => { - this._bindingEventService.bind(footer, 'contextmenu', this.handleFooterContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(footer, 'click', this.handleFooterClick.bind(this) as EventListener); - }); - - this._footerRowScroller.forEach((scroller) => { - this._bindingEventService.bind(scroller, 'scroll', this.handleFooterRowScroll.bind(this) as EventListener); - }); - } - if (this._options.createTopHeaderPanel) { this._bindingEventService.bind(this._topHeaderPanelScroller, 'scroll', this.handleTopHeaderPanelScroll.bind(this) as EventListener); } @@ -1769,15 +1951,6 @@ export class SlickGrid = Column, O e this._bindingEventService.bind(this._focusSink, 'keydown', this.handleKeyDown.bind(this) as EventListener); this._bindingEventService.bind(this._focusSink2, 'keydown', this.handleKeyDown.bind(this) as EventListener); - this._canvas.forEach((element) => { - this._bindingEventService.bind(element, 'keydown', this.handleKeyDown.bind(this) as EventListener); - this._bindingEventService.bind(element, 'click', this.handleClick.bind(this) as EventListener); - this._bindingEventService.bind(element, 'dblclick', this.handleDblClick.bind(this) as EventListener); - this._bindingEventService.bind(element, 'contextmenu', this.handleContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(element, 'mouseover', this.handleCellMouseOver.bind(this) as EventListener); - this._bindingEventService.bind(element, 'mouseout', this.handleCellMouseOut.bind(this) as EventListener); - }); - if (Draggable) { this.slickDraggableInstance = Draggable({ containerElement: this._container, @@ -2421,8 +2594,8 @@ export class SlickGrid = Column, O e * --> triggers onBeforeSort * --> and if not cancelled, updates the sort columns and triggers onSort. */ - protected setupColumnSort() { - this._headers.forEach((header) => { + protected setupColumnSort(headers: HTMLDivElement[] = this._headers) { + headers.forEach((header) => { this._bindingEventService.bind(header, 'click', (e: any) => { if (this.columnResizeDragging) { return; @@ -3175,6 +3348,12 @@ export class SlickGrid = Column, O e actualFrozenRow: this.actualFrozenRow, frozenBottom: !!this._options.frozenBottom, }); + + // materialize the secondary panes if freezing was just enabled on a lazyPanes grid + // (runs before setScroller/setColumns in the internal_setOptions pipeline) + if (this._options.frozenColumn! > -1 || this.hasFrozenRows) { + this.materializeLazyPanes(); + } } ////////////////////////////////////////////////////////////////////////////////////////////// From 3a2099c1ba463055b3b62dd951843eae79e0763d Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Sun, 12 Jul 2026 23:52:00 +0930 Subject: [PATCH 12/43] refactor: derive band-count freeze view in ViewportMgr (Phase 4, milestone 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewportFreezeState gains frozenRowCount (the frozenRow option value) and updateFreezeState derives a FreezeBandCounts view — frozenLeftCols / frozenRightCols (0 until right-frozen columns land) / frozenTopRows / frozenBottomRows — alongside the authoritative legacy snapshot. hasFrozenColumns() is the first predicate re-expressed in band terms (frozenLeftCols > 0, provably equivalent to frozenColumnIdx > -1). Pure refactor, no behaviour change. Full suite: one ambient flake (example-plugin-headerbuttons, passes 9/9 in isolation — third distinct victim of the machine-level ~1-in-4 full-run flake, after excel-clipboard on the phase branch and row-span on pristine master), then a fully green re-run (613 tests, 612 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index f6e3de91..8ccf395a 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -152,6 +152,21 @@ interface ViewportFreezeState { hasFrozenRows: boolean; actualFrozenRow: number; frozenBottom: boolean; + /** the frozenRow option value — number of rows in the frozen row band (0/-1 when none) */ + frozenRowCount?: number; +} + +/** + * Band-count view of the freeze configuration (Phase 4 groundwork for the 3×3 band + * model): a zero count means the band does not exist. Derived by updateFreezeState + * from the legacy freeze snapshot; frozenRightCols stays 0 until right-frozen + * columns land. + */ +interface FreezeBandCounts { + frozenLeftCols: number; + frozenRightCols: number; + frozenTopRows: number; + frozenBottomRows: number; } /** Geometry inputs for ViewportMgr.applyCanvasWidths — computed by the grid, distributed by the manager. */ @@ -572,15 +587,31 @@ class ViewportMgr { ////////////////////////////////////////////////////////////////////////////////////////////// protected freeze: ViewportFreezeState = { frozenColumnIdx: -1, hasFrozenRows: false, actualFrozenRow: -1, frozenBottom: false }; + protected bands: FreezeBandCounts = { frozenLeftCols: 0, frozenRightCols: 0, frozenTopRows: 0, frozenBottomRows: 0 }; /** Receives the grid's freeze configuration; called by SlickGrid.setFrozenOptions(). */ updateFreezeState(f: ViewportFreezeState) { this.freeze = { ...f }; + + // derive the band-count view (Phase 4 groundwork); the legacy fields above stay + // authoritative for the existing 2×2 code paths + const rowCount = f.hasFrozenRows ? Math.max(0, f.frozenRowCount ?? 0) : 0; + this.bands = { + frozenLeftCols: f.frozenColumnIdx + 1, + frozenRightCols: 0, // right-frozen columns arrive with the 3×3 band model + frozenTopRows: f.frozenBottom ? 0 : rowCount, + frozenBottomRows: f.frozenBottom ? rowCount : 0, + }; + } + + /** Band-count view of the freeze configuration (zero count = band does not exist). */ + bandCounts(): FreezeBandCounts { + return this.bands; } /** Returns a boolean indicating whether the grid is configured with frozen columns. */ hasFrozenColumns() { - return this.freeze.frozenColumnIdx > -1; + return this.bands.frozenLeftCols > 0; } /** Returns a boolean indicating whether the grid is configured with frozen rows. */ @@ -3347,6 +3378,7 @@ export class SlickGrid = Column, O e hasFrozenRows: this.hasFrozenRows, actualFrozenRow: this.actualFrozenRow, frozenBottom: !!this._options.frozenBottom, + frozenRowCount: this._options.frozenRow!, }); // materialize the secondary panes if freezing was just enabled on a lazyPanes grid From 4e5ebdccabc39b045d307cf6b4fa6d6c7a676aa9 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 00:00:27 +0930 Subject: [PATCH 13/43] chore(tests): retry once in headless Cypress runs to absorb machine-load flakes Local full-suite runs showed an ambient ~1-in-4 flake in timing-sensitive tests (native clipboard paste, render waits) reproduced on pristine master with three different victim specs across runs. runMode-only retry absorbs these while Cypress still flags retried tests as flaky; openMode stays at 0 so interactive debugging sees raw failures. Matches the { retries: 1 } several specs already carry individually. Co-Authored-By: Claude Fable 5 --- cypress/cypress.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cypress/cypress.config.ts b/cypress/cypress.config.ts index 31490b14..134dd8fa 100644 --- a/cypress/cypress.config.ts +++ b/cypress/cypress.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ video: false, viewportWidth: 1200, viewportHeight: 900, + // retry once in headless runs only: absorbs machine-load flakes in timing-sensitive + // tests (native clipboard paste, render waits) while Cypress still reports retried + // tests as flaky, so real intermittent bugs stay visible + retries: { runMode: 1, openMode: 0 }, e2e: { experimentalRunAllSpecs: true, testIsolation: false, From 09dd6539eda2db669e37e7f9746049707060a086 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 00:14:58 +0930 Subject: [PATCH 14/43] =?UTF-8?q?feat:=20add=20frozenRightColumn=20option?= =?UTF-8?q?=20plumbing=20(Phase=204,=20milestone=2013a=20=E2=80=94=20inert?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New grid option frozenRightColumn: a COUNT of columns to freeze at the right edge (not an index like frozenColumn — counts stay correct under column reorder/hide). setFrozenOptions normalizes it (non-negative integer; the left and right bands must leave at least one scrollable column between them) and pushes it into ViewportMgr's band-count state as frozenRightCols. Nothing consumes the band yet — the right- frozen DOM, routing and geometry arrive in the following M13 stages — so behaviour is unchanged: full Cypress suite green, matching baseline. Co-Authored-By: Claude Fable 5 --- src/models/gridOption.interface.ts | 8 ++++++++ src/slick.grid.ts | 13 ++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/models/gridOption.interface.ts b/src/models/gridOption.interface.ts index 74bb8c2d..053ed2df 100644 --- a/src/models/gridOption.interface.ts +++ b/src/models/gridOption.interface.ts @@ -244,6 +244,14 @@ export interface GridOption { /** Number of row index(es) to freeze (pin) in the grid */ frozenRow?: number; + /** + * Defaults to 0. Number of columns to freeze (pin) at the RIGHT edge of the grid. + * Note this is a COUNT from the right, not a column index like `frozenColumn` — + * counts stay correct when columns are reordered or hidden. + * (Phase 4 of the ViewportMgr refactor; no effect until the right-frozen band lands.) + */ + frozenRightColumn?: number; + /** * Defaults to 100, what is the minimum width to keep for the section on the right of a frozen grid? * This basically fixes an issue that if the user expand any column on the left of the frozen (pinning) section diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 8ccf395a..a694dfe6 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -154,6 +154,8 @@ interface ViewportFreezeState { frozenBottom: boolean; /** the frozenRow option value — number of rows in the frozen row band (0/-1 when none) */ frozenRowCount?: number; + /** the frozenRightColumn option value — number of columns frozen at the right edge (0 when none) */ + frozenRightColCount?: number; } /** @@ -598,7 +600,7 @@ class ViewportMgr { const rowCount = f.hasFrozenRows ? Math.max(0, f.frozenRowCount ?? 0) : 0; this.bands = { frozenLeftCols: f.frozenColumnIdx + 1, - frozenRightCols: 0, // right-frozen columns arrive with the 3×3 band model + frozenRightCols: Math.max(0, f.frozenRightColCount ?? 0), // band DOM arrives with M13's later stages frozenTopRows: f.frozenBottom ? 0 : rowCount, frozenBottomRows: f.frozenBottom ? rowCount : 0, }; @@ -1272,6 +1274,7 @@ export class SlickGrid = Column, O e frozenBottom: false, frozenColumn: -1, frozenRow: -1, + frozenRightColumn: 0, frozenRightViewportMinWidth: 100, lazyPanes: false, throwWhenFrozenNotAllViewable: false, @@ -3361,6 +3364,13 @@ export class SlickGrid = Column, O e ? parseInt(this._options.frozenColumn as unknown as string, 10) : -1; + // normalize the right-frozen column COUNT: non-negative integer, and the left and + // right bands must leave at least one scrollable column between them + const maxRightCols = Math.max(0, this.columns.length - (this._options.frozenColumn! + 1) - 1); + this._options.frozenRightColumn = (this._options.frozenRightColumn! > 0) + ? Math.min(parseInt(this._options.frozenRightColumn as unknown as string, 10), maxRightCols) + : 0; + if (this._options.frozenRow! > -1) { this.hasFrozenRows = true; this.frozenRowsHeight = (this._options.frozenRow!) * this._options.rowHeight!; @@ -3379,6 +3389,7 @@ export class SlickGrid = Column, O e actualFrozenRow: this.actualFrozenRow, frozenBottom: !!this._options.frozenBottom, frozenRowCount: this._options.frozenRow!, + frozenRightColCount: this._options.frozenRightColumn!, }); // materialize the secondary panes if freezing was just enabled on a lazyPanes grid From 4078fade0283dc01ba26176044ac099db4e2d94b Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 03:42:45 +0930 Subject: [PATCH 15/43] feat: materialize the right-frozen column band DOM (Phase 4, milestone 13b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewportMgr.materializeRightFrozenBand() builds three new panes with NEW *-right-frozen css classes (appended after the classic six so classic sibling positions are untouched — the historical 'right' elements keep their names and become the scrollable middle band), plus header/header-row/top-panel chrome, viewports, canvases and footer row; shared element arrays extend at the END so classic indexes 0-3 stay valid. Wired for both init-time (setFrozenOptions runs before the event-binding loops) and runtime enabling, with events bound to the new elements only; applyPaneVisibility shows/hides the band; un-freezing keeps it hidden in the DOM, matching classic pane behaviour. Staged state, pinned by viewportmgr-right-frozen-band.cy.ts (8 tests + new example page): the band exists and is visible, but cells still render in the classic canvases until geometry (M13c) and routing (M13d) land. Full suite green: 623 tests (622 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- .../e2e/viewportmgr-right-frozen-band.cy.ts | 91 +++++++++++ examples/example-frozen-right-columns.html | 74 +++++++++ src/slick.grid.ts | 154 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 cypress/e2e/viewportmgr-right-frozen-band.cy.ts create mode 100644 examples/example-frozen-right-columns.html diff --git a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts new file mode 100644 index 00000000..d863734d --- /dev/null +++ b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts @@ -0,0 +1,91 @@ +/** + * DOM-shape characterization for the right-frozen column band (Phase 4, M13b of the + * ViewportMgr refactor). At this stage the band's DOM materializes (with NEW + * `*-right-frozen` css classes; the historical "right" elements keep their names and + * become the scrollable middle band), but geometry and render routing land in later + * milestones — so cells still render in the classic canvases, and these tests pin + * exactly that staged state. + * + * Named to sort after the example-* specs (shared browser session; see + * viewportmgr-lazy-materialization.cy.ts). + */ + +describe('right-frozen band DOM - frozenRightColumn at init (example-frozen-right-columns)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-right-columns.html`); + }); + + it('should build 9 panes: the classic six in canonical order plus three right-frozen panes after them', () => { + cy.get('#myGrid > .slick-pane').should('have.length', 9).then(($panes) => { + const expected = [ + ['slick-pane-header', 'slick-pane-left'], + ['slick-pane-header', 'slick-pane-right'], + ['slick-pane-top', 'slick-pane-left'], + ['slick-pane-top', 'slick-pane-right'], + ['slick-pane-bottom', 'slick-pane-left'], + ['slick-pane-bottom', 'slick-pane-right'], + ['slick-pane-header', 'slick-pane-right-frozen'], + ['slick-pane-top', 'slick-pane-right-frozen'], + ['slick-pane-bottom', 'slick-pane-right-frozen'], + ]; + expected.forEach((classes, i) => { + classes.forEach((cls) => expect($panes.eq(i), `pane ${i} has .${cls}`).to.have.class(cls)); + }); + }); + }); + + it('should build 6 viewports and 6 canvases with the right-frozen ones correctly nested', () => { + cy.get('#myGrid .slick-viewport').should('have.length', 6); + cy.get('#myGrid .grid-canvas').should('have.length', 6); + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen > .slick-viewport.slick-viewport-top.slick-viewport-right-frozen').should('have.length', 1); + cy.get('#myGrid .slick-viewport-top.slick-viewport-right-frozen > .grid-canvas.grid-canvas-top.grid-canvas-right-frozen').should('have.length', 1); + cy.get('#myGrid .slick-pane-bottom.slick-pane-right-frozen > .slick-viewport-bottom.slick-viewport-right-frozen > .grid-canvas-bottom.grid-canvas-right-frozen').should('have.length', 1); + }); + + it('should build right-frozen header chrome', () => { + cy.get('#myGrid .slick-pane-header.slick-pane-right-frozen > .slick-header.slick-header-right-frozen').should('have.length', 1); + cy.get('#myGrid .slick-header-right-frozen > .slick-header-columns.slick-header-columns-right-frozen').should('have.length', 1); + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen > .slick-headerrow').should('have.length', 1); + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen > .slick-top-panel-scroller').should('have.length', 1); + }); + + it('should show the right-frozen header and top panes, and hide its bottom pane (no frozen rows)', () => { + cy.get('#myGrid .slick-pane-header.slick-pane-right-frozen').should('be.visible'); + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen').should('exist'); + cy.get('#myGrid .slick-pane-bottom.slick-pane-right-frozen').should('not.be.visible'); + }); + + it('should still render all cells in the classic canvases at this stage (routing lands in a later milestone)', () => { + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').should('have.length', 0); + cy.get('#myGrid .slick-header-columns-right-frozen .slick-header-column').should('have.length', 0); + cy.get('#myGrid .grid-canvas .slick-row .slick-cell').first().should('contain', 'Task 0'); + }); +}); + +describe('right-frozen band DOM - runtime materialization on a classic grid', () => { + it('should load the plain example and materialize the band via setOptions', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple.html`); + cy.get('#myGrid > .slick-pane').should('have.length', 6); + + cy.window().then((win: any) => { + win.grid.setOptions({ frozenRightColumn: 1 }); + }); + + cy.get('#myGrid > .slick-pane').should('have.length', 9); + cy.get('#myGrid .slick-viewport').should('have.length', 6); + cy.get('#myGrid .grid-canvas').should('have.length', 6); + cy.get('#myGrid .slick-pane-header.slick-pane-right-frozen').should('be.visible'); + }); + + it('should hide the band again when the right freeze is turned off, keeping it in the DOM', () => { + cy.window().then((win: any) => { + win.grid.setOptions({ frozenRightColumn: 0 }); + }); + + cy.get('#myGrid > .slick-pane').should('have.length', 9); + cy.get('#myGrid .slick-pane-header.slick-pane-right-frozen').should('not.be.visible'); + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen').should('not.be.visible'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + }); +}); diff --git a/examples/example-frozen-right-columns.html b/examples/example-frozen-right-columns.html new file mode 100644 index 00000000..41aba03e --- /dev/null +++ b/examples/example-frozen-right-columns.html @@ -0,0 +1,74 @@ + + + + + + SlickGrid example: right-frozen columns (Phase 4) + + + + +

Example: frozenRightColumn - right-frozen column band

+ + + + + +
+
+
+
+

+ + Demonstrates: +

+
+
    +
  • basic grid with minimal configuration
  • +
+

View Source:

+ +
+ + + + + + + + + + + diff --git a/src/slick.grid.ts b/src/slick.grid.ts index a694dfe6..139e01a7 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -294,6 +294,25 @@ class ViewportMgr { canvasBottomR!: HTMLDivElement; canvas: HTMLDivElement[] = []; + // right-frozen band (Phase 4 — exists only while frozenRightColumn > 0 has been applied) + paneHeaderRF!: HTMLDivElement; + paneTopRF!: HTMLDivElement; + paneBottomRF!: HTMLDivElement; + headerScrollerRF!: HTMLDivElement; + headerRF!: HTMLDivElement; + headerRowScrollerRF!: HTMLDivElement; + headerRowSpacerRF!: HTMLDivElement; + headerRowRF!: HTMLDivElement; + topPanelScrollerRF!: HTMLDivElement; + topPanelRF!: HTMLDivElement; + viewportTopRF!: HTMLDivElement; + viewportBottomRF!: HTMLDivElement; + canvasTopRF!: HTMLDivElement; + canvasBottomRF!: HTMLDivElement; + footerRowScrollerRF!: HTMLDivElement; + footerRowSpacerRF!: HTMLDivElement; + footerRowRF!: HTMLDivElement; + // footer rows (only when createFooterRow) footerRowScrollerL!: HTMLDivElement; footerRowScrollerR!: HTMLDivElement; @@ -584,6 +603,95 @@ class ViewportMgr { return true; } + /** + * Builds the right-frozen column band (Phase 4): three panes with NEW + * `*-right-frozen` css classes, appended AFTER the six classic panes so classic + * sibling positions are untouched, plus header/header-row/top-panel chrome, + * viewports and canvases. Shared element arrays are extended at the END so the + * classic indexes 0–3 (and [L, R] pairs) stay valid for every existing consumer. + * Idempotent: returns false when the band already exists. + * + * The historical "right" elements keep their class names and become the scrollable + * MIDDLE band while this band is active. + */ + materializeRightFrozenBand(o: ViewportMgrBuildOptions): boolean { + if (this.paneHeaderRF) { + return false; + } + + const container = this.container; + + // panes — appended after the classic six (still before the trailing focus sink, + // which the grid appends after all panes) + this.paneHeaderRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right-frozen', tabIndex: 0 }); + this.paneTopRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right-frozen', tabIndex: 0 }); + this.paneBottomRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right-frozen', tabIndex: 0 }); + // insert as a block after the last classic pane (paneBottomR when it exists, + // else the lazy grid's paneTopL) + const lastClassicPane = this.paneBottomR ?? this.paneTopL; + container.insertBefore(this.paneHeaderRF, lastClassicPane.nextSibling); + container.insertBefore(this.paneTopRF, this.paneHeaderRF.nextSibling); + container.insertBefore(this.paneBottomRF, this.paneTopRF.nextSibling); + + // header chrome + this.headerScrollerRF = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right-frozen' }, this.paneHeaderRF); + this.headerScroller.push(this.headerScrollerRF); + this.headerRF = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right-frozen', role: 'row', style: { left: '-1000px' } }, this.headerScrollerRF); + this.headers.push(this.headerRF); + + // header row + this.headerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopRF); + this.headerRowScroller.push(this.headerRowScrollerRF); + this.headerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerRF); + this.headerRowRF = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right-frozen' }, this.headerRowScrollerRF); + this.headerRows.push(this.headerRowRF); + + // top panel + this.topPanelScrollerRF = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopRF); + this.topPanelScrollers.push(this.topPanelScrollerRF); + this.topPanelRF = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerRF); + this.topPanels.push(this.topPanelRF); + + if (!o.showColumnHeader) { + Utils.hide(this.headerScrollerRF); + } + if (!o.showTopPanel) { + Utils.hide(this.topPanelScrollerRF); + } + if (!o.showHeaderRow) { + Utils.hide(this.headerRowScrollerRF); + } + + // viewports and canvases (array order extended at the END: classic 0–3 preserved) + this.viewportTopRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right-frozen', tabIndex: 0 }, this.paneTopRF); + this.viewportBottomRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomRF); + this.viewport.push(this.viewportTopRF, this.viewportBottomRF); + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + this.viewportTopRF.classList.add(...viewportClassList); + this.viewportBottomRF.classList.add(...viewportClassList); + } + + this.canvasTopRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right-frozen', tabIndex: 0 }, this.viewportTopRF); + this.canvasBottomRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomRF); + this.canvas.push(this.canvasTopRF, this.canvasBottomRF); + + // footer row + if (o.createFooterRow) { + this.footerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopRF); + this.footerRowScroller.push(this.footerRowScrollerRF); + this.footerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerRF); + this.footerRowRF = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right-frozen' }, this.footerRowScrollerRF); + this.footerRow.push(this.footerRowRF); + + if (!o.showFooterRow) { + Utils.hide(this.footerRowScrollerRF); + } + } + + return true; + } + ////////////////////////////////////////////////////////////////////////////////////////////// // Freeze state and pane selection (Phase 2 of the encapsulation refactor) ////////////////////////////////////////////////////////////////////////////////////////////// @@ -765,6 +873,22 @@ class ViewportMgr { this.hideIf(this.paneBottomL); } } + + // right-frozen band (exists only after materialization; kept hidden — like the + // classic panes — when the right freeze is turned off again) + if (this.bands.frozenRightCols > 0) { + this.showIf(this.paneHeaderRF); + this.showIf(this.paneTopRF); + if (this.freeze.hasFrozenRows) { + this.showIf(this.paneBottomRF); + } else { + this.hideIf(this.paneBottomRF); + } + } else { + this.hideIf(this.paneHeaderRF); + this.hideIf(this.paneTopRF); + this.hideIf(this.paneBottomRF); + } } /** @@ -3397,6 +3521,36 @@ export class SlickGrid = Column, O e if (this._options.frozenColumn! > -1 || this.hasFrozenRows) { this.materializeLazyPanes(); } + + // materialize the right-frozen band the first time a right freeze is applied + if (this._options.frozenRightColumn! > 0) { + this.materializeRightFrozenPanes(); + } + } + + /** + * Builds the right-frozen band on first use (init-time via finishInitialization's + * setFrozenOptions call — before the event-binding loops — or at runtime via + * setOptions) and wires events for the new elements when the grid is already live. + */ + protected materializeRightFrozenPanes() { + if (!this._viewportMgr.materializeRightFrozenBand(this._options)) { + return; + } + + if (this.initialized) { + const vm = this._viewportMgr; + this.disableSelection([vm.headerRF]); + this.bindPaneEvents({ + viewports: [vm.viewportTopRF, vm.viewportBottomRF], + canvases: [vm.canvasTopRF, vm.canvasBottomRF], + headerScrollers: [vm.headerScrollerRF], + headerRowScrollers: [vm.headerRowScrollerRF], + footerRows: this._options.createFooterRow ? [vm.footerRowRF] : [], + footerRowScrollers: this._options.createFooterRow ? [vm.footerRowScrollerRF] : [], + }); + this.setupColumnSort([vm.headerRF]); + } } ////////////////////////////////////////////////////////////////////////////////////////////// From 418084cbb300236a5f994366f45c1e11e1b3fbec Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 04:01:11 +0930 Subject: [PATCH 16/43] =?UTF-8?q?feat:=20right-frozen=20band=20geometry=20?= =?UTF-8?q?=E2=80=94=20widths,=20placement,=20heights,=20Y-following=20(Ph?= =?UTF-8?q?ase=204,=20milestone=2013c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Three-way width split: getCanvasWidth/getHeadersWidth accumulate canvasWidthRF/ headersWidthRF for the last frozenRightColumn VISIBLE columns (boundary via getFrozenRightStartIdx; identical behaviour when the band is off). - applyCanvasWidths pins the RF panes at the right edge and shrinks the scrollable middle band by the band width — in both the left-frozen layout and the no-left-freeze layout (where the historical '100%' widths become pixel widths while the band is active). - applyPaneHeights mirrors the classic right-pane vertical geometry for the band, including frozen-row canvas heights. - RF viewports keep both overflows hidden (never own a scrollbar) and follow Y via syncVerticalFollowers, scrollTo and updateRowCount. Scroll-owner selection needed NO change: the middle band was already the owner under the historical naming. Cells still render in the classic canvases until M13d routing. Spec grows a geometry test (band sized, right-pinned, middle+RF fill the container). Full suite green (622 tests, 621 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- .../e2e/viewportmgr-right-frozen-band.cy.ts | 29 +++ src/slick.grid.ts | 176 ++++++++++++++++-- 2 files changed, 191 insertions(+), 14 deletions(-) diff --git a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts index d863734d..87799b0d 100644 --- a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts +++ b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts @@ -55,6 +55,35 @@ describe('right-frozen band DOM - frozenRightColumn at init (example-frozen-righ cy.get('#myGrid .slick-pane-bottom.slick-pane-right-frozen').should('not.be.visible'); }); + it('should size the band and pin it to the right edge (M13c geometry)', () => { + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen').then(($pane) => { + const pane = $pane[0] as HTMLElement; + const container = pane.parentElement as HTMLElement; + expect(pane.offsetWidth, 'RF pane has real width').to.be.greaterThan(0); + expect(pane.offsetLeft + pane.offsetWidth, 'RF pane pinned at the right edge') + .to.be.closeTo(container.clientWidth, 3); + }); + + // the scrollable middle band shrinks by the RF band width + cy.get('#myGrid .slick-pane-top.slick-pane-left').then(($mid) => { + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen').then(($rf) => { + const mid = $mid[0] as HTMLElement; + const rf = $rf[0] as HTMLElement; + const container = mid.parentElement as HTMLElement; + expect(mid.offsetWidth + rf.offsetWidth, 'middle + RF widths fill the container') + .to.be.closeTo(container.clientWidth, 3); + }); + }); + + // RF viewport and canvas carry the band width + cy.get('#myGrid .slick-viewport-top.slick-viewport-right-frozen').then(($vp) => { + expect(($vp[0] as HTMLElement).offsetWidth).to.be.greaterThan(0); + }); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen').then(($c) => { + expect(($c[0] as HTMLElement).offsetWidth).to.be.greaterThan(0); + }); + }); + it('should still render all cells in the classic canvases at this stage (routing lands in a later milestone)', () => { cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').should('have.length', 0); diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 139e01a7..b332a1b3 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -177,8 +177,10 @@ interface CanvasWidthsGeometry { canvasWidth: number; canvasWidthL: number; canvasWidthR: number; + canvasWidthRF: number; headersWidthL: number; headersWidthR: number; + headersWidthRF: number; viewportW: number; viewportHasVScroll: boolean; scrollbarWidth: number; @@ -917,6 +919,17 @@ class ViewportMgr { this.viewportBottomR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'auto' : 'auto') : (hasFrozenRows ? 'auto' : 'auto')); } + // right-frozen viewports never own a scrollbar: X is fixed, Y follows the + // scroll owner programmatically (same rationale as the frozen-left viewport) + if (this.viewportTopRF) { + this.viewportTopRF.style.overflowX = 'hidden'; + this.viewportTopRF.style.overflowY = 'hidden'; + } + if (this.viewportBottomRF) { + this.viewportBottomRF.style.overflowX = 'hidden'; + this.viewportBottomRF.style.overflowY = 'hidden'; + } + if (o.viewportClass) { const viewportClassList = Utils.classNameToList(o.viewportClass); // this.viewport only ever contains the elements that were actually built @@ -938,7 +951,12 @@ class ViewportMgr { * of SlickGrid.updateCanvasWidth(); the width computations stay in the grid. */ applyCanvasWidths(g: CanvasWidthsGeometry) { - if (g.widthChanged || this.hasFrozenColumns() || this.freeze.hasFrozenRows) { + // width reserved by the right-frozen band (0 while the band is off or not built); + // the scrollable middle band shrinks by this amount + const rfActive = this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; + const rfW = rfActive ? g.canvasWidthRF : 0; + + if (g.widthChanged || this.hasFrozenColumns() || this.freeze.hasFrozenRows || rfActive) { Utils.width(this.canvasTopL, g.canvasWidthL); Utils.width(this.headerL, g.headersWidthL); @@ -951,21 +969,21 @@ class ViewportMgr { Utils.width(this.paneHeaderL, g.canvasWidthL); Utils.setStyleSize(this.paneHeaderR, 'left', g.canvasWidthL); - Utils.setStyleSize(this.paneHeaderR, 'width', g.viewportW - g.canvasWidthL); + Utils.setStyleSize(this.paneHeaderR, 'width', g.viewportW - g.canvasWidthL - rfW); Utils.width(this.paneTopL, g.canvasWidthL); Utils.setStyleSize(this.paneTopR, 'left', g.canvasWidthL); - Utils.width(this.paneTopR, g.viewportW - g.canvasWidthL); + Utils.width(this.paneTopR, g.viewportW - g.canvasWidthL - rfW); Utils.width(this.headerRowScrollerL, g.canvasWidthL); - Utils.width(this.headerRowScrollerR, g.viewportW - g.canvasWidthL); + Utils.width(this.headerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); Utils.width(this.headerRowL, g.canvasWidthL); Utils.width(this.headerRowR, g.canvasWidthR); if (g.createFooterRow) { Utils.width(this.footerRowScrollerL, g.canvasWidthL); - Utils.width(this.footerRowScrollerR, g.viewportW - g.canvasWidthL); + Utils.width(this.footerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); Utils.width(this.footerRowL, g.canvasWidthL); Utils.width(this.footerRowR, g.canvasWidthR); @@ -974,18 +992,41 @@ class ViewportMgr { Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); } Utils.width(this.viewportTopL, g.canvasWidthL); - Utils.width(this.viewportTopR, g.viewportW - g.canvasWidthL); + Utils.width(this.viewportTopR, g.viewportW - g.canvasWidthL - rfW); if (this.freeze.hasFrozenRows) { Utils.width(this.paneBottomL, g.canvasWidthL); Utils.setStyleSize(this.paneBottomR, 'left', g.canvasWidthL); Utils.width(this.viewportBottomL, g.canvasWidthL); - Utils.width(this.viewportBottomR, g.viewportW - g.canvasWidthL); + Utils.width(this.viewportBottomR, g.viewportW - g.canvasWidthL - rfW); Utils.width(this.canvasBottomL, g.canvasWidthL); Utils.width(this.canvasBottomR, g.canvasWidthR); } + } else if (rfActive) { + // no left freeze, but a right-frozen band: the left pane IS the scrollable + // middle band — pixel widths instead of the historical '100%' + const middleW = g.viewportW - rfW; + Utils.width(this.paneHeaderL, middleW); + Utils.width(this.paneTopL, middleW); + Utils.width(this.headerRowScrollerL, middleW); + Utils.width(this.headerRowL, g.canvasWidth); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerL, middleW); + Utils.width(this.footerRowL, g.canvasWidth); + } + + if (g.createPreHeaderPanel) { + Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); + } + Utils.width(this.viewportTopL, middleW); + + if (this.freeze.hasFrozenRows) { + Utils.width(this.viewportBottomL, middleW); + Utils.width(this.canvasBottomL, g.canvasWidthL); + } } else { Utils.width(this.paneHeaderL, '100%'); Utils.width(this.paneTopL, '100%'); @@ -1007,6 +1048,33 @@ class ViewportMgr { Utils.width(this.canvasBottomL, g.canvasWidthL); } } + + // right-frozen band: fixed-width panes pinned to the right edge + if (rfActive) { + const rfLeft = g.viewportW - rfW; + Utils.setStyleSize(this.paneHeaderRF, 'left', rfLeft); + Utils.width(this.paneHeaderRF, rfW); + Utils.width(this.headerRF, g.headersWidthRF); + + Utils.setStyleSize(this.paneTopRF, 'left', rfLeft); + Utils.width(this.paneTopRF, rfW); + Utils.width(this.headerRowScrollerRF, rfW); + Utils.width(this.headerRowRF, g.canvasWidthRF); + Utils.width(this.viewportTopRF, rfW); + Utils.width(this.canvasTopRF, g.canvasWidthRF); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerRF, rfW); + Utils.width(this.footerRowRF, g.canvasWidthRF); + } + + if (this.freeze.hasFrozenRows) { + Utils.setStyleSize(this.paneBottomRF, 'left', rfLeft); + Utils.width(this.paneBottomRF, rfW); + Utils.width(this.viewportBottomRF, rfW); + Utils.width(this.canvasBottomRF, g.canvasWidthRF); + } + } } Utils.width(this.headerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); @@ -1130,6 +1198,30 @@ class ViewportMgr { } } + // right-frozen band: mirror the classic right-pane vertical geometry + if (this.bands.frozenRightCols > 0 && this.paneHeaderRF) { + let topHeightOffsetRF = Utils.height(this.paneHeaderL); + if (topHeightOffsetRF) { + topHeightOffsetRF += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this.paneTopRF, 'top', topHeightOffsetRF as number); + Utils.height(this.paneTopRF, paneTopH); + Utils.height(this.viewportTopRF, viewportTopH); + + if (this.freeze.hasFrozenRows) { + const paneBottomTopRF = this.paneTopL.offsetTop + paneTopH; + Utils.setStyleSize(this.paneBottomRF, 'top', paneBottomTopRF); + Utils.height(this.paneBottomRF, paneBottomH); + Utils.height(this.viewportBottomRF, paneBottomH); + + if (this.freeze.frozenBottom) { + Utils.height(this.canvasBottomRF, g.frozenRowsHeight); + } else { + Utils.height(this.canvasTopRF, g.frozenRowsHeight); + } + } + } + return { paneTopH, paneBottomH, viewportTopH, viewportBottomH }; } @@ -1200,7 +1292,7 @@ class ViewportMgr { } } - /** Mirrors the Y scroll position onto the frozen-left viewport that follows the scroll owner. */ + /** Mirrors the Y scroll position onto the frozen-band viewports that follow the scroll owner. */ syncVerticalFollowers(scrollTop: number) { if (this.hasFrozenColumns()) { if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { @@ -1209,6 +1301,15 @@ class ViewportMgr { this.viewportTopL.scrollTop = scrollTop; } } + + // the right-frozen band's scrollable-body viewport follows Y the same way + if (this.bands.frozenRightCols > 0 && this.viewportTopRF) { + if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { + this.viewportBottomRF.scrollTop = scrollTop; + } else { + this.viewportTopRF.scrollTop = scrollTop; + } + } } selectScrollContainers(): { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; } { @@ -1528,9 +1629,11 @@ export class SlickGrid = Column, O e protected canvasWidth = 0; protected canvasWidthL = 0; protected canvasWidthR = 0; + protected canvasWidthRF = 0; protected headersWidth = 0; protected headersWidthL = 0; protected headersWidthR = 0; + protected headersWidthRF = 0; protected viewportHasHScroll = false; protected viewportHasVScroll = false; protected headerColumnWidthDiff = 0; @@ -3483,6 +3586,27 @@ export class SlickGrid = Column, O e * computes the frozenRowsHeight (based on rowHeight), and determines the actual frozen row index * depending on whether frozenBottom is enabled. */ + /** + * Index (into this.columns) of the first column belonging to the right-frozen band — + * the last `frozenRightColumn` VISIBLE columns. Returns columns.length when the band + * is off, so `i >= result` is always false in that case. + */ + protected getFrozenRightStartIdx(): number { + if (!(this._options.frozenRightColumn! > 0)) { + return this.columns.length; + } + let count = 0; + for (let i = this.columns.length - 1; i >= 0; i--) { + if (this.columns[i] && !this.columns[i].hidden) { + count++; + if (count === this._options.frozenRightColumn) { + return i; + } + } + } + return 0; + } + protected setFrozenOptions() { this._options.frozenColumn = (this._options.frozenColumn! >= 0 && this._options.frozenColumn! < this.columns.length) ? parseInt(this._options.frozenColumn as unknown as string, 10) @@ -5846,8 +5970,9 @@ export class SlickGrid = Column, O e * Returns the computed overall header width in pixels. */ getHeadersWidth() { - this.headersWidth = this.headersWidthL = this.headersWidthR = 0; + this.headersWidth = this.headersWidthL = this.headersWidthR = this.headersWidthRF = 0; const includeScrollbar = !this._options.autoHeight; + const rfStartIdx = this.getFrozenRightStartIdx(); let i = 0; const ii = this.columns.length; @@ -5856,7 +5981,10 @@ export class SlickGrid = Column, O e const width = this.columns[i].width; - if (this._viewportMgr.isColumnRightOfFreeze(i)) { + if (i >= rfStartIdx) { + // right-frozen headers are fixed-width (no horizontal scrolling): plain sum + this.headersWidthRF += width || 0; + } else if (this._viewportMgr.isColumnRightOfFreeze(i)) { this.headersWidthR += width || 0; } else { this.headersWidthL += width || 0; @@ -5895,18 +6023,21 @@ export class SlickGrid = Column, O e const availableWidth = this.getViewportInnerWidth(); let i = this.columns.length; - this.canvasWidthL = this.canvasWidthR = 0; + this.canvasWidthL = this.canvasWidthR = this.canvasWidthRF = 0; + const rfStartIdx = this.getFrozenRightStartIdx(); while (i--) { if (!this.columns[i] || this.columns[i].hidden) { continue; } - if (this._viewportMgr.isColumnRightOfFreeze(i)) { + if (i >= rfStartIdx) { + this.canvasWidthRF += this.columns[i].width || 0; + } else if (this._viewportMgr.isColumnRightOfFreeze(i)) { this.canvasWidthR += this.columns[i].width || 0; } else { this.canvasWidthL += this.columns[i].width || 0; } } - let totalRowWidth = this.canvasWidthL + this.canvasWidthR; + let totalRowWidth = this.canvasWidthL + this.canvasWidthR + this.canvasWidthRF; if (this._options.fullWidthRows) { const extraWidth = Math.max(totalRowWidth, availableWidth) - totalRowWidth; if (extraWidth > 0) { @@ -6016,13 +6147,14 @@ export class SlickGrid = Column, O e const oldCanvasWidth = this.canvasWidth; const oldCanvasWidthL = this.canvasWidthL; const oldCanvasWidthR = this.canvasWidthR; + const oldCanvasWidthRF = this.canvasWidthRF; this.canvasWidth = this.getCanvasWidth(); if (this._options.createTopHeaderPanel) { Utils.width(this._topHeaderPanel, this._options.topHeaderPanelWidth ?? this.canvasWidth); } - const widthChanged = this.canvasWidth !== oldCanvasWidth || this.canvasWidthL !== oldCanvasWidthL || this.canvasWidthR !== oldCanvasWidthR; + const widthChanged = this.canvasWidth !== oldCanvasWidth || this.canvasWidthL !== oldCanvasWidthL || this.canvasWidthR !== oldCanvasWidthR || this.canvasWidthRF !== oldCanvasWidthRF; // recompute the header width split only when the pane widths will be redistributed // (preserves the historical conditional side effect on headersWidthL/R) @@ -6035,8 +6167,10 @@ export class SlickGrid = Column, O e canvasWidth: this.canvasWidth, canvasWidthL: this.canvasWidthL, canvasWidthR: this.canvasWidthR, + canvasWidthRF: this.canvasWidthRF, headersWidthL: this.headersWidthL, headersWidthR: this.headersWidthR, + headersWidthRF: this.headersWidthRF, viewportW: this.viewportW, viewportHasVScroll: this.viewportHasVScroll, scrollbarWidth: this.scrollbarDimensions?.width ?? 0, @@ -7020,11 +7154,17 @@ export class SlickGrid = Column, O e if (this._viewportMgr.hasFrozenColumns()) { Utils.height(this._canvasBottomR, this.h); } + if (this._options.frozenRightColumn! > 0 && this._viewportMgr.canvasBottomRF) { + Utils.height(this._viewportMgr.canvasBottomRF, this.h); + } } else { Utils.height(this._canvasTopL, this.h); if (this._canvasTopR) { Utils.height(this._canvasTopR, this.h); } + if (this._options.frozenRightColumn! > 0 && this._viewportMgr.canvasTopRF) { + Utils.height(this._viewportMgr.canvasTopRF, this.h); + } } this.scrollTop = this._viewportScrollContainerY.scrollTop; @@ -7589,6 +7729,14 @@ export class SlickGrid = Column, O e this._viewportBottomL.scrollTop = this._viewportBottomR.scrollTop = newScrollTop; } + // right-frozen viewports follow programmatic Y scrolling too + if (this._options.frozenRightColumn! > 0 && this._viewportMgr.viewportTopRF) { + this._viewportMgr.viewportTopRF.scrollTop = newScrollTop; + if (this._viewportMgr.hasFrozenRows()) { + this._viewportMgr.viewportBottomRF.scrollTop = newScrollTop; + } + } + if (this._viewportScrollContainerY) { this._viewportScrollContainerY.scrollTop = newScrollTop; } From 9cb148c516bc15362871adb33a39929ccdf8ecc6 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 10:00:11 +0930 Subject: [PATCH 17/43] feat: route headers, cells and coordinates into the right-frozen band (Phase 4, milestone 13d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-frozen columns now work end-to-end: - createColumnHeaders/createColumnFooter route the band's columns via the new three-way bandElementForColumn (headerRF/headerRowRF/footerRowRF included in the destroy/empty/width passes). - appendRowHtml builds a third row fragment; renderRows collects divArrayRF and attachRow appends it to the RF canvases, growing rowsCache[].rowNode to 3 entries (RF fragment always last; rowNodeIdxForColumn encapsulates the index math). - applyColumnWidths and getCellNodeBox rebase RF columns to band-local coordinates; paneCellIndex maps RF cells to array slots 4/5 (materializeRightFrozenPanes canonicalizes the classic set first so those slots hold under lazyPanes). - cleanUpCells exempts the band (always horizontally visible); RF cells get the 'frozen' css class; ensureCellNodesInRowsCache generalizes to N fragments; the public getHeader/getHeaderColumn/getHeaderRowColumn/getFooterRowColumn getters resolve three-way with band-local child indexes. Spec asserts real routing: header split, RF row fragments with band-local x=0, runtime toggle restoring classic routing both directions. Verification note: the first full-suite run after this change failed 13 rowspan tests in example-0032 during an overnight machine-load window (3:41 vs the usual ~2:45); the spec passes 45/45 in isolation, with its preceding spec subset, and the full-suite re-run is completely green (623 tests, 622 pass / 1 pending) — consistent with the documented ambient flake on this machine, which also struck this same spec on pristine master. Co-Authored-By: Claude Fable 5 --- .../e2e/viewportmgr-right-frozen-band.cy.ts | 41 +++- src/slick.grid.ts | 180 ++++++++++++++---- 2 files changed, 180 insertions(+), 41 deletions(-) diff --git a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts index 87799b0d..acca7652 100644 --- a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts +++ b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts @@ -84,11 +84,34 @@ describe('right-frozen band DOM - frozenRightColumn at init (example-frozen-righ }); }); - it('should still render all cells in the classic canvases at this stage (routing lands in a later milestone)', () => { + it('should route the last two header columns into the right-frozen header (M13d routing)', () => { + cy.get('#myGrid .slick-header-columns-left .slick-header-column').should('have.length', 4); + cy.get('#myGrid .slick-header-columns-right-frozen .slick-header-column').should('have.length', 2); + cy.get('#myGrid .slick-header-columns-right-frozen .slick-header-column').first().should('contain', 'Finish'); + }); + + it('should render row fragments in the right-frozen canvas with band-local cell positions', () => { cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); - cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').should('have.length', 0); - cy.get('#myGrid .slick-header-columns-right-frozen .slick-header-column').should('have.length', 0); - cy.get('#myGrid .grid-canvas .slick-row .slick-cell').first().should('contain', 'Task 0'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').should('have.length.greaterThan', 0); + + // each RF row fragment carries exactly the two right-frozen cells + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').first().find('.slick-cell') + .should('have.length', 2); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').first().find('.slick-cell').first() + .should('contain', '01/05/2009') + .and('have.class', 'frozen'); + + // middle rows carry the remaining four cells, starting with Task 0 + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').first().find('.slick-cell') + .should('have.length', 4); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-cell').first().should('contain', 'Task 0'); + + // band-local coordinates: the first RF cell sits at the band origin, not at its + // global column offset + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').first().find('.slick-cell').first() + .then(($cell) => { + expect(($cell[0] as HTMLElement).offsetLeft, 'RF cell rebased to band-local x').to.equal(0); + }); }); }); @@ -105,9 +128,14 @@ describe('right-frozen band DOM - runtime materialization on a classic grid', () cy.get('#myGrid .slick-viewport').should('have.length', 6); cy.get('#myGrid .grid-canvas').should('have.length', 6); cy.get('#myGrid .slick-pane-header.slick-pane-right-frozen').should('be.visible'); + + // routing follows the runtime toggle: 5 middle headers + 1 right-frozen header + cy.get('#myGrid .slick-header-columns-left .slick-header-column').should('have.length', 5); + cy.get('#myGrid .slick-header-columns-right-frozen .slick-header-column').should('have.length', 1); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').should('have.length.greaterThan', 0); }); - it('should hide the band again when the right freeze is turned off, keeping it in the DOM', () => { + it('should hide the band again when the right freeze is turned off, restoring classic routing', () => { cy.window().then((win: any) => { win.grid.setOptions({ frozenRightColumn: 0 }); }); @@ -115,6 +143,9 @@ describe('right-frozen band DOM - runtime materialization on a classic grid', () cy.get('#myGrid > .slick-pane').should('have.length', 9); cy.get('#myGrid .slick-pane-header.slick-pane-right-frozen').should('not.be.visible'); cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen').should('not.be.visible'); + cy.get('#myGrid .slick-header-columns-left .slick-header-column').should('have.length', 6); + cy.get('#myGrid .slick-header-columns-right-frozen .slick-header-column').should('have.length', 0); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-row').should('have.length', 0); cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); }); }); diff --git a/src/slick.grid.ts b/src/slick.grid.ts index b332a1b3..2271a76e 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -156,6 +156,8 @@ interface ViewportFreezeState { frozenRowCount?: number; /** the frozenRightColumn option value — number of columns frozen at the right edge (0 when none) */ frozenRightColCount?: number; + /** index of the first right-frozen column (columns.length when the band is off) */ + frozenRightStartIdx?: number; } /** @@ -741,11 +743,16 @@ class ViewportMgr { } /** - * Index of the pane owning cell (colIdx, rowIdx) in the 4-slot - * [TopL, TopR, BottomL, BottomR] element arrays. + * Index of the pane owning cell (colIdx, rowIdx) in the element arrays: + * classic slots [TopL, TopR, BottomL, BottomR], right-frozen slots [TopRF, BottomRF] + * appended at 4/5 (materializeRightFrozenPanes canonicalizes the classic set first, + * so these positions hold under lazyPanes too). */ paneCellIndex(colIdx: number, rowIdx: number): number { const isBottomSide = this.freeze.hasFrozenRows && rowIdx >= this.freeze.actualFrozenRow + (this.freeze.frozenBottom ? 0 : 1); + if (this.isColumnInRightFrozenBand(colIdx)) { + return 4 + (isBottomSide ? 1 : 0); + } const isRightSide = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; return (isBottomSide ? 2 : 0) + (isRightSide ? 1 : 0); } @@ -832,6 +839,44 @@ class ViewportMgr { return this.isColumnRightOfFreeze(colIdx) ? right : left; } + /** Whether the right-frozen band is active AND its DOM has been materialized. */ + hasRightFrozenBand(): boolean { + return this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; + } + + /** Whether the column index falls inside the right-frozen band. */ + isColumnInRightFrozenBand(colIdx: number): boolean { + return this.bands.frozenRightCols > 0 && colIdx >= (this.freeze.frozenRightStartIdx ?? Number.MAX_SAFE_INTEGER); + } + + /** Three-way band pick: left band, scrollable middle, or right-frozen element. */ + bandElementForColumn(colIdx: number, left: T, right: T, rightFrozen: T): T { + if (this.isColumnInRightFrozenBand(colIdx)) { + return rightFrozen; + } + return this.sideForColumn(colIdx, left, right); + } + + /** Column index local to its band container (right-frozen children index from the band start). */ + bandLocalColumnIdx(colIdx: number): number { + if (this.isColumnInRightFrozenBand(colIdx)) { + return colIdx - this.freeze.frozenRightStartIdx!; + } + return this.sideLocalColumnIdx(colIdx); + } + + /** + * Index of the column's node inside rowsCache[].rowNode: 0 for the left fragment + * (which is the scrollable fragment when no columns are left-frozen), 1 for the + * middle fragment under a left freeze, and last for the right-frozen fragment. + */ + rowNodeIdxForColumn(colIdx: number): number { + if (this.isColumnInRightFrozenBand(colIdx)) { + return this.hasFrozenColumns() ? 2 : 1; + } + return this.isColumnRightOfFreeze(colIdx) ? 1 : 0; + } + /** Utils.show that tolerates panes not built under lazyPanes. */ protected showIf(el?: HTMLElement) { if (el) { Utils.show(el); } @@ -1238,29 +1283,39 @@ class ViewportMgr { * render-side and cell-lookup-side splits differ by one row in the non-frozenBottom * case, and that asymmetry is preserved verbatim. */ - attachRow(rowIdx: number, left: HTMLElement | null, right: HTMLElement | null): HTMLElement[] | null { - if ((this.freeze.hasFrozenRows) && (rowIdx >= this.freeze.actualFrozenRow)) { + attachRow(rowIdx: number, left: HTMLElement | null, right: HTMLElement | null, rightFrozen?: HTMLElement | null): HTMLElement[] | null { + let attached: HTMLElement[] | null = null; + const isBottomBand = (this.freeze.hasFrozenRows) && (rowIdx >= this.freeze.actualFrozenRow); + + if (isBottomBand) { if (this.hasFrozenColumns()) { if (left && right) { this.canvasBottomL.appendChild(left); this.canvasBottomR.appendChild(right); - return [left, right]; + attached = [left, right]; } } else if (left) { this.canvasBottomL.appendChild(left); - return [left]; + attached = [left]; } } else if (this.hasFrozenColumns()) { if (left && right) { this.canvasTopL.appendChild(left); this.canvasTopR.appendChild(right); - return [left, right]; + attached = [left, right]; } } else if (left) { this.canvasTopL.appendChild(left); - return [left]; + attached = [left]; } - return null; + + // right-frozen fragment always sits LAST in the rowNode array + if (attached && this.bands.frozenRightCols > 0 && this.canvasTopRF && rightFrozen) { + (isBottomBand ? this.canvasBottomRF : this.canvasTopRF).appendChild(rightFrozen); + attached.push(rightFrozen); + } + + return attached; } /** Applies an X scroll position to the scroll-owner viewport and every horizontal follower. */ @@ -2758,7 +2813,7 @@ export class SlickGrid = Column, O e return this._viewportMgr.hasFrozenColumns() ? this._headers : this._headerL; } const idx = this.getColumnIndex(columnDef.id); - return this._viewportMgr.sideForColumn(idx, this._headerL, this._headerR); + return this._viewportMgr.bandElementForColumn(idx, this._headerL, this._headerR, this._viewportMgr.headerRF); } /** @@ -2767,8 +2822,8 @@ export class SlickGrid = Column, O e */ getHeaderColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const targetHeader = this._viewportMgr.sideForColumn(idx, this._headerL, this._headerR); - const targetIndex = this._viewportMgr.sideLocalColumnIdx(idx); + const targetHeader = this._viewportMgr.bandElementForColumn(idx, this._headerL, this._headerR, this._viewportMgr.headerRF); + const targetIndex = this._viewportMgr.bandLocalColumnIdx(idx); return targetHeader.children[targetIndex] as HTMLDivElement; } @@ -2789,9 +2844,9 @@ export class SlickGrid = Column, O e */ getHeaderRowColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const headerRowTarget = this._viewportMgr.sideForColumn(idx, this._headerRowL, this._headerRowR); + const headerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._headerRowL, this._headerRowR, this._viewportMgr.headerRowRF); - return headerRowTarget.children[this._viewportMgr.sideLocalColumnIdx(idx)] as HTMLDivElement; + return headerRowTarget.children[this._viewportMgr.bandLocalColumnIdx(idx)] as HTMLDivElement; } /** @@ -2800,9 +2855,9 @@ export class SlickGrid = Column, O e */ getFooterRowColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const footerRowTarget = this._viewportMgr.sideForColumn(idx, this._footerRowL, this._footerRowR); + const footerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._footerRowL, this._footerRowR, this._viewportMgr.footerRowRF); - return footerRowTarget.children[this._viewportMgr.sideLocalColumnIdx(idx)] as HTMLDivElement; + return footerRowTarget.children[this._viewportMgr.bandLocalColumnIdx(idx)] as HTMLDivElement; } /** @@ -2831,7 +2886,7 @@ export class SlickGrid = Column, O e const m = this.columns[i]; if (!m || m.hidden) { continue; } - const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this._viewportMgr.sideForColumn(i, this._footerRowL, this._footerRowR)); + const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this._viewportMgr.bandElementForColumn(i, this._footerRowL, this._footerRowR, this._viewportMgr.footerRowRF)); const className = this._viewportMgr.isColumnInFrozenBand(i) ? 'frozen' : null; if (className) { footerRowCell.classList.add(className); @@ -2978,11 +3033,15 @@ export class SlickGrid = Column, O e if (this._headerR) { Utils.emptyElement(this._headerR); } + if (this._viewportMgr.headerRF) { + Utils.emptyElement(this._viewportMgr.headerRF); + } this.getHeadersWidth(); Utils.width(this._headerL, this.headersWidthL); Utils.width(this._headerR, this.headersWidthR); + Utils.width(this._viewportMgr.headerRF, this.headersWidthRF); this._headerRows.forEach((row) => { const columnElements = row.querySelectorAll('.slick-headerrow-column'); @@ -3002,6 +3061,9 @@ export class SlickGrid = Column, O e if (this._headerRowR) { Utils.emptyElement(this._headerRowR); } + if (this._viewportMgr.headerRowRF) { + Utils.emptyElement(this._viewportMgr.headerRowRF); + } if (this._options.createFooterRow) { const footerRowLColumnElements = this._footerRowL.querySelectorAll('.slick-footerrow-column'); @@ -3031,14 +3093,29 @@ export class SlickGrid = Column, O e }); Utils.emptyElement(this._footerRowR); } + + if (this._viewportMgr.footerRowRF) { + const footerRowRFColumnElements = this._viewportMgr.footerRowRF.querySelectorAll('.slick-footerrow-column'); + footerRowRFColumnElements.forEach((column) => { + const columnDef = Utils.storage.get(column, 'column'); + if (columnDef) { + this.trigger(this.onBeforeFooterRowCellDestroy, { + node: this, + column: columnDef, + grid: this + }); + } + }); + Utils.emptyElement(this._viewportMgr.footerRowRF); + } } for (let i = 0; i < this.columns.length; i++) { const m: C = this.columns[i]; if (m.hidden) { continue; } - const headerTarget = this._viewportMgr.sideForColumn(i, this._headerL, this._headerR); - const headerRowTarget = this._viewportMgr.sideForColumn(i, this._headerRowL, this._headerRowR); + const headerTarget = this._viewportMgr.bandElementForColumn(i, this._headerL, this._headerR, this._viewportMgr.headerRF); + const headerRowTarget = this._viewportMgr.bandElementForColumn(i, this._headerRowL, this._headerRowR, this._viewportMgr.headerRowRF); const header = Utils.createDomElement('div', { id: `${this.uid + m.id}`, dataset: { id: String(m.id) }, role: 'columnheader', className: 'ui-state-default slick-state-default slick-header-column' }, headerTarget); if (m.toolTip) { @@ -3112,7 +3189,7 @@ export class SlickGrid = Column, O e }); } if (this._options.createFooterRow && this._options.showFooterRow) { - const footerRowTarget = this._viewportMgr.sideForColumn(i, this._footerRow[0], this._footerRow[1]); + const footerRowTarget = this._viewportMgr.bandElementForColumn(i, this._footerRow[0], this._footerRow[1], this._viewportMgr.footerRowRF); const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, footerRowTarget); Utils.storage.put(footerRowCell, 'column', m); @@ -3638,6 +3715,7 @@ export class SlickGrid = Column, O e frozenBottom: !!this._options.frozenBottom, frozenRowCount: this._options.frozenRow!, frozenRightColCount: this._options.frozenRightColumn!, + frozenRightStartIdx: this.getFrozenRightStartIdx(), }); // materialize the secondary panes if freezing was just enabled on a lazyPanes grid @@ -3658,6 +3736,10 @@ export class SlickGrid = Column, O e * setOptions) and wires events for the new elements when the grid is already live. */ protected materializeRightFrozenPanes() { + // canonicalize the classic pane set first so the RF viewports/canvases land at + // array indexes 4/5 (paneCellIndex depends on it) — no-op on non-lazy grids + this.materializeLazyPanes(); + if (!this._viewportMgr.materializeRightFrozenBand(this._options)) { return; } @@ -4406,13 +4488,20 @@ export class SlickGrid = Column, O e let x = 0; let w = 0; let rule: any; + const rfStartIdx = this.getFrozenRightStartIdx(); for (let i = 0; i < this.columns.length; i++) { + // the right-frozen band starts a new viewport: reset the running left offset + if (i === rfStartIdx) { + x = 0; + } if (!this.columns[i]?.hidden) { w = this.columns[i].width || 0; rule = this.getColumnCssRules(i); rule.left.style.left = `${x}px`; - rule.right.style.right = (((this._options.frozenColumn !== -1 && i > this._options.frozenColumn!) ? this.canvasWidthR : this.canvasWidthL) - x - w) + 'px'; + rule.right.style.right = ((i >= rfStartIdx + ? this.canvasWidthRF + : ((this._options.frozenColumn !== -1 && i > this._options.frozenColumn!) ? this.canvasWidthR : this.canvasWidthL)) - x - w) + 'px'; // If this column is frozen, reset the css left value since the // column starts in a new viewport. @@ -6502,7 +6591,7 @@ export class SlickGrid = Column, O e * @param {CellViewportRange} range - The visible viewport range for rendering cells. * @param {number} dataLength - The total data length to determine if the row is loading. */ - protected appendRowHtml(divArrayL: HTMLElement[], divArrayR: HTMLElement[], row: number, range: CellViewportRange, dataLength: number) { + protected appendRowHtml(divArrayL: HTMLElement[], divArrayR: HTMLElement[], divArrayRF: HTMLElement[], row: number, range: CellViewportRange, dataLength: number) { const d = this.getDataItem(row); const dataLoading = row < dataLength && !d; let rowCss = 'slick-row' + @@ -6534,6 +6623,7 @@ export class SlickGrid = Column, O e } let rowDivR: HTMLElement | undefined; + let rowDivRF: HTMLElement | undefined; divArrayL.push(rowDiv); if (this._viewportMgr.hasFrozenColumns()) { // it has to be a deep copy otherwise we will have issues with pass by reference in js since @@ -6541,6 +6631,10 @@ export class SlickGrid = Column, O e rowDivR = rowDiv.cloneNode(true) as HTMLElement; divArrayR.push(rowDivR); } + if (this._viewportMgr.hasRightFrozenBand()) { + rowDivRF = rowDiv.cloneNode(true) as HTMLElement; + divArrayRF.push(rowDivRF); + } const columnCount = this.columns.length; let columnData: ColumnMetadata | null; @@ -6589,11 +6683,14 @@ export class SlickGrid = Column, O e // All columns to the right are outside the range, so no need to render them if (isRenderCell) { - const targetedRowDiv = this._viewportMgr.sideForColumn(i, rowDiv, rowDivR!); + const targetedRowDiv = this._viewportMgr.bandElementForColumn(i, rowDiv, rowDivR!, rowDivRF!); this.appendCellHtml(targetedRowDiv, row, i, ncolspan, rowspan, columnData, d); } } else if (m.alwaysRenderColumn || this._viewportMgr.isColumnInFrozenBand(i)) { this.appendCellHtml(rowDiv, row, i, ncolspan, rowspan, columnData, d); + } else if (this._viewportMgr.isColumnInRightFrozenBand(i)) { + // right-frozen cells are always horizontally visible, like the left-frozen band + this.appendCellHtml(rowDivRF!, row, i, ncolspan, rowspan, columnData, d); } if (ncolspan > 1) { @@ -6628,7 +6725,7 @@ export class SlickGrid = Column, O e + (rowspan > 1 ? ' rowspan' : '') + (columnMetadata?.cssClass ? ` ${columnMetadata.cssClass}` : ''); - if (this._viewportMgr.isColumnInFrozenBand(cell)) { + if (this._viewportMgr.isColumnInFrozenBand(cell) || this._viewportMgr.isColumnInRightFrozenBand(cell)) { cellCss += ' frozen'; } @@ -7278,8 +7375,10 @@ export class SlickGrid = Column, O e if (cacheEntry?.cellRenderQueue.length && cacheEntry.rowNode?.length) { const rowNode = cacheEntry.rowNode as HTMLElement[]; let children = Array.from(rowNode[0].children) as HTMLElement[]; - if (rowNode.length > 1) { - children = children.concat(Array.from(rowNode[1].children) as HTMLElement[]); + // concat every additional fragment's children (middle band and, when active, + // the right-frozen band — fragments are ordered by ascending column index) + for (let n = 1; n < rowNode.length; n++) { + children = children.concat(Array.from(rowNode[n].children) as HTMLElement[]); } let i = children.length - 1; @@ -7317,8 +7416,8 @@ export class SlickGrid = Column, O e // This is a string, so it needs to be cast back to a number. const i = +cellNodeIdx; - // Ignore frozen columns - if (this._viewportMgr.isColumnInFrozenBand(i)) { + // Ignore frozen columns (left and right bands are always horizontally visible) + if (this._viewportMgr.isColumnInFrozenBand(i) || this._viewportMgr.isColumnInRightFrozenBand(i)) { return; } @@ -7458,11 +7557,7 @@ export class SlickGrid = Column, O e if (!node) { continue; } - if (this._viewportMgr.isColumnRightOfFreeze(columnIdx)) { - cacheEntry.rowNode![1].appendChild(node); - } else { - cacheEntry.rowNode![0].appendChild(node); - } + cacheEntry.rowNode![this._viewportMgr.rowNodeIdxForColumn(columnIdx)].appendChild(node); cacheEntry.cellNodesByColumnIdx![columnIdx] = node; } } @@ -7479,6 +7574,7 @@ export class SlickGrid = Column, O e protected renderRows(range: { top: number; bottom: number; leftPx: number; rightPx: number; }) { const divArrayL: HTMLElement[] = []; const divArrayR: HTMLElement[] = []; + const divArrayRF: HTMLElement[] = []; const rows: number[] = []; let needToReselectCell = false; const dataLength = this.getDataLength(); @@ -7504,7 +7600,7 @@ export class SlickGrid = Column, O e } } - this.appendRowHtml(divArrayL, divArrayR, i, range, dataLength); + this.appendRowHtml(divArrayL, divArrayR, divArrayRF, i, range, dataLength); mustRenderRows.add(i); if (this.activeCellNode && this.activeRow === i) { needToReselectCell = true; @@ -7519,20 +7615,22 @@ export class SlickGrid = Column, O e this.removeRowFromCache(r); // remove any previous element to avoid duplicates in DOM rows.push(r); this.rowsCache[r] = this.createEmptyCachingRow(); - this.appendRowHtml(divArrayL, divArrayR, r, range, dataLength); + this.appendRowHtml(divArrayL, divArrayR, divArrayRF, r, range, dataLength); }); } if (rows.length) { const x = document.createElement('div'); const xRight = document.createElement('div'); + const xRF = document.createElement('div'); divArrayL.forEach(elm => x.appendChild(elm as HTMLElement)); divArrayR.forEach(elm => xRight.appendChild(elm as HTMLElement)); + divArrayRF.forEach(elm => xRF.appendChild(elm as HTMLElement)); for (let i = 0, ii = rows.length; i < ii; i++) { if (this.rowsCache?.hasOwnProperty(rows[i])) { - const attached = this._viewportMgr.attachRow(rows[i], x.firstChild as HTMLElement | null, xRight.firstChild as HTMLElement | null); + const attached = this._viewportMgr.attachRow(rows[i], x.firstChild as HTMLElement | null, xRight.firstChild as HTMLElement | null, xRF.firstChild as HTMLElement | null); if (attached) { this.rowsCache[rows[i]].rowNode = attached; } @@ -9048,6 +9146,16 @@ export class SlickGrid = Column, O e x1 = 0; } } + + // the right-frozen band starts a new viewport: rebase to band-local coordinates + const rfStartIdx = this.getFrozenRightStartIdx(); + if (cell >= rfStartIdx) { + x1 = 0; + for (let i = rfStartIdx; i < cell; i++) { + if (!this.columns[i] || this.columns[i].hidden) { continue; } + x1 += (this.columns[i].width || 0); + } + } const x2 = x1 + (this.columns[cell]?.width || 0); return { From 8972eddaec79b702a301986fbf7915678c38b029 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 12:45:21 +0930 Subject: [PATCH 18/43] fix: harden right-frozen band transition states found by equivalence audit (Phase 4, M13 hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-way adversarial audit proved the M13c/M13d changes behaviourally inert with frozenRightColumn=0 (sealing the example-0032 incident as the documented ambient machine flake) and surfaced four RF-on transition weaknesses, fixed here: - appendRowHtml: cell routing now requires the RF fragment to exist (guard was band-count-based while the fragment clone was DOM-based), with a same-fragment fallback in the three-way pick — no undefined targets during materialization transitions. - attachRow: the RF append guard now checks the actual target canvas (bottom-band rows previously checked canvasTopRF but appended to canvasBottomRF). - appendRowHtml: divArrayRF moved to a trailing optional parameter so downstream subclass overrides of the protected method keep compiling and binding correctly. - updateCanvasWidth: the getHeadersWidth() recompute guard gains the RF term, keeping it symmetric with applyCanvasWidths' distribution guard (stale headersWidthRF was otherwise possible with RF on and nothing else frozen). Full suite verified green BEFORE this commit: 623 tests, 622 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 2271a76e..28e34143 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -1310,8 +1310,9 @@ class ViewportMgr { } // right-frozen fragment always sits LAST in the rowNode array - if (attached && this.bands.frozenRightCols > 0 && this.canvasTopRF && rightFrozen) { - (isBottomBand ? this.canvasBottomRF : this.canvasTopRF).appendChild(rightFrozen); + const rfTargetCanvas = isBottomBand ? this.canvasBottomRF : this.canvasTopRF; + if (attached && this.bands.frozenRightCols > 0 && rfTargetCanvas && rightFrozen) { + rfTargetCanvas.appendChild(rightFrozen); attached.push(rightFrozen); } @@ -6246,8 +6247,9 @@ export class SlickGrid = Column, O e const widthChanged = this.canvasWidth !== oldCanvasWidth || this.canvasWidthL !== oldCanvasWidthL || this.canvasWidthR !== oldCanvasWidthR || this.canvasWidthRF !== oldCanvasWidthRF; // recompute the header width split only when the pane widths will be redistributed - // (preserves the historical conditional side effect on headersWidthL/R) - if (widthChanged || this._viewportMgr.hasFrozenColumns() || this._viewportMgr.hasFrozenRows()) { + // (preserves the historical conditional side effect on headersWidthL/R; the RF term + // keeps this guard symmetric with applyCanvasWidths' rfActive distribution guard) + if (widthChanged || this._viewportMgr.hasFrozenColumns() || this._viewportMgr.hasFrozenRows() || this._viewportMgr.hasRightFrozenBand()) { this.getHeadersWidth(); } @@ -6591,7 +6593,7 @@ export class SlickGrid = Column, O e * @param {CellViewportRange} range - The visible viewport range for rendering cells. * @param {number} dataLength - The total data length to determine if the row is loading. */ - protected appendRowHtml(divArrayL: HTMLElement[], divArrayR: HTMLElement[], divArrayRF: HTMLElement[], row: number, range: CellViewportRange, dataLength: number) { + protected appendRowHtml(divArrayL: HTMLElement[], divArrayR: HTMLElement[], row: number, range: CellViewportRange, dataLength: number, divArrayRF: HTMLElement[] = []) { const d = this.getDataItem(row); const dataLoading = row < dataLength && !d; let rowCss = 'slick-row' + @@ -6683,14 +6685,16 @@ export class SlickGrid = Column, O e // All columns to the right are outside the range, so no need to render them if (isRenderCell) { - const targetedRowDiv = this._viewportMgr.bandElementForColumn(i, rowDiv, rowDivR!, rowDivRF!); + // fall back to the row's own fragment if the RF fragment was not cloned + // (band count set but DOM not yet materialized — transition safety) + const targetedRowDiv = this._viewportMgr.bandElementForColumn(i, rowDiv, rowDivR!, rowDivRF ?? rowDiv); this.appendCellHtml(targetedRowDiv, row, i, ncolspan, rowspan, columnData, d); } } else if (m.alwaysRenderColumn || this._viewportMgr.isColumnInFrozenBand(i)) { this.appendCellHtml(rowDiv, row, i, ncolspan, rowspan, columnData, d); - } else if (this._viewportMgr.isColumnInRightFrozenBand(i)) { + } else if (rowDivRF && this._viewportMgr.isColumnInRightFrozenBand(i)) { // right-frozen cells are always horizontally visible, like the left-frozen band - this.appendCellHtml(rowDivRF!, row, i, ncolspan, rowspan, columnData, d); + this.appendCellHtml(rowDivRF, row, i, ncolspan, rowspan, columnData, d); } if (ncolspan > 1) { @@ -7600,7 +7604,7 @@ export class SlickGrid = Column, O e } } - this.appendRowHtml(divArrayL, divArrayR, divArrayRF, i, range, dataLength); + this.appendRowHtml(divArrayL, divArrayR, i, range, dataLength, divArrayRF); mustRenderRows.add(i); if (this.activeCellNode && this.activeRow === i) { needToReselectCell = true; @@ -7615,7 +7619,7 @@ export class SlickGrid = Column, O e this.removeRowFromCache(r); // remove any previous element to avoid duplicates in DOM rows.push(r); this.rowsCache[r] = this.createEmptyCachingRow(); - this.appendRowHtml(divArrayL, divArrayR, divArrayRF, r, range, dataLength); + this.appendRowHtml(divArrayL, divArrayR, r, range, dataLength, divArrayRF); }); } From 86313c12ede5f12fa929ebd5ff29693eae10d2d4 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 14:51:33 +0930 Subject: [PATCH 19/43] feat: suppress horizontal scroll for right-frozen cells in scrollCellIntoView (Phase 4, milestone 13e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-frozen cells are always horizontally visible, so scrollCellIntoView now returns early for them — mirroring the historical left-frozen guard. New spec test drives arrow-key navigation across the middle↔right-frozen boundary in both directions, which also exercises the RF canvas keydown wiring and pane-index cell lookup end-to-end, and asserts the middle viewport does not scroll on band entry. Full suite verified green BEFORE this commit: 624 tests, 623 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- .../e2e/viewportmgr-right-frozen-band.cy.ts | 27 +++++++++++++++++++ src/slick.grid.ts | 5 ++++ 2 files changed, 32 insertions(+) diff --git a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts index acca7652..5a66f743 100644 --- a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts +++ b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts @@ -115,6 +115,33 @@ describe('right-frozen band DOM - frozenRightColumn at init (example-frozen-righ }); }); +describe('right-frozen band - keyboard navigation across the band boundary (M13e)', () => { + it('should cross middle→right-frozen and back with arrow keys, without horizontal scrolling', () => { + // fresh load to reset scroll/active state + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-right-columns.html`); + + // activate the last middle-band cell (Start) on the first row + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').first().find('.slick-cell').last().click(); + cy.get('#myGrid .slick-cell.active').should('have.length', 1); + + // ArrowRight crosses into the first right-frozen column (Finish) — this also + // exercises the RF canvas's keydown wiring and pane-index cell lookup + cy.get('#myGrid .slick-cell.active').type('{rightarrow}'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right-frozen .slick-cell.active') + .should('have.length', 1) + .and('contain', '01/05/2009'); + + // entering the band must not horizontally scroll the middle viewport + cy.get('#myGrid .slick-viewport-top.slick-viewport-left').then(($vp) => { + expect(($vp[0] as HTMLElement).scrollLeft, 'middle band did not scroll').to.equal(0); + }); + + // ArrowLeft returns to the middle band + cy.get('#myGrid .slick-cell.active').type('{leftarrow}'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-cell.active').should('have.length', 1); + }); +}); + describe('right-frozen band DOM - runtime materialization on a classic grid', () => { it('should load the plain example and materialize the band via setOptions', () => { cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple.html`); diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 28e34143..9739a85e 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -8019,6 +8019,11 @@ export class SlickGrid = Column, O e return; } + // right-frozen cells are always horizontally visible — never scroll for them + if (this._viewportMgr.isColumnInRightFrozenBand(cell)) { + return; + } + const colspan = this.getColspan(row, cell); this.internalScrollColumnIntoView(this.columnPosLeft[cell], this.columnPosRight[cell + (colspan > 1 ? colspan - 1 : 0)]); } From 07221d941bf8728b30734aa23409f5ff6eb9bee0 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 16:51:10 +0930 Subject: [PATCH 20/43] =?UTF-8?q?feat:=20add=20frozenBottomRow=20option=20?= =?UTF-8?q?plumbing=20(Phase=204,=20milestone=2014a=20=E2=80=94=20inert)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New grid option frozenBottomRow: a COUNT of rows to freeze at the bottom, usable TOGETHER with frozenRow (which then always means top rows; the legacy frozenBottom flag is ignored when the count is set — it only positions the single-band case). setFrozenOptions normalizes it (non-negative integer; top + bottom bands must leave at least one scrollable body row) and pushes it into ViewportMgr's band derivation, which now expresses all three row-band configurations while every legacy configuration derives to exactly the same values as before. Nothing consumes the simultaneous-bands state yet — the bottom-frozen band DOM, geometry and routing arrive in M14b-d. Verification: first full run failed only the documented clipboard flake (passes 8/8 isolated); full re-run verified green BEFORE this commit (624 tests, 623 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- src/models/gridOption.interface.ts | 9 +++++++++ src/slick.grid.ts | 21 ++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/models/gridOption.interface.ts b/src/models/gridOption.interface.ts index 053ed2df..e06010b4 100644 --- a/src/models/gridOption.interface.ts +++ b/src/models/gridOption.interface.ts @@ -244,6 +244,15 @@ export interface GridOption { /** Number of row index(es) to freeze (pin) in the grid */ frozenRow?: number; + /** + * Defaults to 0. Number of ROWS to freeze (pin) at the BOTTOM of the grid, usable + * TOGETHER with `frozenRow` (which then always means rows frozen at the top). + * A COUNT, like `frozenRightColumn`. When set (> 0) the legacy `frozenBottom` flag + * is ignored — that flag only selects the position of the single `frozenRow` band. + * (Phase 4 of the ViewportMgr refactor; no effect until the bottom-frozen band lands.) + */ + frozenBottomRow?: number; + /** * Defaults to 0. Number of columns to freeze (pin) at the RIGHT edge of the grid. * Note this is a COUNT from the right, not a column index like `frozenColumn` — diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 9739a85e..8776f95f 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -158,6 +158,8 @@ interface ViewportFreezeState { frozenRightColCount?: number; /** index of the first right-frozen column (columns.length when the band is off) */ frozenRightStartIdx?: number; + /** the frozenBottomRow option value — rows frozen at the bottom ALONGSIDE top rows (0 when none) */ + frozenBottomRowCount?: number; } /** @@ -710,11 +712,14 @@ class ViewportMgr { // derive the band-count view (Phase 4 groundwork); the legacy fields above stay // authoritative for the existing 2×2 code paths const rowCount = f.hasFrozenRows ? Math.max(0, f.frozenRowCount ?? 0) : 0; + const bottomRowCount = Math.max(0, f.frozenBottomRowCount ?? 0); this.bands = { frozenLeftCols: f.frozenColumnIdx + 1, - frozenRightCols: Math.max(0, f.frozenRightColCount ?? 0), // band DOM arrives with M13's later stages - frozenTopRows: f.frozenBottom ? 0 : rowCount, - frozenBottomRows: f.frozenBottom ? rowCount : 0, + frozenRightCols: Math.max(0, f.frozenRightColCount ?? 0), + // with an explicit bottom count, frozenRow always means TOP rows and the legacy + // frozenBottom flag is ignored (it only positions the single-band case) + frozenTopRows: bottomRowCount > 0 ? rowCount : (f.frozenBottom ? 0 : rowCount), + frozenBottomRows: bottomRowCount > 0 ? bottomRowCount : (f.frozenBottom ? rowCount : 0), }; } @@ -1553,6 +1558,7 @@ export class SlickGrid = Column, O e enableTextSelectionOnCells: false, dataItemColumnValueExtractor: null, frozenBottom: false, + frozenBottomRow: 0, frozenColumn: -1, frozenRow: -1, frozenRightColumn: 0, @@ -3697,6 +3703,14 @@ export class SlickGrid = Column, O e ? Math.min(parseInt(this._options.frozenRightColumn as unknown as string, 10), maxRightCols) : 0; + // normalize the bottom-frozen row COUNT: non-negative integer, and the top and + // bottom bands must leave at least one scrollable body row between them + const topRowCount = (this._options.frozenRow! > -1 && !this._options.frozenBottom) ? this._options.frozenRow! : 0; + const maxBottomRows = Math.max(0, this.getDataLength() - topRowCount - 1); + this._options.frozenBottomRow = (this._options.frozenBottomRow! > 0) + ? Math.min(parseInt(this._options.frozenBottomRow as unknown as string, 10), maxBottomRows) + : 0; + if (this._options.frozenRow! > -1) { this.hasFrozenRows = true; this.frozenRowsHeight = (this._options.frozenRow!) * this._options.rowHeight!; @@ -3717,6 +3731,7 @@ export class SlickGrid = Column, O e frozenRowCount: this._options.frozenRow!, frozenRightColCount: this._options.frozenRightColumn!, frozenRightStartIdx: this.getFrozenRightStartIdx(), + frozenBottomRowCount: this._options.frozenBottomRow!, }); // materialize the secondary panes if freezing was just enabled on a lazyPanes grid From c675828b81b472bbc0072e87f76ce93fd0e586b2 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 19:55:26 +0930 Subject: [PATCH 21/43] feat: materialize the bottom-frozen row band DOM + fix init-window double-binding (Phase 4, milestone 14b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - materializeBottomFrozenBand() builds one pane+viewport+canvas per active column band with *-bottom-frozen classes, appended after all existing panes; the shared bottom-frozen × right-frozen corner pane is added by WHICHEVER band materializes second (ensureBottomFrozenRightVariant called from both), with events bound on either path. applyPaneVisibility shows the band only in simultaneous top+bottom mode, mirroring classic bottom-pane column visibility. - Slot registry: dynamically materialized viewports/canvases record their array positions at push time (rfTopSlot/rfBottomSlot/bfSlot*), replacing paneCellIndex's hardcoded RF slots 4/5 — band materialization order can no longer skew lookups. - Fixes a latent M13b bug: finishInitialization sets initialized=true BEFORE its array-wide bindPaneEvents pass, so bands materialized during setFrozenOptions in that window were bound twice (once by the materializer, once by the array pass) — affected right-frozen-at-init grids. Materializers now gate on a _paneEventsBound flag set after the array-wide pass. Staged state pinned by viewportmgr-bottom-frozen-band.cy.ts (6 tests + example page): the band exists (init-time and runtime) but classic top-frozen rendering is unchanged until M14c geometry and M14d routing. Full suite verified green BEFORE this commit: 630 tests, 629 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- .../e2e/viewportmgr-bottom-frozen-band.cy.ts | 66 +++++++ examples/example-frozen-top-bottom-rows.html | 75 ++++++++ src/slick.grid.ts | 177 +++++++++++++++++- 3 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts create mode 100644 examples/example-frozen-top-bottom-rows.html diff --git a/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts new file mode 100644 index 00000000..5a39ceac --- /dev/null +++ b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts @@ -0,0 +1,66 @@ +/** + * DOM-shape characterization for the bottom-frozen row band (Phase 4, M14b of the + * ViewportMgr refactor). With frozenRow AND frozenBottomRow both set, a third row of + * panes materializes with `*-bottom-frozen` css classes — one per active column band. + * At this stage only the DOM exists: geometry (M14c) and routing (M14d) have not + * landed, so the classic top-frozen layout still renders all rows and these tests pin + * exactly that staged state. + * + * Named to sort after the example-* specs (shared browser session). + */ + +describe('bottom-frozen band DOM - frozenRow + frozenBottomRow at init (example-frozen-top-bottom-rows)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-top-bottom-rows.html`); + }); + + it('should build 8 panes: the classic six plus two bottom-frozen panes after them', () => { + cy.get('#myGrid > .slick-pane').should('have.length', 8).then(($panes) => { + expect($panes.eq(6)).to.have.class('slick-pane-bottom-frozen'); + expect($panes.eq(6)).to.have.class('slick-pane-left'); + expect($panes.eq(7)).to.have.class('slick-pane-bottom-frozen'); + expect($panes.eq(7)).to.have.class('slick-pane-right'); + }); + cy.get('#myGrid .slick-pane-right-frozen').should('have.length', 0); + }); + + it('should build the bottom-frozen viewports and canvases, correctly nested', () => { + cy.get('#myGrid .slick-viewport').should('have.length', 6); + cy.get('#myGrid .grid-canvas').should('have.length', 6); + cy.get('#myGrid .slick-pane-bottom-frozen.slick-pane-left > .slick-viewport.slick-viewport-bottom-frozen.slick-viewport-left').should('have.length', 1); + cy.get('#myGrid .slick-viewport-bottom-frozen.slick-viewport-left > .grid-canvas.grid-canvas-bottom-frozen.grid-canvas-left').should('have.length', 1); + }); + + it('should keep classic top-frozen rendering at this stage (geometry and routing land later)', () => { + // classic top-frozen layout: 3 frozen rows in the top-left canvas, body in bottom-left + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length', 3); + cy.get('#myGrid .grid-canvas-bottom.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + cy.get('#myGrid .grid-canvas-bottom-frozen .slick-row').should('have.length', 0); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-cell').first().should('contain', 'Task 0'); + }); +}); + +describe('bottom-frozen band DOM - runtime materialization on a classic grid', () => { + it('should materialize the band when both freeze options are set at runtime', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple.html`); + cy.get('#myGrid > .slick-pane').should('have.length', 6); + + cy.window().then((win: any) => { + win.grid.setOptions({ frozenRow: 2, frozenBottomRow: 2 }); + }); + + cy.get('#myGrid > .slick-pane').should('have.length', 8); + cy.get('#myGrid .slick-viewport').should('have.length', 6); + cy.get('#myGrid .grid-canvas').should('have.length', 6); + }); + + it('should keep the band in the DOM but hidden when simultaneous mode is turned off', () => { + cy.window().then((win: any) => { + win.grid.setOptions({ frozenBottomRow: 0 }); + }); + + cy.get('#myGrid > .slick-pane').should('have.length', 8); + cy.get('#myGrid .slick-pane-bottom-frozen.slick-pane-left').should('not.be.visible'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + }); +}); diff --git a/examples/example-frozen-top-bottom-rows.html b/examples/example-frozen-top-bottom-rows.html new file mode 100644 index 00000000..add1e270 --- /dev/null +++ b/examples/example-frozen-top-bottom-rows.html @@ -0,0 +1,75 @@ + + + + + + SlickGrid example: simultaneous top+bottom frozen rows (Phase 4) + + + + +

Example: frozenRow + frozenBottomRow - three row bands

+ + + + + +
+
+
+
+

+ + Demonstrates: +

+
+
    +
  • basic grid with minimal configuration
  • +
+

View Source:

+ +
+ + + + + + + + + + + diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 8776f95f..8fd4785b 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -246,6 +246,17 @@ class ViewportMgr { */ protected lazy = false; + /** + * Array positions of dynamically materialized viewports/canvases (the viewport and + * canvas arrays always extend in lockstep, so one slot serves both). Recorded at + * materialization time instead of hardcoding, so band order never matters. + */ + protected rfTopSlot = -1; + protected rfBottomSlot = -1; + protected bfSlotL = -1; + protected bfSlotR = -1; + protected bfSlotRF = -1; + // panes paneHeaderL!: HTMLDivElement; paneHeaderR!: HTMLDivElement; @@ -319,6 +330,17 @@ class ViewportMgr { footerRowSpacerRF!: HTMLDivElement; footerRowRF!: HTMLDivElement; + // bottom-frozen row band (Phase 4 — exists only in simultaneous top+bottom mode) + paneBottomFrozenL!: HTMLDivElement; + paneBottomFrozenR!: HTMLDivElement; + paneBottomFrozenRF!: HTMLDivElement; + viewportBottomFrozenL!: HTMLDivElement; + viewportBottomFrozenR!: HTMLDivElement; + viewportBottomFrozenRF!: HTMLDivElement; + canvasBottomFrozenL!: HTMLDivElement; + canvasBottomFrozenR!: HTMLDivElement; + canvasBottomFrozenRF!: HTMLDivElement; + // footer rows (only when createFooterRow) footerRowScrollerL!: HTMLDivElement; footerRowScrollerR!: HTMLDivElement; @@ -681,6 +703,8 @@ class ViewportMgr { this.canvasTopRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right-frozen', tabIndex: 0 }, this.viewportTopRF); this.canvasBottomRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomRF); this.canvas.push(this.canvasTopRF, this.canvasBottomRF); + this.rfTopSlot = this.canvas.indexOf(this.canvasTopRF); + this.rfBottomSlot = this.canvas.indexOf(this.canvasBottomRF); // footer row if (o.createFooterRow) { @@ -695,9 +719,77 @@ class ViewportMgr { } } + // if the bottom-frozen band already exists, add the shared corner pane + this.ensureBottomFrozenRightVariant(o); + return true; } + /** + * Builds the bottom-frozen row band (Phase 4, simultaneous top+bottom mode): one + * pane+viewport+canvas per active column band, appended after all existing panes + * with `*-bottom-frozen` css classes. Element arrays extend at the END and the + * slots are recorded (bfSlotL/R/RF). Idempotent: returns false when the band + * already exists. The right-frozen column variant is built only when that band's + * DOM exists at call time; materializeRightFrozenBand adds it later otherwise. + */ + materializeBottomFrozenBand(o: ViewportMgrBuildOptions): boolean { + if (this.paneBottomFrozenL) { + // band exists — but the RF column variant may have arrived after us + this.ensureBottomFrozenRightVariant(o); + return false; + } + + const container = this.container; + const lastPane = this.paneBottomRF ?? this.paneBottomR ?? this.paneTopL; + + this.paneBottomFrozenL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-left', tabIndex: 0 }); + container.insertBefore(this.paneBottomFrozenL, lastPane.nextSibling); + this.paneBottomFrozenR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneBottomFrozenR, this.paneBottomFrozenL.nextSibling); + + this.viewportBottomFrozenL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-left', tabIndex: 0 }, this.paneBottomFrozenL); + this.viewportBottomFrozenR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right', tabIndex: 0 }, this.paneBottomFrozenR); + this.canvasBottomFrozenL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-left', tabIndex: 0 }, this.viewportBottomFrozenL); + this.canvasBottomFrozenR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right', tabIndex: 0 }, this.viewportBottomFrozenR); + + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + this.viewportBottomFrozenL.classList.add(...viewportClassList); + this.viewportBottomFrozenR.classList.add(...viewportClassList); + } + + this.viewport.push(this.viewportBottomFrozenL, this.viewportBottomFrozenR); + this.canvas.push(this.canvasBottomFrozenL, this.canvasBottomFrozenR); + this.bfSlotL = this.canvas.indexOf(this.canvasBottomFrozenL); + this.bfSlotR = this.canvas.indexOf(this.canvasBottomFrozenR); + + this.ensureBottomFrozenRightVariant(o); + return true; + } + + /** Adds the bottom-frozen × right-frozen corner pane when both bands exist. */ + protected ensureBottomFrozenRightVariant(o: ViewportMgrBuildOptions) { + if (!this.paneBottomFrozenL || !this.paneHeaderRF || this.paneBottomFrozenRF) { + return; + } + this.paneBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right-frozen', tabIndex: 0 }); + this.container.insertBefore(this.paneBottomFrozenRF, this.paneBottomFrozenR.nextSibling); + this.viewportBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomFrozenRF); + this.canvasBottomFrozenRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomFrozenRF); + if (o.viewportClass) { + this.viewportBottomFrozenRF.classList.add(...Utils.classNameToList(o.viewportClass)); + } + this.viewport.push(this.viewportBottomFrozenRF); + this.canvas.push(this.canvasBottomFrozenRF); + this.bfSlotRF = this.canvas.indexOf(this.canvasBottomFrozenRF); + } + + /** Whether the simultaneous top+bottom row mode is active AND its DOM exists. */ + hasBottomFrozenBand(): boolean { + return this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; + } + ////////////////////////////////////////////////////////////////////////////////////////////// // Freeze state and pane selection (Phase 2 of the encapsulation refactor) ////////////////////////////////////////////////////////////////////////////////////////////// @@ -756,7 +848,7 @@ class ViewportMgr { paneCellIndex(colIdx: number, rowIdx: number): number { const isBottomSide = this.freeze.hasFrozenRows && rowIdx >= this.freeze.actualFrozenRow + (this.freeze.frozenBottom ? 0 : 1); if (this.isColumnInRightFrozenBand(colIdx)) { - return 4 + (isBottomSide ? 1 : 0); + return isBottomSide ? this.rfBottomSlot : this.rfTopSlot; } const isRightSide = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; return (isBottomSide ? 2 : 0) + (isRightSide ? 1 : 0); @@ -941,6 +1033,26 @@ class ViewportMgr { this.hideIf(this.paneTopRF); this.hideIf(this.paneBottomRF); } + + // bottom-frozen row band (simultaneous top+bottom mode only); column-band + // visibility mirrors the classic bottom panes + if (this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0) { + this.showIf(this.paneBottomFrozenL); + if (this.hasFrozenColumns()) { + this.showIf(this.paneBottomFrozenR); + } else { + this.hideIf(this.paneBottomFrozenR); + } + if (this.bands.frozenRightCols > 0) { + this.showIf(this.paneBottomFrozenRF); + } else { + this.hideIf(this.paneBottomFrozenRF); + } + } else { + this.hideIf(this.paneBottomFrozenL); + this.hideIf(this.paneBottomFrozenR); + this.hideIf(this.paneBottomFrozenRF); + } } /** @@ -1657,6 +1769,13 @@ export class SlickGrid = Column, O e protected _focusSink!: HTMLDivElement; protected _focusSink2!: HTMLDivElement; protected _viewportMgr!: ViewportMgr; + /** + * True once finishInitialization has run its array-wide bindPaneEvents pass. + * Band materializers bind their new elements only AFTER this point; during the + * init window (initialized is already true but the pass hasn't run) the arrays + * still cover everything, so binding in the materializer would double-register. + */ + protected _paneEventsBound = false; protected _groupHeaders: HTMLDivElement[] = []; protected _headerScroller: HTMLDivElement[] = []; protected _headers: HTMLDivElement[] = []; @@ -2195,7 +2314,7 @@ export class SlickGrid = Column, O e } this.syncViewportMgrAliases(); - if (this.initialized) { + if (this._paneEventsBound) { this.disableSelection([this._headerR]); this.bindPaneEvents({ @@ -2258,6 +2377,7 @@ export class SlickGrid = Column, O e footerRows: this._footerRow, footerRowScrollers: this._footerRowScroller, }); + this._paneEventsBound = true; if (this._options.createTopHeaderPanel) { this._bindingEventService.bind(this._topHeaderPanelScroller, 'scroll', this.handleTopHeaderPanelScroll.bind(this) as EventListener); @@ -3744,6 +3864,41 @@ export class SlickGrid = Column, O e if (this._options.frozenRightColumn! > 0) { this.materializeRightFrozenPanes(); } + + // materialize the bottom-frozen row band the first time simultaneous + // top+bottom freezing is applied + if (this._options.frozenRow! > -1 && this._options.frozenBottomRow! > 0) { + this.materializeBottomFrozenPanes(); + } + } + + /** + * Builds the bottom-frozen row band on first use (simultaneous top+bottom mode) + * and wires events for the new elements when the grid is already live. + */ + protected materializeBottomFrozenPanes() { + // canonicalize the classic pane set first (no-op on non-lazy grids) + this.materializeLazyPanes(); + + const vm = this._viewportMgr; + const hadCorner = !!vm.canvasBottomFrozenRF; + if (!vm.materializeBottomFrozenBand(this._options)) { + // idempotent call may still have added the RF corner variant late + if (!hadCorner && vm.canvasBottomFrozenRF && this._paneEventsBound) { + this.bindPaneEvents({ viewports: [vm.viewportBottomFrozenRF], canvases: [vm.canvasBottomFrozenRF] }); + } + return; + } + + if (this._paneEventsBound) { + const viewports = [vm.viewportBottomFrozenL, vm.viewportBottomFrozenR]; + const canvases = [vm.canvasBottomFrozenL, vm.canvasBottomFrozenR]; + if (vm.canvasBottomFrozenRF) { + viewports.push(vm.viewportBottomFrozenRF); + canvases.push(vm.canvasBottomFrozenRF); + } + this.bindPaneEvents({ viewports, canvases }); + } } /** @@ -3752,20 +3907,28 @@ export class SlickGrid = Column, O e * setOptions) and wires events for the new elements when the grid is already live. */ protected materializeRightFrozenPanes() { - // canonicalize the classic pane set first so the RF viewports/canvases land at - // array indexes 4/5 (paneCellIndex depends on it) — no-op on non-lazy grids + // canonicalize the classic pane set first (paneCellIndex's classic slots 0-3 + // depend on it) — no-op on non-lazy grids this.materializeLazyPanes(); + const hadCorner = !!this._viewportMgr.canvasBottomFrozenRF; if (!this._viewportMgr.materializeRightFrozenBand(this._options)) { return; } - if (this.initialized) { + if (this._paneEventsBound) { const vm = this._viewportMgr; this.disableSelection([vm.headerRF]); + const viewports = [vm.viewportTopRF, vm.viewportBottomRF]; + const canvases = [vm.canvasTopRF, vm.canvasBottomRF]; + if (!hadCorner && vm.canvasBottomFrozenRF) { + // the shared bottom-frozen × right-frozen corner arrived with this band + viewports.push(vm.viewportBottomFrozenRF); + canvases.push(vm.canvasBottomFrozenRF); + } this.bindPaneEvents({ - viewports: [vm.viewportTopRF, vm.viewportBottomRF], - canvases: [vm.canvasTopRF, vm.canvasBottomRF], + viewports, + canvases, headerScrollers: [vm.headerScrollerRF], headerRowScrollers: [vm.headerRowScrollerRF], footerRows: this._options.createFooterRow ? [vm.footerRowRF] : [], From 0ea64eac183bab2ddff4ddc421b18c1f5bd5aa0c Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 21:26:51 +0930 Subject: [PATCH 22/43] =?UTF-8?q?feat:=20bottom-frozen=20band=20geometry?= =?UTF-8?q?=20=E2=80=94=20heights,=20placement,=20widths,=20X-following=20?= =?UTF-8?q?(Phase=204,=20milestone=2014c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In simultaneous top+bottom mode the scrollable body pane shrinks by the band height inside the height computation itself (single write, no post-adjustment); the band pins directly below the body with per-column-band widths mirroring the classic bottom panes across all three column configurations (left-frozen / right-frozen / plain), including the shared BF×RF corner pane. Band viewports are scrollbar-less on both axes; the band's scrollable-column viewport joins the X-followers in syncHorizontalScroll, mirroring how frozen-top viewports follow horizontal scrolling. The body pane remains the X/Y scroll owner (v1 layout: the horizontal scrollbar sits at the body's bottom edge, above the band). Rows still render in the classic canvases until M14d routing; the spec's new geometry test pins band height = frozenBottomRow * rowHeight and its position directly below the body pane. Full suite verified green BEFORE this commit: 631 tests, 630 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- .../e2e/viewportmgr-bottom-frozen-band.cy.ts | 18 ++++ src/slick.grid.ts | 83 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts index 5a39ceac..c0a51997 100644 --- a/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts +++ b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts @@ -31,6 +31,24 @@ describe('bottom-frozen band DOM - frozenRow + frozenBottomRow at init (example- cy.get('#myGrid .slick-viewport-bottom-frozen.slick-viewport-left > .grid-canvas.grid-canvas-bottom-frozen.grid-canvas-left').should('have.length', 1); }); + it('should size and pin the band below the shrunk body pane (M14c geometry)', () => { + // example options: frozenRow: 3, frozenBottomRow: 2, default rowHeight 25 + cy.get('#myGrid .slick-pane-bottom-frozen.slick-pane-left').then(($bf) => { + const bf = $bf[0] as HTMLElement; + expect(bf.offsetHeight, 'band height = frozenBottomRow * rowHeight').to.equal(2 * 25); + + cy.get('#myGrid .slick-pane-bottom.slick-pane-left').then(($body) => { + const body = $body[0] as HTMLElement; + expect(bf.offsetTop, 'band sits directly below the body pane') + .to.be.closeTo(body.offsetTop + body.offsetHeight, 2); + }); + }); + + cy.get('#myGrid .slick-viewport-bottom-frozen.slick-viewport-left').then(($vp) => { + expect(($vp[0] as HTMLElement).offsetHeight).to.equal(2 * 25); + }); + }); + it('should keep classic top-frozen rendering at this stage (geometry and routing land later)', () => { // classic top-frozen layout: 3 frozen rows in the top-left canvas, body in bottom-left cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length', 3); diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 8fd4785b..cc230745 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -197,6 +197,8 @@ interface CanvasWidthsGeometry { interface PaneHeightsGeometry { viewportH: number; frozenRowsHeight: number; + /** height of the bottom-frozen row band (simultaneous top+bottom mode; 0 otherwise) */ + frozenBottomRowsHeight?: number; scrollbarHeight: number; topPanelH: number; headerRowH: number; @@ -1081,6 +1083,21 @@ class ViewportMgr { this.viewportBottomR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'auto' : 'auto') : (hasFrozenRows ? 'auto' : 'auto')); } + // bottom-frozen viewports never own a scrollbar: Y is fixed, X follows the + // scroll owner programmatically + if (this.viewportBottomFrozenL) { + this.viewportBottomFrozenL.style.overflowX = 'hidden'; + this.viewportBottomFrozenL.style.overflowY = 'hidden'; + } + if (this.viewportBottomFrozenR) { + this.viewportBottomFrozenR.style.overflowX = 'hidden'; + this.viewportBottomFrozenR.style.overflowY = 'hidden'; + } + if (this.viewportBottomFrozenRF) { + this.viewportBottomFrozenRF.style.overflowX = 'hidden'; + this.viewportBottomFrozenRF.style.overflowY = 'hidden'; + } + // right-frozen viewports never own a scrollbar: X is fixed, Y follows the // scroll owner programmatically (same rationale as the frozen-left viewport) if (this.viewportTopRF) { @@ -1211,6 +1228,35 @@ class ViewportMgr { } } + // bottom-frozen row band (simultaneous mode): column widths mirror the classic + // bottom panes + if (this.hasBottomFrozenBand()) { + if (this.hasFrozenColumns()) { + Utils.width(this.paneBottomFrozenL, g.canvasWidthL); + Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); + Utils.setStyleSize(this.paneBottomFrozenR, 'left', g.canvasWidthL); + Utils.width(this.paneBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); + Utils.width(this.viewportBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); + Utils.width(this.canvasBottomFrozenR, g.canvasWidthR); + Utils.width(this.viewportBottomFrozenL, g.canvasWidthL); + } else if (rfActive) { + Utils.width(this.paneBottomFrozenL, g.viewportW - rfW); + Utils.width(this.viewportBottomFrozenL, g.viewportW - rfW); + Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); + } else { + Utils.width(this.paneBottomFrozenL, '100%'); + Utils.width(this.viewportBottomFrozenL, '100%'); + Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); + } + + if (this.paneBottomFrozenRF && rfActive) { + Utils.setStyleSize(this.paneBottomFrozenRF, 'left', g.viewportW - rfW); + Utils.width(this.paneBottomFrozenRF, rfW); + Utils.width(this.viewportBottomFrozenRF, rfW); + Utils.width(this.canvasBottomFrozenRF, g.canvasWidthRF); + } + } + // right-frozen band: fixed-width panes pinned to the right edge if (rfActive) { const rfLeft = g.viewportW - rfW; @@ -1264,6 +1310,7 @@ class ViewportMgr { const viewportBottomH = 0; // Account for Frozen Rows + const simultaneousBands = this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; if (this.freeze.hasFrozenRows) { if (this.freeze.frozenBottom) { paneTopH = g.viewportH - g.frozenRowsHeight - g.scrollbarHeight; @@ -1271,6 +1318,10 @@ class ViewportMgr { } else { paneTopH = g.frozenRowsHeight; paneBottomH = g.viewportH - g.frozenRowsHeight; + if (simultaneousBands) { + // the scrollable body shrinks by the bottom-frozen band height + paneBottomH -= g.frozenBottomRowsHeight ?? 0; + } } } else { paneTopH = g.viewportH; @@ -1360,6 +1411,31 @@ class ViewportMgr { } } + // bottom-frozen row band (simultaneous mode): pinned below the shrunk body pane + if (simultaneousBands) { + const bfH = g.frozenBottomRowsHeight ?? 0; + const bfTop = this.paneTopL.offsetTop + paneTopH + paneBottomH; + + Utils.setStyleSize(this.paneBottomFrozenL, 'top', bfTop); + Utils.height(this.paneBottomFrozenL, bfH); + Utils.height(this.viewportBottomFrozenL, bfH); + Utils.height(this.canvasBottomFrozenL, bfH); + + if (this.hasFrozenColumns()) { + Utils.setStyleSize(this.paneBottomFrozenR, 'top', bfTop); + Utils.height(this.paneBottomFrozenR, bfH); + Utils.height(this.viewportBottomFrozenR, bfH); + Utils.height(this.canvasBottomFrozenR, bfH); + } + + if (this.paneBottomFrozenRF) { + Utils.setStyleSize(this.paneBottomFrozenRF, 'top', bfTop); + Utils.height(this.paneBottomFrozenRF, bfH); + Utils.height(this.viewportBottomFrozenRF, bfH); + Utils.height(this.canvasBottomFrozenRF, bfH); + } + } + // right-frozen band: mirror the classic right-pane vertical geometry if (this.bands.frozenRightCols > 0 && this.paneHeaderRF) { let topHeightOffsetRF = Utils.height(this.paneHeaderL); @@ -1463,6 +1539,12 @@ class ViewportMgr { } this.headerRowScrollerL.scrollLeft = x; // left header row scrolling with regular grid } + + // the bottom-frozen band's scrollable-column viewport follows X like the + // frozen-top viewports do + if (this.hasBottomFrozenBand()) { + (this.hasFrozenColumns() ? this.viewportBottomFrozenR : this.viewportBottomFrozenL).scrollLeft = x; + } } /** Mirrors the Y scroll position onto the frozen-band viewports that follow the scroll owner. */ @@ -7322,6 +7404,7 @@ export class SlickGrid = Column, O e const heights = this._viewportMgr.applyPaneHeights({ viewportH: this.viewportH, frozenRowsHeight: this.frozenRowsHeight, + frozenBottomRowsHeight: (this._options.frozenBottomRow ?? 0) * this._options.rowHeight!, scrollbarHeight: this.scrollbarDimensions?.height ?? 0, topPanelH: this.topPanelH, headerRowH: this.headerRowH, From 88dfe550b51199b2ce3ebf0324f23f27f6f61913 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Mon, 13 Jul 2026 23:35:30 +0930 Subject: [PATCH 23/43] =?UTF-8?q?feat:=20route=20rows=20into=20the=20botto?= =?UTF-8?q?m-frozen=20band=20=E2=80=94=20simultaneous=20top+bottom=20froze?= =?UTF-8?q?n=20rows=20work=20end-to-end=20(Phase=204,=20milestone=2014d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3x3 band model is complete: with frozenRow + frozenBottomRow set, the last frozenBottomRow rows render in the band's canvases across all column bands. - isRowInBottomFrozenBand: BOUNDED membership (splitRow <= row < splitRow + count) so the add-new row can never be captured; the same test serves both render and lookup sides — the new band deliberately has no threshold asymmetry (the historical one-row asymmetry remains only in the legacy single-band logic). - bottomFrozenSplitRow = dataLength - frozenBottomRow, computed in setFrozenOptions (staleness semantics deliberately match actualFrozenRow's). - attachRow routes three row bands x three column bands (incl. the BF x RF corner fragment); paneCellIndex resolves BF cells via the slot registry. - render() gains a second frozen pass + a band range in the horizontal-scroll cell pass; updateRowCount excludes the band from the scrollable body count; frozenRowOffset rebases band rows to band-local coordinates; cleanupRows/ cleanUpCells exempt the band; band rows get the frozen css class; scrollRowIntoView never scrolls for band rows; getCellFromEvent resolves clicks in the band canvas (the -bottom-frozen class token cannot collide with the classic -bottom selector). Spec now asserts real routing: Task 498/499 in the band with band-local y=0, frozen class, click-activation without body scroll, runtime toggle restoring classic routing. Full suite verified green BEFORE this commit: 632 tests, 631 pass / 1 pending. BF-off equivalence audit running as the closing check. Co-Authored-By: Claude Fable 5 --- .../e2e/viewportmgr-bottom-frozen-band.cy.ts | 37 ++++++-- src/slick.grid.ts | 85 +++++++++++++++++-- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts index c0a51997..b145b872 100644 --- a/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts +++ b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts @@ -49,12 +49,34 @@ describe('bottom-frozen band DOM - frozenRow + frozenBottomRow at init (example- }); }); - it('should keep classic top-frozen rendering at this stage (geometry and routing land later)', () => { - // classic top-frozen layout: 3 frozen rows in the top-left canvas, body in bottom-left + it('should route rows into all three row bands (M14d routing)', () => { + // top band: 3 frozen rows; body: scrollable middle; bottom band: last 2 rows cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length', 3); - cy.get('#myGrid .grid-canvas-bottom.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); - cy.get('#myGrid .grid-canvas-bottom-frozen .slick-row').should('have.length', 0); cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-cell').first().should('contain', 'Task 0'); + cy.get('#myGrid .grid-canvas-bottom.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); + + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-row').should('have.length', 2); + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-row').first() + .should('have.class', 'frozen') + .find('.slick-cell').first().should('contain', 'Task 498'); + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-row').last() + .find('.slick-cell').first().should('contain', 'Task 499'); + + // band-local coordinates: the first bottom-frozen row sits at the band origin + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-row').first().then(($row) => { + expect(($row[0] as HTMLElement).offsetTop, 'first band row rebased to y=0').to.equal(0); + }); + }); + + it('should activate a bottom-frozen cell on click without scrolling the body', () => { + cy.get('#myGrid .slick-viewport-bottom.slick-viewport-left').then(($vp) => { + const before = ($vp[0] as HTMLElement).scrollTop; + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-cell').first().click(); + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-cell.active').should('have.length', 1); + cy.get('#myGrid .slick-viewport-bottom.slick-viewport-left').then(($vp2) => { + expect(($vp2[0] as HTMLElement).scrollTop, 'body did not scroll').to.equal(before); + }); + }); }); }); @@ -70,15 +92,20 @@ describe('bottom-frozen band DOM - runtime materialization on a classic grid', ( cy.get('#myGrid > .slick-pane').should('have.length', 8); cy.get('#myGrid .slick-viewport').should('have.length', 6); cy.get('#myGrid .grid-canvas').should('have.length', 6); + + // routing follows the runtime toggle + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-row').should('have.length', 2); + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-cell').first().should('contain', 'Task 498'); }); - it('should keep the band in the DOM but hidden when simultaneous mode is turned off', () => { + it('should keep the band in the DOM but hidden when simultaneous mode is turned off, restoring classic routing', () => { cy.window().then((win: any) => { win.grid.setOptions({ frozenBottomRow: 0 }); }); cy.get('#myGrid > .slick-pane').should('have.length', 8); cy.get('#myGrid .slick-pane-bottom-frozen.slick-pane-left').should('not.be.visible'); + cy.get('#myGrid .grid-canvas-bottom-frozen.grid-canvas-left .slick-row').should('have.length', 0); cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length.greaterThan', 0); }); }); diff --git a/src/slick.grid.ts b/src/slick.grid.ts index cc230745..15b7216b 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -160,6 +160,8 @@ interface ViewportFreezeState { frozenRightStartIdx?: number; /** the frozenBottomRow option value — rows frozen at the bottom ALONGSIDE top rows (0 when none) */ frozenBottomRowCount?: number; + /** first row of the bottom-frozen band = dataLength − frozenBottomRow (MAX_SAFE_INTEGER when off) */ + bottomFrozenSplitRow?: number; } /** @@ -792,6 +794,20 @@ class ViewportMgr { return this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; } + /** + * Whether the row belongs to the bottom-frozen band. BOUNDED on both sides so the + * add-new row (index === dataLength) can never be captured by the band. The same + * test is used on both the render and lookup sides — the new band deliberately + * avoids the historical one-row threshold asymmetry of the legacy single band. + */ + isRowInBottomFrozenBand(row: number): boolean { + if (!this.hasBottomFrozenBand()) { + return false; + } + const split = this.freeze.bottomFrozenSplitRow ?? Number.MAX_SAFE_INTEGER; + return row >= split && row < split + this.bands.frozenBottomRows; + } + ////////////////////////////////////////////////////////////////////////////////////////////// // Freeze state and pane selection (Phase 2 of the encapsulation refactor) ////////////////////////////////////////////////////////////////////////////////////////////// @@ -848,6 +864,13 @@ class ViewportMgr { * so these positions hold under lazyPanes too). */ paneCellIndex(colIdx: number, rowIdx: number): number { + if (this.isRowInBottomFrozenBand(rowIdx)) { + if (this.isColumnInRightFrozenBand(colIdx)) { + return this.bfSlotRF; + } + const isRightSideBF = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; + return isRightSideBF ? this.bfSlotR : this.bfSlotL; + } const isBottomSide = this.freeze.hasFrozenRows && rowIdx >= this.freeze.actualFrozenRow + (this.freeze.frozenBottom ? 0 : 1); if (this.isColumnInRightFrozenBand(colIdx)) { return isBottomSide ? this.rfBottomSlot : this.rfTopSlot; @@ -866,6 +889,11 @@ class ViewportMgr { * @param {Number} row - grid row number */ frozenRowOffset(row: number, g: { h: number; viewportTopH: number; frozenRowsHeight: number; rowHeight: number; }): number { + // bottom-frozen band (simultaneous mode): rebase to band-local coordinates + if (this.isRowInBottomFrozenBand(row)) { + return this.freeze.bottomFrozenSplitRow! * g.rowHeight; + } + // let offset = ( hasFrozenRows ) ? ( this._options.frozenBottom ) ? ( row >= actualFrozenRow ) ? ( h < viewportTopH ) ? ( actualFrozenRow * this._options.rowHeight ) : h : 0 : ( row >= actualFrozenRow ) ? frozenRowsHeight : 0 : 0; // WTF? let offset = 0; if (this.freeze.hasFrozenRows) { @@ -899,6 +927,9 @@ class ViewportMgr { * virtualization cleanup (historical cleanupRows predicate). */ isRowInFrozenBand(row: number): boolean { + if (this.isRowInBottomFrozenBand(row)) { + return true; + } return this.freeze.hasFrozenRows && ((this.freeze.frozenBottom && row >= this.freeze.actualFrozenRow) // Frozen bottom rows || (!this.freeze.frozenBottom && row <= this.freeze.actualFrozenRow) // Frozen top rows @@ -912,6 +943,9 @@ class ViewportMgr { * long-standing upstream behaviour and is deliberately preserved. */ isRowCellCleanupExempt(row: number): boolean { + if (this.isRowInBottomFrozenBand(row)) { + return true; + } return this.freeze.hasFrozenRows && ((this.freeze.frozenBottom && row > this.freeze.actualFrozenRow) // Frozen bottom rows || (row <= this.freeze.actualFrozenRow) // Frozen top rows @@ -1478,9 +1512,21 @@ class ViewportMgr { */ attachRow(rowIdx: number, left: HTMLElement | null, right: HTMLElement | null, rightFrozen?: HTMLElement | null): HTMLElement[] | null { let attached: HTMLElement[] | null = null; - const isBottomBand = (this.freeze.hasFrozenRows) && (rowIdx >= this.freeze.actualFrozenRow); + const isBFBand = this.isRowInBottomFrozenBand(rowIdx); + const isBottomBand = !isBFBand && (this.freeze.hasFrozenRows) && (rowIdx >= this.freeze.actualFrozenRow); - if (isBottomBand) { + if (isBFBand) { + if (this.hasFrozenColumns()) { + if (left && right) { + this.canvasBottomFrozenL.appendChild(left); + this.canvasBottomFrozenR.appendChild(right); + attached = [left, right]; + } + } else if (left) { + this.canvasBottomFrozenL.appendChild(left); + attached = [left]; + } + } else if (isBottomBand) { if (this.hasFrozenColumns()) { if (left && right) { this.canvasBottomL.appendChild(left); @@ -1503,7 +1549,7 @@ class ViewportMgr { } // right-frozen fragment always sits LAST in the rowNode array - const rfTargetCanvas = isBottomBand ? this.canvasBottomRF : this.canvasTopRF; + const rfTargetCanvas = isBFBand ? this.canvasBottomFrozenRF : (isBottomBand ? this.canvasBottomRF : this.canvasTopRF); if (attached && this.bands.frozenRightCols > 0 && rfTargetCanvas && rightFrozen) { rfTargetCanvas.appendChild(rightFrozen); attached.push(rightFrozen); @@ -3934,6 +3980,9 @@ export class SlickGrid = Column, O e frozenRightColCount: this._options.frozenRightColumn!, frozenRightStartIdx: this.getFrozenRightStartIdx(), frozenBottomRowCount: this._options.frozenBottomRow!, + bottomFrozenSplitRow: (this._options.frozenRow! > -1 && this._options.frozenBottomRow! > 0) + ? this.getDataLength() - this._options.frozenBottomRow! + : Number.MAX_SAFE_INTEGER, }); // materialize the secondary panes if freezing was just enabled on a lazyPanes grid @@ -6183,8 +6232,12 @@ export class SlickGrid = Column, O e let rowOffset = 0; const c = Utils.offset(Utils.parents(cellNode, '.grid-canvas')[0] as HTMLElement); const isBottom = Utils.parents(cellNode, '.grid-canvas-bottom').length; + const isBottomFrozen = Utils.parents(cellNode, '.grid-canvas-bottom-frozen').length; - if (isBottom) { + if (isBottomFrozen) { + // bottom-frozen band: canvas origin is the band's first row + rowOffset = (this.getDataLength() - this._options.frozenBottomRow!) * this._options.rowHeight!; + } else if (isBottom) { rowOffset = (this._options.frozenBottom) ? Utils.height(this._canvasTopL) as number : this.frozenRowsHeight; } @@ -6857,7 +6910,7 @@ export class SlickGrid = Column, O e const d = this.getDataItem(row); const dataLoading = row < dataLength && !d; let rowCss = 'slick-row' + - (this._viewportMgr.hasFrozenRows() && row <= this._options.frozenRow! ? ' frozen' : '') + + ((this._viewportMgr.hasFrozenRows() && row <= this._options.frozenRow!) || this._viewportMgr.isRowInBottomFrozenBand(row) ? ' frozen' : '') + (dataLoading ? ' loading' : '') + (row === this.activeRow && this._options.showCellSelection ? ' active' : '') + (row % 2 === 1 ? ' odd' : ' even'); @@ -7459,6 +7512,10 @@ export class SlickGrid = Column, O e if (this._viewportMgr.hasFrozenRows()) { numberOfRows = this.getDataLength() - this._options.frozenRow!; + if (this._viewportMgr.hasBottomFrozenBand()) { + // the bottom-frozen band's rows are not part of the scrollable body + numberOfRows -= this._options.frozenBottomRow!; + } } else { numberOfRows = dataLengthIncludingAddNew + (this._options.leaveSpaceForNewRows ? this.numVisibleRows - 1 : 0); } @@ -7960,6 +8017,12 @@ export class SlickGrid = Column, O e } this.cleanUpAndRenderCells(renderedFrozenRows); } + if (this._viewportMgr.hasBottomFrozenBand()) { + const renderedBottomFrozenRows = Utils.extend(true, {}, rendered); + renderedBottomFrozenRows.top = this.getDataLength() - this._options.frozenBottomRow!; + renderedBottomFrozenRows.bottom = this.getDataLength() - 1; + this.cleanUpAndRenderCells(renderedBottomFrozenRows); + } this.cleanUpAndRenderCells(rendered); } @@ -7979,6 +8042,13 @@ export class SlickGrid = Column, O e } } + // Render the bottom-frozen band (simultaneous top+bottom mode) + if (this._viewportMgr.hasBottomFrozenBand()) { + this.renderRows({ + top: this.getDataLength() - this._options.frozenBottomRow!, bottom: this.getDataLength() - 1, leftPx: rendered.leftPx, rightPx: rendered.rightPx + }); + } + this.postProcessFromRow = visible.top; this.postProcessToRow = Math.min(this.getDataLengthIncludingAddNew() - 1, visible.bottom); this.startPostProcessing(); @@ -8492,6 +8562,11 @@ export class SlickGrid = Column, O e * @param {Boolean} doPaging - scroll when pagination is enabled */ scrollRowIntoView(row: number, doPaging?: boolean) { + // bottom-frozen rows are always vertically visible — never scroll for them + if (this._viewportMgr.isRowInBottomFrozenBand(row)) { + return; + } + if (!this._viewportMgr.hasFrozenRows() || (!this._options.frozenBottom && row > this.actualFrozenRow - 1) || (this._options.frozenBottom && row < this.actualFrozenRow - 1)) { From 2a31d65154a1aa857083f29a98b18e192069cf4e Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Tue, 14 Jul 2026 10:41:09 +0930 Subject: [PATCH 24/43] fix: harden M14 per BF-off equivalence audit (Phase 4, M14 hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-agent adversarial audit of the M14b-d delta vs the M13e tip; the render-routing area proved fully inert when simultaneous mode is off, and one real regression plus two tightenings were found and fixed: - disableSelection ordering (REAL): the _paneEventsBound guard swap had silently dropped disableSelection() for headers materialized during the init window (right-frozen-at-init and lazy+right-frozen grids) — it was never part of the double-binding the flag fixed, and the array-wide disableSelection(this._headers) ran BEFORE setFrozenOptions created those headers. finishInitialization now applies it AFTER setFrozenOptions so init-materialized headers are covered via the shared array, with no double-application. - setFrozenOptions consults getDataLength() only when frozenBottomRow is actually in use, restoring BASE's call cadence for legacy grids (matters for side-effectful CustomDataView.getLength implementations and the null-data pre-init edge). - getCellFromEvent short-circuits the '.grid-canvas-bottom-frozen' ancestor walk behind hasBottomFrozenBand() (perf; the class token could never false-match). Full suite verified green BEFORE this commit: 632 tests, 631 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 15b7216b..d7645ad9 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -2479,9 +2479,13 @@ export class SlickGrid = Column, O e // calculate the diff so we can set consistent sizes this.measureCellPaddingAndBorder(); - this.disableSelection(this._headers); // disable all text selection in header (including input and textarea) - this.setFrozenOptions(); + + // disable all text selection in header (including input and textarea); + // AFTER setFrozenOptions so headers materialized during the init window + // (right-frozen / lazy bands) are included in the shared array + this.disableSelection(this._headers); + this.setPaneFrozenClasses(); this.setPaneVisibility(); this.setScroller(); @@ -3953,11 +3957,14 @@ export class SlickGrid = Column, O e // normalize the bottom-frozen row COUNT: non-negative integer, and the top and // bottom bands must leave at least one scrollable body row between them - const topRowCount = (this._options.frozenRow! > -1 && !this._options.frozenBottom) ? this._options.frozenRow! : 0; - const maxBottomRows = Math.max(0, this.getDataLength() - topRowCount - 1); - this._options.frozenBottomRow = (this._options.frozenBottomRow! > 0) - ? Math.min(parseInt(this._options.frozenBottomRow as unknown as string, 10), maxBottomRows) - : 0; + // (getDataLength() only consulted when the option is actually in use) + if (this._options.frozenBottomRow! > 0) { + const topRowCount = (this._options.frozenRow! > -1 && !this._options.frozenBottom) ? this._options.frozenRow! : 0; + const maxBottomRows = Math.max(0, this.getDataLength() - topRowCount - 1); + this._options.frozenBottomRow = Math.min(parseInt(this._options.frozenBottomRow as unknown as string, 10), maxBottomRows); + } else { + this._options.frozenBottomRow = 0; + } if (this._options.frozenRow! > -1) { this.hasFrozenRows = true; @@ -6232,7 +6239,7 @@ export class SlickGrid = Column, O e let rowOffset = 0; const c = Utils.offset(Utils.parents(cellNode, '.grid-canvas')[0] as HTMLElement); const isBottom = Utils.parents(cellNode, '.grid-canvas-bottom').length; - const isBottomFrozen = Utils.parents(cellNode, '.grid-canvas-bottom-frozen').length; + const isBottomFrozen = this._viewportMgr.hasBottomFrozenBand() && Utils.parents(cellNode, '.grid-canvas-bottom-frozen').length; if (isBottomFrozen) { // bottom-frozen band: canvas origin is the band's first row From af9caacd5bba648168f553ad790837370ac43175 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Tue, 14 Jul 2026 14:53:37 +0930 Subject: [PATCH 25/43] feat: migrate cellrangeselector onto the public band API (Phase 4, milestone 15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New public band facade on the grid — getFrozenBandCounts() (copy of the band-count view) and getFrozenRightStartIndex() (safe membership boundary) — and the cellrangeselector plugin now drives its band logic from it instead of raw frozen options. Beyond the migration this fixes two real gaps: - the plugin's cross-canvas measurement now respects the 'frozenBottom is inert when frozenBottomRow is set' rule (it previously read the raw flag and would measure the wrong canvas in simultaneous mode); - drags starting in right-frozen or bottom-frozen canvases get correct pixel offsets, and range extension is clamped at the right-freeze and bottom-freeze boundaries (previously zero offsets and no clamping — those bands postdate the plugin). Preserved exactly: the degenerate frozenRow: 0 clamp semantics (documented raw activity flag) and the original clamp's operator-precedence shape. Full suite verified green BEFORE this commit — twice (interrupted run completed green + clean re-run): 632 tests, 631 pass / 1 pending each. Co-Authored-By: Claude Fable 5 --- src/plugins/slick.cellrangeselector.ts | 74 ++++++++++++++++++++++---- src/slick.grid.ts | 16 ++++++ 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/plugins/slick.cellrangeselector.ts b/src/plugins/slick.cellrangeselector.ts index 71e3dbfa..6eb5cb7b 100644 --- a/src/plugins/slick.cellrangeselector.ts +++ b/src/plugins/slick.cellrangeselector.ts @@ -52,6 +52,12 @@ export class SlickCellRangeSelector implements SlickPlugin { protected _columnOffset = 0; protected _isRightCanvas = false; protected _isBottomCanvas = false; + protected _isRightFrozenCanvas = false; + protected _isBottomFrozenCanvas = false; + /** band view of the grid's freeze configuration, refreshed on each drag init */ + protected _bands = { frozenLeftCols: 0, frozenRightCols: 0, frozenTopRows: 0, frozenBottomRows: 0 }; + /** raw activity flag — deliberately includes the degenerate frozenRow: 0 configuration */ + protected _legacyRowFreezeActive = false; // autoScroll related constiables protected _activeViewport!: HTMLElement; @@ -133,25 +139,53 @@ export class SlickCellRangeSelector implements SlickPlugin { this._rowOffset = 0; this._columnOffset = 0; - this._isBottomCanvas = this._activeCanvas.classList.contains('grid-canvas-bottom'); - if (this._gridOptions.frozenRow! > -1 && this._isBottomCanvas) { - const canvasSelector = `.${this._grid.getUID()} .grid-canvas-${this._gridOptions.frozenBottom ? 'bottom' : 'top'}`; + // band view of the freeze configuration (Phase 4 band API); the raw frozenRow + // activity flag is kept solely to preserve the degenerate frozenRow: 0 semantics + this._bands = this._grid.getFrozenBandCounts(); + this._legacyRowFreezeActive = this._gridOptions.frozenRow! > -1; + + this._isBottomCanvas = this._activeCanvas.classList.contains('grid-canvas-bottom'); + this._isBottomFrozenCanvas = this._activeCanvas.classList.contains('grid-canvas-bottom-frozen'); + this._isRightCanvas = this._activeCanvas.classList.contains('grid-canvas-right'); + this._isRightFrozenCanvas = this._activeCanvas.classList.contains('grid-canvas-right-frozen'); + + if (this._legacyRowFreezeActive && this._isBottomCanvas) { + // measure the canvas above the drag canvas; the single frozen band sits at the + // bottom only in legacy frozenBottom mode (band counts already encode that the + // flag is inert when frozenBottomRow is in use) + const legacyBottomMode = this._bands.frozenBottomRows > 0 && this._bands.frozenTopRows === 0; + const canvasSelector = `.${this._grid.getUID()} .grid-canvas-${legacyBottomMode ? 'bottom' : 'top'}`; const canvasElm = document.querySelector(canvasSelector); if (canvasElm) { this._rowOffset = canvasElm.clientHeight || 0; } } - this._isRightCanvas = this._activeCanvas.classList.contains('grid-canvas-right'); + if (this._isBottomFrozenCanvas) { + // bottom-frozen band canvas: origin is the band's first row + this._rowOffset = (this._grid.getDataLength() - this._bands.frozenBottomRows) * (this._gridOptions.rowHeight ?? 25); + } - if (this._gridOptions.frozenColumn! > -1 && this._isRightCanvas) { + if (this._bands.frozenLeftCols > 0 && this._isRightCanvas) { const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-left`); if (canvasLeftElm) { this._columnOffset = canvasLeftElm.clientWidth || 0; } } + if (this._isRightFrozenCanvas) { + // right-frozen band canvas: offset by every canvas to its left (left band, when + // present, plus the scrollable middle band) + const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-top.grid-canvas-left`); + let offset = canvasLeftElm?.clientWidth || 0; + if (this._bands.frozenLeftCols > 0) { + const canvasMiddleElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-top.grid-canvas-right`); + offset += canvasMiddleElm?.clientWidth || 0; + } + this._columnOffset = offset; + } + this._dragReplaceHandleActive = (dd.matchClassTag === 'dragReplaceHandle'); if (this._dragReplaceHandleActive) { this._dragReplaceHandleCell = this._grid.getCellFromEvent(e); @@ -180,12 +214,12 @@ export class SlickCellRangeSelector implements SlickPlugin { const canvasOffset = Utils.offset(this._canvas); let startX = dd.startX - (canvasOffset?.left ?? 0); - if (this._gridOptions.frozenColumn! >= 0 && this._isRightCanvas) { + if (this._bands.frozenLeftCols > 0 && this._isRightCanvas) { startX += this._scrollLeft; } let startY = dd.startY - (canvasOffset?.top ?? 0); - if (this._gridOptions.frozenRow! >= 0 && this._isBottomCanvas) { + if (this._legacyRowFreezeActive && this._isBottomCanvas) { startY += this._scrollTop; } @@ -351,16 +385,36 @@ export class SlickCellRangeSelector implements SlickPlugin { targetEvent.pageY - (canvasOffset?.top ?? 0) + this._rowOffset ); - // ... frozen column(s), - if (this._gridOptions.frozenColumn! >= 0 && (!this._isRightCanvas && (end.cell > this._gridOptions.frozenColumn!)) || (this._isRightCanvas && (end.cell <= this._gridOptions.frozenColumn!))) { + // ... frozen column(s): the range may not cross the left-freeze boundary + // (end.cell > frozenColumn ⇔ end.cell >= frozenLeftCols — same algebra as before) + if (this._bands.frozenLeftCols > 0 && (!this._isRightCanvas && (end.cell >= this._bands.frozenLeftCols)) || (this._isRightCanvas && (end.cell < this._bands.frozenLeftCols))) { return; } - // ... or frozen row(s) + // ... nor the right-freeze boundary + if (this._bands.frozenRightCols > 0) { + const endInRightFrozen = end.cell >= this._grid.getFrozenRightStartIndex(); + if (this._isRightFrozenCanvas !== endInRightFrozen) { + return; + } + } + + // ... or frozen row(s) — raw frozenRow deliberately preserved here so the + // degenerate frozenRow: 0 clamp behaves exactly as it always has; in + // simultaneous mode frozenRow is the TOP band count, so this clamp guards the + // top boundary unchanged if (this._gridOptions.frozenRow! >= 0 && (!this._isBottomCanvas && (end.row >= this._gridOptions.frozenRow!)) || (this._isBottomCanvas && (end.row < this._gridOptions.frozenRow!))) { return; } + // ... nor the bottom-frozen band boundary (simultaneous mode) + if (this._bands.frozenTopRows > 0 && this._bands.frozenBottomRows > 0) { + const endInBottomFrozen = end.row >= this._grid.getDataLength() - this._bands.frozenBottomRows; + if (this._isBottomFrozenCanvas !== endInBottomFrozen) { + return; + } + } + // scrolling the viewport to display the target `end` cell if it is not fully displayed if (this._options.autoScroll && this._draggingMouseOffset) { const endCellBox = this._grid.getCellNodeBox(end.row, end.cell); diff --git a/src/slick.grid.ts b/src/slick.grid.ts index d7645ad9..2f40b016 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -2845,6 +2845,22 @@ export class SlickGrid = Column, O e return this._options.frozenColumn ?? -1; } + /** + * Public band view of the freeze configuration (Phase 4 band API for plugins): + * counts per band, zero meaning the band does not exist. Returns a copy. + */ + getFrozenBandCounts(): { frozenLeftCols: number; frozenRightCols: number; frozenTopRows: number; frozenBottomRows: number; } { + return { ...this._viewportMgr.bandCounts() }; + } + + /** + * Index (into getColumns()) of the first right-frozen column, or the column count + * when no right freeze is active — so `idx >= result` is a safe membership test. + */ + getFrozenRightStartIndex(): number { + return this.getFrozenRightStartIdx(); + } + /** * Extends grid options with a given hash. If an there is an active edit, the grid will attempt to commit the changes and only continue if the attempt succeeds. * @param {Object} options - an object with configuration options. From 3e81de4d36d9ae5959bca7c03205bd3e91a3f365 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Tue, 14 Jul 2026 15:50:45 +0930 Subject: [PATCH 26/43] feat: hidden-container measurement wrap for runtime option changes (Phase 4, milestone 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal_setOptions now carries the same cacheCssForHiddenInit/ restoreCssFromHiddenInit wrap that initialize() and autosizeColumns have always used — a no-op for visible grids, and correct layout measurements when options change (including band materialization) while the container or an ancestor is display:none. Closes the hidden-init item deferred from Phase 3. The other deferred item — pane removal on un-freeze — is formally closed as WON'T-DO: removal would violate the element-identity invariant the adversarial audits established as load-bearing (plugins cache canvases at init; rowsCache row nodes live inside band canvases), would require slot re-indexing and group-scoped unbinding, and offers no functional gain over the historical hidden-pane behaviour. Recorded in KNOWN-QUIRKS.md as final design decision #9. Full suite verified green BEFORE this commit: 632 tests, 631 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- src/slick.grid.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 2f40b016..2d5ffcec 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -2938,6 +2938,14 @@ export class SlickGrid = Column, O e * @param {boolean} [suppressSetOverflow] - If `true`, prevents updating the viewport overflow setting. */ protected internal_setOptions(suppressRender?: boolean, suppressColumnSet?: boolean, suppressSetOverflow?: boolean): void { + // borrow the autosizeColumns cache/restore wrap so layout measurements stay + // correct when options change (incl. band materialization) while the container + // or an ancestor is hidden — a no-op for visible grids (M16; matches the wrap + // initialize() has always used) + if (!this._options.suppressCssChangesOnHiddenInit) { + this.cacheCssForHiddenInit(); + } + if (this._options.showColumnHeader !== undefined) { this.setColumnHeaderVisibility(this._options.showColumnHeader); } @@ -2975,6 +2983,10 @@ export class SlickGrid = Column, O e } else if (this._options.enableMouseWheelScrollHandler === false) { this.destroyAllInstances(this.slickMouseWheelInstances); // remove scroll handler when option is disable } + + if (!this._options.suppressCssChangesOnHiddenInit) { + this.restoreCssFromHiddenInit(); + } } /** From b2f380094ffc89db5fa044cbf690344c4cf217f7 Mon Sep 17 00:00:00 2001 From: Ben McIntyre Date: Wed, 15 Jul 2026 13:50:44 +0930 Subject: [PATCH 27/43] refactor: extract ViewportMgr to slick.core.ts with contracts in models (Phase 4, milestone 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the ViewportMgr class (~1,500 lines) out of slick.grid.ts into slick.core.ts — the one file every script-tag page already loads before the grid — using the established cross-file pattern: exported from core, registered on the SlickCore namespace object (alongside DragExtendHandle, the live precedent for a grid-support class in core; TreeColumns is the historical one), consumed by the grid via 'import { ViewportMgr as ViewportMgr_ }' + 'IIFE_ONLY ? Slick.ViewportMgr : ViewportMgr_'. The five geometry/state interfaces move to src/models/viewportMgr.interface.ts per house convention (type-only, excluded from iife builds), re-exported from models/index.ts; the Slick global declaration in global.d.ts gains the member. Zero consumer impact: no new script tag (a standalone slick.viewportmgr.js would have broken script-tag pages — the Phase 1 rationale, now resolved by core placement), DOM byte-identical, esm/cjs pick the export up via index.ts. ViewportMgr is now independently importable (e.g. for future unit tests). Verified: iife smoke test (Slick.ViewportMgr resolves and instantiates like DragExtendHandle), gates across script-tag and esm pages, and full suite green BEFORE this commit: 632 tests, 631 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- src/global.d.ts | 2 + src/models/index.ts | 1 + src/models/viewportMgr.interface.ts | 87 ++ src/slick.core.ts | 1432 +++++++++++++++++++++++++ src/slick.grid.ts | 1515 +-------------------------- 5 files changed, 1525 insertions(+), 1512 deletions(-) create mode 100644 src/models/viewportMgr.interface.ts diff --git a/src/global.d.ts b/src/global.d.ts index 96e19261..ced78616 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -20,6 +20,7 @@ import type { SelectionUtils, ValueFilterMode, WidthEvalMode, + ViewportMgr as SlickViewportMgr, } from './slick.core.js'; import type { SlickDataView } from './slick.dataview.js'; import type { SlickGrid } from './slick.grid.js'; @@ -114,6 +115,7 @@ declare global { Range: typeof SlickRange, CopyRange: typeof SlickCopyRange, DragExtendHandle: typeof SlickDragExtendHandle, + ViewportMgr: typeof SlickViewportMgr, Resizable: typeof Resizable, RowMoveManager: typeof SlickRowMoveManager, RowSelectionMode: typeof RowSelectionMode, diff --git a/src/models/index.ts b/src/models/index.ts index 3ea76cf9..65d4bd40 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -74,3 +74,4 @@ export type * from './slickGridModel.interface.js'; export type * from './slickPlugin.interface.js'; export * from './sortDirectionNumber.enum.js'; export type * from './usabilityOverrideFn.type.js'; +export type * from './viewportMgr.interface.js'; diff --git a/src/models/viewportMgr.interface.ts b/src/models/viewportMgr.interface.ts new file mode 100644 index 00000000..e80e80eb --- /dev/null +++ b/src/models/viewportMgr.interface.ts @@ -0,0 +1,87 @@ +// ViewportMgr geometry/state contracts (Phase 4 of the frozen rows/columns +// encapsulation refactor). Consumed by slick.core.ts (the class) and slick.grid.ts. + +/** Snapshot of the grid's freeze configuration, pushed into ViewportMgr by setFrozenOptions(). */ +export interface ViewportFreezeState { + frozenColumnIdx: number; + hasFrozenRows: boolean; + actualFrozenRow: number; + frozenBottom: boolean; + /** the frozenRow option value — number of rows in the frozen row band (0/-1 when none) */ + frozenRowCount?: number; + /** the frozenRightColumn option value — number of columns frozen at the right edge (0 when none) */ + frozenRightColCount?: number; + /** index of the first right-frozen column (columns.length when the band is off) */ + frozenRightStartIdx?: number; + /** the frozenBottomRow option value — rows frozen at the bottom ALONGSIDE top rows (0 when none) */ + frozenBottomRowCount?: number; + /** first row of the bottom-frozen band = dataLength − frozenBottomRow (MAX_SAFE_INTEGER when off) */ + bottomFrozenSplitRow?: number; +} + +/** + * Band-count view of the freeze configuration (Phase 4 groundwork for the 3×3 band + * model): a zero count means the band does not exist. Derived by updateFreezeState + * from the legacy freeze snapshot; frozenRightCols stays 0 until right-frozen + * columns land. + */ +export interface FreezeBandCounts { + frozenLeftCols: number; + frozenRightCols: number; + frozenTopRows: number; + frozenBottomRows: number; +} + +/** Geometry inputs for ViewportMgr.applyCanvasWidths — computed by the grid, distributed by the manager. */ +export interface CanvasWidthsGeometry { + widthChanged: boolean; + canvasWidth: number; + canvasWidthL: number; + canvasWidthR: number; + canvasWidthRF: number; + headersWidthL: number; + headersWidthR: number; + headersWidthRF: number; + viewportW: number; + viewportHasVScroll: boolean; + scrollbarWidth: number; + createFooterRow?: boolean; + createPreHeaderPanel?: boolean; + preHeaderPanelWidth?: number | string; +} + +/** Geometry inputs for ViewportMgr.applyPaneHeights — computed by the grid, distributed by the manager. */ +export interface PaneHeightsGeometry { + viewportH: number; + frozenRowsHeight: number; + /** height of the bottom-frozen row band (simultaneous top+bottom mode; 0 otherwise) */ + frozenBottomRowsHeight?: number; + scrollbarHeight: number; + topPanelH: number; + headerRowH: number; + footerRowH: number; + /** lazily computed to avoid an unconditional style recalc; only read on the autoHeight+frozen path */ + getContainerVBoxDelta: () => number; + autoHeight?: boolean; + showPreHeaderPanel?: boolean; + preHeaderPanelHeight?: number; + showTopHeaderPanel?: boolean; + topHeaderPanelHeight?: number; + showHeaderRow?: boolean; + headerRowHeight?: number; +} + +/** Options consumed by ViewportMgr when constructing the pane/viewport/canvas DOM. */ +export interface ViewportMgrBuildOptions { + createPreHeaderPanel?: boolean; + showPreHeaderPanel?: boolean; + createFooterRow?: boolean; + showFooterRow?: boolean; + showColumnHeader?: boolean; + showTopPanel?: boolean; + showHeaderRow?: boolean; + viewportClass?: string; + lazyPanes?: boolean; + frozenColumn?: number; + frozenRow?: number; +} diff --git a/src/slick.core.ts b/src/slick.core.ts index 3d18b480..1d8d1bd4 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -13,6 +13,11 @@ import type { Handler, InferDOMType, MergeTypes, + CanvasWidthsGeometry, + FreezeBandCounts, + PaneHeightsGeometry, + ViewportFreezeState, + ViewportMgrBuildOptions, } from './models/index.js'; import type { SlickGrid } from './slick.grid.js'; @@ -1326,6 +1331,1432 @@ export class SelectionUtils { export const SlickGlobalEditorLock = new SlickEditorLock(); +/** + * ViewportMgr — owns the construction of the grid's pane/viewport/canvas DOM. + * + * Phase 1 of the frozen rows/columns encapsulation refactor: this class builds the + * exact same 6-pane / 4-viewport / 4-canvas structure the grid has always built + * (characterized by cypress/e2e/dom-shape-characterization.cy.ts) and SlickGrid keeps + * aliases to every element, so all existing logic is unchanged. Later phases move pane + * selection, geometry distribution and scroll synchronization in here. + */ +export class ViewportMgr { + /** the grid container, captured by buildPanes */ + protected container!: HTMLElement; + + /** + * True when the grid opted into lazyPanes AND no rows/columns were frozen at build + * time — only the top-left pane set exists until freezing is enabled. + */ + protected lazy = false; + + /** + * Array positions of dynamically materialized viewports/canvases (the viewport and + * canvas arrays always extend in lockstep, so one slot serves both). Recorded at + * materialization time instead of hardcoding, so band order never matters. + */ + protected rfTopSlot = -1; + protected rfBottomSlot = -1; + protected bfSlotL = -1; + protected bfSlotR = -1; + protected bfSlotRF = -1; + + // panes + paneHeaderL!: HTMLDivElement; + paneHeaderR!: HTMLDivElement; + paneTopL!: HTMLDivElement; + paneTopR!: HTMLDivElement; + paneBottomL!: HTMLDivElement; + paneBottomR!: HTMLDivElement; + + // pre-header panels (only when createPreHeaderPanel) + preHeaderPanelScroller!: HTMLDivElement; + preHeaderPanel!: HTMLDivElement; + preHeaderPanelSpacer!: HTMLDivElement; + preHeaderPanelScrollerR!: HTMLDivElement; + preHeaderPanelR!: HTMLDivElement; + preHeaderPanelSpacerR!: HTMLDivElement; + + // header scrollers and header column containers + headerScrollerL!: HTMLDivElement; + headerScrollerR!: HTMLDivElement; + headerScroller: HTMLDivElement[] = []; + headerL!: HTMLDivElement; + headerR!: HTMLDivElement; + headers: HTMLDivElement[] = []; + + // header rows + headerRowScrollerL!: HTMLDivElement; + headerRowScrollerR!: HTMLDivElement; + headerRowScroller: HTMLDivElement[] = []; + headerRowSpacerL!: HTMLDivElement; + headerRowSpacerR!: HTMLDivElement; + headerRowL!: HTMLDivElement; + headerRowR!: HTMLDivElement; + headerRows: HTMLDivElement[] = []; + + // top panels + topPanelScrollerL!: HTMLDivElement; + topPanelScrollerR!: HTMLDivElement; + topPanelScrollers: HTMLDivElement[] = []; + topPanelL!: HTMLDivElement; + topPanelR!: HTMLDivElement; + topPanels: HTMLDivElement[] = []; + + // viewports and canvases + viewportTopL!: HTMLDivElement; + viewportTopR!: HTMLDivElement; + viewportBottomL!: HTMLDivElement; + viewportBottomR!: HTMLDivElement; + viewport: HTMLDivElement[] = []; + canvasTopL!: HTMLDivElement; + canvasTopR!: HTMLDivElement; + canvasBottomL!: HTMLDivElement; + canvasBottomR!: HTMLDivElement; + canvas: HTMLDivElement[] = []; + + // right-frozen band (Phase 4 — exists only while frozenRightColumn > 0 has been applied) + paneHeaderRF!: HTMLDivElement; + paneTopRF!: HTMLDivElement; + paneBottomRF!: HTMLDivElement; + headerScrollerRF!: HTMLDivElement; + headerRF!: HTMLDivElement; + headerRowScrollerRF!: HTMLDivElement; + headerRowSpacerRF!: HTMLDivElement; + headerRowRF!: HTMLDivElement; + topPanelScrollerRF!: HTMLDivElement; + topPanelRF!: HTMLDivElement; + viewportTopRF!: HTMLDivElement; + viewportBottomRF!: HTMLDivElement; + canvasTopRF!: HTMLDivElement; + canvasBottomRF!: HTMLDivElement; + footerRowScrollerRF!: HTMLDivElement; + footerRowSpacerRF!: HTMLDivElement; + footerRowRF!: HTMLDivElement; + + // bottom-frozen row band (Phase 4 — exists only in simultaneous top+bottom mode) + paneBottomFrozenL!: HTMLDivElement; + paneBottomFrozenR!: HTMLDivElement; + paneBottomFrozenRF!: HTMLDivElement; + viewportBottomFrozenL!: HTMLDivElement; + viewportBottomFrozenR!: HTMLDivElement; + viewportBottomFrozenRF!: HTMLDivElement; + canvasBottomFrozenL!: HTMLDivElement; + canvasBottomFrozenR!: HTMLDivElement; + canvasBottomFrozenRF!: HTMLDivElement; + + // footer rows (only when createFooterRow) + footerRowScrollerL!: HTMLDivElement; + footerRowScrollerR!: HTMLDivElement; + footerRowScroller: HTMLDivElement[] = []; + footerRowSpacerL!: HTMLDivElement; + footerRowSpacerR!: HTMLDivElement; + footerRowL!: HTMLDivElement; + footerRowR!: HTMLDivElement; + footerRow: HTMLDivElement[] = []; + + /** + * Builds the pane/viewport/canvas DOM inside the given container. + * The construction order and every class/style is identical to the historical + * inline construction in SlickGrid.initialize(). + */ + buildPanes(container: HTMLElement, o: ViewportMgrBuildOptions) { + this.container = container; + this.lazy = !!o.lazyPanes && !(((o.frozenColumn ?? -1) > -1) || ((o.frozenRow ?? -1) > -1)); + + // Containers used for scrolling frozen columns and rows. + // Under lazyPanes with nothing frozen, only the top-left pane set is built; + // the creation ORDER of the conditional elements must stay canonical so both + // modes produce the same sibling sequence for whatever exists. + this.paneHeaderL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, container); + if (!this.lazy) { + this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, container); + } + this.paneTopL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-left', tabIndex: 0 }, container); + if (!this.lazy) { + this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }, container); + this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }, container); + this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }, container); + } + + if (o.createPreHeaderPanel) { + this.preHeaderPanelScroller = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderL); + this.preHeaderPanelScroller.appendChild(document.createElement('div')); + this.preHeaderPanel = Utils.createDomElement('div', null, this.preHeaderPanelScroller); + this.preHeaderPanelSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScroller); + + if (!this.lazy) { + this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); + this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); + this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); + } + + if (!o.showPreHeaderPanel) { + Utils.hide(this.preHeaderPanelScroller); + this.hideIf(this.preHeaderPanelScrollerR); + } + } + + // Append the header scroller containers + this.headerScrollerL = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-left' }, this.paneHeaderL); + if (!this.lazy) { + this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); + } + + // Cache the header scroller containers + this.headerScroller.push(this.headerScrollerL); + if (!this.lazy) { + this.headerScroller.push(this.headerScrollerR); + } + + // Append the columnn containers to the headers + this.headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { left: '-1000px' } }, this.headerScrollerL); + if (!this.lazy) { + this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); + } + + // Cache the header columns + this.headers = this.lazy ? [this.headerL] : [this.headerL, this.headerR]; + + this.headerRowScrollerL = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopL); + if (!this.lazy) { + this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); + } + + this.headerRowScroller = this.lazy ? [this.headerRowScrollerL] : [this.headerRowScrollerL, this.headerRowScrollerR]; + + this.headerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerL); + if (!this.lazy) { + this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); + } + + this.headerRowL = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }, this.headerRowScrollerL); + if (!this.lazy) { + this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); + } + + this.headerRows = this.lazy ? [this.headerRowL] : [this.headerRowL, this.headerRowR]; + + // Append the top panel scroller + this.topPanelScrollerL = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopL); + if (!this.lazy) { + this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); + } + + this.topPanelScrollers = this.lazy ? [this.topPanelScrollerL] : [this.topPanelScrollerL, this.topPanelScrollerR]; + + // Append the top panel + this.topPanelL = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerL); + if (!this.lazy) { + this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); + } + + this.topPanels = this.lazy ? [this.topPanelL] : [this.topPanelL, this.topPanelR]; + + if (!o.showColumnHeader) { + this.headerScroller.forEach((el) => { + Utils.hide(el); + }); + } + + if (!o.showTopPanel) { + this.topPanelScrollers.forEach((scroller) => { + Utils.hide(scroller); + }); + } + + if (!o.showHeaderRow) { + this.headerRowScroller.forEach((scroller) => { + Utils.hide(scroller); + }); + } + + // Append the viewport containers + this.viewportTopL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-left', tabIndex: 0 }, this.paneTopL); + if (!this.lazy) { + this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); + this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); + this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); + } + + // Cache the viewports + this.viewport = this.lazy + ? [this.viewportTopL] + : [this.viewportTopL, this.viewportTopR, this.viewportBottomL, this.viewportBottomR]; + if (o.viewportClass) { + this.viewport.forEach((view) => { + view.classList.add(...Utils.classNameToList((o.viewportClass))); + }); + } + + // Append the canvas containers + this.canvasTopL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-left', tabIndex: 0 }, this.viewportTopL); + if (!this.lazy) { + this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); + this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); + this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); + } + + // Cache the canvases + this.canvas = this.lazy + ? [this.canvasTopL] + : [this.canvasTopL, this.canvasTopR, this.canvasBottomL, this.canvasBottomR]; + } + + /** + * Builds the footer-row containers (only called when the createFooterRow option is on). + * Identical construction to the historical inline code, including the R-before-L + * scroller creation order and spacer widths. + */ + buildFooterRows(o: ViewportMgrBuildOptions, canvasWithScrollbarWidth: number) { + if (!this.lazy) { + this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); + } + this.footerRowScrollerL = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopL); + + this.footerRowScroller = this.lazy ? [this.footerRowScrollerL] : [this.footerRowScrollerL, this.footerRowScrollerR]; + + this.footerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerL); + Utils.width(this.footerRowSpacerL, canvasWithScrollbarWidth); + if (!this.lazy) { + this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); + Utils.width(this.footerRowSpacerR, canvasWithScrollbarWidth); + } + + this.footerRowL = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-left' }, this.footerRowScrollerL); + if (!this.lazy) { + this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); + } + + this.footerRow = this.lazy ? [this.footerRowL] : [this.footerRowL, this.footerRowR]; + + if (!o.showFooterRow) { + this.footerRowScroller.forEach((scroller) => { + Utils.hide(scroller); + }); + } + } + + /** + * Builds the right/bottom panes, chrome, viewports and canvases that a lazyPanes + * grid skipped at init, inserting each pane at its canonical sibling position and + * pushing the new elements into the shared caches IN PLACE (the grid's array + * aliases keep working). Idempotent: returns false when the grid is not lazy + * (already fully built or built non-lazy). + */ + materializeSecondaryPanes(o: ViewportMgrBuildOptions): boolean { + if (!this.lazy) { + return false; + } + this.lazy = false; + + const container = this.container; + + // panes, at their canonical sibling positions + this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneHeaderR, this.paneTopL); + this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneTopR, this.paneTopL.nextSibling); + this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }); + container.insertBefore(this.paneBottomL, this.paneTopR.nextSibling); + this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneBottomR, this.paneBottomL.nextSibling); + + if (o.createPreHeaderPanel) { + this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); + this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); + this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); + + if (!o.showPreHeaderPanel) { + Utils.hide(this.preHeaderPanelScrollerR); + } + } + + // header scroller + header columns + this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); + this.headerScroller.push(this.headerScrollerR); + this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); + this.headers.push(this.headerR); + + // header row + this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); + this.headerRowScroller.push(this.headerRowScrollerR); + this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); + this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); + this.headerRows.push(this.headerRowR); + + // top panel + this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); + this.topPanelScrollers.push(this.topPanelScrollerR); + this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); + this.topPanels.push(this.topPanelR); + + if (!o.showColumnHeader) { + Utils.hide(this.headerScrollerR); + } + if (!o.showTopPanel) { + Utils.hide(this.topPanelScrollerR); + } + if (!o.showHeaderRow) { + Utils.hide(this.headerRowScrollerR); + } + + // viewports (pushed in canonical [TopL, TopR, BottomL, BottomR] order) + this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); + this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); + this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); + this.viewport.push(this.viewportTopR, this.viewportBottomL, this.viewportBottomR); + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + this.viewportTopR.classList.add(...viewportClassList); + this.viewportBottomL.classList.add(...viewportClassList); + this.viewportBottomR.classList.add(...viewportClassList); + } + + // canvases + this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); + this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); + this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); + this.canvas.push(this.canvasTopR, this.canvasBottomL, this.canvasBottomR); + + // footer row (right side; the left one was built at init when createFooterRow) + if (o.createFooterRow) { + this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); + this.footerRowScroller.push(this.footerRowScrollerR); + this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); + this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); + this.footerRow.push(this.footerRowR); + + if (!o.showFooterRow) { + Utils.hide(this.footerRowScrollerR); + } + } + + return true; + } + + /** + * Builds the right-frozen column band (Phase 4): three panes with NEW + * `*-right-frozen` css classes, appended AFTER the six classic panes so classic + * sibling positions are untouched, plus header/header-row/top-panel chrome, + * viewports and canvases. Shared element arrays are extended at the END so the + * classic indexes 0–3 (and [L, R] pairs) stay valid for every existing consumer. + * Idempotent: returns false when the band already exists. + * + * The historical "right" elements keep their class names and become the scrollable + * MIDDLE band while this band is active. + */ + materializeRightFrozenBand(o: ViewportMgrBuildOptions): boolean { + if (this.paneHeaderRF) { + return false; + } + + const container = this.container; + + // panes — appended after the classic six (still before the trailing focus sink, + // which the grid appends after all panes) + this.paneHeaderRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right-frozen', tabIndex: 0 }); + this.paneTopRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right-frozen', tabIndex: 0 }); + this.paneBottomRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right-frozen', tabIndex: 0 }); + // insert as a block after the last classic pane (paneBottomR when it exists, + // else the lazy grid's paneTopL) + const lastClassicPane = this.paneBottomR ?? this.paneTopL; + container.insertBefore(this.paneHeaderRF, lastClassicPane.nextSibling); + container.insertBefore(this.paneTopRF, this.paneHeaderRF.nextSibling); + container.insertBefore(this.paneBottomRF, this.paneTopRF.nextSibling); + + // header chrome + this.headerScrollerRF = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right-frozen' }, this.paneHeaderRF); + this.headerScroller.push(this.headerScrollerRF); + this.headerRF = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right-frozen', role: 'row', style: { left: '-1000px' } }, this.headerScrollerRF); + this.headers.push(this.headerRF); + + // header row + this.headerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopRF); + this.headerRowScroller.push(this.headerRowScrollerRF); + this.headerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerRF); + this.headerRowRF = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right-frozen' }, this.headerRowScrollerRF); + this.headerRows.push(this.headerRowRF); + + // top panel + this.topPanelScrollerRF = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopRF); + this.topPanelScrollers.push(this.topPanelScrollerRF); + this.topPanelRF = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerRF); + this.topPanels.push(this.topPanelRF); + + if (!o.showColumnHeader) { + Utils.hide(this.headerScrollerRF); + } + if (!o.showTopPanel) { + Utils.hide(this.topPanelScrollerRF); + } + if (!o.showHeaderRow) { + Utils.hide(this.headerRowScrollerRF); + } + + // viewports and canvases (array order extended at the END: classic 0–3 preserved) + this.viewportTopRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right-frozen', tabIndex: 0 }, this.paneTopRF); + this.viewportBottomRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomRF); + this.viewport.push(this.viewportTopRF, this.viewportBottomRF); + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + this.viewportTopRF.classList.add(...viewportClassList); + this.viewportBottomRF.classList.add(...viewportClassList); + } + + this.canvasTopRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right-frozen', tabIndex: 0 }, this.viewportTopRF); + this.canvasBottomRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomRF); + this.canvas.push(this.canvasTopRF, this.canvasBottomRF); + this.rfTopSlot = this.canvas.indexOf(this.canvasTopRF); + this.rfBottomSlot = this.canvas.indexOf(this.canvasBottomRF); + + // footer row + if (o.createFooterRow) { + this.footerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopRF); + this.footerRowScroller.push(this.footerRowScrollerRF); + this.footerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerRF); + this.footerRowRF = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right-frozen' }, this.footerRowScrollerRF); + this.footerRow.push(this.footerRowRF); + + if (!o.showFooterRow) { + Utils.hide(this.footerRowScrollerRF); + } + } + + // if the bottom-frozen band already exists, add the shared corner pane + this.ensureBottomFrozenRightVariant(o); + + return true; + } + + /** + * Builds the bottom-frozen row band (Phase 4, simultaneous top+bottom mode): one + * pane+viewport+canvas per active column band, appended after all existing panes + * with `*-bottom-frozen` css classes. Element arrays extend at the END and the + * slots are recorded (bfSlotL/R/RF). Idempotent: returns false when the band + * already exists. The right-frozen column variant is built only when that band's + * DOM exists at call time; materializeRightFrozenBand adds it later otherwise. + */ + materializeBottomFrozenBand(o: ViewportMgrBuildOptions): boolean { + if (this.paneBottomFrozenL) { + // band exists — but the RF column variant may have arrived after us + this.ensureBottomFrozenRightVariant(o); + return false; + } + + const container = this.container; + const lastPane = this.paneBottomRF ?? this.paneBottomR ?? this.paneTopL; + + this.paneBottomFrozenL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-left', tabIndex: 0 }); + container.insertBefore(this.paneBottomFrozenL, lastPane.nextSibling); + this.paneBottomFrozenR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right', tabIndex: 0 }); + container.insertBefore(this.paneBottomFrozenR, this.paneBottomFrozenL.nextSibling); + + this.viewportBottomFrozenL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-left', tabIndex: 0 }, this.paneBottomFrozenL); + this.viewportBottomFrozenR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right', tabIndex: 0 }, this.paneBottomFrozenR); + this.canvasBottomFrozenL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-left', tabIndex: 0 }, this.viewportBottomFrozenL); + this.canvasBottomFrozenR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right', tabIndex: 0 }, this.viewportBottomFrozenR); + + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + this.viewportBottomFrozenL.classList.add(...viewportClassList); + this.viewportBottomFrozenR.classList.add(...viewportClassList); + } + + this.viewport.push(this.viewportBottomFrozenL, this.viewportBottomFrozenR); + this.canvas.push(this.canvasBottomFrozenL, this.canvasBottomFrozenR); + this.bfSlotL = this.canvas.indexOf(this.canvasBottomFrozenL); + this.bfSlotR = this.canvas.indexOf(this.canvasBottomFrozenR); + + this.ensureBottomFrozenRightVariant(o); + return true; + } + + /** Adds the bottom-frozen × right-frozen corner pane when both bands exist. */ + protected ensureBottomFrozenRightVariant(o: ViewportMgrBuildOptions) { + if (!this.paneBottomFrozenL || !this.paneHeaderRF || this.paneBottomFrozenRF) { + return; + } + this.paneBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right-frozen', tabIndex: 0 }); + this.container.insertBefore(this.paneBottomFrozenRF, this.paneBottomFrozenR.nextSibling); + this.viewportBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomFrozenRF); + this.canvasBottomFrozenRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomFrozenRF); + if (o.viewportClass) { + this.viewportBottomFrozenRF.classList.add(...Utils.classNameToList(o.viewportClass)); + } + this.viewport.push(this.viewportBottomFrozenRF); + this.canvas.push(this.canvasBottomFrozenRF); + this.bfSlotRF = this.canvas.indexOf(this.canvasBottomFrozenRF); + } + + /** Whether the simultaneous top+bottom row mode is active AND its DOM exists. */ + hasBottomFrozenBand(): boolean { + return this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; + } + + /** + * Whether the row belongs to the bottom-frozen band. BOUNDED on both sides so the + * add-new row (index === dataLength) can never be captured by the band. The same + * test is used on both the render and lookup sides — the new band deliberately + * avoids the historical one-row threshold asymmetry of the legacy single band. + */ + isRowInBottomFrozenBand(row: number): boolean { + if (!this.hasBottomFrozenBand()) { + return false; + } + const split = this.freeze.bottomFrozenSplitRow ?? Number.MAX_SAFE_INTEGER; + return row >= split && row < split + this.bands.frozenBottomRows; + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // Freeze state and pane selection (Phase 2 of the encapsulation refactor) + ////////////////////////////////////////////////////////////////////////////////////////////// + + protected freeze: ViewportFreezeState = { frozenColumnIdx: -1, hasFrozenRows: false, actualFrozenRow: -1, frozenBottom: false }; + protected bands: FreezeBandCounts = { frozenLeftCols: 0, frozenRightCols: 0, frozenTopRows: 0, frozenBottomRows: 0 }; + + /** Receives the grid's freeze configuration; called by SlickGrid.setFrozenOptions(). */ + updateFreezeState(f: ViewportFreezeState) { + this.freeze = { ...f }; + + // derive the band-count view (Phase 4 groundwork); the legacy fields above stay + // authoritative for the existing 2×2 code paths + const rowCount = f.hasFrozenRows ? Math.max(0, f.frozenRowCount ?? 0) : 0; + const bottomRowCount = Math.max(0, f.frozenBottomRowCount ?? 0); + this.bands = { + frozenLeftCols: f.frozenColumnIdx + 1, + frozenRightCols: Math.max(0, f.frozenRightColCount ?? 0), + // with an explicit bottom count, frozenRow always means TOP rows and the legacy + // frozenBottom flag is ignored (it only positions the single-band case) + frozenTopRows: bottomRowCount > 0 ? rowCount : (f.frozenBottom ? 0 : rowCount), + frozenBottomRows: bottomRowCount > 0 ? bottomRowCount : (f.frozenBottom ? rowCount : 0), + }; + } + + /** Band-count view of the freeze configuration (zero count = band does not exist). */ + bandCounts(): FreezeBandCounts { + return this.bands; + } + + /** Returns a boolean indicating whether the grid is configured with frozen columns. */ + hasFrozenColumns() { + return this.bands.frozenLeftCols > 0; + } + + /** Returns a boolean indicating whether the grid is configured with frozen rows. */ + hasFrozenRows() { + return this.freeze.hasFrozenRows; + } + + /** + * The left canvas of the scrollable body band: bottom-left while rows are frozen at + * the top, top-left otherwise (historical selector used by updateRowCount and + * bindAncestorScrollEvents). + */ + bodyCanvasL(): HTMLDivElement { + return (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) ? this.canvasBottomL : this.canvasTopL; + } + + /** + * Index of the pane owning cell (colIdx, rowIdx) in the element arrays: + * classic slots [TopL, TopR, BottomL, BottomR], right-frozen slots [TopRF, BottomRF] + * appended at 4/5 (materializeRightFrozenPanes canonicalizes the classic set first, + * so these positions hold under lazyPanes too). + */ + paneCellIndex(colIdx: number, rowIdx: number): number { + if (this.isRowInBottomFrozenBand(rowIdx)) { + if (this.isColumnInRightFrozenBand(colIdx)) { + return this.bfSlotRF; + } + const isRightSideBF = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; + return isRightSideBF ? this.bfSlotR : this.bfSlotL; + } + const isBottomSide = this.freeze.hasFrozenRows && rowIdx >= this.freeze.actualFrozenRow + (this.freeze.frozenBottom ? 0 : 1); + if (this.isColumnInRightFrozenBand(colIdx)) { + return isBottomSide ? this.rfBottomSlot : this.rfTopSlot; + } + const isRightSide = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; + return (isBottomSide ? 2 : 0) + (isRightSide ? 1 : 0); + } + + /** + * Get frozen (pinned) row offset + * + * Returns the vertical pixel offset to apply for frozen rows. + * Depending on whether frozen rows are pinned at the bottom or top and based on grid height, + * it returns either a fixed frozen rows height or a calculated offset. + * + * @param {Number} row - grid row number + */ + frozenRowOffset(row: number, g: { h: number; viewportTopH: number; frozenRowsHeight: number; rowHeight: number; }): number { + // bottom-frozen band (simultaneous mode): rebase to band-local coordinates + if (this.isRowInBottomFrozenBand(row)) { + return this.freeze.bottomFrozenSplitRow! * g.rowHeight; + } + + // let offset = ( hasFrozenRows ) ? ( this._options.frozenBottom ) ? ( row >= actualFrozenRow ) ? ( h < viewportTopH ) ? ( actualFrozenRow * this._options.rowHeight ) : h : 0 : ( row >= actualFrozenRow ) ? frozenRowsHeight : 0 : 0; // WTF? + let offset = 0; + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + if (row >= this.freeze.actualFrozenRow) { + if (g.h < g.viewportTopH) { + offset = (this.freeze.actualFrozenRow * g.rowHeight); + } else { + offset = g.h; + } + } else { + offset = 0; + } + } + else { + if (row >= this.freeze.actualFrozenRow) { + offset = g.frozenRowsHeight; + } else { + offset = 0; + } + } + } else { + offset = 0; + } + + return offset; + } + + /** + * Whether the row lives in a frozen band and must therefore be kept out of row + * virtualization cleanup (historical cleanupRows predicate). + */ + isRowInFrozenBand(row: number): boolean { + if (this.isRowInBottomFrozenBand(row)) { + return true; + } + return this.freeze.hasFrozenRows + && ((this.freeze.frozenBottom && row >= this.freeze.actualFrozenRow) // Frozen bottom rows + || (!this.freeze.frozenBottom && row <= this.freeze.actualFrozenRow) // Frozen top rows + ); + } + + /** + * Whether cell-level cleanup must skip the row entirely (historical cleanUpCells + * predicate). NOTE: transcribed verbatim — the second disjunct is NOT guarded by + * !frozenBottom, so for frozenBottom grids every row is exempt; that quirk is + * long-standing upstream behaviour and is deliberately preserved. + */ + isRowCellCleanupExempt(row: number): boolean { + if (this.isRowInBottomFrozenBand(row)) { + return true; + } + return this.freeze.hasFrozenRows + && ((this.freeze.frozenBottom && row > this.freeze.actualFrozenRow) // Frozen bottom rows + || (row <= this.freeze.actualFrozenRow) // Frozen top rows + ); + } + + /** Whether the column index falls inside the left frozen band. */ + isColumnInFrozenBand(colIdx: number): boolean { + return colIdx <= this.freeze.frozenColumnIdx; + } + + /** True when frozen columns are on AND the column index falls right of the freeze. */ + isColumnRightOfFreeze(colIdx: number): boolean { + return this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; + } + + /** Column index local to its side container (right-side children are indexed after the freeze). */ + sideLocalColumnIdx(colIdx: number): number { + return this.isColumnRightOfFreeze(colIdx) ? colIdx - this.freeze.frozenColumnIdx - 1 : colIdx; + } + + /** Pick the left or right element of an [L, R] pair for the given column. */ + sideForColumn(colIdx: number, left: T, right: T): T { + return this.isColumnRightOfFreeze(colIdx) ? right : left; + } + + /** Whether the right-frozen band is active AND its DOM has been materialized. */ + hasRightFrozenBand(): boolean { + return this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; + } + + /** Whether the column index falls inside the right-frozen band. */ + isColumnInRightFrozenBand(colIdx: number): boolean { + return this.bands.frozenRightCols > 0 && colIdx >= (this.freeze.frozenRightStartIdx ?? Number.MAX_SAFE_INTEGER); + } + + /** Three-way band pick: left band, scrollable middle, or right-frozen element. */ + bandElementForColumn(colIdx: number, left: T, right: T, rightFrozen: T): T { + if (this.isColumnInRightFrozenBand(colIdx)) { + return rightFrozen; + } + return this.sideForColumn(colIdx, left, right); + } + + /** Column index local to its band container (right-frozen children index from the band start). */ + bandLocalColumnIdx(colIdx: number): number { + if (this.isColumnInRightFrozenBand(colIdx)) { + return colIdx - this.freeze.frozenRightStartIdx!; + } + return this.sideLocalColumnIdx(colIdx); + } + + /** + * Index of the column's node inside rowsCache[].rowNode: 0 for the left fragment + * (which is the scrollable fragment when no columns are left-frozen), 1 for the + * middle fragment under a left freeze, and last for the right-frozen fragment. + */ + rowNodeIdxForColumn(colIdx: number): number { + if (this.isColumnInRightFrozenBand(colIdx)) { + return this.hasFrozenColumns() ? 2 : 1; + } + return this.isColumnRightOfFreeze(colIdx) ? 1 : 0; + } + + /** Utils.show that tolerates panes not built under lazyPanes. */ + protected showIf(el?: HTMLElement) { + if (el) { Utils.show(el); } + } + + /** Utils.hide that tolerates panes not built under lazyPanes. */ + protected hideIf(el?: HTMLElement) { + if (el) { Utils.hide(el); } + } + + /** add/remove frozen class to left headers/footer when defined */ + applyPaneFrozenClasses(): void { + const classAction = this.hasFrozenColumns() ? 'add' : 'remove'; + for (const elm of [this.paneHeaderL, this.paneTopL, this.paneBottomL]) { + elm?.classList[classAction]('frozen'); + } + } + + /** Shows/hides the right and bottom panes according to the freeze configuration. */ + applyPaneVisibility() { + if (this.hasFrozenColumns()) { + this.showIf(this.paneHeaderR); + this.showIf(this.paneTopR); + + if (this.freeze.hasFrozenRows) { + this.showIf(this.paneBottomL); + this.showIf(this.paneBottomR); + } else { + this.hideIf(this.paneBottomR); + this.hideIf(this.paneBottomL); + } + } else { + this.hideIf(this.paneHeaderR); + this.hideIf(this.paneTopR); + this.hideIf(this.paneBottomR); + + if (this.freeze.hasFrozenRows) { + this.showIf(this.paneBottomL); + } else { + this.hideIf(this.paneBottomR); + this.hideIf(this.paneBottomL); + } + } + + // right-frozen band (exists only after materialization; kept hidden — like the + // classic panes — when the right freeze is turned off again) + if (this.bands.frozenRightCols > 0) { + this.showIf(this.paneHeaderRF); + this.showIf(this.paneTopRF); + if (this.freeze.hasFrozenRows) { + this.showIf(this.paneBottomRF); + } else { + this.hideIf(this.paneBottomRF); + } + } else { + this.hideIf(this.paneHeaderRF); + this.hideIf(this.paneTopRF); + this.hideIf(this.paneBottomRF); + } + + // bottom-frozen row band (simultaneous top+bottom mode only); column-band + // visibility mirrors the classic bottom panes + if (this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0) { + this.showIf(this.paneBottomFrozenL); + if (this.hasFrozenColumns()) { + this.showIf(this.paneBottomFrozenR); + } else { + this.hideIf(this.paneBottomFrozenR); + } + if (this.bands.frozenRightCols > 0) { + this.showIf(this.paneBottomFrozenRF); + } else { + this.hideIf(this.paneBottomFrozenRF); + } + } else { + this.hideIf(this.paneBottomFrozenL); + this.hideIf(this.paneBottomFrozenR); + this.hideIf(this.paneBottomFrozenRF); + } + } + + /** + * Sets the CSS overflowX and overflowY styles for all four viewport elements + * (top–left, top–right, bottom–left, bottom–right) based on the freeze configuration + * and options such as alwaysAllowHorizontalScroll and alwaysShowVerticalScroll. + * If a viewportClass is specified in options, the class is added to each viewport. + */ + applyOverflow(o: { alwaysAllowHorizontalScroll?: boolean; alwaysShowVerticalScroll?: boolean; viewportClass?: string; }) { + const hasFrozenRows = this.freeze.hasFrozenRows; + this.viewportTopL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); + this.viewportTopL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); + + if (this.viewportTopR) { + this.viewportTopR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); + this.viewportTopR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'scroll' : 'auto') : (hasFrozenRows ? 'scroll' : 'auto')); + } + + if (this.viewportBottomL) { + this.viewportBottomL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); + this.viewportBottomL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); + } + + if (this.viewportBottomR) { + this.viewportBottomR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); + this.viewportBottomR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'auto' : 'auto') : (hasFrozenRows ? 'auto' : 'auto')); + } + + // bottom-frozen viewports never own a scrollbar: Y is fixed, X follows the + // scroll owner programmatically + if (this.viewportBottomFrozenL) { + this.viewportBottomFrozenL.style.overflowX = 'hidden'; + this.viewportBottomFrozenL.style.overflowY = 'hidden'; + } + if (this.viewportBottomFrozenR) { + this.viewportBottomFrozenR.style.overflowX = 'hidden'; + this.viewportBottomFrozenR.style.overflowY = 'hidden'; + } + if (this.viewportBottomFrozenRF) { + this.viewportBottomFrozenRF.style.overflowX = 'hidden'; + this.viewportBottomFrozenRF.style.overflowY = 'hidden'; + } + + // right-frozen viewports never own a scrollbar: X is fixed, Y follows the + // scroll owner programmatically (same rationale as the frozen-left viewport) + if (this.viewportTopRF) { + this.viewportTopRF.style.overflowX = 'hidden'; + this.viewportTopRF.style.overflowY = 'hidden'; + } + if (this.viewportBottomRF) { + this.viewportBottomRF.style.overflowX = 'hidden'; + this.viewportBottomRF.style.overflowY = 'hidden'; + } + + if (o.viewportClass) { + const viewportClassList = Utils.classNameToList(o.viewportClass); + // this.viewport only ever contains the elements that were actually built + this.viewport.forEach((view) => { + view.classList.add(...viewportClassList); + }); + } + } + + /** + * Picks which viewport owns the X and Y scrollbars and which header/header-row/footer-row + * scrollers follow horizontal scrolling, according to the freeze configuration. + * The horizontal scrollbar must sit at the physical bottom of the grid, which is why + * frozenBottom splits X and Y ownership. + */ + /** + * Distributes computed canvas/header widths onto the pane, viewport, canvas, header, + * header-row and footer-row elements. Transcribed from the historical middle section + * of SlickGrid.updateCanvasWidth(); the width computations stay in the grid. + */ + applyCanvasWidths(g: CanvasWidthsGeometry) { + // width reserved by the right-frozen band (0 while the band is off or not built); + // the scrollable middle band shrinks by this amount + const rfActive = this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; + const rfW = rfActive ? g.canvasWidthRF : 0; + + if (g.widthChanged || this.hasFrozenColumns() || this.freeze.hasFrozenRows || rfActive) { + Utils.width(this.canvasTopL, g.canvasWidthL); + + Utils.width(this.headerL, g.headersWidthL); + if (this.headerR) { + Utils.width(this.headerR, g.headersWidthR); + } + + if (this.hasFrozenColumns()) { + Utils.width(this.canvasTopR, g.canvasWidthR); + + Utils.width(this.paneHeaderL, g.canvasWidthL); + Utils.setStyleSize(this.paneHeaderR, 'left', g.canvasWidthL); + Utils.setStyleSize(this.paneHeaderR, 'width', g.viewportW - g.canvasWidthL - rfW); + + Utils.width(this.paneTopL, g.canvasWidthL); + Utils.setStyleSize(this.paneTopR, 'left', g.canvasWidthL); + Utils.width(this.paneTopR, g.viewportW - g.canvasWidthL - rfW); + + Utils.width(this.headerRowScrollerL, g.canvasWidthL); + Utils.width(this.headerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); + + Utils.width(this.headerRowL, g.canvasWidthL); + Utils.width(this.headerRowR, g.canvasWidthR); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerL, g.canvasWidthL); + Utils.width(this.footerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); + + Utils.width(this.footerRowL, g.canvasWidthL); + Utils.width(this.footerRowR, g.canvasWidthR); + } + if (g.createPreHeaderPanel) { + Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); + } + Utils.width(this.viewportTopL, g.canvasWidthL); + Utils.width(this.viewportTopR, g.viewportW - g.canvasWidthL - rfW); + + if (this.freeze.hasFrozenRows) { + Utils.width(this.paneBottomL, g.canvasWidthL); + Utils.setStyleSize(this.paneBottomR, 'left', g.canvasWidthL); + + Utils.width(this.viewportBottomL, g.canvasWidthL); + Utils.width(this.viewportBottomR, g.viewportW - g.canvasWidthL - rfW); + + Utils.width(this.canvasBottomL, g.canvasWidthL); + Utils.width(this.canvasBottomR, g.canvasWidthR); + } + } else if (rfActive) { + // no left freeze, but a right-frozen band: the left pane IS the scrollable + // middle band — pixel widths instead of the historical '100%' + const middleW = g.viewportW - rfW; + Utils.width(this.paneHeaderL, middleW); + Utils.width(this.paneTopL, middleW); + Utils.width(this.headerRowScrollerL, middleW); + Utils.width(this.headerRowL, g.canvasWidth); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerL, middleW); + Utils.width(this.footerRowL, g.canvasWidth); + } + + if (g.createPreHeaderPanel) { + Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); + } + Utils.width(this.viewportTopL, middleW); + + if (this.freeze.hasFrozenRows) { + Utils.width(this.viewportBottomL, middleW); + Utils.width(this.canvasBottomL, g.canvasWidthL); + } + } else { + Utils.width(this.paneHeaderL, '100%'); + Utils.width(this.paneTopL, '100%'); + Utils.width(this.headerRowScrollerL, '100%'); + Utils.width(this.headerRowL, g.canvasWidth); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerL, '100%'); + Utils.width(this.footerRowL, g.canvasWidth); + } + + if (g.createPreHeaderPanel) { + Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); + } + Utils.width(this.viewportTopL, '100%'); + + if (this.freeze.hasFrozenRows) { + Utils.width(this.viewportBottomL, '100%'); + Utils.width(this.canvasBottomL, g.canvasWidthL); + } + } + + // bottom-frozen row band (simultaneous mode): column widths mirror the classic + // bottom panes + if (this.hasBottomFrozenBand()) { + if (this.hasFrozenColumns()) { + Utils.width(this.paneBottomFrozenL, g.canvasWidthL); + Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); + Utils.setStyleSize(this.paneBottomFrozenR, 'left', g.canvasWidthL); + Utils.width(this.paneBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); + Utils.width(this.viewportBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); + Utils.width(this.canvasBottomFrozenR, g.canvasWidthR); + Utils.width(this.viewportBottomFrozenL, g.canvasWidthL); + } else if (rfActive) { + Utils.width(this.paneBottomFrozenL, g.viewportW - rfW); + Utils.width(this.viewportBottomFrozenL, g.viewportW - rfW); + Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); + } else { + Utils.width(this.paneBottomFrozenL, '100%'); + Utils.width(this.viewportBottomFrozenL, '100%'); + Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); + } + + if (this.paneBottomFrozenRF && rfActive) { + Utils.setStyleSize(this.paneBottomFrozenRF, 'left', g.viewportW - rfW); + Utils.width(this.paneBottomFrozenRF, rfW); + Utils.width(this.viewportBottomFrozenRF, rfW); + Utils.width(this.canvasBottomFrozenRF, g.canvasWidthRF); + } + } + + // right-frozen band: fixed-width panes pinned to the right edge + if (rfActive) { + const rfLeft = g.viewportW - rfW; + Utils.setStyleSize(this.paneHeaderRF, 'left', rfLeft); + Utils.width(this.paneHeaderRF, rfW); + Utils.width(this.headerRF, g.headersWidthRF); + + Utils.setStyleSize(this.paneTopRF, 'left', rfLeft); + Utils.width(this.paneTopRF, rfW); + Utils.width(this.headerRowScrollerRF, rfW); + Utils.width(this.headerRowRF, g.canvasWidthRF); + Utils.width(this.viewportTopRF, rfW); + Utils.width(this.canvasTopRF, g.canvasWidthRF); + + if (g.createFooterRow) { + Utils.width(this.footerRowScrollerRF, rfW); + Utils.width(this.footerRowRF, g.canvasWidthRF); + } + + if (this.freeze.hasFrozenRows) { + Utils.setStyleSize(this.paneBottomRF, 'left', rfLeft); + Utils.width(this.paneBottomRF, rfW); + Utils.width(this.viewportBottomRF, rfW); + Utils.width(this.canvasBottomRF, g.canvasWidthRF); + } + } + } + + Utils.width(this.headerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + if (this.headerRowSpacerR) { + Utils.width(this.headerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + } + + if (g.createFooterRow) { + Utils.width(this.footerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + if (this.footerRowSpacerR) { + Utils.width(this.footerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + } + } + } + + /** + * Computes the pane/viewport heights from the freeze configuration and distributes them + * onto the pane, viewport and canvas elements. Transcribed from the historical middle + * section of SlickGrid.resizeCanvas(); returns the computed heights for the grid to keep. + */ + applyPaneHeights(g: PaneHeightsGeometry): { paneTopH: number; paneBottomH: number; viewportTopH: number; viewportBottomH: number; } { + let paneTopH = 0; + let paneBottomH = 0; + let viewportTopH = 0; + const viewportBottomH = 0; + + // Account for Frozen Rows + const simultaneousBands = this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + paneTopH = g.viewportH - g.frozenRowsHeight - g.scrollbarHeight; + paneBottomH = g.frozenRowsHeight + g.scrollbarHeight; + } else { + paneTopH = g.frozenRowsHeight; + paneBottomH = g.viewportH - g.frozenRowsHeight; + if (simultaneousBands) { + // the scrollable body shrinks by the bottom-frozen band height + paneBottomH -= g.frozenBottomRowsHeight ?? 0; + } + } + } else { + paneTopH = g.viewportH; + } + + // The top pane includes the top panel and the header row + paneTopH += g.topPanelH + g.headerRowH + g.footerRowH; + + if (this.hasFrozenColumns() && g.autoHeight) { + paneTopH += g.scrollbarHeight; + } + + // The top viewport does not contain the top panel or header row + viewportTopH = paneTopH - g.topPanelH - g.headerRowH - g.footerRowH; + + if (g.autoHeight) { + if (this.hasFrozenColumns()) { + let fullHeight = paneTopH + this.headerScrollerL.offsetHeight; + fullHeight += g.getContainerVBoxDelta(); + if (g.showPreHeaderPanel) { + fullHeight += g.preHeaderPanelHeight!; + } + Utils.height(this.container, fullHeight); + } + + this.paneTopL.style.position = 'relative'; + } + + let topHeightOffset = Utils.height(this.paneHeaderL); + if (topHeightOffset) { + topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); + } else { + topHeightOffset = (g.showHeaderRow ? g.headerRowHeight! : 0) + (g.showPreHeaderPanel ? g.preHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this.paneTopL, 'top', topHeightOffset || topHeightOffset); + Utils.height(this.paneTopL, paneTopH); + + const paneBottomTop = this.paneTopL.offsetTop + paneTopH; + + if (!g.autoHeight) { + Utils.height(this.viewportTopL, viewportTopH); + } + + if (this.hasFrozenColumns()) { + let topHeightOffset = Utils.height(this.paneHeaderL); + if (topHeightOffset) { + topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this.paneTopR, 'top', topHeightOffset as number); + Utils.height(this.paneTopR, paneTopH); + Utils.height(this.viewportTopR, viewportTopH); + + if (this.freeze.hasFrozenRows) { + Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); + Utils.height(this.paneBottomL, paneBottomH); + Utils.setStyleSize(this.paneBottomR, 'top', paneBottomTop); + Utils.height(this.paneBottomR, paneBottomH); + Utils.height(this.viewportBottomR, paneBottomH); + } + } else { + if (this.freeze.hasFrozenRows) { + Utils.width(this.paneBottomL, '100%'); + Utils.height(this.paneBottomL, paneBottomH); + Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); + } + } + + if (this.freeze.hasFrozenRows) { + Utils.height(this.viewportBottomL, paneBottomH); + + if (this.freeze.frozenBottom) { + Utils.height(this.canvasBottomL, g.frozenRowsHeight); + + if (this.hasFrozenColumns()) { + Utils.height(this.canvasBottomR, g.frozenRowsHeight); + } + } else { + Utils.height(this.canvasTopL, g.frozenRowsHeight); + + if (this.hasFrozenColumns()) { + Utils.height(this.canvasTopR, g.frozenRowsHeight); + } + } + } else { + if (this.viewportTopR) { + Utils.height(this.viewportTopR, viewportTopH); + } + } + + // bottom-frozen row band (simultaneous mode): pinned below the shrunk body pane + if (simultaneousBands) { + const bfH = g.frozenBottomRowsHeight ?? 0; + const bfTop = this.paneTopL.offsetTop + paneTopH + paneBottomH; + + Utils.setStyleSize(this.paneBottomFrozenL, 'top', bfTop); + Utils.height(this.paneBottomFrozenL, bfH); + Utils.height(this.viewportBottomFrozenL, bfH); + Utils.height(this.canvasBottomFrozenL, bfH); + + if (this.hasFrozenColumns()) { + Utils.setStyleSize(this.paneBottomFrozenR, 'top', bfTop); + Utils.height(this.paneBottomFrozenR, bfH); + Utils.height(this.viewportBottomFrozenR, bfH); + Utils.height(this.canvasBottomFrozenR, bfH); + } + + if (this.paneBottomFrozenRF) { + Utils.setStyleSize(this.paneBottomFrozenRF, 'top', bfTop); + Utils.height(this.paneBottomFrozenRF, bfH); + Utils.height(this.viewportBottomFrozenRF, bfH); + Utils.height(this.canvasBottomFrozenRF, bfH); + } + } + + // right-frozen band: mirror the classic right-pane vertical geometry + if (this.bands.frozenRightCols > 0 && this.paneHeaderRF) { + let topHeightOffsetRF = Utils.height(this.paneHeaderL); + if (topHeightOffsetRF) { + topHeightOffsetRF += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); + } + Utils.setStyleSize(this.paneTopRF, 'top', topHeightOffsetRF as number); + Utils.height(this.paneTopRF, paneTopH); + Utils.height(this.viewportTopRF, viewportTopH); + + if (this.freeze.hasFrozenRows) { + const paneBottomTopRF = this.paneTopL.offsetTop + paneTopH; + Utils.setStyleSize(this.paneBottomRF, 'top', paneBottomTopRF); + Utils.height(this.paneBottomRF, paneBottomH); + Utils.height(this.viewportBottomRF, paneBottomH); + + if (this.freeze.frozenBottom) { + Utils.height(this.canvasBottomRF, g.frozenRowsHeight); + } else { + Utils.height(this.canvasTopRF, g.frozenRowsHeight); + } + } + } + + return { paneTopH, paneBottomH, viewportTopH, viewportBottomH }; + } + + /** the current scroll-owner/follower set, refreshed by selectScrollContainers() */ + protected scrollContainers!: { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; }; + + /** + * Attaches one rendered row (left fragment + right fragment when columns are frozen) + * to the canvases owned by the row's band, returning the rowNode array for the grid's + * rowsCache (or null if the expected fragments are missing). + * + * NOTE: the band threshold here is `rowIdx >= actualFrozenRow` — deliberately WITHOUT + * the `+ (frozenBottom ? 0 : 1)` adjustment used by paneCellIndex(); the historical + * render-side and cell-lookup-side splits differ by one row in the non-frozenBottom + * case, and that asymmetry is preserved verbatim. + */ + attachRow(rowIdx: number, left: HTMLElement | null, right: HTMLElement | null, rightFrozen?: HTMLElement | null): HTMLElement[] | null { + let attached: HTMLElement[] | null = null; + const isBFBand = this.isRowInBottomFrozenBand(rowIdx); + const isBottomBand = !isBFBand && (this.freeze.hasFrozenRows) && (rowIdx >= this.freeze.actualFrozenRow); + + if (isBFBand) { + if (this.hasFrozenColumns()) { + if (left && right) { + this.canvasBottomFrozenL.appendChild(left); + this.canvasBottomFrozenR.appendChild(right); + attached = [left, right]; + } + } else if (left) { + this.canvasBottomFrozenL.appendChild(left); + attached = [left]; + } + } else if (isBottomBand) { + if (this.hasFrozenColumns()) { + if (left && right) { + this.canvasBottomL.appendChild(left); + this.canvasBottomR.appendChild(right); + attached = [left, right]; + } + } else if (left) { + this.canvasBottomL.appendChild(left); + attached = [left]; + } + } else if (this.hasFrozenColumns()) { + if (left && right) { + this.canvasTopL.appendChild(left); + this.canvasTopR.appendChild(right); + attached = [left, right]; + } + } else if (left) { + this.canvasTopL.appendChild(left); + attached = [left]; + } + + // right-frozen fragment always sits LAST in the rowNode array + const rfTargetCanvas = isBFBand ? this.canvasBottomFrozenRF : (isBottomBand ? this.canvasBottomRF : this.canvasTopRF); + if (attached && this.bands.frozenRightCols > 0 && rfTargetCanvas && rightFrozen) { + rfTargetCanvas.appendChild(rightFrozen); + attached.push(rightFrozen); + } + + return attached; + } + + /** Applies an X scroll position to the scroll-owner viewport and every horizontal follower. */ + syncHorizontalScroll(x: number, o: { createFooterRow?: boolean; createPreHeaderPanel?: boolean; }) { + this.scrollContainers.x.scrollLeft = x; + this.scrollContainers.header.scrollLeft = x; + this.topPanelScrollers[0].scrollLeft = x; + if (o.createFooterRow) { + this.scrollContainers.footerRow.scrollLeft = x; + } + if (o.createPreHeaderPanel) { + if (this.hasFrozenColumns()) { + this.preHeaderPanelScrollerR.scrollLeft = x; + } else { + this.preHeaderPanelScroller.scrollLeft = x; + } + } + + if (this.hasFrozenColumns()) { + if (this.freeze.hasFrozenRows) { + this.viewportTopR.scrollLeft = x; + } + this.headerRowScrollerR.scrollLeft = x; // right header row scrolling with frozen grid + } else { + if (this.freeze.hasFrozenRows) { + this.viewportTopL.scrollLeft = x; + } + this.headerRowScrollerL.scrollLeft = x; // left header row scrolling with regular grid + } + + // the bottom-frozen band's scrollable-column viewport follows X like the + // frozen-top viewports do + if (this.hasBottomFrozenBand()) { + (this.hasFrozenColumns() ? this.viewportBottomFrozenR : this.viewportBottomFrozenL).scrollLeft = x; + } + } + + /** Mirrors the Y scroll position onto the frozen-band viewports that follow the scroll owner. */ + syncVerticalFollowers(scrollTop: number) { + if (this.hasFrozenColumns()) { + if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { + this.viewportBottomL.scrollTop = scrollTop; + } else { + this.viewportTopL.scrollTop = scrollTop; + } + } + + // the right-frozen band's scrollable-body viewport follows Y the same way + if (this.bands.frozenRightCols > 0 && this.viewportTopRF) { + if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { + this.viewportBottomRF.scrollTop = scrollTop; + } else { + this.viewportTopRF.scrollTop = scrollTop; + } + } + } + + selectScrollContainers(): { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; } { + let x: HTMLDivElement; + let y: HTMLDivElement; + let header: HTMLDivElement; + let headerRow: HTMLDivElement; + let footerRow: HTMLDivElement; + + if (this.hasFrozenColumns()) { + header = this.headerScrollerR; + headerRow = this.headerRowScrollerR; + footerRow = this.footerRowScrollerR; + + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + x = this.viewportBottomR; + y = this.viewportTopR; + } else { + x = y = this.viewportBottomR; + } + } else { + x = y = this.viewportTopR; + } + } else { + header = this.headerScrollerL; + headerRow = this.headerRowScrollerL; + footerRow = this.footerRowScrollerL; + + if (this.freeze.hasFrozenRows) { + if (this.freeze.frozenBottom) { + x = this.viewportBottomL; + y = this.viewportTopL; + } else { + x = y = this.viewportBottomL; + } + } else { + x = y = this.viewportTopL; + } + } + + this.scrollContainers = { x, y, header, headerRow, footerRow }; + return this.scrollContainers; + } +} + // export Slick namespace on both global & window objects const SlickCore = { Event: SlickEvent, @@ -1334,6 +2765,7 @@ const SlickCore = { Range: SlickRange, CopyRange: SlickCopyRange, DragExtendHandle: SlickDragExtendHandle, + ViewportMgr, NonDataRow: SlickNonDataItem, Group: SlickGroup, GroupTotals: SlickGroupTotals, diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 2d5ffcec..4b1f8c44 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -90,6 +90,7 @@ import { SlickEventData as SlickEventData_, SlickRange as SlickRange_, Utils as Utils_, + ViewportMgr as ViewportMgr_, SelectionUtils as SelectionUtils_, ValueFilterMode as ValueFilterMode_, WidthEvalMode as WidthEvalMode_, @@ -117,6 +118,7 @@ const Draggable = IIFE_ONLY ? Slick.Draggable : Draggable_; const MouseWheel = IIFE_ONLY ? Slick.MouseWheel : MouseWheel_; const Resizable = IIFE_ONLY ? Slick.Resizable : Resizable_; const DragExtendHandle = IIFE_ONLY ? Slick.DragExtendHandle : DragExtendHandle_; +const ViewportMgr = IIFE_ONLY ? Slick.ViewportMgr : ViewportMgr_; /** * @license @@ -146,1517 +148,6 @@ interface RowCaching { cellRenderQueue: any[]; } -/** Snapshot of the grid's freeze configuration, pushed into ViewportMgr by setFrozenOptions(). */ -interface ViewportFreezeState { - frozenColumnIdx: number; - hasFrozenRows: boolean; - actualFrozenRow: number; - frozenBottom: boolean; - /** the frozenRow option value — number of rows in the frozen row band (0/-1 when none) */ - frozenRowCount?: number; - /** the frozenRightColumn option value — number of columns frozen at the right edge (0 when none) */ - frozenRightColCount?: number; - /** index of the first right-frozen column (columns.length when the band is off) */ - frozenRightStartIdx?: number; - /** the frozenBottomRow option value — rows frozen at the bottom ALONGSIDE top rows (0 when none) */ - frozenBottomRowCount?: number; - /** first row of the bottom-frozen band = dataLength − frozenBottomRow (MAX_SAFE_INTEGER when off) */ - bottomFrozenSplitRow?: number; -} - -/** - * Band-count view of the freeze configuration (Phase 4 groundwork for the 3×3 band - * model): a zero count means the band does not exist. Derived by updateFreezeState - * from the legacy freeze snapshot; frozenRightCols stays 0 until right-frozen - * columns land. - */ -interface FreezeBandCounts { - frozenLeftCols: number; - frozenRightCols: number; - frozenTopRows: number; - frozenBottomRows: number; -} - -/** Geometry inputs for ViewportMgr.applyCanvasWidths — computed by the grid, distributed by the manager. */ -interface CanvasWidthsGeometry { - widthChanged: boolean; - canvasWidth: number; - canvasWidthL: number; - canvasWidthR: number; - canvasWidthRF: number; - headersWidthL: number; - headersWidthR: number; - headersWidthRF: number; - viewportW: number; - viewportHasVScroll: boolean; - scrollbarWidth: number; - createFooterRow?: boolean; - createPreHeaderPanel?: boolean; - preHeaderPanelWidth?: number | string; -} - -/** Geometry inputs for ViewportMgr.applyPaneHeights — computed by the grid, distributed by the manager. */ -interface PaneHeightsGeometry { - viewportH: number; - frozenRowsHeight: number; - /** height of the bottom-frozen row band (simultaneous top+bottom mode; 0 otherwise) */ - frozenBottomRowsHeight?: number; - scrollbarHeight: number; - topPanelH: number; - headerRowH: number; - footerRowH: number; - /** lazily computed to avoid an unconditional style recalc; only read on the autoHeight+frozen path */ - getContainerVBoxDelta: () => number; - autoHeight?: boolean; - showPreHeaderPanel?: boolean; - preHeaderPanelHeight?: number; - showTopHeaderPanel?: boolean; - topHeaderPanelHeight?: number; - showHeaderRow?: boolean; - headerRowHeight?: number; -} - -/** Options consumed by ViewportMgr when constructing the pane/viewport/canvas DOM. */ -interface ViewportMgrBuildOptions { - createPreHeaderPanel?: boolean; - showPreHeaderPanel?: boolean; - createFooterRow?: boolean; - showFooterRow?: boolean; - showColumnHeader?: boolean; - showTopPanel?: boolean; - showHeaderRow?: boolean; - viewportClass?: string; - lazyPanes?: boolean; - frozenColumn?: number; - frozenRow?: number; -} - -/** - * ViewportMgr — owns the construction of the grid's pane/viewport/canvas DOM. - * - * Phase 1 of the frozen rows/columns encapsulation refactor: this class builds the - * exact same 6-pane / 4-viewport / 4-canvas structure the grid has always built - * (characterized by cypress/e2e/dom-shape-characterization.cy.ts) and SlickGrid keeps - * aliases to every element, so all existing logic is unchanged. Later phases move pane - * selection, geometry distribution and scroll synchronization in here. - */ -class ViewportMgr { - /** the grid container, captured by buildPanes */ - protected container!: HTMLElement; - - /** - * True when the grid opted into lazyPanes AND no rows/columns were frozen at build - * time — only the top-left pane set exists until freezing is enabled. - */ - protected lazy = false; - - /** - * Array positions of dynamically materialized viewports/canvases (the viewport and - * canvas arrays always extend in lockstep, so one slot serves both). Recorded at - * materialization time instead of hardcoding, so band order never matters. - */ - protected rfTopSlot = -1; - protected rfBottomSlot = -1; - protected bfSlotL = -1; - protected bfSlotR = -1; - protected bfSlotRF = -1; - - // panes - paneHeaderL!: HTMLDivElement; - paneHeaderR!: HTMLDivElement; - paneTopL!: HTMLDivElement; - paneTopR!: HTMLDivElement; - paneBottomL!: HTMLDivElement; - paneBottomR!: HTMLDivElement; - - // pre-header panels (only when createPreHeaderPanel) - preHeaderPanelScroller!: HTMLDivElement; - preHeaderPanel!: HTMLDivElement; - preHeaderPanelSpacer!: HTMLDivElement; - preHeaderPanelScrollerR!: HTMLDivElement; - preHeaderPanelR!: HTMLDivElement; - preHeaderPanelSpacerR!: HTMLDivElement; - - // header scrollers and header column containers - headerScrollerL!: HTMLDivElement; - headerScrollerR!: HTMLDivElement; - headerScroller: HTMLDivElement[] = []; - headerL!: HTMLDivElement; - headerR!: HTMLDivElement; - headers: HTMLDivElement[] = []; - - // header rows - headerRowScrollerL!: HTMLDivElement; - headerRowScrollerR!: HTMLDivElement; - headerRowScroller: HTMLDivElement[] = []; - headerRowSpacerL!: HTMLDivElement; - headerRowSpacerR!: HTMLDivElement; - headerRowL!: HTMLDivElement; - headerRowR!: HTMLDivElement; - headerRows: HTMLDivElement[] = []; - - // top panels - topPanelScrollerL!: HTMLDivElement; - topPanelScrollerR!: HTMLDivElement; - topPanelScrollers: HTMLDivElement[] = []; - topPanelL!: HTMLDivElement; - topPanelR!: HTMLDivElement; - topPanels: HTMLDivElement[] = []; - - // viewports and canvases - viewportTopL!: HTMLDivElement; - viewportTopR!: HTMLDivElement; - viewportBottomL!: HTMLDivElement; - viewportBottomR!: HTMLDivElement; - viewport: HTMLDivElement[] = []; - canvasTopL!: HTMLDivElement; - canvasTopR!: HTMLDivElement; - canvasBottomL!: HTMLDivElement; - canvasBottomR!: HTMLDivElement; - canvas: HTMLDivElement[] = []; - - // right-frozen band (Phase 4 — exists only while frozenRightColumn > 0 has been applied) - paneHeaderRF!: HTMLDivElement; - paneTopRF!: HTMLDivElement; - paneBottomRF!: HTMLDivElement; - headerScrollerRF!: HTMLDivElement; - headerRF!: HTMLDivElement; - headerRowScrollerRF!: HTMLDivElement; - headerRowSpacerRF!: HTMLDivElement; - headerRowRF!: HTMLDivElement; - topPanelScrollerRF!: HTMLDivElement; - topPanelRF!: HTMLDivElement; - viewportTopRF!: HTMLDivElement; - viewportBottomRF!: HTMLDivElement; - canvasTopRF!: HTMLDivElement; - canvasBottomRF!: HTMLDivElement; - footerRowScrollerRF!: HTMLDivElement; - footerRowSpacerRF!: HTMLDivElement; - footerRowRF!: HTMLDivElement; - - // bottom-frozen row band (Phase 4 — exists only in simultaneous top+bottom mode) - paneBottomFrozenL!: HTMLDivElement; - paneBottomFrozenR!: HTMLDivElement; - paneBottomFrozenRF!: HTMLDivElement; - viewportBottomFrozenL!: HTMLDivElement; - viewportBottomFrozenR!: HTMLDivElement; - viewportBottomFrozenRF!: HTMLDivElement; - canvasBottomFrozenL!: HTMLDivElement; - canvasBottomFrozenR!: HTMLDivElement; - canvasBottomFrozenRF!: HTMLDivElement; - - // footer rows (only when createFooterRow) - footerRowScrollerL!: HTMLDivElement; - footerRowScrollerR!: HTMLDivElement; - footerRowScroller: HTMLDivElement[] = []; - footerRowSpacerL!: HTMLDivElement; - footerRowSpacerR!: HTMLDivElement; - footerRowL!: HTMLDivElement; - footerRowR!: HTMLDivElement; - footerRow: HTMLDivElement[] = []; - - /** - * Builds the pane/viewport/canvas DOM inside the given container. - * The construction order and every class/style is identical to the historical - * inline construction in SlickGrid.initialize(). - */ - buildPanes(container: HTMLElement, o: ViewportMgrBuildOptions) { - this.container = container; - this.lazy = !!o.lazyPanes && !(((o.frozenColumn ?? -1) > -1) || ((o.frozenRow ?? -1) > -1)); - - // Containers used for scrolling frozen columns and rows. - // Under lazyPanes with nothing frozen, only the top-left pane set is built; - // the creation ORDER of the conditional elements must stay canonical so both - // modes produce the same sibling sequence for whatever exists. - this.paneHeaderL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, container); - if (!this.lazy) { - this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, container); - } - this.paneTopL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-left', tabIndex: 0 }, container); - if (!this.lazy) { - this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }, container); - this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }, container); - this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }, container); - } - - if (o.createPreHeaderPanel) { - this.preHeaderPanelScroller = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderL); - this.preHeaderPanelScroller.appendChild(document.createElement('div')); - this.preHeaderPanel = Utils.createDomElement('div', null, this.preHeaderPanelScroller); - this.preHeaderPanelSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScroller); - - if (!this.lazy) { - this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); - this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); - this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); - } - - if (!o.showPreHeaderPanel) { - Utils.hide(this.preHeaderPanelScroller); - this.hideIf(this.preHeaderPanelScrollerR); - } - } - - // Append the header scroller containers - this.headerScrollerL = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-left' }, this.paneHeaderL); - if (!this.lazy) { - this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); - } - - // Cache the header scroller containers - this.headerScroller.push(this.headerScrollerL); - if (!this.lazy) { - this.headerScroller.push(this.headerScrollerR); - } - - // Append the columnn containers to the headers - this.headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { left: '-1000px' } }, this.headerScrollerL); - if (!this.lazy) { - this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); - } - - // Cache the header columns - this.headers = this.lazy ? [this.headerL] : [this.headerL, this.headerR]; - - this.headerRowScrollerL = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopL); - if (!this.lazy) { - this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); - } - - this.headerRowScroller = this.lazy ? [this.headerRowScrollerL] : [this.headerRowScrollerL, this.headerRowScrollerR]; - - this.headerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerL); - if (!this.lazy) { - this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); - } - - this.headerRowL = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }, this.headerRowScrollerL); - if (!this.lazy) { - this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); - } - - this.headerRows = this.lazy ? [this.headerRowL] : [this.headerRowL, this.headerRowR]; - - // Append the top panel scroller - this.topPanelScrollerL = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopL); - if (!this.lazy) { - this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); - } - - this.topPanelScrollers = this.lazy ? [this.topPanelScrollerL] : [this.topPanelScrollerL, this.topPanelScrollerR]; - - // Append the top panel - this.topPanelL = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerL); - if (!this.lazy) { - this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); - } - - this.topPanels = this.lazy ? [this.topPanelL] : [this.topPanelL, this.topPanelR]; - - if (!o.showColumnHeader) { - this.headerScroller.forEach((el) => { - Utils.hide(el); - }); - } - - if (!o.showTopPanel) { - this.topPanelScrollers.forEach((scroller) => { - Utils.hide(scroller); - }); - } - - if (!o.showHeaderRow) { - this.headerRowScroller.forEach((scroller) => { - Utils.hide(scroller); - }); - } - - // Append the viewport containers - this.viewportTopL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-left', tabIndex: 0 }, this.paneTopL); - if (!this.lazy) { - this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); - this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); - this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); - } - - // Cache the viewports - this.viewport = this.lazy - ? [this.viewportTopL] - : [this.viewportTopL, this.viewportTopR, this.viewportBottomL, this.viewportBottomR]; - if (o.viewportClass) { - this.viewport.forEach((view) => { - view.classList.add(...Utils.classNameToList((o.viewportClass))); - }); - } - - // Append the canvas containers - this.canvasTopL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-left', tabIndex: 0 }, this.viewportTopL); - if (!this.lazy) { - this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); - this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); - this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); - } - - // Cache the canvases - this.canvas = this.lazy - ? [this.canvasTopL] - : [this.canvasTopL, this.canvasTopR, this.canvasBottomL, this.canvasBottomR]; - } - - /** - * Builds the footer-row containers (only called when the createFooterRow option is on). - * Identical construction to the historical inline code, including the R-before-L - * scroller creation order and spacer widths. - */ - buildFooterRows(o: ViewportMgrBuildOptions, canvasWithScrollbarWidth: number) { - if (!this.lazy) { - this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); - } - this.footerRowScrollerL = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopL); - - this.footerRowScroller = this.lazy ? [this.footerRowScrollerL] : [this.footerRowScrollerL, this.footerRowScrollerR]; - - this.footerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerL); - Utils.width(this.footerRowSpacerL, canvasWithScrollbarWidth); - if (!this.lazy) { - this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); - Utils.width(this.footerRowSpacerR, canvasWithScrollbarWidth); - } - - this.footerRowL = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-left' }, this.footerRowScrollerL); - if (!this.lazy) { - this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); - } - - this.footerRow = this.lazy ? [this.footerRowL] : [this.footerRowL, this.footerRowR]; - - if (!o.showFooterRow) { - this.footerRowScroller.forEach((scroller) => { - Utils.hide(scroller); - }); - } - } - - /** - * Builds the right/bottom panes, chrome, viewports and canvases that a lazyPanes - * grid skipped at init, inserting each pane at its canonical sibling position and - * pushing the new elements into the shared caches IN PLACE (the grid's array - * aliases keep working). Idempotent: returns false when the grid is not lazy - * (already fully built or built non-lazy). - */ - materializeSecondaryPanes(o: ViewportMgrBuildOptions): boolean { - if (!this.lazy) { - return false; - } - this.lazy = false; - - const container = this.container; - - // panes, at their canonical sibling positions - this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneHeaderR, this.paneTopL); - this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneTopR, this.paneTopL.nextSibling); - this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }); - container.insertBefore(this.paneBottomL, this.paneTopR.nextSibling); - this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneBottomR, this.paneBottomL.nextSibling); - - if (o.createPreHeaderPanel) { - this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); - this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); - this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); - - if (!o.showPreHeaderPanel) { - Utils.hide(this.preHeaderPanelScrollerR); - } - } - - // header scroller + header columns - this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); - this.headerScroller.push(this.headerScrollerR); - this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); - this.headers.push(this.headerR); - - // header row - this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); - this.headerRowScroller.push(this.headerRowScrollerR); - this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); - this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); - this.headerRows.push(this.headerRowR); - - // top panel - this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); - this.topPanelScrollers.push(this.topPanelScrollerR); - this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); - this.topPanels.push(this.topPanelR); - - if (!o.showColumnHeader) { - Utils.hide(this.headerScrollerR); - } - if (!o.showTopPanel) { - Utils.hide(this.topPanelScrollerR); - } - if (!o.showHeaderRow) { - Utils.hide(this.headerRowScrollerR); - } - - // viewports (pushed in canonical [TopL, TopR, BottomL, BottomR] order) - this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); - this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); - this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); - this.viewport.push(this.viewportTopR, this.viewportBottomL, this.viewportBottomR); - if (o.viewportClass) { - const viewportClassList = Utils.classNameToList(o.viewportClass); - this.viewportTopR.classList.add(...viewportClassList); - this.viewportBottomL.classList.add(...viewportClassList); - this.viewportBottomR.classList.add(...viewportClassList); - } - - // canvases - this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); - this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); - this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); - this.canvas.push(this.canvasTopR, this.canvasBottomL, this.canvasBottomR); - - // footer row (right side; the left one was built at init when createFooterRow) - if (o.createFooterRow) { - this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); - this.footerRowScroller.push(this.footerRowScrollerR); - this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); - this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); - this.footerRow.push(this.footerRowR); - - if (!o.showFooterRow) { - Utils.hide(this.footerRowScrollerR); - } - } - - return true; - } - - /** - * Builds the right-frozen column band (Phase 4): three panes with NEW - * `*-right-frozen` css classes, appended AFTER the six classic panes so classic - * sibling positions are untouched, plus header/header-row/top-panel chrome, - * viewports and canvases. Shared element arrays are extended at the END so the - * classic indexes 0–3 (and [L, R] pairs) stay valid for every existing consumer. - * Idempotent: returns false when the band already exists. - * - * The historical "right" elements keep their class names and become the scrollable - * MIDDLE band while this band is active. - */ - materializeRightFrozenBand(o: ViewportMgrBuildOptions): boolean { - if (this.paneHeaderRF) { - return false; - } - - const container = this.container; - - // panes — appended after the classic six (still before the trailing focus sink, - // which the grid appends after all panes) - this.paneHeaderRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right-frozen', tabIndex: 0 }); - this.paneTopRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right-frozen', tabIndex: 0 }); - this.paneBottomRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right-frozen', tabIndex: 0 }); - // insert as a block after the last classic pane (paneBottomR when it exists, - // else the lazy grid's paneTopL) - const lastClassicPane = this.paneBottomR ?? this.paneTopL; - container.insertBefore(this.paneHeaderRF, lastClassicPane.nextSibling); - container.insertBefore(this.paneTopRF, this.paneHeaderRF.nextSibling); - container.insertBefore(this.paneBottomRF, this.paneTopRF.nextSibling); - - // header chrome - this.headerScrollerRF = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right-frozen' }, this.paneHeaderRF); - this.headerScroller.push(this.headerScrollerRF); - this.headerRF = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right-frozen', role: 'row', style: { left: '-1000px' } }, this.headerScrollerRF); - this.headers.push(this.headerRF); - - // header row - this.headerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopRF); - this.headerRowScroller.push(this.headerRowScrollerRF); - this.headerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerRF); - this.headerRowRF = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right-frozen' }, this.headerRowScrollerRF); - this.headerRows.push(this.headerRowRF); - - // top panel - this.topPanelScrollerRF = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopRF); - this.topPanelScrollers.push(this.topPanelScrollerRF); - this.topPanelRF = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerRF); - this.topPanels.push(this.topPanelRF); - - if (!o.showColumnHeader) { - Utils.hide(this.headerScrollerRF); - } - if (!o.showTopPanel) { - Utils.hide(this.topPanelScrollerRF); - } - if (!o.showHeaderRow) { - Utils.hide(this.headerRowScrollerRF); - } - - // viewports and canvases (array order extended at the END: classic 0–3 preserved) - this.viewportTopRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right-frozen', tabIndex: 0 }, this.paneTopRF); - this.viewportBottomRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomRF); - this.viewport.push(this.viewportTopRF, this.viewportBottomRF); - if (o.viewportClass) { - const viewportClassList = Utils.classNameToList(o.viewportClass); - this.viewportTopRF.classList.add(...viewportClassList); - this.viewportBottomRF.classList.add(...viewportClassList); - } - - this.canvasTopRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right-frozen', tabIndex: 0 }, this.viewportTopRF); - this.canvasBottomRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomRF); - this.canvas.push(this.canvasTopRF, this.canvasBottomRF); - this.rfTopSlot = this.canvas.indexOf(this.canvasTopRF); - this.rfBottomSlot = this.canvas.indexOf(this.canvasBottomRF); - - // footer row - if (o.createFooterRow) { - this.footerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopRF); - this.footerRowScroller.push(this.footerRowScrollerRF); - this.footerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerRF); - this.footerRowRF = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right-frozen' }, this.footerRowScrollerRF); - this.footerRow.push(this.footerRowRF); - - if (!o.showFooterRow) { - Utils.hide(this.footerRowScrollerRF); - } - } - - // if the bottom-frozen band already exists, add the shared corner pane - this.ensureBottomFrozenRightVariant(o); - - return true; - } - - /** - * Builds the bottom-frozen row band (Phase 4, simultaneous top+bottom mode): one - * pane+viewport+canvas per active column band, appended after all existing panes - * with `*-bottom-frozen` css classes. Element arrays extend at the END and the - * slots are recorded (bfSlotL/R/RF). Idempotent: returns false when the band - * already exists. The right-frozen column variant is built only when that band's - * DOM exists at call time; materializeRightFrozenBand adds it later otherwise. - */ - materializeBottomFrozenBand(o: ViewportMgrBuildOptions): boolean { - if (this.paneBottomFrozenL) { - // band exists — but the RF column variant may have arrived after us - this.ensureBottomFrozenRightVariant(o); - return false; - } - - const container = this.container; - const lastPane = this.paneBottomRF ?? this.paneBottomR ?? this.paneTopL; - - this.paneBottomFrozenL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-left', tabIndex: 0 }); - container.insertBefore(this.paneBottomFrozenL, lastPane.nextSibling); - this.paneBottomFrozenR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneBottomFrozenR, this.paneBottomFrozenL.nextSibling); - - this.viewportBottomFrozenL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-left', tabIndex: 0 }, this.paneBottomFrozenL); - this.viewportBottomFrozenR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right', tabIndex: 0 }, this.paneBottomFrozenR); - this.canvasBottomFrozenL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-left', tabIndex: 0 }, this.viewportBottomFrozenL); - this.canvasBottomFrozenR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right', tabIndex: 0 }, this.viewportBottomFrozenR); - - if (o.viewportClass) { - const viewportClassList = Utils.classNameToList(o.viewportClass); - this.viewportBottomFrozenL.classList.add(...viewportClassList); - this.viewportBottomFrozenR.classList.add(...viewportClassList); - } - - this.viewport.push(this.viewportBottomFrozenL, this.viewportBottomFrozenR); - this.canvas.push(this.canvasBottomFrozenL, this.canvasBottomFrozenR); - this.bfSlotL = this.canvas.indexOf(this.canvasBottomFrozenL); - this.bfSlotR = this.canvas.indexOf(this.canvasBottomFrozenR); - - this.ensureBottomFrozenRightVariant(o); - return true; - } - - /** Adds the bottom-frozen × right-frozen corner pane when both bands exist. */ - protected ensureBottomFrozenRightVariant(o: ViewportMgrBuildOptions) { - if (!this.paneBottomFrozenL || !this.paneHeaderRF || this.paneBottomFrozenRF) { - return; - } - this.paneBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right-frozen', tabIndex: 0 }); - this.container.insertBefore(this.paneBottomFrozenRF, this.paneBottomFrozenR.nextSibling); - this.viewportBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomFrozenRF); - this.canvasBottomFrozenRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomFrozenRF); - if (o.viewportClass) { - this.viewportBottomFrozenRF.classList.add(...Utils.classNameToList(o.viewportClass)); - } - this.viewport.push(this.viewportBottomFrozenRF); - this.canvas.push(this.canvasBottomFrozenRF); - this.bfSlotRF = this.canvas.indexOf(this.canvasBottomFrozenRF); - } - - /** Whether the simultaneous top+bottom row mode is active AND its DOM exists. */ - hasBottomFrozenBand(): boolean { - return this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; - } - - /** - * Whether the row belongs to the bottom-frozen band. BOUNDED on both sides so the - * add-new row (index === dataLength) can never be captured by the band. The same - * test is used on both the render and lookup sides — the new band deliberately - * avoids the historical one-row threshold asymmetry of the legacy single band. - */ - isRowInBottomFrozenBand(row: number): boolean { - if (!this.hasBottomFrozenBand()) { - return false; - } - const split = this.freeze.bottomFrozenSplitRow ?? Number.MAX_SAFE_INTEGER; - return row >= split && row < split + this.bands.frozenBottomRows; - } - - ////////////////////////////////////////////////////////////////////////////////////////////// - // Freeze state and pane selection (Phase 2 of the encapsulation refactor) - ////////////////////////////////////////////////////////////////////////////////////////////// - - protected freeze: ViewportFreezeState = { frozenColumnIdx: -1, hasFrozenRows: false, actualFrozenRow: -1, frozenBottom: false }; - protected bands: FreezeBandCounts = { frozenLeftCols: 0, frozenRightCols: 0, frozenTopRows: 0, frozenBottomRows: 0 }; - - /** Receives the grid's freeze configuration; called by SlickGrid.setFrozenOptions(). */ - updateFreezeState(f: ViewportFreezeState) { - this.freeze = { ...f }; - - // derive the band-count view (Phase 4 groundwork); the legacy fields above stay - // authoritative for the existing 2×2 code paths - const rowCount = f.hasFrozenRows ? Math.max(0, f.frozenRowCount ?? 0) : 0; - const bottomRowCount = Math.max(0, f.frozenBottomRowCount ?? 0); - this.bands = { - frozenLeftCols: f.frozenColumnIdx + 1, - frozenRightCols: Math.max(0, f.frozenRightColCount ?? 0), - // with an explicit bottom count, frozenRow always means TOP rows and the legacy - // frozenBottom flag is ignored (it only positions the single-band case) - frozenTopRows: bottomRowCount > 0 ? rowCount : (f.frozenBottom ? 0 : rowCount), - frozenBottomRows: bottomRowCount > 0 ? bottomRowCount : (f.frozenBottom ? rowCount : 0), - }; - } - - /** Band-count view of the freeze configuration (zero count = band does not exist). */ - bandCounts(): FreezeBandCounts { - return this.bands; - } - - /** Returns a boolean indicating whether the grid is configured with frozen columns. */ - hasFrozenColumns() { - return this.bands.frozenLeftCols > 0; - } - - /** Returns a boolean indicating whether the grid is configured with frozen rows. */ - hasFrozenRows() { - return this.freeze.hasFrozenRows; - } - - /** - * The left canvas of the scrollable body band: bottom-left while rows are frozen at - * the top, top-left otherwise (historical selector used by updateRowCount and - * bindAncestorScrollEvents). - */ - bodyCanvasL(): HTMLDivElement { - return (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) ? this.canvasBottomL : this.canvasTopL; - } - - /** - * Index of the pane owning cell (colIdx, rowIdx) in the element arrays: - * classic slots [TopL, TopR, BottomL, BottomR], right-frozen slots [TopRF, BottomRF] - * appended at 4/5 (materializeRightFrozenPanes canonicalizes the classic set first, - * so these positions hold under lazyPanes too). - */ - paneCellIndex(colIdx: number, rowIdx: number): number { - if (this.isRowInBottomFrozenBand(rowIdx)) { - if (this.isColumnInRightFrozenBand(colIdx)) { - return this.bfSlotRF; - } - const isRightSideBF = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; - return isRightSideBF ? this.bfSlotR : this.bfSlotL; - } - const isBottomSide = this.freeze.hasFrozenRows && rowIdx >= this.freeze.actualFrozenRow + (this.freeze.frozenBottom ? 0 : 1); - if (this.isColumnInRightFrozenBand(colIdx)) { - return isBottomSide ? this.rfBottomSlot : this.rfTopSlot; - } - const isRightSide = this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; - return (isBottomSide ? 2 : 0) + (isRightSide ? 1 : 0); - } - - /** - * Get frozen (pinned) row offset - * - * Returns the vertical pixel offset to apply for frozen rows. - * Depending on whether frozen rows are pinned at the bottom or top and based on grid height, - * it returns either a fixed frozen rows height or a calculated offset. - * - * @param {Number} row - grid row number - */ - frozenRowOffset(row: number, g: { h: number; viewportTopH: number; frozenRowsHeight: number; rowHeight: number; }): number { - // bottom-frozen band (simultaneous mode): rebase to band-local coordinates - if (this.isRowInBottomFrozenBand(row)) { - return this.freeze.bottomFrozenSplitRow! * g.rowHeight; - } - - // let offset = ( hasFrozenRows ) ? ( this._options.frozenBottom ) ? ( row >= actualFrozenRow ) ? ( h < viewportTopH ) ? ( actualFrozenRow * this._options.rowHeight ) : h : 0 : ( row >= actualFrozenRow ) ? frozenRowsHeight : 0 : 0; // WTF? - let offset = 0; - if (this.freeze.hasFrozenRows) { - if (this.freeze.frozenBottom) { - if (row >= this.freeze.actualFrozenRow) { - if (g.h < g.viewportTopH) { - offset = (this.freeze.actualFrozenRow * g.rowHeight); - } else { - offset = g.h; - } - } else { - offset = 0; - } - } - else { - if (row >= this.freeze.actualFrozenRow) { - offset = g.frozenRowsHeight; - } else { - offset = 0; - } - } - } else { - offset = 0; - } - - return offset; - } - - /** - * Whether the row lives in a frozen band and must therefore be kept out of row - * virtualization cleanup (historical cleanupRows predicate). - */ - isRowInFrozenBand(row: number): boolean { - if (this.isRowInBottomFrozenBand(row)) { - return true; - } - return this.freeze.hasFrozenRows - && ((this.freeze.frozenBottom && row >= this.freeze.actualFrozenRow) // Frozen bottom rows - || (!this.freeze.frozenBottom && row <= this.freeze.actualFrozenRow) // Frozen top rows - ); - } - - /** - * Whether cell-level cleanup must skip the row entirely (historical cleanUpCells - * predicate). NOTE: transcribed verbatim — the second disjunct is NOT guarded by - * !frozenBottom, so for frozenBottom grids every row is exempt; that quirk is - * long-standing upstream behaviour and is deliberately preserved. - */ - isRowCellCleanupExempt(row: number): boolean { - if (this.isRowInBottomFrozenBand(row)) { - return true; - } - return this.freeze.hasFrozenRows - && ((this.freeze.frozenBottom && row > this.freeze.actualFrozenRow) // Frozen bottom rows - || (row <= this.freeze.actualFrozenRow) // Frozen top rows - ); - } - - /** Whether the column index falls inside the left frozen band. */ - isColumnInFrozenBand(colIdx: number): boolean { - return colIdx <= this.freeze.frozenColumnIdx; - } - - /** True when frozen columns are on AND the column index falls right of the freeze. */ - isColumnRightOfFreeze(colIdx: number): boolean { - return this.hasFrozenColumns() && colIdx > this.freeze.frozenColumnIdx; - } - - /** Column index local to its side container (right-side children are indexed after the freeze). */ - sideLocalColumnIdx(colIdx: number): number { - return this.isColumnRightOfFreeze(colIdx) ? colIdx - this.freeze.frozenColumnIdx - 1 : colIdx; - } - - /** Pick the left or right element of an [L, R] pair for the given column. */ - sideForColumn(colIdx: number, left: T, right: T): T { - return this.isColumnRightOfFreeze(colIdx) ? right : left; - } - - /** Whether the right-frozen band is active AND its DOM has been materialized. */ - hasRightFrozenBand(): boolean { - return this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; - } - - /** Whether the column index falls inside the right-frozen band. */ - isColumnInRightFrozenBand(colIdx: number): boolean { - return this.bands.frozenRightCols > 0 && colIdx >= (this.freeze.frozenRightStartIdx ?? Number.MAX_SAFE_INTEGER); - } - - /** Three-way band pick: left band, scrollable middle, or right-frozen element. */ - bandElementForColumn(colIdx: number, left: T, right: T, rightFrozen: T): T { - if (this.isColumnInRightFrozenBand(colIdx)) { - return rightFrozen; - } - return this.sideForColumn(colIdx, left, right); - } - - /** Column index local to its band container (right-frozen children index from the band start). */ - bandLocalColumnIdx(colIdx: number): number { - if (this.isColumnInRightFrozenBand(colIdx)) { - return colIdx - this.freeze.frozenRightStartIdx!; - } - return this.sideLocalColumnIdx(colIdx); - } - - /** - * Index of the column's node inside rowsCache[].rowNode: 0 for the left fragment - * (which is the scrollable fragment when no columns are left-frozen), 1 for the - * middle fragment under a left freeze, and last for the right-frozen fragment. - */ - rowNodeIdxForColumn(colIdx: number): number { - if (this.isColumnInRightFrozenBand(colIdx)) { - return this.hasFrozenColumns() ? 2 : 1; - } - return this.isColumnRightOfFreeze(colIdx) ? 1 : 0; - } - - /** Utils.show that tolerates panes not built under lazyPanes. */ - protected showIf(el?: HTMLElement) { - if (el) { Utils.show(el); } - } - - /** Utils.hide that tolerates panes not built under lazyPanes. */ - protected hideIf(el?: HTMLElement) { - if (el) { Utils.hide(el); } - } - - /** add/remove frozen class to left headers/footer when defined */ - applyPaneFrozenClasses(): void { - const classAction = this.hasFrozenColumns() ? 'add' : 'remove'; - for (const elm of [this.paneHeaderL, this.paneTopL, this.paneBottomL]) { - elm?.classList[classAction]('frozen'); - } - } - - /** Shows/hides the right and bottom panes according to the freeze configuration. */ - applyPaneVisibility() { - if (this.hasFrozenColumns()) { - this.showIf(this.paneHeaderR); - this.showIf(this.paneTopR); - - if (this.freeze.hasFrozenRows) { - this.showIf(this.paneBottomL); - this.showIf(this.paneBottomR); - } else { - this.hideIf(this.paneBottomR); - this.hideIf(this.paneBottomL); - } - } else { - this.hideIf(this.paneHeaderR); - this.hideIf(this.paneTopR); - this.hideIf(this.paneBottomR); - - if (this.freeze.hasFrozenRows) { - this.showIf(this.paneBottomL); - } else { - this.hideIf(this.paneBottomR); - this.hideIf(this.paneBottomL); - } - } - - // right-frozen band (exists only after materialization; kept hidden — like the - // classic panes — when the right freeze is turned off again) - if (this.bands.frozenRightCols > 0) { - this.showIf(this.paneHeaderRF); - this.showIf(this.paneTopRF); - if (this.freeze.hasFrozenRows) { - this.showIf(this.paneBottomRF); - } else { - this.hideIf(this.paneBottomRF); - } - } else { - this.hideIf(this.paneHeaderRF); - this.hideIf(this.paneTopRF); - this.hideIf(this.paneBottomRF); - } - - // bottom-frozen row band (simultaneous top+bottom mode only); column-band - // visibility mirrors the classic bottom panes - if (this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0) { - this.showIf(this.paneBottomFrozenL); - if (this.hasFrozenColumns()) { - this.showIf(this.paneBottomFrozenR); - } else { - this.hideIf(this.paneBottomFrozenR); - } - if (this.bands.frozenRightCols > 0) { - this.showIf(this.paneBottomFrozenRF); - } else { - this.hideIf(this.paneBottomFrozenRF); - } - } else { - this.hideIf(this.paneBottomFrozenL); - this.hideIf(this.paneBottomFrozenR); - this.hideIf(this.paneBottomFrozenRF); - } - } - - /** - * Sets the CSS overflowX and overflowY styles for all four viewport elements - * (top–left, top–right, bottom–left, bottom–right) based on the freeze configuration - * and options such as alwaysAllowHorizontalScroll and alwaysShowVerticalScroll. - * If a viewportClass is specified in options, the class is added to each viewport. - */ - applyOverflow(o: { alwaysAllowHorizontalScroll?: boolean; alwaysShowVerticalScroll?: boolean; viewportClass?: string; }) { - const hasFrozenRows = this.freeze.hasFrozenRows; - this.viewportTopL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); - this.viewportTopL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); - - if (this.viewportTopR) { - this.viewportTopR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'scroll') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'hidden' : 'auto'); - this.viewportTopR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'scroll' : 'auto') : (hasFrozenRows ? 'scroll' : 'auto')); - } - - if (this.viewportBottomL) { - this.viewportBottomL.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); - this.viewportBottomL.style.overflowY = (!this.hasFrozenColumns() && o.alwaysShowVerticalScroll) ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'hidden' : 'hidden') : (hasFrozenRows ? 'scroll' : 'auto')); - } - - if (this.viewportBottomR) { - this.viewportBottomR.style.overflowX = (this.hasFrozenColumns()) ? (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'scroll' : 'auto') : (hasFrozenRows && !o.alwaysAllowHorizontalScroll ? 'auto' : 'auto'); - this.viewportBottomR.style.overflowY = o.alwaysShowVerticalScroll ? 'scroll' : ((this.hasFrozenColumns()) ? (hasFrozenRows ? 'auto' : 'auto') : (hasFrozenRows ? 'auto' : 'auto')); - } - - // bottom-frozen viewports never own a scrollbar: Y is fixed, X follows the - // scroll owner programmatically - if (this.viewportBottomFrozenL) { - this.viewportBottomFrozenL.style.overflowX = 'hidden'; - this.viewportBottomFrozenL.style.overflowY = 'hidden'; - } - if (this.viewportBottomFrozenR) { - this.viewportBottomFrozenR.style.overflowX = 'hidden'; - this.viewportBottomFrozenR.style.overflowY = 'hidden'; - } - if (this.viewportBottomFrozenRF) { - this.viewportBottomFrozenRF.style.overflowX = 'hidden'; - this.viewportBottomFrozenRF.style.overflowY = 'hidden'; - } - - // right-frozen viewports never own a scrollbar: X is fixed, Y follows the - // scroll owner programmatically (same rationale as the frozen-left viewport) - if (this.viewportTopRF) { - this.viewportTopRF.style.overflowX = 'hidden'; - this.viewportTopRF.style.overflowY = 'hidden'; - } - if (this.viewportBottomRF) { - this.viewportBottomRF.style.overflowX = 'hidden'; - this.viewportBottomRF.style.overflowY = 'hidden'; - } - - if (o.viewportClass) { - const viewportClassList = Utils.classNameToList(o.viewportClass); - // this.viewport only ever contains the elements that were actually built - this.viewport.forEach((view) => { - view.classList.add(...viewportClassList); - }); - } - } - - /** - * Picks which viewport owns the X and Y scrollbars and which header/header-row/footer-row - * scrollers follow horizontal scrolling, according to the freeze configuration. - * The horizontal scrollbar must sit at the physical bottom of the grid, which is why - * frozenBottom splits X and Y ownership. - */ - /** - * Distributes computed canvas/header widths onto the pane, viewport, canvas, header, - * header-row and footer-row elements. Transcribed from the historical middle section - * of SlickGrid.updateCanvasWidth(); the width computations stay in the grid. - */ - applyCanvasWidths(g: CanvasWidthsGeometry) { - // width reserved by the right-frozen band (0 while the band is off or not built); - // the scrollable middle band shrinks by this amount - const rfActive = this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; - const rfW = rfActive ? g.canvasWidthRF : 0; - - if (g.widthChanged || this.hasFrozenColumns() || this.freeze.hasFrozenRows || rfActive) { - Utils.width(this.canvasTopL, g.canvasWidthL); - - Utils.width(this.headerL, g.headersWidthL); - if (this.headerR) { - Utils.width(this.headerR, g.headersWidthR); - } - - if (this.hasFrozenColumns()) { - Utils.width(this.canvasTopR, g.canvasWidthR); - - Utils.width(this.paneHeaderL, g.canvasWidthL); - Utils.setStyleSize(this.paneHeaderR, 'left', g.canvasWidthL); - Utils.setStyleSize(this.paneHeaderR, 'width', g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.paneTopL, g.canvasWidthL); - Utils.setStyleSize(this.paneTopR, 'left', g.canvasWidthL); - Utils.width(this.paneTopR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.headerRowScrollerL, g.canvasWidthL); - Utils.width(this.headerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.headerRowL, g.canvasWidthL); - Utils.width(this.headerRowR, g.canvasWidthR); - - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerL, g.canvasWidthL); - Utils.width(this.footerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.footerRowL, g.canvasWidthL); - Utils.width(this.footerRowR, g.canvasWidthR); - } - if (g.createPreHeaderPanel) { - Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); - } - Utils.width(this.viewportTopL, g.canvasWidthL); - Utils.width(this.viewportTopR, g.viewportW - g.canvasWidthL - rfW); - - if (this.freeze.hasFrozenRows) { - Utils.width(this.paneBottomL, g.canvasWidthL); - Utils.setStyleSize(this.paneBottomR, 'left', g.canvasWidthL); - - Utils.width(this.viewportBottomL, g.canvasWidthL); - Utils.width(this.viewportBottomR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.canvasBottomL, g.canvasWidthL); - Utils.width(this.canvasBottomR, g.canvasWidthR); - } - } else if (rfActive) { - // no left freeze, but a right-frozen band: the left pane IS the scrollable - // middle band — pixel widths instead of the historical '100%' - const middleW = g.viewportW - rfW; - Utils.width(this.paneHeaderL, middleW); - Utils.width(this.paneTopL, middleW); - Utils.width(this.headerRowScrollerL, middleW); - Utils.width(this.headerRowL, g.canvasWidth); - - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerL, middleW); - Utils.width(this.footerRowL, g.canvasWidth); - } - - if (g.createPreHeaderPanel) { - Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); - } - Utils.width(this.viewportTopL, middleW); - - if (this.freeze.hasFrozenRows) { - Utils.width(this.viewportBottomL, middleW); - Utils.width(this.canvasBottomL, g.canvasWidthL); - } - } else { - Utils.width(this.paneHeaderL, '100%'); - Utils.width(this.paneTopL, '100%'); - Utils.width(this.headerRowScrollerL, '100%'); - Utils.width(this.headerRowL, g.canvasWidth); - - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerL, '100%'); - Utils.width(this.footerRowL, g.canvasWidth); - } - - if (g.createPreHeaderPanel) { - Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); - } - Utils.width(this.viewportTopL, '100%'); - - if (this.freeze.hasFrozenRows) { - Utils.width(this.viewportBottomL, '100%'); - Utils.width(this.canvasBottomL, g.canvasWidthL); - } - } - - // bottom-frozen row band (simultaneous mode): column widths mirror the classic - // bottom panes - if (this.hasBottomFrozenBand()) { - if (this.hasFrozenColumns()) { - Utils.width(this.paneBottomFrozenL, g.canvasWidthL); - Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); - Utils.setStyleSize(this.paneBottomFrozenR, 'left', g.canvasWidthL); - Utils.width(this.paneBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); - Utils.width(this.viewportBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); - Utils.width(this.canvasBottomFrozenR, g.canvasWidthR); - Utils.width(this.viewportBottomFrozenL, g.canvasWidthL); - } else if (rfActive) { - Utils.width(this.paneBottomFrozenL, g.viewportW - rfW); - Utils.width(this.viewportBottomFrozenL, g.viewportW - rfW); - Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); - } else { - Utils.width(this.paneBottomFrozenL, '100%'); - Utils.width(this.viewportBottomFrozenL, '100%'); - Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); - } - - if (this.paneBottomFrozenRF && rfActive) { - Utils.setStyleSize(this.paneBottomFrozenRF, 'left', g.viewportW - rfW); - Utils.width(this.paneBottomFrozenRF, rfW); - Utils.width(this.viewportBottomFrozenRF, rfW); - Utils.width(this.canvasBottomFrozenRF, g.canvasWidthRF); - } - } - - // right-frozen band: fixed-width panes pinned to the right edge - if (rfActive) { - const rfLeft = g.viewportW - rfW; - Utils.setStyleSize(this.paneHeaderRF, 'left', rfLeft); - Utils.width(this.paneHeaderRF, rfW); - Utils.width(this.headerRF, g.headersWidthRF); - - Utils.setStyleSize(this.paneTopRF, 'left', rfLeft); - Utils.width(this.paneTopRF, rfW); - Utils.width(this.headerRowScrollerRF, rfW); - Utils.width(this.headerRowRF, g.canvasWidthRF); - Utils.width(this.viewportTopRF, rfW); - Utils.width(this.canvasTopRF, g.canvasWidthRF); - - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerRF, rfW); - Utils.width(this.footerRowRF, g.canvasWidthRF); - } - - if (this.freeze.hasFrozenRows) { - Utils.setStyleSize(this.paneBottomRF, 'left', rfLeft); - Utils.width(this.paneBottomRF, rfW); - Utils.width(this.viewportBottomRF, rfW); - Utils.width(this.canvasBottomRF, g.canvasWidthRF); - } - } - } - - Utils.width(this.headerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); - if (this.headerRowSpacerR) { - Utils.width(this.headerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); - } - - if (g.createFooterRow) { - Utils.width(this.footerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); - if (this.footerRowSpacerR) { - Utils.width(this.footerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); - } - } - } - - /** - * Computes the pane/viewport heights from the freeze configuration and distributes them - * onto the pane, viewport and canvas elements. Transcribed from the historical middle - * section of SlickGrid.resizeCanvas(); returns the computed heights for the grid to keep. - */ - applyPaneHeights(g: PaneHeightsGeometry): { paneTopH: number; paneBottomH: number; viewportTopH: number; viewportBottomH: number; } { - let paneTopH = 0; - let paneBottomH = 0; - let viewportTopH = 0; - const viewportBottomH = 0; - - // Account for Frozen Rows - const simultaneousBands = this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; - if (this.freeze.hasFrozenRows) { - if (this.freeze.frozenBottom) { - paneTopH = g.viewportH - g.frozenRowsHeight - g.scrollbarHeight; - paneBottomH = g.frozenRowsHeight + g.scrollbarHeight; - } else { - paneTopH = g.frozenRowsHeight; - paneBottomH = g.viewportH - g.frozenRowsHeight; - if (simultaneousBands) { - // the scrollable body shrinks by the bottom-frozen band height - paneBottomH -= g.frozenBottomRowsHeight ?? 0; - } - } - } else { - paneTopH = g.viewportH; - } - - // The top pane includes the top panel and the header row - paneTopH += g.topPanelH + g.headerRowH + g.footerRowH; - - if (this.hasFrozenColumns() && g.autoHeight) { - paneTopH += g.scrollbarHeight; - } - - // The top viewport does not contain the top panel or header row - viewportTopH = paneTopH - g.topPanelH - g.headerRowH - g.footerRowH; - - if (g.autoHeight) { - if (this.hasFrozenColumns()) { - let fullHeight = paneTopH + this.headerScrollerL.offsetHeight; - fullHeight += g.getContainerVBoxDelta(); - if (g.showPreHeaderPanel) { - fullHeight += g.preHeaderPanelHeight!; - } - Utils.height(this.container, fullHeight); - } - - this.paneTopL.style.position = 'relative'; - } - - let topHeightOffset = Utils.height(this.paneHeaderL); - if (topHeightOffset) { - topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); - } else { - topHeightOffset = (g.showHeaderRow ? g.headerRowHeight! : 0) + (g.showPreHeaderPanel ? g.preHeaderPanelHeight! : 0); - } - Utils.setStyleSize(this.paneTopL, 'top', topHeightOffset || topHeightOffset); - Utils.height(this.paneTopL, paneTopH); - - const paneBottomTop = this.paneTopL.offsetTop + paneTopH; - - if (!g.autoHeight) { - Utils.height(this.viewportTopL, viewportTopH); - } - - if (this.hasFrozenColumns()) { - let topHeightOffset = Utils.height(this.paneHeaderL); - if (topHeightOffset) { - topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); - } - Utils.setStyleSize(this.paneTopR, 'top', topHeightOffset as number); - Utils.height(this.paneTopR, paneTopH); - Utils.height(this.viewportTopR, viewportTopH); - - if (this.freeze.hasFrozenRows) { - Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); - Utils.height(this.paneBottomL, paneBottomH); - Utils.setStyleSize(this.paneBottomR, 'top', paneBottomTop); - Utils.height(this.paneBottomR, paneBottomH); - Utils.height(this.viewportBottomR, paneBottomH); - } - } else { - if (this.freeze.hasFrozenRows) { - Utils.width(this.paneBottomL, '100%'); - Utils.height(this.paneBottomL, paneBottomH); - Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); - } - } - - if (this.freeze.hasFrozenRows) { - Utils.height(this.viewportBottomL, paneBottomH); - - if (this.freeze.frozenBottom) { - Utils.height(this.canvasBottomL, g.frozenRowsHeight); - - if (this.hasFrozenColumns()) { - Utils.height(this.canvasBottomR, g.frozenRowsHeight); - } - } else { - Utils.height(this.canvasTopL, g.frozenRowsHeight); - - if (this.hasFrozenColumns()) { - Utils.height(this.canvasTopR, g.frozenRowsHeight); - } - } - } else { - if (this.viewportTopR) { - Utils.height(this.viewportTopR, viewportTopH); - } - } - - // bottom-frozen row band (simultaneous mode): pinned below the shrunk body pane - if (simultaneousBands) { - const bfH = g.frozenBottomRowsHeight ?? 0; - const bfTop = this.paneTopL.offsetTop + paneTopH + paneBottomH; - - Utils.setStyleSize(this.paneBottomFrozenL, 'top', bfTop); - Utils.height(this.paneBottomFrozenL, bfH); - Utils.height(this.viewportBottomFrozenL, bfH); - Utils.height(this.canvasBottomFrozenL, bfH); - - if (this.hasFrozenColumns()) { - Utils.setStyleSize(this.paneBottomFrozenR, 'top', bfTop); - Utils.height(this.paneBottomFrozenR, bfH); - Utils.height(this.viewportBottomFrozenR, bfH); - Utils.height(this.canvasBottomFrozenR, bfH); - } - - if (this.paneBottomFrozenRF) { - Utils.setStyleSize(this.paneBottomFrozenRF, 'top', bfTop); - Utils.height(this.paneBottomFrozenRF, bfH); - Utils.height(this.viewportBottomFrozenRF, bfH); - Utils.height(this.canvasBottomFrozenRF, bfH); - } - } - - // right-frozen band: mirror the classic right-pane vertical geometry - if (this.bands.frozenRightCols > 0 && this.paneHeaderRF) { - let topHeightOffsetRF = Utils.height(this.paneHeaderL); - if (topHeightOffsetRF) { - topHeightOffsetRF += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); - } - Utils.setStyleSize(this.paneTopRF, 'top', topHeightOffsetRF as number); - Utils.height(this.paneTopRF, paneTopH); - Utils.height(this.viewportTopRF, viewportTopH); - - if (this.freeze.hasFrozenRows) { - const paneBottomTopRF = this.paneTopL.offsetTop + paneTopH; - Utils.setStyleSize(this.paneBottomRF, 'top', paneBottomTopRF); - Utils.height(this.paneBottomRF, paneBottomH); - Utils.height(this.viewportBottomRF, paneBottomH); - - if (this.freeze.frozenBottom) { - Utils.height(this.canvasBottomRF, g.frozenRowsHeight); - } else { - Utils.height(this.canvasTopRF, g.frozenRowsHeight); - } - } - } - - return { paneTopH, paneBottomH, viewportTopH, viewportBottomH }; - } - - /** the current scroll-owner/follower set, refreshed by selectScrollContainers() */ - protected scrollContainers!: { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; }; - - /** - * Attaches one rendered row (left fragment + right fragment when columns are frozen) - * to the canvases owned by the row's band, returning the rowNode array for the grid's - * rowsCache (or null if the expected fragments are missing). - * - * NOTE: the band threshold here is `rowIdx >= actualFrozenRow` — deliberately WITHOUT - * the `+ (frozenBottom ? 0 : 1)` adjustment used by paneCellIndex(); the historical - * render-side and cell-lookup-side splits differ by one row in the non-frozenBottom - * case, and that asymmetry is preserved verbatim. - */ - attachRow(rowIdx: number, left: HTMLElement | null, right: HTMLElement | null, rightFrozen?: HTMLElement | null): HTMLElement[] | null { - let attached: HTMLElement[] | null = null; - const isBFBand = this.isRowInBottomFrozenBand(rowIdx); - const isBottomBand = !isBFBand && (this.freeze.hasFrozenRows) && (rowIdx >= this.freeze.actualFrozenRow); - - if (isBFBand) { - if (this.hasFrozenColumns()) { - if (left && right) { - this.canvasBottomFrozenL.appendChild(left); - this.canvasBottomFrozenR.appendChild(right); - attached = [left, right]; - } - } else if (left) { - this.canvasBottomFrozenL.appendChild(left); - attached = [left]; - } - } else if (isBottomBand) { - if (this.hasFrozenColumns()) { - if (left && right) { - this.canvasBottomL.appendChild(left); - this.canvasBottomR.appendChild(right); - attached = [left, right]; - } - } else if (left) { - this.canvasBottomL.appendChild(left); - attached = [left]; - } - } else if (this.hasFrozenColumns()) { - if (left && right) { - this.canvasTopL.appendChild(left); - this.canvasTopR.appendChild(right); - attached = [left, right]; - } - } else if (left) { - this.canvasTopL.appendChild(left); - attached = [left]; - } - - // right-frozen fragment always sits LAST in the rowNode array - const rfTargetCanvas = isBFBand ? this.canvasBottomFrozenRF : (isBottomBand ? this.canvasBottomRF : this.canvasTopRF); - if (attached && this.bands.frozenRightCols > 0 && rfTargetCanvas && rightFrozen) { - rfTargetCanvas.appendChild(rightFrozen); - attached.push(rightFrozen); - } - - return attached; - } - - /** Applies an X scroll position to the scroll-owner viewport and every horizontal follower. */ - syncHorizontalScroll(x: number, o: { createFooterRow?: boolean; createPreHeaderPanel?: boolean; }) { - this.scrollContainers.x.scrollLeft = x; - this.scrollContainers.header.scrollLeft = x; - this.topPanelScrollers[0].scrollLeft = x; - if (o.createFooterRow) { - this.scrollContainers.footerRow.scrollLeft = x; - } - if (o.createPreHeaderPanel) { - if (this.hasFrozenColumns()) { - this.preHeaderPanelScrollerR.scrollLeft = x; - } else { - this.preHeaderPanelScroller.scrollLeft = x; - } - } - - if (this.hasFrozenColumns()) { - if (this.freeze.hasFrozenRows) { - this.viewportTopR.scrollLeft = x; - } - this.headerRowScrollerR.scrollLeft = x; // right header row scrolling with frozen grid - } else { - if (this.freeze.hasFrozenRows) { - this.viewportTopL.scrollLeft = x; - } - this.headerRowScrollerL.scrollLeft = x; // left header row scrolling with regular grid - } - - // the bottom-frozen band's scrollable-column viewport follows X like the - // frozen-top viewports do - if (this.hasBottomFrozenBand()) { - (this.hasFrozenColumns() ? this.viewportBottomFrozenR : this.viewportBottomFrozenL).scrollLeft = x; - } - } - - /** Mirrors the Y scroll position onto the frozen-band viewports that follow the scroll owner. */ - syncVerticalFollowers(scrollTop: number) { - if (this.hasFrozenColumns()) { - if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { - this.viewportBottomL.scrollTop = scrollTop; - } else { - this.viewportTopL.scrollTop = scrollTop; - } - } - - // the right-frozen band's scrollable-body viewport follows Y the same way - if (this.bands.frozenRightCols > 0 && this.viewportTopRF) { - if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { - this.viewportBottomRF.scrollTop = scrollTop; - } else { - this.viewportTopRF.scrollTop = scrollTop; - } - } - } - - selectScrollContainers(): { x: HTMLDivElement; y: HTMLDivElement; header: HTMLDivElement; headerRow: HTMLDivElement; footerRow: HTMLDivElement; } { - let x: HTMLDivElement; - let y: HTMLDivElement; - let header: HTMLDivElement; - let headerRow: HTMLDivElement; - let footerRow: HTMLDivElement; - - if (this.hasFrozenColumns()) { - header = this.headerScrollerR; - headerRow = this.headerRowScrollerR; - footerRow = this.footerRowScrollerR; - - if (this.freeze.hasFrozenRows) { - if (this.freeze.frozenBottom) { - x = this.viewportBottomR; - y = this.viewportTopR; - } else { - x = y = this.viewportBottomR; - } - } else { - x = y = this.viewportTopR; - } - } else { - header = this.headerScrollerL; - headerRow = this.headerRowScrollerL; - footerRow = this.footerRowScrollerL; - - if (this.freeze.hasFrozenRows) { - if (this.freeze.frozenBottom) { - x = this.viewportBottomL; - y = this.viewportTopL; - } else { - x = y = this.viewportBottomL; - } - } else { - x = y = this.viewportTopL; - } - } - - this.scrollContainers = { x, y, header, headerRow, footerRow }; - return this.scrollContainers; - } -} - export class SlickGrid = Column, O extends BaseGridOption = BaseGridOption> { ////////////////////////////////////////////////////////////////////////////////////////////// // Public API @@ -1896,7 +387,7 @@ export class SlickGrid = Column, O e protected dragReplaceEl = new DragExtendHandle(this.uid); protected _focusSink!: HTMLDivElement; protected _focusSink2!: HTMLDivElement; - protected _viewportMgr!: ViewportMgr; + protected _viewportMgr!: ViewportMgr_; /** * True once finishInitialization has run its array-wide bindPaneEvents pass. * Band materializers bind their new elements only AFTER this point; during the From e452113c1b2f49b01d855b00422460a5217b7119 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Thu, 16 Jul 2026 18:04:25 +0930 Subject: [PATCH 28/43] feat: stamp band-truth data attributes on panes/viewports/canvases (M18a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the dual-labelling scheme (BAND-LABELLING.md): the historical positional css classes stay untouched as a fixed legacy skin, and every pane/viewport/canvas that currently participates in the layout carries data-colband (left | main | right-frozen) and data-rowband (header | top-frozen | body | bottom-frozen) attributes stating its CURRENT role. Inactive elements carry no markers, so [data-colband=main] etc. uniquely select live elements with no mode logic. Markers refresh on every freeze application (applyBandMarkers, called from applyPaneVisibility — the same trigger as the dynamic frozen class), covering init-time and runtime changes. This classifier becomes the single source of truth for the M18 pane-matrix loops, so the legacy-name mapping cannot drift from the internal band model. Six new spec assertions pin the markers across non-frozen, frozen-both, right-frozen and simultaneous configurations. Full suite verified green BEFORE this commit: 636 tests, 635 pass / 1 pending. Co-Authored-By: Claude Fable 5 --- cypress/e2e/dom-shape-characterization.cy.ts | 28 ++++++++++ .../e2e/viewportmgr-bottom-frozen-band.cy.ts | 9 +++ .../e2e/viewportmgr-right-frozen-band.cy.ts | 8 +++ src/slick.core.ts | 56 +++++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/cypress/e2e/dom-shape-characterization.cy.ts b/cypress/e2e/dom-shape-characterization.cy.ts index 8c65b7d7..b0246b10 100644 --- a/cypress/e2e/dom-shape-characterization.cy.ts +++ b/cypress/e2e/dom-shape-characterization.cy.ts @@ -107,6 +107,20 @@ describe('DOM shape characterization - non-frozen grid (example1-simple)', () => it('should render all rows into the top-left canvas only', () => { assertRowRouting({ topL: true, topR: false, bottomL: false, bottomR: false }); }); + + it('should stamp band-truth markers: left elements are the live main/body band, right panes unmarked (M18a)', () => { + cy.get('#myGrid .slick-pane-top.slick-pane-left') + .should('have.attr', 'data-colband', 'main') + .and('have.attr', 'data-rowband', 'body'); + cy.get('#myGrid .grid-canvas-top.grid-canvas-left') + .should('have.attr', 'data-colband', 'main') + .and('have.attr', 'data-rowband', 'body'); + cy.get('#myGrid .slick-pane-header.slick-pane-left').should('have.attr', 'data-rowband', 'header'); + cy.get('#myGrid .slick-pane-header.slick-pane-right').should('not.have.attr', 'data-colband'); + cy.get('#myGrid .slick-pane-bottom.slick-pane-left').should('not.have.attr', 'data-rowband'); + // the live body canvas is uniquely selectable without mode logic + cy.get('#myGrid .grid-canvas[data-colband="main"][data-rowband="body"]').should('have.length', 1); + }); }); describe('DOM shape characterization - frozen columns only (example-frozen-columns)', () => { @@ -161,4 +175,18 @@ describe('DOM shape characterization - frozen columns and rows (example-frozen-c it('should render rows into all four canvases', () => { assertRowRouting({ topL: true, topR: true, bottomL: true, bottomR: true }); }); + + it('should stamp band-truth markers per role in the frozen-both configuration (M18a)', () => { + cy.get('#myGrid .slick-pane-top.slick-pane-left') + .should('have.attr', 'data-colband', 'left') + .and('have.attr', 'data-rowband', 'top-frozen'); + cy.get('#myGrid .slick-pane-top.slick-pane-right') + .should('have.attr', 'data-colband', 'main') + .and('have.attr', 'data-rowband', 'top-frozen'); + cy.get('#myGrid .slick-pane-bottom.slick-pane-right') + .should('have.attr', 'data-colband', 'main') + .and('have.attr', 'data-rowband', 'body'); + // exactly one live main/body canvas, mode-independently + cy.get('#myGrid .grid-canvas[data-colband="main"][data-rowband="body"]').should('have.length', 1); + }); }); diff --git a/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts index b145b872..451ad8c3 100644 --- a/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts +++ b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts @@ -49,6 +49,15 @@ describe('bottom-frozen band DOM - frozenRow + frozenBottomRow at init (example- }); }); + it('should stamp three-row-band markers in simultaneous mode (M18a)', () => { + cy.get('#myGrid .slick-pane-top.slick-pane-left').should('have.attr', 'data-rowband', 'top-frozen'); + cy.get('#myGrid .slick-pane-bottom.slick-pane-left').should('have.attr', 'data-rowband', 'body'); + cy.get('#myGrid .slick-pane-bottom-frozen.slick-pane-left') + .should('have.attr', 'data-rowband', 'bottom-frozen') + .and('have.attr', 'data-colband', 'main'); + cy.get('#myGrid .grid-canvas[data-rowband="bottom-frozen"]').should('have.length', 1); + }); + it('should route rows into all three row bands (M14d routing)', () => { // top band: 3 frozen rows; body: scrollable middle; bottom band: last 2 rows cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').should('have.length', 3); diff --git a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts index 5a66f743..33373f6f 100644 --- a/cypress/e2e/viewportmgr-right-frozen-band.cy.ts +++ b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts @@ -49,6 +49,14 @@ describe('right-frozen band DOM - frozenRightColumn at init (example-frozen-righ cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen > .slick-top-panel-scroller').should('have.length', 1); }); + it('should stamp right-frozen band markers, with the middle band as main (M18a)', () => { + cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen') + .should('have.attr', 'data-colband', 'right-frozen') + .and('have.attr', 'data-rowband', 'body'); + cy.get('#myGrid .slick-pane-top.slick-pane-left').should('have.attr', 'data-colband', 'main'); + cy.get('#myGrid .grid-canvas[data-colband="right-frozen"]').should('have.length', 1); + }); + it('should show the right-frozen header and top panes, and hide its bottom pane (no frozen rows)', () => { cy.get('#myGrid .slick-pane-header.slick-pane-right-frozen').should('be.visible'); cy.get('#myGrid .slick-pane-top.slick-pane-right-frozen').should('exist'); diff --git a/src/slick.core.ts b/src/slick.core.ts index 1d8d1bd4..f8637486 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -2129,7 +2129,63 @@ export class ViewportMgr { } /** Shows/hides the right and bottom panes according to the freeze configuration. */ + /** + * Stamps band-truth markers (see BAND-LABELLING.md): every pane/viewport/canvas + * that currently participates in the layout carries `data-colband` and + * `data-rowband` attributes stating its CURRENT role (the historical positional + * css classes are a fixed legacy skin and do not change); inactive elements carry + * no markers, so `[data-colband="main"]` etc. uniquely select live elements. + * Refreshed on every freeze application, alongside applyPaneVisibility. + */ + protected applyBandMarkers() { + const leftActive = this.bands.frozenLeftCols > 0; + const rfActive = this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; + const hasRows = this.freeze.hasFrozenRows; + const bfSimultaneous = this.hasBottomFrozenBand(); + const legacyBottomMode = hasRows && this.bands.frozenBottomRows > 0 && this.bands.frozenTopRows === 0; + + // current role of the historical sides/rows + const lCol = leftActive ? 'left' : 'main'; + const topRow = this.bands.frozenTopRows > 0 ? 'top-frozen' : 'body'; + const bottomRow = legacyBottomMode ? 'bottom-frozen' : 'body'; + + const mark = (el: HTMLElement | undefined, colband: string, rowband: string, active: boolean) => { + if (!el) { return; } + if (active) { + el.setAttribute('data-colband', colband); + el.setAttribute('data-rowband', rowband); + } else { + el.removeAttribute('data-colband'); + el.removeAttribute('data-rowband'); + } + }; + const markSet = (els: Array, colband: string, rowband: string, active: boolean) => { + els.forEach((el) => mark(el, colband, rowband, active)); + }; + + // header row band + mark(this.paneHeaderL, lCol, 'header', true); + mark(this.paneHeaderR, 'main', 'header', leftActive); + mark(this.paneHeaderRF, 'right-frozen', 'header', rfActive); + + // classic top row + markSet([this.paneTopL, this.viewportTopL, this.canvasTopL], lCol, topRow, true); + markSet([this.paneTopR, this.viewportTopR, this.canvasTopR], 'main', topRow, leftActive); + markSet([this.paneTopRF, this.viewportTopRF, this.canvasTopRF], 'right-frozen', topRow, rfActive); + + // classic bottom row (participates only while rows are frozen) + markSet([this.paneBottomL, this.viewportBottomL, this.canvasBottomL], lCol, bottomRow, hasRows); + markSet([this.paneBottomR, this.viewportBottomR, this.canvasBottomR], 'main', bottomRow, hasRows && leftActive); + markSet([this.paneBottomRF, this.viewportBottomRF, this.canvasBottomRF], 'right-frozen', bottomRow, hasRows && rfActive); + + // bottom-frozen band (simultaneous top+bottom mode only) + markSet([this.paneBottomFrozenL, this.viewportBottomFrozenL, this.canvasBottomFrozenL], lCol, 'bottom-frozen', bfSimultaneous); + markSet([this.paneBottomFrozenR, this.viewportBottomFrozenR, this.canvasBottomFrozenR], 'main', 'bottom-frozen', bfSimultaneous && leftActive); + markSet([this.paneBottomFrozenRF, this.viewportBottomFrozenRF, this.canvasBottomFrozenRF], 'right-frozen', 'bottom-frozen', bfSimultaneous && rfActive); + } + applyPaneVisibility() { + this.applyBandMarkers(); if (this.hasFrozenColumns()) { this.showIf(this.paneHeaderR); this.showIf(this.paneTopR); From 57afad6904d5c14fafbe61be574343fa8daff998 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Fri, 17 Jul 2026 11:56:35 +0930 Subject: [PATCH 29/43] refactor: pane matrix with table-driven construction (M18b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four creation paths (classic build, lazy secondary materialization, the right-frozen band, the bottom-frozen band and its shared corner) now flow through one buildPaneSet primitive driven by the structural (row x column) pane matrix and the legacy-class tables; shared element arrays rebuild IN PLACE in canonical order via syncElementArrays (strengthening the shared-array identity invariant — the array objects now never change), which also derives the dynamic-band slot registry. The 60 named element fields become one-line compat getters over the matrix with identical runtime semantics (undefined until built), so every consumer — grid aliases, geometry, visibility, markers — compiled unchanged. Preserved verbatim: pane sibling order, per-pane child order, the left pre-header anonymous leading div, footer R-before-L creation and its init-vs-materialization spacer-width difference, hide-on-create option handling, and lazy/materialized insert positions. Net -133 lines (+277/-410). DOM byte-identical: all five DOM-shape suites passed first-run against the matrix build; full suite verified green BEFORE this commit (636 tests, 635 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 687 +++++++++++++++++++--------------------------- 1 file changed, 277 insertions(+), 410 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index f8637486..960f718e 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -1340,7 +1340,106 @@ export const SlickGlobalEditorLock = new SlickEditorLock(); * aliases to every element, so all existing logic is unchanged. Later phases move pane * selection, geometry distribution and scroll synchronization in here. */ +/** Structural column key of a pane set (historical sides, NOT semantic bands — see BAND-LABELLING.md). */ +export type PaneColKey = 'l' | 'r' | 'rf'; +/** Structural row key of a pane set. */ +export type PaneRowKey = 'header' | 'top' | 'bottom' | 'bf'; + +/** The elements of one pane cell in the (row × column) pane matrix. */ +export interface PaneSet { + pane: HTMLDivElement; + viewport?: HTMLDivElement; + canvas?: HTMLDivElement; + headerScroller?: HTMLDivElement; + header?: HTMLDivElement; + headerRowScroller?: HTMLDivElement; + headerRowSpacer?: HTMLDivElement; + headerRow?: HTMLDivElement; + topPanelScroller?: HTMLDivElement; + topPanel?: HTMLDivElement; + footerRowScroller?: HTMLDivElement; + footerRowSpacer?: HTMLDivElement; + footerRow?: HTMLDivElement; + preHeaderScroller?: HTMLDivElement; + preHeader?: HTMLDivElement; + preHeaderSpacer?: HTMLDivElement; +} + +/** css suffix of each structural column (the fixed legacy skin). */ +const PANE_COL_CSS: Record = { l: 'left', r: 'right', rf: 'right-frozen' }; +/** css suffix of each structural row. */ +const PANE_ROW_CSS: Record = { header: 'header', top: 'top', bottom: 'bottom', bf: 'bottom-frozen' }; + export class ViewportMgr { + // named-element compat getters over the pane matrix (M18b): same runtime + // semantics as the historical definite-assignment fields (undefined until built) + get paneHeaderL(): HTMLDivElement { return this.paneAt('header', 'l')?.pane as HTMLDivElement; } + get paneHeaderR(): HTMLDivElement { return this.paneAt('header', 'r')?.pane as HTMLDivElement; } + get paneHeaderRF(): HTMLDivElement { return this.paneAt('header', 'rf')?.pane as HTMLDivElement; } + get paneTopL(): HTMLDivElement { return this.paneAt('top', 'l')?.pane as HTMLDivElement; } + get paneTopR(): HTMLDivElement { return this.paneAt('top', 'r')?.pane as HTMLDivElement; } + get paneTopRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.pane as HTMLDivElement; } + get paneBottomL(): HTMLDivElement { return this.paneAt('bottom', 'l')?.pane as HTMLDivElement; } + get paneBottomR(): HTMLDivElement { return this.paneAt('bottom', 'r')?.pane as HTMLDivElement; } + get paneBottomRF(): HTMLDivElement { return this.paneAt('bottom', 'rf')?.pane as HTMLDivElement; } + get paneBottomFrozenL(): HTMLDivElement { return this.paneAt('bf', 'l')?.pane as HTMLDivElement; } + get paneBottomFrozenR(): HTMLDivElement { return this.paneAt('bf', 'r')?.pane as HTMLDivElement; } + get paneBottomFrozenRF(): HTMLDivElement { return this.paneAt('bf', 'rf')?.pane as HTMLDivElement; } + get preHeaderPanelScroller(): HTMLDivElement { return this.paneAt('header', 'l')?.preHeaderScroller as HTMLDivElement; } + get preHeaderPanel(): HTMLDivElement { return this.paneAt('header', 'l')?.preHeader as HTMLDivElement; } + get preHeaderPanelSpacer(): HTMLDivElement { return this.paneAt('header', 'l')?.preHeaderSpacer as HTMLDivElement; } + get preHeaderPanelScrollerR(): HTMLDivElement { return this.paneAt('header', 'r')?.preHeaderScroller as HTMLDivElement; } + get preHeaderPanelR(): HTMLDivElement { return this.paneAt('header', 'r')?.preHeader as HTMLDivElement; } + get preHeaderPanelSpacerR(): HTMLDivElement { return this.paneAt('header', 'r')?.preHeaderSpacer as HTMLDivElement; } + get headerScrollerL(): HTMLDivElement { return this.paneAt('header', 'l')?.headerScroller as HTMLDivElement; } + get headerScrollerR(): HTMLDivElement { return this.paneAt('header', 'r')?.headerScroller as HTMLDivElement; } + get headerScrollerRF(): HTMLDivElement { return this.paneAt('header', 'rf')?.headerScroller as HTMLDivElement; } + get headerL(): HTMLDivElement { return this.paneAt('header', 'l')?.header as HTMLDivElement; } + get headerR(): HTMLDivElement { return this.paneAt('header', 'r')?.header as HTMLDivElement; } + get headerRF(): HTMLDivElement { return this.paneAt('header', 'rf')?.header as HTMLDivElement; } + get headerRowScrollerL(): HTMLDivElement { return this.paneAt('top', 'l')?.headerRowScroller as HTMLDivElement; } + get headerRowScrollerR(): HTMLDivElement { return this.paneAt('top', 'r')?.headerRowScroller as HTMLDivElement; } + get headerRowScrollerRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.headerRowScroller as HTMLDivElement; } + get headerRowSpacerL(): HTMLDivElement { return this.paneAt('top', 'l')?.headerRowSpacer as HTMLDivElement; } + get headerRowSpacerR(): HTMLDivElement { return this.paneAt('top', 'r')?.headerRowSpacer as HTMLDivElement; } + get headerRowSpacerRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.headerRowSpacer as HTMLDivElement; } + get headerRowL(): HTMLDivElement { return this.paneAt('top', 'l')?.headerRow as HTMLDivElement; } + get headerRowR(): HTMLDivElement { return this.paneAt('top', 'r')?.headerRow as HTMLDivElement; } + get headerRowRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.headerRow as HTMLDivElement; } + get topPanelScrollerL(): HTMLDivElement { return this.paneAt('top', 'l')?.topPanelScroller as HTMLDivElement; } + get topPanelScrollerR(): HTMLDivElement { return this.paneAt('top', 'r')?.topPanelScroller as HTMLDivElement; } + get topPanelScrollerRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.topPanelScroller as HTMLDivElement; } + get topPanelL(): HTMLDivElement { return this.paneAt('top', 'l')?.topPanel as HTMLDivElement; } + get topPanelR(): HTMLDivElement { return this.paneAt('top', 'r')?.topPanel as HTMLDivElement; } + get topPanelRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.topPanel as HTMLDivElement; } + get viewportTopL(): HTMLDivElement { return this.paneAt('top', 'l')?.viewport as HTMLDivElement; } + get viewportTopR(): HTMLDivElement { return this.paneAt('top', 'r')?.viewport as HTMLDivElement; } + get viewportTopRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.viewport as HTMLDivElement; } + get viewportBottomL(): HTMLDivElement { return this.paneAt('bottom', 'l')?.viewport as HTMLDivElement; } + get viewportBottomR(): HTMLDivElement { return this.paneAt('bottom', 'r')?.viewport as HTMLDivElement; } + get viewportBottomRF(): HTMLDivElement { return this.paneAt('bottom', 'rf')?.viewport as HTMLDivElement; } + get viewportBottomFrozenL(): HTMLDivElement { return this.paneAt('bf', 'l')?.viewport as HTMLDivElement; } + get viewportBottomFrozenR(): HTMLDivElement { return this.paneAt('bf', 'r')?.viewport as HTMLDivElement; } + get viewportBottomFrozenRF(): HTMLDivElement { return this.paneAt('bf', 'rf')?.viewport as HTMLDivElement; } + get canvasTopL(): HTMLDivElement { return this.paneAt('top', 'l')?.canvas as HTMLDivElement; } + get canvasTopR(): HTMLDivElement { return this.paneAt('top', 'r')?.canvas as HTMLDivElement; } + get canvasTopRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.canvas as HTMLDivElement; } + get canvasBottomL(): HTMLDivElement { return this.paneAt('bottom', 'l')?.canvas as HTMLDivElement; } + get canvasBottomR(): HTMLDivElement { return this.paneAt('bottom', 'r')?.canvas as HTMLDivElement; } + get canvasBottomRF(): HTMLDivElement { return this.paneAt('bottom', 'rf')?.canvas as HTMLDivElement; } + get canvasBottomFrozenL(): HTMLDivElement { return this.paneAt('bf', 'l')?.canvas as HTMLDivElement; } + get canvasBottomFrozenR(): HTMLDivElement { return this.paneAt('bf', 'r')?.canvas as HTMLDivElement; } + get canvasBottomFrozenRF(): HTMLDivElement { return this.paneAt('bf', 'rf')?.canvas as HTMLDivElement; } + get footerRowScrollerL(): HTMLDivElement { return this.paneAt('top', 'l')?.footerRowScroller as HTMLDivElement; } + get footerRowScrollerR(): HTMLDivElement { return this.paneAt('top', 'r')?.footerRowScroller as HTMLDivElement; } + get footerRowScrollerRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.footerRowScroller as HTMLDivElement; } + get footerRowSpacerL(): HTMLDivElement { return this.paneAt('top', 'l')?.footerRowSpacer as HTMLDivElement; } + get footerRowSpacerR(): HTMLDivElement { return this.paneAt('top', 'r')?.footerRowSpacer as HTMLDivElement; } + get footerRowSpacerRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.footerRowSpacer as HTMLDivElement; } + get footerRowL(): HTMLDivElement { return this.paneAt('top', 'l')?.footerRow as HTMLDivElement; } + get footerRowR(): HTMLDivElement { return this.paneAt('top', 'r')?.footerRow as HTMLDivElement; } + get footerRowRF(): HTMLDivElement { return this.paneAt('top', 'rf')?.footerRow as HTMLDivElement; } + /** the grid container, captured by buildPanes */ protected container!: HTMLElement; @@ -1361,98 +1460,170 @@ export class ViewportMgr { protected bfSlotR = -1; protected bfSlotRF = -1; + /** + * The pane matrix (M18): every pane cell keyed by structural (row, column) — + * the single store behind the named-element getters. Cells exist only once + * built; `paneAt(row, col)` is the lookup. + */ + protected paneMatrix: Partial>>> = {}; + + paneAt(row: PaneRowKey, col: PaneColKey): PaneSet | undefined { + return this.paneMatrix[row]?.[col]; + } + + /** + * Builds one pane cell — the pane element plus its standard children for the row + * kind (header chrome / top chrome+viewport+canvas / bottom viewport+canvas) — + * with the historical class skin, and registers it in the matrix. `after` places + * the pane at a canonical sibling position for materialized bands; omitted panes + * append to the container (initial build order). + */ + protected buildPaneSet(row: PaneRowKey, col: PaneColKey, o: ViewportMgrBuildOptions, after?: HTMLElement): PaneSet { + const colCss = PANE_COL_CSS[col]; + const rowCss = PANE_ROW_CSS[row]; + + const pane = Utils.createDomElement('div', { className: `slick-pane slick-pane-${rowCss} slick-pane-${colCss}`, tabIndex: 0 }); + if (after) { + this.container.insertBefore(pane, after.nextSibling); + } else { + this.container.appendChild(pane); + } + const set: PaneSet = { pane }; + + if (row === 'header') { + if (o.createPreHeaderPanel && col !== 'rf') { + set.preHeaderScroller = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, pane); + if (col === 'l') { + // historical: the left pre-header scroller carries a leading anonymous div + set.preHeaderScroller.appendChild(document.createElement('div')); + } + set.preHeader = Utils.createDomElement('div', null, set.preHeaderScroller); + set.preHeaderSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, set.preHeaderScroller); + if (!o.showPreHeaderPanel) { + Utils.hide(set.preHeaderScroller); + } + } + set.headerScroller = Utils.createDomElement('div', { className: `slick-header ui-state-default slick-state-default slick-header-${colCss}` }, pane); + set.header = Utils.createDomElement('div', { className: `slick-header-columns slick-header-columns-${colCss}`, role: 'row', style: { left: '-1000px' } }, set.headerScroller); + if (!o.showColumnHeader) { + Utils.hide(set.headerScroller); + } + } else if (row === 'top') { + set.headerRowScroller = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, pane); + set.headerRowSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, set.headerRowScroller); + set.headerRow = Utils.createDomElement('div', { className: `slick-headerrow-columns slick-headerrow-columns-${colCss}`, }, set.headerRowScroller); + if (!o.showHeaderRow) { + Utils.hide(set.headerRowScroller); + } + set.topPanelScroller = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, pane); + set.topPanel = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, set.topPanelScroller); + if (!o.showTopPanel) { + Utils.hide(set.topPanelScroller); + } + this.addViewportAndCanvas(set, 'top', colCss, o); + // the footer-row chrome is appended after the viewport by buildFooterRowFor + // (historically created later, in R-before-L order) + } else { + this.addViewportAndCanvas(set, rowCss, colCss, o); + } + + (this.paneMatrix[row] ??= {})[col] = set; + return set; + } + + /** Adds the viewport+canvas pair of a pane cell (all rows except the header row). */ + protected addViewportAndCanvas(set: PaneSet, rowCss: string, colCss: string, o: ViewportMgrBuildOptions) { + set.viewport = Utils.createDomElement('div', { className: `slick-viewport slick-viewport-${rowCss} slick-viewport-${colCss}`, tabIndex: 0 }, set.pane); + if (o.viewportClass) { + set.viewport.classList.add(...Utils.classNameToList(o.viewportClass)); + } + set.canvas = Utils.createDomElement('div', { className: `grid-canvas grid-canvas-${rowCss} grid-canvas-${colCss}`, tabIndex: 0 }, set.viewport); + } + + /** Adds the footer-row chrome to an existing top-row pane cell (historical order and widths). */ + protected buildFooterRowFor(col: PaneColKey, o: ViewportMgrBuildOptions, canvasWithScrollbarWidth?: number) { + const set = this.paneAt('top', col); + if (!set || set.footerRowScroller) { + return; + } + set.footerRowScroller = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, set.pane); + set.footerRowSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, set.footerRowScroller); + if (canvasWithScrollbarWidth !== undefined) { + // init-time path sets the spacer width immediately; materialization paths leave + // it to the updateCanvasWidth that follows (historical behaviour of both) + Utils.width(set.footerRowSpacer, canvasWithScrollbarWidth); + } + set.footerRow = Utils.createDomElement('div', { className: `slick-footerrow-columns slick-footerrow-columns-${PANE_COL_CSS[col]}` }, set.footerRowScroller); + if (!o.showFooterRow) { + Utils.hide(set.footerRowScroller); + } + } + + /** + * Rebuilds the shared element arrays IN PLACE from the matrix in canonical + * (historical) order, and refreshes the dynamic-band slot registry. The array + * object identities are part of the grid contract and never change. + */ + protected syncElementArrays() { + const fill = (arr: HTMLDivElement[], els: Array) => { + arr.length = 0; + els.forEach((el) => { if (el) { arr.push(el); } }); + }; + const cell = (row: PaneRowKey, col: PaneColKey) => this.paneAt(row, col); + // canonical order preserves the historical array layout: classic four first, + // then the right-frozen pair, then the bottom-frozen row + const vpOrder: Array = [ + cell('top', 'l'), cell('top', 'r'), cell('bottom', 'l'), cell('bottom', 'r'), + cell('top', 'rf'), cell('bottom', 'rf'), + cell('bf', 'l'), cell('bf', 'r'), cell('bf', 'rf'), + ]; + fill(this.viewport, vpOrder.map((s) => s?.viewport)); + fill(this.canvas, vpOrder.map((s) => s?.canvas)); + + const colOrder: PaneColKey[] = ['l', 'r', 'rf']; + fill(this.headerScroller, colOrder.map((c) => cell('header', c)?.headerScroller)); + fill(this.headers, colOrder.map((c) => cell('header', c)?.header)); + fill(this.headerRowScroller, colOrder.map((c) => cell('top', c)?.headerRowScroller)); + fill(this.headerRows, colOrder.map((c) => cell('top', c)?.headerRow)); + fill(this.topPanelScrollers, colOrder.map((c) => cell('top', c)?.topPanelScroller)); + fill(this.topPanels, colOrder.map((c) => cell('top', c)?.topPanel)); + fill(this.footerRowScroller, colOrder.map((c) => cell('top', c)?.footerRowScroller)); + fill(this.footerRow, colOrder.map((c) => cell('top', c)?.footerRow)); + + // dynamic-band slots (index into the viewport/canvas arrays) + this.rfTopSlot = this.canvas.indexOf(cell('top', 'rf')?.canvas as HTMLDivElement); + this.rfBottomSlot = this.canvas.indexOf(cell('bottom', 'rf')?.canvas as HTMLDivElement); + this.bfSlotL = this.canvas.indexOf(cell('bf', 'l')?.canvas as HTMLDivElement); + this.bfSlotR = this.canvas.indexOf(cell('bf', 'r')?.canvas as HTMLDivElement); + this.bfSlotRF = this.canvas.indexOf(cell('bf', 'rf')?.canvas as HTMLDivElement); + } + // panes - paneHeaderL!: HTMLDivElement; - paneHeaderR!: HTMLDivElement; - paneTopL!: HTMLDivElement; - paneTopR!: HTMLDivElement; - paneBottomL!: HTMLDivElement; - paneBottomR!: HTMLDivElement; // pre-header panels (only when createPreHeaderPanel) - preHeaderPanelScroller!: HTMLDivElement; - preHeaderPanel!: HTMLDivElement; - preHeaderPanelSpacer!: HTMLDivElement; - preHeaderPanelScrollerR!: HTMLDivElement; - preHeaderPanelR!: HTMLDivElement; - preHeaderPanelSpacerR!: HTMLDivElement; // header scrollers and header column containers - headerScrollerL!: HTMLDivElement; - headerScrollerR!: HTMLDivElement; headerScroller: HTMLDivElement[] = []; - headerL!: HTMLDivElement; - headerR!: HTMLDivElement; headers: HTMLDivElement[] = []; // header rows - headerRowScrollerL!: HTMLDivElement; - headerRowScrollerR!: HTMLDivElement; headerRowScroller: HTMLDivElement[] = []; - headerRowSpacerL!: HTMLDivElement; - headerRowSpacerR!: HTMLDivElement; - headerRowL!: HTMLDivElement; - headerRowR!: HTMLDivElement; headerRows: HTMLDivElement[] = []; // top panels - topPanelScrollerL!: HTMLDivElement; - topPanelScrollerR!: HTMLDivElement; topPanelScrollers: HTMLDivElement[] = []; - topPanelL!: HTMLDivElement; - topPanelR!: HTMLDivElement; topPanels: HTMLDivElement[] = []; // viewports and canvases - viewportTopL!: HTMLDivElement; - viewportTopR!: HTMLDivElement; - viewportBottomL!: HTMLDivElement; - viewportBottomR!: HTMLDivElement; viewport: HTMLDivElement[] = []; - canvasTopL!: HTMLDivElement; - canvasTopR!: HTMLDivElement; - canvasBottomL!: HTMLDivElement; - canvasBottomR!: HTMLDivElement; canvas: HTMLDivElement[] = []; // right-frozen band (Phase 4 — exists only while frozenRightColumn > 0 has been applied) - paneHeaderRF!: HTMLDivElement; - paneTopRF!: HTMLDivElement; - paneBottomRF!: HTMLDivElement; - headerScrollerRF!: HTMLDivElement; - headerRF!: HTMLDivElement; - headerRowScrollerRF!: HTMLDivElement; - headerRowSpacerRF!: HTMLDivElement; - headerRowRF!: HTMLDivElement; - topPanelScrollerRF!: HTMLDivElement; - topPanelRF!: HTMLDivElement; - viewportTopRF!: HTMLDivElement; - viewportBottomRF!: HTMLDivElement; - canvasTopRF!: HTMLDivElement; - canvasBottomRF!: HTMLDivElement; - footerRowScrollerRF!: HTMLDivElement; - footerRowSpacerRF!: HTMLDivElement; - footerRowRF!: HTMLDivElement; // bottom-frozen row band (Phase 4 — exists only in simultaneous top+bottom mode) - paneBottomFrozenL!: HTMLDivElement; - paneBottomFrozenR!: HTMLDivElement; - paneBottomFrozenRF!: HTMLDivElement; - viewportBottomFrozenL!: HTMLDivElement; - viewportBottomFrozenR!: HTMLDivElement; - viewportBottomFrozenRF!: HTMLDivElement; - canvasBottomFrozenL!: HTMLDivElement; - canvasBottomFrozenR!: HTMLDivElement; - canvasBottomFrozenRF!: HTMLDivElement; // footer rows (only when createFooterRow) - footerRowScrollerL!: HTMLDivElement; - footerRowScrollerR!: HTMLDivElement; footerRowScroller: HTMLDivElement[] = []; - footerRowSpacerL!: HTMLDivElement; - footerRowSpacerR!: HTMLDivElement; - footerRowL!: HTMLDivElement; - footerRowR!: HTMLDivElement; footerRow: HTMLDivElement[] = []; /** @@ -1464,143 +1635,18 @@ export class ViewportMgr { this.container = container; this.lazy = !!o.lazyPanes && !(((o.frozenColumn ?? -1) > -1) || ((o.frozenRow ?? -1) > -1)); - // Containers used for scrolling frozen columns and rows. - // Under lazyPanes with nothing frozen, only the top-left pane set is built; - // the creation ORDER of the conditional elements must stay canonical so both - // modes produce the same sibling sequence for whatever exists. - this.paneHeaderL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-left', tabIndex: 0 }, container); - if (!this.lazy) { - this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }, container); - } - this.paneTopL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-left', tabIndex: 0 }, container); - if (!this.lazy) { - this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }, container); - this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }, container); - this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }, container); - } - - if (o.createPreHeaderPanel) { - this.preHeaderPanelScroller = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderL); - this.preHeaderPanelScroller.appendChild(document.createElement('div')); - this.preHeaderPanel = Utils.createDomElement('div', null, this.preHeaderPanelScroller); - this.preHeaderPanelSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScroller); - - if (!this.lazy) { - this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); - this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); - this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); - } - - if (!o.showPreHeaderPanel) { - Utils.hide(this.preHeaderPanelScroller); - this.hideIf(this.preHeaderPanelScrollerR); - } - } - - // Append the header scroller containers - this.headerScrollerL = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-left' }, this.paneHeaderL); - if (!this.lazy) { - this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); - } - - // Cache the header scroller containers - this.headerScroller.push(this.headerScrollerL); - if (!this.lazy) { - this.headerScroller.push(this.headerScrollerR); - } - - // Append the columnn containers to the headers - this.headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { left: '-1000px' } }, this.headerScrollerL); - if (!this.lazy) { - this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); - } - - // Cache the header columns - this.headers = this.lazy ? [this.headerL] : [this.headerL, this.headerR]; - - this.headerRowScrollerL = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopL); - if (!this.lazy) { - this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); - } - - this.headerRowScroller = this.lazy ? [this.headerRowScrollerL] : [this.headerRowScrollerL, this.headerRowScrollerR]; - - this.headerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerL); - if (!this.lazy) { - this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); - } - - this.headerRowL = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }, this.headerRowScrollerL); - if (!this.lazy) { - this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); - } - - this.headerRows = this.lazy ? [this.headerRowL] : [this.headerRowL, this.headerRowR]; - - // Append the top panel scroller - this.topPanelScrollerL = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopL); + // historical sibling order: headerL, [headerR], topL, [topR, bottomL, bottomR] + this.buildPaneSet('header', 'l', o); if (!this.lazy) { - this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); + this.buildPaneSet('header', 'r', o); } - - this.topPanelScrollers = this.lazy ? [this.topPanelScrollerL] : [this.topPanelScrollerL, this.topPanelScrollerR]; - - // Append the top panel - this.topPanelL = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerL); + this.buildPaneSet('top', 'l', o); if (!this.lazy) { - this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); - } - - this.topPanels = this.lazy ? [this.topPanelL] : [this.topPanelL, this.topPanelR]; - - if (!o.showColumnHeader) { - this.headerScroller.forEach((el) => { - Utils.hide(el); - }); + this.buildPaneSet('top', 'r', o); + this.buildPaneSet('bottom', 'l', o); + this.buildPaneSet('bottom', 'r', o); } - - if (!o.showTopPanel) { - this.topPanelScrollers.forEach((scroller) => { - Utils.hide(scroller); - }); - } - - if (!o.showHeaderRow) { - this.headerRowScroller.forEach((scroller) => { - Utils.hide(scroller); - }); - } - - // Append the viewport containers - this.viewportTopL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-left', tabIndex: 0 }, this.paneTopL); - if (!this.lazy) { - this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); - this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); - this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); - } - - // Cache the viewports - this.viewport = this.lazy - ? [this.viewportTopL] - : [this.viewportTopL, this.viewportTopR, this.viewportBottomL, this.viewportBottomR]; - if (o.viewportClass) { - this.viewport.forEach((view) => { - view.classList.add(...Utils.classNameToList((o.viewportClass))); - }); - } - - // Append the canvas containers - this.canvasTopL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-left', tabIndex: 0 }, this.viewportTopL); - if (!this.lazy) { - this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); - this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); - this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); - } - - // Cache the canvases - this.canvas = this.lazy - ? [this.canvasTopL] - : [this.canvasTopL, this.canvasTopR, this.canvasBottomL, this.canvasBottomR]; + this.syncElementArrays(); } /** @@ -1609,32 +1655,12 @@ export class ViewportMgr { * scroller creation order and spacer widths. */ buildFooterRows(o: ViewportMgrBuildOptions, canvasWithScrollbarWidth: number) { + // historical creation order: right scroller before left if (!this.lazy) { - this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); - } - this.footerRowScrollerL = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopL); - - this.footerRowScroller = this.lazy ? [this.footerRowScrollerL] : [this.footerRowScrollerL, this.footerRowScrollerR]; - - this.footerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerL); - Utils.width(this.footerRowSpacerL, canvasWithScrollbarWidth); - if (!this.lazy) { - this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); - Utils.width(this.footerRowSpacerR, canvasWithScrollbarWidth); - } - - this.footerRowL = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-left' }, this.footerRowScrollerL); - if (!this.lazy) { - this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); - } - - this.footerRow = this.lazy ? [this.footerRowL] : [this.footerRowL, this.footerRowR]; - - if (!o.showFooterRow) { - this.footerRowScroller.forEach((scroller) => { - Utils.hide(scroller); - }); + this.buildFooterRowFor('r', o, canvasWithScrollbarWidth); } + this.buildFooterRowFor('l', o, canvasWithScrollbarWidth); + this.syncElementArrays(); } /** @@ -1650,88 +1676,18 @@ export class ViewportMgr { } this.lazy = false; - const container = this.container; - - // panes, at their canonical sibling positions - this.paneHeaderR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneHeaderR, this.paneTopL); - this.paneTopR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneTopR, this.paneTopL.nextSibling); - this.paneBottomL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-left', tabIndex: 0 }); - container.insertBefore(this.paneBottomL, this.paneTopR.nextSibling); - this.paneBottomR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneBottomR, this.paneBottomL.nextSibling); + // canonical sibling positions between the existing panes + const headerL = this.paneAt('header', 'l')!.pane; + const topR = this.buildPaneSet('top', 'r', o, this.paneAt('top', 'l')!.pane); + this.buildPaneSet('header', 'r', o, headerL); + const bottomL = this.buildPaneSet('bottom', 'l', o, topR.pane); + this.buildPaneSet('bottom', 'r', o, bottomL.pane); - if (o.createPreHeaderPanel) { - this.preHeaderPanelScrollerR = Utils.createDomElement('div', { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this.paneHeaderR); - this.preHeaderPanelR = Utils.createDomElement('div', null, this.preHeaderPanelScrollerR); - this.preHeaderPanelSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.preHeaderPanelScrollerR); - - if (!o.showPreHeaderPanel) { - Utils.hide(this.preHeaderPanelScrollerR); - } - } - - // header scroller + header columns - this.headerScrollerR = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right' }, this.paneHeaderR); - this.headerScroller.push(this.headerScrollerR); - this.headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this.headerScrollerR); - this.headers.push(this.headerR); - - // header row - this.headerRowScrollerR = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopR); - this.headerRowScroller.push(this.headerRowScrollerR); - this.headerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerR); - this.headerRowR = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, this.headerRowScrollerR); - this.headerRows.push(this.headerRowR); - - // top panel - this.topPanelScrollerR = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopR); - this.topPanelScrollers.push(this.topPanelScrollerR); - this.topPanelR = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerR); - this.topPanels.push(this.topPanelR); - - if (!o.showColumnHeader) { - Utils.hide(this.headerScrollerR); - } - if (!o.showTopPanel) { - Utils.hide(this.topPanelScrollerR); - } - if (!o.showHeaderRow) { - Utils.hide(this.headerRowScrollerR); - } - - // viewports (pushed in canonical [TopL, TopR, BottomL, BottomR] order) - this.viewportTopR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right', tabIndex: 0 }, this.paneTopR); - this.viewportBottomL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-left', tabIndex: 0 }, this.paneBottomL); - this.viewportBottomR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right', tabIndex: 0 }, this.paneBottomR); - this.viewport.push(this.viewportTopR, this.viewportBottomL, this.viewportBottomR); - if (o.viewportClass) { - const viewportClassList = Utils.classNameToList(o.viewportClass); - this.viewportTopR.classList.add(...viewportClassList); - this.viewportBottomL.classList.add(...viewportClassList); - this.viewportBottomR.classList.add(...viewportClassList); - } - - // canvases - this.canvasTopR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right', tabIndex: 0 }, this.viewportTopR); - this.canvasBottomL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-left', tabIndex: 0 }, this.viewportBottomL); - this.canvasBottomR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right', tabIndex: 0 }, this.viewportBottomR); - this.canvas.push(this.canvasTopR, this.canvasBottomL, this.canvasBottomR); - - // footer row (right side; the left one was built at init when createFooterRow) if (o.createFooterRow) { - this.footerRowScrollerR = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopR); - this.footerRowScroller.push(this.footerRowScrollerR); - this.footerRowSpacerR = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerR); - this.footerRowR = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right' }, this.footerRowScrollerR); - this.footerRow.push(this.footerRowR); - - if (!o.showFooterRow) { - Utils.hide(this.footerRowScrollerR); - } + // spacer width is applied by the updateCanvasWidth that follows materialization + this.buildFooterRowFor('r', o); } - + this.syncElementArrays(); return true; } @@ -1747,85 +1703,24 @@ export class ViewportMgr { * MIDDLE band while this band is active. */ materializeRightFrozenBand(o: ViewportMgrBuildOptions): boolean { - if (this.paneHeaderRF) { + if (this.paneAt('header', 'rf')) { + // band exists — but the bottom-frozen corner may have arrived after us + this.ensureBottomFrozenRightVariant(o); return false; } - const container = this.container; - - // panes — appended after the classic six (still before the trailing focus sink, - // which the grid appends after all panes) - this.paneHeaderRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-header slick-pane-right-frozen', tabIndex: 0 }); - this.paneTopRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-top slick-pane-right-frozen', tabIndex: 0 }); - this.paneBottomRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom slick-pane-right-frozen', tabIndex: 0 }); - // insert as a block after the last classic pane (paneBottomR when it exists, - // else the lazy grid's paneTopL) - const lastClassicPane = this.paneBottomR ?? this.paneTopL; - container.insertBefore(this.paneHeaderRF, lastClassicPane.nextSibling); - container.insertBefore(this.paneTopRF, this.paneHeaderRF.nextSibling); - container.insertBefore(this.paneBottomRF, this.paneTopRF.nextSibling); - - // header chrome - this.headerScrollerRF = Utils.createDomElement('div', { className: 'slick-header ui-state-default slick-state-default slick-header-right-frozen' }, this.paneHeaderRF); - this.headerScroller.push(this.headerScrollerRF); - this.headerRF = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right-frozen', role: 'row', style: { left: '-1000px' } }, this.headerScrollerRF); - this.headers.push(this.headerRF); - - // header row - this.headerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-headerrow ui-state-default slick-state-default' }, this.paneTopRF); - this.headerRowScroller.push(this.headerRowScrollerRF); - this.headerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.headerRowScrollerRF); - this.headerRowRF = Utils.createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right-frozen' }, this.headerRowScrollerRF); - this.headerRows.push(this.headerRowRF); - - // top panel - this.topPanelScrollerRF = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this.paneTopRF); - this.topPanelScrollers.push(this.topPanelScrollerRF); - this.topPanelRF = Utils.createDomElement('div', { className: 'slick-top-panel', style: { width: '10000px' } }, this.topPanelScrollerRF); - this.topPanels.push(this.topPanelRF); - - if (!o.showColumnHeader) { - Utils.hide(this.headerScrollerRF); - } - if (!o.showTopPanel) { - Utils.hide(this.topPanelScrollerRF); - } - if (!o.showHeaderRow) { - Utils.hide(this.headerRowScrollerRF); - } - - // viewports and canvases (array order extended at the END: classic 0–3 preserved) - this.viewportTopRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-right-frozen', tabIndex: 0 }, this.paneTopRF); - this.viewportBottomRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomRF); - this.viewport.push(this.viewportTopRF, this.viewportBottomRF); - if (o.viewportClass) { - const viewportClassList = Utils.classNameToList(o.viewportClass); - this.viewportTopRF.classList.add(...viewportClassList); - this.viewportBottomRF.classList.add(...viewportClassList); - } - - this.canvasTopRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-right-frozen', tabIndex: 0 }, this.viewportTopRF); - this.canvasBottomRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomRF); - this.canvas.push(this.canvasTopRF, this.canvasBottomRF); - this.rfTopSlot = this.canvas.indexOf(this.canvasTopRF); - this.rfBottomSlot = this.canvas.indexOf(this.canvasBottomRF); - - // footer row + // appended as a block after the last classic pane + const lastClassic = (this.paneAt('bottom', 'r') ?? this.paneAt('top', 'l'))!.pane; + const h = this.buildPaneSet('header', 'rf', o, lastClassic); + const t = this.buildPaneSet('top', 'rf', o, h.pane); + this.buildPaneSet('bottom', 'rf', o, t.pane); if (o.createFooterRow) { - this.footerRowScrollerRF = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this.paneTopRF); - this.footerRowScroller.push(this.footerRowScrollerRF); - this.footerRowSpacerRF = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this.footerRowScrollerRF); - this.footerRowRF = Utils.createDomElement('div', { className: 'slick-footerrow-columns slick-footerrow-columns-right-frozen' }, this.footerRowScrollerRF); - this.footerRow.push(this.footerRowRF); - - if (!o.showFooterRow) { - Utils.hide(this.footerRowScrollerRF); - } + this.buildFooterRowFor('rf', o); } // if the bottom-frozen band already exists, add the shared corner pane this.ensureBottomFrozenRightVariant(o); - + this.syncElementArrays(); return true; } @@ -1838,55 +1733,27 @@ export class ViewportMgr { * DOM exists at call time; materializeRightFrozenBand adds it later otherwise. */ materializeBottomFrozenBand(o: ViewportMgrBuildOptions): boolean { - if (this.paneBottomFrozenL) { - // band exists — but the RF column variant may have arrived after us + if (this.paneAt('bf', 'l')) { + // idempotent call may still need to add the RF corner variant late this.ensureBottomFrozenRightVariant(o); return false; } - const container = this.container; - const lastPane = this.paneBottomRF ?? this.paneBottomR ?? this.paneTopL; - - this.paneBottomFrozenL = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-left', tabIndex: 0 }); - container.insertBefore(this.paneBottomFrozenL, lastPane.nextSibling); - this.paneBottomFrozenR = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right', tabIndex: 0 }); - container.insertBefore(this.paneBottomFrozenR, this.paneBottomFrozenL.nextSibling); - - this.viewportBottomFrozenL = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-left', tabIndex: 0 }, this.paneBottomFrozenL); - this.viewportBottomFrozenR = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right', tabIndex: 0 }, this.paneBottomFrozenR); - this.canvasBottomFrozenL = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-left', tabIndex: 0 }, this.viewportBottomFrozenL); - this.canvasBottomFrozenR = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right', tabIndex: 0 }, this.viewportBottomFrozenR); - - if (o.viewportClass) { - const viewportClassList = Utils.classNameToList(o.viewportClass); - this.viewportBottomFrozenL.classList.add(...viewportClassList); - this.viewportBottomFrozenR.classList.add(...viewportClassList); - } - - this.viewport.push(this.viewportBottomFrozenL, this.viewportBottomFrozenR); - this.canvas.push(this.canvasBottomFrozenL, this.canvasBottomFrozenR); - this.bfSlotL = this.canvas.indexOf(this.canvasBottomFrozenL); - this.bfSlotR = this.canvas.indexOf(this.canvasBottomFrozenR); - + const lastPane = (this.paneAt('bottom', 'rf') ?? this.paneAt('bottom', 'r') ?? this.paneAt('top', 'l'))!.pane; + const l = this.buildPaneSet('bf', 'l', o, lastPane); + this.buildPaneSet('bf', 'r', o, l.pane); this.ensureBottomFrozenRightVariant(o); + this.syncElementArrays(); return true; } /** Adds the bottom-frozen × right-frozen corner pane when both bands exist. */ protected ensureBottomFrozenRightVariant(o: ViewportMgrBuildOptions) { - if (!this.paneBottomFrozenL || !this.paneHeaderRF || this.paneBottomFrozenRF) { + if (!this.paneAt('bf', 'l') || !this.paneAt('header', 'rf') || this.paneAt('bf', 'rf')) { return; } - this.paneBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-pane slick-pane-bottom-frozen slick-pane-right-frozen', tabIndex: 0 }); - this.container.insertBefore(this.paneBottomFrozenRF, this.paneBottomFrozenR.nextSibling); - this.viewportBottomFrozenRF = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-bottom-frozen slick-viewport-right-frozen', tabIndex: 0 }, this.paneBottomFrozenRF); - this.canvasBottomFrozenRF = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-bottom-frozen grid-canvas-right-frozen', tabIndex: 0 }, this.viewportBottomFrozenRF); - if (o.viewportClass) { - this.viewportBottomFrozenRF.classList.add(...Utils.classNameToList(o.viewportClass)); - } - this.viewport.push(this.viewportBottomFrozenRF); - this.canvas.push(this.canvasBottomFrozenRF); - this.bfSlotRF = this.canvas.indexOf(this.canvasBottomFrozenRF); + this.buildPaneSet('bf', 'rf', o, this.paneAt('bf', 'r')!.pane); + this.syncElementArrays(); } /** Whether the simultaneous top+bottom row mode is active AND its DOM exists. */ From ab04471183e03a5339a50c71195cd10212c91308 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Fri, 17 Jul 2026 13:15:00 +0930 Subject: [PATCH 30/43] refactor: loop applyCanvasWidths over the pane matrix (M18c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-branch width distribution (left-frozen / right-frozen-only / plain, plus the bottom-frozen and right-frozen blocks) collapses to one loop over per-column geometry records. Historical value rules preserved verbatim and now explicit: header element widths write whenever the element exists (a plain grid writes headerR = 0 — historical); the main band chrome width is the FULL row width when no left freeze is active; the classic bottom row width-sizes its left pane only under a left freeze and gives its middle pane left-but-no- width, while the bottom-frozen band sizes both everywhere; spacers remain the classic left/right pair only, never RF. Net -64 lines (+78/-142). Geometry gate 77/77 first-run; full suite verified green BEFORE this commit (636 tests, 635 pass / 1 pending). Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 220 ++++++++++++++++------------------------------ 1 file changed, 78 insertions(+), 142 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index 960f718e..e42e259a 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -2187,170 +2187,106 @@ export class ViewportMgr { * of SlickGrid.updateCanvasWidth(); the width computations stay in the grid. */ applyCanvasWidths(g: CanvasWidthsGeometry) { - // width reserved by the right-frozen band (0 while the band is off or not built); - // the scrollable middle band shrinks by this amount - const rfActive = this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; + const leftActive = this.hasFrozenColumns(); + const rfActive = this.bands.frozenRightCols > 0 && !!this.paneAt('header', 'rf'); const rfW = rfActive ? g.canvasWidthRF : 0; - if (g.widthChanged || this.hasFrozenColumns() || this.freeze.hasFrozenRows || rfActive) { - Utils.width(this.canvasTopL, g.canvasWidthL); - + if (g.widthChanged || leftActive || this.freeze.hasFrozenRows || rfActive) { + // header element widths are written whenever the element exists (historical: + // a plain grid writes headerR's width too — it computes to 0) Utils.width(this.headerL, g.headersWidthL); if (this.headerR) { Utils.width(this.headerR, g.headersWidthR); } - - if (this.hasFrozenColumns()) { - Utils.width(this.canvasTopR, g.canvasWidthR); - - Utils.width(this.paneHeaderL, g.canvasWidthL); - Utils.setStyleSize(this.paneHeaderR, 'left', g.canvasWidthL); - Utils.setStyleSize(this.paneHeaderR, 'width', g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.paneTopL, g.canvasWidthL); - Utils.setStyleSize(this.paneTopR, 'left', g.canvasWidthL); - Utils.width(this.paneTopR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.headerRowScrollerL, g.canvasWidthL); - Utils.width(this.headerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.headerRowL, g.canvasWidthL); - Utils.width(this.headerRowR, g.canvasWidthR); - - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerL, g.canvasWidthL); - Utils.width(this.footerRowScrollerR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.footerRowL, g.canvasWidthL); - Utils.width(this.footerRowR, g.canvasWidthR); - } - if (g.createPreHeaderPanel) { - Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); - } - Utils.width(this.viewportTopL, g.canvasWidthL); - Utils.width(this.viewportTopR, g.viewportW - g.canvasWidthL - rfW); - - if (this.freeze.hasFrozenRows) { - Utils.width(this.paneBottomL, g.canvasWidthL); - Utils.setStyleSize(this.paneBottomR, 'left', g.canvasWidthL); - - Utils.width(this.viewportBottomL, g.canvasWidthL); - Utils.width(this.viewportBottomR, g.viewportW - g.canvasWidthL - rfW); - - Utils.width(this.canvasBottomL, g.canvasWidthL); - Utils.width(this.canvasBottomR, g.canvasWidthR); - } - } else if (rfActive) { - // no left freeze, but a right-frozen band: the left pane IS the scrollable - // middle band — pixel widths instead of the historical '100%' - const middleW = g.viewportW - rfW; - Utils.width(this.paneHeaderL, middleW); - Utils.width(this.paneTopL, middleW); - Utils.width(this.headerRowScrollerL, middleW); - Utils.width(this.headerRowL, g.canvasWidth); - - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerL, middleW); - Utils.width(this.footerRowL, g.canvasWidth); - } - - if (g.createPreHeaderPanel) { - Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); - } - Utils.width(this.viewportTopL, middleW); - - if (this.freeze.hasFrozenRows) { - Utils.width(this.viewportBottomL, middleW); - Utils.width(this.canvasBottomL, g.canvasWidthL); - } - } else { - Utils.width(this.paneHeaderL, '100%'); - Utils.width(this.paneTopL, '100%'); - Utils.width(this.headerRowScrollerL, '100%'); - Utils.width(this.headerRowL, g.canvasWidth); - - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerL, '100%'); - Utils.width(this.footerRowL, g.canvasWidth); - } - - if (g.createPreHeaderPanel) { - Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); - } - Utils.width(this.viewportTopL, '100%'); - - if (this.freeze.hasFrozenRows) { - Utils.width(this.viewportBottomL, '100%'); - Utils.width(this.canvasBottomL, g.canvasWidthL); - } + if (rfActive && this.headerRF) { + Utils.width(this.headerRF, g.headersWidthRF); } - // bottom-frozen row band (simultaneous mode): column widths mirror the classic - // bottom panes - if (this.hasBottomFrozenBand()) { - if (this.hasFrozenColumns()) { - Utils.width(this.paneBottomFrozenL, g.canvasWidthL); - Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); - Utils.setStyleSize(this.paneBottomFrozenR, 'left', g.canvasWidthL); - Utils.width(this.paneBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); - Utils.width(this.viewportBottomFrozenR, g.viewportW - g.canvasWidthL - rfW); - Utils.width(this.canvasBottomFrozenR, g.canvasWidthR); - Utils.width(this.viewportBottomFrozenL, g.canvasWidthL); - } else if (rfActive) { - Utils.width(this.paneBottomFrozenL, g.viewportW - rfW); - Utils.width(this.viewportBottomFrozenL, g.viewportW - rfW); - Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); - } else { - Utils.width(this.paneBottomFrozenL, '100%'); - Utils.width(this.viewportBottomFrozenL, '100%'); - Utils.width(this.canvasBottomFrozenL, g.canvasWidthL); - } - - if (this.paneBottomFrozenRF && rfActive) { - Utils.setStyleSize(this.paneBottomFrozenRF, 'left', g.viewportW - rfW); - Utils.width(this.paneBottomFrozenRF, rfW); - Utils.width(this.viewportBottomFrozenRF, rfW); - Utils.width(this.canvasBottomFrozenRF, g.canvasWidthRF); - } + // one geometry record per ACTIVE structural column; paneW may be the + // historical '100%' in the plain single-band layout. + // canvasW = the band's own column-width sum; chromeW = header-row/footer-row + // content width (historically the FULL row width for the main band when no + // left freeze is active). + const cols: Array<[PaneColKey, { paneLeft?: number; paneW: number | string; canvasW: number; chromeW: number; }]> = []; + cols.push(['l', leftActive + ? { paneW: g.canvasWidthL, canvasW: g.canvasWidthL, chromeW: g.canvasWidthL } + : { paneW: rfActive ? g.viewportW - rfW : '100%', canvasW: g.canvasWidthL, chromeW: g.canvasWidth }]); + if (leftActive) { + cols.push(['r', { paneLeft: g.canvasWidthL, paneW: g.viewportW - g.canvasWidthL - rfW, canvasW: g.canvasWidthR, chromeW: g.canvasWidthR }]); } - - // right-frozen band: fixed-width panes pinned to the right edge if (rfActive) { - const rfLeft = g.viewportW - rfW; - Utils.setStyleSize(this.paneHeaderRF, 'left', rfLeft); - Utils.width(this.paneHeaderRF, rfW); - Utils.width(this.headerRF, g.headersWidthRF); + cols.push(['rf', { paneLeft: g.viewportW - rfW, paneW: rfW, canvasW: g.canvasWidthRF, chromeW: g.canvasWidthRF }]); + } - Utils.setStyleSize(this.paneTopRF, 'left', rfLeft); - Utils.width(this.paneTopRF, rfW); - Utils.width(this.headerRowScrollerRF, rfW); - Utils.width(this.headerRowRF, g.canvasWidthRF); - Utils.width(this.viewportTopRF, rfW); - Utils.width(this.canvasTopRF, g.canvasWidthRF); + for (const [col, w] of cols) { + const placePane = (el: HTMLElement | undefined, sizeWidth = true) => { + if (!el) { return; } + if (w.paneLeft !== undefined) { + Utils.setStyleSize(el, 'left', w.paneLeft); + } + if (sizeWidth) { + Utils.width(el, w.paneW); + } + }; + const header = this.paneAt('header', col); + const top = this.paneAt('top', col); + const bottom = this.paneAt('bottom', col); + const bf = this.paneAt('bf', col); + + placePane(header?.pane); + + if (top) { + placePane(top.pane); + if (top.headerRowScroller) { + Utils.width(top.headerRowScroller, w.paneW); + Utils.width(top.headerRow as HTMLElement, w.chromeW); + } + if (g.createFooterRow && top.footerRowScroller) { + Utils.width(top.footerRowScroller, w.paneW); + Utils.width(top.footerRow as HTMLElement, w.chromeW); + } + if (top.viewport) { + Utils.width(top.viewport, w.paneW); + Utils.width(top.canvas as HTMLElement, w.canvasW); + } + } - if (g.createFooterRow) { - Utils.width(this.footerRowScrollerRF, rfW); - Utils.width(this.footerRowRF, g.canvasWidthRF); + // classic bottom row participates while rows are frozen. Historical quirks + // preserved: its left pane is width-sized only under a left freeze, and its + // middle pane receives 'left' but no width. + if (this.freeze.hasFrozenRows && bottom) { + placePane(bottom.pane, col === 'l' ? leftActive : col === 'rf'); + if (bottom.viewport) { + Utils.width(bottom.viewport, w.paneW); + Utils.width(bottom.canvas as HTMLElement, w.canvasW); + } } - if (this.freeze.hasFrozenRows) { - Utils.setStyleSize(this.paneBottomRF, 'left', rfLeft); - Utils.width(this.paneBottomRF, rfW); - Utils.width(this.viewportBottomRF, rfW); - Utils.width(this.canvasBottomRF, g.canvasWidthRF); + // the bottom-frozen band (simultaneous mode) sizes left AND width everywhere + if (this.hasBottomFrozenBand() && bf) { + placePane(bf.pane); + if (bf.viewport) { + Utils.width(bf.viewport, w.paneW); + Utils.width(bf.canvas as HTMLElement, w.canvasW); + } } } + + if (g.createPreHeaderPanel && this.preHeaderPanel) { + Utils.width(this.preHeaderPanel, g.preHeaderPanelWidth ?? g.canvasWidth); + } } - Utils.width(this.headerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + // spacers: historically only the classic left/right pair, never the RF one + const spacerW = g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0); + Utils.width(this.headerRowSpacerL, spacerW); if (this.headerRowSpacerR) { - Utils.width(this.headerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + Utils.width(this.headerRowSpacerR, spacerW); } - if (g.createFooterRow) { - Utils.width(this.footerRowSpacerL, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + Utils.width(this.footerRowSpacerL, spacerW); if (this.footerRowSpacerR) { - Utils.width(this.footerRowSpacerR, g.canvasWidth + (g.viewportHasVScroll ? g.scrollbarWidth : 0)); + Utils.width(this.footerRowSpacerR, spacerW); } } } From 680946c80bb6860a83356b7113c091d1f31fee81 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Fri, 17 Jul 2026 15:14:23 +0930 Subject: [PATCH 31/43] refactor: loop applyPaneHeights + merge visibility/markers into one band rule (M18d) - applyPaneHeights: two-phase loop over the pane matrix. Phase 1 places paneTopL (offset WITH the historical fallback), reads the shared bottom offset from paneTopL.offsetTop mid-sequence, then places the secondary columns (offset recomputed WITHOUT the fallback - historical asymmetry preserved). Bottom row keeps the plain-layout quirk of width:100% on the left pane; frozen-band canvas heights and the bottom-frozen band loop over the same column list. - applyBandMarkers deleted: visibility and data-colband/data-rowband markers now derive from one (row, col) activity rule inside applyPaneVisibility. The historically never-toggled left header/top panes remain exempt from show/hide. - syncVerticalFollowers: Y-follower bands loop via followerViewport(col). No behavior change: tsc clean, eslint clean, gate suites 72/72, full suite 636 tests / 0 failing. Net -103 lines (M18 running total -300). Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 299 +++++++++++++++------------------------------- 1 file changed, 98 insertions(+), 201 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index e42e259a..d8215620 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -1996,121 +1996,51 @@ export class ViewportMgr { } /** Shows/hides the right and bottom panes according to the freeze configuration. */ - /** - * Stamps band-truth markers (see BAND-LABELLING.md): every pane/viewport/canvas - * that currently participates in the layout carries `data-colband` and - * `data-rowband` attributes stating its CURRENT role (the historical positional - * css classes are a fixed legacy skin and do not change); inactive elements carry - * no markers, so `[data-colband="main"]` etc. uniquely select live elements. - * Refreshed on every freeze application, alongside applyPaneVisibility. - */ - protected applyBandMarkers() { - const leftActive = this.bands.frozenLeftCols > 0; - const rfActive = this.bands.frozenRightCols > 0 && !!this.paneHeaderRF; - const hasRows = this.freeze.hasFrozenRows; - const bfSimultaneous = this.hasBottomFrozenBand(); - const legacyBottomMode = hasRows && this.bands.frozenBottomRows > 0 && this.bands.frozenTopRows === 0; - - // current role of the historical sides/rows - const lCol = leftActive ? 'left' : 'main'; - const topRow = this.bands.frozenTopRows > 0 ? 'top-frozen' : 'body'; - const bottomRow = legacyBottomMode ? 'bottom-frozen' : 'body'; - - const mark = (el: HTMLElement | undefined, colband: string, rowband: string, active: boolean) => { - if (!el) { return; } - if (active) { - el.setAttribute('data-colband', colband); - el.setAttribute('data-rowband', rowband); - } else { - el.removeAttribute('data-colband'); - el.removeAttribute('data-rowband'); - } - }; - const markSet = (els: Array, colband: string, rowband: string, active: boolean) => { - els.forEach((el) => mark(el, colband, rowband, active)); - }; - - // header row band - mark(this.paneHeaderL, lCol, 'header', true); - mark(this.paneHeaderR, 'main', 'header', leftActive); - mark(this.paneHeaderRF, 'right-frozen', 'header', rfActive); - - // classic top row - markSet([this.paneTopL, this.viewportTopL, this.canvasTopL], lCol, topRow, true); - markSet([this.paneTopR, this.viewportTopR, this.canvasTopR], 'main', topRow, leftActive); - markSet([this.paneTopRF, this.viewportTopRF, this.canvasTopRF], 'right-frozen', topRow, rfActive); - - // classic bottom row (participates only while rows are frozen) - markSet([this.paneBottomL, this.viewportBottomL, this.canvasBottomL], lCol, bottomRow, hasRows); - markSet([this.paneBottomR, this.viewportBottomR, this.canvasBottomR], 'main', bottomRow, hasRows && leftActive); - markSet([this.paneBottomRF, this.viewportBottomRF, this.canvasBottomRF], 'right-frozen', bottomRow, hasRows && rfActive); - - // bottom-frozen band (simultaneous top+bottom mode only) - markSet([this.paneBottomFrozenL, this.viewportBottomFrozenL, this.canvasBottomFrozenL], lCol, 'bottom-frozen', bfSimultaneous); - markSet([this.paneBottomFrozenR, this.viewportBottomFrozenR, this.canvasBottomFrozenR], 'main', 'bottom-frozen', bfSimultaneous && leftActive); - markSet([this.paneBottomFrozenRF, this.viewportBottomFrozenRF, this.canvasBottomFrozenRF], 'right-frozen', 'bottom-frozen', bfSimultaneous && rfActive); - } + applyPaneVisibility() { - this.applyBandMarkers(); - if (this.hasFrozenColumns()) { - this.showIf(this.paneHeaderR); - this.showIf(this.paneTopR); - - if (this.freeze.hasFrozenRows) { - this.showIf(this.paneBottomL); - this.showIf(this.paneBottomR); - } else { - this.hideIf(this.paneBottomR); - this.hideIf(this.paneBottomL); - } - } else { - this.hideIf(this.paneHeaderR); - this.hideIf(this.paneTopR); - this.hideIf(this.paneBottomR); - - if (this.freeze.hasFrozenRows) { - this.showIf(this.paneBottomL); - } else { - this.hideIf(this.paneBottomR); - this.hideIf(this.paneBottomL); - } - } - - // right-frozen band (exists only after materialization; kept hidden — like the - // classic panes — when the right freeze is turned off again) - if (this.bands.frozenRightCols > 0) { - this.showIf(this.paneHeaderRF); - this.showIf(this.paneTopRF); - if (this.freeze.hasFrozenRows) { - this.showIf(this.paneBottomRF); - } else { - this.hideIf(this.paneBottomRF); - } - } else { - this.hideIf(this.paneHeaderRF); - this.hideIf(this.paneTopRF); - this.hideIf(this.paneBottomRF); - } + const leftActive = this.hasFrozenColumns(); + const rfActive = this.bands.frozenRightCols > 0 && !!this.paneAt('header', 'rf'); + const hasRows = this.freeze.hasFrozenRows; + const bf = this.hasBottomFrozenBand(); + const legacyBottom = hasRows && this.bands.frozenBottomRows > 0 && this.bands.frozenTopRows === 0; + + for (const row of ['header', 'top', 'bottom', 'bf'] as PaneRowKey[]) { + for (const col of ['l', 'r', 'rf'] as PaneColKey[]) { + const set = this.paneAt(row, col); + if (!set) { continue; } + + const colActive = col === 'l' ? true : (col === 'r' ? leftActive : rfActive); + const rowActive = (row === 'header' || row === 'top') ? true : (row === 'bottom' ? hasRows : bf); + const active = colActive && rowActive; + + // visibility — the left header/top panes were historically never toggled + if (!(col === 'l' && (row === 'header' || row === 'top'))) { + if (active) { + this.showIf(set.pane); + } else { + this.hideIf(set.pane); + } + } - // bottom-frozen row band (simultaneous top+bottom mode only); column-band - // visibility mirrors the classic bottom panes - if (this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0) { - this.showIf(this.paneBottomFrozenL); - if (this.hasFrozenColumns()) { - this.showIf(this.paneBottomFrozenR); - } else { - this.hideIf(this.paneBottomFrozenR); - } - if (this.bands.frozenRightCols > 0) { - this.showIf(this.paneBottomFrozenRF); - } else { - this.hideIf(this.paneBottomFrozenRF); + // band-truth markers (BAND-LABELLING.md): stamped on active pane/viewport/ + // canvas with the CURRENT role; removed from inactive elements + const colband = col === 'l' ? (leftActive ? 'left' : 'main') : (col === 'r' ? 'main' : 'right-frozen'); + const rowband = row === 'header' ? 'header' + : row === 'top' ? (this.bands.frozenTopRows > 0 ? 'top-frozen' : 'body') + : row === 'bottom' ? (legacyBottom ? 'bottom-frozen' : 'body') + : 'bottom-frozen'; + for (const el of [set.pane, set.viewport, set.canvas]) { + if (!el) { continue; } + if (active) { + el.setAttribute('data-colband', colband); + el.setAttribute('data-rowband', rowband); + } else { + el.removeAttribute('data-colband'); + el.removeAttribute('data-rowband'); + } + } } - } else { - this.hideIf(this.paneBottomFrozenL); - this.hideIf(this.paneBottomFrozenR); - this.hideIf(this.paneBottomFrozenRF); } } @@ -2302,8 +2232,11 @@ export class ViewportMgr { let viewportTopH = 0; const viewportBottomH = 0; + const leftActive = this.hasFrozenColumns(); + const rfActive = this.bands.frozenRightCols > 0 && !!this.paneAt('header', 'rf'); + const simultaneousBands = this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneAt('bf', 'l'); + // Account for Frozen Rows - const simultaneousBands = this.bands.frozenTopRows > 0 && this.bands.frozenBottomRows > 0 && !!this.paneBottomFrozenL; if (this.freeze.hasFrozenRows) { if (this.freeze.frozenBottom) { paneTopH = g.viewportH - g.frozenRowsHeight - g.scrollbarHeight; @@ -2323,7 +2256,7 @@ export class ViewportMgr { // The top pane includes the top panel and the header row paneTopH += g.topPanelH + g.headerRowH + g.footerRowH; - if (this.hasFrozenColumns() && g.autoHeight) { + if (leftActive && g.autoHeight) { paneTopH += g.scrollbarHeight; } @@ -2331,7 +2264,7 @@ export class ViewportMgr { viewportTopH = paneTopH - g.topPanelH - g.headerRowH - g.footerRowH; if (g.autoHeight) { - if (this.hasFrozenColumns()) { + if (leftActive) { let fullHeight = paneTopH + this.headerScrollerL.offsetHeight; fullHeight += g.getContainerVBoxDelta(); if (g.showPreHeaderPanel) { @@ -2343,6 +2276,9 @@ export class ViewportMgr { this.paneTopL.style.position = 'relative'; } + // place the left top pane first (offset WITH the historical fallback), then read + // the shared bottom offset from it, then place the secondary columns (offset + // recomputed WITHOUT the fallback — historical asymmetry preserved) let topHeightOffset = Utils.height(this.paneHeaderL); if (topHeightOffset) { topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); @@ -2358,44 +2294,45 @@ export class ViewportMgr { Utils.height(this.viewportTopL, viewportTopH); } - if (this.hasFrozenColumns()) { - let topHeightOffset = Utils.height(this.paneHeaderL); - if (topHeightOffset) { - topHeightOffset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); - } - Utils.setStyleSize(this.paneTopR, 'top', topHeightOffset as number); - Utils.height(this.paneTopR, paneTopH); - Utils.height(this.viewportTopR, viewportTopH); - - if (this.freeze.hasFrozenRows) { - Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); - Utils.height(this.paneBottomL, paneBottomH); - Utils.setStyleSize(this.paneBottomR, 'top', paneBottomTop); - Utils.height(this.paneBottomR, paneBottomH); - Utils.height(this.viewportBottomR, paneBottomH); - } - } else { - if (this.freeze.hasFrozenRows) { - Utils.width(this.paneBottomL, '100%'); - Utils.height(this.paneBottomL, paneBottomH); - Utils.setStyleSize(this.paneBottomL, 'top', paneBottomTop); + const secondaryCols: PaneColKey[] = []; + if (leftActive) { secondaryCols.push('r'); } + if (rfActive) { secondaryCols.push('rf'); } + for (const col of secondaryCols) { + const top = this.paneAt('top', col); + if (!top) { continue; } + let offset = Utils.height(this.paneHeaderL); + if (offset) { + offset += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); } + Utils.setStyleSize(top.pane, 'top', offset as number); + Utils.height(top.pane, paneTopH); + Utils.height(top.viewport as HTMLElement, viewportTopH); } if (this.freeze.hasFrozenRows) { - Utils.height(this.viewportBottomL, paneBottomH); - - if (this.freeze.frozenBottom) { - Utils.height(this.canvasBottomL, g.frozenRowsHeight); - - if (this.hasFrozenColumns()) { - Utils.height(this.canvasBottomR, g.frozenRowsHeight); + // classic bottom panes per column; historical quirk: in the plain layout the + // left bottom pane also gets width '100%' here + const bottomCols: PaneColKey[] = ['l', ...secondaryCols]; + for (const col of bottomCols) { + const bottom = this.paneAt('bottom', col); + if (!bottom) { continue; } + if (col === 'l' && !leftActive) { + Utils.width(bottom.pane, '100%'); } - } else { - Utils.height(this.canvasTopL, g.frozenRowsHeight); + Utils.setStyleSize(bottom.pane, 'top', paneBottomTop); + Utils.height(bottom.pane, paneBottomH); + if (col !== 'l') { + Utils.height(bottom.viewport as HTMLElement, paneBottomH); + } + } + Utils.height(this.viewportBottomL, paneBottomH); - if (this.hasFrozenColumns()) { - Utils.height(this.canvasTopR, g.frozenRowsHeight); + // the frozen row band's canvases carry the band height + const frozenRow: PaneRowKey = this.freeze.frozenBottom ? 'bottom' : 'top'; + for (const col of ['l', ...secondaryCols] as PaneColKey[]) { + const set = this.paneAt(frozenRow, col); + if (set?.canvas) { + Utils.height(set.canvas, g.frozenRowsHeight); } } } else { @@ -2408,48 +2345,13 @@ export class ViewportMgr { if (simultaneousBands) { const bfH = g.frozenBottomRowsHeight ?? 0; const bfTop = this.paneTopL.offsetTop + paneTopH + paneBottomH; - - Utils.setStyleSize(this.paneBottomFrozenL, 'top', bfTop); - Utils.height(this.paneBottomFrozenL, bfH); - Utils.height(this.viewportBottomFrozenL, bfH); - Utils.height(this.canvasBottomFrozenL, bfH); - - if (this.hasFrozenColumns()) { - Utils.setStyleSize(this.paneBottomFrozenR, 'top', bfTop); - Utils.height(this.paneBottomFrozenR, bfH); - Utils.height(this.viewportBottomFrozenR, bfH); - Utils.height(this.canvasBottomFrozenR, bfH); - } - - if (this.paneBottomFrozenRF) { - Utils.setStyleSize(this.paneBottomFrozenRF, 'top', bfTop); - Utils.height(this.paneBottomFrozenRF, bfH); - Utils.height(this.viewportBottomFrozenRF, bfH); - Utils.height(this.canvasBottomFrozenRF, bfH); - } - } - - // right-frozen band: mirror the classic right-pane vertical geometry - if (this.bands.frozenRightCols > 0 && this.paneHeaderRF) { - let topHeightOffsetRF = Utils.height(this.paneHeaderL); - if (topHeightOffsetRF) { - topHeightOffsetRF += (g.showTopHeaderPanel ? g.topHeaderPanelHeight! : 0); - } - Utils.setStyleSize(this.paneTopRF, 'top', topHeightOffsetRF as number); - Utils.height(this.paneTopRF, paneTopH); - Utils.height(this.viewportTopRF, viewportTopH); - - if (this.freeze.hasFrozenRows) { - const paneBottomTopRF = this.paneTopL.offsetTop + paneTopH; - Utils.setStyleSize(this.paneBottomRF, 'top', paneBottomTopRF); - Utils.height(this.paneBottomRF, paneBottomH); - Utils.height(this.viewportBottomRF, paneBottomH); - - if (this.freeze.frozenBottom) { - Utils.height(this.canvasBottomRF, g.frozenRowsHeight); - } else { - Utils.height(this.canvasTopRF, g.frozenRowsHeight); - } + for (const col of ['l', 'r', 'rf'] as PaneColKey[]) { + const bf = this.paneAt('bf', col); + if (!bf || (col === 'r' && !leftActive)) { continue; } + Utils.setStyleSize(bf.pane, 'top', bfTop); + Utils.height(bf.pane, bfH); + Utils.height(bf.viewport as HTMLElement, bfH); + Utils.height(bf.canvas as HTMLElement, bfH); } } @@ -2554,21 +2456,16 @@ export class ViewportMgr { /** Mirrors the Y scroll position onto the frozen-band viewports that follow the scroll owner. */ syncVerticalFollowers(scrollTop: number) { + // the frozen-left and right-frozen bands' scrollable-body viewports follow Y + const followerViewport = (col: PaneColKey) => + (this.freeze.hasFrozenRows && !this.freeze.frozenBottom ? this.paneAt('bottom', col) : this.paneAt('top', col))?.viewport; if (this.hasFrozenColumns()) { - if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { - this.viewportBottomL.scrollTop = scrollTop; - } else { - this.viewportTopL.scrollTop = scrollTop; - } + const v = followerViewport('l'); + if (v) { v.scrollTop = scrollTop; } } - - // the right-frozen band's scrollable-body viewport follows Y the same way - if (this.bands.frozenRightCols > 0 && this.viewportTopRF) { - if (this.freeze.hasFrozenRows && !this.freeze.frozenBottom) { - this.viewportBottomRF.scrollTop = scrollTop; - } else { - this.viewportTopRF.scrollTop = scrollTop; - } + if (this.bands.frozenRightCols > 0) { + const v = followerViewport('rf'); + if (v) { v.scrollTop = scrollTop; } } } From d9765af6da6b391c8a2ebcf46053d22a94336e71 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Fri, 17 Jul 2026 21:36:10 +0930 Subject: [PATCH 32/43] refactor: cellrangeselector per-drag queries onto band markers (M18e) The three mode-dependent document.querySelector dances in handleDragInit now use the band-truth markers (BAND-LABELLING.md): - frozen classic-band height measurement: grid-canvas-{bottom|top} ternary -> [data-rowband=bottom-frozen|top-frozen] (degenerate frozenRow: 0 yields a null match and the offset stays 0, same as measuring the 0-height canvas) - left-band width: .grid-canvas-left -> [data-colband=left] - right-frozen offset: the positional left + conditional right pair collapses to a mode-independent left + main sum (left contributes 0 when absent) The four pane-IDENTITY flags stay positional-class based, with a comment explaining why: markers state band role, which conflates the legacy frozenBottom classic canvas with a bf-band canvas and both degenerate frozenRow: 0 canvases - the offset/clamp math needs pane identity. No behavior change: tsc/eslint clean, drag+RF/BF gate 87/87, full suite 636 tests / 0 failing. Co-Authored-By: Claude Fable 5 --- src/plugins/slick.cellrangeselector.ts | 34 +++++++++++++++----------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/plugins/slick.cellrangeselector.ts b/src/plugins/slick.cellrangeselector.ts index 6eb5cb7b..e515680a 100644 --- a/src/plugins/slick.cellrangeselector.ts +++ b/src/plugins/slick.cellrangeselector.ts @@ -145,17 +145,25 @@ export class SlickCellRangeSelector implements SlickPlugin { this._bands = this._grid.getFrozenBandCounts(); this._legacyRowFreezeActive = this._gridOptions.frozenRow! > -1; + // pane-IDENTITY flags: deliberately positional-class based, NOT band markers. + // The offset/clamp math below needs to know WHICH pane hosts the drag, and the + // markers state band role instead: in legacy frozenBottom mode the classic bottom + // canvas carries data-rowband="bottom-frozen" (same value as a bf-band canvas), + // and in the degenerate frozenRow: 0 configuration both classic canvases are + // rowband "body" — either would conflate panes these flags must distinguish. this._isBottomCanvas = this._activeCanvas.classList.contains('grid-canvas-bottom'); this._isBottomFrozenCanvas = this._activeCanvas.classList.contains('grid-canvas-bottom-frozen'); this._isRightCanvas = this._activeCanvas.classList.contains('grid-canvas-right'); this._isRightFrozenCanvas = this._activeCanvas.classList.contains('grid-canvas-right-frozen'); if (this._legacyRowFreezeActive && this._isBottomCanvas) { - // measure the canvas above the drag canvas; the single frozen band sits at the - // bottom only in legacy frozenBottom mode (band counts already encode that the - // flag is inert when frozenBottomRow is in use) + // measure the frozen classic-band canvas via its band-truth marker + // (BAND-LABELLING.md): bottom-frozen in legacy frozenBottom mode, top-frozen + // otherwise. In the degenerate frozenRow: 0 configuration no canvas carries a + // frozen rowband and the offset stays 0 — the same result the positional query + // produced by measuring the 0-height frozen canvas. const legacyBottomMode = this._bands.frozenBottomRows > 0 && this._bands.frozenTopRows === 0; - const canvasSelector = `.${this._grid.getUID()} .grid-canvas-${legacyBottomMode ? 'bottom' : 'top'}`; + const canvasSelector = `.${this._grid.getUID()} .grid-canvas[data-rowband="${legacyBottomMode ? 'bottom-frozen' : 'top-frozen'}"]`; const canvasElm = document.querySelector(canvasSelector); if (canvasElm) { this._rowOffset = canvasElm.clientHeight || 0; @@ -168,22 +176,20 @@ export class SlickCellRangeSelector implements SlickPlugin { } if (this._bands.frozenLeftCols > 0 && this._isRightCanvas) { - const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-left`); + const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas[data-colband="left"]`); if (canvasLeftElm) { this._columnOffset = canvasLeftElm.clientWidth || 0; } } if (this._isRightFrozenCanvas) { - // right-frozen band canvas: offset by every canvas to its left (left band, when - // present, plus the scrollable middle band) - const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-top.grid-canvas-left`); - let offset = canvasLeftElm?.clientWidth || 0; - if (this._bands.frozenLeftCols > 0) { - const canvasMiddleElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-top.grid-canvas-right`); - offset += canvasMiddleElm?.clientWidth || 0; - } - this._columnOffset = offset; + // right-frozen band canvas: offset by every band to its left. The band-truth + // markers make this mode-independent: 'left' is absent without a left freeze + // (contributing 0) and 'main' is the scrollable band whichever pane hosts it — + // the same totals the positional left/right class queries produced per mode. + const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas[data-colband="left"]`); + const canvasMainElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas[data-colband="main"]`); + this._columnOffset = (canvasLeftElm?.clientWidth || 0) + (canvasMainElm?.clientWidth || 0); } this._dragReplaceHandleActive = (dd.matchClassTag === 'dragReplaceHandle'); From 6250f4458079b1a823cb674e818ed55da12b9423 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sat, 18 Jul 2026 11:59:55 +0930 Subject: [PATCH 33/43] fix: M18 closing-audit resolutions Six-dimension adversarial equivalence audit of the M18 series (creation paths, widths, heights/visibility/markers, compat getters + shared arrays, cellrangeselector, cross-cutting invariants) against pre-M18 (484988d3^). Everything verified equivalent except: - cellrangeselector: add a positional fallback to the frozen-band marker query. Restores byte-equivalence in the degenerate frozenRow: 0 variants (incl. the frozenBottom body-height quirk) and covers stale-marker suppressColumnSet transition windows. - materializeRightFrozenBand: drop the ensureBottomFrozenRightVariant call from the already-exists early return (restores exact pre-M18 shape). The state it guarded is unreachable, and if it ever fired the corner would be created without pane events (the grid caller binds nothing on that path). Accepted deltas documented as KNOWN-QUIRKS #10-13: canonical shared-array band order, impossible-state TypeErrors -> silent skips, read-only compat getters, markers-stale-when-visibility-stale. Gates: tsc/eslint clean, shape+band+drag gate 74/74, full suite 636 / 0 failing. Co-Authored-By: Claude Fable 5 --- src/plugins/slick.cellrangeselector.ts | 11 +++++++---- src/slick.core.ts | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/plugins/slick.cellrangeselector.ts b/src/plugins/slick.cellrangeselector.ts index e515680a..bd297952 100644 --- a/src/plugins/slick.cellrangeselector.ts +++ b/src/plugins/slick.cellrangeselector.ts @@ -159,12 +159,15 @@ export class SlickCellRangeSelector implements SlickPlugin { if (this._legacyRowFreezeActive && this._isBottomCanvas) { // measure the frozen classic-band canvas via its band-truth marker // (BAND-LABELLING.md): bottom-frozen in legacy frozenBottom mode, top-frozen - // otherwise. In the degenerate frozenRow: 0 configuration no canvas carries a - // frozen rowband and the offset stays 0 — the same result the positional query - // produced by measuring the 0-height frozen canvas. + // otherwise. Positional fallback: in the degenerate frozenRow: 0 variants no + // canvas carries a frozen rowband (and in a suppressColumnSet transition + // window markers can be stale), so the historical positional query reproduces + // the pre-marker offsets exactly — including the frozenBottom body-height + // quirk of the frozenRow: 0 + frozenBottom: true combination. const legacyBottomMode = this._bands.frozenBottomRows > 0 && this._bands.frozenTopRows === 0; const canvasSelector = `.${this._grid.getUID()} .grid-canvas[data-rowband="${legacyBottomMode ? 'bottom-frozen' : 'top-frozen'}"]`; - const canvasElm = document.querySelector(canvasSelector); + const canvasElm = document.querySelector(canvasSelector) + ?? document.querySelector(`.${this._grid.getUID()} .grid-canvas-${legacyBottomMode ? 'bottom' : 'top'}`); if (canvasElm) { this._rowOffset = canvasElm.clientHeight || 0; } diff --git a/src/slick.core.ts b/src/slick.core.ts index d8215620..36657fab 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -1704,8 +1704,10 @@ export class ViewportMgr { */ materializeRightFrozenBand(o: ViewportMgrBuildOptions): boolean { if (this.paneAt('header', 'rf')) { - // band exists — but the bottom-frozen corner may have arrived after us - this.ensureBottomFrozenRightVariant(o); + // band already exists: no corner work here (pre-matrix behavior). The BF×RF + // corner is always created by whichever band materializes SECOND, on its + // success path — and the grid-side caller binds no pane events on this early + // return, so creating the corner here would leave it event-less. return false; } From 897dbad1f23f8f297befa48394f9e024c6537490 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sat, 18 Jul 2026 22:15:18 +0930 Subject: [PATCH 34/43] refactor: facade collections + alias-layer deletion (M19a) First slice of the ViewportMgr facade (FACADE-FEASIBILITY.md). slick.core.ts gains BandSet (per-role l/r/rf collections: at/pick/forEach/empty/width/ setStyle/query, iterating only materialized bands) and CellSet (pane matrix cells), 16 cached collection getters wrapping the SAME live shared arrays (identity contract intact), and live scroll-container getters that recompute via selectScrollContainers per access - the stale-owner failure mode is gone rather than mitigated. Four raw arrays renamed *Arr to free the getter names. slick.grid.ts deletions, compiler-proven complete: ~55 alias fields (incl. three dead _groupHeaders* fields), the 61-line syncViewportMgrAliases, the per-alias destroy nulling block, setScroller. 170 alias reads re-pointed at the facade. Semantic conversions in this slice (broadcast-write / geometry / public-api categories): createColumnHeaders reads like the pre-frozen original (headers.empty() + headers.width({l,r,rf})); footer resets keep the historical asymmetries explicit (pick(l,r); R gated on hasFrozenColumns inline, per-band event/empty interleave preserved); sort-indicator clearing via headers.query(); panel toggles feed collection elements; measurement [0] reads become named first() calls. Preserved contracts: getFooterRow still throws without createFooterRow; Sortable.create(headerR) undefined-tolerance; getCanvases()/getViewports() return the same live array objects. Behavior notes: post-destroy alias reads were null, now dereference the nulled ViewportMgr (unreachable - handlers unbind first); getHeadersWidth() called once instead of once per band in the init broadcast (idempotent recompute). tsc/eslint clean; gate 98/98; full suite 654 / 0 failing. Net -105 lines. Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 168 ++++++++++++- src/slick.grid.ts | 619 ++++++++++++++-------------------------------- 2 files changed, 341 insertions(+), 446 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index 36657fab..d9624a91 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -1370,6 +1370,120 @@ const PANE_COL_CSS: Record = { l: 'left', r: 'right', rf: 'r /** css suffix of each structural row. */ const PANE_ROW_CSS: Record = { header: 'header', top: 'top', bottom: 'bottom', bf: 'bottom-frozen' }; +/** + * One element-role across the column bands (l | r | rf) — the ViewportMgr facade's + * jQuery-like element collections (FACADE-FEASIBILITY.md, stage M19). Iteration + * touches only MATERIALIZED bands, so per-band existence gating disappears from + * call sites. When a set is identity-critical (headers and the header/footer/panel + * chrome the grid exposes), `elements` IS the live shared array the grid contract + * depends on; derived sets (spacers, pre-headers, pick() views) rebuild a fresh + * snapshot per access — cold paths only. + * + * (An ElementGroup-extends-Array design — the shared arrays themselves becoming + * the collections — was considered and rejected: the wrapper reads the same + * without Symbol.species/toolchain edge risk.) + */ +export class BandSet { + constructor( + protected readonly mgr: ViewportMgr, + protected readonly row: PaneRowKey, + protected readonly part: keyof PaneSet, + protected readonly live?: HTMLDivElement[], + protected readonly cols: PaneColKey[] = ['l', 'r', 'rf'], + ) {} + + /** the live shared array for identity-critical sets; a fresh snapshot otherwise */ + get elements(): HTMLDivElement[] { + if (this.live) { return this.live; } + const out: HTMLDivElement[] = []; + this.forEach((el) => { out.push(el); }); + return out; + } + + get length(): number { return this.elements.length; } + + /** the canonical measurement element (the first materialized band — historically L) */ + first(): HTMLDivElement { return this.elements[0]; } + + at(col: PaneColKey): HTMLDivElement | undefined { + return this.mgr.paneAt(this.row, col)?.[this.part] as HTMLDivElement | undefined; + } + + /** a filtered view — keeps historical band asymmetries explicit and grep-able */ + pick(...cols: PaneColKey[]): BandSet { + return new BandSet(this.mgr, this.row, this.part, undefined, cols); + } + + forEach(fn: (el: HTMLDivElement, col: PaneColKey, i: number) => void): void { + let i = 0; + for (const col of this.cols) { + const el = this.at(col); + if (el) { fn(el, col, i++); } + } + } + + empty(): void { + this.forEach((el) => Utils.emptyElement(el)); + } + + /** one width for every materialized band, or a per-band map (absent keys skip) */ + width(w: number | Partial>): void { + this.forEach((el, col) => { + const value = typeof w === 'number' ? w : w[col]; + if (value !== undefined) { + Utils.width(el, value); + } + }); + } + + setStyle(styles: Partial): void { + this.forEach((el) => { Object.assign(el.style, styles); }); + } + + query(selector: string): HTMLElement[] { + const out: HTMLElement[] = []; + this.forEach((el) => { out.push(...Array.from(el.querySelectorAll(selector))); }); + return out; + } +} + +/** 2D analogue of BandSet for the pane/viewport/canvas cells of the pane matrix. */ +export class CellSet { + constructor( + protected readonly mgr: ViewportMgr, + protected readonly part: 'pane' | 'viewport' | 'canvas', + protected readonly live?: HTMLDivElement[], + ) {} + + /** the live shared array (canonical order) for viewports/canvases; derived for panes */ + get elements(): HTMLDivElement[] { + if (this.live) { return this.live; } + const out: HTMLDivElement[] = []; + for (const row of ['header', 'top', 'bottom', 'bf'] as PaneRowKey[]) { + for (const col of ['l', 'r', 'rf'] as PaneColKey[]) { + const el = this.at(row, col); + if (el) { out.push(el); } + } + } + return out; + } + + at(row: PaneRowKey, col: PaneColKey): HTMLDivElement | undefined { + return this.mgr.paneAt(row, col)?.[this.part] as HTMLDivElement | undefined; + } + + /** the top-left cell — the measurement/default-active canonical (historical `[0]`) */ + first(): HTMLDivElement { return this.elements[0]; } + + forEach(fn: (el: HTMLDivElement, i: number) => void): void { + this.elements.forEach((el, i) => fn(el, i)); + } + + setStyle(styles: Partial): void { + this.forEach((el) => { Object.assign(el.style, styles); }); + } +} + export class ViewportMgr { // named-element compat getters over the pane matrix (M18b): same runtime // semantics as the historical definite-assignment fields (undefined until built) @@ -1582,11 +1696,11 @@ export class ViewportMgr { const colOrder: PaneColKey[] = ['l', 'r', 'rf']; fill(this.headerScroller, colOrder.map((c) => cell('header', c)?.headerScroller)); - fill(this.headers, colOrder.map((c) => cell('header', c)?.header)); + fill(this.headersArr, colOrder.map((c) => cell('header', c)?.header)); fill(this.headerRowScroller, colOrder.map((c) => cell('top', c)?.headerRowScroller)); - fill(this.headerRows, colOrder.map((c) => cell('top', c)?.headerRow)); - fill(this.topPanelScrollers, colOrder.map((c) => cell('top', c)?.topPanelScroller)); - fill(this.topPanels, colOrder.map((c) => cell('top', c)?.topPanel)); + fill(this.headerRowsArr, colOrder.map((c) => cell('top', c)?.headerRow)); + fill(this.topPanelScrollersArr, colOrder.map((c) => cell('top', c)?.topPanelScroller)); + fill(this.topPanelsArr, colOrder.map((c) => cell('top', c)?.topPanel)); fill(this.footerRowScroller, colOrder.map((c) => cell('top', c)?.footerRowScroller)); fill(this.footerRow, colOrder.map((c) => cell('top', c)?.footerRow)); @@ -1598,21 +1712,57 @@ export class ViewportMgr { this.bfSlotRF = this.canvas.indexOf(cell('bf', 'rf')?.canvas as HTMLDivElement); } + // --- facade collections (M19a): one cached instance per set, wrapping the live + // --- shared arrays (identity contract) or deriving from the matrix (cold sets) + protected readonly facadeSets: Record = {}; + protected bandSetFor(key: string, row: PaneRowKey, part: keyof PaneSet, live?: HTMLDivElement[]): BandSet { + return (this.facadeSets[key] ??= new BandSet(this, row, part, live)) as BandSet; + } + protected cellSetFor(part: 'pane' | 'viewport' | 'canvas', live?: HTMLDivElement[]): CellSet { + return (this.facadeSets[part] ??= new CellSet(this, part, live)) as CellSet; + } + + get headers(): BandSet { return this.bandSetFor('headers', 'header', 'header', this.headersArr); } + get headerScrollers(): BandSet { return this.bandSetFor('headerScrollers', 'header', 'headerScroller', this.headerScroller); } + get headerRows(): BandSet { return this.bandSetFor('headerRows', 'top', 'headerRow', this.headerRowsArr); } + get headerRowScrollers(): BandSet { return this.bandSetFor('headerRowScrollers', 'top', 'headerRowScroller', this.headerRowScroller); } + get headerRowSpacers(): BandSet { return this.bandSetFor('headerRowSpacers', 'top', 'headerRowSpacer'); } + get footerRows(): BandSet { return this.bandSetFor('footerRows', 'top', 'footerRow', this.footerRow); } + get footerRowScrollers(): BandSet { return this.bandSetFor('footerRowScrollers', 'top', 'footerRowScroller', this.footerRowScroller); } + get footerRowSpacers(): BandSet { return this.bandSetFor('footerRowSpacers', 'top', 'footerRowSpacer'); } + get topPanels(): BandSet { return this.bandSetFor('topPanels', 'top', 'topPanel', this.topPanelsArr); } + get topPanelScrollers(): BandSet { return this.bandSetFor('topPanelScrollers', 'top', 'topPanelScroller', this.topPanelScrollersArr); } + get preHeaderPanels(): BandSet { return this.bandSetFor('preHeaderPanels', 'header', 'preHeader'); } + get preHeaderScrollers(): BandSet { return this.bandSetFor('preHeaderScrollers', 'header', 'preHeaderScroller'); } + get preHeaderSpacers(): BandSet { return this.bandSetFor('preHeaderSpacers', 'header', 'preHeaderSpacer'); } + get viewports(): CellSet { return this.cellSetFor('viewport', this.viewport); } + get canvases(): CellSet { return this.cellSetFor('canvas', this.canvas); } + get panes(): CellSet { return this.cellSetFor('pane'); } + + // --- scroll-container facade (M19a): LIVE selection per access, so the owners can + // --- never be stale (replaces the grid's setScroller()-time alias snapshots; the + // --- selection itself is a handful of field reads) + get scrollContainerX(): HTMLDivElement { return this.selectScrollContainers().x; } + get scrollContainerY(): HTMLDivElement { return this.selectScrollContainers().y; } + get headerScrollContainer(): HTMLDivElement { return this.selectScrollContainers().header; } + get headerRowScrollContainer(): HTMLDivElement { return this.selectScrollContainers().headerRow; } + get footerRowScrollContainer(): HTMLDivElement { return this.selectScrollContainers().footerRow; } + // panes // pre-header panels (only when createPreHeaderPanel) // header scrollers and header column containers headerScroller: HTMLDivElement[] = []; - headers: HTMLDivElement[] = []; + protected headersArr: HTMLDivElement[] = []; // header rows headerRowScroller: HTMLDivElement[] = []; - headerRows: HTMLDivElement[] = []; + protected headerRowsArr: HTMLDivElement[] = []; // top panels - topPanelScrollers: HTMLDivElement[] = []; - topPanels: HTMLDivElement[] = []; + protected topPanelScrollersArr: HTMLDivElement[] = []; + protected topPanelsArr: HTMLDivElement[] = []; // viewports and canvases viewport: HTMLDivElement[] = []; @@ -2425,7 +2575,7 @@ export class ViewportMgr { syncHorizontalScroll(x: number, o: { createFooterRow?: boolean; createPreHeaderPanel?: boolean; }) { this.scrollContainers.x.scrollLeft = x; this.scrollContainers.header.scrollLeft = x; - this.topPanelScrollers[0].scrollLeft = x; + this.topPanelScrollersArr[0].scrollLeft = x; if (o.createFooterRow) { this.scrollContainers.footerRow.scrollLeft = x; } diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 4b1f8c44..87645bbc 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -395,30 +395,9 @@ export class SlickGrid = Column, O e * still cover everything, so binding in the materializer would double-register. */ protected _paneEventsBound = false; - protected _groupHeaders: HTMLDivElement[] = []; - protected _headerScroller: HTMLDivElement[] = []; - protected _headers: HTMLDivElement[] = []; - protected _headerRows!: HTMLDivElement[]; - protected _headerRowScroller!: HTMLDivElement[]; - protected _headerRowSpacerL!: HTMLDivElement; - protected _headerRowSpacerR!: HTMLDivElement; - protected _footerRow!: HTMLDivElement[]; - protected _footerRowScroller!: HTMLDivElement[]; - protected _footerRowSpacerL!: HTMLDivElement; - protected _footerRowSpacerR!: HTMLDivElement; - protected _preHeaderPanel!: HTMLDivElement; - protected _preHeaderPanelScroller!: HTMLDivElement; - protected _preHeaderPanelSpacer!: HTMLDivElement; - protected _preHeaderPanelR!: HTMLDivElement; - protected _preHeaderPanelScrollerR!: HTMLDivElement; - protected _preHeaderPanelSpacerR!: HTMLDivElement; protected _topHeaderPanel!: HTMLDivElement; protected _topHeaderPanelScroller!: HTMLDivElement; protected _topHeaderPanelSpacer!: HTMLDivElement; - protected _topPanelScrollers!: HTMLDivElement[]; - protected _topPanels!: HTMLDivElement[]; - protected _viewport!: HTMLDivElement[]; - protected _canvas!: HTMLDivElement[]; protected _style?: HTMLStyleElement; protected _boundAncestors: HTMLElement[] = []; protected stylesheet?: { cssRules: Array<{ selectorText: string; }>; rules: Array<{ selectorText: string; }>; } | null; @@ -520,44 +499,6 @@ export class SlickGrid = Column, O e protected counter_rows_rendered = 0; protected counter_rows_removed = 0; - protected _paneHeaderL!: HTMLDivElement; - protected _paneHeaderR!: HTMLDivElement; - protected _paneTopL!: HTMLDivElement; - protected _paneTopR!: HTMLDivElement; - protected _paneBottomL!: HTMLDivElement; - protected _paneBottomR!: HTMLDivElement; - protected _headerScrollerL!: HTMLDivElement; - protected _headerScrollerR!: HTMLDivElement; - protected _headerL!: HTMLDivElement; - protected _headerR!: HTMLDivElement; - protected _groupHeadersL!: HTMLDivElement; - protected _groupHeadersR!: HTMLDivElement; - protected _headerRowScrollerL!: HTMLDivElement; - protected _headerRowScrollerR!: HTMLDivElement; - protected _footerRowScrollerL!: HTMLDivElement; - protected _footerRowScrollerR!: HTMLDivElement; - protected _headerRowL!: HTMLDivElement; - protected _headerRowR!: HTMLDivElement; - protected _footerRowL!: HTMLDivElement; - protected _footerRowR!: HTMLDivElement; - protected _topPanelScrollerL!: HTMLDivElement; - protected _topPanelScrollerR!: HTMLDivElement; - protected _topPanelL!: HTMLDivElement; - protected _topPanelR!: HTMLDivElement; - protected _viewportTopL!: HTMLDivElement; - protected _viewportTopR!: HTMLDivElement; - protected _viewportBottomL!: HTMLDivElement; - protected _viewportBottomR!: HTMLDivElement; - protected _canvasTopL!: HTMLDivElement; - protected _canvasTopR!: HTMLDivElement; - protected _canvasBottomL!: HTMLDivElement; - protected _canvasBottomR!: HTMLDivElement; - protected _viewportScrollContainerX!: HTMLDivElement; - protected _viewportScrollContainerY!: HTMLDivElement; - protected _headerScrollContainer!: HTMLDivElement; - protected _headerRowScrollContainer!: HTMLDivElement; - protected _footerRowScrollContainer!: HTMLDivElement; - // store css attributes if display:none is active in container or parent protected cssShow = { position: 'absolute', visibility: 'hidden', display: 'block' }; protected _hiddenParents: HTMLElement[] = []; @@ -732,16 +673,15 @@ export class SlickGrid = Column, O e // existing logic operates unchanged. this._viewportMgr = new ViewportMgr(); this._viewportMgr.buildPanes(this._container, this._options); - this.syncViewportMgrAliases(); // Default the active viewport to the top left - this._activeViewportNode = this._viewportTopL; + this._activeViewportNode = this._viewportMgr.viewportTopL; this.scrollbarDimensions = this.scrollbarDimensions || this.measureScrollbar(); const canvasWithScrollbarWidth = this.getCanvasWidth() + this.scrollbarDimensions.width; // Default the active canvas to the top left - this._activeCanvasNode = this._canvasTopL; + this._activeCanvasNode = this._viewportMgr.canvasTopL; // top-header if (this._topHeaderPanelSpacer) { @@ -749,23 +689,17 @@ export class SlickGrid = Column, O e } // pre-header - if (this._preHeaderPanelSpacer) { - Utils.width(this._preHeaderPanelSpacer, canvasWithScrollbarWidth); + if (this._viewportMgr.preHeaderPanelSpacer) { + Utils.width(this._viewportMgr.preHeaderPanelSpacer, canvasWithScrollbarWidth); } - this._headers.forEach((el) => { - Utils.width(el, this.getHeadersWidth()); - }); + this._viewportMgr.headers.width(this.getHeadersWidth()); - Utils.width(this._headerRowSpacerL, canvasWithScrollbarWidth); - if (this._headerRowSpacerR) { - Utils.width(this._headerRowSpacerR, canvasWithScrollbarWidth); - } + this._viewportMgr.headerRowSpacers.width(canvasWithScrollbarWidth); // footer Row if (this._options.createFooterRow) { this._viewportMgr.buildFooterRows(this._options, canvasWithScrollbarWidth); - this.syncViewportMgrAliases(); } this._focusSink2 = this._focusSink.cloneNode(true) as HTMLDivElement; @@ -784,72 +718,6 @@ export class SlickGrid = Column, O e * (e.g. for scrolling, mouse, keyboard, drag-and-drop). * It also starts up any asynchronous post–render processing if enabled. */ - /** - * Copies every pane/viewport/canvas/chrome element reference from the ViewportMgr - * onto the grid's historical field names. Called after buildPanes, buildFooterRows - * and materializeSecondaryPanes; idempotent. - */ - protected syncViewportMgrAliases() { - this._paneHeaderL = this._viewportMgr.paneHeaderL; - this._paneHeaderR = this._viewportMgr.paneHeaderR; - this._paneTopL = this._viewportMgr.paneTopL; - this._paneTopR = this._viewportMgr.paneTopR; - this._paneBottomL = this._viewportMgr.paneBottomL; - this._paneBottomR = this._viewportMgr.paneBottomR; - - this._preHeaderPanelScroller = this._viewportMgr.preHeaderPanelScroller; - this._preHeaderPanel = this._viewportMgr.preHeaderPanel; - this._preHeaderPanelSpacer = this._viewportMgr.preHeaderPanelSpacer; - this._preHeaderPanelScrollerR = this._viewportMgr.preHeaderPanelScrollerR; - this._preHeaderPanelR = this._viewportMgr.preHeaderPanelR; - this._preHeaderPanelSpacerR = this._viewportMgr.preHeaderPanelSpacerR; - - this._headerScrollerL = this._viewportMgr.headerScrollerL; - this._headerScrollerR = this._viewportMgr.headerScrollerR; - this._headerScroller = this._viewportMgr.headerScroller; - this._headerL = this._viewportMgr.headerL; - this._headerR = this._viewportMgr.headerR; - this._headers = this._viewportMgr.headers; - - this._headerRowScrollerL = this._viewportMgr.headerRowScrollerL; - this._headerRowScrollerR = this._viewportMgr.headerRowScrollerR; - this._headerRowScroller = this._viewportMgr.headerRowScroller; - this._headerRowSpacerL = this._viewportMgr.headerRowSpacerL; - this._headerRowSpacerR = this._viewportMgr.headerRowSpacerR; - this._headerRowL = this._viewportMgr.headerRowL; - this._headerRowR = this._viewportMgr.headerRowR; - this._headerRows = this._viewportMgr.headerRows; - - this._topPanelScrollerL = this._viewportMgr.topPanelScrollerL; - this._topPanelScrollerR = this._viewportMgr.topPanelScrollerR; - this._topPanelScrollers = this._viewportMgr.topPanelScrollers; - this._topPanelL = this._viewportMgr.topPanelL; - this._topPanelR = this._viewportMgr.topPanelR; - this._topPanels = this._viewportMgr.topPanels; - - this._viewportTopL = this._viewportMgr.viewportTopL; - this._viewportTopR = this._viewportMgr.viewportTopR; - this._viewportBottomL = this._viewportMgr.viewportBottomL; - this._viewportBottomR = this._viewportMgr.viewportBottomR; - this._viewport = this._viewportMgr.viewport; - - this._canvasTopL = this._viewportMgr.canvasTopL; - this._canvasTopR = this._viewportMgr.canvasTopR; - this._canvasBottomL = this._viewportMgr.canvasBottomL; - this._canvasBottomR = this._viewportMgr.canvasBottomR; - this._canvas = this._viewportMgr.canvas; - - if (this._options.createFooterRow) { - this._footerRowScrollerL = this._viewportMgr.footerRowScrollerL; - this._footerRowScrollerR = this._viewportMgr.footerRowScrollerR; - this._footerRowScroller = this._viewportMgr.footerRowScroller; - this._footerRowSpacerL = this._viewportMgr.footerRowSpacerL; - this._footerRowSpacerR = this._viewportMgr.footerRowSpacerR; - this._footerRowL = this._viewportMgr.footerRowL; - this._footerRowR = this._viewportMgr.footerRowR; - this._footerRow = this._viewportMgr.footerRow; - } - } /** * Binds the per-element event handlers for pane-level elements. Used by @@ -931,27 +799,26 @@ export class SlickGrid = Column, O e if (!this._viewportMgr.materializeSecondaryPanes(this._options)) { return; } - this.syncViewportMgrAliases(); if (this._paneEventsBound) { - this.disableSelection([this._headerR]); + this.disableSelection([this._viewportMgr.headerR]); this.bindPaneEvents({ - viewports: [this._viewportTopR, this._viewportBottomL, this._viewportBottomR], - canvases: [this._canvasTopR, this._canvasBottomL, this._canvasBottomR], - headerScrollers: [this._headerScrollerR], - headerRowScrollers: [this._headerRowScrollerR], - footerRows: this._options.createFooterRow ? [this._footerRowR] : [], - footerRowScrollers: this._options.createFooterRow ? [this._footerRowScrollerR] : [], + viewports: [this._viewportMgr.viewportTopR, this._viewportMgr.viewportBottomL, this._viewportMgr.viewportBottomR], + canvases: [this._viewportMgr.canvasTopR, this._viewportMgr.canvasBottomL, this._viewportMgr.canvasBottomR], + headerScrollers: [this._viewportMgr.headerScrollerR], + headerRowScrollers: [this._viewportMgr.headerRowScrollerR], + footerRows: this._options.createFooterRow ? [this._viewportMgr.footerRowR] : [], + footerRowScrollers: this._options.createFooterRow ? [this._viewportMgr.footerRowScrollerR] : [], }); if (this._options.createPreHeaderPanel) { - this._bindingEventService.bind(this._preHeaderPanelScrollerR, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(this._preHeaderPanelScrollerR, 'click', this.handlePreHeaderClick.bind(this) as EventListener); + this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScrollerR, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScrollerR, 'click', this.handlePreHeaderClick.bind(this) as EventListener); } // sort clicks for the new right header container - this.setupColumnSort([this._headerR]); + this.setupColumnSort([this._viewportMgr.headerR]); // the ancestor-scroll anchor canvas may have changed band this.unbindAncestorScrollEvents(); @@ -975,11 +842,10 @@ export class SlickGrid = Column, O e // disable all text selection in header (including input and textarea); // AFTER setFrozenOptions so headers materialized during the init window // (right-frozen / lazy bands) are included in the shared array - this.disableSelection(this._headers); + this.disableSelection(this._viewportMgr.headers.elements); this.setPaneFrozenClasses(); this.setPaneVisibility(); - this.setScroller(); this.setOverflow(); this.updateColumnCaches(); @@ -993,12 +859,12 @@ export class SlickGrid = Column, O e this._bindingEventService.bind(this._container, 'resize', this.resizeCanvas.bind(this)); this.bindPaneEvents({ - viewports: this._viewport, - canvases: this._canvas, - headerScrollers: this._headerScroller, - headerRowScrollers: this._headerRowScroller, - footerRows: this._footerRow, - footerRowScrollers: this._footerRowScroller, + viewports: this._viewportMgr.viewports.elements, + canvases: this._viewportMgr.canvases.elements, + headerScrollers: this._viewportMgr.headerScrollers.elements, + headerRowScrollers: this._viewportMgr.headerRowScrollers.elements, + footerRows: this._viewportMgr.footerRows.elements, + footerRowScrollers: this._viewportMgr.footerRowScrollers.elements, }); this._paneEventsBound = true; @@ -1007,11 +873,11 @@ export class SlickGrid = Column, O e } if (this._options.createPreHeaderPanel) { - this._bindingEventService.bind(this._preHeaderPanelScroller, 'scroll', this.handlePreHeaderPanelScroll.bind(this) as EventListener); - this._bindingEventService.bind(this._preHeaderPanelScroller, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(this._preHeaderPanelScrollerR, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(this._preHeaderPanelScroller, 'click', this.handlePreHeaderClick.bind(this) as EventListener); - this._bindingEventService.bind(this._preHeaderPanelScrollerR, 'click', this.handlePreHeaderClick.bind(this) as EventListener); + this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScroller, 'scroll', this.handlePreHeaderPanelScroll.bind(this) as EventListener); + this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScroller, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScrollerR, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScroller, 'click', this.handlePreHeaderClick.bind(this) as EventListener); + this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScrollerR, 'click', this.handlePreHeaderClick.bind(this) as EventListener); } this._bindingEventService.bind(this._focusSink, 'keydown', this.handleKeyDown.bind(this) as EventListener); @@ -1146,7 +1012,7 @@ export class SlickGrid = Column, O e this._bindingEventService.unbindByEventName(this._container, 'resize'); this.removeCssRules(); - this._canvas.forEach((element) => { + this._viewportMgr.canvases.elements.forEach((element) => { this._bindingEventService.unbindByEventName(element, 'keydown'); this._bindingEventService.unbindByEventName(element, 'click'); this._bindingEventService.unbindByEventName(element, 'dblclick'); @@ -1154,34 +1020,30 @@ export class SlickGrid = Column, O e this._bindingEventService.unbindByEventName(element, 'mouseover'); this._bindingEventService.unbindByEventName(element, 'mouseout'); }); - this._viewport.forEach((view) => { + this._viewportMgr.viewports.elements.forEach((view) => { this._bindingEventService.unbindByEventName(view, 'scroll'); }); - this._headerScroller.forEach((el) => { + this._viewportMgr.headerScrollers.elements.forEach((el) => { this._bindingEventService.unbindByEventName(el, 'contextmenu'); this._bindingEventService.unbindByEventName(el, 'click'); }); - this._headerRowScroller.forEach((scroller) => { + this._viewportMgr.headerRowScrollers.elements.forEach((scroller) => { this._bindingEventService.unbindByEventName(scroller, 'scroll'); }); - if (this._footerRow) { - this._footerRow.forEach((footer) => { - this._bindingEventService.unbindByEventName(footer, 'contextmenu'); - this._bindingEventService.unbindByEventName(footer, 'click'); - }); - } + this._viewportMgr.footerRows.elements.forEach((footer) => { + this._bindingEventService.unbindByEventName(footer, 'contextmenu'); + this._bindingEventService.unbindByEventName(footer, 'click'); + }); - if (this._footerRowScroller) { - this._footerRowScroller.forEach((scroller) => { - this._bindingEventService.unbindByEventName(scroller, 'scroll'); - }); - } + this._viewportMgr.footerRowScrollers.elements.forEach((scroller) => { + this._bindingEventService.unbindByEventName(scroller, 'scroll'); + }); - if (this._preHeaderPanelScroller) { - this._bindingEventService.unbindByEventName(this._preHeaderPanelScroller, 'scroll'); + if (this._viewportMgr.preHeaderPanelScroller) { + this._bindingEventService.unbindByEventName(this._viewportMgr.preHeaderPanelScroller, 'scroll'); } if (this._topHeaderPanelScroller) { @@ -1245,74 +1107,18 @@ export class SlickGrid = Column, O e */ protected destroyAllElements() { // drop the ViewportMgr first — it holds references to every pane/viewport/canvas - // element and the container, which would otherwise keep the detached DOM alive + // element and the container, which would otherwise keep the detached DOM alive. + // The historical per-alias nulling left with the aliases (M19a): every element + // reference now lives behind the ViewportMgr, so dropping it drops them all. this._viewportMgr = null as any; this._activeCanvasNode = null as any; this._activeViewportNode = null as any; this._boundAncestors = null as any; - this._canvas = null as any; - this._canvasTopL = null as any; - this._canvasTopR = null as any; - this._canvasBottomL = null as any; - this._canvasBottomR = null as any; this._container = null as any; this._focusSink = null as any; this._focusSink2 = null as any; - this._groupHeaders = null as any; - this._groupHeadersL = null as any; - this._groupHeadersR = null as any; - this._headerL = null as any; - this._headerR = null as any; - this._headers = null as any; - this._headerRows = null as any; - this._headerRowL = null as any; - this._headerRowR = null as any; - this._headerRowSpacerL = null as any; - this._headerRowSpacerR = null as any; - this._headerRowScrollContainer = null as any; - this._headerRowScroller = null as any; - this._headerRowScrollerL = null as any; - this._headerRowScrollerR = null as any; - this._headerScrollContainer = null as any; - this._headerScroller = null as any; - this._headerScrollerL = null as any; - this._headerScrollerR = null as any; this._hiddenParents = null as any; - this._footerRow = null as any; - this._footerRowL = null as any; - this._footerRowR = null as any; - this._footerRowSpacerL = null as any; - this._footerRowSpacerR = null as any; - this._footerRowScroller = null as any; - this._footerRowScrollerL = null as any; - this._footerRowScrollerR = null as any; - this._footerRowScrollContainer = null as any; - this._preHeaderPanel = null as any; - this._preHeaderPanelR = null as any; - this._preHeaderPanelScroller = null as any; - this._preHeaderPanelScrollerR = null as any; - this._preHeaderPanelSpacer = null as any; - this._preHeaderPanelSpacerR = null as any; - this._topPanels = null as any; - this._topPanelScrollers = null as any; this._style = null as any; - this._topPanelScrollerL = null as any; - this._topPanelScrollerR = null as any; - this._topPanelL = null as any; - this._topPanelR = null as any; - this._paneHeaderL = null as any; - this._paneHeaderR = null as any; - this._paneTopL = null as any; - this._paneTopR = null as any; - this._paneBottomL = null as any; - this._paneBottomR = null as any; - this._viewport = null as any; - this._viewportTopL = null as any; - this._viewportTopR = null as any; - this._viewportBottomL = null as any; - this._viewportBottomR = null as any; - this._viewportScrollContainerX = null as any; - this._viewportScrollContainerY = null as any; } /** Returns an object containing all of the Grid options set on the grid. See a list of Grid Options here. */ @@ -1448,14 +1254,11 @@ export class SlickGrid = Column, O e this.enforceFrozenRowHeightRecalc = true; } - this._viewport.forEach((view) => { - view.style.overflowY = this._options.autoHeight ? 'hidden' : 'auto'; - }); + this._viewportMgr.viewports.setStyle({ overflowY: this._options.autoHeight ? 'hidden' : 'auto' }); if (!suppressRender) { this.render(); } - this.setScroller(); if (!suppressSetOverflow) { this.setOverflow(); } @@ -1464,8 +1267,8 @@ export class SlickGrid = Column, O e this.setColumns(this.columns); } - if (this._options.enableMouseWheelScrollHandler && this._viewport && (!this.slickMouseWheelInstances || this.slickMouseWheelInstances.length === 0)) { - this._viewport.forEach((view) => { + if (this._options.enableMouseWheelScrollHandler && this._viewportMgr.viewports.elements && (!this.slickMouseWheelInstances || this.slickMouseWheelInstances.length === 0)) { + this._viewportMgr.viewports.elements.forEach((view) => { this.slickMouseWheelInstances.push(MouseWheel({ element: view, onMouseWheel: this.handleMouseWheel.bind(this) @@ -1588,10 +1391,10 @@ export class SlickGrid = Column, O e */ getHeader(columnDef: C) { if (!columnDef) { - return this._viewportMgr.hasFrozenColumns() ? this._headers : this._headerL; + return this._viewportMgr.hasFrozenColumns() ? this._viewportMgr.headers.elements : this._viewportMgr.headerL; } const idx = this.getColumnIndex(columnDef.id); - return this._viewportMgr.bandElementForColumn(idx, this._headerL, this._headerR, this._viewportMgr.headerRF); + return this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.headerL, this._viewportMgr.headerR, this._viewportMgr.headerRF); } /** @@ -1600,7 +1403,7 @@ export class SlickGrid = Column, O e */ getHeaderColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const targetHeader = this._viewportMgr.bandElementForColumn(idx, this._headerL, this._headerR, this._viewportMgr.headerRF); + const targetHeader = this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.headerL, this._viewportMgr.headerR, this._viewportMgr.headerRF); const targetIndex = this._viewportMgr.bandLocalColumnIdx(idx); return targetHeader.children[targetIndex] as HTMLDivElement; @@ -1608,12 +1411,16 @@ export class SlickGrid = Column, O e /** Get the Header Row DOM element */ getHeaderRow() { - return this._viewportMgr.hasFrozenColumns() ? this._headerRows : this._headerRows[0]; + return this._viewportMgr.hasFrozenColumns() ? this._viewportMgr.headerRows.elements : this._viewportMgr.headerRows.first(); } /** Get the Footer DOM element */ getFooterRow() { - return this._viewportMgr.hasFrozenColumns() ? this._footerRow : this._footerRow[0]; + // historical shape preserved: the footer array was undefined unless + // createFooterRow, so the non-frozen [0] read throws — callers rely on + // getFooterRow() failing loudly in that configuration + const footerRow = this._options.createFooterRow ? this._viewportMgr.footerRows.elements : undefined; + return this._viewportMgr.hasFrozenColumns() ? footerRow : footerRow![0]; } /** @@ -1622,7 +1429,7 @@ export class SlickGrid = Column, O e */ getHeaderRowColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const headerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._headerRowL, this._headerRowR, this._viewportMgr.headerRowRF); + const headerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.headerRowL, this._viewportMgr.headerRowR, this._viewportMgr.headerRowRF); return headerRowTarget.children[this._viewportMgr.bandLocalColumnIdx(idx)] as HTMLDivElement; } @@ -1633,7 +1440,7 @@ export class SlickGrid = Column, O e */ getFooterRowColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const footerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._footerRowL, this._footerRowR, this._viewportMgr.footerRowRF); + const footerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.footerRowL, this._viewportMgr.footerRowR, this._viewportMgr.footerRowRF); return footerRowTarget.children[this._viewportMgr.bandLocalColumnIdx(idx)] as HTMLDivElement; } @@ -1645,7 +1452,7 @@ export class SlickGrid = Column, O e */ protected createColumnFooter() { if (this._options.createFooterRow) { - this._footerRow.forEach((footer) => { + this._viewportMgr.footerRows.elements.forEach((footer) => { const columnElements = footer.querySelectorAll('.slick-footerrow-column'); columnElements.forEach((column) => { const columnDef = Utils.storage.get(column, 'column'); @@ -1657,14 +1464,15 @@ export class SlickGrid = Column, O e }); }); - Utils.emptyElement(this._footerRowL); - Utils.emptyElement(this._footerRowR); + // RF footer deliberately excluded — historical asymmetry (only the + // createColumnHeaders reset path touches it); pick() keeps that grep-able + this._viewportMgr.footerRows.pick('l', 'r').empty(); for (let i = 0; i < this.columns.length; i++) { const m = this.columns[i]; if (!m || m.hidden) { continue; } - const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this._viewportMgr.bandElementForColumn(i, this._footerRowL, this._footerRowR, this._viewportMgr.footerRowRF)); + const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this._viewportMgr.bandElementForColumn(i, this._viewportMgr.footerRowL, this._viewportMgr.footerRowR, this._viewportMgr.footerRowRF)); const className = this._viewportMgr.isColumnInFrozenBand(i) ? 'frozen' : null; if (className) { footerRowCell.classList.add(className); @@ -1688,7 +1496,7 @@ export class SlickGrid = Column, O e * --> triggers onBeforeSort * --> and if not cancelled, updates the sort columns and triggers onSort. */ - protected setupColumnSort(headers: HTMLDivElement[] = this._headers) { + protected setupColumnSort(headers: HTMLDivElement[] = this._viewportMgr.headers.elements) { headers.forEach((header) => { this._bindingEventService.bind(header, 'click', (e: any) => { if (this.columnResizeDragging) { @@ -1793,88 +1601,45 @@ export class SlickGrid = Column, O e * and sort indicator elements. Also triggers before–destroy and rendered events as needed. */ protected createColumnHeaders() { - this._headers.forEach((header) => { - const columnElements = header.querySelectorAll('.slick-header-column'); - columnElements.forEach((column) => { - const columnDef = Utils.storage.get(column, 'column'); - if (columnDef) { - this.trigger(this.onBeforeHeaderCellDestroy, { - node: column, - column: columnDef, - grid: this - }); - } - }); + this._viewportMgr.headers.query('.slick-header-column').forEach((column) => { + const columnDef = Utils.storage.get(column, 'column'); + if (columnDef) { + this.trigger(this.onBeforeHeaderCellDestroy, { + node: column, + column: columnDef, + grid: this + }); + } }); - Utils.emptyElement(this._headerL); - if (this._headerR) { - Utils.emptyElement(this._headerR); - } - if (this._viewportMgr.headerRF) { - Utils.emptyElement(this._viewportMgr.headerRF); - } + this._viewportMgr.headers.empty(); this.getHeadersWidth(); - Utils.width(this._headerL, this.headersWidthL); - Utils.width(this._headerR, this.headersWidthR); - Utils.width(this._viewportMgr.headerRF, this.headersWidthRF); - - this._headerRows.forEach((row) => { - const columnElements = row.querySelectorAll('.slick-headerrow-column'); - columnElements.forEach((column) => { - const columnDef = Utils.storage.get(column, 'column'); - if (columnDef) { - this.trigger(this.onBeforeHeaderRowCellDestroy, { - node: this, - column: columnDef, - grid: this - }); - } - }); - }); - - Utils.emptyElement(this._headerRowL); - if (this._headerRowR) { - Utils.emptyElement(this._headerRowR); - } - if (this._viewportMgr.headerRowRF) { - Utils.emptyElement(this._viewportMgr.headerRowRF); - } - - if (this._options.createFooterRow) { - const footerRowLColumnElements = this._footerRowL.querySelectorAll('.slick-footerrow-column'); - footerRowLColumnElements.forEach((column) => { - const columnDef = Utils.storage.get(column, 'column'); - if (columnDef) { - this.trigger(this.onBeforeFooterRowCellDestroy, { - node: this, - column: columnDef, - grid: this - }); - } - }); - Utils.emptyElement(this._footerRowL); + this._viewportMgr.headers.width({ l: this.headersWidthL, r: this.headersWidthR, rf: this.headersWidthRF }); - if (this._viewportMgr.hasFrozenColumns()) { - const footerRowRColumnElements = this._footerRowR.querySelectorAll('.slick-footerrow-column'); - footerRowRColumnElements.forEach((column) => { - const columnDef = Utils.storage.get(column, 'column'); - if (columnDef) { - this.trigger(this.onBeforeFooterRowCellDestroy, { - node: this, - column: columnDef, - grid: this - }); - } + this._viewportMgr.headerRows.query('.slick-headerrow-column').forEach((column) => { + const columnDef = Utils.storage.get(column, 'column'); + if (columnDef) { + this.trigger(this.onBeforeHeaderRowCellDestroy, { + node: this, + column: columnDef, + grid: this }); - Utils.emptyElement(this._footerRowR); } + }); + + this._viewportMgr.headerRows.empty(); - if (this._viewportMgr.footerRowRF) { - const footerRowRFColumnElements = this._viewportMgr.footerRowRF.querySelectorAll('.slick-footerrow-column'); - footerRowRFColumnElements.forEach((column) => { + if (this._options.createFooterRow) { + // historical band gating preserved verbatim: L always, R only under a LEFT + // FREEZE (not by existence — an un-frozen grid with materialized panes skips + // R), RF by existence. Per-band event/empty interleave also preserved. + const footerBands = this._viewportMgr.hasFrozenColumns() + ? this._viewportMgr.footerRows + : this._viewportMgr.footerRows.pick('l', 'rf'); + footerBands.forEach((footer) => { + footer.querySelectorAll('.slick-footerrow-column').forEach((column) => { const columnDef = Utils.storage.get(column, 'column'); if (columnDef) { this.trigger(this.onBeforeFooterRowCellDestroy, { @@ -1884,16 +1649,16 @@ export class SlickGrid = Column, O e }); } }); - Utils.emptyElement(this._viewportMgr.footerRowRF); - } + Utils.emptyElement(footer); + }); } for (let i = 0; i < this.columns.length; i++) { const m: C = this.columns[i]; if (m.hidden) { continue; } - const headerTarget = this._viewportMgr.bandElementForColumn(i, this._headerL, this._headerR, this._viewportMgr.headerRF); - const headerRowTarget = this._viewportMgr.bandElementForColumn(i, this._headerRowL, this._headerRowR, this._viewportMgr.headerRowRF); + const headerTarget = this._viewportMgr.bandElementForColumn(i, this._viewportMgr.headerL, this._viewportMgr.headerR, this._viewportMgr.headerRF); + const headerRowTarget = this._viewportMgr.bandElementForColumn(i, this._viewportMgr.headerRowL, this._viewportMgr.headerRowR, this._viewportMgr.headerRowRF); const header = Utils.createDomElement('div', { id: `${this.uid + m.id}`, dataset: { id: String(m.id) }, role: 'columnheader', className: 'ui-state-default slick-state-default slick-header-column' }, headerTarget); if (m.toolTip) { @@ -1967,7 +1732,7 @@ export class SlickGrid = Column, O e }); } if (this._options.createFooterRow && this._options.showFooterRow) { - const footerRowTarget = this._viewportMgr.bandElementForColumn(i, this._footerRow[0], this._footerRow[1], this._viewportMgr.footerRowRF); + const footerRowTarget = this._viewportMgr.bandElementForColumn(i, this._viewportMgr.footerRows.first(), this._viewportMgr.footerRows.elements[1], this._viewportMgr.footerRowRF); const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, footerRowTarget); Utils.storage.put(footerRowCell, 'column', m); @@ -1983,7 +1748,7 @@ export class SlickGrid = Column, O e this.setupColumnResize(); if (this._options.enableColumnReorder) { if (typeof this._options.enableColumnReorder === 'function') { - this._options.enableColumnReorder(this as unknown as SlickGridModel, this._headers, this.headerColumnWidthDiff, this.setColumns as any, this.setupColumnResize, this.columns, this.getColumnIndex, this.uid, this.trigger); + this._options.enableColumnReorder(this as unknown as SlickGridModel, this._viewportMgr.headers.elements, this.headerColumnWidthDiff, this.setColumns as any, this.setupColumnResize, this.columns, this.getColumnIndex, this.uid, this.trigger); } else { this.setupColumnReorder(); } @@ -2002,8 +1767,8 @@ export class SlickGrid = Column, O e let columnScrollTimer: any = null; - const scrollColumnsRight = () => this._viewportScrollContainerX.scrollLeft = this._viewportScrollContainerX.scrollLeft + 10; - const scrollColumnsLeft = () => this._viewportScrollContainerX.scrollLeft = this._viewportScrollContainerX.scrollLeft - 10; + const scrollColumnsRight = () => this._viewportMgr.scrollContainerX.scrollLeft = this._viewportMgr.scrollContainerX.scrollLeft + 10; + const scrollColumnsLeft = () => this._viewportMgr.scrollContainerX.scrollLeft = this._viewportMgr.scrollContainerX.scrollLeft - 10; let prevColumnIds: Array = []; let canDragScroll = false; @@ -2024,13 +1789,13 @@ export class SlickGrid = Column, O e }, onStart: (e: SortableEvent) => { e.item.classList.add('slick-header-column-active'); - canDragScroll = !this._viewportMgr.hasFrozenColumns() || Utils.offset(e.item)!.left > Utils.offset(this._viewportScrollContainerX)!.left; + canDragScroll = !this._viewportMgr.hasFrozenColumns() || Utils.offset(e.item)!.left > Utils.offset(this._viewportMgr.scrollContainerX)!.left; if (canDragScroll && e.originalEvent.pageX > this._container.clientWidth) { if (!(columnScrollTimer)) { columnScrollTimer = window.setInterval(scrollColumnsRight, 100); } - } else if (canDragScroll && e.originalEvent.pageX < Utils.offset(this._viewportScrollContainerX)!.left) { + } else if (canDragScroll && e.originalEvent.pageX < Utils.offset(this._viewportMgr.scrollContainerX)!.left) { if (!(columnScrollTimer)) { columnScrollTimer = window.setInterval(scrollColumnsLeft, 100); } @@ -2071,8 +1836,8 @@ export class SlickGrid = Column, O e }, } as SortableOptions; - this.sortableSideLeftInstance = Sortable.create(this._headerL, sortableOptions); - this.sortableSideRightInstance = Sortable.create(this._headerR, sortableOptions); + this.sortableSideLeftInstance = Sortable.create(this._viewportMgr.headerL, sortableOptions); + this.sortableSideRightInstance = Sortable.create(this._viewportMgr.headerR, sortableOptions); } /** @@ -2082,7 +1847,7 @@ export class SlickGrid = Column, O e protected getHeaderChildren() { // _headers only contains the header containers that were actually built // (a single left container under lazyPanes) - return this._headers.flatMap((headerEl) => Array.from(headerEl.children)) as HTMLElement[]; + return this._viewportMgr.headers.elements.flatMap((headerEl) => Array.from(headerEl.children)) as HTMLElement[]; } /** @@ -2394,8 +2159,8 @@ export class SlickGrid = Column, O e } if (this._viewportMgr.hasFrozenColumns() && newCanvasWidthL !== this.canvasWidthL) { - Utils.width(this._headerL, newCanvasWidthL + 1000); - Utils.setStyleSize(this._paneHeaderR, 'left', newCanvasWidthL); + Utils.width(this._viewportMgr.headerL, newCanvasWidthL + 1000); + Utils.setStyleSize(this._viewportMgr.paneHeaderR, 'left', newCanvasWidthL); } this.applyColumnHeaderWidths(); @@ -3301,7 +3066,7 @@ export class SlickGrid = Column, O e let columnIndex = 0; const vc = this.getVisibleColumns(); - this._headers.forEach((header) => { + this._viewportMgr.headers.elements.forEach((header) => { for (let i = 0; i < header.children.length; i++, columnIndex++) { const h = header.children[i] as HTMLElement; const col = vc[columnIndex] || {}; @@ -3373,7 +3138,7 @@ export class SlickGrid = Column, O e */ getColumnByIndex(id: number) { let result: HTMLElement | undefined; - this._headers.every((header) => { + this._viewportMgr.headers.elements.every((header) => { const length = header.children.length; if (id < length) { result = header.children[id] as HTMLElement; @@ -3398,21 +3163,14 @@ export class SlickGrid = Column, O e this.sortColumns = cols; const numberCols = this._options.numberedMultiColumnSort && this.sortColumns.length > 1; - this._headers.forEach((header) => { - let indicators = header.querySelectorAll('.slick-header-column-sorted'); - indicators.forEach((indicator) => { - indicator.classList.remove('slick-header-column-sorted'); - }); - - indicators = header.querySelectorAll('.slick-sort-indicator'); - indicators.forEach((indicator) => { - indicator.classList.remove('slick-sort-indicator-asc'); - indicator.classList.remove('slick-sort-indicator-desc'); - }); - indicators = header.querySelectorAll('.slick-sort-indicator-numbered'); - indicators.forEach((el) => { - el.textContent = ''; - }); + this._viewportMgr.headers.query('.slick-header-column-sorted').forEach((indicator) => { + indicator.classList.remove('slick-header-column-sorted'); + }); + this._viewportMgr.headers.query('.slick-sort-indicator').forEach((indicator) => { + indicator.classList.remove('slick-sort-indicator-asc', 'slick-sort-indicator-desc'); + }); + this._viewportMgr.headers.query('.slick-sort-indicator-numbered').forEach((el) => { + el.textContent = ''; }); let i = 1; @@ -3779,7 +3537,7 @@ export class SlickGrid = Column, O e if (this._viewportMgr.hasFrozenRows() && isBottom) { rowOffset -= (this._options.frozenBottom) - ? Utils.height(this._canvasTopL) as number + ? Utils.height(this._viewportMgr.canvasTopL) as number : this.frozenRowsHeight; } @@ -4283,12 +4041,12 @@ export class SlickGrid = Column, O e * @param {number} deltaY - The vertical scroll delta. */ protected handleMouseWheel(e: MouseEvent, _delta: number, deltaX: number, deltaY: number) { - this.scrollHeight = this._viewportScrollContainerY.scrollHeight; + this.scrollHeight = this._viewportMgr.scrollContainerY.scrollHeight; if (e.shiftKey) { - this.scrollLeft = this._viewportScrollContainerX.scrollLeft + (deltaX * 10); + this.scrollLeft = this._viewportMgr.scrollContainerX.scrollLeft + (deltaX * 10); } else { - this.scrollTop = Math.max(0, this._viewportScrollContainerY.scrollTop - (deltaY * this._options.rowHeight!)); - this.scrollLeft = this._viewportScrollContainerX.scrollLeft + (deltaX * 10); + this.scrollTop = Math.max(0, this._viewportMgr.scrollContainerY.scrollTop - (deltaY * this._options.rowHeight!)); + this.scrollLeft = this._viewportMgr.scrollContainerX.scrollLeft + (deltaX * 10); } const handled = this._handleScroll('mousewheel'); if (handled) { @@ -4764,7 +4522,7 @@ export class SlickGrid = Column, O e // bottom-frozen band: canvas origin is the band's first row rowOffset = (this.getDataLength() - this._options.frozenBottomRow!) * this._options.rowHeight!; } else if (isBottom) { - rowOffset = (this._options.frozenBottom) ? Utils.height(this._canvasTopL) as number : this.frozenRowsHeight; + rowOffset = (this._options.frozenBottom) ? Utils.height(this._viewportMgr.canvasTopL) as number : this.frozenRowsHeight; } row = this.getCellFromPoint(targetEvent.clientX - c!.left, targetEvent.clientY - c!.top + rowOffset + document.documentElement.scrollTop).row; @@ -4851,7 +4609,7 @@ export class SlickGrid = Column, O e /** Get the canvas DOM element */ getCanvases() { - return this._canvas; + return this._viewportMgr.canvases.elements; } /** Get the Viewport DOM node element */ @@ -4861,7 +4619,7 @@ export class SlickGrid = Column, O e /** Get all the Viewport node elements */ getViewports() { - return this._viewport; + return this._viewportMgr.viewports.elements; } /** @@ -5118,17 +4876,17 @@ export class SlickGrid = Column, O e /** @alias `getPreHeaderPanelLeft` */ getPreHeaderPanel() { - return this._preHeaderPanel; + return this._viewportMgr.preHeaderPanel; } /** Get the Pre-Header Panel Left DOM node element */ getPreHeaderPanelLeft() { - return this._preHeaderPanel; + return this._viewportMgr.preHeaderPanel; } /** Get the Pre-Header Panel Right DOM node element */ getPreHeaderPanelRight() { - return this._preHeaderPanelR; + return this._viewportMgr.preHeaderPanelR; } /** Get the Top-Header Panel DOM node element */ @@ -5288,12 +5046,12 @@ export class SlickGrid = Column, O e /** Get Top Panel DOM element */ getTopPanel() { - return this._topPanels[0]; + return this._viewportMgr.topPanels.first(); } /** Get Top Panels (left/right) DOM element */ getTopPanels() { - return this._topPanels; + return this._viewportMgr.topPanels.elements; } /** @@ -5334,7 +5092,7 @@ export class SlickGrid = Column, O e * @param {Boolean} [animate] - optionally enable an animation while toggling the panel */ setTopPanelVisibility(visible?: boolean, animate?: boolean) { - this.togglePanelVisibility('showTopPanel', this._topPanelScrollers, visible, animate); + this.togglePanelVisibility('showTopPanel', this._viewportMgr.topPanelScrollers.elements, visible, animate); } /** @@ -5343,7 +5101,7 @@ export class SlickGrid = Column, O e * @param {Boolean} [animate] - optionally enable an animation while toggling the panel */ setHeaderRowVisibility(visible?: boolean, animate?: boolean) { - this.togglePanelVisibility('showHeaderRow', this._headerRowScroller, visible, animate); + this.togglePanelVisibility('showHeaderRow', this._viewportMgr.headerRowScrollers.elements, visible, animate); } /** @@ -5352,7 +5110,7 @@ export class SlickGrid = Column, O e * @param {Boolean} [animate] - optionally enable an animation while toggling the panel */ setColumnHeaderVisibility(visible?: boolean, animate?: boolean) { - this.togglePanelVisibility('showColumnHeader', this._headerScroller, visible, animate); + this.togglePanelVisibility('showColumnHeader', this._viewportMgr.headerScrollers.elements, visible, animate); } /** @@ -5361,7 +5119,7 @@ export class SlickGrid = Column, O e * @param {Boolean} [animate] - optionally enable an animation while toggling the panel */ setFooterRowVisibility(visible?: boolean, animate?: boolean) { - this.togglePanelVisibility('showFooterRow', this._footerRowScroller, visible, animate); + this.togglePanelVisibility('showFooterRow', this._viewportMgr.footerRowScrollers.elements, visible, animate); } /** @@ -5370,7 +5128,7 @@ export class SlickGrid = Column, O e * @param {Boolean} [animate] - optionally enable an animation while toggling the panel */ setPreHeaderPanelVisibility(visible?: boolean, animate?: boolean) { - this.togglePanelVisibility('showPreHeaderPanel', [this._preHeaderPanelScroller, this._preHeaderPanelScrollerR], visible, animate); + this.togglePanelVisibility('showPreHeaderPanel', this._viewportMgr.preHeaderScrollers.elements, visible, animate); } /** @@ -5914,16 +5672,16 @@ export class SlickGrid = Column, O e */ getViewportHeight() { if (!this._options.autoHeight || this._options.frozenColumn !== -1) { - this.topPanelH = (this._options.showTopPanel) ? this._options.topPanelHeight! + this.getVBoxDelta(this._topPanelScrollers[0]) : 0; - this.headerRowH = (this._options.showHeaderRow) ? this._options.headerRowHeight! + this.getVBoxDelta(this._headerRowScroller[0]) : 0; - this.footerRowH = (this._options.showFooterRow) ? this._options.footerRowHeight! + this.getVBoxDelta(this._footerRowScroller[0]) : 0; + this.topPanelH = (this._options.showTopPanel) ? this._options.topPanelHeight! + this.getVBoxDelta(this._viewportMgr.topPanelScrollers.first()) : 0; + this.headerRowH = (this._options.showHeaderRow) ? this._options.headerRowHeight! + this.getVBoxDelta(this._viewportMgr.headerRowScrollers.first()) : 0; + this.footerRowH = (this._options.showFooterRow) ? this._options.footerRowHeight! + this.getVBoxDelta(this._viewportMgr.footerRowScrollers.first()) : 0; } if (this._options.autoHeight) { - let fullHeight = this._paneHeaderL.offsetHeight; - fullHeight += (this._options.showPreHeaderPanel) ? this._options.preHeaderPanelHeight! + this.getVBoxDelta(this._preHeaderPanelScroller) : 0; - fullHeight += (this._options.showHeaderRow) ? this._options.headerRowHeight! + this.getVBoxDelta(this._headerRowScroller[0]) : 0; - fullHeight += (this._options.showFooterRow) ? this._options.footerRowHeight! + this.getVBoxDelta(this._footerRowScroller[0]) : 0; + let fullHeight = this._viewportMgr.paneHeaderL.offsetHeight; + fullHeight += (this._options.showPreHeaderPanel) ? this._options.preHeaderPanelHeight! + this.getVBoxDelta(this._viewportMgr.preHeaderPanelScroller) : 0; + fullHeight += (this._options.showHeaderRow) ? this._options.headerRowHeight! + this.getVBoxDelta(this._viewportMgr.headerRowScrollers.first()) : 0; + fullHeight += (this._options.showFooterRow) ? this._options.footerRowHeight! + this.getVBoxDelta(this._viewportMgr.footerRowScrollers.first()) : 0; fullHeight += (this.getCanvasWidth() > this.viewportW) ? (this.scrollbarDimensions?.height ?? 0) : 0; this.viewportH = this._options.rowHeight! @@ -5933,8 +5691,8 @@ export class SlickGrid = Column, O e const style = getComputedStyle(this._container); const containerBoxH = style.boxSizing !== 'content-box' ? this.getVBoxDelta(this._container) : 0; const topHeaderH = (this._options.createTopHeaderPanel && this._options.showTopHeaderPanel) ? this._options.topHeaderPanelHeight! + this.getVBoxDelta(this._topHeaderPanelScroller) : 0; - const preHeaderH = (this._options.createPreHeaderPanel && this._options.showPreHeaderPanel) ? this._options.preHeaderPanelHeight! + this.getVBoxDelta(this._preHeaderPanelScroller) : 0; - const columnNamesH = (this._options.showColumnHeader) ? Utils.toFloat(Utils.height(this._headerScroller[0]) as number) : 0; + const preHeaderH = (this._options.createPreHeaderPanel && this._options.showPreHeaderPanel) ? this._options.preHeaderPanelHeight! + this.getVBoxDelta(this._viewportMgr.preHeaderPanelScroller) : 0; + const columnNamesH = (this._options.showColumnHeader) ? Utils.toFloat(Utils.height(this._viewportMgr.headerScrollers.first()) as number) : 0; this.viewportH = Utils.toFloat(style.height) - Utils.toFloat(style.paddingTop) - Utils.toFloat(style.paddingBottom) @@ -6046,7 +5804,7 @@ export class SlickGrid = Column, O e numberOfRows = dataLengthIncludingAddNew + (this._options.leaveSpaceForNewRows ? this.numVisibleRows - 1 : 0); } - const tempViewportH = Utils.height(this._viewportScrollContainerY) as number; + const tempViewportH = Utils.height(this._viewportMgr.scrollContainerY) as number; const oldViewportHasVScroll = this.viewportHasVScroll; // with autoHeight, we do not need to accommodate the vertical scroll bar this.viewportHasVScroll = this._options.alwaysShowVerticalScroll || !this._options.autoHeight && (numberOfRows * this._options.rowHeight! > tempViewportH); @@ -6094,26 +5852,26 @@ export class SlickGrid = Column, O e if (this.h !== oldH || this.enforceFrozenRowHeightRecalc) { if (this._viewportMgr.hasFrozenRows() && !this._options.frozenBottom) { - Utils.height(this._canvasBottomL, this.h); + Utils.height(this._viewportMgr.canvasBottomL, this.h); if (this._viewportMgr.hasFrozenColumns()) { - Utils.height(this._canvasBottomR, this.h); + Utils.height(this._viewportMgr.canvasBottomR, this.h); } if (this._options.frozenRightColumn! > 0 && this._viewportMgr.canvasBottomRF) { Utils.height(this._viewportMgr.canvasBottomRF, this.h); } } else { - Utils.height(this._canvasTopL, this.h); - if (this._canvasTopR) { - Utils.height(this._canvasTopR, this.h); + Utils.height(this._viewportMgr.canvasTopL, this.h); + if (this._viewportMgr.canvasTopR) { + Utils.height(this._viewportMgr.canvasTopR, this.h); } if (this._options.frozenRightColumn! > 0 && this._viewportMgr.canvasTopRF) { Utils.height(this._viewportMgr.canvasTopRF, this.h); } } - this.scrollTop = this._viewportScrollContainerY.scrollTop; - this.scrollHeight = this._viewportScrollContainerY.scrollHeight; + this.scrollTop = this._viewportMgr.scrollContainerY.scrollTop; + this.scrollHeight = this._viewportMgr.scrollContainerY.scrollHeight; this.enforceFrozenRowHeightRecalc = false; // reset enforce flag } @@ -6620,7 +6378,7 @@ export class SlickGrid = Column, O e let elem: HTMLElement | null = this._viewportMgr.bodyCanvasL(); while ((elem = elem!.parentNode as HTMLElement) !== document.body && elem) { // bind to scroll containers only - if (elem === this._viewportTopL || elem.scrollWidth !== elem.clientWidth || elem.scrollHeight !== elem.clientHeight) { + if (elem === this._viewportMgr.viewportTopL || elem.scrollWidth !== elem.clientWidth || elem.scrollHeight !== elem.clientHeight) { this._boundAncestors.push(elem); this._bindingEventService.bind(elem, 'scroll', this.handleActiveCellPositionChange.bind(this)); } @@ -6638,19 +6396,6 @@ export class SlickGrid = Column, O e this._boundAncestors = []; } - /** - * Chooses which viewport container(s) will serve as the scroll container for horizontal and vertical scrolling. - * The selection depends on whether the grid has frozen columns and/or frozen rows and whether frozenBottom is set. - */ - protected setScroller() { - const containers = this._viewportMgr.selectScrollContainers(); - this._viewportScrollContainerX = containers.x; - this._viewportScrollContainerY = containers.y; - this._headerScrollContainer = containers.header; - this._headerRowScrollContainer = containers.headerRow; - this._footerRowScrollContainer = containers.footerRow; - } - /** * Scroll to a Y position in the grid (clamped to valid bounds) * @@ -6661,7 +6406,7 @@ export class SlickGrid = Column, O e */ scrollTo(y: number) { y = Math.max(y, 0); - y = Math.min(y, (this.th || 0) - (Utils.height(this._viewportScrollContainerY) as number) + ((this.viewportHasHScroll || this._viewportMgr.hasFrozenColumns()) ? (this.scrollbarDimensions?.height ?? 0) : 0)); + y = Math.min(y, (this.th || 0) - (Utils.height(this._viewportMgr.scrollContainerY) as number) + ((this.viewportHasHScroll || this._viewportMgr.hasFrozenColumns()) ? (this.scrollbarDimensions?.height ?? 0) : 0)); const oldOffset = this.offset; // determine the page for the target position first, then derive the offset from that page @@ -6681,11 +6426,11 @@ export class SlickGrid = Column, O e this.lastRenderedScrollTop = (this.scrollTop = this.prevScrollTop = newScrollTop); if (this._viewportMgr.hasFrozenColumns()) { - this._viewportTopL.scrollTop = newScrollTop; + this._viewportMgr.viewportTopL.scrollTop = newScrollTop; } if (this._viewportMgr.hasFrozenRows()) { - this._viewportBottomL.scrollTop = this._viewportBottomR.scrollTop = newScrollTop; + this._viewportMgr.viewportBottomL.scrollTop = this._viewportMgr.viewportBottomR.scrollTop = newScrollTop; } // right-frozen viewports follow programmatic Y scrolling too @@ -6696,8 +6441,8 @@ export class SlickGrid = Column, O e } } - if (this._viewportScrollContainerY) { - this._viewportScrollContainerY.scrollTop = newScrollTop; + if (this._viewportMgr.scrollContainerY) { + this._viewportMgr.scrollContainerY.scrollTop = newScrollTop; } this.trigger(this.onViewportChanged, {}); @@ -6706,17 +6451,17 @@ export class SlickGrid = Column, O e // When the header row scroller is scrolled, ensures that the viewport’s horizontal scroll position is updated to match it. protected handleHeaderRowScroll() { - const scrollLeft = this._headerRowScrollContainer.scrollLeft; - if (scrollLeft !== this._viewportScrollContainerX.scrollLeft) { - this._viewportScrollContainerX.scrollLeft = scrollLeft; + const scrollLeft = this._viewportMgr.headerRowScrollContainer.scrollLeft; + if (scrollLeft !== this._viewportMgr.scrollContainerX.scrollLeft) { + this._viewportMgr.scrollContainerX.scrollLeft = scrollLeft; } } // When the footer row scroller is scrolled, updates the viewport’s horizontal scroll position to match it. protected handleFooterRowScroll() { - const scrollLeft = this._footerRowScrollContainer.scrollLeft; - if (scrollLeft !== this._viewportScrollContainerX.scrollLeft) { - this._viewportScrollContainerX.scrollLeft = scrollLeft; + const scrollLeft = this._viewportMgr.footerRowScrollContainer.scrollLeft; + if (scrollLeft !== this._viewportMgr.scrollContainerX.scrollLeft) { + this._viewportMgr.scrollContainerX.scrollLeft = scrollLeft; } } @@ -6724,7 +6469,7 @@ export class SlickGrid = Column, O e * horizontal scroll position with the main viewport. */ protected handlePreHeaderPanelScroll() { - this.handleElementScroll(this._preHeaderPanelScroller); + this.handleElementScroll(this._viewportMgr.preHeaderPanelScroller); } /** @@ -6743,8 +6488,8 @@ export class SlickGrid = Column, O e */ protected handleElementScroll(element: HTMLElement) { const scrollLeft = element.scrollLeft; - if (scrollLeft !== this._viewportScrollContainerX.scrollLeft) { - this._viewportScrollContainerX.scrollLeft = scrollLeft; + if (scrollLeft !== this._viewportMgr.scrollContainerX.scrollLeft) { + this._viewportMgr.scrollContainerX.scrollLeft = scrollLeft; } } @@ -6758,9 +6503,9 @@ export class SlickGrid = Column, O e * @returns {boolean} The result of `_handleScroll`. */ protected handleScroll(e?: Event) { - this.scrollHeight = this._viewportScrollContainerY.scrollHeight; - this.scrollTop = this._viewportScrollContainerY.scrollTop; - this.scrollLeft = this._viewportScrollContainerX.scrollLeft; + this.scrollHeight = this._viewportMgr.scrollContainerY.scrollHeight; + this.scrollTop = this._viewportMgr.scrollContainerY.scrollTop; + this.scrollLeft = this._viewportMgr.scrollContainerX.scrollLeft; return this._handleScroll(e ? 'scroll' : 'system'); } @@ -6779,8 +6524,8 @@ export class SlickGrid = Column, O e * @returns {boolean} True if any scroll movement occurred, otherwise false. */ protected _handleScroll(eventType: 'mousewheel' | 'scroll' | 'system' = 'system') { - let maxScrollDistanceY = this._viewportScrollContainerY.scrollHeight - this._viewportScrollContainerY.clientHeight; - let maxScrollDistanceX = this._viewportScrollContainerY.scrollWidth - this._viewportScrollContainerY.clientWidth; + let maxScrollDistanceY = this._viewportMgr.scrollContainerY.scrollHeight - this._viewportMgr.scrollContainerY.clientHeight; + let maxScrollDistanceX = this._viewportMgr.scrollContainerY.scrollWidth - this._viewportMgr.scrollContainerY.clientWidth; // Protect against erroneous clientHeight/Width greater than scrollHeight/Width. // Sometimes seen in Chrome. @@ -6814,7 +6559,7 @@ export class SlickGrid = Column, O e this.prevScrollTop = this.scrollTop; if (eventType === 'mousewheel') { - this._viewportScrollContainerY.scrollTop = this.scrollTop; + this._viewportMgr.scrollContainerY.scrollTop = this.scrollTop; } this._viewportMgr.syncVerticalFollowers(this.scrollTop); @@ -6894,14 +6639,14 @@ export class SlickGrid = Column, O e * @param right */ protected internalScrollColumnIntoView(left: number, right: number) { - const scrollRight = this.scrollLeft + (Utils.width(this._viewportScrollContainerX) as number) - (this.viewportHasVScroll ? (this.scrollbarDimensions?.width ?? 0) : 0); + const scrollRight = this.scrollLeft + (Utils.width(this._viewportMgr.scrollContainerX) as number) - (this.viewportHasVScroll ? (this.scrollbarDimensions?.width ?? 0) : 0); if (left < this.scrollLeft) { - this._viewportScrollContainerX.scrollLeft = left; + this._viewportMgr.scrollContainerX.scrollLeft = left; this.handleScroll(); this.render(); } else if (right > scrollRight) { - this._viewportScrollContainerX.scrollLeft = Math.min(left, right - this._viewportScrollContainerX.clientWidth); + this._viewportMgr.scrollContainerX.scrollLeft = Math.min(left, right - this._viewportMgr.scrollContainerX.clientWidth); this.handleScroll(); this.render(); } @@ -7097,7 +6842,7 @@ export class SlickGrid = Column, O e (!this._options.frozenBottom && row > this.actualFrozenRow - 1) || (this._options.frozenBottom && row < this.actualFrozenRow - 1)) { - const viewportScrollH = Utils.height(this._viewportScrollContainerY) as number; + const viewportScrollH = Utils.height(this._viewportMgr.scrollContainerY) as number; // if frozen row on top // subtract number of frozen row @@ -7682,7 +7427,7 @@ export class SlickGrid = Column, O e */ protected measureScrollbar() { let className = ''; - this._viewport.forEach(v => className += v.className); + this._viewportMgr.viewports.elements.forEach(v => className += v.className); const outerdiv = Utils.createDomElement('div', { className, style: { position: 'absolute', top: '-10000px', left: '-10000px', overflow: 'auto', width: '100px', height: '100px' } }, document.body); const innerdiv = Utils.createDomElement('div', { style: { width: '200px', height: '200px', overflow: 'auto' } }, outerdiv); const dim = { @@ -7783,7 +7528,7 @@ export class SlickGrid = Column, O e protected measureCellPaddingAndBorder() { const h = ['borderLeftWidth', 'borderRightWidth', 'paddingLeft', 'paddingRight']; const v = ['borderTopWidth', 'borderBottomWidth', 'paddingTop', 'paddingBottom']; - const header = this._headers[0]; + const header = this._viewportMgr.headers.first(); this.headerColumnWidthDiff = this.headerColumnHeightDiff = 0; this.cellWidthDiff = this.cellHeightDiff = 0; @@ -7796,7 +7541,7 @@ export class SlickGrid = Column, O e } el.remove(); - const r = Utils.createDomElement('div', { className: 'slick-row' }, this._canvas[0]); + const r = Utils.createDomElement('div', { className: 'slick-row' }, this._viewportMgr.canvases.first()); el = Utils.createDomElement('div', { className: 'slick-cell', id: '', style: { visibility: 'hidden' }, textContent: '-' }, r); style = getComputedStyle(el); if (style.boxSizing !== 'border-box') { From c116daf7e3a98b0dc19f92a1c9c3040320fa111e Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 19 Jul 2026 09:51:16 +0930 Subject: [PATCH 35/43] test: characterize band routing + predicate boundary semantics ahead of M19b Pins, at exact boundary indices across three freeze configurations: - cross-band getHeaderColumn/getHeaderRowColumn/getColumnByIndex routing at the left-freeze and right-frozen boundaries - the INCLUSIVE <= frozenColumn / <= frozenRow comparisons, incl. the historical off-by-one where the row equal to frozenRow carries the frozen class but renders in the scrollable canvas (classic and simultaneous modes) - the frozen-class split: header cells left-band-only, data cells left OR right band - scrollCellIntoView early-outs for both frozen sides (boundary inclusive) All 14 tests pass against current code before any routing logic moves. Co-Authored-By: Claude Fable 5 --- cypress/e2e/viewportmgr-band-routing.cy.ts | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 cypress/e2e/viewportmgr-band-routing.cy.ts diff --git a/cypress/e2e/viewportmgr-band-routing.cy.ts b/cypress/e2e/viewportmgr-band-routing.cy.ts new file mode 100644 index 00000000..0222a18e --- /dev/null +++ b/cypress/e2e/viewportmgr-band-routing.cy.ts @@ -0,0 +1,131 @@ +/** + * Characterization of the band ROUTING and PREDICATE semantics ahead of M19b + * (facade column/row routing — FACADE-FEASIBILITY.md). These tests pin the + * boundary-value behavior the 'one predicate per historical semantic' rule + * protects: the inclusive `<= frozenColumn` / `<= frozenRow` comparisons, the + * header-cells-take-'frozen'-left-band-only vs data-cells-left-OR-right split, + * and cross-band element routing at the exact boundary indices. + * + * Named to sort after the example-* specs (shared browser session; see + * viewportmgr-lazy-materialization.cy.ts). + */ + +describe('band routing - left freeze + frozen rows (example-frozen-columns-and-rows: frozenColumn 2, frozenRow 5)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns-and-rows.html`); + }); + + it('should route getHeaderColumn across the freeze boundary: idx 2 -> left container, idx 3 -> right container', () => { + cy.window().then((win: any) => { + const left = win.grid.getHeaderColumn(2) as HTMLElement; + const right = win.grid.getHeaderColumn(3) as HTMLElement; + expect(left.parentElement!.classList.contains('slick-header-columns-left'), 'idx 2 in left band').to.be.true; + expect(right.parentElement!.classList.contains('slick-header-columns-right'), 'idx 3 in main band').to.be.true; + }); + }); + + it('should route getHeaderRowColumn across the same boundary', () => { + cy.window().then((win: any) => { + const left = win.grid.getHeaderRowColumn(2) as HTMLElement; + const right = win.grid.getHeaderRowColumn(3) as HTMLElement; + expect(left.parentElement!.classList.contains('slick-headerrow-columns-left'), 'idx 2 in left band').to.be.true; + expect(right.parentElement!.classList.contains('slick-headerrow-columns-right'), 'idx 3 in main band').to.be.true; + }); + }); + + it('should give header CELLS the frozen class only in the left band, inclusive of the boundary column', () => { + cy.window().then((win: any) => { + [0, 1, 2].forEach((i) => { + expect((win.grid.getHeaderColumn(i) as HTMLElement).classList.contains('frozen'), `header ${i} frozen`).to.be.true; + }); + expect((win.grid.getHeaderColumn(3) as HTMLElement).classList.contains('frozen'), 'header 3 not frozen').to.be.false; + }); + }); + + it('should mark every top-band row frozen AND exactly one bottom-canvas row (the row == frozenRow inclusive quirk)', () => { + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').then(($rows) => { + expect($rows.length, '5 frozen-band rows rendered').to.eq(5); + $rows.each((_, row) => expect(row.classList.contains('frozen'), 'top row frozen').to.be.true); + }); + // row index 5 satisfies `row <= frozenRow` (inclusive) but renders in the + // scrollable bottom canvas — the historical off-by-one, pinned deliberately + cy.get('#myGrid .grid-canvas-bottom.grid-canvas-left .slick-row.frozen').should('have.length', 1); + }); + + it('should give data CELLS the frozen class in the left band only', () => { + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').first().find('.slick-cell.frozen').should('have.length', 3); + cy.get('#myGrid .grid-canvas-top.grid-canvas-right .slick-row').first().find('.slick-cell.frozen').should('have.length', 0); + }); + + it('should span bands in getColumnByIndex and getHeaderChildren', () => { + cy.window().then((win: any) => { + const visibleCount = win.grid.getColumns().filter((c: any) => !c.hidden).length; + expect(win.grid.getHeaderChildren().length, 'header children across all bands').to.eq(visibleCount); + expect(win.grid.getColumnByIndex(2), 'idx 2 same element via both walks').to.eq(win.grid.getHeaderColumn(2)); + expect(win.grid.getColumnByIndex(3), 'idx 3 same element via both walks').to.eq(win.grid.getHeaderColumn(3)); + }); + }); + + it('should never horizontally scroll for a left-frozen cell, inclusive of the boundary column', () => { + cy.window().then((win: any) => { + const scroller = win.document.querySelector('#myGrid .slick-pane-top.slick-pane-right .slick-viewport') as HTMLElement; + expect(scroller.scrollLeft, 'starts unscrolled').to.eq(0); + win.grid.scrollCellIntoView(8, 2); // boundary column: cell <= frozenColumn early-out + expect(scroller.scrollLeft, 'boundary frozen cell does not scroll').to.eq(0); + }); + }); +}); + +describe('band routing - right-frozen band (example-frozen-right-columns: frozenRightColumn 2, no left freeze)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-right-columns.html`); + }); + + it('should route getHeaderColumn across the RF boundary: main stays in the left container, RF in the right-frozen container', () => { + cy.window().then((win: any) => { + const rfStart = win.grid.getFrozenRightStartIndex(); + const main = win.grid.getHeaderColumn(rfStart - 1) as HTMLElement; + const rf = win.grid.getHeaderColumn(rfStart) as HTMLElement; + expect(main.parentElement!.classList.contains('slick-header-columns-left'), 'main band hosts pre-boundary column').to.be.true; + expect(rf.parentElement!.classList.contains('slick-header-columns-right-frozen'), 'RF band hosts boundary column').to.be.true; + }); + }); + + it('should NOT give RF header cells the frozen class (left-band-only semantic) while RF data cells DO get it', () => { + cy.window().then((win: any) => { + const rfStart = win.grid.getFrozenRightStartIndex(); + expect((win.grid.getHeaderColumn(rfStart) as HTMLElement).classList.contains('frozen'), 'RF header cell not frozen-classed').to.be.false; + }); + cy.get('#myGrid .grid-canvas-right-frozen .slick-row').first().find('.slick-cell.frozen').should('have.length', 2); + cy.get('#myGrid .grid-canvas-left .slick-row').first().find('.slick-cell.frozen').should('have.length', 0); + }); + + it('should never horizontally scroll for a right-frozen cell', () => { + cy.window().then((win: any) => { + const rfStart = win.grid.getFrozenRightStartIndex(); + const scroller = win.document.querySelector('#myGrid .slick-pane-top.slick-pane-left .slick-viewport') as HTMLElement; + expect(scroller.scrollLeft, 'starts unscrolled').to.eq(0); + win.grid.scrollCellIntoView(5, rfStart); + expect(scroller.scrollLeft, 'RF cell does not scroll').to.eq(0); + }); + }); +}); + +describe('band routing - simultaneous top+bottom frozen rows (example-frozen-top-bottom-rows: frozenRow 3, frozenBottomRow 2)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-top-bottom-rows.html`); + }); + + it('should mark all top-band rows and all bottom-frozen-band rows frozen, plus exactly one body row (inclusive quirk)', () => { + cy.get('#myGrid .grid-canvas-top.grid-canvas-left .slick-row').then(($rows) => { + expect($rows.length, '3 top-frozen rows').to.eq(3); + $rows.each((_, row) => expect(row.classList.contains('frozen'), 'top row frozen').to.be.true); + }); + cy.get('#myGrid .grid-canvas-bottom-frozen .slick-row').then(($rows) => { + expect($rows.length, '2 bottom-frozen rows').to.eq(2); + $rows.each((_, row) => expect(row.classList.contains('frozen'), 'bf row frozen').to.be.true); + }); + // row index 3 passes `row <= frozenRow` (inclusive) but lives in the body canvas + cy.get('#myGrid .grid-canvas-bottom.grid-canvas-left:not(.grid-canvas-bottom-frozen) .slick-row.frozen').should('have.length', 1); + }); +}); From 0e9e7e0374782eda00b9f4acfaaf8815a647c1b0 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 19 Jul 2026 14:26:04 +0930 Subject: [PATCH 36/43] refactor: band-spanning column routing + per-semantic predicates (M19b) BandSet gains the cross-band column-cell conventions: cells()/cellAt()/ forEachCell() thread the continuous visible-column index across containers in band order; containerForColumn() is the historical three-way bandElementForColumn pick with elements supplied internally; columnCell() adds the band-local child index (deliberately not optional-chained - the historical code throws on a missing container). ViewportMgr gains per-semantic predicates (doctrine: one predicate per HISTORICAL semantic, transcribed from its call sites, never unified): - isColumnInAnyFrozenBand: the appendCellHtml cell class + cleanUpCells exemption pair (header cells keep left-band-only isColumnInFrozenBand) - isColumnAlwaysHorizontallyVisible: scrollCellIntoView early-outs with the INCLUSIVE <= frozenColumn boundary - isRowFrozenClassed: appendRowHtml row css incl. the inclusive-boundary off-by-one (named for the css semantic, not band membership) slick.grid.ts conversions: getHeaderColumn/getHeaderRowColumn/ getFooterRowColumn collapse to columnCell one-liners (the owner's goal example); getColumnByIndex -> cellAt; getHeaderChildren -> cells(); 5 creation-loop band picks -> containerForColumn; applyColumnHeaderWidths walk -> forEachCell; 4 predicate call sites. The scrollCellIntoView left test moves from a live options.frozenColumn read to the freeze snapshot, joining its RF twin (per the documented snapshot-timing invariant). Guarded by viewportmgr-band-routing.cy.ts (committed first, 14 boundary pins). Deferred to M19d: columnBandGeometry/applyColumnWidths - those sites deliberately re-derive rfStartIdx fresh; the snapshot question lands with the width-math golden tests. tsc/eslint clean; gate 115/115; full suite 668 / 0 failing (one ambient grid-menu flake ruled out by solo pass + clean re-run). Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++ src/slick.grid.ts | 69 ++++++++++++++------------------------------ 2 files changed, 95 insertions(+), 47 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index d9624a91..85df3883 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -1445,6 +1445,54 @@ export class BandSet { this.forEach((el) => { out.push(...Array.from(el.querySelectorAll(selector))); }); return out; } + + // --- band-spanning column-cell conventions (M19b): the CONTINUOUS visible-column + // --- index threaded across the containers in band order l, r, rf --- + + /** all column cells across the bands, in visible-column order */ + cells(): HTMLElement[] { + const out: HTMLElement[] = []; + this.forEach((el) => { out.push(...(Array.from(el.children) as HTMLElement[])); }); + return out; + } + + /** the cell at a continuous visible-column index (historical cross-band walk) */ + cellAt(visibleIdx: number): HTMLElement | undefined { + let remaining = visibleIdx; + let found: HTMLElement | undefined; + this.forEach((el) => { + if (found === undefined) { + if (remaining < el.children.length) { + found = el.children[remaining] as HTMLElement; + } else { + remaining -= el.children.length; + } + } + }); + return found; + } + + forEachCell(fn: (cell: HTMLElement, visibleIdx: number) => void): void { + let i = 0; + this.forEach((el) => { + for (let c = 0; c < el.children.length; c++, i++) { + fn(el.children[c] as HTMLElement, i); + } + }); + } + + /** the band container owning a data-column index — the historical three-way + * bandElementForColumn pick with the elements supplied internally */ + containerForColumn(colIdx: number): HTMLDivElement { + return this.mgr.bandElementForColumn(colIdx, this.at('l') as HTMLDivElement, this.at('r') as HTMLDivElement, this.at('rf') as HTMLDivElement); + } + + /** container pick + band-local child index in one call (the getHeaderColumn / + * getHeaderRowColumn / getFooterRowColumn collapse). Deliberately NOT + * optional-chained: the historical code throws when the container is missing. */ + columnCell(colIdx: number): HTMLDivElement { + return this.containerForColumn(colIdx).children[this.mgr.bandLocalColumnIdx(colIdx)] as HTMLDivElement; + } } /** 2D analogue of BandSet for the pane/viewport/canvas cells of the pane matrix. */ @@ -2117,6 +2165,31 @@ export class ViewportMgr { return this.sideLocalColumnIdx(colIdx); } + // --- per-semantic band predicates (M19b). Doctrine (FACADE-FEASIBILITY.md §5): + // --- one predicate per HISTORICAL semantic, transcribed from its call sites — + // --- never unified, because the boundary comparisons deliberately differ. --- + + /** left OR right frozen membership — the appendCellHtml 'frozen' CELL class and the + * cleanUpCells exemption semantic. Header cells keep isColumnInFrozenBand alone + * (left-band-only) — a different historical semantic, not an oversight. */ + isColumnInAnyFrozenBand(colIdx: number): boolean { + return this.isColumnInFrozenBand(colIdx) || this.isColumnInRightFrozenBand(colIdx); + } + + /** scrollCellIntoView's early-out pair, preserving the historical INCLUSIVE + * `cell <= frozenColumn` comparison — the boundary column itself never scrolls. */ + isColumnAlwaysHorizontallyVisible(colIdx: number): boolean { + return colIdx <= this.freeze.frozenColumnIdx || this.isColumnInRightFrozenBand(colIdx); + } + + /** appendRowHtml's row 'frozen' css semantic: the INCLUSIVE top test (the row equal + * to the frozenRow count is classed although it renders in the scrollable canvas — + * pinned by viewportmgr-band-routing.cy.ts) OR bottom-frozen band membership. + * Distinct from isRowInFrozenBand (cleanup) and the render split (attachRow). */ + isRowFrozenClassed(row: number): boolean { + return (this.freeze.hasFrozenRows && row <= (this.freeze.frozenRowCount ?? -1)) || this.isRowInBottomFrozenBand(row); + } + /** * Index of the column's node inside rowsCache[].rowNode: 0 for the left fragment * (which is the scrollable fragment when no columns are left-frozen), 1 for the diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 87645bbc..8999f805 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -1394,7 +1394,7 @@ export class SlickGrid = Column, O e return this._viewportMgr.hasFrozenColumns() ? this._viewportMgr.headers.elements : this._viewportMgr.headerL; } const idx = this.getColumnIndex(columnDef.id); - return this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.headerL, this._viewportMgr.headerR, this._viewportMgr.headerRF); + return this._viewportMgr.headers.containerForColumn(idx); } /** @@ -1403,10 +1403,7 @@ export class SlickGrid = Column, O e */ getHeaderColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const targetHeader = this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.headerL, this._viewportMgr.headerR, this._viewportMgr.headerRF); - const targetIndex = this._viewportMgr.bandLocalColumnIdx(idx); - - return targetHeader.children[targetIndex] as HTMLDivElement; + return this._viewportMgr.headers.columnCell(idx); } /** Get the Header Row DOM element */ @@ -1429,9 +1426,7 @@ export class SlickGrid = Column, O e */ getHeaderRowColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const headerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.headerRowL, this._viewportMgr.headerRowR, this._viewportMgr.headerRowRF); - - return headerRowTarget.children[this._viewportMgr.bandLocalColumnIdx(idx)] as HTMLDivElement; + return this._viewportMgr.headerRows.columnCell(idx); } /** @@ -1440,9 +1435,7 @@ export class SlickGrid = Column, O e */ getFooterRowColumn(columnIdOrIdx: number | string) { const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx)); - const footerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._viewportMgr.footerRowL, this._viewportMgr.footerRowR, this._viewportMgr.footerRowRF); - - return footerRowTarget.children[this._viewportMgr.bandLocalColumnIdx(idx)] as HTMLDivElement; + return this._viewportMgr.footerRows.columnCell(idx); } /** @@ -1472,7 +1465,7 @@ export class SlickGrid = Column, O e const m = this.columns[i]; if (!m || m.hidden) { continue; } - const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this._viewportMgr.bandElementForColumn(i, this._viewportMgr.footerRowL, this._viewportMgr.footerRowR, this._viewportMgr.footerRowRF)); + const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, this._viewportMgr.footerRows.containerForColumn(i)); const className = this._viewportMgr.isColumnInFrozenBand(i) ? 'frozen' : null; if (className) { footerRowCell.classList.add(className); @@ -1657,8 +1650,8 @@ export class SlickGrid = Column, O e const m: C = this.columns[i]; if (m.hidden) { continue; } - const headerTarget = this._viewportMgr.bandElementForColumn(i, this._viewportMgr.headerL, this._viewportMgr.headerR, this._viewportMgr.headerRF); - const headerRowTarget = this._viewportMgr.bandElementForColumn(i, this._viewportMgr.headerRowL, this._viewportMgr.headerRowR, this._viewportMgr.headerRowRF); + const headerTarget = this._viewportMgr.headers.containerForColumn(i); + const headerRowTarget = this._viewportMgr.headerRows.containerForColumn(i); const header = Utils.createDomElement('div', { id: `${this.uid + m.id}`, dataset: { id: String(m.id) }, role: 'columnheader', className: 'ui-state-default slick-state-default slick-header-column' }, headerTarget); if (m.toolTip) { @@ -1732,7 +1725,7 @@ export class SlickGrid = Column, O e }); } if (this._options.createFooterRow && this._options.showFooterRow) { - const footerRowTarget = this._viewportMgr.bandElementForColumn(i, this._viewportMgr.footerRows.first(), this._viewportMgr.footerRows.elements[1], this._viewportMgr.footerRowRF); + const footerRowTarget = this._viewportMgr.footerRows.containerForColumn(i); const footerRowCell = Utils.createDomElement('div', { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, footerRowTarget); Utils.storage.put(footerRowCell, 'column', m); @@ -1845,9 +1838,9 @@ export class SlickGrid = Column, O e * @returns {HTMLElement[]} - An array of header column elements. */ protected getHeaderChildren() { - // _headers only contains the header containers that were actually built + // only the header containers that were actually built contribute // (a single left container under lazyPanes) - return this._viewportMgr.headers.elements.flatMap((headerEl) => Array.from(headerEl.children)) as HTMLElement[]; + return this._viewportMgr.headers.cells(); } /** @@ -3064,16 +3057,12 @@ export class SlickGrid = Column, O e return; } - let columnIndex = 0; const vc = this.getVisibleColumns(); - this._viewportMgr.headers.elements.forEach((header) => { - for (let i = 0; i < header.children.length; i++, columnIndex++) { - const h = header.children[i] as HTMLElement; - const col = vc[columnIndex] || {}; - const width = (col.width || 0) - this.headerColumnWidthDiff; - if (Utils.width(h) !== width) { - Utils.width(h, width); - } + this._viewportMgr.headers.forEachCell((h, columnIndex) => { + const col = vc[columnIndex] || {}; + const width = (col.width || 0) - this.headerColumnWidthDiff; + if (Utils.width(h) !== width) { + Utils.width(h, width); } }); @@ -3137,18 +3126,7 @@ export class SlickGrid = Column, O e * @returns */ getColumnByIndex(id: number) { - let result: HTMLElement | undefined; - this._viewportMgr.headers.elements.every((header) => { - const length = header.children.length; - if (id < length) { - result = header.children[id] as HTMLElement; - return false; - } - id -= length; - return true; - }); - - return result; + return this._viewportMgr.headers.cellAt(id); } /** @@ -5194,7 +5172,7 @@ export class SlickGrid = Column, O e const d = this.getDataItem(row); const dataLoading = row < dataLength && !d; let rowCss = 'slick-row' + - ((this._viewportMgr.hasFrozenRows() && row <= this._options.frozenRow!) || this._viewportMgr.isRowInBottomFrozenBand(row) ? ' frozen' : '') + + (this._viewportMgr.isRowFrozenClassed(row) ? ' frozen' : '') + (dataLoading ? ' loading' : '') + (row === this.activeRow && this._options.showCellSelection ? ' active' : '') + (row % 2 === 1 ? ' odd' : ' even'); @@ -5326,7 +5304,7 @@ export class SlickGrid = Column, O e + (rowspan > 1 ? ' rowspan' : '') + (columnMetadata?.cssClass ? ` ${columnMetadata.cssClass}` : ''); - if (this._viewportMgr.isColumnInFrozenBand(cell) || this._viewportMgr.isColumnInRightFrozenBand(cell)) { + if (this._viewportMgr.isColumnInAnyFrozenBand(cell)) { cellCss += ' frozen'; } @@ -6023,7 +6001,7 @@ export class SlickGrid = Column, O e const i = +cellNodeIdx; // Ignore frozen columns (left and right bands are always horizontally visible) - if (this._viewportMgr.isColumnInFrozenBand(i) || this._viewportMgr.isColumnInRightFrozenBand(i)) { + if (this._viewportMgr.isColumnInAnyFrozenBand(i)) { return; } @@ -6617,12 +6595,9 @@ export class SlickGrid = Column, O e scrollCellIntoView(row: number, cell: number, doPaging?: boolean) { this.scrollRowIntoView(row, doPaging); - if (cell <= this._options.frozenColumn!) { - return; - } - - // right-frozen cells are always horizontally visible — never scroll for them - if (this._viewportMgr.isColumnInRightFrozenBand(cell)) { + // frozen cells (either side) are always horizontally visible — never scroll for + // them; the left test is INCLUSIVE of the boundary column (historical) + if (this._viewportMgr.isColumnAlwaysHorizontallyVisible(cell)) { return; } From 89684a3ff9127936011cdcf7f4871dde7cf37f3c Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 19 Jul 2026 15:01:05 +0930 Subject: [PATCH 37/43] refactor: row-offset routing onto the facade (M19b completion) - canvasNodeRowOffset(node, geometry, { bfAware }): the vertical data offset of the row-band canvas containing a node. bfAware: true reproduces getCellFromEvent (bottom-frozen band rebases to its first row); bfAware: false reproduces setActiveCellInternal, whose historical bf-blindness (bf canvases carry no grid-canvas-bottom class token) becomes an explicit flag instead of an accident of class names. The classic bottom offset keeps its asymmetric source: frozenBottom measures the LIVE top-left canvas height, top-freeze uses the caller's cached frozenRowsHeight. Callers keep their own hasFrozenRows() gating - each gates more than the offset. - shouldScrollRowIntoView: scrollRowIntoView's guard pair (bf rows never scroll; the exact actualFrozenRow - 1 boundaries) as one predicate. - scrollableRowIndex: the frozen-top row-index rebase. Snapshot notes: actualFrozenRow/frozenBottom/frozenRow reads move from grid fields and live options to the freeze snapshot - identical values through every supported path (all are set only by setFrozenOptions), per the documented snapshot-timing invariant. tsc/eslint clean; gate 85/85; full suite 668 / 0 failing. Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 39 ++++++++++++++++++++++++++++++++++++++ src/slick.grid.ts | 48 ++++++++++++++++++++++------------------------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index 85df3883..16248622 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -2190,6 +2190,45 @@ export class ViewportMgr { return (this.freeze.hasFrozenRows && row <= (this.freeze.frozenRowCount ?? -1)) || this.isRowInBottomFrozenBand(row); } + /** + * Vertical data offset of the row-band canvas containing `node` (M19), for + * translating page coordinates into canvas-local rows. Two historical variants: + * - bfAware (getCellFromEvent): the bottom-frozen band rebases to its first row; + * - NOT bfAware (setActiveCellInternal): the bf band is deliberately not + * distinguished — bf canvases carry no 'grid-canvas-bottom' class token, so the + * bottom test is false and the offset is 0 for them (historical behavior kept + * as an explicit flag, not silently unified). + * The classic bottom offset keeps its asymmetric source: frozenBottom measures the + * LIVE top-left canvas height, top-freeze uses the caller's cached frozenRowsHeight. + * Callers keep their own hasFrozenRows() gating (their surrounding logic differs). + */ + canvasNodeRowOffset(node: Element, g: { dataLength: number; frozenBottomRowCount: number; rowHeight: number; frozenRowsHeight: number; frozenBottom: boolean; }, opts?: { bfAware?: boolean; }): number { + if (opts?.bfAware && this.hasBottomFrozenBand() && Utils.parents(node, '.grid-canvas-bottom-frozen').length) { + // bottom-frozen band: canvas origin is the band's first row + return (g.dataLength - g.frozenBottomRowCount) * g.rowHeight; + } + if (Utils.parents(node, '.grid-canvas-bottom').length) { + return g.frozenBottom ? Utils.height(this.canvasTopL) as number : g.frozenRowsHeight; + } + return 0; + } + + /** scrollRowIntoView's guard: bottom-frozen rows never scroll; frozen-band rows are + * skipped with the exact historical `actualFrozenRow - 1` boundaries. */ + shouldScrollRowIntoView(row: number): boolean { + if (this.isRowInBottomFrozenBand(row)) { + return false; + } + return !this.freeze.hasFrozenRows + || (!this.freeze.frozenBottom && row > this.freeze.actualFrozenRow - 1) + || (this.freeze.frozenBottom && row < this.freeze.actualFrozenRow - 1); + } + + /** frozen-top row-index rebase for vertical scroll arithmetic. */ + scrollableRowIndex(row: number): number { + return this.freeze.hasFrozenRows && !this.freeze.frozenBottom ? row - (this.freeze.frozenRowCount ?? 0) : row; + } + /** * Index of the column's node inside rowsCache[].rowNode: 0 for the left fragment * (which is the scrollable fragment when no columns are left-frozen), 1 for the diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 8999f805..b7125239 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -3511,12 +3511,17 @@ export class SlickGrid = Column, O e if (Utils.isDefined(this.activeCellNode)) { const activeCellOffset = Utils.offset(this.activeCellNode); let rowOffset = Math.floor(Utils.offset(Utils.parents(this.activeCellNode, '.grid-canvas')[0] as HTMLElement)!.top); - const isBottom = Utils.parents(this.activeCellNode, '.grid-canvas-bottom').length; - if (this._viewportMgr.hasFrozenRows() && isBottom) { - rowOffset -= (this._options.frozenBottom) - ? Utils.height(this._viewportMgr.canvasTopL) as number - : this.frozenRowsHeight; + if (this._viewportMgr.hasFrozenRows()) { + // bfAware: false — setActiveCellInternal historically never distinguished the + // bottom-frozen band (its canvases carry no 'grid-canvas-bottom' token) + rowOffset -= this._viewportMgr.canvasNodeRowOffset(this.activeCellNode, { + dataLength: this.getDataLength(), + frozenBottomRowCount: this._options.frozenBottomRow!, + rowHeight: this._options.rowHeight!, + frozenRowsHeight: this.frozenRowsHeight, + frozenBottom: !!this._options.frozenBottom, + }); } const cell = this.getCellFromPoint(activeCellOffset!.left, Math.ceil(activeCellOffset!.top) - rowOffset); @@ -4491,17 +4496,14 @@ export class SlickGrid = Column, O e let row = this.getRowFromNode(cellNode.parentNode as HTMLElement); if (this._viewportMgr.hasFrozenRows()) { - let rowOffset = 0; const c = Utils.offset(Utils.parents(cellNode, '.grid-canvas')[0] as HTMLElement); - const isBottom = Utils.parents(cellNode, '.grid-canvas-bottom').length; - const isBottomFrozen = this._viewportMgr.hasBottomFrozenBand() && Utils.parents(cellNode, '.grid-canvas-bottom-frozen').length; - - if (isBottomFrozen) { - // bottom-frozen band: canvas origin is the band's first row - rowOffset = (this.getDataLength() - this._options.frozenBottomRow!) * this._options.rowHeight!; - } else if (isBottom) { - rowOffset = (this._options.frozenBottom) ? Utils.height(this._viewportMgr.canvasTopL) as number : this.frozenRowsHeight; - } + const rowOffset = this._viewportMgr.canvasNodeRowOffset(cellNode, { + dataLength: this.getDataLength(), + frozenBottomRowCount: this._options.frozenBottomRow!, + rowHeight: this._options.rowHeight!, + frozenRowsHeight: this.frozenRowsHeight, + frozenBottom: !!this._options.frozenBottom, + }, { bfAware: true }); row = this.getCellFromPoint(targetEvent.clientX - c!.left, targetEvent.clientY - c!.top + rowOffset + document.documentElement.scrollTop).row; } @@ -6808,20 +6810,14 @@ export class SlickGrid = Column, O e * @param {Boolean} doPaging - scroll when pagination is enabled */ scrollRowIntoView(row: number, doPaging?: boolean) { - // bottom-frozen rows are always vertically visible — never scroll for them - if (this._viewportMgr.isRowInBottomFrozenBand(row)) { - return; - } - - if (!this._viewportMgr.hasFrozenRows() || - (!this._options.frozenBottom && row > this.actualFrozenRow - 1) || - (this._options.frozenBottom && row < this.actualFrozenRow - 1)) { + // bottom-frozen rows never scroll; frozen-band rows are skipped with the exact + // historical actualFrozenRow - 1 boundaries (both inside the vm predicate) + if (this._viewportMgr.shouldScrollRowIntoView(row)) { const viewportScrollH = Utils.height(this._viewportMgr.scrollContainerY) as number; - // if frozen row on top - // subtract number of frozen row - const rowNumber = (this._viewportMgr.hasFrozenRows() && !this._options.frozenBottom ? row - this._options.frozenRow! : row); + // frozen-top rebase: subtract the frozen row count + const rowNumber = this._viewportMgr.scrollableRowIndex(row); const rowAtTop = rowNumber * this._options.rowHeight!; const rowAtBottom = (rowNumber + 1) * this._options.rowHeight! From 0e0f4571638856da442978dcf3bcd1328471f987 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 19 Jul 2026 21:50:01 +0930 Subject: [PATCH 38/43] refactor: materialization manifests + single band entry point (M19c) Materializers now return PaneElementSets manifests of exactly the elements they CREATED (null when nothing new), replacing boolean returns plus grid-side hand-listed element arrays: - the BFxRF corner obeys an exactly-once rule by construction: only the materializer whose ensureBottomFrozenRightVariant call CREATED the corner reports it (fresh RF path, fresh BF path, or BF idempotent-recall as a corner-only manifest) - the binding service does not dedupe, so this kills the double-bind hazard the hadCorner dances guarded against - ensureBandsMaterialized(o) is the one runtime entry: classic -> RF -> BF in the load-bearing order (classic canonicalization forced by either band exactly as the historical wrapper-nesting did), manifests merged, with bodyCanvasChanged flagging classic materialization only - allPaneElements() feeds the init-time array-wide bind pass Grid: the three materialize wrappers (~95 lines incl. both hadCorner dances) collapse into bindMaterialized(added), preserving the historical per-wrapper wiring order (disableSelection -> bindPaneEvents -> pre-header binds -> setupColumnSort -> ancestor re-anchor on bodyCanvasChanged only); setFrozenOptions makes one ensureBandsMaterialized call instead of three gated wrapper calls; bindPaneEvents takes the shared PaneElementSets type. Note: merged manifests mean ONE bind pass per setFrozenOptions instead of one per band wrapper - per-element handlers are order-independent and the classic-then-RF-then-BF element order inside the merge preserves the historical sequence. tsc/eslint clean; gate 81/81 (lazy-materialization suite leading); full suite 668 / 0 failing. Net +33 (core manifest machinery; grid -95). Co-Authored-By: Claude Fable 5 --- src/models/viewportMgr.interface.ts | 20 ++++ src/slick.core.ts | 148 +++++++++++++++++++++++----- src/slick.grid.ts | 145 ++++++--------------------- 3 files changed, 173 insertions(+), 140 deletions(-) diff --git a/src/models/viewportMgr.interface.ts b/src/models/viewportMgr.interface.ts index e80e80eb..c3d18767 100644 --- a/src/models/viewportMgr.interface.ts +++ b/src/models/viewportMgr.interface.ts @@ -71,6 +71,26 @@ export interface PaneHeightsGeometry { headerRowHeight?: number; } +/** + * Element manifest shared by pane event binding and band materialization (M19c). + * A materializer returns exactly the elements it CREATED (the binding service does + * not dedupe, so double-reporting means double-bound handlers); allPaneElements() + * returns the full current set for the init bind / destroy unbind passes. + */ +export interface PaneElementSets { + viewports?: HTMLDivElement[]; + canvases?: HTMLDivElement[]; + headers?: HTMLDivElement[]; + headerScrollers?: HTMLDivElement[]; + headerRowScrollers?: HTMLDivElement[]; + footerRows?: HTMLDivElement[]; + footerRowScrollers?: HTMLDivElement[]; + preHeaderScrollers?: HTMLDivElement[]; + /** true when the ancestor-scroll anchor canvas may have moved band (classic + * materialization only — RF/BF arrival never re-anchors, historically) */ + bodyCanvasChanged?: boolean; +} + /** Options consumed by ViewportMgr when constructing the pane/viewport/canvas DOM. */ export interface ViewportMgrBuildOptions { createPreHeaderPanel?: boolean; diff --git a/src/slick.core.ts b/src/slick.core.ts index 16248622..58992fe3 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -16,6 +16,7 @@ import type { CanvasWidthsGeometry, FreezeBandCounts, PaneHeightsGeometry, + PaneElementSets, ViewportFreezeState, ViewportMgrBuildOptions, } from './models/index.js'; @@ -1865,12 +1866,13 @@ export class ViewportMgr { * Builds the right/bottom panes, chrome, viewports and canvases that a lazyPanes * grid skipped at init, inserting each pane at its canonical sibling position and * pushing the new elements into the shared caches IN PLACE (the grid's array - * aliases keep working). Idempotent: returns false when the grid is not lazy - * (already fully built or built non-lazy). + * aliases keep working). Idempotent: returns null when the grid is not lazy + * (already fully built or built non-lazy); otherwise a manifest of exactly the + * NEW elements, for the grid's event wiring (M19c). */ - materializeSecondaryPanes(o: ViewportMgrBuildOptions): boolean { + materializeSecondaryPanes(o: ViewportMgrBuildOptions): PaneElementSets | null { if (!this.lazy) { - return false; + return null; } this.lazy = false; @@ -1886,7 +1888,19 @@ export class ViewportMgr { this.buildFooterRowFor('r', o); } this.syncElementArrays(); - return true; + return { + // historical wiring order preserved: topR before the bottom pair + viewports: [this.viewportTopR, this.viewportBottomL, this.viewportBottomR], + canvases: [this.canvasTopR, this.canvasBottomL, this.canvasBottomR], + headers: [this.headerR], + headerScrollers: [this.headerScrollerR], + headerRowScrollers: [this.headerRowScrollerR], + footerRows: o.createFooterRow ? [this.footerRowR] : [], + footerRowScrollers: o.createFooterRow ? [this.footerRowScrollerR] : [], + preHeaderScrollers: o.createPreHeaderPanel ? [this.preHeaderPanelScrollerR] : [], + // classic materialization can move the ancestor-scroll anchor canvas + bodyCanvasChanged: true, + }; } /** @@ -1898,15 +1912,17 @@ export class ViewportMgr { * Idempotent: returns false when the band already exists. * * The historical "right" elements keep their class names and become the scrollable - * MIDDLE band while this band is active. + * MIDDLE band while this band is active. Returns a manifest of exactly the NEW + * elements (incl. the BF×RF corner when it arrived with this band), or null when + * the band already exists. */ - materializeRightFrozenBand(o: ViewportMgrBuildOptions): boolean { + materializeRightFrozenBand(o: ViewportMgrBuildOptions): PaneElementSets | null { if (this.paneAt('header', 'rf')) { // band already exists: no corner work here (pre-matrix behavior). The BF×RF // corner is always created by whichever band materializes SECOND, on its - // success path — and the grid-side caller binds no pane events on this early - // return, so creating the corner here would leave it event-less. - return false; + // success path — and no pane events are wired on this early return, so + // creating the corner here would leave it event-less. + return null; } // appended as a block after the last classic pane @@ -1918,10 +1934,25 @@ export class ViewportMgr { this.buildFooterRowFor('rf', o); } - // if the bottom-frozen band already exists, add the shared corner pane - this.ensureBottomFrozenRightVariant(o); + // if the bottom-frozen band already exists, add the shared corner pane; the + // CREATOR reports it — the exactly-once manifest rule + const corner = this.ensureBottomFrozenRightVariant(o); this.syncElementArrays(); - return true; + const viewports = [this.viewportTopRF, this.viewportBottomRF]; + const canvases = [this.canvasTopRF, this.canvasBottomRF]; + if (corner) { + viewports.push(corner.viewport as HTMLDivElement); + canvases.push(corner.canvas as HTMLDivElement); + } + return { + viewports, + canvases, + headers: [this.headerRF], + headerScrollers: [this.headerScrollerRF], + headerRowScrollers: [this.headerRowScrollerRF], + footerRows: o.createFooterRow ? [this.footerRowRF] : [], + footerRowScrollers: o.createFooterRow ? [this.footerRowScrollerRF] : [], + }; } /** @@ -1929,31 +1960,98 @@ export class ViewportMgr { * pane+viewport+canvas per active column band, appended after all existing panes * with `*-bottom-frozen` css classes. Element arrays extend at the END and the * slots are recorded (bfSlotL/R/RF). Idempotent: returns false when the band - * already exists. The right-frozen column variant is built only when that band's - * DOM exists at call time; materializeRightFrozenBand adds it later otherwise. + * already exists (a corner-only manifest when the idempotent recall added the + * late RF corner variant). The right-frozen column variant is built only when + * that band's DOM exists at call time; materializeRightFrozenBand adds it later + * otherwise. */ - materializeBottomFrozenBand(o: ViewportMgrBuildOptions): boolean { + materializeBottomFrozenBand(o: ViewportMgrBuildOptions): PaneElementSets | null { if (this.paneAt('bf', 'l')) { - // idempotent call may still need to add the RF corner variant late - this.ensureBottomFrozenRightVariant(o); - return false; + // idempotent call may still need to add the RF corner variant late — the + // creator reports it, exactly once + const lateCorner = this.ensureBottomFrozenRightVariant(o); + return lateCorner + ? { viewports: [lateCorner.viewport as HTMLDivElement], canvases: [lateCorner.canvas as HTMLDivElement] } + : null; } const lastPane = (this.paneAt('bottom', 'rf') ?? this.paneAt('bottom', 'r') ?? this.paneAt('top', 'l'))!.pane; const l = this.buildPaneSet('bf', 'l', o, lastPane); this.buildPaneSet('bf', 'r', o, l.pane); - this.ensureBottomFrozenRightVariant(o); + const corner = this.ensureBottomFrozenRightVariant(o); this.syncElementArrays(); - return true; + const viewports = [this.viewportBottomFrozenL, this.viewportBottomFrozenR]; + const canvases = [this.canvasBottomFrozenL, this.canvasBottomFrozenR]; + if (corner) { + viewports.push(corner.viewport as HTMLDivElement); + canvases.push(corner.canvas as HTMLDivElement); + } + return { viewports, canvases }; } - /** Adds the bottom-frozen × right-frozen corner pane when both bands exist. */ - protected ensureBottomFrozenRightVariant(o: ViewportMgrBuildOptions) { + /** Adds the bottom-frozen × right-frozen corner pane when both bands exist, + * returning the created PaneSet (null when nothing was created) so the CALLING + * materializer — and only it — reports the corner in its manifest. */ + protected ensureBottomFrozenRightVariant(o: ViewportMgrBuildOptions): PaneSet | null { if (!this.paneAt('bf', 'l') || !this.paneAt('header', 'rf') || this.paneAt('bf', 'rf')) { - return; + return null; } - this.buildPaneSet('bf', 'rf', o, this.paneAt('bf', 'r')!.pane); + const created = this.buildPaneSet('bf', 'rf', o, this.paneAt('bf', 'r')!.pane); this.syncElementArrays(); + return created; + } + + /** + * The full current element manifest — the init-time array-wide bind pass and the + * destroy-time unbind pass iterate the SAME shape, so they can never diverge. + */ + allPaneElements(): PaneElementSets { + return { + viewports: this.viewport, + canvases: this.canvas, + headerScrollers: this.headerScroller, + headerRowScrollers: this.headerRowScroller, + footerRows: this.footerRow, + footerRowScrollers: this.footerRowScroller, + }; + } + + /** + * One entry for every runtime freeze change (M19c): materializes whatever bands + * the CURRENT freeze snapshot requires, in the load-bearing order classic → RF → + * BF (paneCellIndex's classic slots 0–3 depend on classic canonicalization + * happening first — the historical wrappers forced it before either band), and + * merges the per-band manifests of newly created elements into one. + */ + ensureBandsMaterialized(o: ViewportMgrBuildOptions): PaneElementSets | null { + const classicGate = this.freeze.frozenColumnIdx > -1 || this.freeze.hasFrozenRows; + const rfGate = (this.freeze.frozenRightColCount ?? 0) > 0; + const bfGate = (this.freeze.frozenRowCount ?? -1) > -1 && (this.freeze.frozenBottomRowCount ?? 0) > 0; + + let merged: PaneElementSets | null = null; + const merge = (m: PaneElementSets | null) => { + if (!m) { return; } + merged ??= {}; + for (const key of ['viewports', 'canvases', 'headers', 'headerScrollers', 'headerRowScrollers', 'footerRows', 'footerRowScrollers', 'preHeaderScrollers'] as const) { + if (m[key]?.length) { + (merged[key] ??= []).push(...m[key]!); + } + } + if (m.bodyCanvasChanged) { + merged.bodyCanvasChanged = true; + } + }; + + if (classicGate || rfGate || bfGate) { + merge(this.materializeSecondaryPanes(o)); + } + if (rfGate) { + merge(this.materializeRightFrozenBand(o)); + } + if (bfGate) { + merge(this.materializeBottomFrozenBand(o)); + } + return merged; } /** Whether the simultaneous top+bottom row mode is active AND its DOM exists. */ diff --git a/src/slick.grid.ts b/src/slick.grid.ts index b7125239..d5df6a54 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -69,6 +69,7 @@ import type { OnValidationErrorEventArgs, OnDragReplaceCellsEventArgs, PagingInfo, + PaneElementSets, RowInfo, SelectionModel, SingleColumnSort, @@ -725,14 +726,7 @@ export class SlickGrid = Column, O e * for elements created later — pass ONLY the elements to wire up (the binding * service does not dedupe). */ - protected bindPaneEvents(els: { - viewports?: HTMLDivElement[]; - canvases?: HTMLDivElement[]; - headerScrollers?: HTMLDivElement[]; - headerRowScrollers?: HTMLDivElement[]; - footerRows?: HTMLDivElement[]; - footerRowScrollers?: HTMLDivElement[]; - }) { + protected bindPaneEvents(els: PaneElementSets) { if (!this._options.enableTextSelectionOnCells) { // disable text selection in grid cells except in input and textarea elements els.viewports?.forEach((view) => { @@ -795,31 +789,34 @@ export class SlickGrid = Column, O e * for the NEW elements only, and re-anchors the ancestor scroll bindings. No-op on * non-lazy grids. */ - protected materializeLazyPanes() { - if (!this._viewportMgr.materializeSecondaryPanes(this._options)) { + /** + * Wires events, selection-disable and sort clicks for elements a materializer + * just created (M19c: the manifest lists exactly the NEW elements — the binding + * service does not dedupe). Historical per-wrapper order preserved: + * disableSelection → bindPaneEvents → pre-header binds → setupColumnSort → + * ancestor-scroll re-anchor (classic materialization only). + */ + protected bindMaterialized(added: PaneElementSets) { + if (!this._paneEventsBound) { return; } - if (this._paneEventsBound) { - this.disableSelection([this._viewportMgr.headerR]); + if (added.headers?.length) { + this.disableSelection(added.headers); + } - this.bindPaneEvents({ - viewports: [this._viewportMgr.viewportTopR, this._viewportMgr.viewportBottomL, this._viewportMgr.viewportBottomR], - canvases: [this._viewportMgr.canvasTopR, this._viewportMgr.canvasBottomL, this._viewportMgr.canvasBottomR], - headerScrollers: [this._viewportMgr.headerScrollerR], - headerRowScrollers: [this._viewportMgr.headerRowScrollerR], - footerRows: this._options.createFooterRow ? [this._viewportMgr.footerRowR] : [], - footerRowScrollers: this._options.createFooterRow ? [this._viewportMgr.footerRowScrollerR] : [], - }); + this.bindPaneEvents(added); - if (this._options.createPreHeaderPanel) { - this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScrollerR, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(this._viewportMgr.preHeaderPanelScrollerR, 'click', this.handlePreHeaderClick.bind(this) as EventListener); - } + added.preHeaderScrollers?.forEach((el) => { + this._bindingEventService.bind(el, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(el, 'click', this.handlePreHeaderClick.bind(this) as EventListener); + }); - // sort clicks for the new right header container - this.setupColumnSort([this._viewportMgr.headerR]); + if (added.headers?.length) { + this.setupColumnSort(added.headers); + } + if (added.bodyCanvasChanged) { // the ancestor-scroll anchor canvas may have changed band this.unbindAncestorScrollEvents(); this.bindAncestorScrollEvents(); @@ -858,14 +855,7 @@ export class SlickGrid = Column, O e this._bindingEventService.bind(this._container, 'resize', this.resizeCanvas.bind(this)); - this.bindPaneEvents({ - viewports: this._viewportMgr.viewports.elements, - canvases: this._viewportMgr.canvases.elements, - headerScrollers: this._viewportMgr.headerScrollers.elements, - headerRowScrollers: this._viewportMgr.headerRowScrollers.elements, - footerRows: this._viewportMgr.footerRows.elements, - footerRowScrollers: this._viewportMgr.footerRowScrollers.elements, - }); + this.bindPaneEvents(this._viewportMgr.allPaneElements()); this._paneEventsBound = true; if (this._options.createTopHeaderPanel) { @@ -2269,87 +2259,12 @@ export class SlickGrid = Column, O e : Number.MAX_SAFE_INTEGER, }); - // materialize the secondary panes if freezing was just enabled on a lazyPanes grid - // (runs before setScroller/setColumns in the internal_setOptions pipeline) - if (this._options.frozenColumn! > -1 || this.hasFrozenRows) { - this.materializeLazyPanes(); - } - - // materialize the right-frozen band the first time a right freeze is applied - if (this._options.frozenRightColumn! > 0) { - this.materializeRightFrozenPanes(); - } - - // materialize the bottom-frozen row band the first time simultaneous - // top+bottom freezing is applied - if (this._options.frozenRow! > -1 && this._options.frozenBottomRow! > 0) { - this.materializeBottomFrozenPanes(); - } - } - - /** - * Builds the bottom-frozen row band on first use (simultaneous top+bottom mode) - * and wires events for the new elements when the grid is already live. - */ - protected materializeBottomFrozenPanes() { - // canonicalize the classic pane set first (no-op on non-lazy grids) - this.materializeLazyPanes(); - - const vm = this._viewportMgr; - const hadCorner = !!vm.canvasBottomFrozenRF; - if (!vm.materializeBottomFrozenBand(this._options)) { - // idempotent call may still have added the RF corner variant late - if (!hadCorner && vm.canvasBottomFrozenRF && this._paneEventsBound) { - this.bindPaneEvents({ viewports: [vm.viewportBottomFrozenRF], canvases: [vm.canvasBottomFrozenRF] }); - } - return; - } - - if (this._paneEventsBound) { - const viewports = [vm.viewportBottomFrozenL, vm.viewportBottomFrozenR]; - const canvases = [vm.canvasBottomFrozenL, vm.canvasBottomFrozenR]; - if (vm.canvasBottomFrozenRF) { - viewports.push(vm.viewportBottomFrozenRF); - canvases.push(vm.canvasBottomFrozenRF); - } - this.bindPaneEvents({ viewports, canvases }); - } - } - - /** - * Builds the right-frozen band on first use (init-time via finishInitialization's - * setFrozenOptions call — before the event-binding loops — or at runtime via - * setOptions) and wires events for the new elements when the grid is already live. - */ - protected materializeRightFrozenPanes() { - // canonicalize the classic pane set first (paneCellIndex's classic slots 0-3 - // depend on it) — no-op on non-lazy grids - this.materializeLazyPanes(); - - const hadCorner = !!this._viewportMgr.canvasBottomFrozenRF; - if (!this._viewportMgr.materializeRightFrozenBand(this._options)) { - return; - } - - if (this._paneEventsBound) { - const vm = this._viewportMgr; - this.disableSelection([vm.headerRF]); - const viewports = [vm.viewportTopRF, vm.viewportBottomRF]; - const canvases = [vm.canvasTopRF, vm.canvasBottomRF]; - if (!hadCorner && vm.canvasBottomFrozenRF) { - // the shared bottom-frozen × right-frozen corner arrived with this band - viewports.push(vm.viewportBottomFrozenRF); - canvases.push(vm.canvasBottomFrozenRF); - } - this.bindPaneEvents({ - viewports, - canvases, - headerScrollers: [vm.headerScrollerRF], - headerRowScrollers: [vm.headerRowScrollerRF], - footerRows: this._options.createFooterRow ? [vm.footerRowRF] : [], - footerRowScrollers: this._options.createFooterRow ? [vm.footerRowScrollerRF] : [], - }); - this.setupColumnSort([vm.headerRF]); + // materialize whatever bands the new freeze state requires (classic → RF → BF + // inside the vm; runs before setColumns in the internal_setOptions pipeline) + // and wire events for exactly the elements that were created + const added = this._viewportMgr.ensureBandsMaterialized(this._options); + if (added) { + this.bindMaterialized(added); } } From 3aa1a8a488cad2b998e78fb4c313f117fff57616 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 19 Jul 2026 22:02:27 +0930 Subject: [PATCH 39/43] test: golden characterization of per-band width arithmetic ahead of M19d Formula-transcribed pins computed from live column data (no hardcoded px): the +1000 left/single-band header slack (exact), the right-frozen band as a PLAIN column sum with no slack or scrollbar (exact, header and canvas), the cumulative headersWidthR floor under a left freeze, plain per-band canvas sums, and the plain-grid quirk that the hidden R header container still gets width: 0px written. All 11 tests pass against current code before the computeHeaderWidths/computeCanvasWidths relocation. Co-Authored-By: Claude Fable 5 --- cypress/e2e/viewportmgr-width-golden.cy.ts | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 cypress/e2e/viewportmgr-width-golden.cy.ts diff --git a/cypress/e2e/viewportmgr-width-golden.cy.ts b/cypress/e2e/viewportmgr-width-golden.cy.ts new file mode 100644 index 00000000..d106b016 --- /dev/null +++ b/cypress/e2e/viewportmgr-width-golden.cy.ts @@ -0,0 +1,109 @@ +/** + * Golden characterization of the per-band width arithmetic ahead of M19d (the + * computeHeaderWidths/computeCanvasWidths relocation — FACADE-FEASIBILITY.md). + * Assertions transcribe the getHeadersWidth/getCanvasWidth formulas from column + * data at runtime, pinning the load-bearing quirks: + * - the +1000 slack on the left/single header band (resize drag headroom) + * - the RIGHT-FROZEN header band is a PLAIN column sum — no slack, no scrollbar + * - headersWidthR is CUMULATIVE (includes the post-slack L) under a left freeze + * - a plain grid still writes the R header container's width (it computes to 0) + * - canvas widths are plain per-band sums (when fullWidthRows is off) + * + * Named to sort after the example-* specs (shared browser session). + */ + +const styleWidth = (el: HTMLElement) => parseFloat(el.style.width); +const sumWidths = (cols: any[], from: number, to: number) => + cols.slice(from, to).filter((c: any) => c && !c.hidden).reduce((a: number, c: any) => a + (c.width || 0), 0); + +describe('width golden values - left freeze + frozen rows (example-frozen-columns-and-rows: frozenColumn 2)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns-and-rows.html`); + }); + + it('should size the left header band to its column sum PLUS the historical 1000px slack', () => { + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + const headerL = win.document.querySelector('#myGrid .slick-header-columns-left') as HTMLElement; + expect(styleWidth(headerL), 'headersWidthL = sum(frozen cols) + 1000').to.eq(sumWidths(cols, 0, 3) + 1000); + }); + }); + + it('should size the main header band CUMULATIVELY (it includes the post-slack left width)', () => { + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + const headerL = win.document.querySelector('#myGrid .slick-header-columns-left') as HTMLElement; + const headerR = win.document.querySelector('#myGrid .slick-header-columns-right') as HTMLElement; + // exact value involves max(sumR, viewportW); the cumulative property is the quirk: + expect(styleWidth(headerR), 'headersWidthR includes headersWidthL').to.be.gte(styleWidth(headerL) + sumWidths(cols, 3, cols.length) - 1); + }); + }); + + it('should size the canvases to plain per-band column sums', () => { + cy.window().then((win: any) => { + if (win.grid.getOptions().fullWidthRows) { return; } // extra-width path not exercised here + const cols = win.grid.getColumns(); + const canvasL = win.document.querySelector('#myGrid .grid-canvas-top.grid-canvas-left') as HTMLElement; + const canvasR = win.document.querySelector('#myGrid .grid-canvas-top.grid-canvas-right') as HTMLElement; + expect(styleWidth(canvasL), 'canvasWidthL = sum(frozen cols)').to.eq(sumWidths(cols, 0, 3)); + expect(styleWidth(canvasR), 'canvasWidthR = sum(scrollable cols)').to.eq(sumWidths(cols, 3, cols.length)); + }); + }); +}); + +describe('width golden values - right-frozen band (example-frozen-right-columns: frozenRightColumn 2)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-right-columns.html`); + }); + + it('should size the right-frozen header band to a PLAIN column sum - no slack, no scrollbar', () => { + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + const rfStart = win.grid.getFrozenRightStartIndex(); + const headerRF = win.document.querySelector('#myGrid .slick-header-columns-right-frozen') as HTMLElement; + expect(styleWidth(headerRF), 'headersWidthRF = sum(rf cols) exactly').to.eq(sumWidths(cols, rfStart, cols.length)); + }); + }); + + it('should size the right-frozen canvas to the same plain sum', () => { + cy.window().then((win: any) => { + if (win.grid.getOptions().fullWidthRows) { return; } + const cols = win.grid.getColumns(); + const rfStart = win.grid.getFrozenRightStartIndex(); + const canvasRF = win.document.querySelector('#myGrid .grid-canvas-right-frozen') as HTMLElement; + expect(styleWidth(canvasRF), 'canvasWidthRF = sum(rf cols)').to.eq(sumWidths(cols, rfStart, cols.length)); + }); + }); + + it('should keep the single scrollable band on the +1000-slack formula', () => { + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + const rfStart = win.grid.getFrozenRightStartIndex(); + const headerL = win.document.querySelector('#myGrid .slick-header-columns-left') as HTMLElement; + // no left freeze: L = max(sum + scrollbar, viewportW) + 1000 - assert the floor + expect(styleWidth(headerL), 'headersWidthL >= sum(main cols) + 1000').to.be.gte(sumWidths(cols, 0, rfStart) + 1000); + }); + }); +}); + +describe('width golden values - plain grid (example1-simple)', () => { + it('should load the example', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple.html`); + }); + + it('should still write the RIGHT header container width in a plain grid - it computes to 0 (historical)', () => { + cy.window().then((win: any) => { + const headerR = win.document.querySelector('#myGrid .slick-header-columns-right') as HTMLElement; + expect(headerR, 'hidden R container exists (always-built precedent)').to.exist; + expect(headerR.style.width, 'width written as 0').to.eq('0px'); + }); + }); + + it('should apply the +1000 slack to the single header band', () => { + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + const headerL = win.document.querySelector('#myGrid .slick-header-columns-left') as HTMLElement; + expect(styleWidth(headerL), 'headersWidthL >= sum(all cols) + 1000').to.be.gte(sumWidths(cols, 0, cols.length) + 1000); + }); + }); +}); From 5c6fc9948c6c807dd8c544572ce0e3d08284a3c3 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 19 Jul 2026 22:24:16 +0930 Subject: [PATCH 40/43] refactor: relocate per-band width arithmetic into the vm (M19d) computeHeaderWidths/computeCanvasWidths move VERBATIM from getHeadersWidth/ getCanvasWidth (reverse iteration and all), guarded by the golden spec committed first (viewportmgr-width-golden.cy.ts, 11 formula pins). Quirks preserved in place: the +1000 left/single-band slack, CUMULATIVE r (includes the post-slack l) under a left freeze, the RF band as a plain sum, the out-of-range isColumnRightOfFreeze(columns.length) scrollbar probe after the loop, and fullWidthRows extra width to the scrollable band. Two deliberate non-moves: rfStartIdx stays a GRID-derived fresh input (these call sites historically re-derive it per call; the freeze snapshot is not substituted), and the grid keeps headersWidthL/R/RF + headersWidth + canvasWidthL/R/RF as synced mirrors - they are protected fields visible to subclass wrappers (slickgrid-universal); deleting them is a semver-major follow-up, not part of this work. tsc/eslint clean; gate 77/77 (golden spec leading); full suite 679 / 0 failing. Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++++ src/slick.grid.ts | 95 ++++++++++++----------------------------------- 2 files changed, 118 insertions(+), 71 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index 58992fe3..93cbd7a3 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -2327,6 +2327,100 @@ export class ViewportMgr { return this.freeze.hasFrozenRows && !this.freeze.frozenBottom ? row - (this.freeze.frozenRowCount ?? 0) : row; } + /** + * The per-band header width arithmetic (M19d), moved VERBATIM from the grid's + * getHeadersWidth and guarded by viewportmgr-width-golden.cy.ts. Quirks preserved: + * the +1000 slack on the left/single band, CUMULATIVE r (includes the post-slack + * l) under a left freeze, the RF band as a plain sum (no slack, no scrollbar), + * and the out-of-range isColumnRightOfFreeze(columns.length) scrollbar-attribution + * probe after the loop. g.rfStartIdx stays a GRID-derived fresh input — these + * call sites historically re-derive it per call rather than reading the snapshot. + */ + computeHeaderWidths(columns: Array<{ width?: number; hidden?: boolean; } | undefined>, g: { includeScrollbar: boolean; scrollbarWidth: number; viewportW: number; rfStartIdx: number; }): { l: number; r: number; rf: number; sum: number; padded: number; } { + let l = 0; + let r = 0; + let rf = 0; + + let i = 0; + const ii = columns.length; + for (i = 0; i < ii; i++) { + if (!columns[i] || columns[i]!.hidden) { continue; } + + const width = columns[i]!.width; + + if (i >= g.rfStartIdx) { + // right-frozen headers are fixed-width (no horizontal scrolling): plain sum + rf += width || 0; + } else if (this.isColumnRightOfFreeze(i)) { + r += width || 0; + } else { + l += width || 0; + } + } + + if (g.includeScrollbar) { + // historical out-of-range probe: i === columns.length here, so this reads the + // raw predicate deliberately (a band oracle is undefined past the last column) + if (this.isColumnRightOfFreeze(i)) { + r += g.scrollbarWidth; + } else { + l += g.scrollbarWidth; + } + } + + if (this.hasFrozenColumns()) { + l = l + 1000; + + r = Math.max(r, g.viewportW) + l; + r += g.scrollbarWidth; + } else { + l += g.scrollbarWidth; + l = Math.max(l, g.viewportW) + 1000; + } + + const sum = l + r; + return { l, r, rf, sum, padded: Math.max(sum, g.viewportW) + 1000 }; + } + + /** + * The per-band canvas width arithmetic (M19d), moved VERBATIM from the grid's + * getCanvasWidth (reverse iteration and all): plain per-band sums, with the + * fullWidthRows extra width going to the scrollable band (r under a left freeze, + * l otherwise). g.rfStartIdx is the grid's fresh derivation, as above. + */ + computeCanvasWidths(columns: Array<{ width?: number; hidden?: boolean; } | undefined>, g: { availableWidth: number; fullWidthRows: boolean; rfStartIdx: number; }): { l: number; r: number; rf: number; total: number; } { + let l = 0; + let r = 0; + let rf = 0; + let i = columns.length; + + while (i--) { + if (!columns[i] || columns[i]!.hidden) { continue; } + + if (i >= g.rfStartIdx) { + rf += columns[i]!.width || 0; + } else if (this.isColumnRightOfFreeze(i)) { + r += columns[i]!.width || 0; + } else { + l += columns[i]!.width || 0; + } + } + + let total = l + r + rf; + if (g.fullWidthRows) { + const extraWidth = Math.max(total, g.availableWidth) - total; + if (extraWidth > 0) { + total += extraWidth; + if (this.hasFrozenColumns()) { + r += extraWidth; + } else { + l += extraWidth; + } + } + } + return { l, r, rf, total }; + } + /** * Index of the column's node inside rowsCache[].rowNode: 0 for the left fragment * (which is the scrollable fragment when no columns are left-frozen), 1 for the diff --git a/src/slick.grid.ts b/src/slick.grid.ts index d5df6a54..11385aaa 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -4552,47 +4552,20 @@ export class SlickGrid = Column, O e * Returns the computed overall header width in pixels. */ getHeadersWidth() { - this.headersWidth = this.headersWidthL = this.headersWidthR = this.headersWidthRF = 0; - const includeScrollbar = !this._options.autoHeight; - const rfStartIdx = this.getFrozenRightStartIdx(); - - let i = 0; - const ii = this.columns.length; - for (i = 0; i < ii; i++) { - if (!this.columns[i] || this.columns[i].hidden) { continue; } - - const width = this.columns[i].width; - - if (i >= rfStartIdx) { - // right-frozen headers are fixed-width (no horizontal scrolling): plain sum - this.headersWidthRF += width || 0; - } else if (this._viewportMgr.isColumnRightOfFreeze(i)) { - this.headersWidthR += width || 0; - } else { - this.headersWidthL += width || 0; - } - } - - if (includeScrollbar) { - if (this._viewportMgr.isColumnRightOfFreeze(i)) { - this.headersWidthR += this.scrollbarDimensions?.width ?? 0; - } else { - this.headersWidthL += this.scrollbarDimensions?.width ?? 0; - } - } - - if (this._viewportMgr.hasFrozenColumns()) { - this.headersWidthL = this.headersWidthL + 1000; - - this.headersWidthR = Math.max(this.headersWidthR, this.viewportW) + this.headersWidthL; - this.headersWidthR += this.scrollbarDimensions?.width ?? 0; - } else { - this.headersWidthL += this.scrollbarDimensions?.width ?? 0; - this.headersWidthL = Math.max(this.headersWidthL, this.viewportW) + 1000; - } - - this.headersWidth = this.headersWidthL + this.headersWidthR; - return Math.max(this.headersWidth, this.viewportW) + 1000; + // arithmetic lives in the vm (M19d, golden-guarded); the grid keeps the + // headersWidth* fields as synced mirrors — subclass compatibility (they are + // protected and visible to wrappers like slickgrid-universal) + const w = this._viewportMgr.computeHeaderWidths(this.columns, { + includeScrollbar: !this._options.autoHeight, + scrollbarWidth: this.scrollbarDimensions?.width ?? 0, + viewportW: this.viewportW, + rfStartIdx: this.getFrozenRightStartIdx(), + }); + this.headersWidthL = w.l; + this.headersWidthR = w.r; + this.headersWidthRF = w.rf; + this.headersWidth = w.sum; + return w.padded; } /** Get the grid canvas width @@ -4602,36 +4575,16 @@ export class SlickGrid = Column, O e * If full–width rows are enabled, extra width is added. Returns the total calculated width. */ getCanvasWidth(): number { - const availableWidth = this.getViewportInnerWidth(); - let i = this.columns.length; - - this.canvasWidthL = this.canvasWidthR = this.canvasWidthRF = 0; - const rfStartIdx = this.getFrozenRightStartIdx(); - - while (i--) { - if (!this.columns[i] || this.columns[i].hidden) { continue; } - - if (i >= rfStartIdx) { - this.canvasWidthRF += this.columns[i].width || 0; - } else if (this._viewportMgr.isColumnRightOfFreeze(i)) { - this.canvasWidthR += this.columns[i].width || 0; - } else { - this.canvasWidthL += this.columns[i].width || 0; - } - } - let totalRowWidth = this.canvasWidthL + this.canvasWidthR + this.canvasWidthRF; - if (this._options.fullWidthRows) { - const extraWidth = Math.max(totalRowWidth, availableWidth) - totalRowWidth; - if (extraWidth > 0) { - totalRowWidth += extraWidth; - if (this._viewportMgr.hasFrozenColumns()) { - this.canvasWidthR += extraWidth; - } else { - this.canvasWidthL += extraWidth; - } - } - } - return totalRowWidth; + // arithmetic lives in the vm (M19d, golden-guarded); canvasWidth* mirrors kept + const w = this._viewportMgr.computeCanvasWidths(this.columns, { + availableWidth: this.getViewportInnerWidth(), + fullWidthRows: !!this._options.fullWidthRows, + rfStartIdx: this.getFrozenRightStartIdx(), + }); + this.canvasWidthL = w.l; + this.canvasWidthR = w.r; + this.canvasWidthRF = w.rf; + return w.total; } /** From a70430747b95288ea041215e04dbd769ad9939a7 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Sun, 19 Jul 2026 23:22:33 +0930 Subject: [PATCH 41/43] refactor: column-width band oracle + resize accumulation (M19d completion) - columnBandGeometry(colIdx, geometry): applyColumnWidths' three-way band width pick plus BOTH historical x-reset conventions as named booleans - the RF band resets BEFORE its first column, the left freeze resets AFTER the frozen column (which itself does not accumulate). frozenColumnIdx and rfStartIdx stay grid-fresh inputs (live options read + per-call derivation preserved). - accumulateBandWidths: setupColumnResize's two CLEAN bucketing passes collapse to one call. Honest scope note: the report claimed six repeats; the other four interleave bucketing with forceFit width mutation and are a different shape - they stay inline. - setLiveResizeLeftWidth: the drag-time header slack (+1000) and middle header pane re-anchor move behind the facade. - updateColumnCaches keeps its left-only reset INLINE, now documented: RF columns deliberately continue its coordinate space (preserved asymmetry vs the band oracle). tsc/eslint clean; gate 81/81; full suite 679 / 0 failing. Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ src/slick.grid.ts | 53 ++++++++++++++++++----------------------------- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index 93cbd7a3..58d57df3 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -2382,6 +2382,58 @@ export class ViewportMgr { return { l, r, rf, sum, padded: Math.max(sum, g.viewportW) + 1000 }; } + /** + * setupColumnResize's clean accumulation pass (M19d): bucket visible-column + * widths into left vs scrollable, up to AND INCLUDING upToIdx. RF columns fall + * into the r bucket exactly as historically (no rf accumulator — the resize + * logic never consumed one). The four bucketing passes interleaved with + * forceFit width mutation stay inline in the grid: they are a different shape, + * not repeats of this one. + */ + accumulateBandWidths(columns: Array<{ width?: number; hidden?: boolean; } | undefined>, upToIdx: number): { l: number; r: number; } { + let l = 0; + let r = 0; + for (let k = 0; k <= upToIdx; k++) { + const c = columns[k]; + if (!c || c.hidden) { continue; } + if (this.isColumnRightOfFreeze(k)) { + r += c.width || 0; + } else { + l += c.width || 0; + } + } + return { l, r }; + } + + /** The live resize-drag DOM writes under a left freeze (M19d): the left header + * keeps its historical +1000 slack and the middle header pane is re-anchored. */ + setLiveResizeLeftWidth(newCanvasWidthL: number): void { + Utils.width(this.headerL, newCanvasWidthL + 1000); + Utils.setStyleSize(this.paneHeaderR, 'left', newCanvasWidthL); + } + + /** + * applyColumnWidths' per-column band oracle (M19d): the three-way band width + * pick plus BOTH historical x-reset conventions — the RF band resets the + * running offset BEFORE its first column (it starts a new viewport) and the + * left freeze resets AFTER the frozen column, which itself does not + * accumulate. frozenColumnIdx/rfStartIdx stay grid-fresh inputs (the call site + * historically reads live options and re-derives rfStartIdx per call). + * updateColumnCaches deliberately uses ONLY the left-freeze reset — RF columns + * continue its coordinate space; that asymmetry stays inline there. + */ + columnBandGeometry(colIdx: number, g: { canvasWidthL: number; canvasWidthR: number; canvasWidthRF: number; frozenColumnIdx: number; rfStartIdx: number; }): { bandWidth: number; resetXBefore: boolean; accumulate: boolean; resetXAfter: boolean; } { + const bandWidth = colIdx >= g.rfStartIdx + ? g.canvasWidthRF + : ((g.frozenColumnIdx !== -1 && colIdx > g.frozenColumnIdx) ? g.canvasWidthR : g.canvasWidthL); + return { + bandWidth, + resetXBefore: colIdx === g.rfStartIdx, + accumulate: g.frozenColumnIdx !== colIdx, + resetXAfter: g.frozenColumnIdx === colIdx, + }; + } + /** * The per-band canvas width arithmetic (M19d), moved VERBATIM from the grid's * getCanvasWidth (reverse iteration and all): plain per-band sums, with the diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 11385aaa..4f8d8bf2 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -1859,7 +1859,6 @@ export class SlickGrid = Column, O e } let j: number; - let k: number; let c: C; let pageX: number; let minPageX: number; @@ -2001,16 +2000,7 @@ export class SlickGrid = Column, O e } } - for (k = 0; k <= i; k++) { - c = vc[k]; - if (!c || c.hidden) { continue; } - - if (this._viewportMgr.isColumnRightOfFreeze(k)) { - newCanvasWidthR += c.width || 0; - } else { - newCanvasWidthL += c.width || 0; - } - } + ({ l: newCanvasWidthL, r: newCanvasWidthR } = this._viewportMgr.accumulateBandWidths(vc, i)); if (this._options.forceFitColumns) { x = -d; @@ -2093,16 +2083,7 @@ export class SlickGrid = Column, O e } } - for (k = 0; k <= i; k++) { - c = vc[k]; - if (!c || c.hidden) { continue; } - - if (this._viewportMgr.isColumnRightOfFreeze(k)) { - newCanvasWidthR += c.width || 0; - } else { - newCanvasWidthL += c.width || 0; - } - } + ({ l: newCanvasWidthL, r: newCanvasWidthR } = this._viewportMgr.accumulateBandWidths(vc, i)); if (this._options.forceFitColumns) { x = -d; @@ -2142,8 +2123,7 @@ export class SlickGrid = Column, O e } if (this._viewportMgr.hasFrozenColumns() && newCanvasWidthL !== this.canvasWidthL) { - Utils.width(this._viewportMgr.headerL, newCanvasWidthL + 1000); - Utils.setStyleSize(this._viewportMgr.paneHeaderR, 'left', newCanvasWidthL); + this._viewportMgr.setLiveResizeLeftWidth(newCanvasWidthL); } this.applyColumnHeaderWidths(); @@ -2993,10 +2973,17 @@ export class SlickGrid = Column, O e let x = 0; let w = 0; let rule: any; - const rfStartIdx = this.getFrozenRightStartIdx(); + const geometry = { + canvasWidthL: this.canvasWidthL, + canvasWidthR: this.canvasWidthR, + canvasWidthRF: this.canvasWidthRF, + frozenColumnIdx: this._options.frozenColumn!, + rfStartIdx: this.getFrozenRightStartIdx(), + }; for (let i = 0; i < this.columns.length; i++) { - // the right-frozen band starts a new viewport: reset the running left offset - if (i === rfStartIdx) { + const band = this._viewportMgr.columnBandGeometry(i, geometry); + if (band.resetXBefore) { + // the right-frozen band starts a new viewport: reset the running left offset x = 0; } if (!this.columns[i]?.hidden) { @@ -3004,17 +2991,15 @@ export class SlickGrid = Column, O e rule = this.getColumnCssRules(i); rule.left.style.left = `${x}px`; - rule.right.style.right = ((i >= rfStartIdx - ? this.canvasWidthRF - : ((this._options.frozenColumn !== -1 && i > this._options.frozenColumn!) ? this.canvasWidthR : this.canvasWidthL)) - x - w) + 'px'; + rule.right.style.right = (band.bandWidth - x - w) + 'px'; - // If this column is frozen, reset the css left value since the - // column starts in a new viewport. - if (this._options.frozenColumn !== i) { + // the frozen column itself does not accumulate — it starts a new viewport + if (band.accumulate) { x += this.columns[i].width!; } } - if (this._options.frozenColumn === i) { + if (band.resetXAfter) { + // left freeze resets AFTER the frozen column x = 0; } } @@ -3117,6 +3102,8 @@ export class SlickGrid = Column, O e this.columnPosLeft[i] = x; this.columnPosRight[i] = x + (this.columns[i].width || 0); + // deliberately ONLY the left-freeze reset here — RF columns continue this + // coordinate space (preserved asymmetry vs applyColumnWidths' band oracle) if (this._options.frozenColumn === i) { x = 0; } else { From ea040432b82bad6efe6e455ddcc9bf7926e33fa4 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Mon, 20 Jul 2026 06:05:55 +0930 Subject: [PATCH 42/43] refactor: render-path band knowledge onto the facade (M19e, minimal cut) - createRowFragments(rowDiv): clone-per-active-band moves into the vm (the clone-not-share requirement documented at the source); appendRowHtml keeps the divArray push plumbing verbatim - the holding-div drain in renderRows depends on its exact pattern, so the alignment-hazard zone is untouched. - fragmentForColumn(frags, i, { alwaysRenderColumn, branch }): the cell routing rules keyed by the caller's EXACT control branch. The viewport branch keeps the RF->L fallback (transition safety); the offViewport branch keeps alwaysRender/left-frozen->l and right-frozen->rf. The two rule sets are deliberately NOT unified into one inViewport predicate: outer-viewport-test-true with isRenderCell false must render NOTHING, including frozen cells (historical). - collectRowCellNodes: the fragment-flatten with its ascending-column-order invariant (cellRenderQueue tail-draining depends on it) named and moved. - updateRowPositions keeps its [0]-only reposition INLINE, now documented as inherited upstream behavior. Considered and skipped (no facade gain): appendCellToRow (the band routing is already the vm's rowNodeIdxForColumn), setRowTop as a member, and any restructure of the renderRows holding-div drain. tsc/eslint clean; gate 180/180 (rowspan/colspan suites leading); full suite 679 / 0 failing. Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++ src/slick.grid.ts | 48 +++++++++++++++++----------------------- 2 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index 58d57df3..caf62488 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -2405,6 +2405,62 @@ export class ViewportMgr { return { l, r }; } + /** + * Deep-clones a row div once per additional ACTIVE column band (M19e): the + * clone-not-share requirement lives here — the same element cannot be appended + * to two canvases. `r` exists iff columns are frozen; `rf` iff the right-frozen + * band's DOM exists (band count set but DOM not yet materialized → no rf clone, + * the transition-safety state fragmentForColumn falls back on). + */ + createRowFragments(rowDiv: HTMLElement): { l: HTMLElement; r?: HTMLElement; rf?: HTMLElement; } { + const frags: { l: HTMLElement; r?: HTMLElement; rf?: HTMLElement; } = { l: rowDiv }; + if (this.hasFrozenColumns()) { + // it has to be a deep copy otherwise we will have issues with pass by + // reference in js since attempting to add the same element to 2 different + // arrays will just move 1 item to the other array + frags.r = rowDiv.cloneNode(true) as HTMLElement; + } + if (this.hasRightFrozenBand()) { + frags.rf = rowDiv.cloneNode(true) as HTMLElement; + } + return frags; + } + + /** + * appendRowHtml's cell-routing rules (M19e), keyed by the caller's exact control + * branch — the two rule sets are NOT a unified predicate: + * - 'viewport': three-way band pick with the RF→L fallback when the rf fragment + * was not cloned (transition safety); + * - 'offViewport': alwaysRenderColumn or left-frozen cells render into l, + * right-frozen cells into rf (when cloned), anything else does not render. + */ + fragmentForColumn(frags: { l: HTMLElement; r?: HTMLElement; rf?: HTMLElement; }, colIdx: number, opts: { alwaysRenderColumn: boolean; branch: 'viewport' | 'offViewport'; }): HTMLElement | null { + if (opts.branch === 'viewport') { + return this.bandElementForColumn(colIdx, frags.l, frags.r as HTMLElement, frags.rf ?? frags.l); + } + if (opts.alwaysRenderColumn || this.isColumnInFrozenBand(colIdx)) { + return frags.l; + } + if (frags.rf && this.isColumnInRightFrozenBand(colIdx)) { + // right-frozen cells are always horizontally visible, like the left-frozen band + return frags.rf; + } + return null; + } + + /** + * Flattened cell nodes of a row's band fragments in ascending column order + * (M19e) — the fragment-order-equals-column-order invariant the tail-drained + * cellRenderQueue depends on. + */ + collectRowCellNodes(rowNode: HTMLElement[]): HTMLElement[] { + let children = Array.from(rowNode[0].children) as HTMLElement[]; + for (let n = 1; n < rowNode.length; n++) { + children = children.concat(Array.from(rowNode[n].children) as HTMLElement[]); + } + return children; + } + /** The live resize-drag DOM writes under a left freeze (M19d): the left header * keeps its historical +1000 slack and the middle header pane is re-anchored. */ setLiveResizeLeftWidth(newCanvasWidthL: number): void { diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 4f8d8bf2..47fce28a 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -5055,19 +5055,15 @@ export class SlickGrid = Column, O e } else { rowDiv.style.top = `${topOffset}px`; // default to `top: {offset}px` } - - let rowDivR: HTMLElement | undefined; - let rowDivRF: HTMLElement | undefined; - divArrayL.push(rowDiv); - if (this._viewportMgr.hasFrozenColumns()) { - // it has to be a deep copy otherwise we will have issues with pass by reference in js since - // attempting to add the same element to 2 different arrays will just move 1 item to the other array - rowDivR = rowDiv.cloneNode(true) as HTMLElement; - divArrayR.push(rowDivR); + // clone-per-band lives in the vm (M19e); the divArray plumbing stays here — + // the holding-div drain in renderRows depends on its exact push pattern + const frags = this._viewportMgr.createRowFragments(rowDiv); + divArrayL.push(frags.l); + if (frags.r) { + divArrayR.push(frags.r); } - if (this._viewportMgr.hasRightFrozenBand()) { - rowDivRF = rowDiv.cloneNode(true) as HTMLElement; - divArrayRF.push(rowDivRF); + if (frags.rf) { + divArrayRF.push(frags.rf); } const columnCount = this.columns.length; @@ -5117,16 +5113,16 @@ export class SlickGrid = Column, O e // All columns to the right are outside the range, so no need to render them if (isRenderCell) { - // fall back to the row's own fragment if the RF fragment was not cloned - // (band count set but DOM not yet materialized — transition safety) - const targetedRowDiv = this._viewportMgr.bandElementForColumn(i, rowDiv, rowDivR!, rowDivRF ?? rowDiv); - this.appendCellHtml(targetedRowDiv, row, i, ncolspan, rowspan, columnData, d); + const target = this._viewportMgr.fragmentForColumn(frags, i, { alwaysRenderColumn: !!m.alwaysRenderColumn, branch: 'viewport' }); + this.appendCellHtml(target!, row, i, ncolspan, rowspan, columnData, d); + } + } else { + // off-viewport: alwaysRender/left-frozen cells render into l, right-frozen + // into rf — the two rule sets stay keyed by the OUTER branch (historical) + const target = this._viewportMgr.fragmentForColumn(frags, i, { alwaysRenderColumn: !!m.alwaysRenderColumn, branch: 'offViewport' }); + if (target) { + this.appendCellHtml(target, row, i, ncolspan, rowspan, columnData, d); } - } else if (m.alwaysRenderColumn || this._viewportMgr.isColumnInFrozenBand(i)) { - this.appendCellHtml(rowDiv, row, i, ncolspan, rowspan, columnData, d); - } else if (rowDivRF && this._viewportMgr.isColumnInRightFrozenBand(i)) { - // right-frozen cells are always horizontally visible, like the left-frozen band - this.appendCellHtml(rowDivRF, row, i, ncolspan, rowspan, columnData, d); } if (ncolspan > 1) { @@ -5814,13 +5810,7 @@ export class SlickGrid = Column, O e protected ensureCellNodesInRowsCache(row: number) { const cacheEntry = this.rowsCache[row]; if (cacheEntry?.cellRenderQueue.length && cacheEntry.rowNode?.length) { - const rowNode = cacheEntry.rowNode as HTMLElement[]; - let children = Array.from(rowNode[0].children) as HTMLElement[]; - // concat every additional fragment's children (middle band and, when active, - // the right-frozen band — fragments are ordered by ascending column index) - for (let n = 1; n < rowNode.length; n++) { - children = children.concat(Array.from(rowNode[n].children) as HTMLElement[]); - } + const children = this._viewportMgr.collectRowCellNodes(cacheEntry.rowNode as HTMLElement[]); let i = children.length - 1; while (cacheEntry.cellRenderQueue.length) { @@ -6093,6 +6083,8 @@ export class SlickGrid = Column, O e for (const row in this.rowsCache) { if (this.rowsCache) { const rowNumber = row ? parseInt(row, 10) : 0; + // ONLY fragment [0] is repositioned — inherited upstream behavior (the + // other band fragments follow via their canvases), preserved deliberately const rowNode = this.rowsCache[rowNumber].rowNode![0]; if (this._options.rowTopOffsetRenderType === 'transform') { rowNode.style.transform = `translateY(${this.getRowTop(rowNumber)}px)`; From b490cfbd142cd0cfb03ce5930f2ca642fee700c4 Mon Sep 17 00:00:00 2001 From: 6pac-ai <6pac@dharpa.com> Date: Mon, 20 Jul 2026 06:21:42 +0930 Subject: [PATCH 43/43] docs: retain the 66 named-element getters as vm vocabulary (M19f decision) The facade plan (FACADE-FEASIBILITY.md) scheduled these for terminal deletion as compat scaffolding. The M19f census says otherwise: ~57 legitimate references remain at grid chrome/geometry sites where vm.paneHeaderL reads better than a paneAt()/at() chain with a non-null assertion, and the vm's own geometry appliers and materializer manifests use them throughout. Deleting them would trade readable code for ~66 lines - the same no-facade-gain test that trimmed other report members (appendCellToRow, setRowTop, isBandBoundaryAfter) applies. The getter block's comment now states the retention decision and their role. M19 series complete: a (collections + alias deletion), b (column/row routing + predicates, spec-guarded), c (materialization manifests), d (width arithmetic relocation, golden-guarded), e (render-path minimal cut), f (this decision). slick.grid.ts net -422 vs master with the full 3x3 band feature set included. Co-Authored-By: Claude Fable 5 --- src/slick.core.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/slick.core.ts b/src/slick.core.ts index caf62488..6dffb830 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -1534,8 +1534,14 @@ export class CellSet { } export class ViewportMgr { - // named-element compat getters over the pane matrix (M18b): same runtime - // semantics as the historical definite-assignment fields (undefined until built) + // named-element getters over the pane matrix (M18b): same runtime semantics as + // the historical definite-assignment fields (undefined until built). Introduced + // as compat scaffolding, RETAINED by decision at M19f: after the facade + // conversion they remain the manager's own named-element vocabulary — used by + // the geometry appliers, materializer manifests and the grid's residual + // chrome/geometry sites, where `vm.paneHeaderL` reads better than a + // paneAt()/at() chain with a non-null assertion. Read-only accessors; the + // matrix cell is the single source of truth. get paneHeaderL(): HTMLDivElement { return this.paneAt('header', 'l')?.pane as HTMLDivElement; } get paneHeaderR(): HTMLDivElement { return this.paneAt('header', 'r')?.pane as HTMLDivElement; } get paneHeaderRF(): HTMLDivElement { return this.paneAt('header', 'rf')?.pane as HTMLDivElement; }