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; }