feat: 3x3 frozen band model - right-frozen columns + simultaneous top/bottom frozen rows (ViewportMgr Phase 4) - #1238
feat: 3x3 frozen band model - right-frozen columns + simultaneous top/bottom frozen rows (ViewportMgr Phase 4)#12386pac wants to merge 43 commits into
Conversation
Freeze activation options — ViewportMgr refactor (SlickGrid)Reference for the grid-option surface controlling frozen rows/columns after the Option surface
Activation model
Plugin-facing band API (for completeness)
|
|
Will investigate ways to reduce the line count tomorrow, but this PR is the start for manual testing. |
|
looking into that, but I think it's worth testing at this point to sanity check the overall usability. Claude is indicating that it kept a lot of architecture the same way just to maintain byte-identical HTML in thos PR, but we can lose about 600 further lines through a subsequent stage 2 refactor. I'm investigating adding the sticky rows feature as well - I'm interested to see if that would use a lot of the same code. At the moment I'm not sreally sure how to roll this out though - this PR applies to main and can be merged locally without merging in the main repo, for testing. A further PR could I suppose also be pulled and merged locally on top of the previous merge. Is that workflow OK for you? |
|
I think that considering this is probably going to be released under a major (aka breaking) version, then there's no point in carrying over old code if you can rewrite it in a better way that might change certain flags and behaviours (that's what major versions are for). Like I said a while ago, we can create a branch named The only PR that could be merged and released under current v5 is the variable row height, I would put more focus on finishing that one. Anything else (freeze/pinning rewrite, sticky columns, drop SortableJS, ...) should be done in a separate
I did test the UI quickly, that's how I saw that you did stop rendering the pane-right/pane-bottom/... when not displayed (which I agree with). But apart from the right column freeze (which seems a little off in the demo since left columns weren't covering all left space though it becomes ok when grid has lots of columns), that feature alone surely doesn't explain the addition of over 1400 loc |
…ruction (Phase 1) Moves the construction of the 6 panes, 4 viewports, 4 canvases, header/headerrow/ top-panel/footer-row/pre-header containers out of SlickGrid.initialize() into a new internal ViewportMgr class (same file — a separate file would break script-tag consumers since the iife build emits one file per source). The grid keeps aliases to every element, so all logic is unchanged and the DOM is byte-identical, as proven by the dom-shape-characterization spec. Full suite green: 600 tests (599 pass, 1 pending), matching the Phase 0 baseline exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty and width distribution into ViewportMgr (Phase 2, milestones 1-2) ViewportMgr now owns a freeze-state snapshot (pushed by setFrozenOptions) and: - paneCellIndex() — the (col,row)->pane index math from _getContainerElement - applyPaneFrozenClasses / applyPaneVisibility / applyOverflow - selectScrollContainers() — the setScroller X/Y-owner + follower selection - applyCanvasWidths() — the pane/viewport/canvas/header width distribution from updateCanvasWidth (width computations stay in the grid) The grid methods are now thin delegates. Behaviour identical: full Cypress suite green (600 tests, 599 pass / 1 pending), matching the Phase 0 baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n into ViewportMgr (Phase 2, milestone 3) ViewportMgr.applyPaneHeights() now computes paneTopH/paneBottomH/viewportTopH from the freeze configuration and sizes every pane/viewport/canvas, returning the heights for the grid's layout pipeline. The container VBox delta is passed as a lazy callback so the autoHeight-only style recalc is not made unconditional. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o ViewportMgr (Phase 2, milestones 4-5) - attachRow(): the 4-way canvas attachment from renderRows, returning the rowNode array. Preserves the historical render-side band threshold (rowIdx >= actualFrozenRow without the +1 non-frozenBottom adjustment used by paneCellIndex) verbatim. - syncHorizontalScroll(): the scrollToX follower fan-out (scroll owner, header, top panel, footer row, pre-header, frozen header-row/viewport followers); the grid-owned top-header panel stays in scrollToX. - syncVerticalFollowers(): the frozen-left viewport scrollTop mirroring from _handleScroll. - selectScrollContainers() now caches its result as the authoritative owner set. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…C-eligible Found by an adversarial transcription audit of the Phase 1-2 refactor: the manager retained references to every pane/viewport/canvas element and the container after destroy(), keeping the detached subtree alive if the app held onto the grid instance. Also restores the original post-destroy failure mode for the delegated methods. Full Cypress suite green (600 tests, 599 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o ViewportMgr (Phase 2, milestone 6) - frozenRowOffset(): the getFrozenRowOffset computation (grid method stays as a public delegate), historical commented-out one-liner preserved. - isRowInFrozenBand(): the cleanupRows keep-frozen-rows predicate. - isRowCellCleanupExempt(): the cleanUpCells skip predicate, transcribed verbatim including the long-standing quirk that the second disjunct is not guarded by !frozenBottom (documented at the definition). - isColumnInFrozenBand(): the frozen-column cleanup exemption. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ase 2, milestone 7) New ViewportMgr helpers absorb the recurring 'which side of the freeze' patterns: - isColumnRightOfFreeze() — the hasFrozenColumns() && idx > frozenColumn test - sideLocalColumnIdx() — the right-side child-index rebase (idx - frozenColumn - 1) - sideForColumn() — pick the L or R element of a pair for a column Rewritten call sites: getHeader, getHeaderColumn, getHeaderRow, getFooterRow, getHeaderRowColumn, getFooterRowColumn, getHeadersWidth, getCanvasWidth, appendRowHtml (rowDivR clone guard + cell routing + always-render frozen band), appendCellHtml (frozen cell class), cleanUpAndRenderCells (node reattachment). The compound 'hasFrozenColumns() && idx <= frozenColumn' collapses to isColumnInFrozenBand() alone — equivalent for non-negative indices since the unfrozen sentinel is -1. Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes through ViewportMgr (Phase 2, milestone 8) Adds hasFrozenRows() accessor and bodyCanvasL() (the scrollable-body left canvas selector shared by updateRowCount and bindAncestorScrollEvents) to ViewportMgr, and rewrites the remaining ~34 grid-side sites onto the manager's accessors and column- side helpers: setupColumnResize, setupColumnReorder, createColumnHeaders, createColumnFooter, updateRowCount, scrollTo, scrollRowIntoView, render, renderRows, getCellFromEvent, setActiveCellInternal, navigateToPos, bindAncestorScrollEvents, appendRowHtml, updateCanvasWidth. Grid-side frozen-flag branching is now zero outside setFrozenOptions (the state owner): 95 sites at baseline, 3 writes remaining. frozenBottom/frozenRow option reads intentionally stay grid-side where they carry option semantics rather than band routing. Full Cypress suite green (600 tests, 599 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(Phase 3, milestone 9) Introduces the opt-in lazyPanes grid option (no behaviour yet) and makes every ViewportMgr method plus the four remaining unconditional grid-side right-element writes (initialize spacer width, createColumnHeaders/headerRow emptying, updateRowCount canvasTopR height) tolerate absent panes. All guards are inert while every pane exists: full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…zen (Phase 3, milestone 10) With lazyPanes: true and no frozen rows/columns at init, buildPanes/buildFooterRows now create just 2 panes / 1 viewport / 1 canvas plus left-side chrome, using inline conditionals so element order stays canonical in both modes. getHeaderChildren now iterates the _headers array instead of assuming two containers (the one crash the existence-guard pass had not covered). Adds examples/example-lazy-panes.html and a 7-test characterization spec proving the single-pane DOM shape and basic function (render, navigation, scrolling). Default-mode DOM unchanged: full suite green (607 tests, 606 pass / 1 pending — 600 baseline + 7 new minus overlap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Panes grid (Phase 3, milestone 11) - ViewportMgr.materializeSecondaryPanes(): builds the right/bottom panes, chrome, viewports and canvases at their canonical sibling positions and pushes them into the shared element arrays in place; idempotent. - SlickGrid.materializeLazyPanes(): hooked into setFrozenOptions (so it runs before setScroller/setColumns in the internal_setOptions pipeline); re-aliases fields, wires events for the new elements only, binds sort clicks on the new right header, and re-anchors ancestor-scroll bindings. - Event wiring extracted from finishInitialization into bindPaneEvents() shared by init and materialization — this also closes the audited gap where late-created viewports would miss MouseWheel handling. - Alias block extracted to syncViewportMgrAliases(); setupColumnSort parameterized. - Un-freezing keeps materialized panes (hidden), matching historical behaviour. - Tests: viewportmgr-lazy-materialization.cy.ts (6 tests: freeze columns from lazy, header split + row routing, unfreeze, refreeze with rows, fresh-page frozen-rows- first), deliberately sorted after the example-* specs and self-contained. Verification: 3 consecutive full-suite runs — green, green except one failure in example-excel-compatible-spreadsheet's native-clipboard paste test, green. That test is probabilistically flaky under full-suite load on this Windows machine (passes in isolation and in 2/3 full runs; also failed historically only in full runs); Linux CI remains the arbiter. All 13 lazy/materialization tests passed in all three runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stone 12) ViewportFreezeState gains frozenRowCount (the frozenRow option value) and updateFreezeState derives a FreezeBandCounts view — frozenLeftCols / frozenRightCols (0 until right-frozen columns land) / frozenTopRows / frozenBottomRows — alongside the authoritative legacy snapshot. hasFrozenColumns() is the first predicate re-expressed in band terms (frozenLeftCols > 0, provably equivalent to frozenColumnIdx > -1). Pure refactor, no behaviour change. Full suite: one ambient flake (example-plugin-headerbuttons, passes 9/9 in isolation — third distinct victim of the machine-level ~1-in-4 full-run flake, after excel-clipboard on the phase branch and row-span on pristine master), then a fully green re-run (613 tests, 612 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oad flakes
Local full-suite runs showed an ambient ~1-in-4 flake in timing-sensitive tests
(native clipboard paste, render waits) reproduced on pristine master with three
different victim specs across runs. runMode-only retry absorbs these while
Cypress still flags retried tests as flaky; openMode stays at 0 so interactive
debugging sees raw failures. Matches the { retries: 1 } several specs already
carry individually.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… inert) New grid option frozenRightColumn: a COUNT of columns to freeze at the right edge (not an index like frozenColumn — counts stay correct under column reorder/hide). setFrozenOptions normalizes it (non-negative integer; the left and right bands must leave at least one scrollable column between them) and pushes it into ViewportMgr's band-count state as frozenRightCols. Nothing consumes the band yet — the right- frozen DOM, routing and geometry arrive in the following M13 stages — so behaviour is unchanged: full Cypress suite green, matching baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 13b) ViewportMgr.materializeRightFrozenBand() builds three new panes with NEW *-right-frozen css classes (appended after the classic six so classic sibling positions are untouched — the historical 'right' elements keep their names and become the scrollable middle band), plus header/header-row/top-panel chrome, viewports, canvases and footer row; shared element arrays extend at the END so classic indexes 0-3 stay valid. Wired for both init-time (setFrozenOptions runs before the event-binding loops) and runtime enabling, with events bound to the new elements only; applyPaneVisibility shows/hides the band; un-freezing keeps it hidden in the DOM, matching classic pane behaviour. Staged state, pinned by viewportmgr-right-frozen-band.cy.ts (8 tests + new example page): the band exists and is visible, but cells still render in the classic canvases until geometry (M13c) and routing (M13d) land. Full suite green: 623 tests (622 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…owing (Phase 4, milestone 13c) - Three-way width split: getCanvasWidth/getHeadersWidth accumulate canvasWidthRF/ headersWidthRF for the last frozenRightColumn VISIBLE columns (boundary via getFrozenRightStartIdx; identical behaviour when the band is off). - applyCanvasWidths pins the RF panes at the right edge and shrinks the scrollable middle band by the band width — in both the left-frozen layout and the no-left-freeze layout (where the historical '100%' widths become pixel widths while the band is active). - applyPaneHeights mirrors the classic right-pane vertical geometry for the band, including frozen-row canvas heights. - RF viewports keep both overflows hidden (never own a scrollbar) and follow Y via syncVerticalFollowers, scrollTo and updateRowCount. Scroll-owner selection needed NO change: the middle band was already the owner under the historical naming. Cells still render in the classic canvases until M13d routing. Spec grows a geometry test (band sized, right-pinned, middle+RF fill the container). Full suite green (622 tests, 621 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (Phase 4, milestone 13d) Right-frozen columns now work end-to-end: - createColumnHeaders/createColumnFooter route the band's columns via the new three-way bandElementForColumn (headerRF/headerRowRF/footerRowRF included in the destroy/empty/width passes). - appendRowHtml builds a third row fragment; renderRows collects divArrayRF and attachRow appends it to the RF canvases, growing rowsCache[].rowNode to 3 entries (RF fragment always last; rowNodeIdxForColumn encapsulates the index math). - applyColumnWidths and getCellNodeBox rebase RF columns to band-local coordinates; paneCellIndex maps RF cells to array slots 4/5 (materializeRightFrozenPanes canonicalizes the classic set first so those slots hold under lazyPanes). - cleanUpCells exempts the band (always horizontally visible); RF cells get the 'frozen' css class; ensureCellNodesInRowsCache generalizes to N fragments; the public getHeader/getHeaderColumn/getHeaderRowColumn/getFooterRowColumn getters resolve three-way with band-local child indexes. Spec asserts real routing: header split, RF row fragments with band-local x=0, runtime toggle restoring classic routing both directions. Verification note: the first full-suite run after this change failed 13 rowspan tests in example-0032 during an overnight machine-load window (3:41 vs the usual ~2:45); the spec passes 45/45 in isolation, with its preceding spec subset, and the full-suite re-run is completely green (623 tests, 622 pass / 1 pending) — consistent with the documented ambient flake on this machine, which also struck this same spec on pristine master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…audit (Phase 4, M13 hardening) A three-way adversarial audit proved the M13c/M13d changes behaviourally inert with frozenRightColumn=0 (sealing the example-0032 incident as the documented ambient machine flake) and surfaced four RF-on transition weaknesses, fixed here: - appendRowHtml: cell routing now requires the RF fragment to exist (guard was band-count-based while the fragment clone was DOM-based), with a same-fragment fallback in the three-way pick — no undefined targets during materialization transitions. - attachRow: the RF append guard now checks the actual target canvas (bottom-band rows previously checked canvasTopRF but appended to canvasBottomRF). - appendRowHtml: divArrayRF moved to a trailing optional parameter so downstream subclass overrides of the protected method keep compiling and binding correctly. - updateCanvasWidth: the getHeadersWidth() recompute guard gains the RF term, keeping it symmetric with applyCanvasWidths' distribution guard (stale headersWidthRF was otherwise possible with RF on and nothing else frozen). Full suite verified green BEFORE this commit: 623 tests, 622 pass / 1 pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…IntoView (Phase 4, milestone 13e) Right-frozen cells are always horizontally visible, so scrollCellIntoView now returns early for them — mirroring the historical left-frozen guard. New spec test drives arrow-key navigation across the middle↔right-frozen boundary in both directions, which also exercises the RF canvas keydown wiring and pane-index cell lookup end-to-end, and asserts the middle viewport does not scroll on band entry. Full suite verified green BEFORE this commit: 624 tests, 623 pass / 1 pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nert) New grid option frozenBottomRow: a COUNT of rows to freeze at the bottom, usable TOGETHER with frozenRow (which then always means top rows; the legacy frozenBottom flag is ignored when the count is set — it only positions the single-band case). setFrozenOptions normalizes it (non-negative integer; top + bottom bands must leave at least one scrollable body row) and pushes it into ViewportMgr's band derivation, which now expresses all three row-band configurations while every legacy configuration derives to exactly the same values as before. Nothing consumes the simultaneous-bands state yet — the bottom-frozen band DOM, geometry and routing arrive in M14b-d. Verification: first full run failed only the documented clipboard flake (passes 8/8 isolated); full re-run verified green BEFORE this commit (624 tests, 623 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uble-binding (Phase 4, milestone 14b) - materializeBottomFrozenBand() builds one pane+viewport+canvas per active column band with *-bottom-frozen classes, appended after all existing panes; the shared bottom-frozen × right-frozen corner pane is added by WHICHEVER band materializes second (ensureBottomFrozenRightVariant called from both), with events bound on either path. applyPaneVisibility shows the band only in simultaneous top+bottom mode, mirroring classic bottom-pane column visibility. - Slot registry: dynamically materialized viewports/canvases record their array positions at push time (rfTopSlot/rfBottomSlot/bfSlot*), replacing paneCellIndex's hardcoded RF slots 4/5 — band materialization order can no longer skew lookups. - Fixes a latent M13b bug: finishInitialization sets initialized=true BEFORE its array-wide bindPaneEvents pass, so bands materialized during setFrozenOptions in that window were bound twice (once by the materializer, once by the array pass) — affected right-frozen-at-init grids. Materializers now gate on a _paneEventsBound flag set after the array-wide pass. Staged state pinned by viewportmgr-bottom-frozen-band.cy.ts (6 tests + example page): the band exists (init-time and runtime) but classic top-frozen rendering is unchanged until M14c geometry and M14d routing. Full suite verified green BEFORE this commit: 630 tests, 629 pass / 1 pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lowing (Phase 4, milestone 14c) In simultaneous top+bottom mode the scrollable body pane shrinks by the band height inside the height computation itself (single write, no post-adjustment); the band pins directly below the body with per-column-band widths mirroring the classic bottom panes across all three column configurations (left-frozen / right-frozen / plain), including the shared BF×RF corner pane. Band viewports are scrollbar-less on both axes; the band's scrollable-column viewport joins the X-followers in syncHorizontalScroll, mirroring how frozen-top viewports follow horizontal scrolling. The body pane remains the X/Y scroll owner (v1 layout: the horizontal scrollbar sits at the body's bottom edge, above the band). Rows still render in the classic canvases until M14d routing; the spec's new geometry test pins band height = frozenBottomRow * rowHeight and its position directly below the body pane. Full suite verified green BEFORE this commit: 631 tests, 630 pass / 1 pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m frozen rows work end-to-end (Phase 4, milestone 14d) The 3x3 band model is complete: with frozenRow + frozenBottomRow set, the last frozenBottomRow rows render in the band's canvases across all column bands. - isRowInBottomFrozenBand: BOUNDED membership (splitRow <= row < splitRow + count) so the add-new row can never be captured; the same test serves both render and lookup sides — the new band deliberately has no threshold asymmetry (the historical one-row asymmetry remains only in the legacy single-band logic). - bottomFrozenSplitRow = dataLength - frozenBottomRow, computed in setFrozenOptions (staleness semantics deliberately match actualFrozenRow's). - attachRow routes three row bands x three column bands (incl. the BF x RF corner fragment); paneCellIndex resolves BF cells via the slot registry. - render() gains a second frozen pass + a band range in the horizontal-scroll cell pass; updateRowCount excludes the band from the scrollable body count; frozenRowOffset rebases band rows to band-local coordinates; cleanupRows/ cleanUpCells exempt the band; band rows get the frozen css class; scrollRowIntoView never scrolls for band rows; getCellFromEvent resolves clicks in the band canvas (the -bottom-frozen class token cannot collide with the classic -bottom selector). Spec now asserts real routing: Task 498/499 in the band with band-local y=0, frozen class, click-activation without body scroll, runtime toggle restoring classic routing. Full suite verified green BEFORE this commit: 632 tests, 631 pass / 1 pending. BF-off equivalence audit running as the closing check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two-agent adversarial audit of the M14b-d delta vs the M13e tip; the render-routing area proved fully inert when simultaneous mode is off, and one real regression plus two tightenings were found and fixed: - disableSelection ordering (REAL): the _paneEventsBound guard swap had silently dropped disableSelection() for headers materialized during the init window (right-frozen-at-init and lazy+right-frozen grids) — it was never part of the double-binding the flag fixed, and the array-wide disableSelection(this._headers) ran BEFORE setFrozenOptions created those headers. finishInitialization now applies it AFTER setFrozenOptions so init-materialized headers are covered via the shared array, with no double-application. - setFrozenOptions consults getDataLength() only when frozenBottomRow is actually in use, restoring BASE's call cadence for legacy grids (matters for side-effectful CustomDataView.getLength implementations and the null-data pre-init edge). - getCellFromEvent short-circuits the '.grid-canvas-bottom-frozen' ancestor walk behind hasBottomFrozenBand() (perf; the class token could never false-match). Full suite verified green BEFORE this commit: 632 tests, 631 pass / 1 pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lestone 15) New public band facade on the grid — getFrozenBandCounts() (copy of the band-count view) and getFrozenRightStartIndex() (safe membership boundary) — and the cellrangeselector plugin now drives its band logic from it instead of raw frozen options. Beyond the migration this fixes two real gaps: - the plugin's cross-canvas measurement now respects the 'frozenBottom is inert when frozenBottomRow is set' rule (it previously read the raw flag and would measure the wrong canvas in simultaneous mode); - drags starting in right-frozen or bottom-frozen canvases get correct pixel offsets, and range extension is clamped at the right-freeze and bottom-freeze boundaries (previously zero offsets and no clamping — those bands postdate the plugin). Preserved exactly: the degenerate frozenRow: 0 clamp semantics (documented raw activity flag) and the original clamp's operator-precedence shape. Full suite verified green BEFORE this commit — twice (interrupted run completed green + clean re-run): 632 tests, 631 pass / 1 pending each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hase 4, milestone 16) internal_setOptions now carries the same cacheCssForHiddenInit/ restoreCssFromHiddenInit wrap that initialize() and autosizeColumns have always used — a no-op for visible grids, and correct layout measurements when options change (including band materialization) while the container or an ancestor is display:none. Closes the hidden-init item deferred from Phase 3. The other deferred item — pane removal on un-freeze — is formally closed as WON'T-DO: removal would violate the element-identity invariant the adversarial audits established as load-bearing (plugins cache canvases at init; rowsCache row nodes live inside band canvases), would require slot re-indexing and group-scoped unbinding, and offers no functional gain over the historical hidden-pane behaviour. Recorded in KNOWN-QUIRKS.md as final design decision #9. Full suite verified green BEFORE this commit: 632 tests, 631 pass / 1 pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ls (Phase 4, milestone 17)
Moves the ViewportMgr class (~1,500 lines) out of slick.grid.ts into slick.core.ts
— the one file every script-tag page already loads before the grid — using the
established cross-file pattern: exported from core, registered on the SlickCore
namespace object (alongside DragExtendHandle, the live precedent for a grid-support
class in core; TreeColumns is the historical one), consumed by the grid via
'import { ViewportMgr as ViewportMgr_ }' + 'IIFE_ONLY ? Slick.ViewportMgr :
ViewportMgr_'. The five geometry/state interfaces move to
src/models/viewportMgr.interface.ts per house convention (type-only, excluded from
iife builds), re-exported from models/index.ts; the Slick global declaration in
global.d.ts gains the member.
Zero consumer impact: no new script tag (a standalone slick.viewportmgr.js would
have broken script-tag pages — the Phase 1 rationale, now resolved by core
placement), DOM byte-identical, esm/cjs pick the export up via index.ts.
ViewportMgr is now independently importable (e.g. for future unit tests).
Verified: iife smoke test (Slick.ViewportMgr resolves and instantiates like
DragExtendHandle), gates across script-tag and esm pages, and full suite green
BEFORE this commit: 632 tests, 631 pass / 1 pending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…18a) Implements the dual-labelling scheme (BAND-LABELLING.md): the historical positional css classes stay untouched as a fixed legacy skin, and every pane/viewport/canvas that currently participates in the layout carries data-colband (left | main | right-frozen) and data-rowband (header | top-frozen | body | bottom-frozen) attributes stating its CURRENT role. Inactive elements carry no markers, so [data-colband=main] etc. uniquely select live elements with no mode logic. Markers refresh on every freeze application (applyBandMarkers, called from applyPaneVisibility — the same trigger as the dynamic frozen class), covering init-time and runtime changes. This classifier becomes the single source of truth for the M18 pane-matrix loops, so the legacy-name mapping cannot drift from the internal band model. Six new spec assertions pin the markers across non-frozen, frozen-both, right-frozen and simultaneous configurations. Full suite verified green BEFORE this commit: 636 tests, 635 pass / 1 pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All four creation paths (classic build, lazy secondary materialization, the right-frozen band, the bottom-frozen band and its shared corner) now flow through one buildPaneSet primitive driven by the structural (row x column) pane matrix and the legacy-class tables; shared element arrays rebuild IN PLACE in canonical order via syncElementArrays (strengthening the shared-array identity invariant — the array objects now never change), which also derives the dynamic-band slot registry. The 60 named element fields become one-line compat getters over the matrix with identical runtime semantics (undefined until built), so every consumer — grid aliases, geometry, visibility, markers — compiled unchanged. Preserved verbatim: pane sibling order, per-pane child order, the left pre-header anonymous leading div, footer R-before-L creation and its init-vs-materialization spacer-width difference, hide-on-create option handling, and lazy/materialized insert positions. Net -133 lines (+277/-410). DOM byte-identical: all five DOM-shape suites passed first-run against the matrix build; full suite verified green BEFORE this commit (636 tests, 635 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three-branch width distribution (left-frozen / right-frozen-only / plain, plus the bottom-frozen and right-frozen blocks) collapses to one loop over per-column geometry records. Historical value rules preserved verbatim and now explicit: header element widths write whenever the element exists (a plain grid writes headerR = 0 — historical); the main band chrome width is the FULL row width when no left freeze is active; the classic bottom row width-sizes its left pane only under a left freeze and gives its middle pane left-but-no- width, while the bottom-frozen band sizes both everywhere; spacers remain the classic left/right pair only, never RF. Net -64 lines (+78/-142). Geometry gate 77/77 first-run; full suite verified green BEFORE this commit (636 tests, 635 pass / 1 pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and rule (M18d) - applyPaneHeights: two-phase loop over the pane matrix. Phase 1 places paneTopL (offset WITH the historical fallback), reads the shared bottom offset from paneTopL.offsetTop mid-sequence, then places the secondary columns (offset recomputed WITHOUT the fallback - historical asymmetry preserved). Bottom row keeps the plain-layout quirk of width:100% on the left pane; frozen-band canvas heights and the bottom-frozen band loop over the same column list. - applyBandMarkers deleted: visibility and data-colband/data-rowband markers now derive from one (row, col) activity rule inside applyPaneVisibility. The historically never-toggled left header/top panes remain exempt from show/hide. - syncVerticalFollowers: Y-follower bands loop via followerViewport(col). No behavior change: tsc clean, eslint clean, gate suites 72/72, full suite 636 tests / 0 failing. Net -103 lines (M18 running total -300). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three mode-dependent document.querySelector dances in handleDragInit now
use the band-truth markers (BAND-LABELLING.md):
- frozen classic-band height measurement: grid-canvas-{bottom|top} ternary ->
[data-rowband=bottom-frozen|top-frozen] (degenerate frozenRow: 0 yields a
null match and the offset stays 0, same as measuring the 0-height canvas)
- left-band width: .grid-canvas-left -> [data-colband=left]
- right-frozen offset: the positional left + conditional right pair collapses
to a mode-independent left + main sum (left contributes 0 when absent)
The four pane-IDENTITY flags stay positional-class based, with a comment
explaining why: markers state band role, which conflates the legacy
frozenBottom classic canvas with a bf-band canvas and both degenerate
frozenRow: 0 canvases - the offset/clamp math needs pane identity.
No behavior change: tsc/eslint clean, drag+RF/BF gate 87/87, full suite
636 tests / 0 failing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six-dimension adversarial equivalence audit of the M18 series (creation paths, widths, heights/visibility/markers, compat getters + shared arrays, cellrangeselector, cross-cutting invariants) against pre-M18 (484988d^). Everything verified equivalent except: - cellrangeselector: add a positional fallback to the frozen-band marker query. Restores byte-equivalence in the degenerate frozenRow: 0 variants (incl. the frozenBottom body-height quirk) and covers stale-marker suppressColumnSet transition windows. - materializeRightFrozenBand: drop the ensureBottomFrozenRightVariant call from the already-exists early return (restores exact pre-M18 shape). The state it guarded is unreachable, and if it ever fired the corner would be created without pane events (the grid caller binds nothing on that path). Accepted deltas documented as KNOWN-QUIRKS #10-13: canonical shared-array band order, impossible-state TypeErrors -> silent skips, read-only compat getters, markers-stale-when-visibility-stale. Gates: tsc/eslint clean, shape+band+drag gate 74/74, full suite 636 / 0 failing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
71d79e1 to
6250f44
Compare
First slice of the ViewportMgr facade (FACADE-FEASIBILITY.md). slick.core.ts
gains BandSet (per-role l/r/rf collections: at/pick/forEach/empty/width/
setStyle/query, iterating only materialized bands) and CellSet (pane matrix
cells), 16 cached collection getters wrapping the SAME live shared arrays
(identity contract intact), and live scroll-container getters that recompute
via selectScrollContainers per access - the stale-owner failure mode is gone
rather than mitigated. Four raw arrays renamed *Arr to free the getter names.
slick.grid.ts deletions, compiler-proven complete: ~55 alias fields (incl.
three dead _groupHeaders* fields), the 61-line syncViewportMgrAliases, the
per-alias destroy nulling block, setScroller. 170 alias reads re-pointed at
the facade. Semantic conversions in this slice (broadcast-write / geometry /
public-api categories): createColumnHeaders reads like the pre-frozen
original (headers.empty() + headers.width({l,r,rf})); footer resets keep the
historical asymmetries explicit (pick(l,r); R gated on hasFrozenColumns
inline, per-band event/empty interleave preserved); sort-indicator clearing
via headers.query(); panel toggles feed collection elements; measurement
[0] reads become named first() calls.
Preserved contracts: getFooterRow still throws without createFooterRow;
Sortable.create(headerR) undefined-tolerance; getCanvases()/getViewports()
return the same live array objects. Behavior notes: post-destroy alias reads
were null, now dereference the nulled ViewportMgr (unreachable - handlers
unbind first); getHeadersWidth() called once instead of once per band in the
init broadcast (idempotent recompute).
tsc/eslint clean; gate 98/98; full suite 654 / 0 failing. Net -105 lines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…of M19b Pins, at exact boundary indices across three freeze configurations: - cross-band getHeaderColumn/getHeaderRowColumn/getColumnByIndex routing at the left-freeze and right-frozen boundaries - the INCLUSIVE <= frozenColumn / <= frozenRow comparisons, incl. the historical off-by-one where the row equal to frozenRow carries the frozen class but renders in the scrollable canvas (classic and simultaneous modes) - the frozen-class split: header cells left-band-only, data cells left OR right band - scrollCellIntoView early-outs for both frozen sides (boundary inclusive) All 14 tests pass against current code before any routing logic moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BandSet gains the cross-band column-cell conventions: cells()/cellAt()/ forEachCell() thread the continuous visible-column index across containers in band order; containerForColumn() is the historical three-way bandElementForColumn pick with elements supplied internally; columnCell() adds the band-local child index (deliberately not optional-chained - the historical code throws on a missing container). ViewportMgr gains per-semantic predicates (doctrine: one predicate per HISTORICAL semantic, transcribed from its call sites, never unified): - isColumnInAnyFrozenBand: the appendCellHtml cell class + cleanUpCells exemption pair (header cells keep left-band-only isColumnInFrozenBand) - isColumnAlwaysHorizontallyVisible: scrollCellIntoView early-outs with the INCLUSIVE <= frozenColumn boundary - isRowFrozenClassed: appendRowHtml row css incl. the inclusive-boundary off-by-one (named for the css semantic, not band membership) slick.grid.ts conversions: getHeaderColumn/getHeaderRowColumn/ getFooterRowColumn collapse to columnCell one-liners (the owner's goal example); getColumnByIndex -> cellAt; getHeaderChildren -> cells(); 5 creation-loop band picks -> containerForColumn; applyColumnHeaderWidths walk -> forEachCell; 4 predicate call sites. The scrollCellIntoView left test moves from a live options.frozenColumn read to the freeze snapshot, joining its RF twin (per the documented snapshot-timing invariant). Guarded by viewportmgr-band-routing.cy.ts (committed first, 14 boundary pins). Deferred to M19d: columnBandGeometry/applyColumnWidths - those sites deliberately re-derive rfStartIdx fresh; the snapshot question lands with the width-math golden tests. tsc/eslint clean; gate 115/115; full suite 668 / 0 failing (one ambient grid-menu flake ruled out by solo pass + clean re-run). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- canvasNodeRowOffset(node, geometry, { bfAware }): the vertical data offset
of the row-band canvas containing a node. bfAware: true reproduces
getCellFromEvent (bottom-frozen band rebases to its first row);
bfAware: false reproduces setActiveCellInternal, whose historical
bf-blindness (bf canvases carry no grid-canvas-bottom class token) becomes
an explicit flag instead of an accident of class names. The classic bottom
offset keeps its asymmetric source: frozenBottom measures the LIVE
top-left canvas height, top-freeze uses the caller's cached
frozenRowsHeight. Callers keep their own hasFrozenRows() gating - each
gates more than the offset.
- shouldScrollRowIntoView: scrollRowIntoView's guard pair (bf rows never
scroll; the exact actualFrozenRow - 1 boundaries) as one predicate.
- scrollableRowIndex: the frozen-top row-index rebase.
Snapshot notes: actualFrozenRow/frozenBottom/frozenRow reads move from grid
fields and live options to the freeze snapshot - identical values through
every supported path (all are set only by setFrozenOptions), per the
documented snapshot-timing invariant.
tsc/eslint clean; gate 85/85; full suite 668 / 0 failing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Materializers now return PaneElementSets manifests of exactly the elements they CREATED (null when nothing new), replacing boolean returns plus grid-side hand-listed element arrays: - the BFxRF corner obeys an exactly-once rule by construction: only the materializer whose ensureBottomFrozenRightVariant call CREATED the corner reports it (fresh RF path, fresh BF path, or BF idempotent-recall as a corner-only manifest) - the binding service does not dedupe, so this kills the double-bind hazard the hadCorner dances guarded against - ensureBandsMaterialized(o) is the one runtime entry: classic -> RF -> BF in the load-bearing order (classic canonicalization forced by either band exactly as the historical wrapper-nesting did), manifests merged, with bodyCanvasChanged flagging classic materialization only - allPaneElements() feeds the init-time array-wide bind pass Grid: the three materialize wrappers (~95 lines incl. both hadCorner dances) collapse into bindMaterialized(added), preserving the historical per-wrapper wiring order (disableSelection -> bindPaneEvents -> pre-header binds -> setupColumnSort -> ancestor re-anchor on bodyCanvasChanged only); setFrozenOptions makes one ensureBandsMaterialized call instead of three gated wrapper calls; bindPaneEvents takes the shared PaneElementSets type. Note: merged manifests mean ONE bind pass per setFrozenOptions instead of one per band wrapper - per-element handlers are order-independent and the classic-then-RF-then-BF element order inside the merge preserves the historical sequence. tsc/eslint clean; gate 81/81 (lazy-materialization suite leading); full suite 668 / 0 failing. Net +33 (core manifest machinery; grid -95). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Formula-transcribed pins computed from live column data (no hardcoded px): the +1000 left/single-band header slack (exact), the right-frozen band as a PLAIN column sum with no slack or scrollbar (exact, header and canvas), the cumulative headersWidthR floor under a left freeze, plain per-band canvas sums, and the plain-grid quirk that the hidden R header container still gets width: 0px written. All 11 tests pass against current code before the computeHeaderWidths/computeCanvasWidths relocation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
computeHeaderWidths/computeCanvasWidths move VERBATIM from getHeadersWidth/ getCanvasWidth (reverse iteration and all), guarded by the golden spec committed first (viewportmgr-width-golden.cy.ts, 11 formula pins). Quirks preserved in place: the +1000 left/single-band slack, CUMULATIVE r (includes the post-slack l) under a left freeze, the RF band as a plain sum, the out-of-range isColumnRightOfFreeze(columns.length) scrollbar probe after the loop, and fullWidthRows extra width to the scrollable band. Two deliberate non-moves: rfStartIdx stays a GRID-derived fresh input (these call sites historically re-derive it per call; the freeze snapshot is not substituted), and the grid keeps headersWidthL/R/RF + headersWidth + canvasWidthL/R/RF as synced mirrors - they are protected fields visible to subclass wrappers (slickgrid-universal); deleting them is a semver-major follow-up, not part of this work. tsc/eslint clean; gate 77/77 (golden spec leading); full suite 679 / 0 failing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion) - columnBandGeometry(colIdx, geometry): applyColumnWidths' three-way band width pick plus BOTH historical x-reset conventions as named booleans - the RF band resets BEFORE its first column, the left freeze resets AFTER the frozen column (which itself does not accumulate). frozenColumnIdx and rfStartIdx stay grid-fresh inputs (live options read + per-call derivation preserved). - accumulateBandWidths: setupColumnResize's two CLEAN bucketing passes collapse to one call. Honest scope note: the report claimed six repeats; the other four interleave bucketing with forceFit width mutation and are a different shape - they stay inline. - setLiveResizeLeftWidth: the drag-time header slack (+1000) and middle header pane re-anchor move behind the facade. - updateColumnCaches keeps its left-only reset INLINE, now documented: RF columns deliberately continue its coordinate space (preserved asymmetry vs the band oracle). tsc/eslint clean; gate 81/81; full suite 679 / 0 failing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- createRowFragments(rowDiv): clone-per-active-band moves into the vm (the
clone-not-share requirement documented at the source); appendRowHtml keeps
the divArray push plumbing verbatim - the holding-div drain in renderRows
depends on its exact pattern, so the alignment-hazard zone is untouched.
- fragmentForColumn(frags, i, { alwaysRenderColumn, branch }): the cell
routing rules keyed by the caller's EXACT control branch. The viewport
branch keeps the RF->L fallback (transition safety); the offViewport
branch keeps alwaysRender/left-frozen->l and right-frozen->rf. The two
rule sets are deliberately NOT unified into one inViewport predicate:
outer-viewport-test-true with isRenderCell false must render NOTHING,
including frozen cells (historical).
- collectRowCellNodes: the fragment-flatten with its ascending-column-order
invariant (cellRenderQueue tail-draining depends on it) named and moved.
- updateRowPositions keeps its [0]-only reposition INLINE, now documented
as inherited upstream behavior.
Considered and skipped (no facade gain): appendCellToRow (the band routing
is already the vm's rowNodeIdxForColumn), setRowTop as a member, and any
restructure of the renderRows holding-div drain.
tsc/eslint clean; gate 180/180 (rowspan/colspan suites leading); full
suite 679 / 0 failing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sion) The facade plan (FACADE-FEASIBILITY.md) scheduled these for terminal deletion as compat scaffolding. The M19f census says otherwise: ~57 legitimate references remain at grid chrome/geometry sites where vm.paneHeaderL reads better than a paneAt()/at() chain with a non-null assertion, and the vm's own geometry appliers and materializer manifests use them throughout. Deleting them would trade readable code for ~66 lines - the same no-facade-gain test that trimmed other report members (appendCellToRow, setRowTop, isBandBoundaryAfter) applies. The getter block's comment now states the retention decision and their role. M19 series complete: a (collections + alias deletion), b (column/row routing + predicates, spec-guarded), c (materialization manifests), d (width arithmetic relocation, golden-guarded), e (render-path minimal cut), f (this decision). slick.grid.ts net -422 vs master with the full 3x3 band feature set included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This PR has undergone three stages: 1) wrap the viewport/panes in an object, 2) rework individial viewport related objects (eg, ViewportL, ViewportR, HeaderL, HeaderR, HeaderRowL, HeaderRowR) into an array 3) encapsulate all multi pane concerns into the ViewportMgr object and take the slick.grid.ts code back to what it looked like before the frozen panes change. The primary reason I wanted to do this change was to simplify the code in 'slick.grid.js'. This goal has not been met. To illustrate what I mean, take the following examples: SlickGrid-pre-frozen: (in createColumnHeaders()) SlickGrid-post-frozen: (in createColumnHeaders()) 6pac master: (in createColumnHeaders()) 6pac viewportmgr-phase4: (in createColumnHeaders()) The pre-frozen code is simple and says exactly what it does. Then post-frozen, rather that reading the logical flow, you suddenly have to deal with multiple headers etc - and with the LazyPanes option we used the multiple headers may not even be actively used. desired: This would require wrapping all of the headers and footers into the ViewportMgr object - and that's what I really wanted, a single object that slick.grid.js can manipulate like a single pane that keeps all of the concerns about how many pane/header/footer elements there are and hides them from the grid code. Another example: SlickGrid-pre-frozen: SlickGrid-pre-frozen: 6pac master: 6pac viewportmgr-phase4: } desired: Report on the feasibility of this, and list the proposed new members of ViewportMgr, and before and after code in slick.grid.ts for each use case in that file. It impacts on both the viewportmgr-phase4 and the m16 plan currently in progress. |

Summary
Completes the 3×3 band model. Rebased onto current master (includes the #1229 squash and the #1231/#1232 fixes — the scrollTo paging fix verified intact alongside the band code); supersedes the stacked #1236. Contains the full ViewportMgr series: the #1234 commits replay here identically, so #1234 can be closed in favour of this PR or merged first — either order works. SlickGrid now supports left AND right frozen columns and top AND bottom frozen rows in any combination — up to nine panes — all managed by
ViewportMgr, with every legacy configuration byte-identical (proven by the phase 0–3 characterization suites, which run unchanged).New capabilities (all opt-in via new options; defaults preserve current behaviour exactly)
frozenRightColumn(count from the right edge — counts survive column reorder/hide, unlike index-basedfrozenColumn): a right-frozen column band with new*-right-frozencss classes. The historical "right" elements keep their names and become the scrollable middle band. Column-side selection, geometry (band pinned at the right edge, middle band shrinks), row-fragment routing (rowNodegrows to 3 entries), band-local cell coordinates, keyboard navigation across the boundary, and Y-scroll following are all implemented and spec-pinned.frozenBottomRow(count, usable TOGETHER withfrozenRow, which then always means top rows; the legacyfrozenBottomflag keeps its exact historical single-band meaning): a bottom-frozen row band (*-bottom-frozenclasses) with three-way row routing, geometry (band pinned below the body, above its scrollbar), X-scroll following, and runtime toggling. The shared bottom-frozen × right-frozen corner pane is created by whichever band materializes second.getFrozenBandCounts()/getFrozenRightStartIndex();cellrangeselectoris migrated onto it, which also fixes its cross-canvas measurement under the new modes and adds correct offsets/clamps for the new bands.ViewportMgrextracted toslick.core.ts(registered on the Slick namespace besideDragExtendHandle; contracts insrc/models/viewportMgr.interface.ts) —slick.grid.tsends ~1,500 lines lighter than before the whole refactor, with zero frozen-flag branching outsidesetFrozenOptions. No consumer impact: core already loads before the grid everywhere.Hardening
Three adversarial equivalence audits ran during Phase 4 (right-band transitions, bottom-band-off equivalence, init-window binding). Real defects found and fixed by them: stale-band routing/geometry guards after un-freezing, an init-window event double-binding (latent since the right-band milestone), and a dropped
disableSelectionon init-materialized headers. Known preserved quirks and invariants are documented in the source at their definitions.Testing
Three new example pages and 25 new band tests across three specs (
viewportmgr-right-frozen-band,viewportmgr-bottom-frozen-band, extendedviewportmgr-lazy-materialization), covering init-time and runtime materialization, geometry, routing, navigation, and un-freeze restoration. Every commit in the series landed only after the full Cypress suite passed locally (632 tests). Headless-onlyretries: { runMode: 1 }added to the Cypress config after reproducing a ~1-in-4 machine-level flake on pristine master (three different victim specs across runs; CI here is the authoritative check).🤖 Generated with Claude Code