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,
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/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/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);
+ });
+});
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..451ad8c3
--- /dev/null
+++ b/cypress/e2e/viewportmgr-bottom-frozen-band.cy.ts
@@ -0,0 +1,120 @@
+/**
+ * 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 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 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);
+ 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);
+ });
+ });
+ });
+});
+
+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);
+
+ // 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, 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/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/cypress/e2e/viewportmgr-right-frozen-band.cy.ts b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts
new file mode 100644
index 00000000..33373f6f
--- /dev/null
+++ b/cypress/e2e/viewportmgr-right-frozen-band.cy.ts
@@ -0,0 +1,186 @@
+/**
+ * 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 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');
+ 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 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.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);
+ });
+ });
+});
+
+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`);
+ 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');
+
+ // 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, restoring classic routing', () => {
+ 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 .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/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);
+ });
+ });
+});
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/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/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/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/gridOption.interface.ts b/src/models/gridOption.interface.ts
index 2d2bfe89..e06010b4 100644
--- a/src/models/gridOption.interface.ts
+++ b/src/models/gridOption.interface.ts
@@ -244,6 +244,23 @@ 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` —
+ * 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
@@ -251,6 +268,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/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..c3d18767
--- /dev/null
+++ b/src/models/viewportMgr.interface.ts
@@ -0,0 +1,107 @@
+// 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;
+}
+
+/**
+ * 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;
+ showPreHeaderPanel?: boolean;
+ createFooterRow?: boolean;
+ showFooterRow?: boolean;
+ showColumnHeader?: boolean;
+ showTopPanel?: boolean;
+ showHeaderRow?: boolean;
+ viewportClass?: string;
+ lazyPanes?: boolean;
+ frozenColumn?: number;
+ frozenRow?: number;
+}
diff --git a/src/plugins/slick.cellrangeselector.ts b/src/plugins/slick.cellrangeselector.ts
index 71e3dbfa..bd297952 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,62 @@ 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'}`;
- const canvasElm = document.querySelector(canvasSelector);
+ // 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;
+
+ // 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 frozen classic-band canvas via its band-truth marker
+ // (BAND-LABELLING.md): bottom-frozen in legacy frozenBottom mode, top-frozen
+ // 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)
+ ?? document.querySelector(`.${this._grid.getUID()} .grid-canvas-${legacyBottomMode ? 'bottom' : 'top'}`);
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) {
- const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-left`);
+ if (this._bands.frozenLeftCols > 0 && this._isRightCanvas) {
+ 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 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');
if (this._dragReplaceHandleActive) {
this._dragReplaceHandleCell = this._grid.getCellFromEvent(e);
@@ -180,12 +223,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 +394,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.core.ts b/src/slick.core.ts
index 3d18b480..6dffb830 100644
--- a/src/slick.core.ts
+++ b/src/slick.core.ts
@@ -13,6 +13,12 @@ import type {
Handler,
InferDOMType,
MergeTypes,
+ CanvasWidthsGeometry,
+ FreezeBandCounts,
+ PaneHeightsGeometry,
+ PaneElementSets,
+ ViewportFreezeState,
+ ViewportMgrBuildOptions,
} from './models/index.js';
import type { SlickGrid } from './slick.grid.js';
@@ -1326,6 +1332,1757 @@ 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.
+ */
+/** 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' };
+
+/**
+ * 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;
+ }
+
+ // --- 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. */
+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 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; }
+ 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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.headersArr, colOrder.map((c) => cell('header', c)?.header));
+ fill(this.headerRowScroller, colOrder.map((c) => cell('top', c)?.headerRowScroller));
+ 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));
+
+ // 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);
+ }
+
+ // --- 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[] = [];
+ protected headersArr: HTMLDivElement[] = [];
+
+ // header rows
+ headerRowScroller: HTMLDivElement[] = [];
+ protected headerRowsArr: HTMLDivElement[] = [];
+
+ // top panels
+ protected topPanelScrollersArr: HTMLDivElement[] = [];
+ protected topPanelsArr: HTMLDivElement[] = [];
+
+ // viewports and canvases
+ viewport: HTMLDivElement[] = [];
+ canvas: HTMLDivElement[] = [];
+
+ // right-frozen band (Phase 4 — exists only while frozenRightColumn > 0 has been applied)
+
+ // bottom-frozen row band (Phase 4 — exists only in simultaneous top+bottom mode)
+
+ // footer rows (only when createFooterRow)
+ footerRowScroller: 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));
+
+ // historical sibling order: headerL, [headerR], topL, [topR, bottomL, bottomR]
+ this.buildPaneSet('header', 'l', o);
+ if (!this.lazy) {
+ this.buildPaneSet('header', 'r', o);
+ }
+ this.buildPaneSet('top', 'l', o);
+ if (!this.lazy) {
+ this.buildPaneSet('top', 'r', o);
+ this.buildPaneSet('bottom', 'l', o);
+ this.buildPaneSet('bottom', 'r', o);
+ }
+ this.syncElementArrays();
+ }
+
+ /**
+ * 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) {
+ // historical creation order: right scroller before left
+ if (!this.lazy) {
+ this.buildFooterRowFor('r', o, canvasWithScrollbarWidth);
+ }
+ this.buildFooterRowFor('l', o, canvasWithScrollbarWidth);
+ this.syncElementArrays();
+ }
+
+ /**
+ * 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 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): PaneElementSets | null {
+ if (!this.lazy) {
+ return null;
+ }
+ this.lazy = false;
+
+ // 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.createFooterRow) {
+ // spacer width is applied by the updateCanvasWidth that follows materialization
+ this.buildFooterRowFor('r', o);
+ }
+ this.syncElementArrays();
+ 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,
+ };
+ }
+
+ /**
+ * 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. 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): 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 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
+ 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.buildFooterRowFor('rf', 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();
+ 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] : [],
+ };
+ }
+
+ /**
+ * 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 (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): PaneElementSets | null {
+ if (this.paneAt('bf', 'l')) {
+ // 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);
+ const corner = this.ensureBottomFrozenRightVariant(o);
+ this.syncElementArrays();
+ 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,
+ * 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 null;
+ }
+ 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. */
+ 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);
+ }
+
+ // --- 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);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 };
+ }
+
+ /**
+ * 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 };
+ }
+
+ /**
+ * 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 {
+ 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
+ * 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
+ * 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() {
+ 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);
+ }
+ }
+
+ // 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');
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ const leftActive = this.hasFrozenColumns();
+ const rfActive = this.bands.frozenRightCols > 0 && !!this.paneAt('header', 'rf');
+ const rfW = rfActive ? g.canvasWidthRF : 0;
+
+ 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 (rfActive && this.headerRF) {
+ Utils.width(this.headerRF, g.headersWidthRF);
+ }
+
+ // 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 }]);
+ }
+ if (rfActive) {
+ cols.push(['rf', { paneLeft: g.viewportW - rfW, paneW: rfW, canvasW: g.canvasWidthRF, chromeW: 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);
+ }
+ }
+
+ // 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);
+ }
+ }
+
+ // 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);
+ }
+ }
+
+ // 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, spacerW);
+ }
+ if (g.createFooterRow) {
+ Utils.width(this.footerRowSpacerL, spacerW);
+ if (this.footerRowSpacerR) {
+ Utils.width(this.footerRowSpacerR, spacerW);
+ }
+ }
+ }
+
+ /**
+ * 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;
+
+ 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
+ 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 (leftActive && 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 (leftActive) {
+ 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';
+ }
+
+ // 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);
+ } 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);
+ }
+
+ 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) {
+ // 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%');
+ }
+ 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);
+
+ // 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 {
+ 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;
+ 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);
+ }
+ }
+
+ 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.topPanelScrollersArr[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) {
+ // 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()) {
+ const v = followerViewport('l');
+ if (v) { v.scrollTop = scrollTop; }
+ }
+ if (this.bands.frozenRightCols > 0) {
+ const v = followerViewport('rf');
+ if (v) { v.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 +3091,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 9d329b49..47fce28a 100644
--- a/src/slick.grid.ts
+++ b/src/slick.grid.ts
@@ -69,6 +69,7 @@ import type {
OnValidationErrorEventArgs,
OnDragReplaceCellsEventArgs,
PagingInfo,
+ PaneElementSets,
RowInfo,
SelectionModel,
SingleColumnSort,
@@ -90,6 +91,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 +119,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
@@ -287,9 +290,12 @@ export class SlickGrid = Column, O e
enableTextSelectionOnCells: false,
dataItemColumnValueExtractor: null,
frozenBottom: false,
+ frozenBottomRow: 0,
frozenColumn: -1,
frozenRow: -1,
+ frozenRightColumn: 0,
frozenRightViewportMinWidth: 100,
+ lazyPanes: false,
throwWhenFrozenNotAllViewable: false,
fullWidthRows: false,
multiColumnSort: false,
@@ -382,30 +388,17 @@ export class SlickGrid = Column, O e
protected dragReplaceEl = new DragExtendHandle(this.uid);
protected _focusSink!: HTMLDivElement;
protected _focusSink2!: HTMLDivElement;
- 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 _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 _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;
@@ -416,9 +409,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;
@@ -505,44 +500,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[] = [];
@@ -711,119 +668,21 @@ 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);
// 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._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) {
@@ -831,39 +690,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);
- Utils.width(this._headerRowSpacerR, canvasWithScrollbarWidth);
+ this._viewportMgr.headerRowSpacers.width(canvasWithScrollbarWidth);
// 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._focusSink2 = this._focusSink.cloneNode(true) as HTMLDivElement;
@@ -882,6 +719,110 @@ 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.
*/
+
+ /**
+ * 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: PaneElementSets) {
+ 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();
+ });
+ });
+ }
+
+ 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);
+ });
+ }
+
+ /**
+ * 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.
+ */
+ /**
+ * 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 (added.headers?.length) {
+ this.disableSelection(added.headers);
+ }
+
+ this.bindPaneEvents(added);
+
+ 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);
+ });
+
+ if (added.headers?.length) {
+ this.setupColumnSort(added.headers);
+ }
+
+ if (added.bodyCanvasChanged) {
+ // the ancestor-scroll anchor canvas may have changed band
+ this.unbindAncestorScrollEvents();
+ this.bindAncestorScrollEvents();
+ }
+ }
+
protected finishInitialization() {
if (!this.initialized) {
this.initialized = true;
@@ -893,24 +834,15 @@ 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();
- 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();
- });
- });
- }
+ // 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._viewportMgr.headers.elements);
- this.setFrozenOptions();
this.setPaneFrozenClasses();
this.setPaneVisibility();
- this.setScroller();
this.setOverflow();
this.updateColumnCaches();
@@ -922,63 +854,25 @@ 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._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);
- });
- }
+ this.bindPaneEvents(this._viewportMgr.allPaneElements());
+ this._paneEventsBound = true;
if (this._options.createTopHeaderPanel) {
this._bindingEventService.bind(this._topHeaderPanelScroller, 'scroll', this.handleTopHeaderPanelScroll.bind(this) as EventListener);
}
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);
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,
@@ -1108,7 +1002,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');
@@ -1116,34 +1010,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) {
@@ -1206,72 +1096,19 @@ 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.
+ // 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. */
@@ -1295,6 +1132,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.
@@ -1372,6 +1225,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);
}
@@ -1383,14 +1244,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();
}
@@ -1399,8 +1257,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)
@@ -1409,6 +1267,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();
+ }
}
/**
@@ -1453,10 +1315,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();
}
//////////////////////////////////////////////////////////////////////
@@ -1522,10 +1381,10 @@ export class SlickGrid = Column, O e
*/
getHeader(columnDef: C) {
if (!columnDef) {
- return this.hasFrozenColumns() ? this._headers : this._headerL;
+ return this._viewportMgr.hasFrozenColumns() ? this._viewportMgr.headers.elements : this._viewportMgr.headerL;
}
const idx = this.getColumnIndex(columnDef.id);
- return this.hasFrozenColumns() ? ((idx <= this._options.frozenColumn!) ? this._headerL : this._headerR) : this._headerL;
+ return this._viewportMgr.headers.containerForColumn(idx);
}
/**
@@ -1534,20 +1393,21 @@ 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;
-
- return targetHeader.children[targetIndex] as HTMLDivElement;
+ return this._viewportMgr.headers.columnCell(idx);
}
/** Get the Header Row DOM element */
getHeaderRow() {
- return this.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.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];
}
/**
@@ -1555,21 +1415,8 @@ 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;
- }
-
- return headerRowTarget.children[idx] as HTMLDivElement;
+ const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx));
+ return this._viewportMgr.headerRows.columnCell(idx);
}
/**
@@ -1577,22 +1424,8 @@ 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;
- }
-
- return footerRowTarget.children[idx] as HTMLDivElement;
+ const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx));
+ return this._viewportMgr.footerRows.columnCell(idx);
}
/**
@@ -1602,7 +1435,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');
@@ -1614,15 +1447,16 @@ 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.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.footerRows.containerForColumn(i));
+ const className = this._viewportMgr.isColumnInFrozenBand(i) ? 'frozen' : null;
if (className) {
footerRowCell.classList.add(className);
}
@@ -1645,8 +1479,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._viewportMgr.headers.elements) {
+ headers.forEach((header) => {
this._bindingEventService.bind(header, 'click', (e: any) => {
if (this.columnResizeDragging) {
return;
@@ -1750,62 +1584,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);
- Utils.emptyElement(this._headerR);
+ this._viewportMgr.headers.empty();
this.getHeadersWidth();
- Utils.width(this._headerL, this.headersWidthL);
- Utils.width(this._headerR, this.headersWidthR);
+ this._viewportMgr.headers.width({ l: this.headersWidthL, r: this.headersWidthR, rf: 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
- });
- }
- });
+ 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._headerRowL);
- Utils.emptyElement(this._headerRowR);
+ this._viewportMgr.headerRows.empty();
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);
-
- if (this.hasFrozenColumns()) {
- const footerRowRColumnElements = this._footerRowR.querySelectorAll('.slick-footerrow-column');
- footerRowRColumnElements.forEach((column) => {
+ // 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, {
@@ -1815,16 +1632,16 @@ export class SlickGrid = Column, O e
});
}
});
- Utils.emptyElement(this._footerRowR);
- }
+ 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.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.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) {
@@ -1842,7 +1659,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);
}
@@ -1881,7 +1698,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);
}
@@ -1898,7 +1715,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.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);
@@ -1914,7 +1731,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();
}
@@ -1933,8 +1750,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;
@@ -1947,7 +1764,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; }) => {
@@ -1955,13 +1772,13 @@ 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._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);
}
@@ -2002,8 +1819,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);
}
/**
@@ -2011,9 +1828,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[];
+ // only the header containers that were actually built contribute
+ // (a single left container under lazyPanes)
+ return this._viewportMgr.headers.cells();
}
/**
@@ -2042,7 +1859,6 @@ export class SlickGrid = Column, O e
}
let j: number;
- let k: number;
let c: C;
let pageX: number;
let minPageX: number;
@@ -2184,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.hasFrozenColumns() && (k > this._options.frozenColumn!)) {
- newCanvasWidthR += c.width || 0;
- } else {
- newCanvasWidthL += c.width || 0;
- }
- }
+ ({ l: newCanvasWidthL, r: newCanvasWidthR } = this._viewportMgr.accumulateBandWidths(vc, i));
if (this._options.forceFitColumns) {
x = -d;
@@ -2209,7 +2016,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;
@@ -2221,7 +2028,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;
@@ -2262,7 +2069,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
@@ -2276,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.hasFrozenColumns() && (k > this._options.frozenColumn!)) {
- newCanvasWidthR += c.width || 0;
- } else {
- newCanvasWidthL += c.width || 0;
- }
- }
+ ({ l: newCanvasWidthL, r: newCanvasWidthR } = this._viewportMgr.accumulateBandWidths(vc, i));
if (this._options.forceFitColumns) {
x = -d;
@@ -2302,7 +2100,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;
@@ -2314,7 +2112,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 {
@@ -2324,9 +2122,8 @@ export class SlickGrid = Column, O e
}
}
- if (this.hasFrozenColumns() && newCanvasWidthL !== this.canvasWidthL) {
- Utils.width(this._headerL, newCanvasWidthL + 1000);
- Utils.setStyleSize(this._paneHeaderR, 'left', newCanvasWidthL);
+ if (this._viewportMgr.hasFrozenColumns() && newCanvasWidthL !== this.canvasWidthL) {
+ this._viewportMgr.setLiveResizeLeftWidth(newCanvasWidthL);
}
this.applyColumnHeaderWidths();
@@ -2372,11 +2169,50 @@ 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)
: -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;
+
+ // 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
+ // (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;
this.frozenRowsHeight = (this._options.frozenRow!) * this._options.rowHeight!;
@@ -2387,6 +2223,29 @@ 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,
+ frozenRowCount: this._options.frozenRow!,
+ 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 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);
+ }
}
//////////////////////////////////////////////////////////////////////////////////////////////
@@ -3093,16 +2952,12 @@ export class SlickGrid = Column, O e
return;
}
- let columnIndex = 0;
const vc = this.getVisibleColumns();
- this._headers.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);
}
});
@@ -3118,21 +2973,33 @@ export class SlickGrid = Column, O e
let x = 0;
let w = 0;
let rule: any;
+ 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++) {
+ 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) {
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 = (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;
}
}
@@ -3159,18 +3026,7 @@ export class SlickGrid = Column, O e
* @returns
*/
getColumnByIndex(id: number) {
- let result: HTMLElement | undefined;
- this._headers.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);
}
/**
@@ -3185,21 +3041,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;
@@ -3253,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 {
@@ -3562,12 +3413,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.hasFrozenRows && isBottom) {
- rowOffset -= (this._options.frozenBottom)
- ? Utils.height(this._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);
@@ -4070,12 +3926,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) {
@@ -4541,14 +4397,15 @@ export class SlickGrid = Column, O e
let row = this.getRowFromNode(cellNode.parentNode as HTMLElement);
- if (this.hasFrozenRows) {
- let rowOffset = 0;
+ if (this._viewportMgr.hasFrozenRows()) {
const c = Utils.offset(Utils.parents(cellNode, '.grid-canvas')[0] as HTMLElement);
- const isBottom = Utils.parents(cellNode, '.grid-canvas-bottom').length;
-
- if (isBottom) {
- rowOffset = (this._options.frozenBottom) ? Utils.height(this._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;
}
@@ -4634,7 +4491,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 */
@@ -4644,7 +4501,7 @@ export class SlickGrid = Column, O e
/** Get all the Viewport node elements */
getViewports() {
- return this._viewport;
+ return this._viewportMgr.viewports.elements;
}
/**
@@ -4682,43 +4539,20 @@ export class SlickGrid = Column, O e
* Returns the computed overall header width in pixels.
*/
getHeadersWidth() {
- this.headersWidth = this.headersWidthL = this.headersWidthR = 0;
- const includeScrollbar = !this._options.autoHeight;
-
- 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 ((this._options.frozenColumn!) > -1 && (i > this._options.frozenColumn!)) {
- this.headersWidthR += width || 0;
- } else {
- this.headersWidthL += width || 0;
- }
- }
-
- if (includeScrollbar) {
- if ((this._options.frozenColumn!) > -1 && (i > this._options.frozenColumn!)) {
- this.headersWidthR += this.scrollbarDimensions?.width ?? 0;
- } else {
- this.headersWidthL += this.scrollbarDimensions?.width ?? 0;
- }
- }
-
- if (this.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
@@ -4728,33 +4562,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 = 0;
-
- while (i--) {
- if (!this.columns[i] || this.columns[i].hidden) { continue; }
-
- if (this.hasFrozenColumns() && (i > this._options.frozenColumn!)) {
- this.canvasWidthR += this.columns[i].width || 0;
- } else {
- this.canvasWidthL += this.columns[i].width || 0;
- }
- }
- let totalRowWidth = this.canvasWidthL + this.canvasWidthR;
- if (this._options.fullWidthRows) {
- const extraWidth = Math.max(totalRowWidth, availableWidth) - totalRowWidth;
- if (extraWidth > 0) {
- totalRowWidth += extraWidth;
- if (this.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;
}
/**
@@ -4852,94 +4669,40 @@ 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;
-
- if (widthChanged || this.hasFrozenColumns() || this.hasFrozenRows) {
- Utils.width(this._canvasTopL, this.canvasWidthL);
+ 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; 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();
-
- 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,
+ canvasWidthRF: this.canvasWidthRF,
+ headersWidthL: this.headersWidthL,
+ headersWidthR: this.headersWidthR,
+ headersWidthRF: this.headersWidthRF,
+ 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();
@@ -4948,17 +4711,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 */
@@ -4972,29 +4735,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();
}
/**
@@ -5004,25 +4745,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);
}
/**
@@ -5158,12 +4881,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;
}
/**
@@ -5204,7 +4927,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);
}
/**
@@ -5213,7 +4936,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);
}
/**
@@ -5222,7 +4945,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);
}
/**
@@ -5231,7 +4954,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);
}
/**
@@ -5240,7 +4963,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);
}
/**
@@ -5302,11 +5025,11 @@ 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[], row: number, range: CellViewportRange, dataLength: number, divArrayRF: HTMLElement[] = []) {
const d = this.getDataItem(row);
const dataLoading = row < dataLength && !d;
let rowCss = 'slick-row' +
- (this.hasFrozenRows && row <= this._options.frozenRow! ? ' frozen' : '') +
+ (this._viewportMgr.isRowFrozenClassed(row) ? ' frozen' : '') +
(dataLoading ? ' loading' : '') +
(row === this.activeRow && this._options.showCellSelection ? ' active' : '') +
(row % 2 === 1 ? ' odd' : ' even');
@@ -5332,14 +5055,15 @@ export class SlickGrid = Column, O e
} else {
rowDiv.style.top = `${topOffset}px`; // default to `top: {offset}px`
}
-
- let rowDivR: HTMLElement | undefined;
- divArrayL.push(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
- 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 (frags.rf) {
+ divArrayRF.push(frags.rf);
}
const columnCount = this.columns.length;
@@ -5389,11 +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) {
- const targetedRowDiv = (this.hasFrozenColumns() && (i > this._options.frozenColumn!) ? rowDivR! : 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.hasFrozenColumns() && i <= this._options.frozenColumn!)) {
- this.appendCellHtml(rowDiv, row, i, ncolspan, rowspan, columnData, d);
}
if (ncolspan > 1) {
@@ -5428,7 +5157,7 @@ export class SlickGrid = Column, O e
+ (rowspan > 1 ? ' rowspan' : '')
+ (columnMetadata?.cssClass ? ` ${columnMetadata.cssClass}` : '');
- if (this.hasFrozenColumns() && cell <= this._options.frozenColumn!) {
+ if (this._viewportMgr.isColumnInAnyFrozenBand(cell)) {
cellCss += ' frozen';
}
@@ -5535,11 +5264,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;
}
@@ -5778,16 +5503,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!
@@ -5797,8 +5522,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)
@@ -5838,108 +5563,33 @@ 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,
+ frozenBottomRowsHeight: (this._options.frozenBottomRow ?? 0) * this._options.rowHeight!,
+ 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();
@@ -5973,15 +5623,19 @@ 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!;
+ 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);
}
- 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);
@@ -6028,19 +5682,27 @@ export class SlickGrid = Column, O e
}
if (this.h !== oldH || this.enforceFrozenRowHeightRecalc) {
- if (this.hasFrozenRows && !this._options.frozenBottom) {
- Utils.height(this._canvasBottomL, this.h);
+ if (this._viewportMgr.hasFrozenRows() && !this._options.frozenBottom) {
+ Utils.height(this._viewportMgr.canvasBottomL, this.h);
- if (this.hasFrozenColumns()) {
- Utils.height(this._canvasBottomR, this.h);
+ if (this._viewportMgr.hasFrozenColumns()) {
+ 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);
- 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
}
@@ -6148,11 +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[];
- if (rowNode.length > 1) {
- children = children.concat(Array.from(rowNode[1].children) as HTMLElement[]);
- }
+ const children = this._viewportMgr.collectRowCellNodes(cacheEntry.rowNode as HTMLElement[]);
let i = children.length - 1;
while (cacheEntry.cellRenderQueue.length) {
@@ -6171,11 +5829,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;
}
@@ -6193,8 +5847,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 (i <= this._options.frozenColumn!) {
+ // Ignore frozen columns (left and right bands are always horizontally visible)
+ if (this._viewportMgr.isColumnInAnyFrozenBand(i)) {
return;
}
@@ -6334,11 +5988,7 @@ export class SlickGrid = Column, O e
if (!node) {
continue;
}
- if (this.hasFrozenColumns() && (columnIdx > this._options.frozenColumn!)) {
- cacheEntry.rowNode![1].appendChild(node);
- } else {
- cacheEntry.rowNode![0].appendChild(node);
- }
+ cacheEntry.rowNode![this._viewportMgr.rowNodeIdxForColumn(columnIdx)].appendChild(node);
cacheEntry.cellNodesByColumnIdx![columnIdx] = node;
}
}
@@ -6355,6 +6005,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();
@@ -6362,7 +6013,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++;
@@ -6380,7 +6031,7 @@ export class SlickGrid = Column, O e
}
}
- this.appendRowHtml(divArrayL, divArrayR, i, range, dataLength);
+ this.appendRowHtml(divArrayL, divArrayR, i, range, dataLength, divArrayRF);
mustRenderRows.add(i);
if (this.activeCellNode && this.activeRow === i) {
needToReselectCell = true;
@@ -6395,41 +6046,24 @@ 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, r, range, dataLength, divArrayRF);
});
}
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.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, xRF.firstChild as HTMLElement | null);
+ if (attached) {
+ this.rowsCache[rows[i]].rowNode = attached;
}
}
}
@@ -6449,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)`;
@@ -6480,7 +6116,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) {
@@ -6492,6 +6128,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);
}
@@ -6499,7 +6141,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
@@ -6511,6 +6153,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();
@@ -6530,32 +6179,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!,
+ });
}
////////////////////////////////////////////////////////
@@ -6573,10 +6202,10 @@ 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) {
+ 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));
}
@@ -6594,44 +6223,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() {
- 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;
- }
- }
- }
-
/**
* Scroll to a Y position in the grid (clamped to valid bounds)
*
@@ -6642,7 +6233,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._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
@@ -6661,16 +6252,24 @@ 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()) {
- this._viewportTopL.scrollTop = newScrollTop;
+ if (this._viewportMgr.hasFrozenColumns()) {
+ this._viewportMgr.viewportTopL.scrollTop = newScrollTop;
}
- if (this.hasFrozenRows) {
- this._viewportBottomL.scrollTop = this._viewportBottomR.scrollTop = newScrollTop;
+ if (this._viewportMgr.hasFrozenRows()) {
+ this._viewportMgr.viewportBottomL.scrollTop = this._viewportMgr.viewportBottomR.scrollTop = newScrollTop;
}
- if (this._viewportScrollContainerY) {
- this._viewportScrollContainerY.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._viewportMgr.scrollContainerY) {
+ this._viewportMgr.scrollContainerY.scrollTop = newScrollTop;
}
this.trigger(this.onViewportChanged, {});
@@ -6679,17 +6278,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;
}
}
@@ -6697,7 +6296,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);
}
/**
@@ -6716,8 +6315,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;
}
}
@@ -6731,9 +6330,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');
}
@@ -6752,8 +6351,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.
@@ -6787,16 +6386,10 @@ export class SlickGrid = Column, O e
this.prevScrollTop = this.scrollTop;
if (eventType === 'mousewheel') {
- this._viewportScrollContainerY.scrollTop = this.scrollTop;
+ this._viewportMgr.scrollContainerY.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) {
@@ -6851,7 +6444,9 @@ export class SlickGrid = Column, O e
scrollCellIntoView(row: number, cell: number, doPaging?: boolean) {
this.scrollRowIntoView(row, doPaging);
- if (cell <= this._options.frozenColumn!) {
+ // 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;
}
@@ -6868,14 +6463,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();
}
@@ -7062,15 +6657,14 @@ export class SlickGrid = Column, O e
* @param {Boolean} doPaging - scroll when pagination is enabled
*/
scrollRowIntoView(row: number, doPaging?: boolean) {
- if (!this.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._viewportScrollContainerY) as number;
+ const viewportScrollH = Utils.height(this._viewportMgr.scrollContainerY) as number;
- // if frozen row on top
- // subtract number of frozen row
- const rowNumber = (this.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!
@@ -7639,10 +7233,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)];
}
/**
@@ -7654,7 +7245,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 = {
@@ -7755,7 +7346,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;
@@ -7768,7 +7359,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') {
@@ -7806,34 +7397,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
- }
}
/**
@@ -8012,6 +7580,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 {
@@ -8689,7 +8267,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;
}