diff --git a/.agents/plans/pinning-sticky-progress.md b/.agents/plans/pinning-sticky-progress.md new file mode 100644 index 000000000..19b41a5ba --- /dev/null +++ b/.agents/plans/pinning-sticky-progress.md @@ -0,0 +1,106 @@ +# Single-viewport pinning/stickiness — implementation status + +Last updated: 2026-09-18. + +This file records the state of the pinning/sticky docking rewrite **in this repository** (the +flat 6pac/SlickGrid tree). It was originally an implementation log from the slickgrid-universal +fork; everything that only applied there (framework demos, unit-test counts, Grid State/Service +plumbing, Header Menu commands, locale strings, migration guides) has been removed. Treat +`src/`, `cypress/e2e/` and `docs/pinning-sticky.md` as the source of truth. + +## Goal + +Replace the multi-pane frozen-column/row architecture with a single-viewport docking model: + +- one live body viewport with one native vertical scrollbar; ordinary grids scroll horizontally + through the viewport, docking grids through one dedicated horizontal scrollbar; +- one virtualized DOM row per data row, each with stable left/centre/right cell regions; +- permanent pinning and scroll-activated stickiness resolved by the same internal controller; +- vertical and horizontal virtualization preserved for large datasets; +- an intentional major-version breaking change: no compatibility with the pane renderer. + +## Implemented architecture + +- `src/slick.core.ts` — `DockingController`, a DOM-free resolver for column and row bands + (permanent pins, sticky activation from natural geometry, viewport-percentage budgets, + `conveyor`/`clamp` overflow, revision counters). Exported as `Slick.DockingController`. +- `src/slick.grid.ts` — single viewport/canvas, per-row regions, header/header-row/footer + regions (`display: contents` wrappers inside the existing roots), the docking overlay for + pinned/sticky rows, the proxy horizontal scrollbar, transforms for chrome and pinned regions, + cross-band colspan host + fragments, docking-aware hit-testing (`getCellFromPoint`), runtime + API (`setColumnPinning`, `setColumnStickiness`, `getPinnedColumns`, `validateColumnPinning`) + and option handling (`pinning`, `stickyRows`, `docking`, `invalidColumnPinning*`). +- `src/styles/_slick-docking.scss` — region layout, overlay stacking, separators, sticky cues. +- `src/models/docking.interface.ts` — public option and layout types. + +The `-1000px` header offset and `HEADER_WIDTH_SLACK` are gone; header, grouped-header and body +coordinates share one coordinate system. The legacy `frozen*` options, `.slick-pane*` DOM and +the right/bottom pane elements no longer exist. + +## Public surface + +See `docs/pinning-sticky.md` for the option semantics, runtime API, selectors and the migration +table. Key rules: + +- numeric column references are indexes (shorthands count visible columns); string references + are column ids; +- numeric row references are indexes; string references are dataset ids via the DataView; +- sticky state is never serialized; permanent pinning is what applications persist; +- `setOptions` replaces the pinning/sticky arrays atomically; `setOptions({ pinning: undefined })` + removes docking and tears the proxy scrollbar and chrome regions down again. + +## Verification + +- `npm run build:prod` (type-check, lint, bundles, CSS, types) must pass. +- Browser coverage (Cypress): `example-pinning-*`, `example-sticky-financial-report`, + `example-colspan` (pinned colspans), `example-variable-row-height-*`, `example-auto-scroll-when-dragging` + (pinned drag auto-scroll), `example11-autoheight`, and the self-hosted `quirk-pinning-*` / + `quirk-sticky-*` harnesses (row boundary, empty configs, bottom hit-testing and cleanup, + bottom-pin reachability, docked-row cell virtualization, hit-testing geometry, chrome scroll + forwarding, lazy activation destroy events, sticky column reorder, destroy references). +- CI runs on Linux/Chrome; menu-alignment specs are geometry sensitive and are also checked on + Windows font metrics. + +## Resolved during review (2026-09-18) + +- Column shorthands/arrays resolve to indexes only and over visible columns; numeric ids no + longer collide with index references. +- Row references match by index or string id; the id→index cache is cleared on row invalidation; + the DataView id property is honoured. +- Sticky-row thresholds account for the permanent top band and no longer subtract the bottom band + twice; `conveyor` keeps the newest candidates on every edge. +- `autoHeight` grids size the container once (no header-height band); the vertical wheel is only + intercepted on docking grids; Ctrl/Meta+drag multi-selection follows the selection model again; + `absBox()`/editor positions are document-relative again. +- `getCellFromPoint()` resolves through the rendered layout (bands, overlay, non-contiguous + pins, unrendered rows); `CellRangeSelector` prefers the event target. +- Bottom-pinned rows keep every scrolling row (including the add-new row) reachable. +- Docked rows virtualize their centre cells horizontally; chrome cells fire their destroy events + on lazy activation/deactivation; native chrome scrolls are forwarded as deltas; reordering + works while a sticky column is docked; cell CSS classes are mirrored onto colspan fragments; + `destroy(true)` drops element references reflectively. +- slickgrid-universal-only options and code paths were removed; the legacy `frozen*` options are + gone from the types; the header-menu demo command is "Column Pinning". + +## Known limitations and follow-ups + +- A colspan host that starts in a pinned band paints across the boundary and stays with its band + while the centre scrolls (centre cells that scroll under it are covered). Clipping the host and + letting the fragment carry the text is the alternative if this is not the wanted look. +- Sticky columns in RTL are not covered by browser tests. +- Sticky group headers (a grouped header that stays visible as a unit) are not supported. +- Focus sinks live outside the grid container (`tabIndex -1`); keyboard routing (Shift+Tab into + header-row filters, F6 to the header) came from the fork and was removed during the audit; the + base focus sinks and `navigatePrev()` handle Tab and Shift+Tab again + with `tabIndex="0"` that the plugins here do not produce. +- Fast vertical-scroll blanking is a separate virtual-rendering task. +- Per-scroll work on row-docking grids (`syncDockedRowContainers` on every vertical scroll, + per-row custom-property writes on horizontal scroll) can be reduced further. + +## Resume checklist + +1. Read `docs/pinning-sticky.md`, then the relevant `src/` code and the specs listed above. +2. Preserve the invariants: one native horizontal and one native vertical scroll owner, one row + node with three regions, one shared `DockingController`, no legacy compatibility branches. +3. Add a self-hosted `quirk-*` spec for any regression fixed; run `npm run build:prod` and the + affected Cypress specs before committing. diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 000000000..bb9f2d733 --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,15 @@ +# Repository AI Skills + +Portable project skills live under `.agents/skills//SKILL.md` and can be discovered +by Cursor and other Agent Skills-compatible tools. + +Available portable skills + +- [`pinning-sticky/SKILL.md`](pinning-sticky/SKILL.md) — configuration and maintenance guidance + for permanent pinning and sticky docking. + +New cross-agent skills should use the portable structure above. + +Project implementation plans live under `.agents/plans/`: + +- [`pinning-sticky-progress.md`](../plans/pinning-sticky-progress.md) diff --git a/.agents/skills/pinning-sticky/SKILL.md b/.agents/skills/pinning-sticky/SKILL.md new file mode 100644 index 000000000..e2f546098 --- /dev/null +++ b/.agents/skills/pinning-sticky/SKILL.md @@ -0,0 +1,82 @@ +--- +name: pinning-sticky +description: Configure, document, review, or change SlickGrid permanent pinning and scroll-activated sticky docking. +--- + +# Pinning and Sticky Docking + +Use this skill when configuring, documenting, reviewing, or changing SlickGrid permanent pinning +or scroll-activated sticky docking. The user-facing reference is `docs/pinning-sticky.md`; keep +it and this file in agreement. + +## Repository layout + +This repository is the flat SlickGrid source tree. Use `src/` for library code, `examples/` for +demos and `cypress/e2e/` for browser tests. There is no unit-test runner; `tests/` holds legacy +manual benchmark pages. Paths such as `packages/common/`, `demos/vanilla/` or framework demo +packages belong to the slickgrid-universal fork and do not exist here. + +## Canonical configuration + +- Use the nested `GridOption.pinning` shape for permanent pins: + `columns.left/right` and `rows.top/bottom`. +- Column references: a number is an inclusive left boundary or a right count over the visible + columns; an array holds column indexes (numbers) and/or column ids (strings) and may be + non-contiguous, for example `columns.left: ['account', 'status']`. +- Row references take three forms: a number is always a row index, a string is a dataset id, and + `{ id: }` is a dataset id of any type, which is how a grid with numeric ids pins by id. + Id references follow their row through a sort or filter; index references do not, so a caller + pinning a positional row such as the last one must recompute it when the row count changes. + Non-contiguous rows are valid, for example `rows.top: [0, 2, 4]`. +- `setOptions({ pinning: null })` and `setOptions({ pinning: undefined })` both clear pinning. +- `Column.pinned` is the per-column permanent-pin form and is kept in sync with the option. + There is no `Column.pinnable`; menus are application code built on `setColumnPinning()`. +- Reordering stays within a band; pinning and unpinning are explicit through configuration, the + runtime API or application menus. Never infer pinning from a drag across bands. + +## Sticky behavior + +- Use `Column.sticky` and `GridOption.stickyRows` for scroll-activated docking. Sticky state is + scroll-dependent and is never serialized; permanent pinning is what an application persists. +- Multiple active top sticky rows stack in natural dataset order. They do not push each other out. +- Sticky row capacity uses the current viewport, not a fixed row count. The default row budget is + 60% of viewport height after permanent pinned rows are accounted for, and measured row heights + determine how many candidates fit. `docking.maxRowViewportHeightPercent` and + `docking.overflowStrategy` control this behavior. +- Permanent pinned rows keep their slot in the dataset height; rows after a pin are rendered so + the pinned slot collapses under the band, and the last scrolling row stays reachable. +- Both bands nest the same way: permanent rows sit at the outer edge and active sticky rows stack + inside them, so a sticky bottom row sits above a permanently pinned bottom row. +- `docking.stickyActivationBuffer` (default 2px) is the column activation buffer; rows dock on the + exact boundary. +- Pinning and sticky docking work in both reading directions. The band names follow the reading + order, not the screen: `left` is the leading band and renders at the right edge of an RTL grid, + `right` is the trailing band. `sticky: true` docks at the leading band either way, so the same + options describe both directions. Distances inside the docking code are measured along the + inline axis; `getInlineDirection()` converts them to physical pixels where a style needs one. + +## Maintenance verification + +When changing this feature: + +1. Check the local interfaces and implementation first: + `src/models/docking.interface.ts`, `src/models/gridOption.interface.ts`, `src/slick.grid.ts` + (rendering, scrolling, hit-testing, options), `src/slick.core.ts` (`DockingController`), and + `src/styles/_slick-docking.scss`. +2. Update `docs/pinning-sticky.md` for any option, selector or behavior change. +3. Add or update browser coverage under `cypress/e2e/`: the `example-pinning-*`, + `example-sticky-*` and `quirk-pinning-*` specs, plus `example-colspan.cy.ts` for cross-band + colspans. Self-hosted `quirk-*` harnesses (a page served through `cy.intercept`) are the + pattern for focused regressions. +4. `DockingController` is exported (`Slick.DockingController`, ESM `DockingController`) but is + an implementation detail; do not extend its public surface without an explicit API decision. +5. Keep fast vertical-scroll blanking as a separate virtual-rendering task; do not conflate it + with sticky-row activation or docking-layout refresh. +6. Verify with `npm run build:prod` and the Cypress suite; CI runs on Linux, so re-check + geometry-sensitive specs (menus, alignment) on Windows font metrics when they change. + +## Source documentation + +- [Pinning and sticky docking](../../../docs/pinning-sticky.md) +- [Implementation status](../../plans/pinning-sticky-progress.md) +- [Documentation README](../../../docs/README.md) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..1e52548d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Repository Agent Instructions + +## Generated files + +- Everything under `dist/` is build output. Never write or hand-edit those files: + produce them by running the project build (`npm run build:prod`) instead. +- `dist/` is refreshed on release commits, so keep it out of feature and fix + commits even after a local build has rewritten it. +- The build output itself is disposable. It is regenerated from `src/` at any + time, so there is nothing in `dist/` worth preserving across a rebuild. diff --git a/README.md b/README.md index 95a223f2f..35989ce41 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This repo builds on the legacy of the [mleibman/SlickGrid](https://github.com/ml We extended the project from the original SlickGrid foundation while also including the following changes: - added a few more Plugins: RowDetail, CellMenu, ContextMenu, GridMenu, CustomTooltip, GridState -- merged [X-SlickGrid](https://github.com/ddomingues/X-SlickGrid) code into the project to bring Frozen Columns/Rows (aka Pinning) +- merged [X-SlickGrid](https://github.com/ddomingues/X-SlickGrid) code into the project to bring permanent column/row pinning - removed jQueryUI requirement in [v3](https://github.com/6pac/SlickGrid/wiki/Major-version-3.0----Removal-of-jQueryUI-requirement-(replaced-by-SortableJS)) (replaced it with [SortableJS](https://sortablejs.github.io/Sortable/)) - removed jQuery requirement in [v4](https://github.com/6pac/SlickGrid/wiki/Major-version-4.0---Removal-of-jQuery-requirement) - modernized the project in [v5](https://github.com/6pac/SlickGrid/wiki/Major-version-5.0-%E2%80%90-ES6-ESM-and-TypeScript-Support) by migrating to TypeScript (we kept IIFE and added ES6/ESM build targets) and we also gave SlickGrid a fresh and more modern look via a new Alpine Theme (CSS/SASS) diff --git a/cypress/e2e/dom-shape-characterization.cy.ts b/cypress/e2e/dom-shape-characterization.cy.ts index 8c65b7d75..78f12d5b9 100644 --- a/cypress/e2e/dom-shape-characterization.cy.ts +++ b/cypress/e2e/dom-shape-characterization.cy.ts @@ -1,164 +1,101 @@ /** - * DOM-shape characterization test (Phase 0 baseline for the ViewportMgr refactor). + * DOM-shape characterization for the single-viewport renderer. * - * Captures the EXACT pane/viewport/canvas DOM structure the grid builds today — - * 6 panes, 4 viewports, 4 canvases, always created regardless of frozen options, - * with visibility and row routing varying by freeze state — so that the Phase 1 - * refactor (building the same DOM through ViewportMgr) can prove it produces an - * identical structure. - * - * Do NOT loosen these assertions to make a refactor pass; a failure here means the - * DOM contract changed. Only update them when the contract change is deliberate - * (e.g. Phase 3 lazy panes behind the opt-in option). + * The grid no longer creates the legacy six-pane/four-viewport structure. + * Every grid has one live viewport and one canvas. Configured pinning or + * sticky docking adds stable left/center/right chrome and row regions; row + * pinning additionally moves pinned rows into the docking overlay. */ -const PANE_CLASSES = [ - ['slick-pane', 'slick-pane-header', 'slick-pane-left'], - ['slick-pane', 'slick-pane-header', 'slick-pane-right'], - ['slick-pane', 'slick-pane-top', 'slick-pane-left'], - ['slick-pane', 'slick-pane-top', 'slick-pane-right'], - ['slick-pane', 'slick-pane-bottom', 'slick-pane-left'], - ['slick-pane', 'slick-pane-bottom', 'slick-pane-right'], -]; - -const SIDES = ['left', 'right'] as const; -const BANDS = ['top', 'bottom'] as const; - -/** Asserts the canonical structure every grid builds today, frozen or not. */ -function assertCanonicalShape() { - // 6 panes as direct children of the container, in exact creation order - cy.get('#myGrid > .slick-pane') - .should('have.length', 6) - .then(($panes) => { - PANE_CLASSES.forEach((classes, i) => { - classes.forEach((cls) => expect($panes.eq(i), `pane ${i} has .${cls}`).to.have.class(cls)); - }); - }); - - // 4 viewports and 4 canvases, each nested in its matching pane/viewport - cy.get('#myGrid .slick-viewport').should('have.length', 4); - cy.get('#myGrid .grid-canvas').should('have.length', 4); - BANDS.forEach((band) => { - SIDES.forEach((side) => { - cy.get(`#myGrid .slick-pane-${band}.slick-pane-${side} > .slick-viewport.slick-viewport-${band}.slick-viewport-${side}`) - .should('have.length', 1); - cy.get(`#myGrid .slick-viewport-${band}.slick-viewport-${side} > .grid-canvas.grid-canvas-${band}.grid-canvas-${side}`) - .should('have.length', 1); - }); - }); - - // one header scroller + one header-columns container per side, in the header panes - cy.get('#myGrid .slick-pane-header.slick-pane-left > .slick-header.slick-header-left').should('have.length', 1); - cy.get('#myGrid .slick-pane-header.slick-pane-right > .slick-header.slick-header-right').should('have.length', 1); - cy.get('#myGrid .slick-header-left > .slick-header-columns.slick-header-columns-left').should('have.length', 1); - cy.get('#myGrid .slick-header-right > .slick-header-columns.slick-header-columns-right').should('have.length', 1); - - // header-row and top-panel scrollers live in the TOP panes, one per side, - // created before the viewport (child order: headerrow, top-panel, viewport) - SIDES.forEach((side) => { - cy.get(`#myGrid .slick-pane-top.slick-pane-${side} > .slick-headerrow`).should('have.length', 1); - cy.get(`#myGrid .slick-pane-top.slick-pane-${side} > .slick-top-panel-scroller`).should('have.length', 1); - }); - cy.get('#myGrid .slick-pane-top.slick-pane-left').children().then(($children) => { - const classNames = $children.toArray().map((el) => el.className); - const idxHeaderRow = classNames.findIndex((c) => c.includes('slick-headerrow')); - const idxTopPanel = classNames.findIndex((c) => c.includes('slick-top-panel-scroller')); - const idxViewport = classNames.findIndex((c) => c.includes('slick-viewport')); - expect(idxHeaderRow, 'headerrow before top-panel').to.be.lessThan(idxTopPanel); - expect(idxTopPanel, 'top-panel before viewport').to.be.lessThan(idxViewport); - }); -} - -/** Asserts which of the 6 panes are visible for a given freeze state. */ -function assertPaneVisibility(visible: { headerR: boolean; topR: boolean; bottomL: boolean; bottomR: boolean; }) { - cy.get('#myGrid .slick-pane-header.slick-pane-left').should('be.visible'); - cy.get('#myGrid .slick-pane-top.slick-pane-left').should('be.visible'); - cy.get('#myGrid .slick-pane-header.slick-pane-right').should(visible.headerR ? 'be.visible' : 'not.be.visible'); - cy.get('#myGrid .slick-pane-top.slick-pane-right').should(visible.topR ? 'be.visible' : 'not.be.visible'); - cy.get('#myGrid .slick-pane-bottom.slick-pane-left').should(visible.bottomL ? 'be.visible' : 'not.be.visible'); - cy.get('#myGrid .slick-pane-bottom.slick-pane-right').should(visible.bottomR ? 'be.visible' : 'not.be.visible'); +interface DockingShapeOptions { + hasDocking: boolean; + hasRowDocking: boolean; + topPinnedRows?: number[]; + bottomPinnedRows?: number[]; } -/** Asserts which canvases receive row elements for a given freeze state. */ -function assertRowRouting(populated: { topL: boolean; topR: boolean; bottomL: boolean; bottomR: boolean; }) { - const expectRows = (band: string, side: string, hasRows: boolean) => { - cy.get(`#myGrid .grid-canvas-${band}.grid-canvas-${side} .slick-row`) - .should(hasRows ? 'have.length.greaterThan' : 'have.length', 0); - }; - expectRows('top', 'left', populated.topL); - expectRows('top', 'right', populated.topR); - expectRows('bottom', 'left', populated.bottomL); - expectRows('bottom', 'right', populated.bottomR); +/** Asserts the canonical structure shared by ordinary and docked grids. */ +function assertCanonicalShape({ hasDocking, hasRowDocking, topPinnedRows = [], bottomPinnedRows = [] }: DockingShapeOptions): void { + cy.get('#myGrid > .slick-pane').should('have.length', 0); + cy.get('#myGrid > .slick-header-root').should('have.length', 1); + cy.get('#myGrid > .slick-content-root').should('have.length', 1); + cy.get('#myGrid .slick-viewport').should('have.length', 1); + cy.get('#myGrid .grid-canvas').should('have.length', 1); + + if (!hasDocking) { + cy.get('#myGrid .slick-header-left > .slick-header-columns-left').should('have.length', 1); + cy.get('#myGrid .slick-headerrow-columns-left').should('have.length', 1); + cy.get('#myGrid .slick-header-columns-root').should('not.exist'); + cy.get('#myGrid .slick-docking-horizontal-scroller').should('not.exist'); + cy.get('#myGrid .slick-docking-overlay').should('not.exist'); + cy.get('#myGrid .slick-row-docked').should('have.length', 0); + return; + } + + // Docked chrome keeps one header/header-row collection and marks pinned + // cells by side. Do not depend on the internal wrapper depth or whether an + // empty center wrapper is materialized by the loaded bundle. + cy.get('#myGrid .slick-header-columns').should('have.length.greaterThan', 0); + cy.get('#myGrid .slick-headerrow-columns').should('have.length.greaterThan', 0); + cy.get('#myGrid .slick-header-column').should('have.length.greaterThan', 0); + cy.get('#myGrid .slick-docking-horizontal-scroller').should('have.length', 1); + + // Docked grids render each row with stable cell-region siblings. + cy.get('#myGrid .slick-row-docked').should('have.length.greaterThan', 0); + cy.get('#myGrid .slick-row-docked').first().then(($row) => { + cy.wrap($row).children('.slick-pinned-left-cells').should('have.length', 1); + cy.wrap($row).children('.slick-scrolling-cells').should('have.length', 1); + cy.wrap($row).children('.slick-pinned-right-cells').should('have.length', 1); + }); + + if (!hasRowDocking) { + cy.get('#myGrid .slick-docking-overlay').should('not.exist'); + return; + } + + cy.get('#myGrid .slick-docking-overlay').should('have.length', 1); + topPinnedRows.concat(bottomPinnedRows).forEach((row) => { + cy.get(`#myGrid .slick-docking-overlay .slick-row[data-row="${row}"]`).should('have.length', 1); + cy.get(`#myGrid .grid-canvas .slick-row[data-row="${row}"]`).should('not.exist'); + }); } -describe('DOM shape characterization - non-frozen grid (example1-simple)', () => { +describe('DOM shape characterization - non-pinned grid (example1-simple)', () => { it('should load the example', () => { cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple.html`); }); - it('should build the canonical 6-pane / 4-viewport / 4-canvas structure', () => { - assertCanonicalShape(); - }); - - it('should show only the left header and top-left panes', () => { - assertPaneVisibility({ headerR: false, topR: false, bottomL: false, bottomR: false }); - }); - - it('should render all rows into the top-left canvas only', () => { - assertRowRouting({ topL: true, topR: false, bottomL: false, bottomR: false }); + it('should build one viewport and one canvas without docking regions', () => { + assertCanonicalShape({ hasDocking: false, hasRowDocking: false }); }); }); -describe('DOM shape characterization - frozen columns only (example-frozen-columns)', () => { +describe('DOM shape characterization - pinned columns only (example-pinning-columns)', () => { it('should load the example', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns.html`); - }); - - it('should build the canonical 6-pane / 4-viewport / 4-canvas structure', () => { - assertCanonicalShape(); + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns.html`); }); - it('should show header and top panes on both sides, bottom panes hidden', () => { - assertPaneVisibility({ headerR: true, topR: true, bottomL: false, bottomR: false }); - }); - - it('should render rows into the two top canvases only', () => { - assertRowRouting({ topL: true, topR: true, bottomL: false, bottomR: false }); + it('should build one viewport with docked chrome and row regions', () => { + assertCanonicalShape({ hasDocking: true, hasRowDocking: false }); }); }); -describe('DOM shape characterization - frozen rows only, frozenBottom (example-frozen-rows)', () => { +describe('DOM shape characterization - pinned rows only (example-pinning-rows)', () => { it('should load the example', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-rows.html`); - }); - - it('should build the canonical 6-pane / 4-viewport / 4-canvas structure', () => { - assertCanonicalShape(); - }); - - it('should show left panes only, including the bottom-left pane', () => { - assertPaneVisibility({ headerR: false, topR: false, bottomL: true, bottomR: false }); + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-rows.html`); }); - it('should render rows into the two left canvases only', () => { - assertRowRouting({ topL: true, topR: false, bottomL: true, bottomR: false }); + it('should route pinned rows to the docking overlay', () => { + assertCanonicalShape({ hasDocking: true, hasRowDocking: true, topPinnedRows: [0, 1], bottomPinnedRows: [49999] }); }); }); -describe('DOM shape characterization - frozen columns and rows (example-frozen-columns-and-rows)', () => { +describe('DOM shape characterization - pinned columns and rows (example-pinning-columns-and-rows)', () => { it('should load the example', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns-and-rows.html`); - }); - - it('should build the canonical 6-pane / 4-viewport / 4-canvas structure', () => { - assertCanonicalShape(); - }); - - it('should show all six panes', () => { - assertPaneVisibility({ headerR: true, topR: true, bottomL: true, bottomR: true }); + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns-and-rows.html`); }); - it('should render rows into all four canvases', () => { - assertRowRouting({ topL: true, topR: true, bottomL: true, bottomR: true }); + it('should route pinned rows through the same single-viewport docking layout', () => { + assertCanonicalShape({ hasDocking: true, hasRowDocking: true, topPinnedRows: [0, 1], bottomPinnedRows: [49999] }); }); }); diff --git a/cypress/e2e/example-0031-row-span-employees.cy.ts b/cypress/e2e/example-0031-row-span-employees.cy.ts index 84b047789..7391bf561 100644 --- a/cypress/e2e/example-0031-row-span-employees.cy.ts +++ b/cypress/e2e/example-0031-row-span-employees.cy.ts @@ -18,223 +18,231 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); - it('should expect 1st column to be frozen (frozen)', () => { - cy.get('.grid-canvas-left .slick-cell.frozen').should('have.length', 10); - cy.get('.grid-canvas-right .slick-cell:not(.frozen)').should('have.length.above', 60); + it('should expect the first column to be pinned on the left', () => { + cy.get('#myGrid .slick-pinned-left-cells .slick-cell.l0.slick-cell-pinned-left').should('have.length', 10); + cy.get('#myGrid .slick-scrolling-cells .slick-cell').should('have.length.above', 60); }); it('should hide Employee ID with hidden while keeping the complete column definition list', () => { cy.get('[data-test="toggle-employee-id"]').click(); cy.get('#myGrid .slick-header-column').should('have.length', 18); - cy.get('[data-row=0] > .slick-cell.l0').should('not.exist'); + cy.get('[data-row=0] .slick-cell.l0').should('not.exist'); cy.get('[data-test="toggle-employee-id"]').click(); cy.get('#myGrid .slick-header-column').should('have.length', 19); }); describe('Spanning', () => { it('should expect "Davolio", "Check Mail", and "Development" to all have rowspan of 2 in morning hours', () => { - cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should('contain', 'Davolio'); - cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=0] .slick-cell.l1.r1.rowspan`).should('contain', 'Davolio'); + cy.get(`[data-row=0] .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); - cy.get(`[data-row=2] > .slick-cell.l2.r4.rowspan`).should('contain', 'Check Mail'); - cy.get(`[data-row=2] > .slick-cell.l2.r4.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=2] .slick-cell.l2.r4.rowspan`).should('contain', 'Check Mail'); + cy.get(`[data-row=2] .slick-cell.l2.r4.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); - cy.get(`[data-row=8] > .slick-cell.l7.r9.rowspan`).should('contain', 'Development'); - cy.get(`[data-row=8] > .slick-cell.l7.r9.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=8] .slick-cell.l7.r9.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=8] .slick-cell.l7.r9.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); }); it('should expect "Lunch Break" to span over 3 columns and over all rows', () => { - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 10)); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 10)); }); it('should expect a large "Development" section that spans over multiple columns & rows in the afternoon', () => { - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5)); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5)); }); }); describe('Basic Key Navigations', () => { + beforeEach(() => { + cy.window().then((win) => win.eval('grid.scrollToX(0)')); + }); + it('should start at Employee 10001, then type "End" key and expect to be in "Team Meeting" between 4:30-5:00pm', () => { - cy.get('[data-row=0] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l0.r0.active').should('contain', '10001'); + cy.get('[data-row=0] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l0.r0.active').should('contain', '10001'); cy.get('@active_cell').type('{end}'); - cy.get('[data-row=0] > .slick-cell.l17.r18.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l17.r18.active').should('contain', 'Team Meeting'); }); it('should start at Employee 10002, then type "End" key and also expect to be in "Team Meeting" between 4:30-5:00pm', () => { - cy.get('[data-row=1] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=1] > .slick-cell.l0.r0.active').should('contain', '10002'); + cy.get('[data-row=1] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=1] .slick-cell.l0.r0.active').should('contain', '10002'); cy.get('@active_cell').type('{end}'); - cy.get('[data-row=0] > .slick-cell.l17.r18.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l17.r18.active').should('contain', 'Team Meeting'); }); it('should start at Employee 10004, then type "ArrowRight" key twice and expect to be in "Check Mail" between 9:00-10:30am', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{rightarrow}{rightarrow}'); - cy.get('[data-row=2] > .slick-cell.l2.r4.active').should('contain', 'Check Mail'); + cy.get('[data-row=2] .slick-cell.l2.r4.active').should('contain', 'Check Mail'); }); it('should start at Employee 10004, then type "ArrowRight" key 4x times and expect to be in "Testing" between 11:00-1:00pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{rightarrow}{rightarrow}{rightarrow}{rightarrow}'); - cy.get('[data-row=3] > .slick-cell.l6.r9.active').should('contain', 'Testing'); + cy.get('[data-row=3] .slick-cell.l6.r9.active').should('contain', 'Testing'); }); it('should start at Employee 10004, then type "ArrowRight" key 5x times and expect to be in "Lunch Break"', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{rightarrow}{rightarrow}{rightarrow}{rightarrow}{rightarrow}'); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); }); it('should start at Employee 10004, then type "ArrowRight" key 6x times and expect to be in "Development" between 2:30-3:30pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{rightarrow}{rightarrow}{rightarrow}{rightarrow}{rightarrow}{rightarrow}'); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); }); // then rollback by going backward it('should be on Employee 10004 row at previous "Development" cell, then type "ArrowLeft" key once and expect to be in "Lunch Break"', () => { - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).as('active_cell').click(); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).as('active_cell').click(); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); cy.get('@active_cell').type('{leftarrow}'); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "ArrowLeft" key once and expect to be in "Conference" between 4:00-5:00pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).type('{leftarrow}'); - cy.get(`[data-row=3] > .slick-cell.l16.r17.active`).should('contain', 'Conference'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).type('{leftarrow}'); + cy.get(`[data-row=3] .slick-cell.l16.r17.active`).should('contain', 'Conference'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "ArrowLeft" key 3x times and expect to be back to "Development" between 2:30-3:30pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).type('{leftarrow}{leftarrow}{leftarrow}'); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).type('{leftarrow}{leftarrow}{leftarrow}'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "ArrowLeft" key 4x times and expect to be back to "Lunch Break"', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{leftarrow}{leftarrow}{leftarrow}{leftarrow}'); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "ArrowLeft" key 5x times and expect to be back to "Testing" between 11:00-1:00pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{leftarrow}{leftarrow}{leftarrow}{leftarrow}{leftarrow}'); - cy.get(`[data-row=3] > .slick-cell.l6.r9.active`).should('contain', 'Testing'); + cy.get(`[data-row=3] .slick-cell.l6.r9.active`).should('contain', 'Testing'); }); // going down it('should start at 10am "Team Meeting, then type "ArrowDown" key once and expect to be in "Support" between 9:30-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}'); - cy.get(`[data-row=1] > .slick-cell.l3.r5.active`).should('contain', 'Support'); + cy.get(`[data-row=1] .slick-cell.l3.r5.active`).should('contain', 'Support'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key twice and expect to be in "Check Email" between 9:00-10:30am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}{downarrow}'); - cy.get(`[data-row=2] > .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); + cy.get(`[data-row=2] .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 3x times and expect to be in "Task Assign" between 9:00-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}{downarrow}{downarrow}'); - cy.get(`[data-row=4] > .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); + cy.get(`[data-row=4] .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times and expect to be in "Support" between 10:00-11:30am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}{downarrow}{downarrow}{downarrow}'); - cy.get(`[data-row=5] > .slick-cell.l4.r6.active`).should('contain', 'Support'); + cy.get(`[data-row=5] .slick-cell.l4.r6.active`).should('contain', 'Support'); }); // going up from inverse it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" once and expect to be in "Task Assign" between 9:00-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}{downarrow}{downarrow}{downarrow}{uparrow}'); - cy.get(`[data-row=4] > .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); + cy.get(`[data-row=4] .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" 2x times and expect to be in "Task Assign" between 9:00-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}{downarrow}{downarrow}{downarrow}{uparrow}{uparrow}'); - cy.get(`[data-row=2] > .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); + cy.get(`[data-row=2] .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" 3x times and expect to be in "Support" between 10:00-11:30am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}{downarrow}{downarrow}{downarrow}{uparrow}{uparrow}{uparrow}'); - cy.get(`[data-row=1] > .slick-cell.l3.r5.active`).should('contain', 'Support'); + cy.get(`[data-row=1] .slick-cell.l3.r5.active`).should('contain', 'Support'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" 4x times and expect to be back to same "Team Meeting"', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('@active_cell').type('{downarrow}{downarrow}{downarrow}{downarrow}{uparrow}{uparrow}{uparrow}{uparrow}'); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); }); }); describe('Grid Navigate Functions', () => { + beforeEach(() => { + cy.window().then((win) => win.eval('grid.scrollToX(0)')); + }); + it('should start at Employee 10004, then type "Navigate Right" twice and expect to be in "Check Mail" between 9:00-10:30am', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); cy.get('[data-test="goto-next"]') .click() .click(); - cy.get('[data-row=2] > .slick-cell.l2.r4.active').should('contain', 'Check Mail'); + cy.get('[data-row=2] .slick-cell.l2.r4.active').should('contain', 'Check Mail'); }); it('should start at Employee 10004, then type "Navigate Right" 4x times and expect to be in "Testing" between 11:00-1:00pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('[data-test="goto-next"]') .click() .click() .click() .click(); - cy.get('[data-row=3] > .slick-cell.l6.r9.active').should('contain', 'Testing'); + cy.get('[data-row=3] .slick-cell.l6.r9.active').should('contain', 'Testing'); }); it('should start at Employee 10004, then type "Navigate Right" 5x times and expect to be in "Lunch Break"', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('[data-test="goto-next"]') .click() .click() .click() .click() .click(); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); }); it('should start at Employee 10004, then type "Navigate Right" 6x times and expect to be in "Development" between 2:30-3:30pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('[data-test="goto-next"]') .click() .click() @@ -242,108 +250,108 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () .click() .click() .click(); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); }); // then rollback by going backward it('should be on Employee 10004 row at previous "Development" cell, then type "Navigate Left" once and expect to be in "Lunch Break"', () => { - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).as('active_cell').click(); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).as('active_cell').click(); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); cy.get('[data-test="goto-prev"]').click(); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "Navigate Left" once and expect to be in "Conference" between 4:00-5:00pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); cy.get('[data-test="goto-prev"]').click(); - cy.get(`[data-row=3] > .slick-cell.l16.r17.active`).should('contain', 'Conference'); + cy.get(`[data-row=3] .slick-cell.l16.r17.active`).should('contain', 'Conference'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "Navigate Left" 3x times and expect to be back to "Development" between 2:30-3:30pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).should('contain', 'Team Meeting'); cy.get('[data-test="goto-prev"]') .click() .click() .click(); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan.active`).should('contain', 'Development'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "Navigate Left" 4x times and expect to be back to "Lunch Break"', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); cy.get('[data-test="goto-prev"]') .click() .click() .click() .click(); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan.active`).should('contain', 'Lunch Break'); }); it('should start at Employee 10004, type "End" and be at "Team Meeting" at 5pm, then type "Navigate Left" 5x times and expect to be back to "Testing" between 11:00-1:00pm', () => { - cy.get('[data-row=3] > .slick-cell.l0.r0').as('active_cell').click(); - cy.get('[data-row=3] > .slick-cell.l0.r0.active').should('contain', '10004'); + cy.get('[data-row=3] .slick-cell.l0.r0').as('active_cell').click(); + cy.get('[data-row=3] .slick-cell.l0.r0.active').should('contain', '10004'); cy.get('@active_cell').type('{end}'); - cy.get(`[data-row=3] > .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); + cy.get(`[data-row=3] .slick-cell.l18.r18.active`).as('active_cell').should('contain', 'Team Meeting'); cy.get('[data-test="goto-prev"]') .click() .click() .click() .click() .click(); - cy.get(`[data-row=3] > .slick-cell.l6.r9.active`).should('contain', 'Testing'); + cy.get(`[data-row=3] .slick-cell.l6.r9.active`).should('contain', 'Testing'); }); // going down it('should start at 10am "Team Meeting, then type "ArrowDown" key once and expect to be in "Support" between 9:30-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click(); - cy.get(`[data-row=1] > .slick-cell.l3.r5.active`).should('contain', 'Support'); + cy.get(`[data-row=1] .slick-cell.l3.r5.active`).should('contain', 'Support'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key twice and expect to be in "Check Email" between 9:00-10:30am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click() .click(); - cy.get(`[data-row=2] > .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); + cy.get(`[data-row=2] .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 3x times and expect to be in "Task Assign" between 9:00-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click() .click() .click(); - cy.get(`[data-row=4] > .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); + cy.get(`[data-row=4] .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times and expect to be in "Support" between 10:00-11:30am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click() .click() .click() .click(); - cy.get(`[data-row=5] > .slick-cell.l4.r6.active`).should('contain', 'Support'); + cy.get(`[data-row=5] .slick-cell.l4.r6.active`).should('contain', 'Support'); }); // going up from inverse it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" once and expect to be in "Task Assign" between 9:00-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click() .click() @@ -351,12 +359,12 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () .click(); cy.get('[data-test="goto-up"]') .click(); - cy.get(`[data-row=4] > .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); + cy.get(`[data-row=4] .slick-cell.l2.r5.active`).should('contain', 'Task Assign'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" 2x times and expect to be in "Task Assign" between 9:00-11:00am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click() .click() @@ -365,12 +373,12 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () cy.get('[data-test="goto-up"]') .click() .click(); - cy.get(`[data-row=2] > .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); + cy.get(`[data-row=2] .slick-cell.l2.r4.active`).should('contain', 'Check Mail'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" 3x times and expect to be in "Support" between 10:00-11:30am', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click() .click() @@ -380,12 +388,12 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () .click() .click() .click(); - cy.get(`[data-row=1] > .slick-cell.l3.r5.active`).should('contain', 'Support'); + cy.get(`[data-row=1] .slick-cell.l3.r5.active`).should('contain', 'Support'); }); it('should start at 10am "Team Meeting, then type "ArrowDown" key 4x times, then "ArrowUp" 4x times and expect to be back to same "Team Meeting"', () => { - cy.get('[data-row=0] > .slick-cell.l4.r4').as('active_cell').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4').as('active_cell').click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); cy.get('[data-test="goto-down"]') .click() .click() @@ -396,59 +404,63 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () .click() .click() .click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); + cy.get('[data-row=0] .slick-cell.l4.r4.active').should('contain', 'Team Meeting'); }); }); describe('Grid Editing', () => { + beforeEach(() => { + cy.window().then((win) => win.eval('grid.scrollToX(0)')); + }); + it('should toggle editing', () => { cy.get('#isEditable').contains('false'); - cy.get('[data-row=0] > .slick-cell.l4.r4').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active .editor-text').should('not.exist'); + cy.get('[data-row=0] .slick-cell.l4.r4').scrollIntoView().click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active .editor-text').should('not.exist'); cy.get('[data-test=toggle-editing]').click(); cy.get('#isEditable').contains('true'); - cy.get('[data-row=0] > .slick-cell.l4.r4').click(); - cy.get('[data-row=0] > .slick-cell.l4.r4.active.editable .editor-text').should('exist'); - cy.get('[data-row=0] > .slick-cell.l4.r4.active.editable .editor-text').type('Team Meeting.xyz{enter}'); + cy.get('[data-row=0] .slick-cell.l4.r4').scrollIntoView().click(); + cy.get('[data-row=0] .slick-cell.l4.r4.active.editable .editor-text').should('exist'); + cy.get('[data-row=0] .slick-cell.l4.r4.active.editable .editor-text').type('Team Meeting.xyz{enter}'); }); // going down it('should have changed active cell to "Support" between 9:30-11:00am', () => { - cy.get('[data-row=1] > .slick-cell.l3.r5.active.editable .editor-text') + cy.get('[data-row=1] .slick-cell.l3.r5.active.editable .editor-text') .invoke('val') .then(text => expect(text).to.eq('Support')); - cy.get('[data-row=1] > .slick-cell.l3.r5.active.editable .editor-text').type('Support.xyz{enter}'); + cy.get('[data-row=1] .slick-cell.l3.r5.active.editable .editor-text').type('Support.xyz{enter}'); }); it('should have changed active cell to "Check Email" between 9:00-10:30am', () => { - cy.get('[data-row=2] > .slick-cell.l2.r4.active.editable .editor-text') + cy.get('[data-row=2] .slick-cell.l2.r4.active.editable .editor-text') .invoke('val') .then(text => expect(text).to.eq('Check Mail')); - cy.get('[data-row=2] > .slick-cell.l2.r4.active.editable .editor-text').type('Check Mail.xyz{enter}'); + cy.get('[data-row=2] .slick-cell.l2.r4.active.editable .editor-text').type('Check Mail.xyz{enter}'); }); it('should have changed active cell to "Task Assign" between 9:00-11:00am', () => { - cy.get('[data-row=4] > .slick-cell.l2.r5.active.editable .editor-text') + cy.get('[data-row=4] .slick-cell.l2.r5.active.editable .editor-text') .invoke('val') .then(text => expect(text).to.eq('Task Assign')); - cy.get('[data-row=4] > .slick-cell.l2.r5.active.editable .editor-text').type('Task Assign.xyz{enter}'); + cy.get('[data-row=4] .slick-cell.l2.r5.active.editable .editor-text').type('Task Assign.xyz{enter}'); }); it('should have changed active cell to "Support" between 10:00-11:30am', () => { - cy.get('[data-row=5] > .slick-cell.l4.r6.active.editable .editor-text') + cy.get('[data-row=5] .slick-cell.l4.r6.active.editable .editor-text') .invoke('val') .then(text => expect(text).to.eq('Support')); - cy.get('[data-row=5] > .slick-cell.l4.r6.active.editable .editor-text').type('Support.xyz{enter}'); + cy.get('[data-row=5] .slick-cell.l4.r6.active.editable .editor-text').type('Support.xyz{enter}'); }); it('should have changed active cell to "Testing" and cancel editing when typing "Escape" key', () => { - cy.get('[data-row=6] > .slick-cell.l4.r4.active.editable .editor-text') + cy.get('[data-row=6] .slick-cell.l4.r4.active.editable .editor-text') .invoke('val') .then(text => expect(text).to.eq('Testing')); - cy.get('[data-row=6] > .slick-cell.l4.r4.active.editable .editor-text').type('{esc}'); - cy.get('[data-row=6] > .slick-cell.l4.r4.active.editable .editor-text').should('not.exist'); + cy.get('[data-row=6] .slick-cell.l4.r4.active.editable .editor-text').type('{esc}'); + cy.get('[data-row=6] .slick-cell.l4.r4.active.editable .editor-text').should('not.exist'); }); }); @@ -459,24 +471,24 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () }); it('should preserve the original cell indexes while EmployeeID is hidden', () => { - cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should('contain', 'Davolio'); - cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=0] .slick-cell.l1.r1.rowspan`).should('contain', 'Davolio'); + cy.get(`[data-row=0] .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); - cy.get(`[data-row=2] > .slick-cell.l2.r4.rowspan`).should('contain', 'Check Mail'); - cy.get(`[data-row=2] > .slick-cell.l2.r4.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=2] .slick-cell.l2.r4.rowspan`).should('contain', 'Check Mail'); + cy.get(`[data-row=2] .slick-cell.l2.r4.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); - cy.get(`[data-row=8] > .slick-cell.l7.r9.rowspan`).should('contain', 'Development'); - cy.get(`[data-row=8] > .slick-cell.l7.r9.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=8] .slick-cell.l7.r9.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=8] .slick-cell.l7.r9.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); }); it('should preserve the original Lunch Break cell index', () => { - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 10)); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 10)); }); it('should preserve the original afternoon Development cell index', () => { - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5)); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5)); }); }); @@ -486,24 +498,24 @@ describe('Example - colspan/rowspan - Employees Timesheets', { retries: 1 }, () }); it('should expect EmployeeID to follow columns at index 1 column index', () => { - cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should('contain', 'Davolio'); - cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=0] .slick-cell.l1.r1.rowspan`).should('contain', 'Davolio'); + cy.get(`[data-row=0] .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); - cy.get(`[data-row=2] > .slick-cell.l2.r4.rowspan`).should('contain', 'Check Mail'); - cy.get(`[data-row=2] > .slick-cell.l2.r4.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=2] .slick-cell.l2.r4.rowspan`).should('contain', 'Check Mail'); + cy.get(`[data-row=2] .slick-cell.l2.r4.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); - cy.get(`[data-row=8] > .slick-cell.l7.r9.rowspan`).should('contain', 'Development'); - cy.get(`[data-row=8] > .slick-cell.l7.r9.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); + cy.get(`[data-row=8] .slick-cell.l7.r9.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=8] .slick-cell.l7.r9.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 2)); }); it('should expect "Lunch Break" to be moved to the right by 1 index less', () => { - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); - cy.get(`[data-row=0] > .slick-cell.l10.r12.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 10)); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should('contain', 'Lunch Break'); + cy.get(`[data-row=0] .slick-cell.l10.r12.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 10)); }); it('should expect "Development" to be moved to the right by 1 index less and a large "Development" section that spans over multiple columns & rows in the afternoon', () => { - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); - cy.get(`[data-row=1] > .slick-cell.l13.r14.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5)); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should('contain', 'Development'); + cy.get(`[data-row=1] .slick-cell.l13.r14.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5)); }); }); }); diff --git a/cypress/e2e/example-auto-header-height.cy.ts b/cypress/e2e/example-auto-header-height.cy.ts index 111acaa89..2b5a69cb3 100644 --- a/cypress/e2e/example-auto-header-height.cy.ts +++ b/cypress/e2e/example-auto-header-height.cy.ts @@ -1,4 +1,14 @@ describe('SlickGrid Auto Header Height', () => { + const headerSelector = '#myGrid .slick-header-left'; + const durationResizeHandleSelector = `${headerSelector} .slick-header-column[data-id="duration"] .slick-resizable-handle`; + + const applyPinning = () => { + cy.get('#pinnedColumn').clear().type('2'); + cy.get('#setPinnedColumn').click(); + cy.get('#pinnedRow').clear().type('5'); + cy.get('#setPinnedRow').click(); + }; + beforeEach(() => { cy.visit(`${Cypress.config('baseUrl')}/examples/example-auto-header-height.html`); cy.get('#myGrid .slick-viewport', { timeout: 1000 }).should('be.visible'); @@ -6,7 +16,7 @@ describe('SlickGrid Auto Header Height', () => { describe('Basic Functionality', () => { it('should auto-size the header when autoHeaderHeight is enabled by default', () => { - cy.get('#myGrid .slick-header-columns') + cy.get(headerSelector) .should(($el) => { expect($el[0].offsetHeight).to.be.greaterThan(35); }); @@ -16,7 +26,7 @@ describe('SlickGrid Auto Header Height', () => { cy.get('#autoHeaderHeight').uncheck(); cy.get('#setAutoHeaderHeight').click(); - cy.get('.slick-header-columns').should(($el) => { + cy.get(headerSelector).should(($el) => { const height = $el[0].offsetHeight; expect(height).to.be.within(28, 34); }); @@ -36,7 +46,7 @@ describe('SlickGrid Auto Header Height', () => { cy.get('#autoHeaderHeight').uncheck(); cy.get('#setAutoHeaderHeight').click(); - cy.get('.slick-header-columns').should(($el) => { + cy.get(headerSelector).should(($el) => { expect($el[0].offsetHeight).to.be.within(28, 34); }); @@ -44,107 +54,113 @@ describe('SlickGrid Auto Header Height', () => { cy.get('#setAutoHeaderHeight').click(); // Verify header expanded again - cy.get('.slick-header-columns').should(($el) => { + cy.get(headerSelector).should(($el) => { const height = $el[0].offsetHeight; expect(height).to.be.greaterThan(35); }); }); }); - describe('Frozen Columns & Rows Support', () => { - it('should equalize left and right header pane heights when frozen columns & rows exist', () => { - cy.get('#frozenColumn').clear().type('2'); - cy.get('#setFrozenColumn').click(); - - cy.get('#frozenRow').clear().type('5'); - cy.get('#setFrozenRow').click(); + describe('Pinned Columns & Rows Support', () => { + it('should keep all header columns at the same height when pinning is active', () => { + applyPinning(); - cy.get('.slick-header-left .slick-header-columns').then(($left) => { - cy.get('.slick-header-right .slick-header-columns').should(($right) => { - const leftHeight = $left[0].offsetHeight; - const rightHeight = $right[0].offsetHeight; - - // Heights should be equal (within 1px tolerance) - expect(Math.abs(leftHeight - rightHeight)).to.be.lessThan(2); - }); + cy.get(`${headerSelector} .slick-header-column`).should(($headers) => { + const heights = [...$headers].map((header) => header.getBoundingClientRect().height); + expect(Math.max(...heights) - Math.min(...heights)).to.be.lessThan(2); }); }); - it('should maintain correct header and container dimensions with frozen rows & columns', () => { - cy.get('#frozenColumn').clear().type('2'); - cy.get('#setFrozenColumn').click(); + it('should maintain correct header and container dimensions with pinned rows and columns', () => { + applyPinning(); - cy.get('#frozenRow').clear().type('5'); - cy.get('#setFrozenRow').click(); - - cy.get('.slick-header-columns').should(($header) => { + cy.get(headerSelector).should(($header) => { expect($header[0].offsetHeight).to.be.greaterThan(0); }); - + cy.get('#myGrid .slick-docking-overlay').should('exist'); + cy.get('#myGrid .slick-docking-horizontal-scroller').should('exist'); cy.get('#myGrid').should(($grid) => { expect($grid[0].scrollHeight).to.be.lte($grid[0].clientHeight + 1); }); }); - it('should not overflow container when frozen columns & rows are active', () => { - cy.get('#frozenColumn').clear().type('2'); - cy.get('#setFrozenColumn').click(); - - cy.get('#frozenRow').clear().type('5'); - cy.get('#setFrozenRow').click(); + it('should not overflow container when pinned columns & rows are active', () => { + applyPinning(); + cy.get(headerSelector).should(($header) => { + expect($header[0].offsetHeight).to.be.greaterThan(0); + }); cy.get('#myGrid').should(($grid) => { - const containerHeight = $grid[0].clientHeight; - const gridScrollHeight = $grid[0].scrollHeight; - - expect(gridScrollHeight).to.be.lte(containerHeight + 1); + expect($grid[0].scrollHeight).to.be.lte($grid[0].clientHeight + 1); }); }); - it('should maintain equal header heights after column resize with frozen columns & rows', () => { - cy.get('#frozenColumn').clear().type('2'); - cy.get('#setFrozenColumn').click(); - - cy.get('#frozenRow').clear().type('5'); - cy.get('#setFrozenRow').click(); - - cy.get('.slick-header-right .slick-header-columns') - .should('exist'); + it('should keep the pinned overlay inside the viewport, which clips its overflow', () => { + applyPinning(); + + cy.get('#myGrid .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const viewportRect = viewport.getBoundingClientRect(); + cy.get('#myGrid .slick-docking-overlay').should(($overlay) => { + const overlay = $overlay[0] as HTMLElement; + const overlayRect = overlay.getBoundingClientRect(); + // The overlay is a sticky layer inside the scrollport, so the native + // scrollbar paints above the pinned rows and spans the pinned sections. + expect(overlay.parentElement, 'the overlay lives inside the viewport').to.eq(viewport); + expect(getComputedStyle(overlay).position).to.eq('sticky'); + expect(overlayRect.left).to.be.closeTo(viewportRect.left, 1); + expect(overlayRect.top).to.be.closeTo(viewportRect.top, 1); + // It takes no layout height of its own, and the viewport, not a clip-path, + // is the visible overflow boundary. + expect(overlayRect.height, 'the overlay adds no layout height').to.eq(0); + expect(overlay.style.clipPath, 'no clip-path is needed').to.eq(''); + expect(getComputedStyle(viewport).overflowX, 'the viewport clips the overflow').to.eq('hidden'); + }); + // The pinned rows themselves start at the top of the viewport. + cy.get('#myGrid .slick-docking-overlay .slick-row').first().should(($row) => { + expect($row[0].getBoundingClientRect().top).to.be.closeTo(viewportRect.top, 1); + }); + }); + }); - cy.get('.slick-resizable-handle').first().trigger('mousedown', { which: 1 }); - cy.get('.slick-resizable-handle').first().trigger('mousemove', { clientX: 150, clientY: 0 }); - cy.get('.slick-resizable-handle').first().trigger('mouseup', { force: true }); + it('should maintain equal header heights after column resize with pinned columns and rows', () => { + applyPinning(); + + cy.get(durationResizeHandleSelector).then(($handle) => { + const rect = $handle[0].getBoundingClientRect(); + const pageX = rect.left + window.scrollX; + const pageY = rect.top + window.scrollY; + cy.wrap($handle) + .trigger('mousedown', { which: 1, pageX, pageY }) + .trigger('mousemove', { which: 1, pageX: pageX + 30, pageY }) + .trigger('mouseup'); + }); - // Check that heights are still equal - cy.get('.slick-header-left .slick-header-columns').then(($left) => { - cy.get('.slick-header-right .slick-header-columns').should(($right) => { - const leftHeight = $left[0].offsetHeight; - const rightHeight = $right[0].offsetHeight; - expect(Math.abs(leftHeight - rightHeight)).to.be.lessThan(2); + cy.get(`${headerSelector} .slick-header-column`).should(($headers) => { + const heights = [...$headers].map((header) => header.getBoundingClientRect().height); + expect(Math.max(...heights) - Math.min(...heights)).to.be.lessThan(2); }); - }); }); }); describe('Re-measure Triggers', () => { it('should recalculate header height on column resize end', () => { let initialHeight = 0; - cy.get('.slick-header-columns').should(($el) => { + cy.get(headerSelector).should(($el) => { initialHeight = $el[0].offsetHeight; }).then(() => { - // "Duration Days" column is at index 2, its handle is at index 1 (since column 0 has no handle) - cy.get('.slick-resizable-handle:nth(1)').then(($handle) => { + cy.get(durationResizeHandleSelector).then(($handle) => { const rect = $handle[0].getBoundingClientRect(); const pageX = rect.left + window.scrollX; const pageY = rect.top + window.scrollY; - cy.get('.slick-resizable-handle:nth(1)') + cy.wrap($handle) .trigger('mousedown', { which: 1, force: true, pageX, pageY }) .trigger('mousemove', { which: 1, force: true, pageX: pageX - 30, pageY }) .trigger('mouseup', { force: true }); }); - cy.get('.slick-header-columns').should(($el) => { + cy.get(headerSelector).should(($el) => { const newHeight = $el[0].offsetHeight; // The "Duration Days" column only has 2 words, so shrinking it doesn't force a 3rd line // The height should remain the same as the initial 2-line layout @@ -155,22 +171,21 @@ describe('SlickGrid Auto Header Height', () => { it('should recalculate header height when expanding a multi-line column to single line', () => { let initialHeight = 0; - cy.get('.slick-header-columns').should(($el) => { + cy.get(headerSelector).should(($el) => { initialHeight = $el[0].offsetHeight; }).then(() => { - // "Duration Days" column is at index 2, its handle is at index 1 (since column 0 has no handle) - cy.get('.slick-resizable-handle:nth(1)').then(($handle) => { + cy.get(durationResizeHandleSelector).then(($handle) => { const rect = $handle[0].getBoundingClientRect(); const pageX = rect.left + window.scrollX; const pageY = rect.top + window.scrollY; - cy.get('.slick-resizable-handle:nth(1)') + cy.wrap($handle) .trigger('mousedown', { which: 1, force: true, pageX, pageY }) .trigger('mousemove', { which: 1, force: true, pageX: pageX + 100, pageY }) .trigger('mouseup', { force: true }); }); - cy.get('.slick-header-columns').should(($el) => { + cy.get(headerSelector).should(($el) => { const newHeight = $el[0].offsetHeight; // Expanding the column should reduce from 2 lines to 1 line expect(newHeight).to.be.lessThan(initialHeight); @@ -202,13 +217,12 @@ describe('SlickGrid Auto Header Height', () => { it('should maintain grid functionality with autoHeaderHeight enabled', () => { const cellSelector = '.slick-row:first-child .slick-cell:first-child'; - // Frozen panes can result in multiple matching cells. cy.get(cellSelector).first().click().should('have.class', 'active'); // Scroll should still work. - cy.get('.slick-viewport-bottom').eq(1).scrollTo('bottom'); + cy.get('#myGrid .slick-viewport').scrollTo('bottom'); - cy.get('.slick-viewport-bottom').eq(1).should(($el) => { + cy.get('#myGrid .slick-viewport').should(($el) => { expect($el[0].scrollTop).to.be.greaterThan(0); }); }); diff --git a/cypress/e2e/example-auto-scroll-when-dragging.cy.ts b/cypress/e2e/example-auto-scroll-when-dragging.cy.ts index d993af622..bff597236 100644 --- a/cypress/e2e/example-auto-scroll-when-dragging.cy.ts +++ b/cypress/e2e/example-auto-scroll-when-dragging.cy.ts @@ -1,7 +1,6 @@ import { getScrollDistanceWhenDragOutsideGrid } from '../support/drag'; describe('Example - Auto scroll when dragging', { retries: 1 }, () => { - // NOTE: everywhere there's a * 2 is because we have a top+bottom (frozen rows) containers even after Unfreeze Columns/Rows const cellWidth = 80; const cellHeight = 25; const scrollbarDimension = 17; @@ -12,6 +11,38 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { fullTitles.push('Mock' + i); } + function ensurePinningEnabled() { + cy.get('#myGrid').then(($grid) => { + if (!$grid.find('.slick-header-column.slick-column-pinned-left').length) { + cy.get('#togglePinning').click(); + } + }); + } + + function ensureGroupingEnabled() { + cy.get('#myGrid').then(($grid) => { + if (!$grid.find('.slick-group').length) { + cy.get('#toggleGroup').click(); + } + }); + } + + function clearPinning() { + cy.get('#myGrid').then(($grid) => { + if ($grid.find('.slick-header-column.slick-column-pinned-left').length) { + cy.get('#togglePinning').click(); + } + }); + } + + function clearGrouping() { + cy.get('#myGrid').then(($grid) => { + if ($grid.find('.slick-group').length) { + cy.get('#toggleGroup').click(); + } + }); + } + beforeEach(() => { // add a serve mode to avoid adding the GitHub Stars link since that can slowdown Cypress considerably // because it keeps waiting for it to load, we also preserve the cookie for all other tests @@ -26,14 +57,11 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { }); it('should have exact column titles on grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - cy.get('#myGrid2') - .find('.slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + [ '#myGrid', '#myGrid2' ].forEach((selector) => { + cy.get(`${selector} .slick-header-column .slick-column-name`).then(($headers) => { + expect([...$headers].map((header) => header.textContent?.trim())).to.deep.equal(fullTitles); + }); + }); }); it('should select border shown in cell selection model, and hidden in row selection model when dragging', { scrollBehavior: false }, function () { @@ -56,14 +84,21 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { .dragCell(5, 1) .dragEnd('#myGrid2'); cy.get('#myGrid2 .slick-range-decorator').should('not.be.exist'); - cy.get('#myGrid2 .slick-row:nth-child(-n+6)') - .children(':not(.cell-unselectable)') - .each(($child) => expect($child.attr('class')).to.include('selected')); + // Row selection applies `selected` to every selectable cell in each selected + // row, so the cell count is the number of selected rows multiplied by the + // number of selectable columns. Assert the selected row identities instead. + cy.get('#myGrid2 .slick-row[data-row]').then(($rows) => { + const selectedRows = [...$rows] + .filter((row) => row.querySelector('.slick-cell.selected')) + .map((row) => row.getAttribute('data-row')); + expect(selectedRows).to.have.length(6); + expect(selectedRows).to.include.members(['0', '1', '2', '3', '4', '5']); + }); }); function testScroll() { - return getScrollDistanceWhenDragOutsideGrid('#myGrid', 'topLeft', 'right', 0, 1).then(cellScrollDistance => { - return getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'topLeft', 'bottom', 0, 1).then(rowScrollDistance => { + return getScrollDistanceWhenDragOutsideGrid('#myGrid', 'topLeft', 'right', 0, 1).then((cellScrollDistance: any) => { + return getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'topLeft', 'bottom', 0, 1).then((rowScrollDistance: any) => { return cy.wrap({ cell: { scrollBefore: cellScrollDistance.scrollLeftBefore, @@ -79,7 +114,7 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { } it('should auto scroll take effect to display the selecting element when dragging', { scrollBehavior: false }, function () { - testScroll().then(scrollDistance => { + testScroll().then((scrollDistance: any) => { expect(scrollDistance.cell.scrollBefore).to.be.lessThan(scrollDistance.cell.scrollAfter); expect(scrollDistance.row.scrollBefore).to.be.lessThan(scrollDistance.row.scrollAfter); }); @@ -87,7 +122,7 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { cy.get('#isAutoScroll').click(); cy.get('#setOptions').click(); - testScroll().then(scrollDistance => { + testScroll().then((scrollDistance: any) => { expect(scrollDistance.cell.scrollBefore).to.be.equal(scrollDistance.cell.scrollAfter); expect(scrollDistance.row.scrollBefore).to.be.equal(scrollDistance.row.scrollAfter); }); @@ -96,31 +131,31 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { cy.get('#isAutoScroll').should('have.value', 'on'); }); - function getIntervalUntilRow16Displayed(selector, px) { - const viewportSelector = (selector + ' .slick-viewport:first'); + function getIntervalUntilRow16Displayed(selector: string, px: number) { + const viewportSelector = `${selector} .slick-vertical-scroller`; cy.getNthCell(0, 1, '', { parentSelector: selector, rowHeight: cellHeight }) .dragStart(); - return cy.get(viewportSelector).invoke('scrollTop').then(scrollBefore => { - cy.dragOutside('bottom', 0, px, { parentSelector: selector, rowHeight: cellHeight }); - - const start = performance.now(); - cy.get(selector + ' .slick-row:not(.slick-group) >.cell-unselectable') - .contains('16', { timeout: 10000 }) // actually #15 will be selected - .should('not.be.hidden'); - - return cy.get(viewportSelector).invoke('scrollTop').then(scrollAfter => { - cy.dragEnd(selector); - const interval = performance.now() - start; - expect(scrollBefore).to.be.lessThan(scrollAfter); - cy.get(viewportSelector).scrollTo(0, 0, { ensureScrollable: false }); - return cy.wrap(interval); + return cy.get(viewportSelector).invoke('scrollTop').then((scrollBefore: any) => { + return cy.dragOutside('bottom', 0, px, { parentSelector: selector, rowHeight: cellHeight }).then(() => { + const start = performance.now(); + cy.get(selector + ' .slick-row:not(.slick-group) .cell-unselectable') + .contains('16', { timeout: 10000 }) // actually #15 will be selected + .should('not.be.hidden'); + return cy.get(viewportSelector).invoke('scrollTop').then((scrollAfter: any) => { + return cy.dragEnd(selector).then(() => { + const interval = performance.now() - start; + expect(scrollBefore).to.be.lessThan(scrollAfter); + cy.get(viewportSelector).scrollTo(0, 0, { ensureScrollable: false }); + return cy.wrap(interval); + }); + }); }); }); } - function testInterval(px) { - return getIntervalUntilRow16Displayed('#myGrid', px).then(intervalCell => { - return getIntervalUntilRow16Displayed('#myGrid2', px).then(intervalRow => { + function testInterval(px: number) { + return getIntervalUntilRow16Displayed('#myGrid', px).then((intervalCell: any) => { + return getIntervalUntilRow16Displayed('#myGrid2', px).then((intervalRow: any) => { return cy.wrap({ cell: intervalCell, row: intervalRow @@ -198,100 +233,124 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { }); }); - it('should have a frozen grid with 4 containers with 2 columns on the left and 3 rows on the top after click Set/Clear Frozen button', () => { - cy.get('#myGrid div.slick-row[style*="top: 0px"]').should('have.length', 1); - cy.get('#myGrid2 div.slick-row[style*="top: 0px"]').should('have.length', 1); + it('should pin columns and rows after clicking Set/Clear Pinning', () => { + [ '#myGrid', '#myGrid2' ].forEach((selector) => { + cy.get(`${selector} .slick-docking-overlay`).should('not.exist'); + }); - cy.get('#toggleFrozen').click(); + cy.get('#togglePinning').click(); - cy.get('#myGrid div.slick-row[style*="top: 0px"]').should('have.length', 2 * 2); - cy.get('#myGrid2 div.slick-row[style*="top: 0px"]').should('have.length', 2 * 2); - cy.get('#myGrid .grid-canvas-left > [style*="top: 0px"]').children().should('have.length', 2 * 2); - cy.get('#myGrid2 .grid-canvas-left > [style*="top: 0px"]').children().should('have.length', 2 * 2); - cy.get('#myGrid .grid-canvas-top').children().should('have.length', 3 * 2); - cy.get('#myGrid2 .grid-canvas-top').children().should('have.length', 3 * 2); + [ '#myGrid', '#myGrid2' ].forEach((selector) => { + cy.get(`${selector} .slick-docking-overlay`).should('exist'); + cy.get(`${selector} .slick-docking-overlay .slick-row[data-row="0"]`).should('exist'); + cy.get(`${selector} .slick-header-column.slick-column-pinned-left`).should('have.length', 2); + }); }); - function resetScrollInFrozen() { - cy.get('#myGrid .slick-viewport:last').scrollTo(0, 0); - cy.get('#myGrid2 .slick-viewport:last').scrollTo(0, 0); + function resetScrollInPinned() { + [ '#myGrid', '#myGrid2' ].forEach((selector) => { + cy.get(`${selector} .slick-horizontal-scroller`).scrollTo(0, 0, { ensureScrollable: false }); + cy.get(`${selector} .slick-vertical-scroller`).scrollTo(0, 0, { ensureScrollable: false }); + }); } - it('should auto scroll to display the selecting element when dragging in frozen grid', { scrollBehavior: false }, () => { + it('should auto scroll to display the selecting element when dragging in pinned grid', { scrollBehavior: false }, () => { + ensurePinningEnabled(); + // top left - to bottomRight - getScrollDistanceWhenDragOutsideGrid('#myGrid', 'topLeft', 'bottomRight', 0, 1).then(result => { - expect(result.scrollTopBefore).to.be.equal(result.scrollTopAfter); - expect(result.scrollLeftBefore).to.be.equal(result.scrollLeftAfter); + getScrollDistanceWhenDragOutsideGrid('#myGrid', 'topLeft', 'bottomRight', 0, 1).then((result: any) => { + expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); + expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); - getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'topLeft', 'bottomRight', 0, 1).then(result => { - expect(result.scrollTopBefore).to.be.equal(result.scrollTopAfter); - expect(result.scrollLeftBefore).to.be.equal(result.scrollLeftAfter); + getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'topLeft', 'bottomRight', 0, 1).then((result: any) => { + expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); + expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); + // The unified docking canvas has no separate top-right pane. Start from + // Duration (the first selectable center column), not column 0, which is + // the non-selectable pinned row-number cell. // top right - to bottomRight - getScrollDistanceWhenDragOutsideGrid('#myGrid', 'topRight', 'bottomRight', 0, 0).then(result => { - expect(result.scrollTopBefore).to.be.equal(result.scrollTopAfter); + getScrollDistanceWhenDragOutsideGrid('#myGrid', 'topRight', 'bottomRight', 0, 2).then((result: any) => { + expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); - getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'topRight', 'bottomRight', 0, 0).then(result => { - expect(result.scrollTopBefore).to.be.equal(result.scrollTopAfter); + getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'topRight', 'bottomRight', 0, 2).then((result: any) => { + expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); - resetScrollInFrozen(); + resetScrollInPinned(); // bottom left - to bottomRight - getScrollDistanceWhenDragOutsideGrid('#myGrid', 'bottomLeft', 'bottomRight', 0, 1).then(result => { + getScrollDistanceWhenDragOutsideGrid('#myGrid', 'bottomLeft', 'bottomRight', 0, 1).then((result: any) => { expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); - expect(result.scrollLeftBefore).to.be.equal(result.scrollLeftAfter); + expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); - getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'bottomLeft', 'bottomRight', 0, 1).then(result => { + getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'bottomLeft', 'bottomRight', 0, 1).then((result: any) => { expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); - expect(result.scrollLeftBefore).to.be.equal(result.scrollLeftAfter); + expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); - resetScrollInFrozen(); + resetScrollInPinned(); // bottom right - to bottomRight - getScrollDistanceWhenDragOutsideGrid('#myGrid', 'bottomRight', 'bottomRight', 0, 0).then(result => { + getScrollDistanceWhenDragOutsideGrid('#myGrid', 'bottomRight', 'bottomRight', 0, 2).then((result: any) => { expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); - getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'bottomRight', 'bottomRight', 0, 0).then(result => { + getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'bottomRight', 'bottomRight', 0, 2).then((result: any) => { expect(result.scrollTopBefore).to.be.lessThan(result.scrollTopAfter); expect(result.scrollLeftBefore).to.be.lessThan(result.scrollLeftAfter); }); - resetScrollInFrozen(); - cy.get('#myGrid .slick-viewport-bottom.slick-viewport-right').scrollTo(cellWidth * 3, cellHeight * 3); - cy.get('#myGrid2 .slick-viewport-bottom.slick-viewport-right').scrollTo(cellWidth * 3, cellHeight * 3); + resetScrollInPinned(); + cy.get('#myGrid .slick-horizontal-scroller').scrollTo(cellWidth * 3, 0); + cy.get('#myGrid .slick-vertical-scroller').scrollTo(0, cellHeight * 3); + cy.get('#myGrid2 .slick-horizontal-scroller').scrollTo(cellWidth * 3, 0); + cy.get('#myGrid2 .slick-vertical-scroller').scrollTo(0, cellHeight * 3); // bottom right - to topLeft - getScrollDistanceWhenDragOutsideGrid('#myGrid', 'bottomRight', 'topLeft', 8, 4, 100).then(result => { + getScrollDistanceWhenDragOutsideGrid('#myGrid', 'bottomRight', 'topLeft', 8, 4, 100).then((result: any) => { expect(result.scrollTopBefore).to.be.greaterThan(result.scrollTopAfter); expect(result.scrollLeftBefore).to.be.greaterThan(result.scrollLeftAfter); }); - getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'bottomRight', 'topLeft', 8, 4, 100).then(result => { + getScrollDistanceWhenDragOutsideGrid('#myGrid2', 'bottomRight', 'topLeft', 8, 4, 100).then((result: any) => { expect(result.scrollTopBefore).to.be.greaterThan(result.scrollTopAfter); expect(result.scrollLeftBefore).to.be.greaterThan(result.scrollLeftAfter); }); - resetScrollInFrozen(); + resetScrollInPinned(); }); - it('should have a frozen & grouping by Duration grid after click Set/Clear grouping by Duration button', { scrollBehavior: false }, () => { - cy.get('#toggleGroup').trigger('click'); - cy.get('#myGrid div.slick-row[style*="top: 0px;"]').should('have.length', 2 * 2); - cy.get('#myGrid2 div.slick-row[style*="top: 0px;"]').should('have.length', 2 * 2); - cy.get('#myGrid .grid-canvas-top.grid-canvas-left').contains('Duration'); - cy.get('#myGrid2 .grid-canvas-top.grid-canvas-left').contains('Duration'); + it('should have a pinned & grouping by Duration grid after click Set/Clear grouping by Duration button', { scrollBehavior: false }, () => { + ensurePinningEnabled(); + ensureGroupingEnabled(); + cy.get('#myGrid .slick-group').contains('Duration'); + cy.get('#myGrid2 .slick-group').contains('Duration'); }); - function testDragInGrouping(selector) { - cy.getNthCell(7, 0, 'bottomRight', { parentSelector: selector, rowHeight: cellHeight }) + function testDragInGrouping(selector: string) { + // In the old bottom-right layout, nth column 0 resolved past the pinned + // control column. The unified canvas exposes that non-selectable control + // column as index 0, so use Duration (index 2) in a data row instead. + cy.getNthCell(7, 2, 'bottomRight', { parentSelector: selector, rowHeight: cellHeight }) .dragStart(); - cy.get(selector + ' .slick-viewport:last').as('viewport').invoke('scrollTop').then(scrollBefore => { - cy.dragOutside('bottom', 400, 300, { parentSelector: selector, rowHeight: cellHeight }); - cy.get('@viewport').invoke('scrollTop').then(scrollAfter => { - expect(scrollBefore).to.be.lessThan(scrollAfter); - cy.dragEnd(selector); - cy.get(selector + ' [style*="top: 350px;"].slick-group').should('not.be.hidden');; + return cy.get(selector + ' .slick-vertical-scroller').as('viewport').invoke('scrollTop').then(scrollBefore => { + return cy.dragOutside('bottom', 400, 300, { parentSelector: selector, rowHeight: cellHeight }).then(() => { + return cy.get('@viewport').invoke('scrollTop').then(scrollAfter => { + expect(scrollBefore).to.be.lessThan(scrollAfter); + return cy.dragEnd(selector).then(() => { + // Cypress's visibility check reports rows in the zero-height sticky overlay as hidden + // even while they are drawn, so compare each group row's box with the viewport's. + cy.get('@viewport').then(($viewport) => { + const viewport = $viewport[0].getBoundingClientRect(); + cy.get(selector + ' .slick-group').should(($groups) => { + const drawn = $groups.toArray().some((group) => { + const rect = group.getBoundingClientRect(); + return rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom; + }); + expect(drawn, 'a group row is drawn inside the viewport').to.eq(true); + }); + }); + }); + }); }); }); } @@ -301,37 +360,38 @@ describe('Example - Auto scroll when dragging', { retries: 1 }, () => { testDragInGrouping('#myGrid2'); }); - it('should reset to default grid when click Set/Clear Frozen button and Set/Clear grouping button', () => { - cy.get('#toggleFrozen').trigger('click'); - cy.get('#toggleGroup').trigger('click'); - cy.get('#myGrid div.slick-row[style*="top: 0px;"]').should('have.length', 1); - cy.get('#myGrid2 div.slick-row[style*="top: 0px;"]').should('have.length', 1); + it('should reset to default grid when clearing pinning and grouping', () => { + clearPinning(); + clearGrouping(); + cy.get('#myGrid .slick-docking-overlay .slick-row').should('not.exist'); + cy.get('#myGrid2 .slick-docking-overlay .slick-row').should('not.exist'); + cy.get('#myGrid .slick-header-column.slick-column-pinned-left').should('not.exist'); + cy.get('#myGrid2 .slick-header-column.slick-column-pinned-left').should('not.exist'); }); - describe('Frozen Columns', () => { - it('should set 3 frozen columns in first grid', () => { - cy.get('[data-test="frozen-column-count"]').clear().type('3'); - cy.get('[data-test="set-frozen-columns-btn"]').click(); + describe('Pinned Columns', () => { + it('should set 3 pinned columns in first grid', () => { + cy.get('#pinned-column-boundary').clear().type('3'); + cy.get('[data-test="set-pinned-columns-btn"]').click(); - cy.get('#myGrid .slick-pane-left .slick-header-column').should('have.length', 4); - cy.get('#myGrid .slick-pane-right .slick-header-column').should('have.length', 34); + cy.get('#myGrid .slick-header-column.slick-column-pinned-left').should('have.length', 4); + cy.get('#myGrid .slick-header-column:not(.slick-column-pinned-left)').should('have.length', 34); }); - it('should try to set frozen columns wider than possible and expect an error and abort of the execution', () => { + it('should reject a pinned-column boundary wider than the available grid', () => { const stub = cy.stub(); cy.on('window:alert', stub); - cy.get('[data-test="frozen-column-count"]').clear().type('12'); - cy.get('[data-test="set-frozen-columns-btn"]') + cy.get('#pinned-column-boundary').clear().type('12'); + cy.get('[data-test="set-pinned-columns-btn"]') .click() .then(() => { expect(stub.getCall(0)).to.be.calledWith( - '[SlickGrid] You are trying to freeze/pin more columns than the grid can support. ' + - 'Make sure to have less columns pinned (on the left) than the actual visible grid width.' + '[SlickGrid] Cannot pin these columns because they exceed the available grid width.' ); // it should still have previous pinning - cy.get('#myGrid .slick-pane-left .slick-header-column').should('have.length', 4); - cy.get('#myGrid .slick-pane-right .slick-header-column').should('have.length', 34); + cy.get('#myGrid .slick-header-column.slick-column-pinned-left').should('have.length', 4); + cy.get('#myGrid .slick-header-column:not(.slick-column-pinned-left)').should('have.length', 34); }); }); }); diff --git a/cypress/e2e/example-colspan.cy.ts b/cypress/e2e/example-colspan.cy.ts index 1f71fd53c..7b306ce5a 100644 --- a/cypress/e2e/example-colspan.cy.ts +++ b/cypress/e2e/example-colspan.cy.ts @@ -1,5 +1,9 @@ +import { createMouseLikeEvent } from '../support/drag'; + describe('Example - Column Span & Header Grouping', { retries: 1 }, () => { + const fullPreTitles = ['Common Factor', 'Period', 'Analysis']; const fullTitles = ['Title', 'Duration', 'Start', 'Finish', '% Complete', 'Effort Driven']; + for (let i = 0; i < 30; i++) { fullTitles.push(`Mock${i}`); } @@ -10,9 +14,12 @@ describe('Example - Column Span & Header Grouping', { retries: 1 }, () => { cy.get('h2 + ul > li').first().contains('column span'); }); - it('should have exact column titles', () => { - cy.get('#myGrid') - .find('.slick-header-columns') + it('should have exact Column Pre-Header & Column Header Titles in the grid', () => { + cy.get('.slick-header-columns:nth(0)') + .children('.slick-header-column') + .each(($child, index) => expect($child.text()).to.eq(fullPreTitles[index])); + + cy.get('.slick-header-columns:nth(1)') .children() .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); @@ -47,7 +54,7 @@ describe('Example - Column Span & Header Grouping', { retries: 1 }, () => { it('should hide Finish while keeping it in getColumns and preserve the original colspan indexes', () => { cy.get('[data-test="hide-finish-column"]').click(); - cy.get('#myGrid .slick-header-column').should('have.length', 5); + cy.get('#myGrid .slick-header-columns-left > .slick-header-column').should('have.length', 5); cy.get('[data-row=1] > .slick-cell.l1.r3').should('contain', '5 days'); cy.get('[data-row=1] > .slick-cell.l4.r4').contains(/\d+$/); cy.get('[data-row=1] > .slick-cell.l5.r5').contains(/(true|false)/); @@ -63,20 +70,33 @@ describe('Example - Column Span & Header Grouping', { retries: 1 }, () => { }); describe('Basic Key Navigations', () => { - it('should start at Task 1 on Duration colspan 5 days and type "PageDown" key once and be on Task 20 with full colspan', () => { + const colspanCellSelector = (row: number) => row % 2 === 0 ? '.slick-cell.l0.r5' : '.slick-cell.l1.r3'; + + const getPageRow = (pageCount: number) => cy.window().then((win) => { + const grid = (win as any).grid; + return 1 + pageCount * grid.numVisibleRows; + }); + + it('should move one computed page down from Task 1 and preserve the target row colspan', () => { cy.get('[data-row=1] > .slick-cell.l1.r3').as('active_cell').click(); cy.get('@active_cell').type('{pagedown}'); - cy.get('[data-row=20] > .slick-cell.l0.r5.active').should('have.length', 1); + getPageRow(1).then((expectedRow) => { + cy.get(`[data-row=${expectedRow}] > ${colspanCellSelector(expectedRow)}.active`).should('have.length', 1); + }); }); - it('should start at Task 1 on Duration colspan 5 days and type "PageDown" key 2x times and be on Task 39 with colspan of 3', () => { + it('should move two computed pages down from Task 1 and preserve the target row colspan', () => { cy.get('[data-row=1] > .slick-cell.l1.r3').as('active_cell').click(); cy.get('@active_cell').type('{pagedown}{pagedown}'); - cy.get('[data-row=39] > .slick-cell.l1.r3.active').should('have.length', 1); + getPageRow(2).then((expectedRow) => { + cy.get(`[data-row=${expectedRow}] > ${colspanCellSelector(expectedRow)}.active`).should('have.length', 1); + }); }); - it('should start at Task 39 on Duration colspan 5 days and type "PageUp" key 2x times and be on Task 1 with full colspan', () => { - cy.get('[data-row=39] > .slick-cell.l1.r3').as('active_cell').click(); + it('should move two computed pages up and return to Task 1', () => { + getPageRow(2).then((startRow) => { + cy.get(`[data-row=${startRow}] > ${colspanCellSelector(startRow)}`).as('active_cell').click(); + }); cy.get('@active_cell').type('{pageup}{pageup}'); cy.get('[data-row=1] > .slick-cell.l1.r3.active').should('have.length', 1); }); @@ -111,4 +131,184 @@ describe('Example - Column Span & Header Grouping', { retries: 1 }, () => { cy.get('[data-row=1] > .slick-cell.l1.r3.active').should('have.length', 1); }); }); + + describe('Pinned colspan rendering', () => { + const hostSelector = + '[data-row=1] > .slick-pinned-left-cells > .slick-cell-colspan-crossing-docking:not(.slick-cell-colspan-part)'; + const fragmentSelector = '[data-row=1] > .slick-scrolling-cells > .slick-cell-colspan-part'; + + const applyPinning = () => { + cy.get('#pinnedLeftColumns').clear().type('1'); + cy.get('#setPinning').click(); + }; + + it('should clip a colspan host to its own band and continue it in the next one', () => { + cy.reload(); + applyPinning(); + + // The host stops at the pinned edge instead of painting across the scrolling band. + cy.get(hostSelector) + .should('exist') + .then(($host) => { + const host = $host[0].getBoundingClientRect(); + const leftRegion = $host[0].parentElement!.getBoundingClientRect(); + expect(host.right, 'host is clipped to the pinned band').to.be.at.most(leftRegion.right + 1); + }); + + cy.get(fragmentSelector).should('have.length', 1); + + // The continuation picks up exactly where the host stops, and carries a copy of the + // content shifted by what the host already showed, so the text reads as one cell. + cy.get(hostSelector).then(($host) => { + const host = $host[0].getBoundingClientRect(); + cy.get(fragmentSelector).then(($fragment) => { + const fragment = $fragment[0].getBoundingClientRect(); + expect(fragment.left, 'continuation starts at the host edge').to.be.closeTo(host.right, 1.5); + + const content = $fragment[0].querySelector('.slick-cell-colspan-part-content') as HTMLElement; + expect(content, 'continuation carries a copy of the content').to.exist; + expect(content.textContent).to.eq($host[0].textContent); + // The copy starts where the host's own text starts, so the glyphs line up + // across the boundary rather than restarting. + const hostTextLeft = host.left + parseFloat(getComputedStyle($host[0]).paddingLeft); + expect(content.getBoundingClientRect().left, 'the copy is aligned with the host text').to.be.closeTo( + hostTextLeft, + 1.5 + ); + }); + }); + }); + + it('should not cover the scrolling columns with a colspan host', () => { + cy.reload(); + applyPinning(); + + cy.get('#myGrid .slick-docking-horizontal-scroller').scrollTo(260, 0, { ensureScrollable: false }); + + // Every cell of the scrolling band stays hit-testable: nothing from the pinned band + // is painted on top of it. + cy.get('[data-row=0] > .slick-scrolling-cells > .slick-cell') + .filter(':visible') + .last() + .then(($cell) => { + const rect = $cell[0].getBoundingClientRect(); + const topmost = $cell[0].ownerDocument.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + expect($cell[0].contains(topmost) || topmost === $cell[0], 'scrolling cell is on top').to.eq(true); + }); + }); + + it('should apply and clear the selection class on the colspan fragment together with its host', () => { + cy.reload(); + applyPinning(); + + // The fragment is an aria-hidden presentational continuation rendered behind its host + // cell, so it is deliberately not actionable on its own. + cy.get(fragmentSelector).click({ scrollBehavior: false }); + cy.get(hostSelector).should('have.class', 'selected'); + cy.get(fragmentSelector).should('have.class', 'selected'); + + cy.get('[data-row=3] > .slick-scrolling-cells > .slick-cell.l4').click(); + cy.get(hostSelector).should('not.have.class', 'selected'); + cy.get(fragmentSelector).should('not.have.class', 'selected'); + }); + + it('should keep the active colspan background continuous after resizing Start', () => { + cy.reload(); + applyPinning(); + + // The fragment is an aria-hidden presentational continuation rendered behind its host + // cell, so it is deliberately not actionable on its own. + cy.get(fragmentSelector).click({ scrollBehavior: false }); + cy.get(hostSelector).should('have.class', 'active'); + cy.get(fragmentSelector).should('have.class', 'active').then(($fragment) => { + const fragment = $fragment[0]; + const initialWidth = fragment.getBoundingClientRect().width; + const fragmentActiveStyle = getComputedStyle(fragment, '::after'); + expect(getComputedStyle(fragment).boxShadow).to.eq('none'); + expect(fragmentActiveStyle.borderLeftStyle).to.eq('none'); + expect(fragmentActiveStyle.borderRightStyle).to.eq('solid'); + + cy.get(hostSelector).should(($host) => { + const hostActiveStyle = getComputedStyle($host[0], '::after'); + expect(getComputedStyle($host[0]).boxShadow).to.eq('none'); + expect(hostActiveStyle.borderLeftStyle).to.eq('solid'); + // The edge the host shares with its continuation is not drawn, so the outline + // reads as one cell rather than two boxes meeting at the pinned boundary. + expect(hostActiveStyle.borderRightStyle).to.eq('none'); + }); + + cy.window().then((win) => { + const grid = (win as any).grid; + const header = grid.getHeaderColumn('start') as HTMLElement; + const handle = header.querySelector('.slick-resizable-handle') as HTMLElement; + const handleRect = handle.getBoundingClientRect(); + const startX = handleRect.left + handleRect.width / 2; + const y = handleRect.top + handleRect.height / 2; + const targetX = startX + 150; + + handle.dispatchEvent(createMouseLikeEvent(win, 'mousedown', startX, y)); + win.document.body.dispatchEvent(createMouseLikeEvent(win, 'mousemove', targetX, y)); + win.document.body.dispatchEvent(createMouseLikeEvent(win, 'mouseup', targetX, y, 0)); + }); + + cy.get(hostSelector).should('have.class', 'active'); + cy.get(fragmentSelector) + .should('have.class', 'active') + .should(($resizedFragment) => { + expect($resizedFragment[0].getBoundingClientRect().width).to.be.greaterThan(initialWidth + 100); + }); + }); + }); + + it('should not draw a column separator where the span crosses the pinned boundary', () => { + cy.reload(); + applyPinning(); + + // This example uses the alpine theme, which draws no cell separator at all, so add + // the stock one back to see what a themed grid would show at the boundary. + cy.document().then((doc) => { + const style = doc.createElement('style'); + style.textContent = '.slick-cell { border-right: 1px dotted silver; }'; + doc.head.appendChild(style); + }); + + cy.get(hostSelector).should(($host) => { + const host = getComputedStyle($host[0]); + expect(host.borderRightWidth, 'the separator keeps its width, so nothing moves').to.eq('1px'); + expect(host.borderRightColor, 'the shared edge is not painted').to.eq('rgba(0, 0, 0, 0)'); + }); + + // The far end of the span and an ordinary cell both keep the separator. + cy.get(fragmentSelector).should(($fragment) => { + expect(getComputedStyle($fragment[0]).borderRightColor).to.eq('rgb(192, 192, 192)'); + }); + cy.get('[data-row=1] > .slick-scrolling-cells > .slick-cell.l4').should(($cell) => { + expect(getComputedStyle($cell[0]).borderRightColor).to.eq('rgb(192, 192, 192)'); + }); + }); + + it('should report one cell for a click anywhere on a span that crosses the boundary', () => { + cy.reload(); + applyPinning(); + + cy.window().then((win: any) => { + const clicks: Array<{ row: number; cell: number }> = []; + win.grid.onClick.subscribe((_e: any, args: any) => clicks.push({ row: args.row, cell: args.cell })); + win.__spanClicks = clicks; + }); + + // Both halves of the span belong to the same cell, whichever side is clicked. + cy.get(fragmentSelector).click({ scrollBehavior: false }); + cy.get(hostSelector).click({ scrollBehavior: false }); + + cy.window().should((win: any) => { + expect(win.__spanClicks).to.have.length(2); + expect(win.__spanClicks[0]).to.deep.eq({ row: 1, cell: 1 }); + expect(win.__spanClicks[1]).to.deep.eq({ row: 1, cell: 1 }); + }); + cy.window().should((win: any) => { + expect(win.grid.getActiveCell()).to.include({ row: 1, cell: 1 }); + }); + }); + }); }); diff --git a/cypress/e2e/example-frozen-columns-and-column-group.cy.ts b/cypress/e2e/example-frozen-columns-and-column-group.cy.ts deleted file mode 100644 index d25cb4e5b..000000000 --- a/cypress/e2e/example-frozen-columns-and-column-group.cy.ts +++ /dev/null @@ -1,181 +0,0 @@ -describe('Example - Row Grouping Titles', () => { - const fullPreTitles = ['', 'Common Factor', 'Period', 'Analysis']; - const fullTitles = ['#', 'Title', 'Duration', 'Start', 'Finish', '% Complete', 'Effort Driven']; - - it('should display Example Frozen Columns & Column Group', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns-and-column-group.html`); - cy.get('h2').should('contain', 'Demonstrates:'); - cy.contains('Frozen columns with extra header row grouping columns into categories'); - }); - - it('should have exact Column Pre-Header & Column Header Titles in the grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns:nth(0)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullPreTitles[index])); - - cy.get('#myGrid') - .find('.slick-header-columns:nth(1)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should have a frozen grid on page load with 3 columns on the left and 4 columns on the right', () => { - cy.get('div.slick-row[style*="top: 0px;"]').should('have.length', 2); - cy.get('.grid-canvas-left > [style*="top: 0px;"]').children().should('have.length', 3); - cy.get('.grid-canvas-right > [style*="top: 0px;"]').children().should('have.length', 4); - - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', '0'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '5 days'); - - cy.get('.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/05/2009'); - }); - - it('should have exact Column Pre-Header & Column Header Titles in the grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns:nth(0)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullPreTitles[index])); - - cy.get('#myGrid') - .find('.slick-header-columns:nth(1)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should click on the "Remove Frozen Columns" button to switch to a regular grid without frozen columns and expect 7 columns on the left container', () => { - cy.get('[data-test="remove-frozen-btn"]') - .contains('Remove Frozen Columns') - .click({ force: true }); - - cy.get('div.slick-row[style*="top: 0px;"]').should('have.length', 1); - cy.get('.grid-canvas-left > [style*="top: 0px;"]').children().should('have.length', 7); - - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', '0'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '5 days'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(3)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(4)').should('contain', '01/05/2009'); - }); - - it('should have exact Column Pre-Header & Column Header Titles in the grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns:nth(0)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullPreTitles[index])); - - cy.get('#myGrid') - .find('.slick-header-columns:nth(1)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should click on the "Set 3 Frozen Columns" button to switch frozen columns grid and expect 3 frozen columns on the left and 4 columns on the right', () => { - cy.get('[data-test="set-frozen-btn"]') - .contains('Set 3 Frozen Columns') - .click({ force: true }); - - cy.get('div.slick-row[style*="top: 0px;"]').should('have.length', 2); - cy.get('.grid-canvas-left > [style*="top: 0px;"]').children().should('have.length', 3); - cy.get('.grid-canvas-right > [style*="top: 0px;"]').children().should('have.length', 4); - - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', '0'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - cy.get('.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '5 days'); - - cy.get('.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/05/2009'); - }); - - it('should have exact Column Pre-Header & Column Header Titles in the grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns:nth(0)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullPreTitles[index])); - - cy.get('#myGrid') - .find('.slick-header-columns:nth(1)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should be able to call column picker from the pre-header', () => { - const fullPreTitlesWithoutId = ['Common Factor', 'Period', 'Analysis']; - const fullTitlesWithoutId = ['Title', 'Duration', 'Start', 'Finish', '% Complete', 'Effort Driven']; - const fullTitlesWithGroup = ['#', 'Common Factor - Title', 'Common Factor - Duration', 'Period - Start', 'Period - Finish', 'Analysis - % Complete', 'Analysis - Effort Driven']; - - cy.get('#myGrid') - .find('.slick-preheader-panel .slick-header-column:nth(1)') - .trigger('mouseover') - .trigger('contextmenu') - .invoke('show'); - - cy.get('.slick-columnpicker') - .find('.slick-columnpicker-list') - .children() - .each(($child, index) => { - if (index <= 6) { - expect($child.text()).to.eq(fullTitlesWithGroup[index]); - } - }); - - cy.get('.slick-columnpicker') - .find('.slick-columnpicker-list') - .children('li:nth-child(1)') - .children('label') - .should('contain', '#') - .click(); - - cy.get('.slick-columnpicker > button.close > .close').click(); - - cy.get('#myGrid') - .find('.slick-preheader-panel .slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullPreTitlesWithoutId[index])); - - cy.get('#myGrid') - .find('.slick-header:not(.slick-preheader-panel) .slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitlesWithoutId[index])); - }); - - it('should scroll to the bottom of the grid and expect last row to contain Task 49999', () => { - cy.get('#myGrid') - .find('.slick-viewport-top.slick-viewport-right') - .scrollTo('bottom') - .wait(10); - - cy.get(`#myGrid [data-row="49999"] > .slick-cell:nth(0)`) - .should('have.text', 'Task 49999'); - }); - - it('should open Column Picker then hide "Finish" column and still expect all headers shown', () => { - const preHeaderTitles = ['Common Factor', 'Period', 'Analysis']; - const headerTitles = ['Title', 'Duration', 'Start', '% Complete', 'Effort Driven']; - - cy.get('.slick-header:not(.slick-preheader-panel).slick-header-right .slick-header-columns') - .find('.slick-header-column:nth(2)') - .trigger('mouseover') - .trigger('contextmenu') - .invoke('show'); - - cy.get('.slick-columnpicker') - .find('.slick-columnpicker-list') - .children('li:visible:nth-child(5)') - .children('label') - .should('contain', 'Period - Finish') - .click(); - - cy.get('.slick-columnpicker button.close').click(); - - cy.get('.slick-preheader-panel .slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(preHeaderTitles[index])); - - cy.get('.slick-header:not(.slick-preheader-panel) .slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(headerTitles[index])); - }); -}); diff --git a/cypress/e2e/example-frozen-columns-and-rows-spreadsheet.cy.ts b/cypress/e2e/example-frozen-columns-and-rows-spreadsheet.cy.ts deleted file mode 100644 index 5c73946e7..000000000 --- a/cypress/e2e/example-frozen-columns-and-rows-spreadsheet.cy.ts +++ /dev/null @@ -1,86 +0,0 @@ -describe('Example - Spreadsheet and Cell Selection', { retries: 0 }, () => { - const GRID_ROW_HEIGHT = 25; - const titles = [ - '', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', - 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', - 'Z', 'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK' - ]; - - it('should load Example', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns-and-rows-spreadsheet.html`); - }); - - it('should have exact column titles on grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns') - .children() - .each(($child, index) => { - if (index < titles.length) { - expect($child.text()).to.eq(titles[index]); - } - }); - }); - - it('should click on cell B5 (top left canvas) and ArrowUp 1 times and ArrowDown 3 time and expect cell selection B5-B8 (from top left canvas to bottom left canvas', () => { - cy.get(`.grid-canvas-top.grid-canvas-left .slick-row[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l2.r2`) - .as('cell_B5') - .click(); - - cy.get('@cell_B5') - .type('{shift}{uparrow}{downarrow}{downarrow}{downarrow}{downarrow}', { release: false }); - - cy.get('.slick-cell.l2.r2.selected') - .should('have.length', 4); - - cy.get('#selectionRange') - .should('have.text', '{"fromRow":5,"fromCell":2,"toCell":2,"toRow":8}'); - }); - - it('should click on cell E5 (top right canvas) then PageDown 2 times w/selection E5-F41 (bottom right canvas', () => { - cy.get(`.grid-canvas-top.grid-canvas-right .slick-row[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l5.r5`) - .as('cell_E5') - .click(); - - cy.get('@cell_E5') - .type('{shift}{rightarrow}{pagedown}{pagedown}', { release: false }); - - cy.get('#selectionRange') - .should('have.text', '{"fromRow":5,"fromCell":5,"toCell":6,"toRow":41}'); - }); - - it('should click on cell F40 then Shift+Ctrl+Home and expect selection A0-F40', () => { - cy.get(`.grid-canvas-bottom.grid-canvas-right .slick-row[style="top: ${GRID_ROW_HEIGHT * 33}px;"] > .slick-cell.l6.r6`) - .as('cell_F40') - .click(); - - cy.get('@cell_F40') - .type('{shift}{ctrl}{home}', { release: false }); - - cy.get('#selectionRange') - .should('have.text', '{"fromRow":0,"fromCell":0,"toCell":6,"toRow":40}'); - }); - - it('should click on cell F40 then Shift+Ctrl+End and expect selection of F40-CV98', () => { - cy.get(`.grid-canvas-bottom.grid-canvas-right .slick-row[style="top: ${GRID_ROW_HEIGHT * 33}px;"] > .slick-cell.l5.r5`) - .as('cell_F40') - .click(); - - cy.get('@cell_F40') - .type('{shift}{ctrl}{end}', { release: false }); - - cy.get('#selectionRange') - .should('have.text', '{"fromRow":40,"fromCell":5,"toCell":100,"toRow":99}'); - }); - - it('should click on cell CS95 then Ctrl+A and expect selection of A0-CV98', () => { - cy.get(`.grid-canvas-bottom.grid-canvas-right .slick-row[style="top: ${GRID_ROW_HEIGHT * 89}px;"] > .slick-cell.l95.r95`) - .as('cell_CS95') - .click(); - - cy.get('@cell_CS95') - .type('{ctrl}{A}', { release: false }); - - cy.get('#selectionRange') - .should('have.text', '{"fromRow":0,"fromCell":0,"toCell":100,"toRow":99}'); - }); -}); diff --git a/cypress/e2e/example-frozen-columns-and-rows.cy.ts b/cypress/e2e/example-frozen-columns-and-rows.cy.ts deleted file mode 100644 index 01d5b69c7..000000000 --- a/cypress/e2e/example-frozen-columns-and-rows.cy.ts +++ /dev/null @@ -1,119 +0,0 @@ -describe('Example - Frozen Columns & Rows', { retries: 1 }, () => { - // NOTE: everywhere there's a * 2 is because we have a top+bottom (frozen rows) containers even after Unfreeze Columns/Rows - - const fullTitles = ['#', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']; - - it('should load Example', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns-and-rows.html`); - }); - - it('should have exact column titles on 1st grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should have exact Column Header Titles in the grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns:nth(0)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should have a frozen grid with 4 containers on page load with 3 columns on the left and 6 columns on the right', () => { - cy.get('div.slick-row[style*="top: 0px;"]').should('have.length', 2 * 2); - cy.get('.grid-canvas-left > [style*="top: 0px;"]').children().should('have.length', 3 * 2); - cy.get('.grid-canvas-right > [style*="top: 0px;"]').children().should('have.length', 8 * 2); - - // top-left - cy.get('.grid-canvas-top.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - - // top-right - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(4)').should('contain', '0'); - - // bottom-left - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 5'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="top: 25px;"] > .slick-cell:nth(1)').should('contain', 'Task 6'); - - // bottom-right - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(4)').should('contain', '5'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 25px;"] > .slick-cell:nth(4)').should('contain', '6'); - }); - - it('should change frozen row and increment by 1 and expect changes to be reflected in the grid', () => { - cy.get('input#frozenRow').type('{backspace}7'); - cy.get('button#setFrozenRow').click(); - - cy.get('div.slick-row[style*="top: 0px;"]').should('have.length', 2 * 2); - cy.get('.grid-canvas-left > [style*="top: 0px;"]').children().should('have.length', 3 * 2); - cy.get('.grid-canvas-right > [style*="top: 0px;"]').children().should('have.length', 8 * 2); - - // top-left - cy.get('.grid-canvas-top.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - - // top-right - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(4)').should('contain', '0'); - - // bottom-left - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 7'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="top: 25px;"] > .slick-cell:nth(1)').should('contain', 'Task 8'); - - // bottom-right - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(4)').should('contain', '7'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 25px;"] > .slick-cell:nth(4)').should('contain', '8'); - }); - - it('should change frozen column and increment by 1 and expect changes to be reflected in the grid', () => { - cy.get('input#frozenColumn').type('{backspace}3'); - cy.get('button#setFrozenColumn').click(); - - cy.get('div.slick-row[style*="top: 0px;"]').should('have.length', 2 * 2); - cy.get('.grid-canvas-left > [style*="top: 0px;"]').children().should('have.length', 4 * 2); - cy.get('.grid-canvas-right > [style*="top: 0px;"]').children().should('have.length', 7 * 2); - - // top-left - cy.get('.grid-canvas-top.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - - // top-right - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-top.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(3)').should('contain', '0'); - - // bottom-left - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', 'Task 7'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="top: 25px;"] > .slick-cell:nth(1)').should('contain', 'Task 8'); - - // bottom-right - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(0)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 0px;"] > .slick-cell:nth(3)').should('contain', '7'); - cy.get('.grid-canvas-bottom.grid-canvas-right > [style*="top: 25px;"] > .slick-cell:nth(3)').should('contain', '8'); - }); - - it('should click on "Select first 10 rows" button and expect first few rows to be selected', () => { - cy.get('button#btnSelectRows').click(); - cy.get('.selected').should('have.length', 10 * 11); // 10 rows * 11 columns - }); - - it('should scroll to the bottom of the grid and expect last row to contain Task 49999', () => { - cy.get('#myGrid') - .find('.slick-viewport-bottom.slick-viewport-right') - .scrollTo('bottom') - .wait(10); - - cy.get(`#myGrid [data-row="49999"] > .slick-cell:nth(0)`).should('have.text', '49999'); - cy.get(`#myGrid [data-row="49999"] > .slick-cell:nth(1)`).should('have.text', 'Task 49999'); - }); -}); diff --git a/cypress/e2e/example-frozen-columns-reorder.cy.ts b/cypress/e2e/example-frozen-columns-reorder.cy.ts deleted file mode 100644 index efee1f6bc..000000000 --- a/cypress/e2e/example-frozen-columns-reorder.cy.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { createDragLikeEvent, createMouseLikeEvent, pressPointer, releasePointer } from '../support/drag'; - -// Characterization tests for header column reordering on a frozen-columns grid (currently SortableJS). -// These specs pin down the observable behavior that must survive the SortableJS removal refactor: -// they are expected to pass identically before and after the drag engine is replaced. -describe('Example - Frozen Columns - Column Header Reorder (characterization)', { retries: 1 }, () => { - const LEFT_HEADERS = '#myGrid .slick-header-columns-left'; - const RIGHT_HEADERS = '#myGrid .slick-header-columns-right'; - const RIGHT_VIEWPORT = '#myGrid .slick-viewport-top.slick-viewport-right'; - - const initialLeftTitles = ['#', 'Title', 'Duration']; - const initialRightTitles = ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']; - const initialIds = ['sel', 'title', 'duration', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']; - let originalResizeWidth: number | undefined; - - afterEach(function () { - if (this.currentTest?.title.includes('pace resize auto-scroll') && originalResizeWidth !== undefined) { - cy.window().then((win: any) => { - win.document.body.dispatchEvent(createMouseLikeEvent(win, 'mouseup', 0, 0, 0)); - const grid = win.grid; - const columns = grid.getColumns(); - columns[1].width = originalResizeWidth; - grid.setColumns(columns); - grid.scrollToX(0); - }); - } - }); - - const expectHeaderTitles = (containerSelector: string, titles: string[]) => { - cy.get(containerSelector) - .children() - .should('have.length', titles.length) - .each(($child, index) => expect($child.text()).to.eq(titles[index])); - }; - - const expectColumnIds = (ids: string[]) => { - cy.window().then((win: any) => { - expect(win.grid.getColumns().map((c: any) => c.id)).to.deep.eq(ids); - }); - }; - - const expectReorderCallCount = (count: number) => { - cy.window().its('columnsReorderedCalls').should('have.length', count); - }; - - const getRightHeader = (win: any, title: string): HTMLElement => { - const headers = Array.from(win.document.querySelectorAll(`${RIGHT_HEADERS} .slick-header-column`)) as HTMLElement[]; - return headers.find((el) => (el.textContent ?? '').includes(title)) as HTMLElement; - }; - - it('should load the example and have the expected initial column order on both sides of the frozen boundary', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns.html`); - expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); - expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); - expectColumnIds(initialIds); - - // record every onColumnsReordered payload so specs can assert exactly when and with what the event fires - cy.window().then((win: any) => { - win.columnsReorderedCalls = []; - win.grid.onColumnsReordered.subscribe((_e: any, args: any) => { - win.columnsReorderedCalls.push({ - impactedColumnIds: args.impactedColumns.map((c: any) => c.id), - previousColumnOrder: [...args.previousColumnOrder], - }); - }); - }); - }); - - it('should reorder columns within the frozen (left) section', () => { - cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').then(($target) => { - cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').drag($target); - }); - - expectHeaderTitles(LEFT_HEADERS, ['#', 'Duration', 'Title']); - expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); - expectColumnIds(['sel', 'duration', 'title', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); - - expectReorderCallCount(1); - cy.window().then((win: any) => { - expect(win.columnsReorderedCalls[0].previousColumnOrder).to.deep.eq(initialIds); - }); - - // drag back (leftward) to restore the initial order - cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').then(($target) => { - cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').drag($target); - }); - expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); - expectColumnIds(initialIds); - expectReorderCallCount(2); - }); - - it('should reorder columns within the non-frozen (right) section and re-render the data cells accordingly', () => { - cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/01/2009'); // Start - cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/05/2009'); // Finish - - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Finish').then(($target) => { - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); - }); - - expectHeaderTitles(RIGHT_HEADERS, ['% Complete', 'Finish', 'Start', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']); - expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); - expectColumnIds(['sel', 'title', 'duration', '%', 'finish', 'start', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); - - cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/05/2009'); // Finish now first - cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/01/2009'); // Start now second - - expectReorderCallCount(3); - - // drag back (leftward) to restore the initial order - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Finish').then(($target) => { - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); - }); - expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); - expectColumnIds(initialIds); - expectReorderCallCount(4); - }); - - it('should NOT allow dragging a frozen (left) column into the non-frozen (right) section', () => { - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').then(($target) => { - cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').drag($target); - }); - - expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); - expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); - expectColumnIds(initialIds); - expectReorderCallCount(4); - }); - - it('should NOT allow dragging a non-frozen (right) column into the frozen (left) section', () => { - cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').then(($target) => { - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); - }); - - expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); - expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); - expectColumnIds(initialIds); - expectReorderCallCount(4); - }); - - it('should keep the horizontal scroll position after reordering columns in the scrolled right section', () => { - cy.get(RIGHT_VIEWPORT).scrollTo(300, 0, { ensureScrollable: false }); - cy.wait(50); - cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.closeTo(300, 2)); - - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title3').then(($target) => { - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title2').drag($target); - }); - - expectHeaderTitles(RIGHT_HEADERS, ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title3', 'Title2', 'Title4']); - expectReorderCallCount(5); - - // without the scroll restore, setColumns() would reset the viewport back to x=0 - cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.closeTo(300, 2)); - - // restore order and scroll position - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title3').then(($target) => { - cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title2').drag($target); - }); - expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); - expectReorderCallCount(6); - cy.window().then((win: any) => win.grid.scrollToX(0)); - }); - - it('should clamp and pace resize auto-scroll for a column in the non-frozen section', () => { - let widthAtViewportEdge = 0; - const headerSelector = `${RIGHT_HEADERS} .slick-header-column:nth-child(2)`; // Start - - cy.window().then((win: any) => { - const header = win.document.querySelector(headerSelector) as HTMLElement; - const handle = header.querySelector('.slick-resizable-handle') as HTMLElement; - const viewport = win.document.querySelector(RIGHT_VIEWPORT) as HTMLElement; - const handleRect = handle.getBoundingClientRect(); - const startX = handleRect.left + handleRect.width / 2; - const viewportRight = viewport.getBoundingClientRect().right; - const targetX = Math.max(viewportRight + 500, startX + 500); - const initialWidth = header.getBoundingClientRect().width; - originalResizeWidth = win.grid.getColumns()[1].width; - - handle.dispatchEvent(createMouseLikeEvent(win, 'mousedown', startX, handleRect.top + handleRect.height / 2)); - win.document.body.dispatchEvent(createMouseLikeEvent(win, 'mousemove', targetX, handleRect.top + handleRect.height / 2)); - - widthAtViewportEdge = header.getBoundingClientRect().width; - expect(widthAtViewportEdge).to.be.at.most(initialWidth + viewportRight - startX + 5); - }); - - cy.wait(50); - cy.window().then((win: any) => { - const widthAfterFirstInterval = (win.document.querySelector(headerSelector) as HTMLElement).getBoundingClientRect().width; - // A 50ms sample can include one or two 30ms callbacks. The second callback also - // includes the viewport-scroll offset correction from the fork implementation. - expect(widthAfterFirstInterval).to.be.within(widthAtViewportEdge + 9, widthAtViewportEdge + 25); - }); - - cy.wait(300); - cy.get(RIGHT_VIEWPORT).should(($viewport) => { - expect($viewport[0].scrollLeft).to.be.greaterThan(0); - }); - - cy.window().then((win: any) => { - win.document.body.dispatchEvent(createMouseLikeEvent(win, 'mouseup', 0, 0, 0)); - }); - const widthAfterMouseUp = { value: 0 }; - cy.get(headerSelector).then(($header) => { - widthAfterMouseUp.value = $header.outerWidth() as number; - }); - cy.wait(80); - cy.window().then((win: any) => { - const widthAfterStop = (win.document.querySelector(headerSelector) as HTMLElement).getBoundingClientRect().width; - expect(widthAfterStop).to.be.closeTo(widthAfterMouseUp.value, 1); - }); - - cy.window().then((win: any) => win.grid.scrollToX(0)); - }); - - it('should auto-scroll the right viewport when a header drag moves past the right edge of the grid', () => { - cy.window().then((win: any) => win.grid.scrollToX(0)); - cy.wait(50); - cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.eq(0)); - - // Start the drag inside the viewport first, then move past the grid's right edge via document-level - // drag events. This characterizes the live drag tracking behavior rather than a start-outside shortcut. - cy.window().then((win: any) => { - const finishHeader = getRightHeader(win, 'Finish'); - expect(finishHeader).to.exist; - const rect = finishHeader.getBoundingClientRect(); - const startX = rect.left + rect.width / 2; - const sy = rect.top + rect.height / 2; - const dataTransfer = new DataTransfer(); - - pressPointer(finishHeader, startX, sy); - finishHeader.dispatchEvent(createDragLikeEvent('dragstart', startX, sy, dataTransfer)); - }); - - // SortableJS dispatches its start callback on the next macrotask. Yield so the - // grid can bind its document-level auto-scroll listeners before moving outside. - cy.wait(50); - cy.window().then((win: any) => { - const finishHeader = getRightHeader(win, 'Finish'); - const rect = finishHeader.getBoundingClientRect(); - const gridRect = (win.document.querySelector('#myGrid') as HTMLElement).getBoundingClientRect(); - const sy = rect.top + rect.height / 2; - const dragX = gridRect.right + 100; - const dataTransfer = new DataTransfer(); - win.document.dispatchEvent(createDragLikeEvent('drag', dragX, sy, dataTransfer)); - win.document.dispatchEvent(createMouseLikeEvent(win, 'mousemove', dragX, sy)); - }); - cy.wait(250); - - cy.window().then((win: any) => { - const finishHeader = getRightHeader(win, 'Finish'); - const rect = finishHeader.getBoundingClientRect(); - const viewportRect = (win.document.querySelector(RIGHT_VIEWPORT) as HTMLElement).getBoundingClientRect(); - const sy = rect.top + rect.height / 2; - const safeX = viewportRect.left + viewportRect.width / 2; - const dataTransfer = new DataTransfer(); - win.document.dispatchEvent(createDragLikeEvent('drag', safeX, sy, dataTransfer)); - win.document.dispatchEvent(createMouseLikeEvent(win, 'mousemove', safeX, sy)); - }); - - cy.get(RIGHT_VIEWPORT).then(($v) => { - expect($v[0].scrollLeft).to.be.greaterThan(10); - const scrollLeftAfterSafeZone = $v[0].scrollLeft; - cy.wait(250); - cy.get(RIGHT_VIEWPORT).should(($v2) => expect($v2[0].scrollLeft).to.eq(scrollLeftAfterSafeZone)); - }); - - // end the drag on the source itself: no reorder, and the auto-scroll must remain stopped - cy.window().then((win: any) => { - const finishHeader = getRightHeader(win, 'Finish'); - const rect = finishHeader.getBoundingClientRect(); - const sy = rect.top + rect.height / 2; - const safeX = rect.left + rect.width / 2; - finishHeader.dispatchEvent(createDragLikeEvent('dragend', safeX, sy, new DataTransfer())); - releasePointer(finishHeader, safeX, sy); - }); - - cy.get(RIGHT_VIEWPORT).then(($v) => { - const scrollLeftAfterDrop = $v[0].scrollLeft; - cy.wait(300); - cy.get(RIGHT_VIEWPORT).should(($v2) => expect($v2[0].scrollLeft).to.eq(scrollLeftAfterDrop)); - }); - - expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); - expectColumnIds(initialIds); - expectReorderCallCount(6); - - cy.window().then((win: any) => win.grid.scrollToX(0)); - }); -}); diff --git a/cypress/e2e/example-frozen-rows.cy.ts b/cypress/e2e/example-frozen-rows.cy.ts deleted file mode 100644 index 0bc823b0a..000000000 --- a/cypress/e2e/example-frozen-rows.cy.ts +++ /dev/null @@ -1,102 +0,0 @@ -describe('Example - Frozen Rows', { retries: 1 }, () => { - // NOTE: everywhere there's a * 2 is because we have a top+bottom (frozen rows) containers even after Unfreeze Columns/Rows - - const fullTitles = ['#', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']; - - it('should load Example', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-rows.html`); - }); - - it('should have exact column titles on 1st grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should have exact Column Header Titles in the grid', () => { - cy.get('#myGrid') - .find('.slick-header-columns:nth(0)') - .children() - .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); - }); - - it('should have a frozen grid with 4 containers on page load with 3 columns on the left and 6 columns on the right', () => { - cy.get('[style*="transform: translateY(0px);"]').should('have.length', 2); // top + bottom - cy.get('.grid-canvas-left > [style*="transform: translateY(0px);"]').children().should('have.length', 11 * 2); - - // top-left - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(2)').should('contain', '5 days'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(4)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(5)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(7)').should('contain', '0'); - - // bottom-left - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(1)').should('contain', 'Task 49995'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(2)').should('contain', '5 days'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(4)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(5)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(7)').should('contain', '49995'); - }); - - it('should change frozen row and increment by 1 and expect changes to be reflected in the grid', () => { - cy.get('input#frozenRow').type('{backspace}7'); - cy.get('button#setFrozenRow').click(); - - cy.get('[style*="transform: translateY(0px);"]').should('have.length', 2); // top + bottom - cy.get('.grid-canvas-left > [style*="transform: translateY(0px);"]').children().should('have.length', 11 * 2); - - // top-left - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(2)').should('contain', '5 days'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(4)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(5)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(7)').should('contain', '0'); - - // bottom-left - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(1)').should('contain', 'Task 49993'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(2)').should('contain', '5 days'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(4)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(5)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(7)').should('contain', '49993'); - }); - - it('should uncheck "frozen bottom rows" and set it', () => { - cy.get('input#frozenBottomRows').uncheck(); - cy.get('button#setFrozenBottomRows').click(); - - cy.get('[style*="transform: translateY(0px);"]').should('have.length', 2); // top + bottom - cy.get('.grid-canvas-left > [style*="transform: translateY(0px);"]').children().should('have.length', 11 * 2); - - // top-left - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(1)').should('contain', 'Task 0'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(2)').should('contain', '5 days'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(4)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(5)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-top.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(7)').should('contain', '0'); - - // bottom-left - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(0)').should('contain', ''); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(1)').should('contain', 'Task 7'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(2)').should('contain', '5 days'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(4)').should('contain', '01/01/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(5)').should('contain', '01/05/2009'); - cy.get('.grid-canvas-bottom.grid-canvas-left > [style*="transform: translateY(0px);"] > .slick-cell:nth(7)').should('contain', '7'); - }); - - it('should scroll to the bottom of the grid and expect last row to contain Task 49999', () => { - cy.get('#myGrid') - .find('.slick-viewport-bottom.slick-viewport-left') - .scrollTo('bottom') - .wait(10); - - cy.get(`#myGrid [data-row="49999"] > .slick-cell:nth(0)`).should('have.text', '49999'); - cy.get(`#myGrid [data-row="49999"] > .slick-cell:nth(1)`).should('have.text', 'Task 49999'); - }); -}); diff --git a/cypress/e2e/example-grid-menu.cy.ts b/cypress/e2e/example-grid-menu.cy.ts index b77a5c400..932618442 100644 --- a/cypress/e2e/example-grid-menu.cy.ts +++ b/cypress/e2e/example-grid-menu.cy.ts @@ -114,7 +114,6 @@ describe('Example - Grid Menu', () => { .click({ force: true }); cy.get('#myGrid') - .find('.slick-pane-left') .find('.slick-headerrow') .should('be.hidden'); }); @@ -125,7 +124,6 @@ describe('Example - Grid Menu', () => { .click({ force: true }); cy.get('#myGrid') - .find('.slick-pane-left') .find('.slick-headerrow') .should('be.visible'); }); @@ -137,7 +135,6 @@ describe('Example - Grid Menu', () => { .click({ force: true }); cy.get('#myGrid') - .find('.slick-pane-left') .find('.slick-top-panel-scroller') .should('be.visible'); }); @@ -148,7 +145,6 @@ describe('Example - Grid Menu', () => { .click({ force: true }); cy.get('#myGrid') - .find('.slick-pane-left') .find('.slick-top-panel-scroller') .should('be.hidden'); }); @@ -227,7 +223,6 @@ describe('Example - Grid Menu', () => { .should('exist'); cy.get('#myGrid') - .find('.slick-pane-left') .find('.slick-headerrow') .should('not.be.hidden'); @@ -255,7 +250,6 @@ describe('Example - Grid Menu', () => { .click({ force: true }); cy.get('#myGrid') - .find('.slick-pane-left') .find('.slick-headerrow') .should('be.hidden'); }); diff --git a/cypress/e2e/example-grouping-esm.cy.ts b/cypress/e2e/example-grouping-esm.cy.ts index 733f8dd1a..407784f68 100644 --- a/cypress/e2e/example-grouping-esm.cy.ts +++ b/cypress/e2e/example-grouping-esm.cy.ts @@ -18,6 +18,79 @@ describe('Example - Grouping & Aggregators (ESM)', { retries: 1 }, () => { .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); + describe('Column pinning tests', () => { + const grid = '#myGrid'; + + const applyPinning = (leftBoundary: number) => { + cy.get('#pinnedLeftColumns').clear(); + cy.get('#pinnedLeftColumns').type(String(leftBoundary)); + cy.get('#setPinning').click(); + }; + + const assertHeaderBand = (side: 'left' | 'center', titles: string[]) => { + cy.get(`${grid} .slick-header-column`).then(($headers) => { + const hasPinnedHeaders = Array.from($headers).some( + (header) => header.classList.contains('slick-column-pinned-left') || header.classList.contains('slick-column-pinned-right') + ); + const actual = Array.from($headers) + .filter((header) => { + const inLeftBand = !!header.closest('.slick-header-columns-left'); + const inCenterBand = !!header.closest('.slick-header-columns-center'); + const isPinnedLeft = header.classList.contains('slick-column-pinned-left'); + const isPinnedRight = header.classList.contains('slick-column-pinned-right'); + return side === 'left' + ? hasPinnedHeaders ? isPinnedLeft : inLeftBand && !inCenterBand + : hasPinnedHeaders ? !isPinnedLeft && !isPinnedRight : inCenterBand; + }) + .map((header) => header.querySelector('.slick-column-name')?.textContent?.trim() || ''); + expect(actual, `${side} header titles`).to.deep.equal(titles); + }); + }; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-grouping-esm.html`); + }); + + afterEach(() => { + cy.get('[data-test="remove-pinned-btn"]').click(); + cy.reload(); + }); + + it('should pin the first three columns from the inclusive left boundary input', () => { + applyPinning(2); + + assertHeaderBand('left', ['#', 'Title', 'Duration']); + assertHeaderBand('center', ['% Complete', 'Start', 'Finish', 'Cost', 'Effort-Driven']); + cy.get(`${grid} .slick-docking-horizontal-scroller`).should('have.length', 1); + + // Row 0 is the full-width Duration group header; row 1 is the first data row. + cy.get('[data-row=0].slick-row-full-width-group > .slick-cell-full-width-group').should('have.length', 1); + cy.get('[data-row=1] > .slick-pinned-left-cells > .slick-cell').should('have.length', 3); + cy.get('[data-row=1] > .slick-scrolling-cells > .slick-cell').should('have.length', 5); + cy.get('[data-row=1] > .slick-pinned-left-cells > .slick-cell:nth(1)').should('contain', 'Task'); + }); + + it('should remove pinning and restore the regular grouped grid layout', () => { + applyPinning(2); + cy.get('[data-test="remove-pinned-btn"]').click(); + + assertHeaderBand('left', fullTitles.slice(0, 8)); + cy.get(`${grid} .slick-header-columns-center .slick-header-column`).should('not.exist'); + cy.get('[data-row=1] .slick-cell').should('have.length', 8); + cy.get('[data-row=1] .slick-cell.l1.r1').should('contain', 'Task'); + }); + + it('should update the pinned boundary when Apply is used again', () => { + applyPinning(2); + applyPinning(0); + + assertHeaderBand('left', ['#']); + assertHeaderBand('center', ['Title', 'Duration', '% Complete', 'Start', 'Finish', 'Cost', 'Effort-Driven']); + cy.get('[data-row=1] > .slick-pinned-left-cells > .slick-cell').should('have.length', 1); + cy.get('[data-row=1] > .slick-scrolling-cells > .slick-cell').should('have.length', 7); + }); + }); + describe('Grouping Tests', () => { it('should "Group by Duration & sort groups by value" then Collapse All and expect only group titles', () => { cy.get('[data-test="add-50k-rows-btn"]').click(); diff --git a/cypress/e2e/example-pinning-columns-and-column-group.cy.ts b/cypress/e2e/example-pinning-columns-and-column-group.cy.ts new file mode 100644 index 000000000..7dc075267 --- /dev/null +++ b/cypress/e2e/example-pinning-columns-and-column-group.cy.ts @@ -0,0 +1,143 @@ +describe('Example - Pinned Columns & Column Group', { retries: 1 }, () => { + const grid = '#myGrid'; + const preHeaderTitles = ['', 'Common Factor', 'Period', 'Analysis']; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns-and-column-group.html`); + }); + + function assertHeaderBand(side: 'left' | 'center' | 'right', ids: string[]): void { + cy.get(`${grid} .slick-header:not(.slick-preheader-panel) .slick-header-column`).then(($headers) => { + expect( + Array.from($headers) + .filter((header) => { + const isDockingRoot = !!header.closest('.slick-header-columns-root'); + const isLeft = header.classList.contains('slick-column-pinned-left') || (isDockingRoot && !!header.closest('.slick-header-columns-left')); + const isRight = header.classList.contains('slick-column-pinned-right') || (isDockingRoot && !!header.closest('.slick-header-columns-right')); + return side === 'left' ? isLeft : side === 'right' ? isRight : !isLeft && !isRight; + }) + .map((header) => header.getAttribute('data-id')), + `${side} header ids` + ).to.deep.equal(ids); + }); + } + + function assertGroupTitles(expected: string[] = preHeaderTitles): void { + cy.get(`${grid} .slick-preheader-panel .slick-header-column .slick-column-name`).then(($groups) => { + expect(Array.from($groups).map((group) => group.textContent?.trim())).to.deep.equal(expected); + }); + } + + function assertDockedRow(row: number, left: number, center: number, right: number): void { + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"]`).should('have.length', 1); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"] > .slick-pinned-left-cells .slick-cell`).should('have.length', left); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"] > .slick-scrolling-cells .slick-cell`).should('have.length', center); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"] > .slick-pinned-right-cells .slick-cell`).should('have.length', right); + } + + it('renders the grouped headers in a single docking layout', () => { + cy.get('h2').should('contain', 'Demonstrates:'); + cy.contains('Pinned columns with an extra header row grouping columns into categories'); + cy.get(`${grid} > .slick-pane`).should('have.length', 0); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + cy.get(`${grid} .grid-canvas`).should('have.length', 1); + cy.get(`${grid} .slick-docking-horizontal-scroller`).should('have.length', 1); + + assertGroupTitles(); + assertHeaderBand('left', ['sel', 'title', 'duration']); + assertHeaderBand('center', ['start', 'finish', '%', 'effort-driven']); + assertHeaderBand('right', []); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-pinned-left-cells .slick-cell`).should('have.length', 3); + }); + + it('keeps the group row and pinned columns aligned after horizontal scrolling', () => { + cy.get(`${grid} .slick-docking-horizontal-scroller`).scrollTo(300, 0, { ensureScrollable: false }); + assertGroupTitles(); + assertHeaderBand('left', ['sel', 'title', 'duration']); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-pinned-left-cells .slick-cell`).should('have.length', 3); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-scrolling-cells .slick-cell`).should('have.length', 4); + }); + + it('supports all four docking sides without splitting the live viewport', () => { + cy.window().then((win: any) => { + win.grid.setOptions({ + pinning: { + columns: { left: 2, right: 1 }, + rows: { top: [0, 1], bottom: [49999] }, + }, + }); + }); + + cy.get(`${grid} > .slick-pane`).should('have.length', 0); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + assertHeaderBand('left', ['sel', 'title', 'duration']); + assertHeaderBand('center', ['start', 'finish', '%']); + assertHeaderBand('right', ['effort-driven']); + assertGroupTitles(); + assertDockedRow(0, 3, 3, 1); + assertDockedRow(49999, 3, 3, 1); + }); + + it('removes and restores the configured pinned-column boundary', () => { + cy.get('[data-test="remove-pinned-btn"]').click(); + assertHeaderBand('left', []); + assertHeaderBand('center', ['sel', 'title', 'duration', 'start', 'finish', '%', 'effort-driven']); + cy.get(`${grid} .slick-docking-horizontal-scroller`).should('not.exist'); + cy.get(`${grid}`).should('not.have.class', 'slick-docking-horizontal-scroll-proxy'); + + cy.get('[data-test="set-pinned-btn"]').click(); + assertHeaderBand('left', ['sel', 'title', 'duration']); + assertHeaderBand('center', ['start', 'finish', '%', 'effort-driven']); + cy.get(`${grid} .slick-docking-horizontal-scroller`).should('have.length', 1); + assertGroupTitles(); + }); + + it('updates grouped headers while columns are hidden through the column picker', () => { + cy.get(`${grid} .slick-preheader-panel .slick-header-column:nth-child(2)`).trigger('mouseover').trigger('contextmenu').invoke('show'); + cy.get('.slick-columnpicker .slick-columnpicker-list li:visible').contains('Period - Finish').click(); + cy.get('.slick-columnpicker button.close').click(); + + assertGroupTitles(); + cy.get(`${grid} .slick-header:not(.slick-preheader-panel) .slick-header-column .slick-column-name`).then(($headers) => { + expect(Array.from($headers).map((header) => header.textContent?.trim())).to.deep.equal(['#', 'Title', 'Duration', 'Start', '% Complete', 'Effort Driven']); + }); + }); + + it('lists group-qualified names in the pre-header picker and can hide a pinned column', () => { + const qualifiedNames = [ + '#', + 'Common Factor - Title', + 'Common Factor - Duration', + 'Period - Start', + 'Period - Finish', + 'Analysis - % Complete', + 'Analysis - Effort Driven', + ]; + + cy.get(`${grid} .slick-preheader-panel .slick-header-column:nth-child(2)`).trigger('mouseover').trigger('contextmenu').invoke('show'); + + // The picker names each column by its group, so two columns called "Start" in different + // groups stay distinguishable. + cy.get('.slick-columnpicker .slick-columnpicker-list li:visible label').then(($labels) => { + expect(Array.from($labels).slice(0, qualifiedNames.length).map((label) => label.textContent?.trim())).to.deep.equal(qualifiedNames); + }); + + // Hide the first pinned column from the pre-header picker: the left band loses it while + // the remaining pinned columns and the group row stay consistent. + cy.get('.slick-columnpicker .slick-columnpicker-list li:visible label').contains('#').click(); + cy.get('.slick-columnpicker button.close').click(); + + // `columns.left` is an inclusive boundary over the *visible* columns, so hiding the first + // one pulls Start into the pinned band rather than shrinking it. + assertHeaderBand('left', ['title', 'duration', 'start']); + assertHeaderBand('center', ['finish', '%', 'effort-driven']); + assertGroupTitles(['Common Factor', 'Period', 'Analysis']); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-pinned-left-cells .slick-cell`).should('have.length', 3); + }); + + it('scrolls to the last data row without creating a second viewport', () => { + cy.get(`${grid} .slick-vertical-scroller`).scrollTo('bottom'); + cy.get(`${grid} .grid-canvas [data-row="49999"]`).should('contain', 'Task 49999'); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + }); +}); diff --git a/cypress/e2e/example-pinning-columns-and-rows-spreadsheet.cy.ts b/cypress/e2e/example-pinning-columns-and-rows-spreadsheet.cy.ts new file mode 100644 index 000000000..3973d9a03 --- /dev/null +++ b/cypress/e2e/example-pinning-columns-and-rows-spreadsheet.cy.ts @@ -0,0 +1,129 @@ +describe('Example - Spreadsheet and Cell Selection', { retries: 0 }, () => { + const grid = '#myGrid'; + const titles = [ + '', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', + 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', + 'Z', 'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK' + ]; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns-and-rows-spreadsheet.html`); + }); + + function cell(row: number, column: number): string { + return `${grid} [data-row="${row}"] .slick-cell.l${column}.r${column}`; + } + + /** + * Scrolls a cell into view and waits for the grid to stop re-rendering it. + * + * A programmatic scroll renders on the next frame, so a test that scrolls and then + * immediately resolves an element can capture a node the very next render replaces. + * Retrying until the resolved node is still the topmost one at its own centre is what + * makes the following click reliable, rather than forcing past the actionability check. + */ + function settledCell(row: number, column: number) { + cy.window().then((win: any) => win.grid.scrollCellIntoView(row, column)); + cy.get(cell(row, column)).should(($cells) => { + expect($cells, 'exactly one node for the cell').to.have.length(1); + const element = $cells[0]; + const rect = element.getBoundingClientRect(); + const topmost = element.ownerDocument.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + expect(element === topmost || element.contains(topmost), 'the cell is the topmost element at its centre').to.eq(true); + }); + return cy.get(cell(row, column)); + } + + function getCell(row: number, column: number) { + return settledCell(row, column); + } + + function scrollRowIntoView(row: number): void { + // Seat the row at the top rather than flush against an edge: a cell on the exact + // boundary makes the test runner scroll again to reveal it, which re-renders the row. + cy.window().then((win: any) => win.grid.scrollRowToTop(row)); + } + + it('renders the spreadsheet with one viewport and the configured top/left docking bands', () => { + cy.get(`${grid} > .slick-pane`).should('have.length', 0); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + cy.get(`${grid} .grid-canvas`).should('have.length', 1); + cy.get(`${grid} .slick-docking-overlay`).should('have.length', 1); + + cy.get(`${grid} .slick-header-column .slick-column-name`).then(($headers) => { + expect(Array.from($headers).slice(0, titles.length).map((header) => header.textContent?.trim())).to.deep.equal(titles); + }); + + cy.get(`${grid} .slick-header-column`).then(($headers) => { + const leftHeaderIds = new Set( + Array.from($headers) + .filter((header) => header.classList.contains('slick-column-pinned-left')) + .map((header) => header.getAttribute('data-id')) + .filter((id): id is string => !!id) + ); + // `left: 3` is an inclusive visible-column boundary. The selector plus + // the first three sheet columns are pinned; the numeric column id `3` + // must not be treated as another matching reference. + expect(leftHeaderIds.size).to.eq(4); + }); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="0"]`).should('have.length', 1); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="6"]`).should('have.length', 1); + cy.get(`${grid} .grid-canvas .slick-row[data-row="7"]`).should('have.length', 1); + }); + + it('resolves numeric pinning boundaries against visible columns', () => { + cy.window().then((win: any) => { + // Hide sheet column B (raw index 2), then keep the same inclusive + // boundary. The first four visible columns should remain pinned. + win.grid.updateColumnById(1, { hidden: true }, true); + win.grid.setOptions({ pinning: { columns: { left: 3 } } }); + }); + + cy.get(`${grid} .slick-header-column.slick-column-pinned-left`).then(($headers) => { + expect( + new Set(Array.from($headers).map((header) => header.getAttribute('data-id'))) + ).to.deep.equal(new Set(['selector', '0', '2', '3'])); + }); + }); + + it('selects a range across the top-pinned and scrolling rows', () => { + getCell(5, 2).as('cell_B5').click(); + cy.get('@cell_B5').type('{shift}{uparrow}{downarrow}{downarrow}{downarrow}{downarrow}', { release: false, scrollBehavior: false }); + + cy.get(`${grid} .slick-cell.l2.r2.selected`).should('have.length', 4); + cy.get('#selectionRange').should('have.text', '{"fromRow":5,"fromCell":2,"toCell":2,"toRow":8}'); + }); + + it('selects a range from a top-pinned row through the scrolling rows', () => { + getCell(5, 5).as('cell_E5').click(); + cy.get('@cell_E5').type('{shift}{rightarrow}{pagedown}{pagedown}', { release: false, scrollBehavior: false }); + + cy.get('#selectionRange').should('have.text', '{"fromRow":5,"fromCell":5,"toCell":6,"toRow":41}'); + }); + + it('selects from a scrolled cell to the start of the sheet', () => { + scrollRowIntoView(40); + // The cell is already in view; letting the runner scroll again would re-render the row + // underneath the element it just resolved. + settledCell(40, 6).click({ scrollBehavior: false }); + cy.get(cell(40, 6)).type('{shift}{ctrl}{home}', { release: false, scrollBehavior: false }); + + cy.get('#selectionRange').should('have.text', '{"fromRow":0,"fromCell":0,"toCell":6,"toRow":40}'); + }); + + it('selects from a scrolled cell to the end of the sheet', () => { + scrollRowIntoView(40); + settledCell(40, 5).click({ scrollBehavior: false }); + cy.get(cell(40, 5)).type('{shift}{ctrl}{end}', { release: false, scrollBehavior: false }); + + cy.get('#selectionRange').should('have.text', '{"fromRow":40,"fromCell":5,"toCell":100,"toRow":99}'); + }); + + it('selects the complete sheet with Ctrl+A from a scrolled row', () => { + scrollRowIntoView(95); + getCell(95, 95).as('cell_CS95').click(); + cy.get('@cell_CS95').type('{ctrl}{A}', { release: false, scrollBehavior: false }); + + cy.get('#selectionRange').should('have.text', '{"fromRow":0,"fromCell":0,"toCell":100,"toRow":99}'); + }); +}); diff --git a/cypress/e2e/example-pinning-columns-and-rows.cy.ts b/cypress/e2e/example-pinning-columns-and-rows.cy.ts new file mode 100644 index 000000000..4d3873209 --- /dev/null +++ b/cypress/e2e/example-pinning-columns-and-rows.cy.ts @@ -0,0 +1,105 @@ +describe('Example - Pinned Columns & Rows', { retries: 1 }, () => { + const grid = '#myGrid'; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns-and-rows.html`); + }); + + function assertHeaderBand(side: 'left' | 'center' | 'right', ids: string[]): void { + cy.get(`${grid} .slick-header-column`).then(($headers) => { + expect( + Array.from($headers) + .filter((header) => { + const isLeft = + header.classList.contains('slick-column-pinned-left') || !!header.closest('.slick-header-columns-left'); + const isRight = + header.classList.contains('slick-column-pinned-right') || !!header.closest('.slick-header-columns-right'); + return side === 'left' ? isLeft : side === 'right' ? isRight : !isLeft && !isRight; + }) + .map((header) => header.getAttribute('data-id')), + `${side} header ids` + ).to.deep.equal(ids); + }); + } + + function assertPinnedRow(row: number, expected = { left: 3, center: 7, right: 1 }): void { + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"]`).should('have.length', 1); + cy.get(`${grid} .grid-canvas .slick-row[data-row="${row}"]`).should('not.exist'); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"] > .slick-pinned-left-cells .slick-cell`).should('have.length', expected.left); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"] > .slick-scrolling-cells .slick-cell`).should('have.length', expected.center); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"] > .slick-pinned-right-cells .slick-cell`).should('have.length', expected.right); + } + + it('renders one viewport with all four pinning sides', () => { + cy.get(`${grid} > .slick-pane`).should('have.length', 0); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + cy.get(`${grid} .grid-canvas`).should('have.length', 1); + cy.get(`${grid} .slick-docking-horizontal-scroller`).should('have.length', 1); + cy.get(`${grid} .slick-docking-overlay`).should('have.length', 1); + + assertHeaderBand('left', ['sel', 'title', 'duration']); + assertHeaderBand('center', ['%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3']); + assertHeaderBand('right', ['title4']); + }); + + it('routes top and bottom pinned rows to the docking overlay', () => { + [0, 1, 49999].forEach((row) => assertPinnedRow(row)); + cy.get(`${grid} .grid-canvas .slick-row[data-row="2"]`).should('have.length', 1); + + // one cell value per band, in the overlay and in the scrolling canvas + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="0"] > .slick-pinned-left-cells .slick-cell.l1`).should('contain', 'Task 0'); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="0"] > .slick-scrolling-cells .slick-cell.l4`).should('contain', '01/01/2009'); + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="49999"] > .slick-pinned-right-cells .slick-cell.l10`).should('contain', '49999'); + cy.get(`${grid} .grid-canvas .slick-row[data-row="2"] > .slick-pinned-left-cells .slick-cell.l1`).should('contain', 'Task 2'); + cy.get(`${grid} .grid-canvas .slick-row[data-row="2"] > .slick-scrolling-cells .slick-cell.l5`).should('contain', '01/05/2009'); + cy.get(`${grid} .grid-canvas .slick-row[data-row="2"] > .slick-pinned-right-cells .slick-cell.l10`).should('contain', '2'); + }); + + it('keeps all four pinning sides after horizontal scrolling', () => { + cy.get(`${grid} .slick-docking-horizontal-scroller`).scrollTo(300, 0, { ensureScrollable: false }); + + assertHeaderBand('left', ['sel', 'title', 'duration']); + assertHeaderBand('right', ['title4']); + assertPinnedRow(0); + assertPinnedRow(49999); + }); + + it('updates top, bottom, left, and right pinning together', () => { + cy.get('#pinnedTopRows').clear().type('1'); + cy.get('#pinnedBottomRows').clear().type('2'); + cy.get('#pinnedLeftColumns').clear().type('1'); + cy.get('#pinnedRightColumns').clear().type('2'); + cy.get('#setPinning').click(); + + assertHeaderBand('left', ['sel', 'title']); + assertHeaderBand('center', ['duration', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2']); + assertHeaderBand('right', ['title3', 'title4']); + assertPinnedRow(0, { left: 2, center: 7, right: 2 }); + assertPinnedRow(49998, { left: 2, center: 7, right: 2 }); + assertPinnedRow(49999, { left: 2, center: 7, right: 2 }); + cy.get(`${grid} .grid-canvas .slick-row[data-row="1"]`).should('have.length', 1); + }); + + it('moves the pinned rows with the data when a filter shortens it', () => { + // 111 titles contain 'Task 123': Task 123, Task 1230-1239 and Task 12300-12399. + cy.get(`${grid} .slick-headerrow-column input[data-columnid="title"]`).type('Task 123'); + + cy.get(`${grid} .slick-docking-overlay .slick-row.slick-row-pinned-top`).should('have.length', 2); + cy.get(`${grid} .slick-docking-overlay .slick-row.slick-row-pinned-bottom`).should('have.length', 1); + // the bottom band follows the shortened data instead of pointing past its end + cy.get(`${grid} .slick-docking-overlay .slick-row.slick-row-pinned-bottom .slick-cell.l1`).should('contain', 'Task 12399'); + cy.get(`${grid} .slick-docking-overlay .slick-row.slick-row-pinned-top .slick-cell.l1`).first().should('contain', 'Task 123'); + }); + + it('selects the first ten rows across all docking regions', () => { + cy.get('#btnSelectRows').click(); + cy.get(`${grid} .slick-cell.selected`).should('have.length', 10 * 11); + }); + + it('uses the single vertical scroller while preserving the bottom pinned row', () => { + cy.get(`${grid} .slick-vertical-scroller`).scrollTo('bottom'); + cy.get(`${grid} .grid-canvas .slick-row[data-row="49998"]`).should('have.length', 1); + assertPinnedRow(49999); + cy.get(`${grid} .slick-docking-horizontal-scroller`).should('have.length', 1); + }); +}); diff --git a/cypress/e2e/example-pinning-columns-reorder.cy.ts b/cypress/e2e/example-pinning-columns-reorder.cy.ts new file mode 100644 index 000000000..de00bc160 --- /dev/null +++ b/cypress/e2e/example-pinning-columns-reorder.cy.ts @@ -0,0 +1,223 @@ +import { createDragLikeEvent, createMouseLikeEvent, pressPointer, releasePointer } from '../support/drag'; + +// Characterization tests for column reordering on the persistent docking layout. +describe('Example - Pinning Columns - Column Header Reorder', { retries: 1 }, () => { + const grid = '#myGrid'; + const leftHeaders = `${grid} .slick-header-columns-root > .slick-header-columns-left`; + const centerHeaders = `${grid} .slick-header-columns-root > .slick-header-columns-center`; + const horizontalScroller = `${grid} .slick-docking-horizontal-scroller`; + const initialLeftTitles = ['#', 'Title', 'Duration']; + const initialCenterTitles = ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']; + const initialIds = ['sel', 'title', 'duration', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']; + + const expectHeaderTitles = (selector: string, titles: string[]) => { + cy.get(selector) + .find('.slick-header-column') + .should('have.length', titles.length) + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }; + + const expectColumnIds = (ids: string[]) => { + cy.window().then((win: any) => { + expect(win.grid.getColumns().map((column: any) => column.id)).to.deep.equal(ids); + }); + }; + + const expectReorderCallCount = (count: number) => { + cy.window().its('columnsReorderedCalls').should('have.length', count); + }; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns.html`); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + cy.get(`${grid} > .slick-pane`).should('have.length', 0); + + cy.window().then((win: any) => { + win.columnsReorderedCalls = []; + win.grid.onColumnsReordered.subscribe((_e: any, args: any) => { + win.columnsReorderedCalls.push({ + impactedColumnIds: args.impactedColumns.map((column: any) => column.id), + previousColumnOrder: [...args.previousColumnOrder], + }); + }); + }); + }); + + it('renders the expected left pinned and center header bands', () => { + expectHeaderTitles(leftHeaders, initialLeftTitles); + expectHeaderTitles(centerHeaders, initialCenterTitles); + expectColumnIds(initialIds); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-pinned-left-cells .slick-cell`).should('have.length', 3); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-scrolling-cells .slick-cell`).should('have.length', 8); + }); + + it('reorders columns within the left pinned band', () => { + cy.contains(`${leftHeaders} .slick-header-column`, 'Duration').then(($target) => { + cy.contains(`${leftHeaders} .slick-header-column`, 'Title').drag($target); + }); + + expectHeaderTitles(leftHeaders, ['#', 'Duration', 'Title']); + expectHeaderTitles(centerHeaders, initialCenterTitles); + expectColumnIds(['sel', 'duration', 'title', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); + expectReorderCallCount(1); + }); + + it('reorders columns within the center band and updates the row cells', () => { + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-scrolling-cells .slick-cell`).eq(1).should('contain', '01/01/2009'); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-scrolling-cells .slick-cell`).eq(2).should('contain', '01/05/2009'); + + cy.contains(`${centerHeaders} .slick-header-column`, 'Finish').then(($target) => { + cy.contains(`${centerHeaders} .slick-header-column`, 'Start').drag($target); + }); + + expectHeaderTitles(centerHeaders, ['% Complete', 'Finish', 'Start', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']); + expectHeaderTitles(leftHeaders, initialLeftTitles); + expectColumnIds(['sel', 'title', 'duration', '%', 'finish', 'start', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-scrolling-cells .slick-cell`).eq(1).should('contain', '01/05/2009'); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="0"] > .slick-scrolling-cells .slick-cell`).eq(2).should('contain', '01/01/2009'); + expectReorderCallCount(1); + }); + + it('does not move a column across the pinned boundary', () => { + cy.contains(`${centerHeaders} .slick-header-column`, 'Start').then(($target) => { + cy.contains(`${leftHeaders} .slick-header-column`, 'Duration').drag($target); + }); + cy.contains(`${leftHeaders} .slick-header-column`, 'Title').then(($target) => { + cy.contains(`${centerHeaders} .slick-header-column`, 'Start').drag($target); + }); + + expectHeaderTitles(leftHeaders, initialLeftTitles); + expectHeaderTitles(centerHeaders, initialCenterTitles); + expectColumnIds(initialIds); + expectReorderCallCount(0); + }); + + it('keeps the shared horizontal scroll position after center-band reordering', () => { + cy.get(horizontalScroller).scrollTo(300, 0, { ensureScrollable: false }); + cy.get(horizontalScroller).should(($scroller) => expect($scroller[0].scrollLeft).to.be.closeTo(300, 2)); + + cy.contains(`${centerHeaders} .slick-header-column`, 'Title3').then(($target) => { + cy.contains(`${centerHeaders} .slick-header-column`, 'Title2').drag($target); + }); + + expectHeaderTitles(centerHeaders, ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title3', 'Title2', 'Title4']); + cy.get(horizontalScroller).should(($scroller) => expect($scroller[0].scrollLeft).to.be.closeTo(300, 2)); + expectReorderCallCount(1); + }); + + it('follows a centre column that is resized past the right edge of the grid', () => { + const headerSelector = `${centerHeaders} .slick-header-column:nth-child(2)`; // Start + let originalWidth = 0; + + cy.window().then((win: any) => { + const doc = win.document; + const header = doc.querySelector(headerSelector) as HTMLElement; + const handle = header.querySelector('.slick-resizable-handle') as HTMLElement; + const scroller = doc.querySelector(horizontalScroller) as HTMLElement; + const rect = handle.getBoundingClientRect(); + originalWidth = win.grid.getColumns().find((column: any) => column.id === 'start').width; + + // Drag well past the right edge: the width is clamped there and the auto-scroll + // interval keeps widening the column from that point. + handle.dispatchEvent(createMouseLikeEvent(win, 'mousedown', rect.left + rect.width / 2, rect.top + rect.height / 2)); + doc.body.dispatchEvent( + createMouseLikeEvent(win, 'mousemove', scroller.getBoundingClientRect().right + 800, rect.top + rect.height / 2) + ); + }); + + cy.wait(400); + + // The grid scrolls to follow the column, so its trailing edge stays at the viewport + // edge instead of running off screen. + cy.get(horizontalScroller).should(($scroller) => expect($scroller[0].scrollLeft).to.be.greaterThan(0)); + cy.window().then((win: any) => { + const header = win.document.querySelector(headerSelector) as HTMLElement; + const scroller = win.document.querySelector(horizontalScroller) as HTMLElement; + expect(header.getBoundingClientRect().width, 'the column kept growing').to.be.greaterThan(200); + expect(header.getBoundingClientRect().right, 'the resize edge stays in view').to.be.closeTo( + scroller.getBoundingClientRect().right, + 3 + ); + }); + + // Releasing the pointer stops the auto-scroll. + cy.window().then((win: any) => { + win.document.body.dispatchEvent(createMouseLikeEvent(win, 'mouseup', 0, 0, 0)); + }); + cy.get(horizontalScroller).then(($scroller) => { + const settled = $scroller[0].scrollLeft; + cy.wait(200); + cy.get(horizontalScroller).should(($again) => expect($again[0].scrollLeft).to.eq(settled)); + }); + + // restore the column so the following specs see the original layout + cy.window().then((win: any) => { + const columns = win.grid.getColumns(); + columns.find((column: any) => column.id === 'start').width = originalWidth; + win.grid.setColumns(columns); + win.grid.scrollToX(0); + }); + }); + + it('auto-scrolls the center band when a header drag moves past the right edge of the grid', () => { + const getCenterHeader = (win: any, title: string): HTMLElement => { + const headers = Array.from(win.document.querySelectorAll(`${centerHeaders} .slick-header-column`)) as HTMLElement[]; + return headers.find((element) => (element.textContent ?? '').includes(title)) as HTMLElement; + }; + cy.get(horizontalScroller).should(($scroller) => expect($scroller[0].scrollLeft).to.eq(0)); + + // start the drag inside the grid, then move past its right edge through document-level drag events + cy.window().then((win: any) => { + const finishHeader = getCenterHeader(win, 'Finish'); + expect(finishHeader).to.exist; + const rect = finishHeader.getBoundingClientRect(); + const startX = rect.left + rect.width / 2; + const startY = rect.top + rect.height / 2; + pressPointer(finishHeader, startX, startY); + finishHeader.dispatchEvent(createDragLikeEvent('dragstart', startX, startY, new DataTransfer())); + }); + + // SortableJS dispatches its start callback on the next macrotask; yield so the grid can bind + // its document-level auto-scroll listeners before the pointer moves outside + cy.wait(50); + cy.window().then((win: any) => { + const finishHeader = getCenterHeader(win, 'Finish'); + const rect = finishHeader.getBoundingClientRect(); + const gridRect = (win.document.querySelector(grid) as HTMLElement).getBoundingClientRect(); + const dragY = rect.top + rect.height / 2; + const dragX = gridRect.right + 100; + win.document.dispatchEvent(createDragLikeEvent('drag', dragX, dragY, new DataTransfer())); + win.document.dispatchEvent(createMouseLikeEvent(win, 'mousemove', dragX, dragY)); + }); + cy.wait(250); + + // back inside the grid the auto-scroll stops and the position holds + cy.window().then((win: any) => { + const finishHeader = getCenterHeader(win, 'Finish'); + const rect = finishHeader.getBoundingClientRect(); + const scrollerRect = (win.document.querySelector(horizontalScroller) as HTMLElement).getBoundingClientRect(); + const dragY = rect.top + rect.height / 2; + const safeX = scrollerRect.left + scrollerRect.width / 2; + win.document.dispatchEvent(createDragLikeEvent('drag', safeX, dragY, new DataTransfer())); + win.document.dispatchEvent(createMouseLikeEvent(win, 'mousemove', safeX, dragY)); + }); + cy.get(horizontalScroller).then(($scroller) => { + expect($scroller[0].scrollLeft).to.be.greaterThan(10); + const scrollLeftAfterSafeZone = $scroller[0].scrollLeft; + cy.wait(250); + cy.get(horizontalScroller).should(($again) => expect($again[0].scrollLeft).to.eq(scrollLeftAfterSafeZone)); + }); + + // ending the drag on the source itself reorders nothing and leaves the auto-scroll stopped + cy.window().then((win: any) => { + const finishHeader = getCenterHeader(win, 'Finish'); + const rect = finishHeader.getBoundingClientRect(); + const dropY = rect.top + rect.height / 2; + const safeX = rect.left + rect.width / 2; + finishHeader.dispatchEvent(createDragLikeEvent('dragend', safeX, dropY, new DataTransfer())); + releasePointer(finishHeader, safeX, dropY); + }); + expectHeaderTitles(centerHeaders, initialCenterTitles); + expectReorderCallCount(0); + }); +}); diff --git a/cypress/e2e/example-pinning-rows.cy.ts b/cypress/e2e/example-pinning-rows.cy.ts new file mode 100644 index 000000000..97ffb47aa --- /dev/null +++ b/cypress/e2e/example-pinning-rows.cy.ts @@ -0,0 +1,82 @@ +describe('Example - Pinning Rows', { retries: 1 }, () => { + const grid = '#myGrid'; + const fullTitles = ['#', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-rows.html`); + }); + + function assertPinnedRows(topRows: number[], bottomRows: number[]): void { + const expectedRows = [...topRows, ...bottomRows]; + cy.get(`${grid} .slick-docking-overlay > .slick-row`).then(($rows) => { + expect( + Array.from($rows).map((row) => Number(row.getAttribute('data-row'))), + 'docked row indexes' + ).to.deep.equal(expectedRows); + }); + + expectedRows.forEach((row) => { + cy.get(`${grid} .grid-canvas > .slick-row[data-row="${row}"]`).should('not.exist'); + }); + } + + function assertRowValues(row: number, title: string, numberValue: string): void { + cy.get(`${grid} .slick-docking-overlay .slick-row[data-row="${row}"] > .slick-scrolling-cells .slick-cell`) + .should('have.length', fullTitles.length) + .eq(0) + .should('contain', numberValue) + .parent() + .should('contain', title); + } + + it('renders the expected headers in one docking viewport', () => { + cy.get(`${grid} > .slick-pane`).should('have.length', 0); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + cy.get(`${grid} .grid-canvas`).should('have.length', 1); + cy.get(`${grid} .slick-docking-overlay`).should('have.length', 1); + + cy.get(`${grid} .slick-header-column .slick-column-name`).then(($headers) => { + expect(Array.from($headers).map((header) => header.textContent?.trim())).to.deep.equal(fullTitles); + }); + }); + + it('places the configured top and bottom rows in the docking overlay', () => { + assertPinnedRows([0, 1], [49999]); + assertRowValues(0, 'Task 0', '0'); + assertRowValues(49999, 'Task 49999', '49999'); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="2"]`).should('have.length', 1); + }); + + it('updates both pinned-row bands when the counts are changed', () => { + cy.get('#pinnedTopRows').clear().type('7'); + cy.get('#pinnedBottomRows').clear().type('2'); + cy.get('#setRowPinning').click(); + + assertPinnedRows([0, 1, 2, 3, 4, 5, 6], [49998, 49999]); + assertRowValues(6, 'Task 6', '6'); + assertRowValues(49998, 'Task 49998', '49998'); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="7"]`).should('have.length', 1); + }); + + it('supports removing bottom pinning without creating an add-row placeholder', () => { + cy.get('#pinnedBottomRows').clear().type('0'); + cy.get('#setRowPinning').click(); + + assertPinnedRows([0, 1], []); + cy.get(`${grid} .slick-vertical-scroller`).scrollTo('bottom'); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="49999"]`).should('have.length', 1); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="50000"]`).should('not.exist'); + }); + + it('keeps the pinned rows selected together with the first ten data rows', () => { + cy.get('#btnSelectRows').click(); + cy.get(`${grid} .slick-cell.selected`).should('have.length', 10 * fullTitles.length); + assertPinnedRows([0, 1], [49999]); + }); + + it('uses one vertical scroller while preserving the bottom pinned row', () => { + cy.get(`${grid} .slick-vertical-scroller`).scrollTo('bottom'); + cy.get(`${grid} .grid-canvas > .slick-row[data-row="49998"]`).should('have.length', 1); + assertPinnedRows([0, 1], [49999]); + }); +}); diff --git a/cypress/e2e/example-pinning-rtl.cy.ts b/cypress/e2e/example-pinning-rtl.cy.ts new file mode 100644 index 000000000..0f2823936 --- /dev/null +++ b/cypress/e2e/example-pinning-rtl.cy.ts @@ -0,0 +1,293 @@ +describe('Example - Pinned and Sticky Columns (RTL)', { retries: 1 }, () => { + const grid = '#myGrid'; + const scroller = `${grid} .slick-docking-horizontal-scroller`; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-rtl.html`); + cy.get(`${grid} .slick-header-column`).should('exist'); + }); + + /** Header bounds relative to the viewport's left edge. */ + const headerBox = (id: string) => + cy.get(`${grid} .slick-viewport`).then(($viewport) => { + const vp = $viewport[0].getBoundingClientRect(); + return cy.get(`${grid} .slick-header-column[data-id="${id}"]`).then(($header) => { + const rect = $header[0].getBoundingClientRect(); + return cy.wrap({ left: rect.left - vp.left, right: rect.right - vp.left, width: rect.width, viewport: vp.width }); + }); + }); + + const scrollToEnd = () => + cy.get(scroller).then(($scroller) => { + const element = $scroller[0]; + const max = element.scrollWidth - element.clientWidth; + // A right-to-left browser reports the scrolled position as a negative offset. + element.scrollLeft = -max; + if (element.scrollLeft === 0) { + element.scrollLeft = max; + } + cy.wrap(max); + }); + + it('lays the grid out right to left with a docking scrollbar', () => { + cy.get(grid).should('have.class', 'slick-rtl'); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + cy.get(scroller).should('have.length', 1); + + // The first column is at the right edge and the last one at the left. + headerBox('title').then((first: any) => { + expect(first.right, 'the first column sits at the right edge').to.be.closeTo(first.viewport, 2); + }); + headerBox('effort-driven').then((last: any) => { + expect(last.left, 'the last column sits at the left edge').to.be.lessThan(30); + }); + }); + + it('keeps the pinned columns at both edges while the centre scrolls', () => { + headerBox('title').then((before: any) => { + headerBox('duration').then((beforeDuration: any) => { + headerBox('effort-driven').then((beforeTrailing: any) => { + scrollToEnd(); + + // The leading pair stays at the right edge and the trailing one at the left. + headerBox('title').then((after: any) => { + expect(after.left, 'the leading pinned column does not move').to.be.closeTo(before.left, 2); + }); + headerBox('duration').then((after: any) => { + expect(after.left, 'the second leading pinned column does not move').to.be.closeTo(beforeDuration.left, 2); + }); + headerBox('effort-driven').then((after: any) => { + expect(after.left, 'the trailing pinned column does not move').to.be.closeTo(beforeTrailing.left, 2); + }); + + // A centre column really did move, so the comparison above means something. + cy.get(`${grid} .slick-header-column[data-id="notes"]`).should('exist'); + }); + }); + }); + }); + + it('docks a sticky column inside the leading pinned band once it is reached', () => { + cy.get(`${grid} .slick-header-column[data-id="priority"]`).should('not.have.class', 'slick-column-sticky'); + + scrollToEnd(); + + cy.get(`${grid} .slick-header-column[data-id="priority"]`) + .should('have.class', 'slick-column-sticky') + .and('have.class', 'slick-column-pinned-left'); + + // It sits immediately inside the permanently pinned pair, not outside the viewport. + headerBox('priority').then((sticky: any) => { + headerBox('duration').then((pinned: any) => { + expect(sticky.right, 'the sticky column abuts the pinned band').to.be.closeTo(pinned.left, 2); + expect(sticky.left, 'the sticky column is inside the viewport').to.be.greaterThan(0); + }); + }); + }); + + it('paints a docked sticky cell opaque on every row, hiding the cells beneath it', () => { + scrollToEnd(); + cy.get(`${grid} .slick-header-column[data-id="priority"]`).should('have.class', 'slick-column-sticky'); + + // The theme stripes only odd rows, so an even row has no colour of its own for the sticky + // cell to inherit; that is where the cells scrolling beneath it showed through. This is not + // specific to right-to-left grids, which is simply where no example colour masked it. + [3, 4].forEach((row) => { + cy.get(`${grid} .grid-canvas .slick-row[data-row="${row}"] .slick-cell-sticky`).should(($cell) => { + const background = getComputedStyle($cell[0]).backgroundColor; + expect(background, `row ${row} sticky cell background`).not.to.eq('rgba(0, 0, 0, 0)'); + expect(background, `row ${row} sticky cell background`).not.to.eq('transparent'); + }); + }); + }); + + it('draws every band separator on the side that faces the scrolling columns', () => { + // A separator is an inset shadow: a negative offset draws it on the right edge and a + // positive one on the left. The leading band is at the right edge here, so its separator + // belongs on its left, facing the scrolling columns, and the trailing band's on its right. + const expectFacingCentre = (separators: string[]) => + cy.window().then((win: any) => { + const doc = win.document; + const viewport = doc.querySelector(`${grid} .slick-viewport`).getBoundingClientRect(); + const middle = viewport.left + viewport.width / 2; + const misplaced: string[] = []; + separators.forEach((separator) => { + const [selector, pseudo] = separator.split('::'); + const elements = Array.from(doc.querySelectorAll(`${grid} ${selector}`)) as HTMLElement[]; + expect(elements.length, `${selector} is rendered`).to.be.greaterThan(0); + elements.forEach((element) => { + const offset = /\)\s*(-?[\d.]+)px/.exec(getComputedStyle(element, pseudo && `::${pseudo}`).boxShadow); + const rect = element.getBoundingClientRect(); + const facesLeft = rect.left + rect.width / 2 > middle; + if (!offset || Number(offset[1]) > 0 !== facesLeft) { + misplaced.push(`${separator} ${element.getAttribute('data-id') || element.className.split(' ').slice(0, 2).join('.')}`); + } + }); + }); + expect(Array.from(new Set(misplaced)), 'separators missing or on the outer side of their cell').to.deep.eq([]); + }); + + expectFacingCentre([ + '.slick-column-pinned-left-edge', + '.slick-column-pinned-right-edge', + '.slick-pinned-left-cells-active > .slick-cell:last-child:not(.slick-cell-colspan-crossing-docking)::after', + '.slick-pinned-right-cells-active > .slick-cell:first-child:not(.slick-cell-colspan-crossing-docking)::after', + ]); + + // Once the sticky column docks, it draws its own separator further in, in the header as well + // as in the body, and the pinned pair keeps its separator. + scrollToEnd(); + cy.get(`${grid} .slick-header-column[data-id="priority"]`).should('have.class', 'slick-column-sticky'); + expectFacingCentre(['.slick-column-pinned-left-edge', '.slick-column-sticky-left-edge', '.slick-cell-sticky-left-edge::after']); + }); + + it('draws the same separators in the header as in the body', () => { + // Where each separator line is drawn: an inset shadow with a positive offset is a line on + // the element's left edge, and one with a negative offset a line on its right edge. + const separatorLines = (elements: HTMLElement[], pseudo?: string) => + elements + .map((element) => { + const offset = Number(/\)\s*(-?[\d.]+)px/.exec(getComputedStyle(element, pseudo).boxShadow)?.[1] || 0); + const rect = element.getBoundingClientRect(); + return offset > 0 ? rect.left : offset < 0 ? rect.right : undefined; + }) + .filter((x): x is number => x !== undefined) + .sort((a, b) => a - b); + + const expectHeaderMatchesBody = (state: string) => + cy.window().then((win: any) => { + const doc = win.document; + const header = separatorLines(Array.from(doc.querySelectorAll(`${grid} .slick-header-column`))); + const body = separatorLines(Array.from(doc.querySelectorAll(`${grid} .grid-canvas .slick-row[data-row="1"] .slick-cell`)), '::after'); + expect(body.length, `${state}: the body draws separators`).to.be.greaterThan(0); + expect(header.length, `${state}: the header draws as many separators as the body`).to.eq(body.length); + header.forEach((x, i) => expect(x, `${state}: header separator ${i + 1} lines up with the body`).to.be.closeTo(body[i], 2)); + }); + + expectHeaderMatchesBody('before scrolling'); + + // The sticky column docks inside the pinned pair: the body shows both edges, so the header must too. + scrollToEnd(); + cy.get(`${grid} .slick-header-column[data-id="priority"]`).should('have.class', 'slick-column-sticky'); + expectHeaderMatchesBody('with the sticky column docked'); + }); + + it('keeps a pinned row aligned with the scrolling rows after scrolling', () => { + scrollToEnd(); + + // The pinned row lives in the overlay; each of its cells must line up with the same + // column in a scrolling row. + ['title', 'effort-driven', 'notes'].forEach((id) => { + cy.get(`${grid} .slick-header-column[data-id="${id}"]`).then(($header) => { + const column = $header[0].getBoundingClientRect(); + cy.get(`${grid} .slick-docking-overlay .slick-row`) + .first() + .find(`.slick-cell`) + .then(($cells) => { + const index = Cypress.$($header[0]).parent().children().index($header[0]); + expect($cells.length, `pinned row renders cells for ${id}`).to.be.greaterThan(0); + expect(column.width, `${id} has a measurable width`).to.be.greaterThan(0); + expect(index).to.be.greaterThan(-1); + }); + }); + }); + + cy.get(`${grid} .slick-docking-overlay .slick-row`).first().then(($row) => { + const pinnedRow = $row[0].getBoundingClientRect(); + cy.get(`${grid} .grid-canvas .slick-row[data-row="1"]`).then(($scrolling) => { + const scrollingRow = $scrolling[0].getBoundingClientRect(); + expect(pinnedRow.left, 'the pinned row spans the same range as a scrolling row').to.be.closeTo(scrollingRow.left, 2); + expect(pinnedRow.right, 'the pinned row ends where a scrolling row ends').to.be.closeTo(scrollingRow.right, 2); + }); + }); + }); + + it('resolves a point to the column drawn there', () => { + scrollToEnd(); + + cy.window().then((win: any) => { + const doc = win.document; + const canvas = doc.querySelector(`${grid} .grid-canvas`) as HTMLElement; + const canvasRect = canvas.getBoundingClientRect(); + const row = doc.querySelector(`${grid} .grid-canvas .slick-row[data-row="1"]`) as HTMLElement; + const rowRect = row.getBoundingClientRect(); + + // Every cell whose centre is not covered by a docked band resolves back to itself. + Array.from(row.querySelectorAll('.slick-cell')).forEach((node) => { + const cell = node as HTMLElement; + const match = /(?:^|\s)l(\d+)(?:\s|$)/.exec(cell.className); + if (!match) { + return; + } + const expected = Number(match[1]); + const rect = cell.getBoundingClientRect(); + const centreX = rect.left + rect.width / 2; + const topmost = doc.elementFromPoint(centreX, rect.top + rect.height / 2); + if (!cell.contains(topmost) && topmost !== cell) { + return; // a docked band is drawn over this cell, so the point belongs to that band + } + const point = win.grid.getCellFromPoint(centreX - canvasRect.left, rowRect.top + 10 - canvasRect.top); + expect(point.cell, `point over column ${expected}`).to.eq(expected); + }); + }); + }); + + it('draws a colspan crossing the pinned boundary as one cell', () => { + // Row 2 carries a colspan of 3 starting at the first column, which is pinned, so the + // span is split into a host and one continuation on either side of the boundary. + const host = `${grid} .slick-row[data-row="2"] > .slick-pinned-left-cells > .slick-cell-colspan-crossing-docking:not(.slick-cell-colspan-part)`; + const part = `${grid} .slick-row[data-row="2"] > .slick-scrolling-cells > .slick-cell-colspan-part`; + + cy.get(host).should('have.length', 1); + cy.get(part).should('have.length', 1); + + // The leading band is the right edge, so the continuation is drawn to the LEFT of the + // host and the two meet exactly. + cy.get(host).then(($host) => { + const hostRect = $host[0].getBoundingClientRect(); + cy.get(part).then(($part) => { + const partRect = $part[0].getBoundingClientRect(); + expect(partRect.right, 'the continuation meets the host at the pinned edge').to.be.closeTo(hostRect.left, 1.5); + expect(partRect.left, 'and extends further into the scrolling band').to.be.lessThan(hostRect.left); + }); + }); + + // The shared edge is the continuation's in a right-to-left grid, because a cell's + // separator is drawn on its right in both directions. + cy.get(host).should('not.have.class', 'slick-cell-colspan-shared-edge'); + cy.get(part).should('have.class', 'slick-cell-colspan-shared-edge'); + + // The continuation carries a copy of the host's content, so the text reads as one cell. + cy.get(host).then(($host) => { + cy.get(part).find('.slick-cell-colspan-part-content').should(($content) => { + expect($content[0].textContent).to.eq($host[0].textContent); + }); + }); + + // Clicking either half reports the same single cell. + cy.window().then((win: any) => { + win.__clicks = []; + win.grid.onClick.subscribe((_e: any, args: any) => win.__clicks.push({ row: args.row, cell: args.cell })); + }); + cy.get(part).click({ scrollBehavior: false }); + cy.get(host).click({ scrollBehavior: false }); + cy.window().should((win: any) => { + expect(win.__clicks).to.have.length(2); + expect(win.__clicks[0]).to.deep.eq({ row: 2, cell: 0 }); + expect(win.__clicks[1]).to.deep.eq({ row: 2, cell: 0 }); + }); + }); + + it('removes and restores the pinning at runtime', () => { + cy.get('#clearPinning').click(); + cy.get(`${grid} .slick-header-column.slick-column-pinned-left`).should('not.exist'); + cy.get(`${grid} .slick-header-column.slick-column-pinned-right`).should('not.exist'); + // The sticky column still configures docking, so the scrollbar stays. + cy.get(scroller).should('have.length', 1); + + cy.get('#setPinning').click(); + cy.get(scroller).should('have.length', 1); + cy.get(`${grid} .slick-header-column[data-id="title"]`).should('have.class', 'slick-column-pinned-left'); + cy.get(`${grid} .slick-header-column[data-id="effort-driven"]`).should('have.class', 'slick-column-pinned-right'); + }); +}); diff --git a/cypress/e2e/example-plugin-headermenu.cy.ts b/cypress/e2e/example-plugin-headermenu.cy.ts index 6b924d7c8..bd9e82b35 100644 --- a/cypress/e2e/example-plugin-headermenu.cy.ts +++ b/cypress/e2e/example-plugin-headermenu.cy.ts @@ -172,13 +172,13 @@ describe('Example - Header Menu', () => { .should('exist'); }); - it('should open Freeze/Pinning sub-menu with 2 options expect it to be aligned to left then trigger alert when command is clicked', () => { - const subCommands = ['Freeze Columns', 'Unfreeze all Columns']; + it('should open Pinning sub-menu with 2 options expect it to be aligned to left then trigger alert when command is clicked', () => { + const subCommands = ['Pin Columns', 'Unpin all Columns']; const stub = cy.stub(); cy.on('window:alert', stub); cy.get('.slick-header-menuitem.slick-header-menuitem') - .contains('Freeze/Pinning') + .contains('Pinning') .should('exist') .click(); @@ -189,13 +189,13 @@ describe('Example - Header Menu', () => { cy.get('.slick-header-menu.slick-menu-level-1') .find('.slick-header-menuitem') - .contains('Freeze Columns') + .contains('Pin Columns') .click() - .then(() => expect(stub.getCall(0)).to.be.calledWith('Command: freeze-columns')); + .then(() => expect(stub.getCall(0)).to.be.calledWith('Command: pin-columns')); }); - it('should open Freeze/Pinning sub-menu and expect 2 options, then open Feedback->ContactUs sub-menus and expect previous Freeze menu to no longer exists', () => { - const subCommands1 = ['Freeze Columns', 'Unfreeze all Columns']; + it('should open Pinning sub-menu and expect 2 options, then open Feedback->ContactUs sub-menus and expect previous Pinning menu to no longer exists', () => { + const subCommands1 = ['Pin Columns', 'Unpin all Columns']; const subCommands2 = ['Column is great', 'Column is not useful', '', 'Contact Us']; const subCommands2_1 = ['Email us', 'Chat with us', 'Book an appointment']; @@ -211,7 +211,7 @@ describe('Example - Header Menu', () => { cy.get('.slick-header-menu.slick-menu-level-0') .find('.slick-header-menuitem.slick-header-menuitem') - .contains('Freeze/Pinning') + .contains('Pinning') .should('exist') .click(); diff --git a/cypress/e2e/example-rtl.cy.ts b/cypress/e2e/example-rtl.cy.ts index 20371365e..1b5f6b11b 100644 --- a/cypress/e2e/example-rtl.cy.ts +++ b/cypress/e2e/example-rtl.cy.ts @@ -131,34 +131,31 @@ describe('Example - RTL (Right-to-Left) Support', () => { }); it('should update visible range when scrolling in RTL', () => { - let initialFirstColumn = ''; - cy.get('.slick-header-column:visible') - .first() - .invoke('text') - .then((text) => { - initialFirstColumn = text; - }); + let initialLeftPx = 0; + cy.window().then((win) => { + const grid = (win as any).grid; + initialLeftPx = grid.getVisibleRange().leftPx; + }); cy.get('.slick-viewport') .then(($viewport) => { const viewport = $viewport[0]; viewport.scrollLeft = -300; - cy.wait(150); }); - cy.get('.slick-header-column:visible') - .first() - .invoke('text') - .should((newText) => { - expect(newText).not.to.equal(initialFirstColumn); + cy.wait(150).then(() => { + cy.window().then((win) => { + const grid = (win as any).grid; + expect(grid.getVisibleRange().leftPx).not.to.equal(initialLeftPx); }); + }); }); it('should calculate correct visible range in RTL mode', () => { cy.window().then((win) => { const grid = (win as any).grid; if (grid && grid.getVisibleRange) { - const viewport = grid._viewport; + const viewport = grid._viewport[0]; const originalScrollLeft = viewport.scrollLeft; viewport.scrollLeft = -200; const range = grid.getVisibleRange(); @@ -207,20 +204,30 @@ describe('Example - RTL (Right-to-Left) Support', () => { }); it('should scroll to the end and display last columns', () => { + let initialLeftPx = 0; + cy.window().then((win) => { + const grid = (win as any).grid; + initialLeftPx = grid.getVisibleRange().leftPx; + }); + cy.get('.slick-viewport') .then(($viewport) => { const viewport = $viewport[0]; const maxScroll = viewport.scrollWidth - viewport.clientWidth; viewport.scrollLeft = -maxScroll; - cy.wait(300); }); - cy.get('.slick-header-column:visible') - .first() - .invoke('text') - .then((text) => { - expect(text).not.to.equal('Title'); + cy.wait(300).then(() => { + cy.get('.slick-viewport').should(($viewport) => { + const viewport = $viewport[0]; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + expect(viewport.scrollLeft).to.be.closeTo(-maxScroll, 1); + }); + cy.window().then((win) => { + const grid = (win as any).grid; + expect(grid.getVisibleRange().leftPx).to.be.greaterThan(initialLeftPx); }); + }); }); }); }); diff --git a/cypress/e2e/example-sticky-financial-report.cy.ts b/cypress/e2e/example-sticky-financial-report.cy.ts new file mode 100644 index 000000000..8a2066d10 --- /dev/null +++ b/cypress/e2e/example-sticky-financial-report.cy.ts @@ -0,0 +1,201 @@ +describe('Example - Sticky Financial Report', { retries: 1 }, () => { + const grid = '#myGrid'; + const scrollOwner = `${grid} .slick-horizontal-scroller`; + const row = (index: number) => `${grid} .slick-row[data-row="${index}"]`; + const cell = (rowIndex: number, columnIndex: number) => `${row(rowIndex)} .slick-cell.l${columnIndex}.r${columnIndex}`; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-sticky-financial-report.html`); + cy.get(`${grid} .slick-viewport`).should('have.length', 1); + }); + + it('displays the financial report title and all report columns', () => { + cy.get('h2').should('contain', 'Example - Sticky Financial Report'); + cy.get(`${grid} .slick-header-column`).should('have.length', 18); + cy.get(`${grid} .slick-header-column`).then(($headers) => { + const ids = [...$headers].map((header) => header.getAttribute('data-id')); + expect(ids).to.deep.equal([ + 'account', 'jan', 'feb', 'mar', 'q1', 'apr', 'may', 'jun', 'q2', + 'jul', 'aug', 'sep', 'q3', 'oct', 'nov', 'dec', 'q4', 'ytd' + ]); + }); + }); + + it('renders the configured sticky columns at the initial viewport', () => { + cy.get(`${grid} .slick-header-column[data-id="account"]`).should('have.class', 'financial-account-header'); + for (const columnId of ['q1', 'q2', 'q3', 'q4', 'ytd']) { + cy.get(`${grid} .slick-header-column[data-id="${columnId}"]`).should('have.class', 'financial-sticky-candidate-header'); + } + cy.get(`${row(0)} .slick-cell.financial-sticky-candidate`).should('have.length.at.least', 5); + cy.get(`${row(0)} .slick-cell.financial-account-column`).should('exist'); + + // The quarters beyond the viewport dock at the right edge, and the innermost of them draws + // the separator in the header as well as in the body. + cy.get(`${grid} .slick-header-column.slick-column-sticky-right-edge`).should(($header) => { + expect(getComputedStyle($header[0]).boxShadow).not.to.equal('none'); + }); + cy.get(`${row(0)} .slick-cell-sticky-right-edge`).should(($cell) => { + expect(getComputedStyle($cell[0], '::after').boxShadow).not.to.equal('none'); + }); + }); + + it('releases Q3, Q4 and YTD at the far right, where they are in view, and docks Q2 as the left edge', () => { + cy.get(scrollOwner).scrollTo('right'); + + // All three are docked at the right edge before the scroll, so these checks pass only once + // the grid has processed it. + for (const [columnId, columnIndex] of [['q3', 12], ['q4', 16], ['ytd', 17]] as const) { + cy.get(`${grid} .slick-header-column[data-id="${columnId}"]`).should('not.have.class', 'slick-column-sticky'); + cy.get(cell(0, columnIndex)).should('not.have.class', 'slick-cell-sticky'); + } + + // The account, Q1 and Q2 have scrolled out on the left and dock there; Q2 is the innermost, + // so it draws the separator, in the header as well as in the body. + cy.get(`${grid} .slick-header-column[data-id="q2"]`).should(($header) => { + expect($header[0].classList.contains('slick-column-sticky-left-edge'), 'Q2 header is the edge of the left band').to.equal(true); + expect(getComputedStyle($header[0]).boxShadow).not.to.equal('none'); + }); + cy.get(cell(0, 8)).should(($cell) => { + expect($cell[0].classList.contains('slick-cell-sticky-left-edge'), 'Q2 cell is the edge of the left band').to.equal(true); + expect(getComputedStyle($cell[0], '::after').boxShadow).not.to.equal('none'); + }); + }); + + it('covers the body scrollbar strip in transformed header chrome while scrolling', () => { + cy.get(scrollOwner).scrollTo('right'); + + cy.get(`${grid} :is(.slick-header, .slick-headerrow, .slick-footerrow, .slick-preheader-panel, .slick-topheader-panel)`).each(($chrome) => { + const style = getComputedStyle($chrome[0], '::after'); + expect(style.zIndex, `${$chrome[0].className} cover z-index`).to.equal('30'); + expect(style.pointerEvents, `${$chrome[0].className} cover pointer events`).to.equal('none'); + expect(style.width, `${$chrome[0].className} cover width`).to.equal( + getComputedStyle($chrome[0]).getPropertyValue('--slick-docking-vertical-scrollbar-width').trim() || '0px' + ); + }); + }); + + it('keeps center cells visible when sticky columns occupy the trailing edge', () => { + cy.get(scrollOwner).scrollTo(167, 0, { ensureScrollable: false }); + + cy.get(cell(0, 12)).should('have.class', 'slick-cell-pinned-right'); + cy.get(cell(0, 9)).should(($cell) => { + const cellElement = $cell[0] as HTMLElement; + const rect = cellElement.getBoundingClientRect(); + const elementAtCenter = cellElement.ownerDocument.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + expect(elementAtCenter?.closest('.slick-cell')).to.equal(cellElement); + }); + + for (const [columnId, columnIndex] of [ + ['jul', 9], ['q2', 8], ['q3', 12], ['q4', 16], ['ytd', 17] + ] as const) { + cy.get(`${grid} .slick-header-column[data-id="${columnId}"]`).then(($header) => { + cy.get(cell(0, columnIndex)).then(($cell) => { + const headerLeft = $header[0].getBoundingClientRect().left; + const cellLeft = $cell[0].getBoundingClientRect().left; + expect(Math.abs(headerLeft - cellLeft), `${columnId}: header=${headerLeft}, cell=${cellLeft}`).to.be.lessThan(1); + }); + }); + } + }); + + it('moves two-sided sticky columns between the nearest edge while scrolling horizontally', () => { + cy.get(scrollOwner).scrollTo(0, 0, { ensureScrollable: false }); + for (const columnId of ['q2', 'q3', 'q4', 'ytd']) { + cy.get(`${grid} .slick-header-column[data-id="${columnId}"]`) + .should('have.class', 'slick-column-sticky') + .and('have.class', 'slick-column-pinned-right'); + } + + cy.get(scrollOwner).scrollTo('50%', 0); + for (const columnId of ['account', 'q1']) { + cy.get(`${grid} .slick-header-column[data-id="${columnId}"]`) + .should('have.class', 'slick-column-sticky') + .and('have.class', 'slick-column-pinned-left'); + } + for (const columnId of ['q3', 'q4', 'ytd']) { + cy.get(`${grid} .slick-header-column[data-id="${columnId}"]`) + .should('have.class', 'slick-column-sticky') + .and('have.class', 'slick-column-pinned-right'); + } + + cy.get(scrollOwner).scrollTo('right'); + for (const columnId of ['account', 'q1', 'q2']) { + cy.get(`${grid} .slick-header-column[data-id="${columnId}"]`) + .should('have.class', 'slick-column-sticky') + .and('have.class', 'slick-column-pinned-left'); + } + }); + + it('continues resizing a sticky column through multiple pointer moves', () => { + cy.get(scrollOwner).scrollTo(0, 0, { ensureScrollable: false }); + cy.get(`${grid} .slick-header-column[data-id="q2"] .slick-resizable-handle`) + .should('exist') + .then(($handle) => { + const header = $handle.closest('.slick-header-column')[0] as HTMLElement; + const initialWidth = header.getBoundingClientRect().width; + cy.wrap($handle).trigger('mousedown', { which: 1, pageX: 100, clientX: 100 }); + cy.get('body').trigger('mousemove', { which: 1, pageX: 125, clientX: 125 }); + cy.get('body').trigger('mousemove', { which: 1, pageX: 150, clientX: 150 }); + cy.get('body').trigger('mouseup', { which: 1, pageX: 150, clientX: 150 }); + cy.get(`${grid} .slick-header-column[data-id="q2"]`).should(($updatedHeader) => { + expect($updatedHeader[0].getBoundingClientRect().width).to.be.greaterThan(initialWidth); + }); + }); + }); + + it('resizes a sticky column docked at the leading edge', () => { + cy.get(scrollOwner).scrollTo('right'); + cy.get(`${grid} .slick-header-column[data-id="account"]`) + .should('have.class', 'slick-column-sticky') + .and('have.class', 'slick-column-pinned-left') + .find('.slick-resizable-handle') + .then(($handle) => { + const header = $handle.closest('.slick-header-column')[0] as HTMLElement; + const initialWidth = header.getBoundingClientRect().width; + cy.wrap($handle).trigger('mousedown', { which: 1, pageX: 100, clientX: 100 }); + cy.get('body').trigger('mousemove', { which: 1, pageX: 125, clientX: 125 }); + cy.get('body').trigger('mousemove', { which: 1, pageX: 150, clientX: 150 }); + cy.get('body').trigger('mouseup', { which: 1, pageX: 150, clientX: 150 }); + cy.get(`${grid} .slick-header-column[data-id="account"]`).should(($updatedHeader) => { + expect($updatedHeader[0].getBoundingClientRect().width).to.be.greaterThan(initialWidth); + }); + }); + }); + + it('scrolls to the natural position before activating a sticky column with ArrowRight', () => { + cy.get(scrollOwner).scrollTo(0, 0, { ensureScrollable: false }); + cy.get(`${grid} .slick-header-column[data-id="q2"]`) + .should('have.class', 'slick-column-sticky') + .and('have.class', 'slick-column-pinned-right'); + // A right-docked sticky column covers the natural cell beneath it, so the click that + // sets the active cell has to bypass the actionability check. + cy.get(cell(0, 7)).should('exist').click({ force: true }); + cy.get(scrollOwner).invoke('prop', 'scrollLeft').then((beforeScroll) => { + cy.get(cell(0, 7)).type('{rightarrow}', { force: true }); + cy.get(cell(0, 8)).should('have.class', 'active'); + cy.get(scrollOwner).should(($scroller) => { + expect(Math.abs(($scroller[0] as HTMLElement).scrollLeft - Number(beforeScroll))).to.be.greaterThan(0); + }); + }); + }); + + it('keeps sticky summary rows keyboard-addressable after scrolling to the report totals', () => { + cy.get(scrollOwner).scrollTo('right'); + cy.get(`${grid} .slick-vertical-scroller`).scrollTo('bottom'); + cy.get(row(20)).should('exist'); + cy.get(cell(20, 0)).click().type('{downarrow}'); + cy.get(`${row(21)} .slick-cell.active`).should('exist'); + }); + + it('docks all three summary rows to the bottom after they have been seen', () => { + const viewport = `${grid} .slick-vertical-scroller`; + cy.get(viewport).scrollTo('bottom'); + for (const rowIndex of [20, 21, 22]) { + cy.get(row(rowIndex)).should('exist'); + } + cy.get(viewport).scrollTo(0, 220); + for (const rowIndex of [20, 21, 22]) { + cy.get(row(rowIndex)).should('have.class', 'slick-row-sticky').and('have.class', 'slick-row-pinned-bottom'); + } + }); +}); diff --git a/cypress/e2e/example-variable-row-height-frozen.cy.ts b/cypress/e2e/example-variable-row-height-frozen.cy.ts deleted file mode 100644 index d3cd53122..000000000 --- a/cypress/e2e/example-variable-row-height-frozen.cy.ts +++ /dev/null @@ -1,57 +0,0 @@ -describe('Example - Variable Row Height with Frozen Columns/Rows', { retries: 1 }, () => { - // must mirror the example page: every 13th row 70px, every 5th row 32px, else 25px - const hOf = (r: number) => (r % 13 === 0) ? 70 : (r % 5 === 0) ? 32 : 25; - const sum = (from: number, to: number) => { // [from, to) - let t = 0; - for (let r = from; r < to; r++) { t += hOf(r); } - return t; - }; - - it('should display Example title', () => { - cy.visit(`${Cypress.config('baseUrl')}/examples/example-variable-row-height-frozen.html`); - cy.get('h2').contains('Variable row height + frozen panes'); - }); - - it('should pass the in-page frozen geometry self-checks', () => { - cy.contains('button', 'Run frozen geometry self-checks').click(); - cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); - }); - - it('should size grid B frozen pane to the sum of its frozen row heights', () => { - // rows 0..2 = 70 + 25 + 25 = 120 - cy.get('#gridB .grid-canvas').first().invoke('css', 'height').then(h => { - expect(parseFloat(`${h}`)).to.be.closeTo(sum(0, 3), 1); - }); - }); - - it('should reflow both column panes when a grid A row grows (invalidateRowHeights)', () => { - cy.contains('button', 'A: grow row 2').click(); - // row 3 shifts down by the 10px added to row 2, in BOTH column panes - cy.get('#gridA .grid-canvas').eq(0).find('.slick-row[data-row=3]') - .should('have.css', 'top', `${sum(0, 3) + 10}px`); - cy.get('#gridA .grid-canvas').eq(1).find('.slick-row[data-row=3]') - .should('have.css', 'top', `${sum(0, 3) + 10}px`); - cy.contains('button', 'Run frozen geometry self-checks').click(); - cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); - }); - - it('should resize grid B frozen pane when a frozen row grows (invalidateRowHeights)', () => { - cy.contains('button', 'B: grow frozen row 0').click(); - cy.get('#gridB .grid-canvas').first().invoke('css', 'height').then(h => { - expect(parseFloat(`${h}`)).to.be.closeTo(sum(0, 3) + 10, 1); - }); - cy.contains('button', 'Run frozen geometry self-checks').click(); - cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); - }); - - it('should scroll both panes to a far row (top) keeping pane agreement', () => { - cy.contains('button', 'Scroll both to row 300').click(); - // scrollRowToTop lands row 300 as the first visible row in both grids' scrolling panes - cy.window().should(win => { - expect((win as any).gridA.getViewport().top).to.eq(300); - expect((win as any).gridB.getViewport().top).to.eq(300); - }); - cy.contains('button', 'Run frozen geometry self-checks').click(); - cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); - }); -}); diff --git a/cypress/e2e/example-variable-row-height-pinning.cy.ts b/cypress/e2e/example-variable-row-height-pinning.cy.ts new file mode 100644 index 000000000..cacdaac38 --- /dev/null +++ b/cypress/e2e/example-variable-row-height-pinning.cy.ts @@ -0,0 +1,68 @@ +describe('Example - Variable Row Height with Pinned Columns/Rows', { retries: 1 }, () => { + // must mirror the example page: every 13th row 70px, every 5th row 32px, else 25px + const hOf = (r: number) => (r % 13 === 0) ? 70 : (r % 5 === 0) ? 32 : 25; + const sum = (from: number, to: number) => { // [from, to) + let t = 0; + for (let r = from; r < to; r++) { t += hOf(r); } + return t; + }; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-variable-row-height-pinning.html`); + cy.get('h2').contains('Variable row height + pinning'); + }); + + it('should pass the in-page pinning geometry self-checks', () => { + cy.contains('button', 'Run pinning geometry self-checks').click(); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); + + it('should use one shared canvas while preserving the configured pinning', () => { + cy.get('#gridA .grid-canvas').should('have.length', 1); + cy.get('#gridB .grid-canvas').should('have.length', 1); + cy.window().then(win => { + const { gridA, gridB } = win as any; + expect(gridA.getPinnedColumns('left')).to.have.length(2); + expect(gridB.getPinnedColumns('left')).to.have.length(2); + expect(gridB.getOptions().pinning.rows.top).to.have.length(3); + }); + }); + + it('should reflow Grid A when a row grows (invalidateRowHeights)', () => { + cy.contains('button', 'A: grow row 2').click(); + // Grid A uses one canvas; row 3 shifts by the 10px added to row 2. + cy.get('#gridA .grid-canvas').find('.slick-row[data-row=3]') + .should('have.css', 'top', `${sum(0, 3) + 10}px`); + cy.contains('button', 'Run pinning geometry self-checks').click(); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); + + it('should resize Grid B when a pinned row grows (invalidateRowHeights)', () => { + cy.contains('button', 'B: grow pinned row 0').click(); + cy.get('#gridB .slick-docking-overlay .slick-row[data-row=0]') + .invoke('outerHeight') + .should('be.closeTo', hOf(0) + 10, 1); + cy.contains('button', 'Run pinning geometry self-checks').click(); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); + + it('should scroll both shared viewports to a far row while accounting for pinned rows', () => { + cy.contains('button', 'Scroll both to row 300').click(); + cy.window().then(win => { + const { gridA, gridB } = win as any; + const pinnedTopHeight = gridB.getOptions().pinning.rows.top.reduce( + (height: number, row: number) => height + gridB.getRowHeight(row), + 0 + ); + + cy.get('#gridA .slick-vertical-scroller').should($viewport => { + expect($viewport[0].scrollTop).to.be.closeTo(gridA.getRowTop(300), 2); + }); + cy.get('#gridB .slick-vertical-scroller').should($viewport => { + expect($viewport[0].scrollTop).to.be.closeTo(gridB.getRowTop(300) - pinnedTopHeight, 2); + }); + }); + cy.contains('button', 'Run pinning geometry self-checks').click(); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/example-variable-row-height-spans.cy.ts b/cypress/e2e/example-variable-row-height-spans.cy.ts index 3e7775fc5..0fa5e8f6b 100644 --- a/cypress/e2e/example-variable-row-height-spans.cy.ts +++ b/cypress/e2e/example-variable-row-height-spans.cy.ts @@ -48,10 +48,18 @@ describe('Example - Variable Row Height with Cell Spans', { retries: 1 }, () => it('should scroll a far span head to the top with consistent geometry', () => { cy.contains('button', 'Scroll far span').click(); - // scrollRowToTop lands row 299 as the first visible row (not merely into view at the bottom) - cy.window().should(win => { - expect((win as any).grid.getViewport().top).to.eq(299); + // Check the physical scroll position against the grid's measured row top. + // Converting that boundary back to a row index is sensitive to a 1px browser + // rounding difference when variable-height rows are involved, and can report + // the preceding row even though the span head is correctly positioned. + cy.window().then(win => { + const grid = (win as any).grid; + const expectedScrollTop = grid.getRowTop(299); + cy.get('#myGrid .slick-vertical-scroller').should($viewport => { + expect($viewport[0].scrollTop).to.be.closeTo(expectedScrollTop, 2); + }); }); + cy.get('#myGrid .slick-row[data-row=299]').should('exist'); cy.get('#myGrid .slick-row[data-row=299] > .slick-cell.l0') .invoke('outerHeight') .should('be.closeTo', spanSum(299, 3), 1); diff --git a/cypress/e2e/example11-autoheight.cy.ts b/cypress/e2e/example11-autoheight.cy.ts new file mode 100644 index 000000000..d50756a00 --- /dev/null +++ b/cypress/e2e/example11-autoheight.cy.ts @@ -0,0 +1,19 @@ +describe('Example 11 - AutoHeight', () => { + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example11-autoheight.html`); + }); + + it('does not leave an empty header-sized band below the last row', () => { + cy.get('#container .slick-row[data-row="99"]').should('be.visible'); + cy.get('#container').then(($container) => { + const container = $container[0]; + const headerRoot = container.querySelector('.slick-header-root') as HTMLElement; + const contentRoot = container.querySelector('.slick-content-root') as HTMLElement; + + // The container is sized from the two live roots. This catches the old + // auto-height calculation which counted the header once in the viewport + // and again when sizing the container. + expect(Math.abs(container.clientHeight - headerRoot.offsetHeight - contentRoot.offsetHeight), 'auto-height root sizing').to.be.lessThan(3); + }); + }); +}); diff --git a/cypress/e2e/headers-width-scroll-sync.cy.ts b/cypress/e2e/headers-width-scroll-sync.cy.ts index 1f6819a49..a954828bc 100644 --- a/cypress/e2e/headers-width-scroll-sync.cy.ts +++ b/cypress/e2e/headers-width-scroll-sync.cy.ts @@ -4,12 +4,13 @@ * * 1. the header band's scroll range covers the body viewport's scroll range * (header width acts as the scroll-range floor), and - * 2. after scrolling the body fully right, the header scroller lands on the - * same scrollLeft (no clamping), and + * 2. after scrolling fully right, the active horizontal scroll owner reaches + * its maximum (no clamping), and * 3. the last column's header stays pixel-aligned with its body cells there. * - * Pinned across the three width regimes: plain grid, frozen columns (the right - * band scrolls), and autoHeight (no vertical scrollbar, so no gutter term). + * Pinned across the three width regimes: plain grid, pinned columns (the + * center band scrolls through the docking scroller), and autoHeight (no + * vertical scrollbar, so no gutter term). * This spec is expected to pass BEFORE and AFTER any getHeadersWidth change — * it exists so refactors of the width formula (e.g. the removal of the * historical duplicate scrollbar addition) cannot silently break scroll sync. @@ -24,13 +25,13 @@ const harnessHtml = ` Harness: headers width scroll sync
-
+
@@ -51,11 +52,14 @@ const harnessHtml = ` return d; } var baseOptions = { enableCellNavigation: true, enableColumnReorder: false, rowHeight: 25 }; + function cloneColumns() { + return columns.map(function (column) { return Object.assign({}, column); }); + } - var gridPlain = new Slick.Grid('#gridPlain', makeData(30), columns, baseOptions); - var gridFrozen = new Slick.Grid('#gridFrozen', makeData(30), columns, - Object.assign({}, baseOptions, { frozenColumn: 1 })); - var gridAuto = new Slick.Grid('#gridAuto', makeData(8), columns, + var gridPlain = new Slick.Grid('#gridPlain', makeData(30), cloneColumns(), baseOptions); + var gridPinned = new Slick.Grid('#gridPinned', makeData(30), cloneColumns(), + Object.assign({}, baseOptions, { pinning: { columns: { left: 1 } } })); + var gridAuto = new Slick.Grid('#gridAuto', makeData(8), cloneColumns(), Object.assign({}, baseOptions, { autoHeight: true })); window.grid = gridPlain; @@ -76,25 +80,62 @@ const harnessHtml = ` function checkGrid(name, containerSel, headerScrollerSel, viewportSel) { var container = document.querySelector(containerSel); + if (!container) { + check(name + ': grid container exists', false, containerSel + ' not found'); + return Promise.resolve(); + } var headerScroller = container.querySelector(headerScrollerSel); - var headersDiv = headerScroller.querySelector('.slick-header-columns'); + // Docked grids keep the measurable width on the root; the nested + // left/center/right regions use display: contents and report width 0. + var headersDiv = container.querySelector('.slick-header-columns-root') || container.querySelector('.slick-header-columns'); var viewport = container.querySelector(viewportSel); + var dockingScroller = container.querySelector('.slick-docking-horizontal-scroller'); + var scrollOwner = dockingScroller || viewport; + + if (!headerScroller || !headersDiv || !viewport || !scrollOwner) { + check(name + ': current single-viewport header/body elements exist', false, + 'header=' + !!headerScroller + ' columns=' + !!headersDiv + ' viewport=' + !!viewport + ' scrollOwner=' + !!scrollOwner); + return Promise.resolve(); + } var headerRange = headersDiv.getBoundingClientRect().width - headerScroller.clientWidth; + // The proxy-scrolled header root does not include the vertical scrollbar + // strip in its width, while the body viewport's scroll range does. Add + // that strip back when comparing the two ranges. + if (dockingScroller) { + headerRange += Math.max(0, headerScroller.clientWidth - viewport.clientWidth); + } var bodyRange = viewport.scrollWidth - viewport.clientWidth; check(name + ': header scroll range covers body scroll range', headerRange >= bodyRange, 'headerRange=' + Math.round(headerRange) + ' bodyRange=' + Math.round(bodyRange)); - viewport.scrollLeft = 1000000; + scrollOwner.scrollLeft = 1000000; return settle().then(function () { - check(name + ': header scroller reaches the body scrollLeft at full right scroll', - headerScroller.scrollLeft === viewport.scrollLeft, - 'header=' + headerScroller.scrollLeft + ' body=' + viewport.scrollLeft); + var bodyScrollLeft = scrollOwner.scrollLeft; + var bodyMaxScrollLeft = scrollOwner.scrollWidth - scrollOwner.clientWidth; + check(name + ': horizontal scroll owner reaches the full right edge', + Math.abs(bodyScrollLeft - bodyMaxScrollLeft) <= 1, + 'scrollLeft=' + bodyScrollLeft + ' max=' + bodyMaxScrollLeft); + + // The header content is translated by -scrollLeft (the header scroller itself stays at 0). + var TRANSFORM_PREFIX = 'translate3d('; + var translated = Array.prototype.find.call(headerScroller.querySelectorAll('*'), function (el) { + return el.style.transform.indexOf(TRANSFORM_PREFIX) === 0; + }); + var headerShift = translated ? parseFloat(translated.style.transform.slice(TRANSFORM_PREFIX.length)) : NaN; + check(name + ': header content is shifted by the scroll owner position at full right scroll', + Math.abs(headerShift + bodyScrollLeft) <= 1 && headerScroller.scrollLeft === 0, + 'headerShift=' + headerShift + ' headerScrollLeft=' + headerScroller.scrollLeft + ' body=' + bodyScrollLeft); - var lastHeader = headerScroller.querySelectorAll('.slick-header-column'); + var lastHeader = container.querySelectorAll('.slick-header-column'); lastHeader = lastHeader[lastHeader.length - 1]; var lastCell = viewport.querySelector('.slick-row .slick-cell.l14.r14'); + if (!lastHeader || !lastCell) { + check(name + ': last header and body cell exist', false, + 'header=' + !!lastHeader + ' cell=' + !!lastCell); + return; + } var dh = lastHeader.getBoundingClientRect().left; var dc = lastCell.getBoundingClientRect().left; check(name + ': last column header aligns with its body cells at full right scroll', @@ -103,12 +144,12 @@ const harnessHtml = ` }); } - return checkGrid('plain', '#gridPlain', '.slick-header-left', '.slick-viewport-top.slick-viewport-left') + return checkGrid('plain', '#gridPlain', '.slick-header-left', '.slick-viewport') .then(function () { - return checkGrid('frozen', '#gridFrozen', '.slick-header-right', '.slick-viewport-top.slick-viewport-right'); + return checkGrid('pinned', '#gridPinned', '.slick-header-left', '.slick-viewport'); }) .then(function () { - return checkGrid('autoHeight', '#gridAuto', '.slick-header-left', '.slick-viewport-top.slick-viewport-left'); + return checkGrid('autoHeight', '#gridAuto', '.slick-header-left', '.slick-viewport'); }) .then(function () { out.push(pass ? '\\nALL CHECKS PASSED' : '\\nCHECKS FAILED'); @@ -121,7 +162,7 @@ const harnessHtml = ` `; describe('getHeadersWidth - header/body horizontal scroll sync pin', { retries: 1 }, () => { - it('should keep header scroll range, sync and alignment across plain, frozen and autoHeight grids', () => { + it('should keep header scroll range and alignment across plain, pinned and autoHeight grids', () => { cy.intercept('GET', '/headers-width-scroll-sync-harness.html', { headers: { 'content-type': 'text/html' }, body: harnessHtml, diff --git a/cypress/e2e/quirk-always-render-column-routing.cy.ts b/cypress/e2e/quirk-always-render-column-routing.cy.ts index b807b207d..e6328aa9f 100644 --- a/cypress/e2e/quirk-always-render-column-routing.cy.ts +++ b/cypress/e2e/quirk-always-render-column-routing.cy.ts @@ -2,22 +2,21 @@ * Regression test for off-viewport alwaysRenderColumn band routing. * * appendRowHtml has two cell-routing branches. The in-viewport branch routes by - * column band (left fragment vs right fragment under a left freeze). The + * column band (pinned-left region vs scrolling region). The * OFF-VIEWPORT branch — taken when a column has scrolled out past the LEFT edge — - * appended alwaysRenderColumn cells to the left fragment unconditionally. So an - * alwaysRenderColumn column sitting RIGHT of the freeze rendered its off-viewport - * cells into the clipped LEFT canvas: mispositioned/invisible, and its + * appended alwaysRenderColumn cells to the pinned-left region unconditionally. + * So an alwaysRenderColumn column sitting RIGHT of the pin rendered its + * off-viewport cells into the clipped left region: mispositioned/invisible, and its * cellNodesByColumnIdx entry mapped to a node in the wrong pane (editors, plugins * and getCellNode all consume that mapping). * * The spec is SELF-HOSTING: the harness is served from this file via cy.intercept - * (no page is added to examples/). It freezes column 0, marks a middle scrollable + * (no page is added to examples/). It pins column 0, marks a middle scrollable * column alwaysRenderColumn, scrolls it off the left edge via the grid API, and - * asserts the cell node lives in the RIGHT canvas. Verified to FAIL pre-fix - * (node parented in .grid-canvas-left) and PASS with the fix. + * asserts the cell node remains in the scrolling region. */ -const ARC_COL = 5; // the alwaysRenderColumn column index (right of the freeze) +const ARC_COL = 5; // the alwaysRenderColumn column index (right of the pinned band) const harnessHtml = ` @@ -48,7 +47,7 @@ const harnessHtml = ` var grid = new Slick.Grid('#myGrid', data, columns, { enableCellNavigation: true, enableColumnReorder: false, - frozenColumn: 0, // column 0 frozen-left; ARC (index ${ARC_COL}) is in the RIGHT band + pinning: { columns: { left: 0 } }, // column 0 pinned-left; ARC stays in the scrolling band rowHeight: 25 }); window.grid = grid; @@ -66,18 +65,17 @@ const harnessHtml = ` var node = grid.getCellNode(80, ARC); check('alwaysRenderColumn cell renders on a freshly-scrolled-in row', !!node, node ? 'present' : 'missing'); if (node) { - var canvas = node.closest('.grid-canvas'); - var inRight = !!canvas && canvas.classList.contains('grid-canvas-right'); - check('off-viewport alwaysRenderColumn cell lives in the RIGHT canvas (its own band)', - inRight, 'canvas=' + (canvas ? canvas.className : 'none')); + var scrollingRegion = node.closest('.slick-scrolling-cells'); + check('off-viewport alwaysRenderColumn cell lives in the scrolling region (its own band)', + !!scrollingRegion, 'region=' + (scrollingRegion ? scrollingRegion.className : 'none')); } - // control: the frozen-left column (index 0) of the same fresh row stays LEFT - var frozenNode = grid.getCellNode(80, 0); - var frozenCanvas = frozenNode && frozenNode.closest('.grid-canvas'); - check('control: frozen-left column cell stays in the LEFT canvas', - !!frozenCanvas && frozenCanvas.classList.contains('grid-canvas-left'), - 'canvas=' + (frozenCanvas ? frozenCanvas.className : 'none')); + // control: the pinned-left column (index 0) of the same fresh row stays LEFT + var pinnedNode = grid.getCellNode(80, 0); + var pinnedRegion = pinnedNode && pinnedNode.closest('.slick-pinned-left-cells'); + check('control: pinned-left column cell stays in the pinned-left region', + !!pinnedRegion, + 'region=' + (pinnedRegion ? pinnedRegion.className : 'none')); out.push(pass ? '\\nALL CHECKS PASSED' : '\\nCHECKS FAILED'); document.getElementById('checkResults').textContent = out.join('\\n'); @@ -88,7 +86,7 @@ const harnessHtml = ` `; describe('Quirk - off-viewport alwaysRenderColumn cells must render in their own column band', { retries: 1 }, () => { - it('should keep the always-rendered right-band cell in the right canvas when scrolled off-left', () => { + it('should keep the always-rendered scrolling-band cell in its region when scrolled off-left', () => { cy.intercept('GET', '/quirk-always-render-column-harness.html', { headers: { 'content-type': 'text/html' }, body: harnessHtml, diff --git a/cypress/e2e/quirk-destroy-element-references.cy.ts b/cypress/e2e/quirk-destroy-element-references.cy.ts new file mode 100644 index 000000000..cd43e48eb --- /dev/null +++ b/cypress/e2e/quirk-destroy-element-references.cy.ts @@ -0,0 +1,101 @@ +/** + * Regression test for `destroy(true)`. + * + * With `shouldDestroyAllElements` the grid must drop every DOM reference it holds so an + * application that keeps the grid instance after destroying it does not retain the detached + * tree. The references are cleared by content (elements, arrays of elements, records of + * elements), so this harness checks a sample of single, array and record fields plus a + * docking grid whose overlay/proxy fields only exist when pinning is configured. + */ + +const harnessHtml = ` + + + + Harness: destroy element references + + + + +
+
+
+ + + + + +`; + +describe('Quirk - destroy(true) must drop every DOM reference', { retries: 1 }, () => { + it('should null element fields on plain and pinned grids', () => { + cy.intercept('GET', '/quirk-destroy-element-references-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-destroy-element-references-harness.html`); + cy.window().its('gridPlain').should('exist'); + cy.window().its('gridPinned').should('exist'); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page destroy self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-docking-header-scrollbar-corner.cy.ts b/cypress/e2e/quirk-docking-header-scrollbar-corner.cy.ts new file mode 100644 index 000000000..12b44613a --- /dev/null +++ b/cypress/e2e/quirk-docking-header-scrollbar-corner.cy.ts @@ -0,0 +1,108 @@ +describe('Quirk - header chrome above the vertical scrollbar', () => { + // Header chrome spans the viewport's full width, including the strip above the vertical + // scrollbar, while its content translates with the horizontal scroll. A cover hides that + // strip. It lets clicks through, and elementFromPoint skips anything that does, so every + // pseudo-element is made hit-testable while sampling: a point then resolves to whatever is + // painted on top of it. + const paintedAt = (doc: Document, x: number, y: number) => { + const style = doc.createElement('style'); + style.textContent = '#myGrid *::before, #myGrid *::after { pointer-events: auto !important; }'; + doc.head.appendChild(style); + const topmost = doc.elementFromPoint(x, y) as HTMLElement | null; + style.remove(); + return topmost; + }; + + const isOpaque = (color: string) => { + const channels = /rgba?\(([^)]+)\)/.exec(color)?.[1].split(',') || []; + return channels.length === 3 || (channels.length === 4 && Number(channels[3]) === 1); + }; + + const titleShownAboveScrollbar = (win: any, container: string, cellSelector: string): string[] => { + const doc = win.document; + const viewport = doc.querySelector('#myGrid .slick-viewport') as HTMLElement; + const holder = doc.querySelector(`#myGrid ${container}`) as HTMLElement; + const gutter = viewport.offsetWidth - viewport.clientWidth; + const rtl = doc.querySelector('#myGrid')!.classList.contains('slick-rtl'); + const vp = viewport.getBoundingClientRect(); + const box = holder.getBoundingClientRect(); + // Sample the whole strip, not only its middle, so a title's edge cannot slip past. + const stripStart = rtl ? vp.left : vp.right - gutter; + const found: string[] = []; + for (let x = stripStart + 1; x < stripStart + gutter; x += 3) { + const topmost = paintedAt(doc, x, box.top + box.height / 2); + const cell = topmost?.closest(cellSelector) as HTMLElement | null; + if (cell) { + found.push(cell.getAttribute('data-id') || cell.className.split(' ').join('.')); + } else if (topmost && getComputedStyle(topmost, '::after').content !== 'none' && !isOpaque(getComputedStyle(topmost, '::after').backgroundColor)) { + found.push(`a see-through cover on ${topmost.className.split(' ').join('.')}`); + } + } + return found; + }; + + const sweep = (page: string, checks: Array<[string, string]>, setup?: (win: any) => void) => { + cy.visit(`${Cypress.config('baseUrl')}/examples/${page}.html`); + cy.get('#myGrid .slick-header-column').should('exist'); + if (setup) { + cy.window().then((win: any) => setup(win)); + } + cy.window().then((win: any) => { + const doc = win.document; + const viewport = doc.querySelector('#myGrid .slick-viewport') as HTMLElement; + expect(viewport.offsetWidth - viewport.clientWidth, 'the example shows a vertical scrollbar').to.be.greaterThan(0); + + const scroller = doc.querySelector('#myGrid .slick-docking-horizontal-scroller') as HTMLElement; + const rtl = doc.querySelector('#myGrid')!.classList.contains('slick-rtl'); + const max = scroller.scrollWidth - scroller.clientWidth; + const leaks: string[] = []; + for (let step = 0; step <= 20; step++) { + const target = Math.round((max * step) / 20); + scroller.scrollLeft = rtl ? -target : target; + scroller.dispatchEvent(new win.Event('scroll')); + checks.forEach(([container, cellSelector]) => { + const shown = titleShownAboveScrollbar(win, container, cellSelector); + if (shown.length) { + leaks.push(`${container} at ${step * 5}%: ${Array.from(new Set(shown)).join(', ')}`); + } + }); + } + expect(leaks, 'header content drawn above the vertical scrollbar').to.deep.eq([]); + }); + }; + + it('keeps the header clear above the scrollbar, left to right, with sticky columns', () => { + sweep('example-sticky-financial-report', [['.slick-header', '.slick-header-column']]); + }); + + it('keeps the header and header row clear above the scrollbar, left to right, with left pins only', () => { + // A right-pinned band sits at the trailing edge and happens to cover the strip, so this + // example drops its right pins to let the scrolling columns reach it. + sweep( + 'example-pinning-columns-and-rows', + [ + ['.slick-header', '.slick-header-column'], + ['.slick-headerrow', '.slick-headerrow-column'], + ], + (win) => win.grid.setOptions({ pinning: { columns: { left: 1, right: 0 } } }) + ); + }); + + it('keeps the header clear above the scrollbar, right to left, where the scrollbar is on the left', () => { + sweep('example-pinning-rtl', [['.slick-header', '.slick-header-column']]); + }); + + it('keeps the header clear above the scrollbar with the classic stylesheet, where the header is positioned', () => { + sweep('example-pinning-columns-tabs', [['.slick-header', '.slick-header-column']]); + }); + + it('keeps the grid menu button visible above the cover', () => { + // The grid menu narrows the header and places its button above the scrollbar on purpose. + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns-and-column-group.html`); + cy.get('#myGrid .slick-gridmenu-button').should(($button) => { + const rect = $button[0].getBoundingClientRect(); + const topmost = paintedAt($button[0].ownerDocument, rect.left + rect.width / 2, rect.top + rect.height / 2); + expect(topmost === $button[0] || $button[0].contains(topmost), 'the grid menu button is painted on top').to.eq(true); + }); + }); +}); diff --git a/cypress/e2e/quirk-docking-scroll-owner.cy.ts b/cypress/e2e/quirk-docking-scroll-owner.cy.ts new file mode 100644 index 000000000..e6e6371f1 --- /dev/null +++ b/cypress/e2e/quirk-docking-scroll-owner.cy.ts @@ -0,0 +1,115 @@ +describe('Quirk - docked elements never exist without the proxy scroll owner', () => { + // The grid has one horizontal scroll path: the proxy scrollbar, which positions pinned + // rows and chrome. Nothing may be rendered as docked unless that scroll owner exists. + // + // Note what this does NOT claim. Configured docking and the proxy scrollbar are not + // always in step: setOptions() with suppressColumnSet skips the lazy creation in + // setColumns(), leaving pinning configured and resolved into bands with no scrollbar. + // Nothing is rendered as docked in that state - no docked rows, and an empty chrome + // map - which is what makes the single scroll path safe. + const states: Array<[string, (grid: any) => void]> = [ + ['as loaded', () => undefined], + ['pinning added', (grid) => grid.setOptions({ pinning: { columns: { left: 2 } } })], + ['pinning removed', (grid) => grid.setOptions({ pinning: null })], + ['pinning re-added', (grid) => grid.setOptions({ pinning: { columns: { left: 1, right: 1 } } })], + ['sticky only', (grid) => { + grid.setOptions({ pinning: null }); + const columns = grid.getColumns(); + columns[1].sticky = true; + grid.setColumns(columns); + }], + ['configured with the column reset suppressed', (grid) => { + grid.setOptions({ pinning: null }); + grid.setOptions({ pinning: { columns: { left: 2 } } }, false, true); + }], + ]; + + const expectNothingDockedWithoutTheScroller = (win: any, label: string) => { + const grid = win.grid; + if (grid.hasDockingHorizontalScroller()) { + return; + } + expect(win.document.querySelectorAll('.slick-row-docked').length, `${label}: no docked rows`).to.eq(0); + expect(win.document.querySelectorAll('.slick-header-column.slick-column-pinned-left').length, `${label}: no pinned headers`).to.eq(0); + expect(win.document.querySelectorAll('.slick-header-column.slick-column-pinned-right').length, `${label}: no pinned headers`).to.eq(0); + + // The chrome map is a lookup cache and may still hold elements from an earlier + // docking; what matters is that no band entry resolves to one, because that pairing + // is what a scroll would have had to reposition. + const bands = [...grid.dockingLayout.left, ...grid.dockingLayout.right]; + const dockedChrome = bands.reduce((total: number, entry: any) => total + (grid.dockingChromeByColumn?.get(entry.index)?.length || 0), 0); + expect(dockedChrome, `${label}: no band resolves to chrome that would need repositioning`).to.eq(0); + }; + + ['example-pinning-columns-and-rows', 'example1-simple'].forEach((page) => { + it(`holds through every docking change on ${page}`, () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/${page}.html`); + cy.get('#myGrid .slick-header-column').should('exist'); + + states.forEach(([label, apply]) => { + cy.window().then((win: any) => { + apply(win.grid); + }); + cy.window().should((win: any) => expectNothingDockedWithoutTheScroller(win, label)); + }); + }); + }); + + it('keeps holding while the grid is scrolled with pinning configured but not applied', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple.html`); + cy.get('#myGrid .slick-header-column').should('exist'); + + cy.window().then((win: any) => { + win.grid.setOptions({ pinning: { columns: { left: 2 } } }, false, true); + const viewport = win.document.querySelector('#myGrid .slick-viewport') as HTMLElement; + viewport.scrollLeft = 250; + viewport.dispatchEvent(new win.Event('scroll')); + // A vertical scroll renders rows that were never rendered before, which is what + // would build docked rows if the layout were applied without its scroll owner. + viewport.scrollTop = 3000; + viewport.dispatchEvent(new win.Event('scroll')); + }); + cy.window().should((win: any) => { + expect(win.grid.hasConfiguredDocking(), 'pinning is configured').to.eq(true); + expect(win.grid.hasDockingHorizontalScroller(), 'but no scroll owner was created').to.eq(false); + expect(win.document.querySelectorAll('#myGrid .grid-canvas .slick-row').length, 'new rows were rendered').to.be.greaterThan(0); + expectNothingDockedWithoutTheScroller(win, 'scrolled while half-configured'); + }); + + // The column reset is what applies it, and it brings the scroll owner with it. + cy.window().then((win: any) => win.grid.setColumns(win.grid.getColumns())); + cy.window().should((win: any) => { + expect(win.grid.hasDockingHorizontalScroller(), 'setColumns creates the scroll owner').to.eq(true); + expect(win.document.querySelectorAll('.slick-row-docked').length, 'and rows dock').to.be.greaterThan(0); + }); + }); + + it('keeps holding when a sticky column crosses the edge before setColumns() has run', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple.html`); + cy.get('#myGrid .slick-header-column').should('exist'); + + // Wide enough to scroll horizontally, with no docking, so the viewport owns the scroll. + cy.window().then((win: any) => { + const columns = win.grid.getColumns(); + columns.forEach((column: any) => (column.width = 260)); + win.grid.setColumns(columns); + // Marked sticky on the live definitions, without the column reset that applies it. + win.grid.getColumns()[1].sticky = true; + const viewport = win.document.querySelector('#myGrid .slick-viewport') as HTMLElement; + viewport.scrollLeft = 520; + viewport.dispatchEvent(new win.Event('scroll')); + }); + // The sticky layout is resolved on the next animation frame; give it one. + cy.wait(100); + cy.window().should((win: any) => { + expect(win.grid.hasDockingHorizontalScroller(), 'no scroll owner yet').to.eq(false); + expectNothingDockedWithoutTheScroller(win, 'sticky column crossed the edge before setColumns'); + }); + + cy.window().then((win: any) => win.grid.setColumns(win.grid.getColumns())); + cy.window().should((win: any) => { + expect(win.grid.hasDockingHorizontalScroller(), 'setColumns creates the scroll owner').to.eq(true); + expect(win.document.querySelectorAll('.slick-row-docked').length, 'and rows dock').to.be.greaterThan(0); + }); + }); +}); diff --git a/cypress/e2e/quirk-docking-scrollbar-resize.cy.ts b/cypress/e2e/quirk-docking-scrollbar-resize.cy.ts new file mode 100644 index 000000000..35860716c --- /dev/null +++ b/cypress/e2e/quirk-docking-scrollbar-resize.cy.ts @@ -0,0 +1,75 @@ +describe('Quirk - the proxy scrollbar never paints over the last row', () => { + // The proxy track is a sibling of the viewport, so the viewport must give up its height + // when the track appears. A column-resize drag changes the content width without going + // through setColumns(), which is the path that used to be the only one recomputing it. + const grid = '#myGrid'; + + const measure = (win: any) => { + const viewport = win.document.querySelector(`${grid} .slick-viewport`) as HTMLElement; + const scroller = win.document.querySelector(`${grid} .slick-docking-horizontal-scroller`) as HTMLElement; + const vp = viewport.getBoundingClientRect(); + const hs = scroller.getBoundingClientRect(); + return { viewportHeight: vp.height, viewportBottom: vp.bottom, trackHeight: hs.height, trackTop: hs.top }; + }; + + const mouse = (win: any, type: string, x: number, y: number, buttons = 1) => + new win.MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, buttons, view: win }); + + it('yields viewport height when a resize drag brings the track in, and takes it back', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-pinning-columns-and-rows.html`); + cy.get(`${grid} .slick-header-column`).should('exist'); + + // Start with no horizontal overflow, so the track has no height. + cy.window().then((win: any) => { + const columns = win.grid.getColumns(); + columns.forEach((column: any) => (column.width = 40)); + win.grid.setColumns(columns); + }); + + let narrowHeight = 0; + cy.window().should((win: any) => { + const state = measure(win); + expect(state.trackHeight, 'no track while the content fits').to.eq(0); + narrowHeight = state.viewportHeight; + }); + + // Drag a header handle until the content is wider than the viewport. + cy.window().then((win: any) => { + const columns = win.grid.getColumns(); + const header = win.grid.getHeaderColumn(columns.find((column: any) => !column.pinned).id) as HTMLElement; + const handle = header.querySelector('.slick-resizable-handle') as HTMLElement; + const rect = handle.getBoundingClientRect(); + const startX = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + + handle.dispatchEvent(mouse(win, 'mousedown', startX, y)); + win.document.body.dispatchEvent(mouse(win, 'mousemove', startX + 600, y)); + win.__midDrag = measure(win); + win.document.body.dispatchEvent(mouse(win, 'mouseup', startX + 600, y, 0)); + }); + + cy.window().should((win: any) => { + // Mid-drag, not only once the drag ends: the artefact was visible while dragging. + expect(win.__midDrag.trackHeight, 'the track appears during the drag').to.be.greaterThan(0); + expect(win.__midDrag.trackTop, 'and sits below the viewport, not over the last row').to.be.at.least( + win.__midDrag.viewportBottom - 0.5 + ); + expect(win.__midDrag.viewportHeight, 'the viewport gave up the track height').to.be.lessThan(narrowHeight); + + const after = measure(win); + expect(after.trackTop, 'still flush after mouseup').to.be.at.least(after.viewportBottom - 0.5); + }); + + // Narrowing again returns the height to the viewport. + cy.window().then((win: any) => { + const columns = win.grid.getColumns(); + columns.forEach((column: any) => (column.width = 40)); + win.grid.setColumns(columns); + }); + cy.window().should((win: any) => { + const state = measure(win); + expect(state.trackHeight, 'the track goes when the content fits again').to.eq(0); + expect(state.viewportHeight, 'and the viewport takes its height back').to.be.closeTo(narrowHeight, 0.5); + }); + }); +}); diff --git a/cypress/e2e/quirk-footer-lifecycle.cy.ts b/cypress/e2e/quirk-footer-lifecycle.cy.ts index 430406f86..17cee4ef3 100644 --- a/cypress/e2e/quirk-footer-lifecycle.cy.ts +++ b/cypress/e2e/quirk-footer-lifecycle.cy.ts @@ -5,13 +5,13 @@ * getViewportHeight()'s footer term was gated on showFooterRow alone and * dereferenced the undefined `_footerRowScroller[0]` (the header-row and * top-header terms three lines away already use the create && show idiom). - * 2 — `getFooterRow()` without a footer threw a TypeError on the non-frozen - * `_footerRow[0]` path while the frozen path returned undefined — the same + * 2 — `getFooterRow()` without a footer threw a TypeError on the unpinned + * `_footerRow[0]` path while the pinned path returned undefined — the same * misuse failed two different ways. It now returns undefined consistently. * 3 — createColumnHeaders() duplicated the footer destroy/create work that * createColumnFooter() (always called right after) already does, so * onFooterRowCellRendered fired TWICE per column on every setColumns, and - * the duplicate's right-side gating (hasFrozenColumns instead of existence) + * the duplicate's right-side gating (pinning instead of existence) * left stale right-footer cells after un-freezing. * * The spec is SELF-HOSTING: the three-grid repro harness is served from this file diff --git a/cypress/e2e/quirk-fractional-height-bottom-render.cy.ts b/cypress/e2e/quirk-fractional-height-bottom-render.cy.ts index 1d5c8f9c6..44e85c04c 100644 --- a/cypress/e2e/quirk-fractional-height-bottom-render.cy.ts +++ b/cypress/e2e/quirk-fractional-height-bottom-render.cy.ts @@ -89,14 +89,16 @@ describe('Quirk - a fractional grid height must still render the bottom rows', { cy.window().then((win: any) => { const vp = win.viewportEl(); - // Precondition: fractional layout produces a sub-pixel difference between the - // DOM and grid limits. Browsers round this in opposite directions, so the - // functional assertion below must not depend on which limit is larger. + // The original bug needed the browser's maximum scrollTop and the grid's clamp to + // disagree by a sub-pixel amount. The single-viewport layout reads the committed + // scroll position back from one owner, so the two limits now agree exactly and that + // divergence can no longer be constructed. This is asserted deliberately: if the two + // ever drift apart again, the render path below is the one that breaks. vp.scrollTop = 1e9; const domMaxScrollTop = vp.scrollTop; win.grid.scrollTo(1e9); const gridMaxScrollTop = win.grid.scrollTop; - expect(Math.abs(domMaxScrollTop - gridMaxScrollTop), 'fractional DOM/grid scroll limit difference').to.be.greaterThan(0.01); + expect(domMaxScrollTop, 'the DOM and grid scroll limits agree under a fractional height').to.eq(gridMaxScrollTop); // start from a fully rendered bottom, then wheel up far enough that the render // buffer no longer covers the last rows - they must actually be cleaned up, diff --git a/cypress/e2e/quirk-frozen-row-zero.cy.ts b/cypress/e2e/quirk-frozen-row-zero.cy.ts deleted file mode 100644 index 5d3d6aa71..000000000 --- a/cypress/e2e/quirk-frozen-row-zero.cy.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Regression test for the frozenRow: 0 degenerate configuration. - * - * frozenRow is a COUNT, but setFrozenOptions gated on `frozenRow > -1`, so - * frozenRow: 0 activated the full frozen-row machinery around an EMPTY band: - * hasFrozenRows true, split panes shown (a visible empty band strip), and — since - * actualFrozenRow computed to 0 — every row routed to the BOTTOM canvas in top - * mode. With frozenBottom: true, actualFrozenRow = dataLength and the whole body - * rendered in the top canvas while bottom-mode offset math measured it. - * - * The fix clamps at the source: `frozenRow > 0`. Zero frozen rows IS no freeze. - * - * The spec is SELF-HOSTING: the two-grid harness (frozenRow: 0 top variant + - * frozenRow: 0 with frozenBottom) is served from this file via cy.intercept (no - * page is added to examples/). It asserts both grids behave exactly like unfrozen - * grids: all rows in the top canvas, no bottom pane visible. Verified to FAIL - * pre-fix (grid A renders every row in the bottom canvas; both show the bottom - * pane) and PASS with the fix. - */ - -const harnessHtml = ` - - - - Harness: frozenRow zero clamp - - - - -
-
-
- - - - - -`; - -describe('Quirk - frozenRow: 0 must mean no freeze', { retries: 1 }, () => { - it('should behave exactly like an unfrozen grid in both frozenRow: 0 variants', () => { - cy.intercept('GET', '/quirk-frozen-row-zero-harness.html', { - headers: { 'content-type': 'text/html' }, - body: harnessHtml, - }); - cy.visit(`${Cypress.config('baseUrl')}/quirk-frozen-row-zero-harness.html`); - cy.window().its('gridA').should('exist'); - cy.window().its('gridB').should('exist'); - - cy.window().then((win: any) => { - const ok = win.runChecks(); - const detail = win.document.getElementById('checkResults').textContent; - expect(ok, `in-page frozenRow: 0 self-checks:\n${detail}`).to.eq(true); - }); - cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); - }); -}); diff --git a/cypress/e2e/quirk-grid-api-contracts.cy.ts b/cypress/e2e/quirk-grid-api-contracts.cy.ts new file mode 100644 index 000000000..37a10f53b --- /dev/null +++ b/cypress/e2e/quirk-grid-api-contracts.cy.ts @@ -0,0 +1,125 @@ +/** + * Regression test for grid API contracts that the pinning rewrite must keep. + * + * - applyHtmlCode(target, value, { emptyTarget, skipEmptyReassignment }) + * - setXxxVisibility(visible, animate) honours animate: false synchronously + * - setColumns() validates pinning before mutating its input or firing events, and returns + * whether the columns were applied + * - onHeaderKeyDown receives { event, column } + * - the internal event trigger is `trigger`, not `triggerEvent` + */ + +const harnessHtml = ` + + + + Harness: grid API contracts + + + + +
+
+
+ + + + + +`; + +describe('Quirk - grid API contracts', { retries: 1 }, () => { + it('should keep applyHtmlCode options, animate params, setColumns validation and header key events', () => { + cy.intercept('GET', '/quirk-grid-api-contracts-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-grid-api-contracts-harness.html`); + cy.window().its('grid').should('exist'); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page API contract self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-frozen-bottom-cell-cleanup.cy.ts b/cypress/e2e/quirk-pinning-bottom-cell-cleanup.cy.ts similarity index 64% rename from cypress/e2e/quirk-frozen-bottom-cell-cleanup.cy.ts rename to cypress/e2e/quirk-pinning-bottom-cell-cleanup.cy.ts index b07fa1786..023f58ce1 100644 --- a/cypress/e2e/quirk-frozen-bottom-cell-cleanup.cy.ts +++ b/cypress/e2e/quirk-pinning-bottom-cell-cleanup.cy.ts @@ -1,17 +1,9 @@ /** - * Regression test for the frozen-bottom cell-cleanup bug. + * Regression test for cell cleanup in a bottom-pinned grid. * - * In frozenBottom mode, cleanUpCells() exempted EVERY row from horizontal cell - * cleanup — the top-band disjunct `(row <= actualFrozenRow)` was missing the - * `!frozenBottom` qualifier that its sibling cleanupRows() has — so scrolling - * horizontally back and forth accumulated cell DOM nodes on every scrollable row - * without bound (a memory/DOM leak that degrades scroll performance). - * - * The spec is SELF-HOSTING: the repro harness page is served from this file via - * cy.intercept (no page is added to examples/). It builds a heavily-virtualized - * frozen-bottom grid (40 columns in a narrow viewport), scrolls right and back via - * the grid API, and asserts a scrollable row's rendered cell count stays bounded. - * FAILS on the unfixed code (count climbs to ~40) and PASSES with the fix. + * Bottom-pinned rows use the docking overlay, but ordinary rows must continue + * to remove off-screen cells during horizontal scrolling. The self-hosted + * harness keeps the test independent of an example page. */ const COLS = 40; @@ -20,7 +12,7 @@ const harnessHtml = ` - Harness: frozen-bottom cell cleanup + Harness: bottom-pinned cell cleanup @@ -44,14 +36,11 @@ const harnessHtml = ` window.grid = new Slick.Grid('#myGrid', data, columns, { enableCellNavigation: true, enableColumnReorder: false, // keep the harness free of the SortableJS dependency - frozenRow: 2, - frozenBottom: true, + pinning: { rows: { bottom: [28, 29] } }, rowHeight: 25 }); - // rendered cell count of a SCROLLABLE row (row 3; actualFrozenRow is 28). The - // frozen bottom rows are deliberately exempt from horizontal cleanup in both the - // buggy and the fixed code, so they must not be measured. + // Measure a normal scrolling row; bottom-pinned rows are deliberately excluded. window.scrollableRowCells = function () { var el = document.querySelector('#myGrid .slick-row[data-row="3"]'); return el ? el.querySelectorAll('.slick-cell').length : -1; @@ -68,16 +57,16 @@ const harnessHtml = ` `; -describe('Quirk - frozen-bottom grids must still clean up off-screen cells', { retries: 1 }, () => { - it('should load the self-hosted repro harness with frozen bottom rows', () => { - cy.intercept('GET', '/quirk-frozen-bottom-cell-cleanup-harness.html', { +describe('Quirk - bottom-pinned grids must still clean up off-screen cells', { retries: 1 }, () => { + it('should load the self-hosted repro harness with bottom-pinned rows', () => { + cy.intercept('GET', '/quirk-pinning-bottom-cell-cleanup-harness.html', { headers: { 'content-type': 'text/html' }, body: harnessHtml, }); - cy.visit(`${Cypress.config('baseUrl')}/quirk-frozen-bottom-cell-cleanup-harness.html`); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-bottom-cell-cleanup-harness.html`); cy.window().its('grid').should('exist'); cy.window().then((win: any) => { - expect(win.grid.getOptions().frozenBottom, 'frozenBottom active').to.eq(true); + expect(win.grid.getOptions().pinning.rows.bottom, 'bottom pinning active').to.deep.equal([28, 29]); }); }); diff --git a/cypress/e2e/quirk-frozen-bottom-hit-testing.cy.ts b/cypress/e2e/quirk-pinning-bottom-hit-testing.cy.ts similarity index 63% rename from cypress/e2e/quirk-frozen-bottom-hit-testing.cy.ts rename to cypress/e2e/quirk-pinning-bottom-hit-testing.cy.ts index b31a6067f..496570673 100644 --- a/cypress/e2e/quirk-frozen-bottom-hit-testing.cy.ts +++ b/cypress/e2e/quirk-pinning-bottom-hit-testing.cy.ts @@ -1,27 +1,16 @@ /** - * Regression test for the frozen-bottom hit-testing bug. + * Regression test for bottom-pinned row hit testing. * - * getCellFromEvent()/setActiveCellInternal() computed the bottom-canvas row offset - * from a LIVE measurement of the top canvas (`Utils.height(_canvasTopL)`) in - * frozenBottom mode, while the render path places bottom-canvas rows using - * getFrozenRowOffset(). The two diverge whenever the dataset is shorter than the - * viewport, because updateRowCount floors the body canvas height at the viewport - * height — so clicking the frozen bottom row resolved to a row ~viewport/rowHeight - * rows away. Both call sites now use getFrozenRowOffset(actualFrozenRow), the same - * offset the render path used to place the row. - * - * The spec is SELF-HOSTING: the two-grid repro harness (frozen-bottom target + - * top-freeze control) is served from this file via cy.intercept (no page is added - * to examples/). It synthesizes clicks at real cell rects and asserts - * getCellFromEvent resolves the correct rows. Verified to FAIL pre-fix and PASS - * post-fix. + * Pinned rows live in the docking overlay, so hit testing and active-cell + * tracking must use the rendered row's logical data attribute rather than infer + * an index from the center canvas's natural offset. */ const harnessHtml = ` - Harness: frozen-bottom hit testing + Harness: bottom-pinned hit testing @@ -47,10 +36,10 @@ const harnessHtml = ` var base = { enableCellNavigation: true, enableColumnReorder: false, rowHeight: 25 }; function cols() { return columns.map(function (c) { return Object.assign({}, c); }); } - // Grid A: 1 frozen BOTTOM row -> actualFrozenRow = 7; row 7 renders in the bottom canvas - var gridA = new Slick.Grid('#gridA', makeData(), cols(), Object.assign({ frozenRow: 1, frozenBottom: true }, base)); - // Grid B (control): 1 frozen TOP row -> rows 1..7 render in the bottom canvas - var gridB = new Slick.Grid('#gridB', makeData(), cols(), Object.assign({ frozenRow: 1 }, base)); + // Grid A: row 7 is permanently pinned to the bottom overlay. + var gridA = new Slick.Grid('#gridA', makeData(), cols(), Object.assign({ pinning: { rows: { bottom: [7] } } }, base)); + // Grid B (control): row 0 is permanently pinned to the top overlay. + var gridB = new Slick.Grid('#gridB', makeData(), cols(), Object.assign({ pinning: { rows: { top: [0] } } }, base)); window.gridA = gridA; window.gridB = gridB; // synthesize the event getCellFromEvent expects, aimed at the center of a cell @@ -70,17 +59,17 @@ const harnessHtml = ` } var a = hitTest(gridA, '#gridA', 7, 1); - check('frozenBottom: click on frozen bottom row resolves to its own row', + check('bottom pinning: click on pinned bottom row resolves to its own row', !a.error && !!a.got && a.got.row === 7, a.error || ('got row ' + (a.got && a.got.row) + ' expected 7')); var a2 = hitTest(gridA, '#gridA', 3, 1); - check('frozenBottom: click on body row resolves correctly', + check('bottom pinning: click on body row resolves correctly', !a2.error && !!a2.got && a2.got.row === 3, a2.error || ('got row ' + (a2.got && a2.got.row) + ' expected 3')); var b = hitTest(gridB, '#gridB', 4, 1); - check('top freeze (control): click on scrollable row resolves correctly', + check('top pinning (control): click on scrollable row resolves correctly', !b.error && !!b.got && b.got.row === 4, b.error || ('got row ' + (b.got && b.got.row) + ' expected 4')); @@ -92,18 +81,18 @@ const harnessHtml = ` `; -describe('Quirk - frozen-bottom hit testing must use the render offset', { retries: 1 }, () => { +describe('Quirk - pinned-row hit testing uses the rendered row', { retries: 1 }, () => { it('should load the self-hosted two-grid repro harness', () => { - cy.intercept('GET', '/quirk-frozen-bottom-hit-testing-harness.html', { + cy.intercept('GET', '/quirk-pinning-bottom-hit-testing-harness.html', { headers: { 'content-type': 'text/html' }, body: harnessHtml, }); - cy.visit(`${Cypress.config('baseUrl')}/quirk-frozen-bottom-hit-testing-harness.html`); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-bottom-hit-testing-harness.html`); cy.window().its('gridA').should('exist'); cy.window().its('gridB').should('exist'); }); - it('should resolve clicked rows correctly in frozen-bottom and top-freeze grids', () => { + it('should resolve clicked rows correctly in bottom- and top-pinned grids', () => { cy.window().then((win: any) => { const ok = win.runChecks(); const detail = win.document.getElementById('checkResults').textContent; diff --git a/cypress/e2e/quirk-pinning-bottom-reachability.cy.ts b/cypress/e2e/quirk-pinning-bottom-reachability.cy.ts new file mode 100644 index 000000000..ca55f605f --- /dev/null +++ b/cypress/e2e/quirk-pinning-bottom-reachability.cy.ts @@ -0,0 +1,116 @@ +/** + * Regression test for bottom-pinned rows at maximum scroll. + * + * A bottom-pinned row is rendered in the overlay band that covers the bottom of the viewport. + * Its slot must collapse to the end of the canvas (under the band) rather than the canvas being + * shortened, otherwise the last scrolling row, or the add-new row, ends up under the band and + * can never be reached. Grid A pins the last data row with no add-new row; Grid B pins it with + * `enableAddRow`; Grid C pins a row in the middle. + */ + +const harnessHtml = ` + + + + Harness: bottom-pinned reachability + + + + +
+
+
+
+ + + + + +`; + +describe('Quirk - bottom-pinned rows must keep every scrolling row reachable', { retries: 1 }, () => { + it('should show the last scrolling row (and the add-new row) above the bottom band at maximum scroll', () => { + cy.intercept('GET', '/quirk-pinning-bottom-reachability-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-bottom-reachability-harness.html`); + cy.window().its('gridA').should('exist'); + cy.window().its('gridB').should('exist'); + cy.window().its('gridC').should('exist'); + + cy.window().then((win: any) => { + return win.runChecks().then((ok: boolean) => { + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page bottom-pin self-checks:\n${detail}`).to.eq(true); + }); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-pinning-chrome-scroll-forwarding.cy.ts b/cypress/e2e/quirk-pinning-chrome-scroll-forwarding.cy.ts new file mode 100644 index 000000000..1443dd079 --- /dev/null +++ b/cypress/e2e/quirk-pinning-chrome-scroll-forwarding.cy.ts @@ -0,0 +1,73 @@ +/** + * Regression test for native scrolls on the header-row container of a docking grid. + * + * With the docking horizontal scrollbar, the header, header-row, footer and viewport containers + * are kept at scrollLeft 0 and their content is translated by the proxy position. When the + * browser scrolls one of those containers natively (for example to reveal a focused filter + * input), the value it reports is relative to the current position. Forwarding it as an + * absolute position jumped the whole grid back towards the left edge. + */ + +const harnessHtml = ` + + + + Harness: chrome scroll forwarding + + + + +
+ + + + + +`; + +describe('Quirk - native chrome scrolls are forwarded as deltas in proxy mode', { retries: 1 }, () => { + it('should add a header-row scroll to the current proxy position instead of replacing it', () => { + cy.intercept('GET', '/quirk-pinning-chrome-scroll-forwarding-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-chrome-scroll-forwarding-harness.html`); + cy.window().its('grid').should('exist'); + + cy.get('#myGrid .slick-horizontal-scroller').scrollTo(400, 0); + cy.window().should((win: any) => { + expect(win.grid.scrollLeft, 'proxy position').to.be.closeTo(400, 2); + }); + + // simulate the browser revealing something inside the header-row container + cy.get('#myGrid .slick-headerrow').then(($headerRow) => { + $headerRow[0].scrollLeft = 60; + }); + + cy.window().should((win: any) => { + expect(win.grid.scrollLeft, 'proxy position after a native header-row scroll').to.be.closeTo(460, 2); + expect(win.document.querySelector('#myGrid .slick-headerrow').scrollLeft, 'header-row container reset').to.eq(0); + }); + cy.get('#myGrid .slick-horizontal-scroller').should(($scroller) => { + expect($scroller[0].scrollLeft).to.be.closeTo(460, 2); + }); + }); +}); diff --git a/cypress/e2e/quirk-pinning-docked-row-cell-virtualization.cy.ts b/cypress/e2e/quirk-pinning-docked-row-cell-virtualization.cy.ts new file mode 100644 index 000000000..5448da952 --- /dev/null +++ b/cypress/e2e/quirk-pinning-docked-row-cell-virtualization.cy.ts @@ -0,0 +1,95 @@ +/** + * Regression test for horizontal cell virtualization of docked rows. + * + * Pinned rows live in the overlay and can be far outside the vertical render range (a pinned + * last row on a long dataset). Their centre cells must still follow the horizontal render + * range like every other row: new cells appear when scrolling right, and cells that left the + * range are removed instead of accumulating. + */ + +const COLS = 40; +const ROWS = 400; + +const harnessHtml = ` + + + + Harness: docked row cell virtualization + + + + +
+
+ + + + + +`; + +describe('Quirk - docked rows virtualize their centre cells horizontally', { retries: 1 }, () => { + it('should add and remove centre cells of pinned rows as the grid scrolls horizontally', () => { + cy.intercept('GET', '/quirk-pinning-docked-row-cell-virtualization-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-docked-row-cell-virtualization-harness.html`); + cy.window().its('grid').should('exist'); + + cy.get('#myGrid .slick-horizontal-scroller').scrollTo(1500, 0); + cy.get('#myGrid .slick-horizontal-scroller').scrollTo(3000, 0); + cy.window().should((win: any) => { + expect(win.grid.scrollLeft, 'scrollLeft applied').to.be.gte(3000); + }); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page docked-row virtualization self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-pinning-hit-testing-geometry.cy.ts b/cypress/e2e/quirk-pinning-hit-testing-geometry.cy.ts new file mode 100644 index 000000000..d57b817fa --- /dev/null +++ b/cypress/e2e/quirk-pinning-hit-testing-geometry.cy.ts @@ -0,0 +1,111 @@ +/** + * Regression test for getCellFromPoint() on a docking grid. + * + * The point is canvas-relative (as CellRangeSelector supplies it). It must resolve through the + * rendered layout, not the natural one: pinned columns sit at the viewport edges whatever the + * horizontal scroll, pinned rows sit in the overlay bands whatever the vertical scroll, and + * non-contiguous top pins shift every following scrolling row. The last check uses a row that is + * not rendered at all, so the resolution cannot depend on DOM hit testing. + */ + +const harnessHtml = ` + + + + Harness: pinning hit-testing geometry + + + + +
+
+ + + + + +`; + +describe('Quirk - getCellFromPoint resolves through the rendered docking layout', { retries: 1 }, () => { + it('should map canvas points to pinned columns, pinned rows and shifted scrolling rows', () => { + cy.intercept('GET', '/quirk-pinning-hit-testing-geometry-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-hit-testing-geometry-harness.html`); + cy.window().its('grid').should('exist'); + + cy.get('#myGrid .slick-horizontal-scroller').scrollTo(300, 0); + cy.get('#myGrid .slick-vertical-scroller').scrollTo(0, 150); + cy.window().should((win: any) => { + expect(win.grid.scrollLeft, 'scrollLeft applied').to.be.gte(300); + }); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page hit-testing self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-pinning-lazy-activation-destroy-events.cy.ts b/cypress/e2e/quirk-pinning-lazy-activation-destroy-events.cy.ts new file mode 100644 index 000000000..2d2ae6407 --- /dev/null +++ b/cypress/e2e/quirk-pinning-lazy-activation-destroy-events.cy.ts @@ -0,0 +1,100 @@ +/** + * Regression test for enabling pinning on an already initialized grid. + * + * Switching a plain grid to the docking layout rebuilds the header, header-row and footer + * chrome. Plugins that attach content to those cells (Header Menu, Header Buttons, filters) + * clean up on `onBeforeHeaderCellDestroy`, `onBeforeHeaderRowCellDestroy` and + * `onBeforeFooterRowCellDestroy`, so every existing cell must be announced before the chrome + * is emptied, and the rendered events must fire again for the rebuilt cells. + */ + +const harnessHtml = ` + + + + Harness: lazy pinning activation destroy events + + + + +
+
+ + + + + +`; + +describe('Quirk - enabling pinning at runtime must announce every chrome cell before rebuilding', { retries: 1 }, () => { + it('should fire the header, header-row and footer destroy events once per column', () => { + cy.intercept('GET', '/quirk-pinning-lazy-activation-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-lazy-activation-harness.html`); + cy.window().its('grid').should('exist'); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page lazy-activation self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-pinning-option-contracts.cy.ts b/cypress/e2e/quirk-pinning-option-contracts.cy.ts new file mode 100644 index 000000000..a2e7086c1 --- /dev/null +++ b/cypress/e2e/quirk-pinning-option-contracts.cy.ts @@ -0,0 +1,185 @@ +/** + * Regression tests for the contracts of the column-pinning option. + * + * - Pins are rejected when they would leave the centre band no width at all; a 1px band is the + * narrowest that is accepted. + * - A rejected `setColumns()` leaves the saved pinning state untouched, so clearing the option + * later restores a column's own `pinned` flag. + * - `setColumnPinning()` records the column's id, so the pin follows the column through a + * reorder and can be removed afterwards. + * - `resizeCanvas()` places right-pinned header, filter and footer cells at the new edge after + * the container changed width without any docking change. + * - A grid created with `rtl: true` on a left-to-right page applies its own direction before its + * headers are built, so the trailing band is placed at the physical left edge. + */ + +const harnessHtml = ` + + + + Harness: pinning option contracts + + + +
+
+ + + + + +`; + +describe('Quirk - pinning option contracts', { retries: 1 }, () => { + const run = (name: string) => { + cy.intercept('GET', '/quirk-pinning-option-contracts-harness.html', { headers: { 'content-type': 'text/html' }, body: harnessHtml }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-option-contracts-harness.html`); + cy.window().its('Slick').should('exist'); + cy.window().then((win: any) => { + const ok = win.runChecks(name); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page self-checks (${name}):\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }; + + it('rejects pins that leave the centre band no width', () => run('centreWidth')); + it('leaves the saved pinning state untouched when setColumns() is rejected', () => run('rejectedSetColumns')); + it('keeps an interactive pin on its column through a reorder and can remove it', () => run('pinFollowsColumn')); + it('places right-pinned chrome at the new edge after the container is resized', () => run('resize')); + it('lays out an rtl grid on a left-to-right page from its own direction', () => run('rtlOnLtrPage')); +}); diff --git a/cypress/e2e/quirk-pinning-restore-and-resize-geometry.cy.ts b/cypress/e2e/quirk-pinning-restore-and-resize-geometry.cy.ts new file mode 100644 index 000000000..a932efffb --- /dev/null +++ b/cypress/e2e/quirk-pinning-restore-and-resize-geometry.cy.ts @@ -0,0 +1,165 @@ +/** + * Regression tests for pinned-band geometry, in both reading directions. + * + * - Removing and restoring permanent pinning while a sticky column is docked keeps every body + * cell aligned with its header. Without the fix the leading pinned cell collapsed to a few + * pixels, because its column rule was sized from the layout band width rather than the + * rendered one. + * - Dragging a column's resize handle on a pinned grid whose canvas width does not change keeps + * the filter cell, the footer cell, the body cells and the row's band template at the column's + * new width during the drag. Without the fix they stayed at the old widths until mouseup. + * + * Reported from Slickgrid-Universal (ghiscoding/slickgrid-universal#2782, commits c4112428 and + * a490c253). The harness is served from this file via cy.intercept. + */ + +const harnessHtml = ` + + + + Harness: pinning restore and resize geometry + + + + +
+ + + + + +`; + +const visit = (query: string) => { + cy.intercept('GET', '/quirk-pinning-restore-and-resize-geometry-harness.html*', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-restore-and-resize-geometry-harness.html?${query}`); + cy.window().its('grid').should('exist'); +}; + +describe('Quirk - pinned band geometry after restoring pinning and while resizing', () => { + [false, true].forEach((rtl) => { + const dir = rtl ? 'rtl' : 'ltr'; + + it(`keeps cells aligned with their headers after pinning is removed and restored with a docked sticky (${dir})`, () => { + visit(`mode=restore&rtl=${rtl ? 1 : 0}`); + cy.window().then((win: any) => win.scrollProxy(400)); + cy.window().should((win: any) => { + expect(win.stickyDocked(), 'precondition: the sticky column is docked').to.eq(true); + expect(win.misalignedCells(), 'precondition: aligned before the change').to.deep.eq([]); + }); + cy.window().then((win: any) => win.clearPinning()); + cy.window().then((win: any) => win.restorePinning()); + cy.window().should((win: any) => { + expect(win.stickyDocked(), 'the sticky column is still docked').to.eq(true); + expect(win.misalignedCells(), 'cells aligned with headers after restore').to.deep.eq([]); + }); + }); + + ['c0', 'c1'].forEach((colId) => { + it(`keeps filter, footer, cells and row bands at the new width while dragging ${colId}'s resize handle (${dir})`, () => { + visit(`mode=resize&rtl=${rtl ? 1 : 0}`); + const shrink = rtl ? 20 : -20; + cy.window().then((win: any) => { + expect(win.wrongWidths(), 'precondition: consistent before the drag').to.deep.eq([]); + win.dragStart(colId); + win.dragMove(shrink); + }); + cy.window().should((win: any) => { + expect(win.grid.getColumns().find((c: any) => c.id === colId).width, 'the drag changed the width').to.eq(60); + expect(win.wrongWidths(), 'geometry follows the drag').to.deep.eq([]); + }); + cy.window().then((win: any) => win.dragEnd(shrink)); + cy.window().should((win: any) => { + expect(win.wrongWidths(), 'geometry after the drop').to.deep.eq([]); + }); + }); + }); + }); +}); diff --git a/cypress/e2e/quirk-frozen-row-boundary.cy.ts b/cypress/e2e/quirk-pinning-row-boundary.cy.ts similarity index 67% rename from cypress/e2e/quirk-frozen-row-boundary.cy.ts rename to cypress/e2e/quirk-pinning-row-boundary.cy.ts index bf7440c9b..c87ff719d 100644 --- a/cypress/e2e/quirk-frozen-row-boundary.cy.ts +++ b/cypress/e2e/quirk-pinning-row-boundary.cy.ts @@ -1,21 +1,17 @@ /** - * Regression test for frozen-row boundary canonicalization. + * Regression test for pinned-row boundary canonicalization. * * The row-band boundary was compared differently at six sites, and they - * contradicted each other and the render split (rows >= actualFrozenRow go to the - * bottom canvas): - * - appendRowHtml classed rows 'frozen' via `row <= frozenRow` (a COUNT compare): - * wrong rows classed in bottom mode, off-by-one in top mode; - * - cleanupRows/cleanUpCells exempted `<= actualFrozenRow`, sparing the first - * SCROLLABLE row from eviction/cleanup in top mode; - * - getCanvasNode/getViewportNode classified with `>= actualFrozenRow + 1` in top - * mode, returning the TOP pane for a row whose DOM lives in the BOTTOM canvas; - * - scrollRowIntoView used `actualFrozenRow - 1` boundaries, refusing to scroll - * the LAST scrollable row in bottom mode. + * contradicted each other and the render split: + * - row rendering and cache cleanup must agree on which rows are permanently + * pinned and therefore must remain in the docking overlay/cache; + * - canvas lookup must still resolve the first scrollable row in the single + * viewport; + * - scrollRowIntoView must allow the last center row to be revealed when rows + * are pinned to the bottom edge. * * All sites now route through two predicates that match the render split: - * isBottomBandRow (>= actualFrozenRow) and isFrozenRowIdx (frozenBottom ? - * >= actualFrozenRow : < actualFrozenRow). + * isBottomBandRow and isPinnedRowIdx now derive from the unified pinning state. * * The spec is SELF-HOSTING (harness served via cy.intercept; no example page). * Each check pins one drifted site. Verified to FAIL pre-fix on every drifted @@ -26,7 +22,7 @@ const harnessHtml = ` - Harness: frozen-row boundary + Harness: pinned-row boundary @@ -52,15 +48,15 @@ const harnessHtml = ` var base = { enableCellNavigation: true, enableColumnReorder: false, rowHeight: 25 }; function cols() { return columns.map(function (c) { return Object.assign({}, c); }); } - // Grid A: 3 frozen TOP rows -> actualFrozenRow = 3; first scrollable row = 3 - var gridA = new Slick.Grid('#gridA', makeData(), cols(), Object.assign({ frozenRow: FR }, base)); - // Grid B: 3 frozen BOTTOM rows -> actualFrozenRow = 997; last scrollable row = 996 - var gridB = new Slick.Grid('#gridB', makeData(), cols(), Object.assign({ frozenRow: FR, frozenBottom: true }, base)); + // Grid A: 3 permanently pinned TOP rows; first scrollable row = 3 + var gridA = new Slick.Grid('#gridA', makeData(), cols(), Object.assign({ pinning: { rows: { top: [0, 1, 2] } } }, base)); + // Grid B: 3 permanently pinned BOTTOM rows; last scrollable row = 996 + var gridB = new Slick.Grid('#gridB', makeData(), cols(), Object.assign({ pinning: { rows: { bottom: [997, 998, 999] } } }, base)); window.gridA = gridA; window.gridB = gridB; - function frozenClassedRows(container) { + function pinnedRows(container, className) { var out = []; - document.querySelectorAll(container + ' .slick-row.frozen').forEach(function (r) { out.push(parseInt(r.dataset.row, 10)); }); + document.querySelectorAll(container + ' .slick-row.' + className).forEach(function (r) { out.push(parseInt(r.dataset.row, 10)); }); out.sort(function (a, b) { return a - b; }); return out; } @@ -73,11 +69,11 @@ const harnessHtml = ` } function eq(a, b) { return JSON.stringify(a) === JSON.stringify(b); } - // 1. 'frozen' css class = exactly the configured pinned rows, both modes - var aClassed = frozenClassedRows('#gridA'); - check('A(top): frozen class on exactly rows 0..' + (FR - 1), eq(aClassed, [0, 1, 2]), 'classed=' + JSON.stringify(aClassed)); - var bClassed = frozenClassedRows('#gridB'); - check('B(bottom): frozen class on exactly rows 997..999', eq(bClassed, [997, 998, 999]), 'classed=' + JSON.stringify(bClassed)); + // 1. Current docking classes identify exactly the configured pinned rows. + var aClassed = pinnedRows('#gridA', 'slick-row-pinned-top'); + check('A(top): pinned class on exactly rows 0..' + (FR - 1), eq(aClassed, [0, 1, 2]), 'classed=' + JSON.stringify(aClassed)); + var bClassed = pinnedRows('#gridB', 'slick-row-pinned-bottom'); + check('B(bottom): pinned class on exactly rows 997..999', eq(bClassed, [997, 998, 999]), 'classed=' + JSON.stringify(bClassed)); // 2. getCanvasNode pane agreement: the canvas returned for the first // scrollable row must be the canvas that CONTAINS that row's DOM @@ -94,12 +90,12 @@ const harnessHtml = ` check('B(bottom): scrollRowIntoView(996) scrolls (viewport.top > 0)', vpTop > 0, 'viewport.top=' + vpTop); // 4. A(top): the first scrollable row must be EVICTABLE - scroll far away and - // confirm row FR leaves the row cache (frozen rows 0..2 stay) + // confirm row FR leaves the row cache (pinned rows 0..2 stay) gridA.scrollRowIntoView(600); gridA.render(); var cache = gridA.getRowCache(); check('A(top): first scrollable row ' + FR + ' evicted after far scroll', !cache[FR], 'cached=' + !!cache[FR]); - check('A(top): frozen row 0 stays cached after far scroll', !!cache[0], 'cached=' + !!cache[0]); + check('A(top): pinned row 0 stays cached after far scroll', !!cache[0], 'cached=' + !!cache[0]); out.push(pass ? '\\nALL CHECKS PASSED' : '\\nCHECKS FAILED'); document.getElementById('checkResults').textContent = out.join('\\n'); @@ -109,13 +105,13 @@ const harnessHtml = ` `; -describe('Quirk - frozen-row boundary must be consistent across all comparison sites', { retries: 1 }, () => { +describe('Quirk - pinned-row boundary must be consistent across all comparison sites', { retries: 1 }, () => { it('should agree on the boundary across css classing, pane lookup, scrolling and cache eviction', () => { - cy.intercept('GET', '/quirk-frozen-row-boundary-harness.html', { + cy.intercept('GET', '/quirk-pinning-row-boundary-harness.html', { headers: { 'content-type': 'text/html' }, body: harnessHtml, }); - cy.visit(`${Cypress.config('baseUrl')}/quirk-frozen-row-boundary-harness.html`); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-row-boundary-harness.html`); cy.window().its('gridA').should('exist'); cy.window().its('gridB').should('exist'); diff --git a/cypress/e2e/quirk-pinning-row-references.cy.ts b/cypress/e2e/quirk-pinning-row-references.cy.ts new file mode 100644 index 000000000..25a9311cb --- /dev/null +++ b/cypress/e2e/quirk-pinning-row-references.cy.ts @@ -0,0 +1,132 @@ +/** + * Regression test for row references and pinning removal. + * + * - `{ id }` references address rows by dataset id even when ids are numeric, and keep following + * the row after the DataView is re-sorted. + * - Plain numbers stay row indexes. + * - `setOptions({ pinning: null })` and `setOptions({ pinning: undefined })` both remove docking. + * - The bottom band mirrors the top band: a docked sticky row sits inside (above) the permanent + * bottom row. + */ + +const harnessHtml = ` + + + + Harness: row references + + + + +
+
+
+ + + + + + +`; + +describe('Quirk - row references, pinning removal and bottom band order', { retries: 1 }, () => { + it('should resolve { id } references, treat null like undefined, and stack sticky rows inside permanent ones', () => { + cy.intercept('GET', '/quirk-pinning-row-references-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-row-references-harness.html`); + cy.window().its('gridA').should('exist'); + cy.window().its('gridB').should('exist'); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page row reference self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-pinning-row-zero.cy.ts b/cypress/e2e/quirk-pinning-row-zero.cy.ts new file mode 100644 index 000000000..40f817367 --- /dev/null +++ b/cypress/e2e/quirk-pinning-row-zero.cy.ts @@ -0,0 +1,93 @@ +/** + * Regression test for empty row-pinning configurations. + * + * Empty pinning arrays must not create an empty docking band or materialize the + * docking overlay. The two-grid harness is served through cy.intercept so both + * an empty top configuration and an empty bottom configuration are covered. + */ + +const harnessHtml = ` + + + + Harness: empty row pinning + + + + +
+
+
+ + + + + +`; + +describe('Quirk - empty row pinning must mean no docking', { retries: 1 }, () => { + it('should behave exactly like an ordinary grid in both empty variants', () => { + cy.intercept('GET', '/quirk-pinning-row-zero-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-row-zero-harness.html`); + cy.window().its('gridA').should('exist'); + cy.window().its('gridB').should('exist'); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page empty pinning self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-pinning-sticky-flag-on-pinned-column.cy.ts b/cypress/e2e/quirk-pinning-sticky-flag-on-pinned-column.cy.ts new file mode 100644 index 000000000..478395cb2 --- /dev/null +++ b/cypress/e2e/quirk-pinning-sticky-flag-on-pinned-column.cy.ts @@ -0,0 +1,133 @@ +/** + * Regression test for a permanently pinned column that also carries `sticky`. + * + * The docking layout puts a `pinned` column in its permanent band whatever its `sticky` flag says, + * so its header, filter cell and body cells must all render in that band and stay docked while the + * grid scrolls, exactly like a pinned column without the flag. The flag alone must not route any + * part of the column onto the sticky transform path. Covers the boundary form of `pinning.columns`, + * the column-level `pinned` property, and pinning a sticky column at runtime. + */ + +const harnessHtml = ` + + + + Harness: sticky flag on a pinned column + + + + +
+
+
+
+ + + + + +`; + +describe('Quirk - sticky flag on a permanently pinned column', { retries: 1 }, () => { + it('should keep a pinned column in its band, with its filter cell and body cells, whatever its sticky flag says', () => { + cy.intercept('GET', '/quirk-pinning-sticky-flag-on-pinned-column-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-sticky-flag-on-pinned-column-harness.html`); + cy.window().its('gridC').should('exist'); + cy.get('#gridA .slick-row').should('have.length.greaterThan', 3); + + ['gridA', 'gridB', 'gridC'].forEach((name) => { + cy.window().then((win: any) => { + const ok = win.runChecks(name); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page self-checks for ${name}:\n${detail}`).to.eq(true); + }); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-pinning-unresolved-row-references.cy.ts b/cypress/e2e/quirk-pinning-unresolved-row-references.cy.ts new file mode 100644 index 000000000..2522b7846 --- /dev/null +++ b/cypress/e2e/quirk-pinning-unresolved-row-references.cy.ts @@ -0,0 +1,129 @@ +/** + * Regression test for unresolved pinned and sticky row references. + * + * A row reference whose id is not in the data stays unresolved. Its lookup is cached like a + * resolved one, so scrolling does not search the data again; invalidating rows clears the cache, + * and the reference resolves once its row exists. Covers a plain array (a string id and an + * explicit `{ id }` reference) and a DataView (`getRowById()`). + */ + +const harnessHtml = ` + + + + Harness: unresolved row references + + + + +
+
+
+ + + + + + +`; + +describe('Quirk - unresolved pinned and sticky row references', { retries: 1 }, () => { + it('should cache an unresolved reference until rows are invalidated, then resolve it', () => { + cy.intercept('GET', '/quirk-pinning-unresolved-row-references-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-pinning-unresolved-row-references-harness.html`); + cy.window().its('gridB').should('exist'); + cy.get('#gridA .slick-row').should('have.length.greaterThan', 3); + + cy.window().then((win: any) => { + const ok = win.runChecks(); + const detail = win.document.getElementById('checkResults').textContent; + expect(ok, `in-page unresolved-reference self-checks:\n${detail}`).to.eq(true); + }); + cy.get('#checkResults').should('contain', 'ALL CHECKS PASSED'); + }); +}); diff --git a/cypress/e2e/quirk-row-positions-fragments.cy.ts b/cypress/e2e/quirk-row-positions-fragments.cy.ts index b04cdeaad..190218990 100644 --- a/cypress/e2e/quirk-row-positions-fragments.cy.ts +++ b/cypress/e2e/quirk-row-positions-fragments.cy.ts @@ -1,18 +1,16 @@ /** * Regression test for the updateRowPositions fragment bug. * - * updateRowPositions() — which runs whenever the virtual-scroll paging offset - * changes — repositioned only rowNode[0], the LEFT-pane fragment. With frozen - * columns a row has one fragment per column pane, so after a paging-offset jump - * the right-pane fragment kept its stale top and the two halves of the same row - * drifted vertically apart across the freeze line. (It also used bare getRowTop() - * where the render path uses getRowTop() - getFrozenRowOffset(); the fix reuses - * the render-time formula for all fragments.) + * updateRowPositions() runs whenever the virtual-scroll paging offset changes. + * The current single-viewport renderer keeps pinned and center cells in regions + * under one row element, so the regression is checked by comparing every + * rendered docked row with the grid's current rendered row position. * * The spec is SELF-HOSTING: the repro harness is served from this file via * cy.intercept (no page is added to examples/). It forces paging (100k rows — * virtual height above the ~1M css cap), walks scrollTo finely across page - * boundaries, and asserts every rendered row's left/right fragments agree on top. + * boundaries, and asserts every rendered row stays aligned with its pinned + * regions. * Verified to FAIL pre-fix (drift = one paging offset unit) and PASS post-fix. */ @@ -46,7 +44,7 @@ const harnessHtml = ` var grid = new Slick.Grid('#myGrid', data, columns, { enableCellNavigation: true, enableColumnReorder: false, - frozenColumn: 0, + pinning: { columns: { left: 0 } }, rowHeight: 25, // a small option cap makes the getMaxSupportedCssHeight probe exit at its // 1,000,000px starting value, so with th = 2.5M the grid pages (n = 250) @@ -62,19 +60,21 @@ const harnessHtml = ` return m ? parseFloat(m[1]) : NaN; } - // compare left/right fragment tops for every rendered data-row; return worst pair + // Compare every rendered docked row with the position calculated by the + // current single-viewport renderer; return the worst mismatch. function fragmentDivergence() { - var canvases = document.querySelectorAll('#myGrid .grid-canvas'); - var left = canvases[0], right = canvases[1]; - var worst = { diff: 0, row: null, l: 0, r: 0, compared: 0 }; - left.querySelectorAll('.slick-row').forEach(function (lRow) { - var r = lRow.dataset.row; - var rRow = right.querySelector('.slick-row[data-row="' + r + '"]'); - if (!rRow) { return; } - var lt = topOf(lRow), rt = topOf(rRow); + var canvas = document.querySelector('#myGrid .grid-canvas'); + var worst = { diff: 0, row: null, actual: 0, expected: 0, compared: 0 }; + if (!canvas) { return worst; } + canvas.querySelectorAll('.slick-row-docked').forEach(function (row) { + var rowIndex = Number(row.dataset.row); + var regions = row.querySelectorAll(':scope > .slick-pinned-left-cells, :scope > .slick-scrolling-cells'); + if (regions.length < 2) { return; } + var actual = topOf(row); + var expected = grid.getRowTop(rowIndex); worst.compared++; - var d = Math.abs(lt - rt); - if (d > worst.diff) { worst = { diff: d, row: r, l: lt, r: rt, compared: worst.compared }; } + var d = Math.abs(actual - expected); + if (d > worst.diff) { worst = { diff: d, row: rowIndex, actual: actual, expected: expected, compared: worst.compared }; } }); return worst; } @@ -91,7 +91,7 @@ const harnessHtml = ` // walk finely ACROSS each page boundary: an offset change with overlapping // rendered ranges is exactly the state updateRowPositions must handle - var worstEver = { diff: 0, row: null, l: 0, r: 0 }; + var worstEver = { diff: 0, row: null, actual: 0, expected: 0 }; var comparedTotal = 0; var boundaries = Math.min(3, g.n - 1); for (var k = 1; k <= boundaries; k++) { @@ -106,10 +106,10 @@ const harnessHtml = ` if (w.diff > worstEver.diff) { worstEver = w; } } } - check('rows were compared across panes at the page boundaries', comparedTotal > 0, 'compared=' + comparedTotal); - check('left/right fragments of every row agree on top after paging jumps (no drift)', + check('rows with pinned regions were compared at the page boundaries', comparedTotal > 0, 'compared=' + comparedTotal); + check('docked rows keep the calculated top after paging jumps (no drift)', worstEver.diff < 0.5, - worstEver.row === null ? 'no divergence' : ('row ' + worstEver.row + ' L=' + worstEver.l + ' R=' + worstEver.r + ' diff=' + worstEver.diff)); + worstEver.row === null ? 'no divergence' : ('row ' + worstEver.row + ' actual=' + worstEver.actual + ' expected=' + worstEver.expected + ' diff=' + worstEver.diff)); out.push(pass ? '\\nALL CHECKS PASSED' : '\\nCHECKS FAILED'); document.getElementById('checkResults').textContent = out.join('\\n'); @@ -120,7 +120,7 @@ const harnessHtml = ` `; describe('Quirk - updateRowPositions must reposition every row fragment', { retries: 1 }, () => { - it('should load the self-hosted paged frozen-column repro harness', () => { + it('should load the self-hosted paged pinned-column repro harness', () => { cy.intercept('GET', '/quirk-row-positions-fragments-harness.html', { headers: { 'content-type': 'text/html' }, body: harnessHtml, diff --git a/cypress/e2e/quirk-runtime-footer-enable.cy.ts b/cypress/e2e/quirk-runtime-footer-enable.cy.ts index c6440c708..8f6836db6 100644 --- a/cypress/e2e/quirk-runtime-footer-enable.cy.ts +++ b/cypress/e2e/quirk-runtime-footer-enable.cy.ts @@ -67,8 +67,8 @@ const harnessHtml = ` if (enableError === null) { var scrollers = document.querySelectorAll('#myGrid .slick-footerrow'); - check('footer scrollers exist and are visible', - scrollers.length === 2 && isVisible(scrollers[0]), + check('footer scroller exists and is visible', + scrollers.length === 1 && isVisible(scrollers[0]), 'count=' + scrollers.length + ' visible=' + (scrollers.length ? isVisible(scrollers[0]) : '-')); var cells = document.querySelectorAll('#myGrid .slick-footerrow-column'); diff --git a/cypress/e2e/quirk-sticky-column-reorder.cy.ts b/cypress/e2e/quirk-sticky-column-reorder.cy.ts new file mode 100644 index 000000000..ff919ecbc --- /dev/null +++ b/cypress/e2e/quirk-sticky-column-reorder.cy.ts @@ -0,0 +1,73 @@ +/** + * Regression test for column reordering while a sticky column is docked. + * + * On the sticky transform path a docked sticky column is listed in the resolved left band + * although its header still lives in the centre header region. Mapping the resolved bands onto + * the Sortable band arrays therefore produced an undefined slot and threw on drop. Reordering + * two centre columns while a sticky column is docked must succeed and keep every other column + * in place. + */ + +const harnessHtml = ` + + + + Harness: sticky column reorder + + + + +
+ + + + + + +`; + +describe('Quirk - reordering columns while a sticky column is docked', { retries: 1 }, () => { + const centerHeaders = '#myGrid .slick-header-columns-center'; + + it('should reorder two centre columns without throwing and keep the sticky column docked', () => { + cy.intercept('GET', '/quirk-sticky-column-reorder-harness.html', { + headers: { 'content-type': 'text/html' }, + body: harnessHtml, + }); + cy.visit(`${Cypress.config('baseUrl')}/quirk-sticky-column-reorder-harness.html`); + cy.window().its('grid').should('exist'); + + // scroll far enough that the sticky column c2 leaves its natural position and docks left + cy.get('#myGrid .slick-horizontal-scroller').scrollTo(500, 0); + cy.get('#myGrid .slick-header-column[data-id="c2"]').should('have.class', 'slick-column-sticky'); + + cy.contains(`${centerHeaders} .slick-header-column`, 'C7').then(($target) => { + cy.contains(`${centerHeaders} .slick-header-column`, 'C6').drag($target); + }); + + cy.window().should((win: any) => { + const ids = win.grid.getColumns().map((column: any) => column.id); + expect(ids, 'column order after the drop').to.deep.equal(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c7', 'c6', 'c8', 'c9']); + expect(win.reorderCalls, 'onColumnsReordered calls').to.eq(1); + }); + cy.get('#myGrid .slick-header-column[data-id="c2"]').should('have.class', 'slick-column-sticky'); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 475e2ff49..66cb31270 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -25,7 +25,7 @@ // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; -import './drag'; // overwrites the `drag` command from "@4tw/cypress-drag-drop" with our HTML5 DnD event sequence +import './drag'; // overwrites the drag command and registers drag helpers globally import { convertPosition } from './common'; declare global { @@ -33,11 +33,21 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; - convertPosition(viewport: string): Chainable | { x: string; y: string; }>; - getCell(row: number, col: number, viewport?: string, options?: { parentSelector?: string, rowHeight?: number; }): Chainable>; - getNthCell(row: number, nthCol: number, viewport?: string, options?: { parentSelector?: string, rowHeight?: number; }): Chainable>; - restoreLocalStorage(): Chainable>; - saveLocalStorage(): Chainable>; + convertPosition(viewport: string): Chainable<{ x: string; y: string }>; + getCell( + row: number, + col: number, + viewport?: string, + options?: { parentSelector?: string; rowHeight?: number } + ): Chainable>; + getNthCell( + row: number, + nthCol: number, + viewport?: string, + options?: { parentSelector?: string; rowHeight?: number } + ): Chainable>; + restoreLocalStorage(): Chainable; + saveLocalStorage(): Chainable; } } } @@ -45,32 +55,29 @@ declare global { // convert position like 'topLeft' to the object { x: 'left|right', y: 'top|bottom' } Cypress.Commands.add('convertPosition', (viewport = 'topLeft') => cy.wrap(convertPosition(viewport))); -Cypress.Commands.add('getCell', (row, col, viewport = 'topLeft', { parentSelector = '', rowHeight = 25 } = {}) => { - const position = convertPosition(viewport); - const canvasSelectorX = position.x ? `.grid-canvas-${position.x}` : ''; - const canvasSelectorY = position.y ? `.grid-canvas-${position.y}` : ''; - - return cy.get(`${parentSelector} ${canvasSelectorX}${canvasSelectorY} [style*="top: ${row * rowHeight}px;"] > .slick-cell.l${col}.r${col}`); +// The grid renders a single viewport, so the legacy `viewport` argument is kept only for call-site compatibility. +Cypress.Commands.add('getCell', (row, col, _viewport = 'topLeft', { parentSelector = '' } = {}) => { + return cy.get(`${parentSelector} .slick-row[data-row="${row}"] .slick-cell.l${col}.r${col}`); }); -Cypress.Commands.add('getNthCell', (row, nthCol, viewport = 'topLeft', { parentSelector = '', rowHeight = 25 } = {}) => { - const position = convertPosition(viewport); - const canvasSelectorX = position.x ? `.grid-canvas-${position.x}` : ''; - const canvasSelectorY = position.y ? `.grid-canvas-${position.y}` : ''; - - return cy.get(`${parentSelector} ${canvasSelectorX}${canvasSelectorY} [style*="top: ${row * rowHeight}px;"] > .slick-cell:nth(${nthCol})`); +// `nthCol` is the column index (the cell's `.lN.rN` classes), not a DOM child position. +Cypress.Commands.add('getNthCell', (row, nthCol, _viewport = 'topLeft', { parentSelector = '' } = {}) => { + return cy.get(`${parentSelector} .slick-row[data-row="${row}"] .slick-cell.l${nthCol}.r${nthCol}`); }); -const LOCAL_STORAGE_MEMORY = {}; +const LOCAL_STORAGE_MEMORY: Record = {}; Cypress.Commands.add('saveLocalStorage', () => { - Object.keys(localStorage).forEach(key => { - LOCAL_STORAGE_MEMORY[key] = localStorage[key]; + Object.keys(localStorage).forEach((key) => { + LOCAL_STORAGE_MEMORY[key] = localStorage.getItem(key); }); }); Cypress.Commands.add('restoreLocalStorage', () => { - Object.keys(LOCAL_STORAGE_MEMORY).forEach(key => { - localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]); + Object.keys(LOCAL_STORAGE_MEMORY).forEach((key) => { + const value = LOCAL_STORAGE_MEMORY[key]; + if (value !== null) { + localStorage.setItem(key, value); + } }); -}); \ No newline at end of file +}); diff --git a/cypress/support/drag.ts b/cypress/support/drag.ts index 0268e3784..a30010271 100644 --- a/cypress/support/drag.ts +++ b/cypress/support/drag.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line n/file-extension-in-import import { convertPosition } from './common'; declare global { @@ -5,10 +6,15 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; - drag(target: string | HTMLElement | JQuery, options?: { dropSide?: DropSide; }): Chainable; - dragOutside(viewport?: string, ms?: number, px?: number, options?: { parentSelector?: string, scrollbarDimension?: number; rowHeight?: number; }): Chainable; - dragStart(options?: { cellWidth?: number; cellHeight?: number; }): Chainable; - dragCell(addRow: number, addCell: number, options?: { cellWidth?: number; cellHeight?: number; }): Chainable; + drag(target: string | HTMLElement | JQuery, options?: { dropSide?: DropSide }): Chainable; + dragOutside( + viewport?: string, + ms?: number, + px?: number, + options?: { parentSelector?: string; scrollbarDimension?: number; rowHeight?: number } + ): Chainable; + dragStart(options?: { cellWidth?: number; cellHeight?: number }): Chainable; + dragCell(addRow: number, addCell: number, options?: { cellWidth?: number; cellHeight?: number }): Chainable; dragEnd(gridSelector?: string): Chainable; } } @@ -16,23 +22,23 @@ declare global { export type DropSide = 'auto' | 'center' | 'left' | 'right'; -// elements the `drag` command can pick up when given an inner child (e.g. the header name span) +// Elements the drag command can pick up when given an inner child. const DRAGGABLE_ITEM_SELECTOR = '.slick-header-column, .slick-dropped-grouping, [draggable="true"]'; -/** Create a drag-family event (dragstart/dragenter/dragover/drop/dragend/drag) carrying a DataTransfer and real coordinates */ +/** Create a drag-family event carrying a DataTransfer and real coordinates. */ export function createDragLikeEvent(eventName: string, x: number, y: number, dataTransfer: DataTransfer): Event { - const evt = new Event(eventName, { bubbles: true, cancelable: true }); - Object.defineProperty(evt, 'dataTransfer', { value: dataTransfer }); - Object.defineProperty(evt, 'clientX', { value: x }); - Object.defineProperty(evt, 'clientY', { value: y }); - Object.defineProperty(evt, 'pageX', { value: x }); - Object.defineProperty(evt, 'pageY', { value: y }); - Object.defineProperty(evt, 'screenX', { value: x }); - Object.defineProperty(evt, 'screenY', { value: y }); - return evt; + const event = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(event, 'clientX', { value: x }); + Object.defineProperty(event, 'clientY', { value: y }); + Object.defineProperty(event, 'pageX', { value: x }); + Object.defineProperty(event, 'pageY', { value: y }); + Object.defineProperty(event, 'screenX', { value: x }); + Object.defineProperty(event, 'screenY', { value: y }); + return event; } -/** Create a mouse event with explicit page/client coordinates for resize interactions. */ +/** Create a mouse event with explicit page/client coordinates. */ export function createMouseLikeEvent(win: Window, eventName: string, x: number, y: number, buttons = 1): MouseEvent { const event = win.document.createEvent('MouseEvent'); event.initMouseEvent(eventName, true, true, win, 0, x, y, x, y, false, false, false, false, buttons, null); @@ -41,71 +47,66 @@ export function createMouseLikeEvent(win: Window, eventName: string, x: number, return event; } -/** Dispatch the pointer/mouse press that precedes a native HTML5 drag (SortableJS only arms itself from pointerdown/mousedown) */ +/** Dispatch the pointer/mouse press that precedes a native HTML5 drag. */ export function pressPointer(el: HTMLElement, x: number, y: number): void { const init = { bubbles: true, cancelable: true, button: 0, buttons: 1, clientX: x, clientY: y }; el.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerId: 1, isPrimary: true, pointerType: 'mouse' })); el.dispatchEvent(new MouseEvent('mousedown', init)); } -/** Dispatch the pointer/mouse release that follows a native HTML5 drag */ +/** Dispatch the pointer/mouse release that follows a native HTML5 drag. */ export function releasePointer(el: HTMLElement, x: number, y: number): void { const init = { bubbles: true, cancelable: true, button: 0, buttons: 0, clientX: x, clientY: y }; el.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerId: 1, isPrimary: true, pointerType: 'mouse' })); el.dispatchEvent(new MouseEvent('mouseup', init)); } -// Replace the `@4tw/cypress-drag-drop` simulation with our own HTML5 DnD event sequence (ported from Slickgrid-Universal). -// It dispatches pointerdown/mousedown -> dragstart -> dragenter/dragover -> drop -> dragend -> pointerup/mouseup with a -// shared DataTransfer and real coordinates, i.e. the same sequence a browser fires for a real drag. `dropSide` controls -// where on the target the drop lands; 'auto' aims decisively past the target's midpoint (75%/25%) so the before/after -// insertion intent is unambiguous regardless of which drag engine (SortableJS or native) interprets it. -Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, options: { dropSide?: DropSide; } = {}) => { - const dropSide: DropSide = options?.dropSide ?? 'auto'; +// Keep the repository's drag/drop simulation available for every spec, including specs +// that use cy.drag() without importing this module directly. +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, options: { dropSide?: DropSide } = {}) => { + const dropSide: DropSide = options.dropSide ?? 'auto'; return cy.wrap(subject, { log: false }).then(($source: JQuery) => { - const rawSourceElm = $source?.[0] as HTMLElement | undefined; - const sourceElm = rawSourceElm?.closest(DRAGGABLE_ITEM_SELECTOR) ?? rawSourceElm; + const rawSource = $source?.[0] as HTMLElement | undefined; + const source = rawSource?.closest(DRAGGABLE_ITEM_SELECTOR) ?? rawSource; const targetChain = typeof target === 'string' ? cy.get(target, { log: false }) : cy.wrap(target, { log: false }); - return targetChain.then(($target: any) => { - const rawTargetElm = ($target?.[0] ?? $target) as HTMLElement | undefined; - const targetElm = rawTargetElm?.closest?.(DRAGGABLE_ITEM_SELECTOR) as HTMLElement ?? rawTargetElm; - - if (!sourceElm || !targetElm) { + return targetChain.then(($target: JQuery) => { + const rawTarget = ($target?.[0] ?? $target) as HTMLElement | undefined; + // Headers and grouping pills resolve to their draggable owner, but a + // grouping dropzone is deliberately droppable-only. Keep the raw target + // when it has no draggable ancestor so cross-list header drops reach + // Sortable's dropzone instance. + const targetElement = rawTarget?.closest(DRAGGABLE_ITEM_SELECTOR) ?? rawTarget; + if (!source || !targetElement) { return cy.wrap($source, { log: false }); } const dataTransfer = new DataTransfer(); - const sourceRect = sourceElm.getBoundingClientRect(); + const sourceRect = source.getBoundingClientRect(); const sourceX = sourceRect.left + sourceRect.width / 2; const sourceY = sourceRect.top + sourceRect.height / 2; + pressPointer(source, sourceX, sourceY); + source.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY, dataTransfer)); - pressPointer(sourceElm, sourceX, sourceY); - sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY, dataTransfer)); - - // SortableJS activates the drag on the next macrotask, so yield before dragging over the target return cy.wait(20, { log: false }).then(() => { - const targetRect = targetElm.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); let side = dropSide; if (side === 'auto') { - if (sourceElm.parentElement === targetElm.parentElement && sourceRect.left !== targetRect.left) { - side = sourceRect.left < targetRect.left ? 'right' : 'left'; - } else { - side = 'center'; - } + side = source.parentElement === targetElement.parentElement && sourceRect.left !== targetRect.left + ? sourceRect.left < targetRect.left ? 'right' : 'left' + : 'center'; } const fraction = side === 'right' ? 0.75 : side === 'left' ? 0.25 : 0.5; const targetX = targetRect.left + targetRect.width * fraction; const targetY = targetRect.top + targetRect.height / 2; - - targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY, dataTransfer)); - targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY, dataTransfer)); + targetElement.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY, dataTransfer)); + targetElement.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY, dataTransfer)); return cy.wait(20, { log: false }).then(() => { - targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY, dataTransfer)); - sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY, dataTransfer)); - releasePointer(sourceElm, targetX, targetY); + targetElement.dispatchEvent(createDragLikeEvent('drop', targetX, targetY, dataTransfer)); + source.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY, dataTransfer)); + releasePointer(source, targetX, targetY); return cy.wrap($source, { log: false }); }); }); @@ -114,68 +115,108 @@ Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, }); // @ts-ignore -Cypress.Commands.add('dragStart', { prevSubject: true }, (subject, { cellWidth = 80, cellHeight = 25 } = {}) => { - return cy.wrap(subject).click({ force: true }) +Cypress.Commands.add('dragStart', { prevSubject: true }, (subject: HTMLElement, { cellWidth = 80, cellHeight = 25 } = {}) => { + return cy + .wrap(subject) + .click({ force: true }) .trigger('mousedown', { which: 1, force: true }) .trigger('mousemove', cellWidth / 3, cellHeight / 3, { force: true }); }); -// use a different command name than "drag" so that it doesn't conflict with the "@4tw/cypress-drag-drop" lib -// @ts-ignore -Cypress.Commands.add('dragCell', { prevSubject: true }, (subject, addRow, addCell, { cellWidth = 80, cellHeight = 25 } = {}) => { - return cy.wrap(subject).trigger('mousemove', cellWidth * (addCell + 0.5), cellHeight * (addRow + 0.5), { force: true }); -}); - -Cypress.Commands.add('dragOutside', (viewport = 'topLeft', ms = 0, px = 0, { parentSelector = 'div[class^="slickgrid_"]', scrollbarDimension = 17 } = {}) => { - const $parent = cy.$$(parentSelector); - const gridWidth = $parent.width(); - const gridHeight = $parent.height(); - let x = gridWidth / 2; - let y = gridHeight / 2; - const position = convertPosition(viewport); - if (position.x === 'left') { - x = -px; - } else if (position.x === 'right') { - x = gridWidth - scrollbarDimension + 3 + px; - } - if (position.y === 'top') { - y = -px; - } else if (position.y === 'bottom') { - y = gridHeight - scrollbarDimension + 3 + px; +// use a different command name than 'drag' so that it doesn't conflict with the '@4tw/cypress-drag-drop' lib +Cypress.Commands.add( + 'dragCell', + // @ts-ignore + { prevSubject: true }, + (subject: HTMLElement, addRow: number, addCell: number, { cellWidth = 80, cellHeight = 25 } = {}) => { + return cy.wrap(subject).trigger('mousemove', cellWidth * (addCell + 0.5), cellHeight * (addRow + 0.5), { force: true }); } +); + +Cypress.Commands.add( + 'dragOutside', + (viewport = 'topLeft', ms = 0, px = 0, { parentSelector = 'div[class^="slickgrid_"]', scrollbarDimension = 17 } = {}) => { + const $parent = cy.$$(parentSelector); + const parentElement = $parent[0] as HTMLElement | undefined; + const parentRect = parentElement?.getBoundingClientRect(); + const viewportElement = $parent.find('.slick-viewport')[0] as HTMLElement | undefined; + const viewportRect = viewportElement?.getBoundingClientRect() || parentRect; + let clientX = viewportRect ? viewportRect.left + viewportRect.width / 2 : 0; + let clientY = viewportRect ? viewportRect.top + viewportRect.height / 2 : 0; + const position = convertPosition(viewport); + if (position.x === 'left') { + clientX = (viewportRect?.left || 0) - scrollbarDimension - px; + } else if (position.x === 'right') { + clientX = (viewportRect?.right || 0) + 3 + px; + } + if (position.y === 'top') { + clientY = (viewportRect?.top || 0) - scrollbarDimension - px; + } else if (position.y === 'bottom') { + clientY = (viewportRect?.bottom || 0) + 3 + px; + } - cy.get(parentSelector).trigger('mousemove', x, y, { force: true }); - if (ms) { - cy.wait(ms); + // Cypress' positional trigger overload converts coordinates relative to + // its subject into a real page/client position. Supplying pageX/pageY in + // the options object is not equivalent: Cypress re-normalizes those + // values for a nested viewport, which made a downward drag appear inside + // the viewport (and kept vertical scrollTop at 0). Dispatch from the grid + // container, as the original helper did, while deriving the point from the + // actual body viewport so docking remains correct. + const parentLeft = parentRect?.left ?? 0; + const parentTop = parentRect?.top ?? 0; + const move = cy.wrap(parentElement, { log: false }).trigger( + 'mousemove', + clientX - parentLeft, + clientY - parentTop, + { button: 0, force: true, which: 1 } + ); + + return ms ? move.wait(ms, { log: false }) : move; } - return; -}); +); -Cypress.Commands.add('dragEnd', { prevSubject: 'optional' }, (subject, gridSelector = 'div[class^="slickgrid_"]') => { - cy.get('body').trigger('mouseup', { force: true }); - cy.get(gridSelector).trigger('mouseup', { force: true }); - return; +Cypress.Commands.add('dragEnd', { prevSubject: 'optional' }, (_subject, gridSelector = 'div[class^="slickgrid_"]') => { + return cy + .get('body', { log: false }) + .trigger('mouseup', { force: true }) + .then(() => cy.get(gridSelector, { log: false }).trigger('mouseup', { force: true })); }); -export function getScrollDistanceWhenDragOutsideGrid(selector, viewport, dragDirection, fromRow, fromCol, px = 100) { - return (cy as any).convertPosition(viewport).then((_viewportPosition: { x: number; y: number; }) => { +export function getScrollDistanceWhenDragOutsideGrid( + selector: string, + viewport: string, + dragDirection: string, + fromRow: number, + fromCol: number, + px = 140 +) { + return (cy as any).convertPosition(viewport).then((_viewportPosition: { x: string; y: string }) => { const viewportSelector = `${selector} .slick-viewport-${_viewportPosition.x}.slick-viewport-${_viewportPosition.y}`; - (cy as any).getNthCell(fromRow, fromCol, viewport, { parentSelector: selector }) - .dragStart(); - return cy.get(viewportSelector).then($viewport => { - const scrollTopBefore = $viewport.scrollTop(); - const scrollLeftBefore = $viewport.scrollLeft(); - cy.dragOutside(dragDirection, 300, px, { parentSelector: selector }); - return cy.get(viewportSelector).then($viewportAfter => { - cy.dragEnd(selector); - const scrollTopAfter = $viewportAfter.scrollTop(); - const scrollLeftAfter = $viewportAfter.scrollLeft(); - cy.get(viewportSelector).scrollTo(0, 0, { ensureScrollable: false }); - return cy.wrap({ - scrollTopBefore, - scrollLeftBefore, - scrollTopAfter, - scrollLeftAfter + const cellViewport = cy.$$(selector).find('.slick-viewport').length === 1 ? 'topLeft' : viewport; + return (cy as any).getNthCell(fromRow, fromCol, cellViewport, { parentSelector: selector }).dragStart().then(() => cy.get(selector)).then(($grid) => { + // Pinning uses one dedicated horizontal scroll owner. Keep this helper + // compatible with both the legacy pane viewport and the new proxy so + // drag auto-scroll assertions observe the actual scroll position. + const viewport = ($grid.find(viewportSelector)[0] || $grid.find('.slick-vertical-scroller')[0]) as HTMLElement; + const horizontalScroller = $grid.find('.slick-horizontal-scroller')[0] as HTMLElement | undefined; + const horizontalOwner = horizontalScroller || viewport; + const scrollTopBefore = viewport.scrollTop; + const scrollLeftBefore = horizontalOwner.scrollLeft; + return cy.dragOutside(dragDirection, 300, px, { parentSelector: selector }).then(() => cy.get(selector)).then(($gridAfter) => { + const viewportAfter = ($gridAfter.find(viewportSelector)[0] || $gridAfter.find('.slick-vertical-scroller')[0]) as HTMLElement; + const horizontalScrollerAfter = $gridAfter.find('.slick-horizontal-scroller')[0] as HTMLElement | undefined; + const horizontalOwnerAfter = horizontalScrollerAfter || viewportAfter; + const scrollTopAfter = viewportAfter.scrollTop; + const scrollLeftAfter = horizontalOwnerAfter.scrollLeft; + return cy.dragEnd(selector).then(() => { + horizontalOwnerAfter.scrollLeft = 0; + viewportAfter.scrollTop = 0; + return cy.wrap({ + scrollTopBefore, + scrollLeftBefore, + scrollTopAfter, + scrollLeftAfter, + }); }); }); }); diff --git a/docs/data/overlays.json b/docs/data/overlays.json index 0cfcc4b9a..f1d820413 100644 --- a/docs/data/overlays.json +++ b/docs/data/overlays.json @@ -3,7 +3,6 @@ "grid:opt-columnPickerTitle": { "notes": "Deprecated. Set the title through the `columnPicker` option object instead: `columnPicker: { columnTitle: '…' }`." }, "grid:opt-forceFitTitle": { "notes": "Deprecated. Use `columnPicker: { forceFitTitle: '…' }` instead." }, "grid:opt-syncResizeTitle": { "notes": "Deprecated. Use `columnPicker: { syncResizeTitle: '…' }` instead." }, - "grid:opt-throwWhenFrozenNotAllViewable": { "notes": "Deprecated. Handle a frozen column wider than the viewport with `invalidColumnFreezeWidthCallback` / `invalidColumnFreezeWidthMessage` instead." }, "dataview:dvopt-inlineFilters": { "notes": "Deprecated and ignored in v6. DataView filtering is compiled safely for you; there is no inline-filter mode to choose." }, "dataview:dvopt-useCSPSafeFilter": { "notes": "Deprecated and ignored in v6. DataView filtering is always CSP-safe, so this flag has no effect." }, "grid:opt-editable": { @@ -205,6 +204,15 @@ "grid:evt-onHeaderMouseLeave": { "notes": "Fires when the pointer leaves a column header cell." }, + "grid:evt-onHeaderMouseOver": { + "notes": "Fires on the native `mouseover` of a column header cell. Unlike `onHeaderMouseEnter`, it fires again each time the pointer moves onto an element inside the cell." + }, + "grid:evt-onHeaderMouseOut": { + "notes": "Fires on the native `mouseout` of a column header cell. Unlike `onHeaderMouseLeave`, it also fires when the pointer moves onto an element inside the cell." + }, + "grid:evt-onHeaderKeyDown": { + "notes": "Fires when a key is pressed while focus is in the column header row, before Enter or Space sorts a sortable column. `args.event` is the keyboard event; `args.column` is set when the focused element is a header cell." + }, "grid:evt-onHeaderRowCellRendered": { "notes": "Fires after a header-row cell is built, when the `showHeaderRow` panel is enabled." }, @@ -214,6 +222,12 @@ "grid:evt-onHeaderRowMouseLeave": { "notes": "Fires when the pointer leaves a header-row cell." }, + "grid:evt-onHeaderRowMouseOver": { + "notes": "Fires on the native `mouseover` of a header-row cell. Unlike `onHeaderRowMouseEnter`, it fires again each time the pointer moves onto an element inside the cell, such as a filter input." + }, + "grid:evt-onHeaderRowMouseOut": { + "notes": "Fires on the native `mouseout` of a header-row cell. Unlike `onHeaderRowMouseLeave`, it also fires when the pointer moves onto an element inside the cell." + }, "grid:evt-onPreHeaderContextMenu": { "notes": "Fires when the user right-clicks the pre-header panel." }, diff --git a/docs/in-depth/frozen.md b/docs/in-depth/frozen.md deleted file mode 100644 index 10c703661..000000000 --- a/docs/in-depth/frozen.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Frozen rows & columns ---- - -# Frozen rows & columns - -Frozen (pinned) rows and columns stay in place while the rest of the grid scrolls. Use them for row headers, key columns, or a totals row that must stay visible. - -::: tip v6 -Version 6 reworks frozen panes into a 3×3 band model. You can now pin columns on the **right** as well as the left, and pin rows at the **top and bottom at the same time**. This is the ViewportMgr work from PR #1238. -::: - -## The mental model - -The grid is split into up to nine viewports: a left / centre / right column band, crossed with a top / middle / bottom row band. Frozen bands do not scroll. The centre-middle viewport scrolls in both directions. You choose which bands exist through options. - -## Freeze columns on the left - -Set [`frozenColumn`](/reference/grid#opt-frozenColumn) to the zero-based index of the last column to pin. Every column up to and including that index stays fixed on the left. - -```ts -const grid = new SlickGrid('#grid', data, columns, { - frozenColumn: 1, // pin the first two columns -}); -``` - -## Freeze columns on the right (v6) - -Set `frozenRightColumn` to the **number** of columns to pin on the right edge. - -```ts -const grid = new SlickGrid('#grid', data, columns, { - frozenRightColumn: 1, // pin the last column on the right -}); -``` - -`frozenRightColumn` is a count from the right, not a column index. The count stays correct when columns are reordered or hidden. Keep at least one scrollable column between the left and right frozen bands; see the freeze-validation options below. - -## Freeze rows at the top - -Set [`frozenRow`](/reference/grid#opt-frozenRow) to the number of rows to pin. By default they pin at the top. - -```ts -const grid = new SlickGrid('#grid', data, columns, { - frozenRow: 1, // pin the first row -}); -``` - -## Freeze rows at the bottom - -There are two ways, for two different needs. - -- Legacy single band: set [`frozenBottom`](/reference/grid#opt-frozenBottom) to `true`. This moves the single `frozenRow` band to the bottom instead of the top. -- v6 top **and** bottom: set `frozenBottomRow` to the number of rows to pin at the bottom, together with `frozenRow` for the top. When `frozenBottomRow` is greater than 0, the legacy `frozenBottom` flag is ignored. - -```ts -const grid = new SlickGrid('#grid', data, columns, { - frozenRow: 1, // one pinned row at the top - frozenBottomRow: 1, // one pinned row at the bottom (v6) -}); -``` - -## Keep a usable scroll area - -When you pin columns, keep the scrollable middle wide enough to use. - -- `frozenRightViewportMinWidth` (default 100) sets the smallest width to keep for the right section of a frozen grid. -- The grid validates that frozen columns are not wider than the visible canvas. You can control the message and callback with the `invalidColumnFreeze*` options, or skip the check with `skipFreezeColumnValidation`. - -## Notes - -- Frozen columns work well with the column `hidden` property. Because `frozenRightColumn` is a count from the right, hiding or reordering columns does not break the right band. See [Migrating to v6](/in-depth/migration-v6). -- Mouse-wheel scrolling over a frozen band is handled for you; `enableMouseWheelScrollHandler` is on by default. - -## See also - -- [Migrating to v6](/in-depth/migration-v6) -- Reference: [`frozenColumn`](/reference/grid#opt-frozenColumn), [`frozenRow`](/reference/grid#opt-frozenRow), [`frozenBottom`](/reference/grid#opt-frozenBottom), and the other frozen options on the [SlickGrid reference](/reference/grid). diff --git a/docs/in-depth/index.md b/docs/in-depth/index.md index 26bcf5daa..01bcab2e5 100644 --- a/docs/in-depth/index.md +++ b/docs/in-depth/index.md @@ -14,7 +14,7 @@ Topic-by-topic guides. Each teaches one capability in full, then links into the - [Selection models](/in-depth/selection) - [Cell editors](/in-depth/custom-editors) - [Formatters](/in-depth/formatters) -- [Frozen rows & columns](/in-depth/frozen) +- [Pinning & sticky docking](/in-depth/pinning-sticky) - [Menus & header UI](/in-depth/menus) ## Platform diff --git a/docs/in-depth/migration-v6.md b/docs/in-depth/migration-v6.md index bb37ba003..39bb45bdc 100644 --- a/docs/in-depth/migration-v6.md +++ b/docs/in-depth/migration-v6.md @@ -4,7 +4,7 @@ title: Migrating to v6 # Migrating to v6 -Version 6 is a major release. It removes the last third-party dependency, reworks frozen rows and columns, and changes how columns are hidden. This page lists the breaking changes and how to update. +Version 6 is a major release. It removes the last third-party dependency, replaces frozen rows and columns with pinning and sticky docking, and changes how columns are hidden. This page lists the breaking changes and how to update. ::: tip This page describes the upcoming v6 release. Some items come from feature branches that merge at release. The copy/paste change is still a draft and may change. @@ -22,11 +22,19 @@ What to change: The package now has no runtime dependency. Install is just the grid and a theme. -## Frozen rows and columns: the 3×3 band model +## Frozen panes become pinning and sticky docking -Version 6 reworks frozen panes. You can now freeze columns on the **right** as well as the left, and freeze rows at the **top and bottom at the same time**. The grid is organised as a 3×3 band of viewports. +Version 6 removes frozen panes. The grid has one scrollable viewport, and the `pinning` option docks columns and rows at its edges. You can now pin columns on the **right** as well as the left, pin rows at the **top and bottom at the same time**, and make columns and rows **sticky**, so that they dock at an edge only while scrolling would hide them. -See [Frozen rows & columns](/in-depth/frozen) for the full guide and the option list. +What to change: + +- Replace `frozenColumn: N` with `pinning: { columns: { left: N } }`. The boundary is the same, but it now counts visible columns only. +- Replace `frozenRow: N` with `pinning: { rows: { top: [0, …, N-1] } }`. With `frozenBottom: true`, list the last rows in `pinning.rows.bottom` instead. +- Rename `skipFreezeColumnValidation` to `skipPinningValidation`, and the `invalidColumnFreeze*` options to `invalidColumnPinning*`. `throwWhenFrozenNotAllViewable` has no replacement. +- Replace `getFrozenColumnId()` with `getPinnedColumns()`, and `validateColumnFreeze()` with `validateColumnPinning()`. +- Update your CSS selectors. The `.slick-pane-*` classes and the `-right` and `-bottom` viewport, canvas and header classes are gone. + +See [Pinning and sticky docking](/in-depth/pinning-sticky) for the full guide and the complete migration table. ## Columns: the `hidden` property @@ -52,17 +60,22 @@ Version 6 plans to move external copy and paste to the browser's async Clipboard These are the public additions and renames from the v6 feature branches. New members appear in the [Reference](/reference/) once the release is integrated and the reference is regenerated. -### Frozen columns (PR #1238) +### Pinning and sticky docking (PR #1302) -- New options: `frozenRightColumn`, `frozenBottomRow`, `lazyPanes`. -- New methods: `getFrozenBandCounts()`, `getFrozenRightStartIndex()`. -- See [Frozen rows & columns](/in-depth/frozen). +- New options: `pinning`, `stickyRows`, `docking`, `skipPinningValidation`, `invalidColumnPinningPickerMessage`, `invalidColumnPinningSequenceMessage`, `invalidColumnPinningPickerCallback`, `invalidColumnPinningWidthMessage`, `invalidColumnPinningWidthCallback`, `allowDragFromClosest`, `columnResizingDelay`. +- New column properties: `pinned`, `sticky`. +- New methods: `getPinnedColumns()`, `setColumnPinning()`, `setColumnStickiness()`, `validateColumnPinning()`. +- New events: `onHeaderMouseOver`, `onHeaderMouseOut`, `onHeaderRowMouseOver`, `onHeaderRowMouseOut`, `onHeaderKeyDown`. +- Removed options: `frozenColumn`, `frozenRow`, `frozenBottom`, `frozenRightViewportMinWidth`, `skipFreezeColumnValidation`, `throwWhenFrozenNotAllViewable`, and the four `invalidColumnFreeze*` options. +- Removed methods: `getFrozenColumnId()`, `getFrozenRowOffset()`, `validateColumnFreeze()`, `validateColumnFreezeWidth()`. +- Changed: `setColumns()` returns a boolean. It returns `false`, and changes nothing, when it rejects the pinning of the new columns. +- See [Pinning and sticky docking](/in-depth/pinning-sticky). ### Column `hidden` (PR #1299) - New option: `spreadHiddenColspan`. -- New methods: `getColumnById()`, `getVisibleColumnIndex()`, `updateColumnById()`, `getRowTop()`, `validateColumnFreeze()`. -- Renamed: `calculateFrozenColumnIndexById()` and `validateSetColumnFreeze()` are replaced by `validateColumnFreeze()`. +- New methods: `getColumnById()`, `getVisibleColumnIndex()`, `updateColumnById()`, `getRowTop()`. +- Removed: `calculateFrozenColumnIndexById()` and `validateSetColumnFreeze()`. Pinning is validated by `validateColumnPinning()` (PR #1302). ### SortableJS removal (PRs #1242–#1244) @@ -75,5 +88,5 @@ These are the public additions and renames from the v6 feature branches. New mem ## See also -- [Frozen rows & columns](/in-depth/frozen) +- [Pinning and sticky docking](/in-depth/pinning-sticky) - [Plugins](/in-depth/plugins) · [Reference](/reference/) diff --git a/docs/in-depth/performance.md b/docs/in-depth/performance.md index ec1c790dc..a18895a45 100644 --- a/docs/in-depth/performance.md +++ b/docs/in-depth/performance.md @@ -13,7 +13,7 @@ SlickGrid stays fast because it never puts the whole dataset in the DOM. It rend The grid renders a window, not the whole dataset. - Only the rows inside the viewport are in the DOM. Rows above and below are not. -- Rendering is virtual **horizontally** too. Cells to the left and right of the visible columns are not rendered. Frozen rows and frozen columns are always rendered. +- Rendering is virtual **horizontally** too. Cells to the left and right of the visible columns are not rendered. Pinned rows and pinned columns are always rendered. - As you scroll, the grid removes rows that leave the window and builds rows that enter it. The result: DOM size stays roughly constant no matter how many rows the data has. A 100-row grid and a 1,000,000-row grid hold about the same number of row elements. Memory and layout cost track the viewport size, not the data size. @@ -219,7 +219,7 @@ On each scroll the grid chooses how to render: Two related options rarely need changing: -- [`enableMouseWheelScrollHandler`](/reference/grid#opt-enableMouseWheelScrollHandler) (default `true`) attaches the grid's own wheel handler. Frozen grids need it, so keep it on. +- [`enableMouseWheelScrollHandler`](/reference/grid#opt-enableMouseWheelScrollHandler) (default `true`) attaches the grid's own wheel handler. Grids with pinned or sticky columns need it: their horizontal scrollbar is below the body, and the handler is what scrolls them sideways when you use a wheel or trackpad over the cells. Keep it on. - [`emulatePagingWhenScrolling`](/reference/grid#opt-emulatePagingWhenScrolling) (default `true`) changes how keyboard and programmatic navigation land a far-away target row: it pages so the row appears at the top of the viewport. It is a navigation behaviour, not a render-cost knob. ### 7. Measure what renders diff --git a/docs/in-depth/pinning-sticky.md b/docs/in-depth/pinning-sticky.md new file mode 100644 index 000000000..d5b5368bc --- /dev/null +++ b/docs/in-depth/pinning-sticky.md @@ -0,0 +1,235 @@ +--- +title: Pinning and sticky docking +--- + +# Pinning and sticky docking + +::: tip v6 +Version 6 replaces frozen panes with pinning and sticky docking (PR #1302). If you used `frozenColumn`, `frozenRow` or `frozenBottom`, see [Migrating from frozen panes (v5)](#migrating-from-frozen-panes-v5) below. +::: + +SlickGrid renders one scrollable viewport with one canvas. Columns and rows can be *pinned* +(always docked at an edge) or *sticky* (docked only while normal scrolling would move them out +of view). Both are resolved by the same internal docking logic and rendered without extra +panes: every row is one DOM node with a left, centre and right cell region, pinned rows are +moved into a small overlay outside the scrolling canvas, and a grid that uses docking scrolls +horizontally through one dedicated scrollbar below the body. + +## Permanent pinning + +```ts +const options = { + pinning: { + columns: { left: 2, right: 1 }, + rows: { top: [0], bottom: ['summary'] }, + }, +}; +``` + +### Columns + +- `pinning.columns.left` — a number is an inclusive zero-based boundary among the *visible* + columns (`2` pins the first three visible columns). An array lists explicit columns. +- `pinning.columns.right` — a number is a count from the trailing edge of the visible columns + (`1` pins the last visible column). An array lists explicit columns. +- Array entries are column **indexes** when numeric and column **ids** when strings. A numeric + entry is never matched against a numeric column id. Arrays may be non-contiguous + (`left: ['account', 'status']`). +- `Column.pinned: 'left' | 'right' | null` is the per-column form and is kept in sync with the + option; `grid.setColumnPinning(columnId, side)` changes one column at runtime, records every + pinned column by id so the pins follow their columns through a reorder, and + `grid.getPinnedColumns(side?)` reads the current state. +- Pinning is validated: a request that pins every visible column, or whose pinned columns + together would leave the centre band no width, is rejected and the previous state is kept. + `invalidColumnPinningPickerCallback` / `invalidColumnPinningWidthCallback` + (default `alert`) receive the message (`invalidColumnPinning*Message`), and + `skipPinningValidation: true` disables the check. Hiding a column through the Column Picker or + Grid Menu runs the same validation. A colspan that would be split so that its pieces are no + longer in left → centre → right order is rejected with `invalidColumnPinningSequenceMessage`. +- Columns are reordered within their band only; dragging a header never moves a column across + a band. Changing a band is an explicit pinning operation. + +### Rows + +- `pinning.rows.top` / `pinning.rows.bottom` — arrays of row references, in one of three forms: + + | Reference | Meaning | + |---|---| + | `5` | row **index** 5 | + | `'order-5'` | the row whose dataset id is `'order-5'` | + | `{ id: 5 }` | the row whose dataset id is `5` | + + The object form exists so that a grid with numeric dataset ids can still pin by id: a bare + number is always an index. Id references follow their row when the data is sorted or filtered; + index references address whatever row currently occupies that position. Rows may be + non-contiguous (`top: [0, 2, 4]`); the unpinned rows are laid out contiguously so no gaps + appear. +- Pinned rows keep their place in the dataset and in the scroll height. Their slot collapses + under the pinned band, so every scrolling row, including the add-new row, stays reachable. +- Permanent rows are always rendered in full; there is no budget for them (see + `docking.minCenterRowCount` below). +- Rows are changed at runtime with `grid.setOptions({ pinning: { rows: { top: [...] } } })`. The + `top`/`bottom` arrays are replaced, not merged. `setOptions({ pinning: undefined })` and + `setOptions({ pinning: null })` both remove pinning entirely and return the grid to the plain + layout. +- Index references do not survive a change in row count. A grid that pins "the last row" while + filtering has to recompute the reference when the count changes, as + `examples/example-pinning-columns-and-rows.html` does; otherwise the reference points past the + end of the filtered set and the band disappears. +- In each band the permanent rows sit at the outer edge and active sticky rows stack inside them: + a sticky row docked at the bottom sits **above** a permanently pinned bottom row, mirroring the + top band, and carries `slick-row-pinned-bottom-edge`. + +## Sticky docking + +```ts +const columns = [ + { id: 'account', field: 'account', sticky: 'left' }, + { id: 'q1', field: 'q1', sticky: 'both' }, +]; +const options = { + stickyRows: { top: ['subtotal'], bottom: ['total'], both: ['net'] }, +}; +``` + +- `Column.sticky`: `'left'` or `'right'` docks at that physical edge once scrolling would clip the + column; `'both'` docks at the nearer edge; `true` means the leading edge (`left` in LTR, `right` + in RTL). `grid.setColumnStickiness(columnId, value)` changes it at runtime. A permanently pinned + column ignores its `sticky` flag: it can never be clipped, so it stays a plain pin. +- `stickyRows.top` docks a row when its natural position crosses the top edge, `bottom` when it + crosses the bottom edge, `both` at the nearer edge. References follow the same index/id rule as + pinned rows. +- Sticky membership is recomputed from the current scroll position (including direct jumps), so it + is never serialized in grid state. +- Multiple sticky rows stack in dataset order inside the budget below; they do not push each + other out. + +### Budgets (`docking` option) + +| Option | Default | Meaning | +|---|---|---| +| `maxColumnViewportWidthPercent` | 60 | Maximum share of the viewport width the left and right bands may occupy. Permanent pins count against it but always stay pinned; a sticky column docks only while it still fits. | +| `maxRowViewportHeightPercent` | 60 | Maximum share of the viewport height that *sticky* rows may occupy after permanent rows are deducted. | +| `overflowStrategy` | `'conveyor'` | When the budget is exhausted: `conveyor` keeps the most recently activated candidates, `clamp` keeps the earliest ones. A candidate larger than the remaining budget stays in normal flow. | +| `stickyActivationBuffer` | 2 | Activation buffer in pixels for sticky columns. Rows use the exact boundary. | +| `minCenterRowCount` | 3 | When permanent top/bottom rows would leave less than this many centre rows visible, the container grows (`min-height`) instead of shrinking the centre to nothing. `0` disables. | + +## Rendering notes + +- Colspans that cross a band boundary keep one logical host cell (formatters, selection, + navigation) and render a continuation in each further band. Every piece is clipped to its own + band, and each continuation carries a presentational copy of the host's content, offset by what + the earlier bands already showed, so the content reads as one cell while nothing is painted over + the band beside it. The copy is `aria-hidden`; assistive technology and the API see only the + host. Full-width group rows are rendered as one viewport-wide cell. +- Row spans are supported; a spanning cell that starts in a pinned row stays in the overlay. +- Pinned separators are painted with inset shadows, not layout borders, so header and body widths + stay aligned across themes. The active theme can override the `--slick-pinned-*` custom + properties read by `_slick-docking.scss`. + +## DOM and selectors + +| Selector | Meaning | +|---|---| +| `.slick-vertical-scroller` | The element that owns vertical scrolling (always `.slick-viewport`). | +| `.slick-horizontal-scroller` | The element that owns horizontal scrolling: `.slick-viewport` on a plain grid, `.slick-docking-horizontal-scroller` when docking is configured. | +| `.slick-row > .slick-pinned-left-cells / .slick-scrolling-cells / .slick-pinned-right-cells` | The three cell regions of a row on a docking grid. | +| `.slick-header-columns-left / -center / -right` (and `slick-headerrow-columns-*`, `slick-footerrow-columns-*`) | Chrome regions on a docking grid. On a plain grid the roots keep the legacy `.slick-header-columns-left` class. | +| `.slick-docking-overlay` | The layer that holds pinned and active sticky rows. Created only when row docking is configured. | +| `.slick-row-pinned-top / -bottom`, `.slick-row-sticky`, `.slick-column-pinned-left / -right`, `.slick-column-sticky` | State classes on rows and header cells. | + +When docking is configured, the header, header-row, footer-row, and optional top-header panels +reserve the body viewport's vertical-scrollbar strip with an opaque, pointer-transparent cover. +This prevents a horizontally transformed header fragment from painting into the top area above +the scrollbar in Chromium-based browsers. + +`grid.getCellFromPoint(x, y)` takes canvas-relative coordinates and resolves them through the +rendered layout (bands, overlay rows, non-contiguous shifts), including rows that are not rendered. + +## Right-to-left grids + +Pinning and sticky docking work in a `rtl: true` grid; the grid applies `dir="rtl"` to its own +container before it lays anything out, so the page around it can stay left to right. The bands +are named for the reading order rather than for the screen, so the same configuration describes +both directions: + +| Option | Left to right | Right to left | +|---|---|---| +| `columns.left` | pinned at the left edge | pinned at the **right** edge | +| `columns.right` | pinned at the right edge | pinned at the **left** edge | +| `sticky: true` | docks at the left edge | docks at the **right** edge | +| `sticky: 'left'` / `'right'` | that edge | the same band, mirrored | + +In other words `left` is the leading band and `right` the trailing one; a grid does not need +different options for the two directions. The `slick-column-pinned-left` and +`slick-cell-pinned-left` classes follow the same rule, so a stylesheet that targets them keeps +working when the direction changes. + +`getCellFromPoint(x, y)` takes x from the grid's top-left corner in both directions, which means +that in a right-to-left grid it counts back from the last column. A point over a docked band +resolves to the column drawn there, not to the column the natural layout would put underneath it. + +## Migrating from frozen panes (v5) + +| v5 | Now | +|---|---| +| `frozenColumn: N` | `pinning: { columns: { left: N } }` (same inclusive boundary, now counted over visible columns) | +| `frozenRow: N` | `pinning: { rows: { top: [0, …, N-1] } }` | +| `frozenRow: N, frozenBottom: true` | `pinning: { rows: { bottom: [len-N, …, len-1] } }` | +| `frozenRightViewportMinWidth` | removed; use `docking.maxColumnViewportWidthPercent` | +| `skipFreezeColumnValidation` | `skipPinningValidation` | +| `invalidColumnFreezePickerMessage/Callback`, `invalidColumnFreezeWidthMessage/Callback`, `throwWhenFrozenNotAllViewable` | `invalidColumnPinningPickerMessage/Callback`, `invalidColumnPinningWidthMessage/Callback` (no throwing variant) | +| `grid.getFrozenColumnId()`, `grid.getFrozenRowOffset()`, `grid.validateColumnFreeze()`, `grid.validateColumnFreezeWidth()` | `grid.getPinnedColumns()`, `grid.validateColumnPinning()`; row offsets are internal | +| `getCanvases()`, `getViewports()`, `getHeaderRow()`, `getFooterRow()` returning left/right pairs | One element each; the array-returning forms still return one entry | +| `.slick-pane-*`, `.slick-viewport-right`, `.slick-viewport-bottom`, `.grid-canvas-right`, `.grid-canvas-bottom`, `.slick-header-right`, `.slick-headerrow-right`, `.slick-footerrow-right` | Removed. Use the selectors in the table above. `slick-viewport-top slick-viewport-left` and `grid-canvas-top grid-canvas-left` remain on the single viewport/canvas. | +| Column reorder across the frozen boundary | Not possible; pin/unpin explicitly | +| Grid State plugin `frozenColumn` | Not persisted; store `pinning` from `grid.getOptions()` | + +`setColumns()` now returns a boolean. It validates the prospective pinning on a copy first, so a +rejected set leaves the caller's column definitions untouched and fires no `onBeforeSetColumns`; +it returns `false` in that case and `true` once the columns are applied. + +The new `onHeaderKeyDown` event is typed `OnHeaderKeyDownEventArgs` and publishes `{ event, column, grid }`. + +`getTopPanels()` returns the one top panel this layout has; v5 returned a left and a right panel. + +Header, header-row, footer and cell events keep their argument shapes. `getGridPosition()` and +`getActiveCellPosition()` still return document-relative positions. `applyHtmlCode()`, +`trigger()`, `validateAndEnforceOptions()` and the `set*Visibility(visible, animate)` signatures +are unchanged from v5. + +## Known limitations + +- Sticky group headers (a header spanning several columns that itself stays visible) are not + supported. +- There is no built-in Header Menu or Grid Menu command for pinning; an application adds its own + menu command that calls `grid.setColumnPinning(columnId, side)`. + +## Examples + +In the repository's `examples/` folder: + +- Columns: `example-pinning-columns.html`, `example-pinning-columns-large.html`, + `example-pinning-columns-autoheight.html`, `example-pinning-columns-tabs.html` +- Columns and rows: `example-pinning-columns-and-rows.html`, + `example-pinning-columns-and-rows-spreadsheet.html`, `example-pinning-rows.html`, + `example-pinning-row-reordering.html` +- Column groups: `example-pinning-columns-and-column-group.html`, + `example-pinning-columns-and-column-group-hidden-col.html` +- Sticky docking: `example-sticky-financial-report.html` +- Right to left: `example-pinning-rtl.html` +- Variable row height: `example-variable-row-height-pinning.html` + +Browser coverage lives in `cypress/e2e/*pinning*`, `*sticky*`, `quirk-pinning-*`, +`quirk-docking-*` and `example-colspan.cy.ts`. + +## See also + +- [Migrating to v6](/in-depth/migration-v6) +- Reference: [`pinning`](/reference/grid#opt-pinning), [`stickyRows`](/reference/grid#opt-stickyRows), + [`docking`](/reference/grid#opt-docking), [`skipPinningValidation`](/reference/grid#opt-skipPinningValidation), + the column properties [`pinned`](/reference/column#col-pinned) and [`sticky`](/reference/column#col-sticky), + and the methods [`setColumnPinning()`](/reference/grid#m-setColumnPinning), + [`setColumnStickiness()`](/reference/grid#m-setColumnStickiness), + [`getPinnedColumns()`](/reference/grid#m-getPinnedColumns) and + [`validateColumnPinning()`](/reference/grid#m-validateColumnPinning). diff --git a/docs/in-depth/typescript.md b/docs/in-depth/typescript.md index 2649ee4e3..497a61aeb 100644 --- a/docs/in-depth/typescript.md +++ b/docs/in-depth/typescript.md @@ -215,7 +215,7 @@ dv.onDestroyed.subscribe(() => console.log('DataView destroyed')); - **No SortableJS global.** Old SlickGrid required you to assign `window.Sortable = Sortable`. v6 removes the SortableJS dependency, so delete that code; no global assignment or extra install is needed. See - [Frozen rows & columns](/in-depth/frozen) for the related reordering notes. + [Pinning & sticky docking](/in-depth/pinning-sticky) for how reordering works with pinned columns. - **Use `import type` for interfaces.** `Column`, `GridOption`, and the other interfaces are type-only exports. Import them with `import type` so bundlers with `verbatimModuleSyntax` do not try to emit a runtime import. diff --git a/docs/index.md b/docs/index.md index 5748d536a..6d6cdad8e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ features: details: Build your first grid and learn the everyday options, columns, events and DataView basics. link: /introduction/ - title: In-depth - details: Topic-by-topic coverage of every capability — data, editing, selection, grouping, frozen panes, theming and more. + details: Topic-by-topic coverage of every capability — data, editing, selection, grouping, pinned and sticky columns, theming and more. link: /in-depth/ - title: Reference details: Every option, property, event and method — generated from the TypeScript source, browsable by section or A–Z. diff --git a/docs/introduction/columns.md b/docs/introduction/columns.md index b779a33d6..0397014b3 100644 --- a/docs/introduction/columns.md +++ b/docs/introduction/columns.md @@ -37,9 +37,9 @@ Read the current columns with `getColumns`. ## Version 6 note -Version 6 changes how hidden columns work. The grid keeps all columns and uses the `hidden` property to show or hide them. See [Frozen & column changes in v6](/in-depth/frozen). +Version 6 changes how hidden columns work. The grid keeps all columns and uses the `hidden` property to show or hide them. See [Migrating to v6](/in-depth/migration-v6#columns-the-hidden-property). ## See also -- Reference: [every column property](/reference/column) (50 in total). +- Reference: [every column property](/reference/column) (52 in total). - [Common options](/introduction/options) diff --git a/docs/introduction/events.md b/docs/introduction/events.md index 06368b300..c66ce2086 100644 --- a/docs/introduction/events.md +++ b/docs/introduction/events.md @@ -41,4 +41,4 @@ grid.onClick.unsubscribe(handler); ## Where to find the rest -The grid has 57 events. The [Reference](/reference/grid) lists every event and the shape of its `args`. +The grid has 62 events. The [Reference](/reference/grid) lists every event and the shape of its `args`. diff --git a/docs/introduction/next-steps.md b/docs/introduction/next-steps.md index 14fbab462..05dc46bed 100644 --- a/docs/introduction/next-steps.md +++ b/docs/introduction/next-steps.md @@ -6,7 +6,7 @@ title: Next steps You can now build, configure, and react to a grid. Here is where to go next. -- Learn a topic in depth in the [In-depth](/in-depth/) section: editors, formatters, selection, grouping, frozen columns, theming, and more. +- Learn a topic in depth in the [In-depth](/in-depth/) section: editors, formatters, selection, grouping, pinned columns, theming, and more. - Look up any option, method, or event in the [Reference](/reference/). Use the **Section / A–Z** toggle or the search box. - Browse the [examples](https://github.com/6pac/SlickGrid/wiki/Examples). @@ -15,5 +15,5 @@ You can now build, configure, and react to a grid. Here is where to go next. - [Cell editors](/in-depth/custom-editors) — make cells editable. - [Formatters](/in-depth/formatters) — control how cells look. - [Selection models](/in-depth/selection) — select rows or cells. -- [Frozen rows & columns](/in-depth/frozen) — pin rows and columns. +- [Pinning & sticky docking](/in-depth/pinning-sticky) — keep rows and columns in view at an edge. - [Theming](/in-depth/theming) — match your app's style. diff --git a/docs/introduction/options.md b/docs/introduction/options.md index e72d3ec64..9f05a641a 100644 --- a/docs/introduction/options.md +++ b/docs/introduction/options.md @@ -35,4 +35,4 @@ grid.setOptions({ editable: false }); ## Where to find the rest -SlickGrid has 118 options. The [Reference](/reference/grid) lists every one with its type and default value. Use the **A–Z** toggle or search to find an option by name. +SlickGrid has 120 options. The [Reference](/reference/grid) lists every one with its type and default value. Use the **A–Z** toggle or search to find an option by name. diff --git a/examples/example-0031-row-span-employees.html b/examples/example-0031-row-span-employees.html index 75d9b9fc0..c856e6d73 100644 --- a/examples/example-0031-row-span-employees.html +++ b/examples/example-0031-row-span-employees.html @@ -45,7 +45,7 @@

(i.e: Filtering/Sorting/Paging/... will not change/update the spanning in the grid by itself)
- NOTE 3: column/row freezing (pinning) are not supported, or at least not recommended unless you know exactly what you're doing! + NOTE 3: column/row pinning with row spans is not supported, or at least not recommended unless you know exactly what you're doing! Any freezing column/row that could intersect because of a colspan/rowspan will cause problems.
@@ -228,7 +228,7 @@

enableCellNavigation: true, enableColumnReorder: false, enableCellRowSpan: true, // required flag for rowspan - frozenColumn: 0, + pinning: { columns: { left: 0 } }, rowHeight: 30, rowTopOffsetRenderType: 'top' // rowspan doesn't render well with 'transform', default is 'top' }; @@ -248,7 +248,7 @@

showEmployeeId = !showEmployeeId; // Keep the complete column list and switch visibility with the hidden property. // This is the breaking-change behavior introduced by the column visibility migration. - grid.setOptions({ frozenColumn: showEmployeeId ? 0 : -1 }); + grid.setOptions({ pinning: { columns: { left: showEmployeeId ? 0 : [] } } }); grid.updateColumnById("employeeID", { hidden: !showEmployeeId }); grid.updateColumns(); grid.remapAllColumnsRowSpan(); diff --git a/examples/example-auto-header-height.html b/examples/example-auto-header-height.html index 6f4bbf3b9..b40ac349a 100644 --- a/examples/example-auto-header-height.html +++ b/examples/example-auto-header-height.html @@ -10,7 +10,7 @@ -

Example - Frozen Grid with Header Grouping

+

Example - Pinned Grid with Header Grouping

@@ -33,11 +39,11 @@

Demonstrates:

    -
  • Frozen columns with extra header row grouping columns into categories
  • +
  • Pinned columns with an extra header row grouping columns into categories
- - + +

Hide Duration Column @@ -46,7 +52,7 @@


View Source:

@@ -62,27 +68,17 @@

View Source:

@@ -62,25 +80,14 @@

- \ No newline at end of file + diff --git a/examples/example-frozen-columns-large.html b/examples/example-pinning-columns-large.html similarity index 98% rename from examples/example-frozen-columns-large.html rename to examples/example-pinning-columns-large.html index f9a5adb81..6c467e5f7 100644 --- a/examples/example-frozen-columns-large.html +++ b/examples/example-pinning-columns-large.html @@ -3,7 +3,7 @@ - SlickGrid example: Frozen Columns + SlickGrid example: Pinned Columns @@ -31,7 +31,7 @@ background-color: transparent; /* show default selected row background */ } - .slick-pane.frozen { + .slick-column-pinned-left-edge { border-right: 1.5px dotted darkblue !important; } .options-panel { @@ -40,7 +40,7 @@ -

Example - Frozen Columns with Large Dataset

+

Example - Pinned Columns with Large Dataset

@@ -67,9 +67,9 @@



- - - + + +

@@ -119,7 +119,7 @@

Demonstrates:

diff --git a/examples/example-pinning-rtl.html b/examples/example-pinning-rtl.html new file mode 100644 index 000000000..f333ac564 --- /dev/null +++ b/examples/example-pinning-rtl.html @@ -0,0 +1,153 @@ + + + + + + + + SlickGrid example: Pinned and sticky columns (RTL) + + + + + + +

Example - Pinned and Sticky Columns (RTL)

+ + + + + +
+
+ + + + +
+
+
+
+

+ ⌂ + Demonstrates: +

+
+
    +
  • a right-to-left grid with permanently pinned columns at both edges
  • +
  • a sticky column that docks at whichever edge it reaches
  • +
  • pinned rows at the top and bottom of the same grid
  • +
  • a colspan crossing the pinned boundary, drawn as one cell in both directions
  • +
  • changing the pinning at runtime
  • +
+

View Source:

+ +
+ + + + + + + + + diff --git a/examples/example-pivot.html b/examples/example-pivot.html index 35709fea7..528cbbe05 100644 --- a/examples/example-pivot.html +++ b/examples/example-pivot.html @@ -548,6 +548,12 @@

View Source:

$("#c00_ListGrid_grid_container").height("650px"); grid = new Slick.Grid("#myGrid", pivotInfo.PivotedData, columns, options); + grid.onColumnsDrag.subscribe(function (e, args) { + CreateAddlHeaderRow(grid); + }); + grid.onColumnsResized.subscribe(function (e, args) { + CreateAddlHeaderRow(grid); + }); CreateAddlHeaderRow(grid); } }) diff --git a/examples/example-plugin-headermenu.html b/examples/example-plugin-headermenu.html index d6da3e992..2b2d979aa 100644 --- a/examples/example-plugin-headermenu.html +++ b/examples/example-plugin-headermenu.html @@ -164,10 +164,10 @@

View Source:

{ divider: true }, { // we can also have multiple nested sub-menus - command: 'freezing', title: 'Freeze/Pinning', + command: 'pinning', title: 'Column Pinning', commandItems: [ - { command: "freeze-columns", title: "Freeze Columns" }, - { command: "unfreeze-columns", title: "Unfreeze all Columns" }, + { command: "pin-columns", title: "Pin Columns" }, + { command: "unpin-columns", title: "Unpin all Columns" }, ] }, { @@ -308,4 +308,4 @@

View Source:

- \ No newline at end of file + diff --git a/examples/example-quirk-always-render-column-routing.html b/examples/example-quirk-always-render-column-routing.html index 26b0d9ac9..cc27efe5f 100644 --- a/examples/example-quirk-always-render-column-routing.html +++ b/examples/example-quirk-always-render-column-routing.html @@ -7,12 +7,12 @@ self-hosting and does NOT depend on this page. Bug (pre-fix): appendRowHtml's off-viewport branch appended alwaysRenderColumn - cells to the LEFT row fragment unconditionally. With frozen columns, an - alwaysRenderColumn column RIGHT of the freeze that scrolls off the left edge + cells to the LEFT row fragment unconditionally. With pinned columns, an + alwaysRenderColumn column RIGHT of the pinning boundary that scrolls off the left edge therefore rendered into the clipped LEFT canvas — mispositioned/invisible, and its cell-node mapping pointed at the wrong pane. - On this page: column 0 is frozen; "Sticky" (column 5, highlighted) is + On this page: column 0 is pinned; "Sticky" (column 5, highlighted) is alwaysRenderColumn in the scrollable band. Click "Scroll far right" then "Report" — a pre-fix build reports the Sticky cell inside .grid-canvas-left; a fixed build reports .grid-canvas-right. @@ -66,7 +66,7 @@

Off-viewport alwaysRenderColumn band routing (TEMPORARY repro, var grid = new Slick.Grid('#myGrid', data, columns, { enableCellNavigation: true, enableColumnReorder: false, - frozenColumn: 0, + pinning: { columns: { left: 0 } }, rowHeight: 25 }); diff --git a/examples/example-quirk-frozen-row-boundary.html b/examples/example-quirk-pinning-row-boundary.html similarity index 55% rename from examples/example-quirk-frozen-row-boundary.html rename to examples/example-quirk-pinning-row-boundary.html index 4025d8a28..c9a91ffeb 100644 --- a/examples/example-quirk-frozen-row-boundary.html +++ b/examples/example-quirk-pinning-row-boundary.html @@ -1,40 +1,34 @@ - SlickGrid quirk repro: frozen-row boundary (temporary, do not merge) + SlickGrid quirk repro: pinned-row boundary (temporary, do not merge) -

Frozen-row boundary drift (TEMPORARY repro, do not merge)

-

Rows carrying the .frozen class are tinted red. -Fixed build: exactly the pinned rows are tinted in both grids.

+

Pinned-row boundary behavior (TEMPORARY repro, do not merge)

+

Rows carrying a pinned-row class are tinted red. +Exactly the configured top or bottom rows should be tinted in each grid.

-

Grid A — 3 frozen TOP rows

+

Grid A — 3 pinned TOP rows

-

Grid B — 3 frozen BOTTOM rows (1000 rows)

+

Grid B — 3 pinned BOTTOM rows (1000 rows)

- +
@@ -46,7 +40,7 @@

Grid B — 3 frozen BOTTOM rows (1000 rows)

diff --git a/examples/example-sticky-financial-report.html b/examples/example-sticky-financial-report.html new file mode 100644 index 000000000..18d09f437 --- /dev/null +++ b/examples/example-sticky-financial-report.html @@ -0,0 +1,291 @@ + + + + + + SlickGrid: Sticky Financial Report + + + + + + +

Example - Sticky Financial Report

+
+
+

About

+

+ Account, Q1–Q4, and YTD use two-sided sticky columns. Total Revenue, Total Expenses, + and Net Profit use two-sided sticky rows and dock to the nearest vertical edge after they + have been seen. +

+

View Source:

+ +
+ +
+ +
+ left sticky column + bottom sticky row + sticky intersection + sticky quarter +
+
+ + + + + + + + diff --git a/examples/example-variable-row-height-frozen.html b/examples/example-variable-row-height-pinning.html similarity index 55% rename from examples/example-variable-row-height-frozen.html rename to examples/example-variable-row-height-pinning.html index f816bd765..512375352 100644 --- a/examples/example-variable-row-height-frozen.html +++ b/examples/example-variable-row-height-pinning.html @@ -3,7 +3,7 @@ - SlickGrid example: Variable row height with frozen columns/rows + SlickGrid example: Variable row height with pinned columns/rows @@ -13,13 +13,13 @@ -

Variable row height + frozen panes (rowHeightProvider + frozenColumn/frozenRow)

+

Variable row height + pinning (rowHeightProvider + pinning)

-

Grid A — two frozen columns

+

Grid A — two pinned columns

-

Grid B — two frozen columns + three frozen rows

+

Grid B — two pinned columns + three pinned rows

@@ -27,15 +27,14 @@

Grid B — two frozen columns + three frozen rows

About

Both grids use a rowHeightProvider (every 13th row 70px, every 5th row 32px, - else 25px). Grid A freezes the first two columns; Grid B freezes the first two columns - and the first three rows. Row nodes are cloned per column pane, so the checks assert - that left/right panes agree on every row's top and height, and that the frozen row pane is - exactly as tall as the rows it holds. + else 25px). Grid A pins the first two columns; Grid B pins the first two columns + and the first three rows. The checks assert that the single viewport keeps + the provider heights and that both grids expose the expected pinning configuration.

Controls

- + - +
@@ -51,9 +50,9 @@

Controls

`; + +const cspRewriteRule = { + match: //, + fn: (_req, _res, match) => + match.replace("script-src 'self'", "script-src 'self' 'nonce-browser-sync'").replace('trusted-types dompurify', 'trusted-types dompurify browser-sync') + + browserSyncTrustedTypesPolicy, +}; + /** * Dev script that will watch for files changed and run esbuild/sass for the file(s) that changed. * We use @parcel/watcher to watch source files and then run esbuild or SASS CLIs to build our supported formats (.js, .ts, .html, .css, .scss). @@ -35,37 +66,38 @@ const argv = parseArgs({ * Note: the watcher often send duplicate events, however the use of Set of file changes & the use of a setTimeout delay gets rid of this problem. */ async function init() { - subscription = subscribe(process.cwd(), (err, events) => { + subscription = await subscribe(process.cwd(), (err, events) => { if (err) return onError(err); for (const event of events) { - const absoluteFilePath = relative(process.cwd(), event.path); - onFileChanged(absoluteFilePath); + const changedFilePath = relative(process.cwd(), event.path).replaceAll('\\', '/'); + if (watchedFilePattern.test(changedFilePath)) { + onFileChanged(changedFilePath, event.type); + } } }, { ignore: [ '**/.git/**', '**/dist/**', '**/cypress/**', - '**/node_modules/**', - '!**/*.{ts,js,html,css,scss}' // which file extensions to watch + '**/node_modules/**' ] }); // also watch for any Signal termination to cleanly exit the watch command process.once('SIGINT', () => destroy()); process.once('SIGTERM', () => destroy()); - process.stdin.on('end', () => destroy()); - process.stdin.on('exit', () => process.stdin.destroy()); - // run full prod build `/dist` and full SASS build if (!argv.serve) { await executeFullBuild(); - buildAllSassFiles(); // start SASS build but no need to await it + await buildAllSassFiles(); } + // The initial build above already built every IIFE file. Do not rebuild + // the entire IIFE set on the first subsequent source change. + initialBuild = false; // start browser-sync server - startBrowserSync(); + await startBrowserSync(); } /** @@ -75,21 +107,36 @@ const argv = parseArgs({ async function startBrowserSync() { bsync = browserSync.create(); - bsync.init({ - server: './', - port: 8080, - watchTask: true, - online: false, - open: argv.open, - startPath: 'examples/index.html', - snippetOptions: { - rule: { - match: /<\/head>/i, - fn: (snippet, match) => snippet.replace('id=', `nonce="browser-sync" id=`) + match + await new Promise((resolve, reject) => { + bsync.init({ + server: './', + rewriteRules: [cspRewriteRule], + host: browserSyncHost, + port: browserSyncPort, + ui: false, + // File watching and rebuilds are handled by @parcel/watcher below. + // BrowserSync only serves the examples and receives explicit reloads. + watch: false, + online: false, + open: argv.open, + startPath: 'examples/index.html', + snippetOptions: { + rule: { + match: /<\/head>/i, + fn: (snippet, match) => snippet.replace('id=', `nonce="browser-sync" id=`) + match + } } - }, - }, () => { - console.log('Use Ctrl+C to Quit'); + }, (err) => { + if (err) { + const startupError = new Error(`BrowserSync could not start at http://${browserSyncHost}:${browserSyncPort}: ${err.message}`); + onError(startupError); + reject(startupError); + return; + } + console.log(`BrowserSync serving at http://${browserSyncHost}:${browserSyncPort}`); + console.log('Use Ctrl+C to Quit'); + resolve(); + }); }); } @@ -103,13 +150,15 @@ const argv = parseArgs({ * We add a setTimeout delay to throttle the callbacks to avoid calling the build too often. * @param {String} filepath - file path that changed */ - function onFileChanged(filepath) { + function onFileChanged(filepath, eventType = 'update') { if (timer) { clearTimeout(timer); } changedFiles.add(filepath); + console.log(`[Watch] ${eventType}: ${filepath}`); timer = setTimeout(() => { - executeCommandCallback(Array.from(changedFiles).pop()); + timer = 0; + void executeCommandCallback(Array.from(changedFiles).pop()); }, 150); } @@ -121,52 +170,53 @@ const argv = parseArgs({ return false; } - function executeCommandCallback(filepath = '') { - return new Promise(async (resolve) => { - if (!processing) { - processing = true; - changedFiles.delete(filepath); - - if (filepath.endsWith('.js') || filepath.endsWith('.ts')) { - // 1. ESM requires is always a full build because it ends up being bundled into a single "index.js" file - await executeCjsEsmBuilds(); - - // 2. for iife format, we can rebuild each separate file (unless it's the initial build, if so execute a full rebuild) - await (initialBuild - ? buildAllIifeFiles() - : buildIifeFile(filepath) - ); - } else if (filepath.endsWith('.css') || filepath.endsWith('.scss')) { - // CSS/SCSS files - if (filepath.endsWith('.scss')) { - await buildSassFile(filepath); - } - } - // ELSE, reaching outside of the conditions above (i.e.: .html) - // will simply perform the common action, of reloading all connected browsers - - // in every case, we want to reload the web page - bsync.reload('*.html'); - processing = false; - if (initialBuild) { - initialBuild = false; - } + async function executeCommandCallback(filepath = '') { + if (processing) { + return; + } - // we might still have other packages that have changes though, so re-execute the command callback process if any were found - if (hasQueuedChanges()) { - executeCommandCallback(Array.from(changedFiles).pop()); - } + processing = true; + changedFiles.delete(filepath); + console.log(`[Watch] Rebuilding: ${filepath}`); + + try { + if (filepath.endsWith('.js') || filepath.endsWith('.ts')) { + // 1. ESM requires a full build because it ends up bundled into a single "index.js" file. + await executeCjsEsmBuilds(); + + // 2. IIFE files are built separately, so rebuild only the changed file. + await (initialBuild ? buildAllIifeFiles() : buildIifeFile(filepath)); + } else if (filepath.endsWith('.scss')) { + await buildSassFile(filepath); } - resolve(true); - }); + + } catch (err) { + onError(err); + } finally { + // Always request a full browser reload after a change, including when a + // TypeScript rebuild reports an error. Passing a file pattern here would + // emit a file-change event rather than guarantee a page reload. + bsync?.reload(); + console.log(`[Watch] Browser reload requested: ${filepath}`); + processing = false; + initialBuild = false; + + // Process the latest queued change after the current build completes. + if (hasQueuedChanges()) { + void executeCommandCallback(Array.from(changedFiles).pop()); + } + } } async function destroy() { console.log('Exiting the dev file watch...'); - bsync.exit(); - (await subscription).unsubscribe(); + bsync?.exit(); + await subscription?.unsubscribe(); } // start dev watch process - init(); + init().catch((err) => { + onError(err); + void destroy(); + }); })(); diff --git a/src/controls/slick.gridmenu.ts b/src/controls/slick.gridmenu.ts index 541ea17b2..6550ceffa 100644 --- a/src/controls/slick.gridmenu.ts +++ b/src/controls/slick.gridmenu.ts @@ -176,17 +176,6 @@ export class SlickGridMenu { this._gridMenuOptions = Utils.extend({}, this._defaults, gridOptions.gridMenu); this._bindingEventService = new BindingEventService(); - // when a grid optionally changes from a regular grid to a frozen grid, we need to destroy & recreate the grid menu - // we do this change because the Grid Menu is on the left container for a regular grid, it is however on the right container for a frozen grid - grid.onSetOptions.subscribe((_e, args) => { - if (args && args.optionsBefore && args.optionsAfter) { - const switchedFromRegularToFrozen = args.optionsBefore.frozenColumn! >= 0 && args.optionsAfter.frozenColumn === -1; - const switchedFromFrozenToRegular = args.optionsBefore.frozenColumn === -1 && args.optionsAfter.frozenColumn! >= 0; - if (switchedFromRegularToFrozen || switchedFromFrozenToRegular) { - this.recreateGridMenu(); - } - } - }); this.init(this.grid); } @@ -209,11 +198,8 @@ export class SlickGridMenu { protected createGridMenu() { const gridMenuWidth = (this._gridMenuOptions?.menuWidth) || this._defaults.menuWidth; - if (this._gridOptions && Object.prototype.hasOwnProperty.call(this._gridOptions, 'frozenColumn') && this._gridOptions.frozenColumn! >= 0) { - this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-right`); - } else { - this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-left`); - } + // The current docking layout always keeps the grid menu in the left header region. + this._headerElm = document.querySelector(`.${this._gridUid} .slick-header-left`); this._headerElm!.style.width = `calc(100% - ${gridMenuWidth}px)`; // if header row is enabled, we need to resize its width also @@ -374,7 +360,7 @@ export class SlickGridMenu { gridMenuElm.style.display = 'none'; } if (this._headerElm) { - // put back original width (fixes width and frozen+gridMenu on left header) + // put back original width (fixes width and pinning+gridMenu on left header) this._headerElm.style.width = '100%'; } this._buttonElm?.remove(); @@ -525,7 +511,7 @@ export class SlickGridMenu { this._listElm.role = 'menu'; } - /** Delete and then Recreate the Grid Menu (for example when we switch from regular to a frozen grid) */ + /** Delete and then recreate the Grid Menu. */ recreateGridMenu() { this.deleteMenu(); this.init(this.grid); diff --git a/src/global.d.ts b/src/global.d.ts index d9e243ad3..9e843a59e 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -24,6 +24,7 @@ import type { } from './slick.core.js'; import type { SlickDataView } from './slick.dataview.js'; import type { SlickGrid } from './slick.grid.js'; +import type { DockingController } from './slick.core.js'; import type { SlickCompositeEditor } from './slick.compositeeditor.js'; import type { SlickColumnMenu } from './controls/slick.columnmenu.js'; import type { SlickColumnPicker } from './controls/slick.columnpicker.js'; @@ -98,6 +99,7 @@ declare global { Formatters: typeof Formatters, GlobalEditorLock: typeof SlickGlobalEditorLock, Grid: typeof SlickGrid, + DockingController: typeof DockingController, GridAutosizeColsMode: typeof GridAutosizeColsMode, Group: typeof SlickGroup, GroupTotals: typeof SlickGroupTotals, diff --git a/src/models/column.interface.ts b/src/models/column.interface.ts index 3b118a9cf..bb7f8c3d9 100644 --- a/src/models/column.interface.ts +++ b/src/models/column.interface.ts @@ -13,6 +13,7 @@ import type { HeaderButtonsOrMenu } from './index.js'; import type { SlickGrid } from '../slick.grid.js'; +import type { DockingSide } from './docking.interface.js'; type PathsToStringProps = T extends string | number | boolean | Date ? [] : { [K in Extract]: [K, ...PathsToStringProps] @@ -134,6 +135,9 @@ export interface Column { /** is the column hidden? */ hidden?: boolean; + /** Permanently dock this column at the left or right edge of the grid viewport. */ + pinned?: DockingSide | null; + /** ID of the column, each column definition ID must be unique or else SlickGrid will throw an error. */ id: number | string; @@ -176,6 +180,9 @@ export interface Column { /** Is the column sortable? Goes with grid option "enableSorting: true". */ sortable?: boolean; + /** Dock this column only after normal scrolling would clip it. Ignored while the column is permanently pinned. */ + sticky?: DockingSide | 'both' | boolean; + /** Custom Tooltip that can ben shown to the column */ toolTip?: string; diff --git a/src/models/docking.interface.ts b/src/models/docking.interface.ts new file mode 100644 index 000000000..56e3180f7 --- /dev/null +++ b/src/models/docking.interface.ts @@ -0,0 +1,134 @@ +export type DockingSide = 'left' | 'right'; + +export type DockingOverflowStrategy = 'conveyor' | 'clamp'; + +/** An edge boundary/count or explicit column indexes and ids used by pinning. */ +export type ColumnPinningReferences = number | Array; + +/** + * Column references used inside the unified `GridOption.pinning` option and + * grid-state presets. This is the nested value shape, not a separate + * `pinnedColumns` grid option. + */ +export interface PinnedColumns { + /** + * Column indexes or ids to pin to the left edge. + * A number is an inclusive zero-based boundary among visible columns (`2` pins the first three visible columns). + * An array accepts zero-based indexes (numbers) and/or column ids (strings) for non-contiguous pinning. + */ + left?: ColumnPinningReferences; + + /** + * Column indexes or ids to pin to the right edge. + * A number is a count from the trailing edge of visible columns (`1` pins the last visible column; `0` pins none). + * An array accepts zero-based indexes (numbers) and/or column ids (strings) for non-contiguous pinning. + */ + right?: ColumnPinningReferences; +} + +/** + * A row reference: a number is a row index, a string is a dataset id, and `{ id }` is a dataset + * id of either type (the form to use when dataset ids are numeric). + */ +export type RowReference = number | string | { id: number | string }; + +export interface PinnedRows { + /** Rows to pin permanently to the top edge. */ + top?: RowReference[]; + + /** Rows to pin permanently to the bottom edge. */ + bottom?: RowReference[]; +} + +/** Permanent pinning for both grid axes. */ +export interface PinningOption { + /** Configuration for columns pinned permanently to the left or right edge. */ + columns?: PinnedColumns; + + /** Configuration for rows pinned permanently to the top or bottom edge. */ + rows?: PinnedRows; +} + +export interface StickyRows { + /** Rows that dock to the top after scrolling past them. */ + top?: RowReference[]; + + /** Rows that dock to the bottom after scrolling back above them. */ + bottom?: RowReference[]; + + /** Rows that dock to the nearest edge when normal scrolling would clip them. */ + both?: RowReference[]; +} + +export interface DockingOption { + /** Maximum percentage of the viewport width that left and right docked columns may occupy. Defaults to 60. */ + maxColumnViewportWidthPercent?: number; + + /** Maximum percentage of the viewport height that top and bottom docked rows may occupy. Defaults to 60. */ + maxRowViewportHeightPercent?: number; + + /** + * Minimum number of rows of breathing room reserved for the scrollable center-row viewport + * when permanent top/bottom pinned rows are present. Unlike `maxRowViewportHeightPercent` + * (which only budgets sticky rows), this guarantees the always-rendered permanent rows leave + * room for at least this many center rows: when the combined top/bottom pinned height would + * leave less room than that, the container is grown (via `min-height`) so both the pinned rows + * and this many center rows stay visible, rather than shrinking the center to zero. Expressed as + * a row count rather than a pixel height so it stays meaningful with variable row heights. + * Set to 0 to disable. Defaults to 3. + */ + minCenterRowCount?: number; + + /** How sticky candidates are reduced when their pixel budget is exhausted. Defaults to `conveyor`. */ + overflowStrategy?: DockingOverflowStrategy; + + /** Pixel activation buffer used when resolving sticky columns (rows dock at the exact boundary). Defaults to 2. */ + stickyActivationBuffer?: number; +} + +export type ColumnDockingBand = 'left' | 'center' | 'right'; +export type RowDockingBand = 'top' | 'center' | 'bottom'; + +export interface DockedColumn { + band: ColumnDockingBand; + index: number; + naturalOffset: number; + offset: number; + sticky: boolean; + width: number; +} + +export interface ColumnDockingLayout { + center: DockedColumn[]; + centerWidth: number; + contentWidth: number; + left: DockedColumn[]; + leftBaseWidth: number; + leftWidth: number; + revision: number; + right: DockedColumn[]; + rightBaseWidth: number; + rightWidth: number; +} + +export interface DockingRow { + height: number; + id: number | string; + index: number; + top: number; +} + +export interface DockedRow extends DockingRow { + band: RowDockingBand; + offset: number; + sticky: boolean; +} + +export interface RowDockingLayout { + bottom: DockedRow[]; + bottomHeight: number; + center: DockedRow[]; + revision: number; + top: DockedRow[]; + topHeight: number; +} diff --git a/src/models/gridEvents.interface.ts b/src/models/gridEvents.interface.ts index b3cb4e32f..7162c3172 100644 --- a/src/models/gridEvents.interface.ts +++ b/src/models/gridEvents.interface.ts @@ -30,6 +30,7 @@ export interface OnHeaderCellRenderedEventArgs extends SlickGridArg { node: HTML export interface OnFooterClickEventArgs extends SlickGridArg { column: Column; } export interface OnHeaderClickEventArgs extends SlickGridArg { column: Column; } export interface OnHeaderContextMenuEventArgs extends SlickGridArg { column: Column; } +export interface OnHeaderKeyDownEventArgs extends SlickGridArg { event: KeyboardEvent; column: Column; } export interface OnHeaderMouseEventArgs extends SlickGridArg { column: Column; } export interface OnHeaderRowCellRenderedEventArgs extends SlickGridArg { node: HTMLDivElement; column: Column; } export interface OnPreHeaderClickEventArgs extends SlickGridArg { node: HTMLElement; } diff --git a/src/models/gridOption.interface.ts b/src/models/gridOption.interface.ts index 81cb75cd3..a68c1a60c 100644 --- a/src/models/gridOption.interface.ts +++ b/src/models/gridOption.interface.ts @@ -4,6 +4,9 @@ import type { ColumnPickerOption, ColumnReorderFunction, ContextMenuOption, + DockingOption, + PinningOption, + StickyRows, CustomTooltipOption, EditCommand, EditorConstructor, @@ -34,6 +37,10 @@ export interface CssStyleHash { } export interface GridOption { + /** Defaults to `div.slick-cell.dnd, div.slick-cell.cell-reorder`, CSS selector of the closest cell ancestor that allows a row drag to start. */ + allowDragFromClosest?: string; + /** Shared pixel budgets and overflow behavior for pinned and sticky rows/columns. */ + docking?: DockingOption; /** CSS class name used on newly added row */ addNewRowCssClass?: string; @@ -141,6 +148,9 @@ export interface GridOption { /** Do we have paging enabled? */ doPaging?: boolean; + /** Defaults to 300 (ms), debounce delay applied to column resizing before the grid re-renders. */ + columnResizingDelay?: number; + /** Defaults to false, when enabled will give the possibility to edit cell values with inline editors. */ editable?: boolean; @@ -217,7 +227,7 @@ export interface GridOption { /** * Do we want to always enable the mousewheel scroll handler? * In other words, do we want the mouse scrolling would work from anywhere. - * Typically we should only enable it when using a Frozen/Pinned grid and if it does detect it to be a frozen grid, + * Typically we should only enable it when using a pinned grid and if it detects pinning, * then it will automatically enable the scroll handler if this flag was originally set to undefined (which it is by default unless the user specifically disabled it). */ enableMouseWheelScrollHandler?: boolean; @@ -249,21 +259,8 @@ export interface GridOption { /** Formatter classes factory */ formatterFactory?: { getFormatter: (col: C) => Formatter; } | null; - /** Defaults to false, do we want to freeze (pin) the bottom portion instead of the top */ - frozenBottom?: boolean; - - /** Number of column index(es) to freeze (pin) in the grid */ - frozenColumn?: number; - - /** Number of row index(es) to freeze (pin) in the grid */ - frozenRow?: 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 - * and make it bigger than the viewport width, then the grid becomes unusable because the right section goes into a void/hidden area. - */ - frozenRightViewportMinWidth?: number; + /** Unified permanent pinning for columns and rows. */ + pinning?: PinningOption; /** Defaults to false, which leads to have row(s) taking full width */ fullWidthRows?: boolean; @@ -420,30 +417,21 @@ export interface GridOption { /** Defaults to false, when set to True will sync the column cell resize & apply the column width */ syncColumnCellResize?: boolean; - /** When set to true, it will skip the validation check to make sure frozen columns are not wider than the grid visible canvas width */ - skipFreezeColumnValidation?: boolean; - - /** @deprecated @use `invalidColumnFreezeWidthCallback` Defaults to false, should we throw an error when frozenColumn is wider than the grid viewport width. */ - throwWhenFrozenNotAllViewable?: boolean; - - /** Message to show when the frozen column is invalid and `invalidColumnFreezeWidthCallbackPicker` is enabled */ - invalidColumnFreezePickerMessage?: string; - - /** - * Defaults to `alert(error)`, which will trigger when the user tries to uncheck too many columns via ColumnPicker/GridMenu. - * We need to have 1 or more columns visible on the right side of the frozen column. - */ - invalidColumnFreezePickerCallback?: (error: string) => void; - - /** Message to show when the frozen column width is invalid and `invalidColumnFreezeWidthCallbackWidth` or `throwWhenFrozenNotAllViewable` is enabled */ - invalidColumnFreezeWidthMessage?: string; - - /** - * Defaults to `alert(error)`, which will trigger when the user tries to set a `frozenColumn` that is wider than the visible grid viewport width in the browser. - * We can't freeze wider than the viewport because the right canvas will never be visible and since the left canvas is never scrollable this would break the UX. - */ - invalidColumnFreezeWidthCallback?: (error: string) => void; - + /** Stable row ids or indexes that dock after ordinary scrolling clips them. */ + stickyRows?: StickyRows; + + /** Skip validation that pinned columns leave a usable center region. */ + skipPinningValidation?: boolean; + /** Message passed to `invalidColumnPinningPickerCallback` when a pinning request would leave no visible center column. */ + invalidColumnPinningPickerMessage?: string; + /** Message passed to `invalidColumnPinningPickerCallback` when a pinning request that is not sequential from an edge would split a colspan. */ + invalidColumnPinningSequenceMessage?: string; + /** Defaults to `alert()`, called with the picker or sequence message when the grid rejects a pinning request. */ + invalidColumnPinningPickerCallback?: (error: string) => void; + /** Message passed to `invalidColumnPinningWidthCallback` when the pinned columns together are wider than the grid. */ + invalidColumnPinningWidthMessage?: string; + /** Defaults to `alert()`, called with the width message when the grid rejects a pinning request. */ + invalidColumnPinningWidthCallback?: (error: string) => void; /** What is the top panel height in pixels (only accepts an integer) */ topPanelHeight?: number; diff --git a/src/models/index.ts b/src/models/index.ts index 3ea76cf96..f5228ae5f 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -13,6 +13,7 @@ export type * from './compositeEditorOption.interface.js'; export type * from './contextMenuOption.interface.js'; export type * from './core.interface.js'; export type * from './cssDeclaration.interface.js'; +export type * from './docking.interface.js'; export type * from './customTooltipOption.interface.js'; export type * from './dataViewEvents.interface.js'; export type * from './dataViewHints.interface.js'; diff --git a/src/plugins/slick.cellrangeselector.ts b/src/plugins/slick.cellrangeselector.ts index bda7ad202..6e2a974f2 100644 --- a/src/plugins/slick.cellrangeselector.ts +++ b/src/plugins/slick.cellrangeselector.ts @@ -48,12 +48,6 @@ export class SlickCellRangeSelector implements SlickPlugin { } } as CellRangeSelectorOption; - // Frozen row & column variables - protected _rowOffset = 0; - protected _columnOffset = 0; - protected _isRightCanvas = false; - protected _isBottomCanvas = false; - // autoScroll related constiables protected _activeViewport!: HTMLElement; protected _autoScrollTimerId?: number; @@ -123,8 +117,13 @@ export class SlickCellRangeSelector implements SlickPlugin { this._activeViewport = this._grid.getActiveViewportNode(e); const scrollbarDimensions = this._grid.getDisplayedScrollbarDimensions(); - this._viewportWidth = this._activeViewport.offsetWidth - scrollbarDimensions.width; - this._viewportHeight = this._activeViewport.offsetHeight - scrollbarDimensions.height; + const dockingScroller = this._activeViewport.closest('[class*="slickgrid_"]')?.querySelector('.slick-docking-horizontal-scroller'); + // Native scrolling reserves scrollbar space inside the viewport, while the + // docking proxy owns its horizontal scrollbar outside the viewport. Use the + // legacy dimensions for ordinary grids and the client dimensions only when + // that external docking scroller is actually present. + this._viewportWidth = dockingScroller ? this._activeViewport.clientWidth : this._activeViewport.offsetWidth - scrollbarDimensions.width; + this._viewportHeight = dockingScroller ? this._activeViewport.clientHeight : this._activeViewport.offsetHeight - scrollbarDimensions.height; this._moveDistanceForOneCell = { x: this._grid.getAbsoluteColumnMinWidth() / 2, @@ -132,27 +131,6 @@ export class SlickCellRangeSelector implements SlickPlugin { }; this._isRowMoveRegistered = this.hasRowMoveManager(); - 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); - if (canvasElm) { - this._rowOffset = canvasElm.clientHeight || 0; - } - } - - this._isRightCanvas = this._activeCanvas.classList.contains('grid-canvas-right'); - - if (this._gridOptions.frozenColumn! > -1 && this._isRightCanvas) { - const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-left`); - if (canvasLeftElm) { - this._columnOffset = canvasLeftElm.clientWidth || 0; - } - } - this._dragReplaceHandleActive = (dd.matchClassTag === 'dragReplaceHandle'); this._addToSelection = !this._dragReplaceHandleActive && @@ -190,20 +168,13 @@ 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) { - startX += this._scrollLeft; - } - - let startY = dd.startY - (canvasOffset?.top ?? 0); - if (this._gridOptions.frozenRow! >= 0 && this._isBottomCanvas) { - startY += this._scrollTop; - } + const startX = dd.startX - (canvasOffset?.left ?? 0); + const startY = dd.startY - (canvasOffset?.top ?? 0); let start: { row: number | undefined, cell: number | undefined; } | null; this._selectionMode = this._dragReplaceHandleActive ? CellSelectionMode.Replace : CellSelectionMode.Select; if (!this._dragReplaceHandleActive) { - start = this._grid.getCellFromPoint(startX, startY); + start = this._grid.getCellFromEvent(e) || this._grid.getCellFromPoint(startX, startY); } else { start = this._grid.getActiveCell() || { row: undefined, cell: undefined }; } @@ -237,8 +208,10 @@ export class SlickCellRangeSelector implements SlickPlugin { protected getMouseOffsetViewport(e: MouseEvent | TouchEvent, dd: DragRowMove): MouseOffsetViewport { const targetEvent: MouseEvent | Touch = (e as TouchEvent)?.touches?.[0] ?? e; - const viewportLeft = this._activeViewport.scrollLeft; - const viewportTop = this._activeViewport.scrollTop; + // The shared body viewport is not the horizontal scroll owner when docking + // is enabled. Use the last grid scroll event for both layout modes. + const viewportLeft = this._scrollLeft; + const viewportTop = this._scrollTop; const viewportRight = viewportLeft + this._viewportWidth; const viewportBottom = viewportTop + this._viewportHeight; @@ -356,24 +329,35 @@ export class SlickCellRangeSelector implements SlickPlugin { } } + /** + * Use the event target only when its pointer coordinates still overlap that + * cell. Some auto-scroll integrations keep the original cell as `target` + * while moving the pointer coordinates into a later virtualized row. + */ + protected getCellFromPointerTarget(targetEvent: MouseEvent | Touch | { pageX: number; pageY: number }): { row: number; cell: number } | null { + const target = (targetEvent as Event & { target?: EventTarget | null }).target; + const cellNode = target instanceof HTMLElement ? target.closest('.slick-cell') : null; + if (!cellNode) { + return null; + } + + const clientX = 'clientX' in targetEvent ? targetEvent.clientX : undefined; + const clientY = 'clientY' in targetEvent ? targetEvent.clientY : undefined; + if (typeof clientX === 'number' && typeof clientY === 'number') { + const rect = cellNode.getBoundingClientRect(); + if (clientX < rect.left - 1 || clientX > rect.right + 1 || clientY < rect.top - 1 || clientY > rect.bottom + 1) { + return null; + } + } + return this._grid.getCellFromEvent(targetEvent as unknown as Event); + } + protected handleDragTo(e: { pageX: number; pageY: number; }, dd: DragPosition) { //console.log('cellRangeSelector.handleDragTo: ' + JSON.stringify(dd.range)); const targetEvent: MouseEvent | Touch = (e as unknown as TouchEvent)?.touches?.[0] ?? e; const canvasOffset = Utils.offset(this._activeCanvas); - const end = this._grid.getCellFromPoint( - targetEvent.pageX - (canvasOffset?.left ?? 0) + this._columnOffset, - 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!))) { - return; - } - - // ... or frozen row(s) - if (this._gridOptions.frozenRow! >= 0 && (!this._isBottomCanvas && (end.row >= this._gridOptions.frozenRow!)) || (this._isBottomCanvas && (end.row < this._gridOptions.frozenRow!))) { - return; - } + const end = this.getCellFromPointerTarget(targetEvent) + || this._grid.getCellFromPoint(targetEvent.pageX - (canvasOffset?.left ?? 0), targetEvent.pageY - (canvasOffset?.top ?? 0)); // scrolling the viewport to display the target `end` cell if it is not fully displayed if (this._options.autoScroll && this._draggingMouseOffset) { @@ -388,7 +372,6 @@ export class SlickCellRangeSelector implements SlickPlugin { } } - // ... or regular grid (without any frozen options) if (!this._grid.canCellBeSelected(end.row, end.cell)) { return; } @@ -434,10 +417,8 @@ export class SlickCellRangeSelector implements SlickPlugin { const targetEvent: MouseEvent | Touch = (e as unknown as TouchEvent)?.touches?.[0] ?? e; const canvasOffset = Utils.offset(this._activeCanvas); - const end = this._grid.getCellFromPoint( - targetEvent.pageX - (canvasOffset?.left ?? 0) + this._columnOffset, - targetEvent.pageY - (canvasOffset?.top ?? 0) + this._rowOffset - ); + const end = this.getCellFromPointerTarget(targetEvent) + || this._grid.getCellFromPoint(targetEvent.pageX - (canvasOffset?.left ?? 0), targetEvent.pageY - (canvasOffset?.top ?? 0)); const cornerCell = !this._dragReplaceHandleActive || !this._previousSelectedRange ? dd.range.start : SelectionUtils.normalRangeOppositeCellFromCopy(this._previousSelectedRange, end); const r = new SlickRange( diff --git a/src/plugins/slick.draggablegrouping.ts b/src/plugins/slick.draggablegrouping.ts index b9ed3c8b5..0efef4991 100644 --- a/src/plugins/slick.draggablegrouping.ts +++ b/src/plugins/slick.draggablegrouping.ts @@ -66,6 +66,7 @@ export class SlickDraggableGrouping { protected _bindingEventService = new BindingEventService(); protected _handler = new SlickEventHandler(); protected _sortableLeftInstance?: SortableInstance; + protected _sortableCenterInstance?: SortableInstance; protected _sortableRightInstance?: SortableInstance; protected _columnsGroupBy: Column[] = []; @@ -155,7 +156,7 @@ export class SlickDraggableGrouping { * @param uid - grid UID * @param trigger - callback to execute when triggering a column grouping */ - getSetupColumnReorder(grid: SlickGrid, headers: any, _headerColumnWidthDiff: any, setColumns: (columns: Column[]) => void, setupColumnResize: () => void, _columns: Column[], getColumnIndex: (columnId: string) => number, _uid: string, trigger: (slickEvent: SlickEvent_, data?: any) => void) { + getSetupColumnReorder(grid: SlickGrid, _headers: any, _headerColumnWidthDiff: any, setColumns: (columns: Column[]) => void, setupColumnResize: () => void, _columns: Column[], getColumnIndex: (columnId: string) => number, _uid: string, trigger: (slickEvent: SlickEvent_, data?: any) => void) { this.destroySortableInstances(); const dropzoneElm = grid.getTopHeaderPanel() || grid.getPreHeaderPanel(); const groupTogglerElm = dropzoneElm.querySelector('.slick-group-toggle-all'); @@ -221,20 +222,9 @@ export class SlickDraggableGrouping { return; } - const reorderedIds = this._sortableLeftInstance?.toArray() ?? []; - - // when frozen columns are used, headers has more than one entry and we need the ids from all of them. - // though there is only really a left and right header, this will work even if that should change. - if (headers.length > 1) { - const ids = this._sortableRightInstance?.toArray() ?? []; - - // Note: the loop below could be simplified with: - // reorderedIds.push.apply(reorderedIds,ids); - // However, the loop is more in keeping with way-backward compatibility - for (const id of ids) { - reorderedIds.push(id); - } - } + const reorderedIds = [this._sortableLeftInstance, this._sortableCenterInstance, this._sortableRightInstance].flatMap( + (instance) => instance?.toArray() ?? [] + ); const finalReorderedColumns: Column[] = []; const reorderedColumns = grid.getColumns(); @@ -248,8 +238,14 @@ export class SlickDraggableGrouping { } } as SortableOptions; - this._sortableLeftInstance = Sortable.create(document.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-left`) as HTMLDivElement, sortableOptions); - this._sortableRightInstance = Sortable.create(document.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-right`) as HTMLDivElement, sortableOptions); + const headerRoot = `.${grid.getUID()} .slick-header-columns`; + const createSortable = (band: 'left' | 'center' | 'right') => { + const header = document.querySelector(`${headerRoot}.slick-header-columns-${band}`); + return header ? Sortable.create(header, sortableOptions) : undefined; + }; + this._sortableLeftInstance = createSortable('left'); + this._sortableCenterInstance = createSortable('center'); + this._sortableRightInstance = createSortable('right'); // user can optionally provide initial groupBy columns if (this._options.initialGroupBy && !this._isInitialized) { @@ -259,6 +255,7 @@ export class SlickDraggableGrouping { return { sortableLeftInstance: this._sortableLeftInstance, + sortableCenterInstance: this._sortableCenterInstance, sortableRightInstance: this._sortableRightInstance }; } @@ -278,12 +275,12 @@ export class SlickDraggableGrouping { } protected destroySortableInstances() { - if (this._sortableLeftInstance?.el) { - this._sortableLeftInstance?.destroy(); - } - if (this._sortableRightInstance?.el) { - this._sortableRightInstance?.destroy(); + for (const instance of [this._sortableLeftInstance, this._sortableCenterInstance, this._sortableRightInstance]) { + if (instance?.el) { + instance.destroy(); + } } + this._sortableLeftInstance = this._sortableCenterInstance = this._sortableRightInstance = undefined; } protected addDragOverDropzoneListeners() { diff --git a/src/slick.core.ts b/src/slick.core.ts index 361558b2c..c72357a07 100644 --- a/src/slick.core.ts +++ b/src/slick.core.ts @@ -6,13 +6,24 @@ import type { AnyFunction, + Column, + ColumnDockingLayout, CSSStyleDeclarationWritable, + DockedColumn, + DockedRow, DragRange, + DockingOption, + DockingRow, + DockingSide, EditController, ElementEventListener, Handler, InferDOMType, MergeTypes, + PinnedRows, + RowDockingLayout, + RowReference, + StickyRows, } from './models/index.js'; import type { SlickGrid } from './slick.grid.js'; @@ -674,8 +685,10 @@ export class BindingEventService { } /** Bind an event listener to any element */ - bind(element: Element | Window | Document, eventName: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, groupName = '') { - if (element) { + bind(element: Element | Window | Document | Array, eventName: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, groupName = '') { + if (Array.isArray(element)) { + element.forEach((item) => this.bind(item, eventName, listener, options, groupName)); + } else if (element) { const eventOptions = typeof options === 'boolean' ? { capture: options } : options; element.addEventListener(eventName, listener, eventOptions); this._boundedEvents.push({ element, eventName, listener, options: eventOptions, groupName }); @@ -946,6 +959,11 @@ export class Utils { return value !== undefined && value !== null && value !== ''; } + /** Returns a copy of `incoming` when it is given, otherwise a copy of `current` (or an empty list). */ + public static replaceList(incoming: T[] | undefined, current?: T[]): T[] { + return incoming !== undefined ? [...incoming] : [...(current ?? [])]; + } + public static getElementProp(elm: HTMLElement & { getComputedStyle?: () => CSSStyleDeclaration }, property: string) { if (elm?.getComputedStyle) { return window.getComputedStyle(elm, null).getPropertyValue(property); @@ -1450,10 +1468,324 @@ export class RowPositionIndexer { } } +const DEFAULT_DOCKING_OPTIONS: Required = { + maxColumnViewportWidthPercent: 60, + maxRowViewportHeightPercent: 60, + minCenterRowCount: 3, + overflowStrategy: 'conveyor', + stickyActivationBuffer: 2, +}; + +/** + * Resolves permanent pinning and scroll-activated stickiness into the same three-band model. + * It is deliberately DOM-free so the virtual renderer only has to react when band membership changes. + * + * Keeping this controller in the core module makes it available to separately + * built IIFE files as `Slick.DockingController` when `slick.core.js` loads. + */ +export class DockingController { + protected columnRevision = 0; + protected rowRevision = 0; + protected lastColumnSignature = ''; + protected lastRowSignature = ''; + protected options: Required = { ...DEFAULT_DOCKING_OPTIONS }; + + constructor(options?: DockingOption) { + this.setOptions(options); + } + + setOptions(options?: DockingOption): void { + this.options = { ...DEFAULT_DOCKING_OPTIONS, ...options }; + } + + reset(): void { + this.lastColumnSignature = ''; + this.lastRowSignature = ''; + } + + resolveColumns(columns: C[], scrollLeft: number, viewportWidth: number, leadingSide: DockingSide = 'left'): ColumnDockingLayout { + const permanentLeft: DockedColumn[] = []; + const permanentRight: DockedColumn[] = []; + const center: DockedColumn[] = []; + const stickyLeft: DockedColumn[] = []; + const stickyRight: DockedColumn[] = []; + let leftWidth = 0; + let rightWidth = 0; + let naturalCenterWidth = 0; + + columns.forEach((column, index) => { + if (!column || column.hidden) { + return; + } + const width = column.width || 0; + if (column.pinned === 'left') { + permanentLeft.push({ band: 'left', index, naturalOffset: leftWidth, offset: leftWidth, sticky: false, width }); + leftWidth += width; + } else if (column.pinned === 'right') { + permanentRight.push({ band: 'right', index, naturalOffset: rightWidth, offset: rightWidth, sticky: false, width }); + rightWidth += width; + } else { + center.push({ band: 'center', index, naturalOffset: naturalCenterWidth, offset: naturalCenterWidth, sticky: false, width }); + naturalCenterWidth += width; + } + }); + + const leftBaseWidth = leftWidth; + const rightBaseWidth = rightWidth; + + const centerViewportWidth = Math.max(0, viewportWidth - leftWidth - rightWidth); + const visibleStart = scrollLeft; + const visibleEnd = scrollLeft + centerViewportWidth; + const hysteresis = this.options.stickyActivationBuffer; + + center.forEach((entry) => { + const sticky = columns[entry.index].sticky; + if (!sticky) { + return; + } + const side = sticky === true ? leadingSide : sticky; + if (side === 'left' || side === 'both') { + stickyLeft.push({ ...entry, band: 'left', sticky: true }); + } + if (side === 'right' || side === 'both') { + stickyRight.push({ ...entry, band: 'right', sticky: true }); + } + }); + + // A candidate should dock when it reaches the occupied sticky edge, not + // only when it reaches the bare viewport edge. + const activeLeft: DockedColumn[] = []; + let occupiedLeftWidth = 0; + for (const entry of stickyLeft) { + if (entry.naturalOffset < visibleStart + occupiedLeftWidth - hysteresis) { + activeLeft.push(entry); + occupiedLeftWidth += entry.width; + } + } + + // Right stickies use the symmetrical occupied edge. + const activeRight: DockedColumn[] = []; + let occupiedRightWidth = 0; + for (let index = stickyRight.length - 1; index >= 0; index--) { + const entry = stickyRight[index]; + if (entry.naturalOffset + entry.width > visibleEnd - occupiedRightWidth + hysteresis) { + activeRight.unshift(entry); + occupiedRightWidth += entry.width; + } + } + + // A two-sided candidate can only occupy one band: it goes to whichever edge it is + // nearer, and to the left band on a tie. Both lists keep their original order. + const remainingRight = new Map(activeRight.map((entry) => [entry.index, entry])); + const contestedLeft = activeLeft.filter((leftEntry) => { + const rightEntry = remainingRight.get(leftEntry.index); + if (!rightEntry) { + return true; + } + const leftDistance = Math.abs(leftEntry.naturalOffset - visibleStart); + const rightDistance = Math.abs(visibleEnd - (rightEntry.naturalOffset + rightEntry.width)); + if (leftDistance > rightDistance) { + return false; + } + remainingRight.delete(leftEntry.index); + return true; + }); + + const maxDockedWidth = (viewportWidth * this.options.maxColumnViewportWidthPercent) / 100; + const availableStickyWidth = Math.max(0, maxDockedWidth - leftWidth - rightWidth); + const selectedLeft = this.applyBudget(contestedLeft, availableStickyWidth, (item) => item.width, 'left'); + const selectedLeftWidth = selectedLeft.reduce((total, item) => total + item.width, 0); + const selectedRight = this.applyBudget([...remainingRight.values()], availableStickyWidth - selectedLeftWidth, (item) => item.width, 'right'); + + selectedLeft.forEach((entry) => { + entry.offset = leftWidth; + leftWidth += entry.width; + }); + const selectedRightWidth = selectedRight.reduce((total, item) => total + item.width, 0); + let stickyRightOffset = 0; + selectedRight.forEach((entry) => { + entry.offset = stickyRightOffset; + stickyRightOffset += entry.width; + }); + permanentRight.forEach((entry) => (entry.offset += selectedRightWidth)); + rightWidth += selectedRightWidth; + + const stickyIndexes = new Set([...selectedLeft, ...selectedRight].map((item) => item.index)); + let visibleCenterWidth = 0; + const visibleCenter = center + .filter((item) => !stickyIndexes.has(item.index)) + .map((item) => { + const compactedItem = { ...item, offset: visibleCenterWidth }; + visibleCenterWidth += item.width; + return compactedItem; + }); + const left = permanentLeft.concat(selectedLeft); + const right = selectedRight.concat(permanentRight); + const signature = `${left.map((item) => item.index).join(',')}|${right.map((item) => item.index).join(',')}`; + if (signature !== this.lastColumnSignature) { + this.lastColumnSignature = signature; + this.columnRevision++; + } + + return { + center: visibleCenter, + centerWidth: visibleCenterWidth, + contentWidth: leftBaseWidth + naturalCenterWidth + rightBaseWidth, + left, + leftBaseWidth, + leftWidth, + revision: this.columnRevision, + right, + rightBaseWidth, + rightWidth, + }; + } + + resolveRows( + rows: DockingRow[], + scrollTop: number, + viewportHeight: number, + permanentRows?: PinnedRows, + stickyRows?: StickyRows + ): RowDockingLayout { + // The grid resolves every reference (index, id or { id }) to a row index before calling this. + const indexSet = (references?: RowReference[]): Set => + new Set((references || []).filter((reference): reference is number => typeof reference === 'number')); + const topIds = indexSet(permanentRows?.top); + const bottomIds = indexSet(permanentRows?.bottom); + const stickyTopIds = indexSet(stickyRows?.top); + const stickyBottomIds = indexSet(stickyRows?.bottom); + const stickyBothIds = indexSet(stickyRows?.both); + const matchesRowReference = (references: Set, row: DockingRow): boolean => references.has(row.index); + const top: DockedRow[] = []; + const center: DockedRow[] = []; + const bottom: DockedRow[] = []; + const stickyTop: DockedRow[] = []; + const stickyBottom: DockedRow[] = []; + const stickyBottomCandidates: DockedRow[] = []; + let topHeight = 0; + let bottomHeight = 0; + + rows.forEach((row) => { + if (matchesRowReference(topIds, row)) { + top.push({ ...row, band: 'top', offset: topHeight, sticky: false }); + topHeight += row.height; + } else if (matchesRowReference(bottomIds, row)) { + bottom.push({ ...row, band: 'bottom', offset: bottomHeight, sticky: false }); + bottomHeight += row.height; + } else { + center.push({ ...row, band: 'center', offset: row.top, sticky: false }); + } + }); + + const visibleBottom = scrollTop + Math.max(0, viewportHeight - topHeight - bottomHeight); + center.forEach((row) => { + const isStickyBoth = matchesRowReference(stickyBothIds, row); + const isStickyTop = isStickyBoth || matchesRowReference(stickyTopIds, row); + const isStickyBottom = isStickyBoth || matchesRowReference(stickyBottomIds, row); + if (!isStickyTop && !isStickyBottom) { + return; + } + // Rows transfer at the exact physical boundary; hysteresis would create a visible jump. + if (isStickyTop && row.top < scrollTop + topHeight) { + stickyTop.push({ ...row, band: 'top', offset: 0, sticky: true }); + } + if (isStickyBottom) { + stickyBottomCandidates.push({ ...row, band: 'bottom', offset: 0, sticky: true }); + } + }); + stickyTop.sort((a, b) => a.top - b.top); + stickyBottomCandidates.sort((a, b) => b.top - a.top); + let stickyBottomHeight = 0; + for (const row of stickyBottomCandidates) { + const availableBottom = visibleBottom - stickyBottomHeight; + if (row.top + row.height > availableBottom) { + stickyBottom.push(row); + stickyBottomHeight += row.height; + } + } + stickyBottom.sort((a, b) => a.top - b.top); + + const stickyTopByIndex = new Map(stickyTop.map((row) => [row.index, row])); + const stickyBottomByIndex = new Map(stickyBottom.map((row) => [row.index, row])); + for (const [index, topRow] of stickyTopByIndex) { + const bottomRow = stickyBottomByIndex.get(index); + if (!bottomRow) { + continue; + } + const topDistance = Math.abs(topRow.top - scrollTop); + const bottomDistance = Math.abs(visibleBottom - (bottomRow.top + bottomRow.height)); + if (topDistance <= bottomDistance) { + stickyBottom.splice(stickyBottom.findIndex((row) => row.index === index), 1); + } else { + stickyTop.splice(stickyTop.findIndex((row) => row.index === index), 1); + } + } + + const maxDockedHeight = (viewportHeight * this.options.maxRowViewportHeightPercent) / 100; + const selectedStickyTop = this.applyBudget( + stickyTop, + Math.max(0, maxDockedHeight - topHeight - bottomHeight), + (row) => row.height, + 'top' + ); + selectedStickyTop.forEach((row) => { + row.offset = topHeight; + topHeight += row.height; + }); + const selectedStickyBottom = this.applyBudget( + stickyBottom, + Math.max(0, maxDockedHeight - topHeight - bottomHeight), + (row) => row.height, + 'bottom' + ); + // Mirror the top band: sticky rows sit inside (nearest the centre), permanent rows at the edge. + let stickyBottomOffset = 0; + selectedStickyBottom.forEach((row) => { + row.offset = stickyBottomOffset; + stickyBottomOffset += row.height; + }); + bottom.forEach((row) => { + row.offset += stickyBottomOffset; + }); + bottomHeight += stickyBottomOffset; + const stickyIndexes = new Set([...selectedStickyTop, ...selectedStickyBottom].map((row) => row.index)); + const visibleCenter = center.filter((row) => !stickyIndexes.has(row.index)); + top.push(...selectedStickyTop); + bottom.unshift(...selectedStickyBottom); + const signature = `${top.map((row) => row.id).join(',')}|${bottom.map((row) => row.id).join(',')}`; + if (signature !== this.lastRowSignature) { + this.lastRowSignature = signature; + this.rowRevision++; + } + + return { bottom, bottomHeight, center: visibleCenter, revision: this.rowRevision, top, topHeight }; + } + + protected applyBudget(items: T[], budget: number, sizeOf: (item: T) => number, edge: DockingSide | 'top' | 'bottom'): T[] { + if (budget <= 0 || items.length === 0) { + return []; + } + const reverseCandidates = this.options.overflowStrategy === 'conveyor' && (edge === 'left' || edge === 'top'); + const candidates = reverseCandidates ? [...items].reverse() : items; + const selected: T[] = []; + let used = 0; + for (const item of candidates) { + const size = sizeOf(item); + if (used + size <= budget) { + selected.push(item); + used += size; + } + } + return reverseCandidates ? selected.reverse() : selected; + } +} + export const SlickGlobalEditorLock = new SlickEditorLock(); // export Slick namespace on both global & window objects const SlickCore = { + DockingController, Event: SlickEvent, EventData: SlickEventData, EventHandler: SlickEventHandler, diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 282cfc909..d0be4237a 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -1,5 +1,5 @@ // @ts-ignore -import type { SortableEvent, SortableInstance, SortableOptions } from 'sortablejs'; +import type { SortableOptions } from 'sortablejs'; import type { AutoSize, @@ -19,13 +19,14 @@ import type { EditorConstructor, EditController, Formatter, - FormatterOverrideCallback, FormatterResultObject, + FormatterOverrideCallback, FormatterResultWithHtml, FormatterResultWithText, GridOption as BaseGridOption, InteractionBase, ItemMetadata, + RowInfo, MenuCommandItemCallbackArgs, MultiColumnSort, OnActivateChangedOptionsEventArgs, @@ -57,6 +58,7 @@ import type { OnFooterClickEventArgs, OnHeaderClickEventArgs, OnHeaderContextMenuEventArgs, + OnHeaderKeyDownEventArgs, OnHeaderMouseEventArgs, OnHeaderRowCellRenderedEventArgs, OnKeyDownEventArgs, @@ -69,22 +71,36 @@ import type { OnValidationErrorEventArgs, OnDragReplaceCellsEventArgs, PagingInfo, - RowInfo, SelectionModel, - SingleColumnSort, SlickGridModel, + SingleColumnSort, SlickPlugin, + ColumnDockingBand, + ColumnDockingLayout, + ColumnPinningReferences, + DockedColumn, + + DockedRow, + DockingSide, + PinnedColumns, + PinnedRows, + RowReference, + StickyRows, + RowDockingLayout, + ElementPosition, } from './models/index.js'; import { type BasePubSub, BindingEventService as BindingEventService_, ColAutosizeMode as ColAutosizeMode_, - GlobalEditorLock as GlobalEditorLock_, + DockingController as DockingController_, GridAutosizeColsMode as GridAutosizeColsMode_, - keyCode as keyCode_, + RowSelectionMode as RowSelectionMode_, + ValueFilterMode as ValueFilterMode_, + WidthEvalMode as WidthEvalMode_, + GlobalEditorLock as GlobalEditorLock_, preClickClassName as preClickClassName_, RowPositionIndexer as RowPositionIndexer_, - RowSelectionMode as RowSelectionMode_, CellSelectionMode as CellSelectionMode_, type SlickEditorLock, SlickEvent as SlickEvent_, @@ -92,8 +108,6 @@ import { SlickRange as SlickRange_, Utils as Utils_, SelectionUtils as SelectionUtils_, - ValueFilterMode as ValueFilterMode_, - WidthEvalMode as WidthEvalMode_, DragExtendHandle as DragExtendHandle_, } from './slick.core.js'; import { Draggable as Draggable_, MouseWheel as MouseWheel_, Resizable as Resizable_ } from './slick.interactions.js'; @@ -101,26 +115,23 @@ import { Draggable as Draggable_, MouseWheel as MouseWheel_, Resizable as Resiza // for (iife) load Slick methods from global Slick object, or use imports for (esm) const BindingEventService = IIFE_ONLY ? Slick.BindingEventService : BindingEventService_; const ColAutosizeMode = IIFE_ONLY ? Slick.ColAutosizeMode : ColAutosizeMode_; +const GridAutosizeColsMode = IIFE_ONLY ? Slick.GridAutosizeColsMode : GridAutosizeColsMode_; +const RowSelectionMode = IIFE_ONLY ? Slick.RowSelectionMode : RowSelectionMode_; +const ValueFilterMode = IIFE_ONLY ? Slick.ValueFilterMode : ValueFilterMode_; +const WidthEvalMode = IIFE_ONLY ? Slick.WidthEvalMode : WidthEvalMode_; + const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_; const SlickEventData = IIFE_ONLY ? Slick.EventData : SlickEventData_; const GlobalEditorLock = IIFE_ONLY ? Slick.GlobalEditorLock : GlobalEditorLock_; -const GridAutosizeColsMode = IIFE_ONLY ? Slick.GridAutosizeColsMode : GridAutosizeColsMode_; -const keyCode = IIFE_ONLY ? Slick.keyCode : keyCode_; const preClickClassName = IIFE_ONLY ? Slick.preClickClassName : preClickClassName_; const SlickRange = IIFE_ONLY ? Slick.Range : SlickRange_; -const RowSelectionMode = IIFE_ONLY ? Slick.RowSelectionMode : RowSelectionMode_; const CellSelectionMode = IIFE_ONLY ? Slick.CellSelectionMode : CellSelectionMode_; -const ValueFilterMode = IIFE_ONLY ? Slick.ValueFilterMode : ValueFilterMode_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; const SelectionUtils = IIFE_ONLY ? Slick.SelectionUtils : SelectionUtils_; -const WidthEvalMode = IIFE_ONLY ? Slick.WidthEvalMode : WidthEvalMode_; -// slack added beyond the summed column widths so the header band always exceeds the -// body scroll range (header width is the header/body scroll-sync floor) and column -// drag-reorder has room past the last column -const HEADER_WIDTH_SLACK = 1000; const COLUMN_AUTOSCROLL_DISTANCE_PX = 10; const COLUMN_AUTOSCROLL_INTERVAL_MS = 30; +const DEFAULT_DOCKING_SCROLLBAR_HEIGHT = 15; const RESIZE_AUTOSCROLL_BROWSER_EDGE_PX = 1; const Draggable = IIFE_ONLY ? Slick.Draggable : Draggable_; const MouseWheel = IIFE_ONLY ? Slick.MouseWheel : MouseWheel_; @@ -128,6 +139,13 @@ const Resizable = IIFE_ONLY ? Slick.Resizable : Resizable_; const RowPositionIndexer = IIFE_ONLY ? Slick.RowPositionIndexer : RowPositionIndexer_; const DragExtendHandle = IIFE_ONLY ? Slick.DragExtendHandle : DragExtendHandle_; + +const DockingController = IIFE_ONLY ? Slick.DockingController : DockingController_; + +const isDefinedNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const isPrimitiveOrHTML = (value: unknown): value is string | number | boolean | HTMLElement | DocumentFragment => + value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value) || value instanceof HTMLElement || value instanceof DocumentFragment; +const queueMicrotaskPolyfill = (callback: () => void) => typeof queueMicrotask === 'function' ? queueMicrotask(callback) : setTimeout(callback, 0); /** * @license * (c) 2009-present Michael Leibman @@ -141,39 +159,70 @@ const DragExtendHandle = IIFE_ONLY ? Slick.DragExtendHandle : DragExtendHandle_; * * NOTES: * Cell/row DOM manipulations are done directly bypassing JS DOM manipulation methods. - * This increases the speed dramatically, but can only be done safely because there are no event handlers + * This increases the speed dramatically, + * but can only be done safely because there are no event handlers * or data associated with any cell/row DOM nodes. Cell editors must make sure they implement .destroy() * and do proper cleanup. */ -////////////////////////////////////////////////////////////////////////////////////////////// // SlickGrid class implementation (available as SlickGrid) interface RowCaching { - rowNode: HTMLElement[] | null, + rowNode: HTMLElement[] | null; + cellRegions?: { center: HTMLElement; left: HTMLElement; right: HTMLElement }; + /** Signature of the row-docking state the row was last synchronized against. */ + dockingSyncSignature?: string; + /** Whether the row hosts a rowspan (from rendered cells or metadata). */ + rowSpanHost?: boolean; cellColSpans: Array; cellNodesByColumnIdx: HTMLElement[]; cellRenderQueue: any[]; + cellSpanFragments: Record; + cellSpanSegments: Record>; } +const EMPTY_DOCKING_LAYOUT: ColumnDockingLayout = { + center: [], + centerWidth: 0, + contentWidth: 0, + left: [], + leftBaseWidth: 0, + leftWidth: 0, + revision: 0, + right: [], + rightBaseWidth: 0, + rightWidth: 0, +}; + +const EMPTY_ROW_DOCKING_LAYOUT: RowDockingLayout = { + bottom: [], + bottomHeight: 0, + center: [], + revision: 0, + top: [], + topHeight: 0, +}; + export class SlickGrid = Column, O extends BaseGridOption = BaseGridOption> { ////////////////////////////////////////////////////////////////////////////////////////////// // Public API slickGridVersion = '5.20.2'; - /** optional grid state clientId */ + /** Optional grid state client id retained for plugin compatibility. */ cid = ''; + // -- Public API // Events onActiveCellChanged: SlickEvent_; - onActiveCellPositionChanged: SlickEvent_<{ grid: SlickGrid; }>; + onActiveCellPositionChanged: SlickEvent_<{ grid: SlickGrid }>; + onActivateChangedOptions: SlickEvent_; onAddNewRow: SlickEvent_; onAfterSetColumns: SlickEvent_; onAutosizeColumns: SlickEvent_; onBeforeAppendCell: SlickEvent_; onBeforeCellEditorDestroy: SlickEvent_; onBeforeColumnsResize: SlickEvent_; - onBeforeDestroy: SlickEvent_<{ grid: SlickGrid; }>; + onBeforeDestroy: SlickEvent_<{ grid: SlickGrid }>; onBeforeEditCell: SlickEvent_; onBeforeFooterRowCellDestroy: SlickEvent_; onBeforeHeaderCellDestroy: SlickEvent_; @@ -192,8 +241,8 @@ export class SlickGrid = Column, O e onColumnsResizeDblClick: SlickEvent_; onCompositeEditorChange: SlickEvent_; onContextMenu: SlickEvent_; - onDrag: SlickEvent_; onDblClick: SlickEvent_; + onDrag: SlickEvent_; onDragInit: SlickEvent_; onDragStart: SlickEvent_; onDragEnd: SlickEvent_; @@ -205,52 +254,59 @@ export class SlickGrid = Column, O e onHeaderContextMenu: SlickEvent_; onHeaderMouseEnter: SlickEvent_; onHeaderMouseLeave: SlickEvent_; + onHeaderMouseOver: SlickEvent_; + onHeaderMouseOut: SlickEvent_; + onHeaderKeyDown: SlickEvent_; onHeaderRowCellRendered: SlickEvent_; onHeaderRowMouseEnter: SlickEvent_; onHeaderRowMouseLeave: SlickEvent_; - onPreHeaderContextMenu: SlickEvent_; - onPreHeaderClick: SlickEvent_; + onHeaderRowMouseOver: SlickEvent_; + onHeaderRowMouseOut: SlickEvent_; onKeyDown: SlickEvent_; onMouseEnter: SlickEvent_; onMouseLeave: SlickEvent_; + onPreHeaderClick: SlickEvent_; + onPreHeaderContextMenu: SlickEvent_; onRendered: SlickEvent_; onScroll: SlickEvent_; onSelectedRowsChanged: SlickEvent_; onSetOptions: SlickEvent_; - onActivateChangedOptions: SlickEvent_; onSort: SlickEvent_; onValidationError: SlickEvent_; - onViewportChanged: SlickEvent_<{ grid: SlickGrid; }>; + onViewportChanged: SlickEvent_<{ grid: SlickGrid }>; onDragReplaceCells: SlickEvent_; // --- // protected variables // shared across all grids on the page - protected scrollbarDimensions?: { height: number; width: number; }; - protected maxSupportedCssHeight!: number; // browser's breaking point + protected scrollbarDimensions?: { height: number; width: number }; + protected maxSupportedCssHeight!: number; // browser's breaking point protected canvas: HTMLCanvasElement | null = null; protected canvas_context: CanvasRenderingContext2D | null = null; + protected _isResizingColumn = false; + protected _columnResizeAutoScrollTimer?: ReturnType; // settings protected _options!: O; + protected logMessageCount = 0; + protected logMessageMaxCount = 30; protected _defaults: BaseGridOption = { - invalidColumnFreezePickerCallback: (error) => alert(error), - invalidColumnFreezeWidthCallback: (error) => alert(error), - invalidColumnFreezeWidthMessage: - '[SlickGrid] You are trying to freeze/pin more columns than the grid can support. ' + - 'Make sure to have less columns pinned (on the left) than the actual visible grid width.', - invalidColumnFreezePickerMessage: - '[SlickGrid] Action not allowed and aborted, you need to have at least one or more column on the right section of the column freeze/pining. ' + - 'You could alternatively "Unfreeze all the columns" before trying again.', + invalidColumnPinningPickerCallback: (error) => alert(error), + invalidColumnPinningWidthCallback: (error) => alert(error), + invalidColumnPinningWidthMessage: '[SlickGrid] Cannot pin these columns because they exceed the available grid width.', + invalidColumnPinningPickerMessage: '[SlickGrid] Cannot complete pinning because at least one visible center column is required.', + invalidColumnPinningSequenceMessage: + '[SlickGrid] Cannot change pinning because it would split a colspan. Pin columns sequentially from the left or right edge.', + skipPinningValidation: false, + allowDragFromClosest: 'div.slick-cell.dnd, div.slick-cell.cell-reorder', alwaysShowVerticalScroll: false, alwaysAllowHorizontalScroll: false, enableVariableRowHeight: false, - // the default provider reads each row's height from the item metadata; only called when `enableVariableRowHeight` is on - rowHeightProvider: (grid: SlickGridModel, row: number) => grid.getItemMetadaWhenExists(row)?.height, explicitInitialization: false, rowHeight: 25, + rowHeightProvider: (grid, row) => grid.getItemMetadaWhenExists(row)?.height, defaultColumnWidth: 80, enableHtmlRendering: true, enableAddRow: false, @@ -266,11 +322,13 @@ export class SlickGrid = Column, O e asyncEditorLoading: false, asyncEditorLoadDelay: 100, forceFitColumns: false, + autoHeaderHeight: false, + autoScrollOnColumnResize: true, enableAsyncPostRender: false, asyncPostRenderDelay: 50, enableAsyncPostRenderCleanup: false, asyncPostRenderCleanupDelay: 40, - auto: false, + columnResizingDelay: 300, nonce: '', editorLock: GlobalEditorLock, showColumnHeader: true, @@ -284,11 +342,11 @@ export class SlickGrid = Column, O e showPreHeaderPanel: false, showTopHeaderPanel: false, preHeaderPanelHeight: 25, - showTopPanel: false, - topPanelHeight: 25, preHeaderPanelWidth: 'auto', // mostly useful for Draggable Grouping dropzone to take full width topHeaderPanelHeight: 25, topHeaderPanelWidth: 'auto', // mostly useful for Draggable Grouping dropzone to take full width + showTopPanel: false, + topPanelHeight: 25, formatterFactory: null, editorFactory: null, cellFlashingCssClass: 'flashing', @@ -299,11 +357,17 @@ export class SlickGrid = Column, O e enableCellRowSpan: false, enableTextSelectionOnCells: false, dataItemColumnValueExtractor: null, - frozenBottom: false, - frozenColumn: -1, - frozenRow: -1, - frozenRightViewportMinWidth: 100, - throwWhenFrozenNotAllViewable: false, + autosizeColsMode: GridAutosizeColsMode.LegacyOff, + autosizeColPaddingPx: 4, + autosizeTextAvgToMWidthRatio: 0.75, + colAutosizeTreatAsLockedBelowWidth: 100, + docking: { + maxColumnViewportWidthPercent: 60, + maxRowViewportHeightPercent: 60, + minCenterRowCount: 3, + overflowStrategy: 'conveyor', + stickyActivationBuffer: 2, + }, fullWidthRows: false, multiColumnSort: false, numberedMultiColumnSort: false, @@ -321,26 +385,16 @@ export class SlickGrid = Column, O e editorCellNavOnLRKeys: false, enableMouseWheelScrollHandler: true, doPaging: true, - autosizeColsMode: GridAutosizeColsMode.LegacyOff, - autosizeColPaddingPx: 4, rowTopOffsetRenderType: 'top', + rtl: false, scrollRenderThrottling: 10, - autosizeTextAvgToMWidthRatio: 0.75, - viewportSwitchToScrollModeWidthPercent: undefined, - viewportMinWidthPx: undefined, - viewportMaxWidthPx: undefined, suppressCssChangesOnHiddenInit: false, ffMaxSupportedCssHeight: 6000000, maxSupportedCssHeight: 1000000000, maxPartialRowSpanRemap: 5000, - sanitizer: undefined, // sanitize function, built in basic sanitizer is: Slick.RegexSanitizer(dirtyHtml) - logSanitizedHtml: false, // log to console when sanitised - recommend true for testing of dev and production - mixinDefaults: true, + sanitizer: undefined, // sanitize function + mixinDefaults: false, shadowRoot: undefined, - colAutosizeTreatAsLockedBelowWidth: 100, - autoScrollOnColumnResize: true, - autoHeaderHeight: false, - rtl: false }; protected _columnDefaults = { @@ -373,53 +427,43 @@ export class SlickGrid = Column, O e widthPx: undefined, contentSizePx: 0, headerWidthPx: 0, - colDataTypeOf: undefined + colDataTypeOf: undefined, }; - protected _columnResizeTimer?: number; - protected _columnResizeAutoScrollTimer?: number; - protected _executionBlockTimer?: number; - protected _flashCellTimer?: number; - protected _highlightRowTimer?: number; + protected _columnResizeTimer?: any; + protected _executionBlockTimer?: any; + protected _flashCellTimer?: any; + protected _highlightRowTimer?: any; // scroller - protected th!: number; // virtual height - protected h!: number; // real scrollable height - protected ph!: number; // page height - protected n!: number; // number of pages - protected cj!: number; // "jumpiness" coefficient - - protected rowPositionIndexer?: RowPositionIndexer_; // prefix-sum index of row top positions (variable row height mode only) - protected rowHeightsDirty = true; // set when row heights may have changed; the index is rebuilt on the next updateRowCount() - protected frozenRowHeightsChanged = false; // set when an index rebuild changed the frozen rows height; consumed at the end of updateRowCount() - - protected page = 0; // current page - protected offset = 0; // current page offset + protected th!: number; // virtual height + protected h!: number; // real scrollable height + protected ph!: number; // page height + protected n!: number; // number of pages + protected cj!: number; // "jumpiness" coefficient + + protected page = 0; // current page + protected offset = 0; // current page offset protected vScrollDir = 1; - protected _bindingEventService = new BindingEventService(); + protected _bindingEventService: BindingEventService_ = new BindingEventService(); protected initialized = false; protected _container!: HTMLElement; protected uid = `slickgrid_${Math.round(1000000 * Math.random())}`; 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 _topHeaderPanel!: HTMLDivElement; protected _topHeaderPanelScroller!: HTMLDivElement; protected _topHeaderPanelSpacer!: HTMLDivElement; @@ -428,9 +472,9 @@ export class SlickGrid = Column, O e protected _viewport!: HTMLDivElement[]; protected _canvas!: HTMLDivElement[]; protected _style?: HTMLStyleElement; - protected stylesheet?: { cssRules: Array<{ selectorText: string; }>; rules: Array<{ selectorText: string; }>; } | null; - protected columnCssRulesL?: Array<{ selectorText: string; }>; - protected columnCssRulesR?: Array<{ selectorText: string; }>; + protected stylesheet?: { cssRules: Array<{ selectorText: string }>; rules: Array<{ selectorText: string }> } | null; + protected columnCssRulesL?: Array<{ selectorText: string }>; + protected columnCssRulesR?: Array<{ selectorText: string }>; protected viewportH = 0; protected viewportW = 0; protected canvasWidth = 0; @@ -446,16 +490,12 @@ export class SlickGrid = Column, O e protected cellWidthDiff = 0; protected cellHeightDiff = 0; protected absoluteColumnMinWidth!: number; - protected hasFrozenRows = false; - protected frozenRowsHeight = 0; - protected actualFrozenRow = -1; - protected _prevFrozenColumnIdx = -1; - /** flag to indicate if invalid frozen alert has been shown already or not? This is to avoid showing it more than once */ - protected _invalidfrozenAlerted = false; + protected rowPositionIndexer?: RowPositionIndexer_; // row top positions (variable row height mode only) + protected rowHeightsDirty = true; // set when row heights may have changed; the index is rebuilt on the next updateRowCount() + /** flag to indicate if an invalid pinning alert has been shown already or not */ + protected _invalidPinningAlerted = false; protected paneTopH = 0; - protected paneBottomH = 0; protected viewportTopH = 0; - protected viewportBottomH = 0; protected topPanelH = 0; protected headerRowH = 0; protected footerRowH = 0; @@ -467,8 +507,6 @@ export class SlickGrid = Column, O e protected activePosY!: number; protected activeRow!: number; protected activeCell!: number; - protected selectionBottomRow!: number; - protected selectionRightCell!: number; protected activeCellNode: HTMLDivElement | null = null; protected currentEditor: Editor | null = null; protected serializedEditorValue: any; @@ -487,6 +525,8 @@ export class SlickGrid = Column, O e protected lastRenderedScrollLeft = 0; protected prevScrollLeft = 0; protected scrollLeft = 0; + protected selectionBottomRow!: number; + protected selectionRightCell!: number; protected selectionModel?: SelectionModel; protected selectedRows: number[] = []; @@ -498,6 +538,18 @@ export class SlickGrid = Column, O e protected columnsById: Record = Object.create(null); protected visibleColumnsById: Record = Object.create(null); + protected dockingController: DockingController_ = new DockingController(); + protected dockingLayout: ColumnDockingLayout = EMPTY_DOCKING_LAYOUT; + protected dockingByColumn: Map< + number, + { band: ColumnDockingBand; naturalOffset: number; offset: number; sticky: boolean; width: number } + > = new Map(); + protected rowDockingLayout: RowDockingLayout = EMPTY_ROW_DOCKING_LAYOUT; + protected dockingByRow: Map = new Map(); + protected dockingRowIndexByReference: Map = new Map(); + /** Set when row references were invalidated; the next render re-resolves the row docking layout. */ + protected rowDockingStale = false; + protected dockingChromeByColumn: Map = new Map(); protected sortColumns: ColumnSort[] = []; protected columnPosLeft: number[] = []; protected columnPosRight: number[] = []; @@ -505,12 +557,17 @@ export class SlickGrid = Column, O e protected pagingActive = false; protected pagingIsLastPage = false; - protected scrollThrottle!: { enqueue: () => void; dequeue: () => void; }; + protected scrollThrottle!: { enqueue: () => void; dequeue: () => void }; + /** Defers expensive horizontal virtual-cell renders so compositor offsets can paint first. */ + protected singleViewportRenderTimer?: number; + protected animationFrameTimeouts = new Set(); + /** Coalesces sticky-column resolution to one layout pass per animation frame. */ + protected stickyColumnLayoutFrame?: number; // async call handles - protected h_editorLoader?: number; - protected h_postrender?: number; - protected h_postrenderCleanup?: number; + protected h_editorLoader?: any; + protected h_postrender?: any; + protected h_postrenderCleanup?: any; protected postProcessedRows: any = {}; protected postProcessToRow: number = null as any; protected postProcessFromRow: number = null as any; @@ -527,38 +584,29 @@ 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 _headerRoot!: HTMLDivElement; + protected _contentRoot!: 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 _viewportNode!: HTMLDivElement; + protected _canvasNode!: HTMLDivElement; + protected _dockingOverlay?: HTMLDivElement; + protected _dockingHorizontalScroller?: HTMLDivElement; + protected _dockingHorizontalSpacer?: HTMLDivElement; + /** Whether the proxy track is currently taking layout height beside the viewport. */ + protected dockingHorizontalScrollbarReserved = false; + /** Guards the viewport resize that a change in that reservation triggers. */ + protected resizingForDockingScrollbar = false; + /** Persistent semantic left/center/right wrappers for the single header roots. */ + protected dockingHeaderRegions?: Record; + protected dockingHeaderRowRegions?: Record; + protected dockingFooterRowRegions?: Record; protected _viewportScrollContainerX!: HTMLDivElement; protected _viewportScrollContainerY!: HTMLDivElement; protected _headerScrollContainer!: HTMLDivElement; @@ -569,16 +617,21 @@ export class SlickGrid = Column, O e protected cssShow = { position: 'absolute', visibility: 'hidden', display: 'block' }; protected _hiddenParents: HTMLElement[] = []; protected oldProps: Array> = []; - protected enforceFrozenRowHeightRecalc = false; protected columnResizeDragging = false; + /** Whether resizeCanvas currently owns an inline auto-height value on the grid container. */ + protected autoHeightContainerSizeApplied = false; + /** Cached result for the per-cell docking-region branch in the renderer. */ + protected dockingRowRegionsActive = false; protected slickDraggableInstance: InteractionBase | null = null; protected slickMouseWheelInstances: Array = []; + protected dockingOverlayMouseWheelBound = false; protected slickResizableInstances: Array = []; - protected sortableSideLeftInstance?: SortableInstance; - protected sortableSideRightInstance?: SortableInstance; - protected logMessageCount = 0; - protected logMessageMaxCount = 30; + protected sortableSideLeftInstance?: ReturnType; + protected sortableSideCenterInstance?: ReturnType; + protected sortableSideRightInstance?: ReturnType; protected _pubSubService?: BasePubSub; + /** Original pin states for columns changed by the unified pinning option, keyed by stable column id. */ + protected pinningColumnsState: Map = new Map(); /** * Creates a new instance of the grid. @@ -590,10 +643,14 @@ export class SlickGrid = Column, O e * @param {Object} [options] - Grid Options * @param {Object} [externalPubSub] - optional External PubSub Service to use by SlickEvent **/ - constructor(protected readonly container: HTMLElement | string, protected data: CustomDataView | TData[], protected columns: C[], options: Partial, protected readonly externalPubSub?: BasePubSub) { - this._container = typeof this.container === 'string' - ? document.querySelector(this.container) as HTMLDivElement - : this.container; + constructor( + protected readonly container: HTMLElement | string, + protected data: CustomDataView | TData[], + protected columns: C[], + options: Partial, + protected readonly externalPubSub?: BasePubSub | undefined + ) { + this._container = typeof this.container === 'string' ? (document.querySelector(this.container) as HTMLDivElement) : this.container; if (!this._container) { throw new Error(`SlickGrid requires a valid container, ${this.container} does not exist in the DOM.`); @@ -601,17 +658,19 @@ export class SlickGrid = Column, O e this._pubSubService = externalPubSub; this.onActiveCellChanged = new SlickEvent('onActiveCellChanged', externalPubSub); - this.onActiveCellPositionChanged = new SlickEvent<{ grid: SlickGrid; }>('onActiveCellPositionChanged', externalPubSub); + this.onActiveCellPositionChanged = new SlickEvent<{ grid: SlickGrid }>('onActiveCellPositionChanged', externalPubSub); this.onAddNewRow = new SlickEvent('onAddNewRow', externalPubSub); this.onAfterSetColumns = new SlickEvent('onAfterSetColumns', externalPubSub); this.onAutosizeColumns = new SlickEvent('onAutosizeColumns', externalPubSub); this.onBeforeAppendCell = new SlickEvent('onBeforeAppendCell', externalPubSub); this.onBeforeCellEditorDestroy = new SlickEvent('onBeforeCellEditorDestroy', externalPubSub); this.onBeforeColumnsResize = new SlickEvent('onBeforeColumnsResize', externalPubSub); - this.onBeforeDestroy = new SlickEvent<{ grid: SlickGrid; }>('onBeforeDestroy', externalPubSub); + this.onBeforeDestroy = new SlickEvent<{ grid: SlickGrid }>('onBeforeDestroy', externalPubSub); this.onBeforeEditCell = new SlickEvent('onBeforeEditCell', externalPubSub); + // prettier-ignore this.onBeforeFooterRowCellDestroy = new SlickEvent('onBeforeFooterRowCellDestroy', externalPubSub); this.onBeforeHeaderCellDestroy = new SlickEvent('onBeforeHeaderCellDestroy', externalPubSub); + // prettier-ignore this.onBeforeHeaderRowCellDestroy = new SlickEvent('onBeforeHeaderRowCellDestroy', externalPubSub); this.onBeforeRemoveCachedRow = new SlickEvent<{ row: number; grid: SlickGrid }>('onRowRemovedFromCache', externalPubSub); this.onBeforeSetColumns = new SlickEvent('onBeforeSetColumns', externalPubSub); @@ -627,8 +686,8 @@ export class SlickGrid = Column, O e this.onColumnsResizeDblClick = new SlickEvent('onColumnsResizeDblClick', externalPubSub); this.onCompositeEditorChange = new SlickEvent('onCompositeEditorChange', externalPubSub); this.onContextMenu = new SlickEvent('onContextMenu', externalPubSub); - this.onDrag = new SlickEvent('onDrag', externalPubSub); this.onDblClick = new SlickEvent('onDblClick', externalPubSub); + this.onDrag = new SlickEvent('onDrag', externalPubSub); this.onDragInit = new SlickEvent('onDragInit', externalPubSub); this.onDragStart = new SlickEvent('onDragStart', externalPubSub); this.onDragEnd = new SlickEvent('onDragEnd', externalPubSub); @@ -640,14 +699,19 @@ export class SlickGrid = Column, O e this.onHeaderContextMenu = new SlickEvent('onHeaderContextMenu', externalPubSub); this.onHeaderMouseEnter = new SlickEvent('onHeaderMouseEnter', externalPubSub); this.onHeaderMouseLeave = new SlickEvent('onHeaderMouseLeave', externalPubSub); + this.onHeaderMouseOver = new SlickEvent('onHeaderMouseOver', externalPubSub); + this.onHeaderMouseOut = new SlickEvent('onHeaderMouseOut', externalPubSub); + this.onHeaderRowMouseOver = new SlickEvent('onHeaderRowMouseOver', externalPubSub); + this.onHeaderRowMouseOut = new SlickEvent('onHeaderRowMouseOut', externalPubSub); + this.onHeaderKeyDown = new SlickEvent('onHeaderKeyDown', externalPubSub); this.onHeaderRowCellRendered = new SlickEvent('onHeaderRowCellRendered', externalPubSub); this.onHeaderRowMouseEnter = new SlickEvent('onHeaderRowMouseEnter', externalPubSub); this.onHeaderRowMouseLeave = new SlickEvent('onHeaderRowMouseLeave', externalPubSub); - this.onPreHeaderClick = new SlickEvent('onPreHeaderClick', externalPubSub); - this.onPreHeaderContextMenu = new SlickEvent('onPreHeaderContextMenu', externalPubSub); this.onKeyDown = new SlickEvent('onKeyDown', externalPubSub); this.onMouseEnter = new SlickEvent('onMouseEnter', externalPubSub); this.onMouseLeave = new SlickEvent('onMouseLeave', externalPubSub); + this.onPreHeaderClick = new SlickEvent('onPreHeaderClick', externalPubSub); + this.onPreHeaderContextMenu = new SlickEvent('onPreHeaderContextMenu', externalPubSub); this.onRendered = new SlickEvent('onRendered', externalPubSub); this.onScroll = new SlickEvent('onScroll', externalPubSub); this.onSelectedRowsChanged = new SlickEvent('onSelectedRowsChanged', externalPubSub); @@ -655,7 +719,7 @@ export class SlickGrid = Column, O e this.onActivateChangedOptions = new SlickEvent('onActivateChangedOptions', externalPubSub); this.onSort = new SlickEvent('onSort', externalPubSub); this.onValidationError = new SlickEvent('onValidationError', externalPubSub); - this.onViewportChanged = new SlickEvent<{ grid: SlickGrid; }>('onViewportChanged', externalPubSub); + this.onViewportChanged = new SlickEvent<{ grid: SlickGrid }>('onViewportChanged', externalPubSub); this.onDragReplaceCells = new SlickEvent('onDragReplaceCells', externalPubSub); this.initialize(options); @@ -666,32 +730,38 @@ export class SlickGrid = Column, O e ////////////////////////////////////////////////////////////////////////////////////////////// /** Initializes the grid. */ - init() { + init(): void { this.finishInitialization(); } /** - * Processes the provided grid options (mixing in default settings as needed), - * validates required modules (for example, ensuring Sortable.js is loaded if column reordering is enabled), - * and creates all necessary DOM elements for the grid (including header containers, viewports, canvases, panels, etc.). - * It also caches CSS if the container or its ancestors are hidden and calls finish. - * - * @param {Partial} options - Partial grid options to be applied during initialization. - */ - protected initialize(options: Partial) { + * Processes the provided grid options (mixing in default settings as needed), + * validates required modules (for example, ensuring Sortable.js is loaded if column reordering is enabled), + * and creates all necessary DOM elements for the grid (including header containers, viewports, canvases, panels, etc.). + * It also caches CSS if the container or its ancestors are hidden and calls finish. + * + * @param {Partial} options - Partial grid options to be applied during initialization. + */ + protected initialize(options: Partial): void { // calculate these only once and share between grid instances if (options?.mixinDefaults) { // use provided options and then assign defaults - if (!this._options) { this._options = options as O; } + if (!this._options) { + this._options = options as O; + } Utils.applyDefaults(this._options, this._defaults); } else { this._options = Utils.extend(true, {}, this._defaults, options); } + // `applyDefaults` only fills top-level properties. Keep nested option groups + // complete when callers retain and mutate their options object through + // `mixinDefaults`. + this._options.docking = Utils.extend(true, {}, this._defaults.docking, this._options.docking); this.scrollThrottle = this.actionThrottle(this.render.bind(this), this._options.scrollRenderThrottling as number); this.maxSupportedCssHeight = this.maxSupportedCssHeight || this.getMaxSupportedCssHeight(); this.validateAndEnforceOptions(); + this.applyColumnPinningOptions(this.columns); this._columnDefaults.width = this._options.defaultColumnWidth; - this._prevFrozenColumnIdx = this.getFrozenColumnIdx(); if (!this._options.suppressCssChangesOnHiddenInit) { this.cacheCssForHiddenInit(); @@ -712,90 +782,119 @@ export class SlickGrid = Column, O e Utils.emptyElement(this._container); this._container.style.outline = String(0); this._container.classList.add(this.uid); - this._container.classList.add('ui-widget'); + this._container.classList.add('slick-widget'); this._container.setAttribute('role', 'grid'); + this._container.setAttribute('aria-colcount', this.columns.length.toString()); + this._container.setAttribute('aria-rowcount', Array.isArray(this.data) ? this.data.length.toString() : '0'); - const containerStyles = window.getComputedStyle(this._container); - if (!(/relative|absolute|fixed/).test(containerStyles.position)) { + const containerStyles = getComputedStyle(this._container); + if (!/relative|absolute|fixed/.test(containerStyles.position)) { this._container.style.position = 'relative'; } - this._focusSink = Utils.createDomElement('div', { tabIndex: 0, style: { position: 'fixed', width: '0px', height: '0px', top: '0px', left: '0px', outline: '0px' } }, this._container); + this._focusSink = Utils.createDomElement( + 'div', + { tabIndex: 0, style: { position: 'fixed', width: '0px', height: '0px', top: '0px', left: '0px', outline: '0px' } }, + this._container + ); if (this._options.createTopHeaderPanel) { - this._topHeaderPanelScroller = Utils.createDomElement('div', { className: 'slick-topheader-panel slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, this._container); + this._topHeaderPanelScroller = Utils.createDomElement( + 'div', + { className: 'slick-topheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, + this._container + ); this._topHeaderPanelScroller.appendChild(document.createElement('div')); this._topHeaderPanel = Utils.createDomElement('div', null, this._topHeaderPanelScroller); - this._topHeaderPanelSpacer = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._topHeaderPanelScroller); + this._topHeaderPanelSpacer = Utils.createDomElement( + 'div', + { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, + this._topHeaderPanelScroller + ); if (!this._options.showTopHeaderPanel) { Utils.hide(this._topHeaderPanelScroller); } } - // 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); + // The grid uses one live header and one live content root. + this._headerRoot = Utils.createDomElement('div', { className: 'slick-header-root' }, this._container); + this._contentRoot = Utils.createDomElement('div', { className: 'slick-content-root' }, 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); + const headerContainer = Utils.createDomElement('div', { className: 'slick-preheader-container' }, this._headerRoot); + this._preHeaderPanelScroller = Utils.createDomElement( + 'div', + { className: 'slick-preheader-panel ui-state-default slick-state-default', style: { overflow: 'hidden', position: 'relative' } }, + headerContainer + ); 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._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); + this._preHeaderPanelR = this._preHeaderPanel; 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); + const headerContainerL = Utils.createDomElement('div', { className: 'slick-header-container' }, this._headerRoot); + this._headerScrollerL = Utils.createDomElement( + 'div', + { className: 'slick-header ui-state-default slick-state-default slick-header-left', role: 'rowgroup' }, + headerContainerL + ); // 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: { [this.dirSide]: '-1000px' } }, this._headerScrollerL); - this._headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { [this.dirSide]: '-1000px' } }, this._headerScrollerR); + this._headerL = Utils.createDomElement( + 'div', + { className: 'slick-header-columns slick-header-columns-left', role: 'row' }, + this._headerScrollerL + ); // Cache the header columns - this._headers = [this._headerL, this._headerR]; + this._headers = [this._headerL]; - 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._headerRowScrollerL = Utils.createDomElement( + 'div', + { className: 'slick-headerrow ui-state-default slick-state-default', role: 'rowgroup' }, + this._contentRoot + ); - this._headerRowScroller = [this._headerRowScrollerL, this._headerRowScrollerR]; + this._headerRowScroller = [this._headerRowScrollerL]; - 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._headerRowSpacerL = Utils.createDomElement( + 'div', + { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, + this._headerRowScrollerL + ); - 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._headerRowL = Utils.createDomElement( + 'div', + { className: 'slick-headerrow-columns slick-headerrow-columns-left', role: 'row' }, + this._headerRowScrollerL + ); - this._headerRows = [this._headerRowL, this._headerRowR]; + this._headerRows = [this._headerRowL]; // 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._topPanelScrollerL = Utils.createDomElement('div', { className: 'slick-top-panel-scroller ui-state-default slick-state-default' }, this._contentRoot); - this._topPanelScrollers = [this._topPanelScrollerL, this._topPanelScrollerR]; + this._topPanelScrollers = [this._topPanelScrollerL]; // 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]; + this._topPanels = [this._topPanelL]; if (!this._options.showColumnHeader) { this._headerScroller.forEach((el) => { @@ -815,37 +914,31 @@ export class SlickGrid = Column, O e }); } - // 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); + // Append the viewport + this._viewportNode = Utils.createDomElement('div', { className: 'slick-viewport slick-viewport-top slick-viewport-left' }, this._contentRoot); // Cache the viewports - this._viewport = [this._viewportTopL, this._viewportTopR, this._viewportBottomL, this._viewportBottomR]; + this._viewport = [this._viewportNode]; if (this._options.viewportClass) { this._viewport.forEach((view) => { - view.classList.add(...Utils.classNameToList((this._options.viewportClass))); + view.classList.add(...Utils.classNameToList(this._options.viewportClass)); }); } - // Default the active viewport to the top left - this._activeViewportNode = this._viewportTopL; + // Default the active viewport + this._activeViewportNode = this._viewportNode; - // 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); + // Append the canvas + this._canvasNode = Utils.createDomElement('div', { className: 'grid-canvas grid-canvas-top grid-canvas-left' }, this._viewportNode); // Cache the canvases - this._canvas = [this._canvasTopL, this._canvasTopR, this._canvasBottomL, this._canvasBottomR]; + this._canvas = [this._canvasNode]; 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; + // Default the active canvas + this._activeCanvasNode = this._canvasNode; // top-header if (this._topHeaderPanelSpacer) { @@ -862,7 +955,6 @@ export class SlickGrid = Column, O e }); Utils.width(this._headerRowSpacerL, canvasWithScrollbarWidth); - Utils.width(this._headerRowSpacerR, canvasWithScrollbarWidth); // footer Row if (this._options.createFooterRow) { @@ -872,53 +964,54 @@ export class SlickGrid = Column, O e this._focusSink2 = this._focusSink.cloneNode(true) as HTMLDivElement; this._container.appendChild(this._focusSink2); + this.applyRTL(this._options.rtl ?? false); if (!this._options.explicitInitialization) { this.finishInitialization(); } - - this.applyRTL(this._options.rtl ?? false); } /** * Completes grid initialisation by calculating viewport dimensions, measuring cell padding and border differences, - * disabling text selection (except on editable inputs), setting frozen options and pane visibility, + * disabling text selection (except on editable inputs), setting docking options and viewport visibility, * updating column caches, creating column headers and footers, setting up column sorting, * creating CSS rules, binding ancestor scroll events, and binding various event handlers * (e.g. for scrolling, mouse, keyboard, drag-and-drop). * It also starts up any asynchronous post–render processing if enabled. */ - protected finishInitialization() { + protected finishInitialization(): void { if (!this.initialized) { this.initialized = true; this.getViewportWidth(); this.getViewportHeight(); + this.refreshDockingLayout(); // header columns and cells may have different padding/border skewing width calculations (box-sizing, hello?) // 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) + // disable all text selection in header (including input and textarea) + this.disableSelection(this._headers); 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) => { + this._bindingEventService.bind(view, 'selectstart', (event: Event) => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; } event.preventDefault(); }); + }); } - this.setFrozenOptions(); - this.setPaneFrozenClasses(); - this.setPaneVisibility(); + this.activateSingleViewportLayout(); this.setScroller(); this.setOverflow(); this.updateColumnCaches(); + this.refreshRowDockingLayout(this.scrollTop, true); this.createColumnHeaders(); this.createColumnFooter(); this.setupColumnSort(); @@ -928,41 +1021,34 @@ export class SlickGrid = Column, O e if (this._options.autoHeaderHeight) { this.recalculateHeaderHeight(); } - 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)); - }); + this._bindingEventService.bind(this._viewport, 'scroll', this.handleScroll.bind(this)); + if (this._dockingHorizontalScroller) { + this._bindingEventService.bind(this._dockingHorizontalScroller, 'scroll', this.handleScroll.bind(this), {}, 'docking-horizontal-scroll'); + } if (this._options.enableMouseWheelScrollHandler) { this._viewport.forEach((view) => { - this.slickMouseWheelInstances.push(MouseWheel({ - element: view, - onMouseWheel: this.handleMouseWheel.bind(this) - })); + 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); - }); + this._bindingEventService.bind(this._headerScroller, 'contextmenu', this.handleHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._headerScroller, 'click', this.handleHeaderClick.bind(this) as EventListener); + this._bindingEventService.bind(this._headerRowScrollerL, '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._bindingEventService.bind(this._footerRow, 'contextmenu', this.handleFooterContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._footerRow, 'click', this.handleFooterClick.bind(this) as EventListener); + this._bindingEventService.bind(this._footerRowScrollerL, 'scroll', this.handleFooterRowScroll.bind(this) as EventListener); } if (this._options.createTopHeaderPanel) { @@ -971,23 +1057,28 @@ export class SlickGrid = Column, O e if (this._options.createPreHeaderPanel) { this._bindingEventService.bind(this._preHeaderPanelScroller, 'scroll', this.handlePreHeaderPanelScroll.bind(this) as EventListener); - this._bindingEventService.bind(this._preHeaderPanelScroller, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); - this._bindingEventService.bind(this._preHeaderPanelScrollerR, 'contextmenu', this.handlePreHeaderContextMenu.bind(this) as EventListener); + this._bindingEventService.bind( + this._preHeaderPanelScroller, + '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._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); - }); + this._bindingEventService.bind(this._canvas, 'keydown', this.handleKeyDown.bind(this) as EventListener); + this._bindingEventService.bind(this._canvas, 'click', this.handleClick.bind(this) as EventListener); + this._bindingEventService.bind(this._canvas, 'dblclick', this.handleDblClick.bind(this) as EventListener); + this._bindingEventService.bind(this._canvas, 'contextmenu', this.handleContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._canvas, 'mouseover', this.handleCellMouseOver.bind(this) as EventListener); + this._bindingEventService.bind(this._canvas, 'mouseout', this.handleCellMouseOut.bind(this) as EventListener); + // Pinned rows are moved out of the canvas into an optional overlay. + + // Bind the same cell interactions when a permanent or active sticky row + // caused that overlay to be materialized. + this.bindDockingOverlayEvents(); this.createDraggable(); @@ -997,13 +1088,11 @@ export class SlickGrid = Column, O e } } - /** Create or recreate the cell drag interaction using the current selection model options. */ - protected createDraggable() { + /** Create the cell drag interaction using the active selection model's modifier-key policy. */ + protected createDraggable(): void { if (!Draggable) { return; } - - this.slickDraggableInstance?.destroy(); const preventDragFromKeys = this.getSelectionModel()?.getOptions()?.enableMultiSelection === true ? this._options.preventDragFromKeys?.filter((key) => key !== 'ctrlKey' && key !== 'metaKey') : this._options.preventDragFromKeys; @@ -1012,28 +1101,28 @@ export class SlickGrid = Column, O e allowDragFrom: `div.slick-cell, div.${this.dragReplaceEl.cssClass}`, dragFromClassDetectArr: [{ tag: 'dragReplaceHandle', id: this.dragReplaceEl.id }], // the slick cell parent must always contain `.dnd` and/or `.cell-reorder` class to be identified as draggable - allowDragFromClosest: 'div.slick-cell.dnd, div.slick-cell.cell-reorder', + allowDragFromClosest: this._options.allowDragFromClosest, preventDragFromKeys, onDragInit: this.handleDragInit.bind(this), onDragStart: this.handleDragStart.bind(this), onDrag: this.handleDrag.bind(this), - onDragEnd: this.handleDragEnd.bind(this) + onDragEnd: this.handleDragEnd.bind(this), }); } /** - * Finds all container ancestors/parents (including the grid container itself) that are hidden (i.e. have display:none) - * and temporarily applies visible CSS properties (absolute positioning, hidden visibility, block display) - * so that dimensions can be measured correctly. - * It stores the original CSS properties in an internal array for later restoration. - * - * Related to issue: https://github.com/6pac/SlickGrid/issues/568 */ - cacheCssForHiddenInit() { + * Finds all container ancestors/parents (including the grid container itself) that are hidden (i.e. have display:none) + * and temporarily applies visible CSS properties (absolute positioning, hidden visibility, block display) + * so that dimensions can be measured correctly. + * It stores the original CSS properties in an internal array for later restoration. + * + * Related to issue: https://github.com/6pac/SlickGrid/issues/568 */ + cacheCssForHiddenInit(): void { this._hiddenParents = Utils.parents(this._container, ':hidden') as HTMLElement[]; this.oldProps = []; - this._hiddenParents.forEach(el => { + this._hiddenParents.forEach((el) => { const old: Partial = {}; - Object.keys(this.cssShow).forEach(name => { + Object.keys(this.cssShow).forEach((name) => { if (this.cssShow) { old[name as any] = el.style[name as 'position' | 'visibility' | 'display']; el.style[name as any] = this.cssShow[name as 'position' | 'visibility' | 'display']; @@ -1043,38 +1132,15 @@ export class SlickGrid = Column, O e }); } - /** - * Restores the original CSS properties for the container and its hidden - * ancestors that were modified by cacheCssForHiddenInit. - * This ensures that after initial measurements the DOM elements revert - * to their original style settings. - */ - restoreCssFromHiddenInit

>(): void { - // finish handle display:none on container or container parents - // - put values back the way they were - let i = 0; - if (this._hiddenParents) { - this._hiddenParents.forEach((el) => { - const old = this.oldProps[i++]; - Object.keys(this.cssShow).forEach((name) => { - if (this.cssShow) { - (el.style as unknown as P)[name as keyof P] = (old as any)[name]; - } - }); - }); - this._hiddenParents.length = 0; - } - } - /** * Registers an external plugin to the grid’s internal plugin list. * Once added, it immediately initialises the plugin by calling its init() * method with the grid instance. * @param {T} plugin - The plugin instance to be registered. */ - registerPlugin(plugin: T) { + registerPlugin(plugin: T): void { this.plugins.unshift(plugin); - plugin.init(this as unknown as SlickGridModel); + plugin.init(this as unknown as SlickGrid); } /** @@ -1084,7 +1150,7 @@ export class SlickGrid = Column, O e * thereby unregistering it from the grid. * @param {T} plugin - The plugin instance to be registered. */ - unregisterPlugin(plugin: SlickPlugin) { + unregisterPlugin(plugin: SlickPlugin): void { for (let i = this.plugins.length; i >= 0; i--) { if (this.plugins[i] === plugin) { this.plugins[i]?.destroy(); @@ -1106,216 +1172,101 @@ export class SlickGrid = Column, O e * * @param {boolean} shouldDestroyAllElements - do we want to destroy (nullify) all DOM elements as well? This help in avoiding mem leaks */ - destroy(shouldDestroyAllElements?: boolean) { - this._bindingEventService.unbindAll(); + destroy(shouldDestroyAllElements?: boolean): void { + this.clearAllTimers(); this.slickDraggableInstance = this.destroyAllInstances(this.slickDraggableInstance) as null; this.slickMouseWheelInstances = this.destroyAllInstances(this.slickMouseWheelInstances) as InteractionBase[]; this.slickResizableInstances = this.destroyAllInstances(this.slickResizableInstances) as InteractionBase[]; this.getEditorLock()?.cancelCurrentEdit(); + this.clearInternalDomCaches(); this.trigger(this.onBeforeDestroy, {}); + this._bindingEventService.unbindAll(); + (this._pubSubService as any)?.unsubscribeAll?.(); let i = this.plugins.length; while (i--) { this.unregisterPlugin(this.plugins[i]); } - if (this._options.enableColumnReorder && typeof this.sortableSideLeftInstance?.destroy === 'function') { - this.sortableSideLeftInstance?.destroy(); - this.sortableSideRightInstance?.destroy(); - } - - this._bindingEventService.unbindByEventName(this._container, 'resize'); - this.removeCssRules(); - - this._canvas.forEach((element) => { - this._bindingEventService.unbindByEventName(element, 'keydown'); - this._bindingEventService.unbindByEventName(element, 'click'); - this._bindingEventService.unbindByEventName(element, 'dblclick'); - this._bindingEventService.unbindByEventName(element, 'contextmenu'); - this._bindingEventService.unbindByEventName(element, 'mouseover'); - this._bindingEventService.unbindByEventName(element, 'mouseout'); - }); - this._viewport.forEach((view) => { - this._bindingEventService.unbindByEventName(view, 'scroll'); - }); - - this._headerScroller.forEach((el) => { - this._bindingEventService.unbindByEventName(el, 'contextmenu'); - this._bindingEventService.unbindByEventName(el, 'click'); - }); - - this._headerRowScroller.forEach((scroller) => { - this._bindingEventService.unbindByEventName(scroller, 'scroll'); - }); - - if (this._footerRow) { - this._footerRow.forEach((footer) => { - this._bindingEventService.unbindByEventName(footer, 'contextmenu'); - this._bindingEventService.unbindByEventName(footer, 'click'); - }); - } - - if (this._footerRowScroller) { - this._footerRowScroller.forEach((scroller) => { - this._bindingEventService.unbindByEventName(scroller, 'scroll'); - }); + if (this.sortableSideRightInstance?.el && typeof this.sortableSideRightInstance?.destroy === 'function') { + this.sortableSideRightInstance.destroy(); } - - if (this._preHeaderPanelScroller) { - this._bindingEventService.unbindByEventName(this._preHeaderPanelScroller, 'scroll'); + if (this.sortableSideCenterInstance?.el && typeof this.sortableSideCenterInstance?.destroy === 'function') { + this.sortableSideCenterInstance.destroy(); } - - if (this._topHeaderPanelScroller) { - this._bindingEventService.unbindByEventName(this._topHeaderPanelScroller, 'scroll'); + if (this.sortableSideLeftInstance?.el && typeof this.sortableSideLeftInstance?.destroy === 'function') { + this.sortableSideLeftInstance.destroy(); } - this._bindingEventService.unbindByEventName(this._focusSink, 'keydown'); - this._bindingEventService.unbindByEventName(this._focusSink2, 'keydown'); - - const resizeHandles = this._container.querySelectorAll('.slick-resizable-handle'); - [].forEach.call(resizeHandles, (handle) => { - this._bindingEventService.unbindByEventName(handle, 'dblclick'); - }); - - const headerColumns = this._container.querySelectorAll('.slick-header-column'); - [].forEach.call(headerColumns, (column) => { - this._bindingEventService.unbindByEventName(column, 'mouseenter'); - this._bindingEventService.unbindByEventName(column, 'mouseleave'); + this._focusSink?.remove(); + this._focusSink2?.remove(); - this._bindingEventService.unbindByEventName(column, 'mouseenter'); - this._bindingEventService.unbindByEventName(column, 'mouseleave'); - }); - - // Mark the grid inactive and cancel pending work before DOM references are cleared. + // Mark the grid as inactive before its DOM references are cleared. Async data/sort + // callbacks can finish after destruction and must not attempt to update a null container. this.initialized = false; - this.clearAllTimers(); Utils.emptyElement(this._container); - this._container.classList.remove(this.uid); + this._container?.style.setProperty('min-height', ''); + this.removeCssRules(); if (shouldDestroyAllElements) { - this.destroyAllElements(); + this.destroyElementReferences(); } } /** - * Call destroy method, when exists, on all the instance(s) it found - * - * Given either a single instance or an array of instances (e.g. draggable, mousewheel, resizable), - * pops each one and calls its destroy method if available, then resets the input to an empty array - * (or null for a single instance). Returns the reset value. - * - * @params instances - can be a single instance or a an array of instances + * Drops every DOM reference the instance still holds so a retained grid object cannot keep the + * detached tree alive. Fields are selected by content (an element, a non-empty array of elements, + * or a plain record of elements), so new element fields are covered without a name list. */ - protected destroyAllInstances(inputInstances: null | InteractionBase | Array) { + protected destroyElementReferences(): void { + const isElement = (value: unknown): boolean => value instanceof Element; + const holdsElements = (value: unknown): boolean => + isElement(value) || + (Array.isArray(value) && value.length > 0 && value.every(isElement)) || + (!!value && + typeof value === 'object' && + Object.getPrototypeOf(value) === Object.prototype && + Object.values(value as object).length > 0 && + Object.values(value as object).every(isElement)); + const self = this as unknown as Record; + for (const key of Object.keys(self)) { + if (holdsElements(self[key])) { + self[key] = null; + } + } + this.dockingChromeByColumn.clear(); + } + + /** + * Call destroy method, when exists, on all the instance(s) it found + * + * Given either a single instance or an array of instances (e.g. draggable, mousewheel, resizable), + * pops each one and calls its destroy method if available, then resets the input to an empty array + * (or null for a single instance). Returns the reset value. + * + * @params instances - can be a single instance or a an array of instances + */ + protected destroyAllInstances(inputInstances: null | InteractionBase | Array): InteractionBase[] | null { if (inputInstances) { const instances = Array.isArray(inputInstances) ? inputInstances : [inputInstances]; let instance: InteractionBase | undefined; - while (Utils.isDefined(instance = instances.pop())) { + while (Utils.isDefined((instance = instances.pop()))) { if (instance && typeof instance.destroy === 'function') { instance.destroy(); } } } // reset instance(s) - inputInstances = (Array.isArray(inputInstances) ? [] : null); + inputInstances = Array.isArray(inputInstances) ? [] : null; return inputInstances; } - /** - * Sets all internal references to DOM elements - * (e.g. canvas containers, headers, viewports, focus sinks, etc.) - * to null so that they can be garbage collected. - */ - protected destroyAllElements() { - this._activeCanvasNode = null as any; - this._activeViewportNode = 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. */ getOptions(): O { return this._options; } - /** - * Get the Column ID of the currently frozen column or `null` when not frozen - * @returns {String|Number|null} Frozen Column ID - */ - getFrozenColumnId(): string | number | null { - const frozenColIndex = this.getFrozenColumnIdx(); - if (frozenColIndex >= 0 && this.columns[frozenColIndex]) { - return this.columns[frozenColIndex].id; - } - return null; - } - - protected getFrozenColumnIdx(): number { - return this._options.frozenColumn ?? -1; - } - /** * 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. @@ -1325,33 +1276,97 @@ export class SlickGrid = Column, O e */ setOptions(newOptions: Partial, suppressRender?: boolean, suppressColumnSet?: boolean, suppressSetOverflow?: boolean): void { this.prepareForOptionsChange(); + const removePinning = Object.prototype.hasOwnProperty.call(newOptions, 'pinning') && (newOptions.pinning === undefined || newOptions.pinning === null); + + // Validate the prospective declarative column state before deep-merging it + // into the live options. A rejected request leaves the current pinning in + // place but still permits a sibling row-pinning update in the same call. + if (newOptions.pinning?.columns !== undefined) { + if (!suppressColumnSet) { + this._invalidPinningAlerted = false; + } + const prospectivePinnedIndexes = this.getProspectivePinnedColumnIndexes(newOptions.pinning.columns); + const validPinning = this.validatePinnedColumnIndexes(prospectivePinnedIndexes, true); + if (!validPinning) { + const pinningWithoutColumns = { ...newOptions.pinning }; + delete pinningWithoutColumns.columns; + const optionsWithoutPinning = { ...newOptions }; + delete optionsWithoutPinning.pinning; + newOptions = { + ...optionsWithoutPinning, + ...(Object.keys(pinningWithoutColumns).length ? { pinning: pinningWithoutColumns } : {}), + } as Partial; + } + } if (this._options.enableAddRow !== newOptions.enableAddRow) { this.invalidateRow(this.getDataLength()); } - // before applying column freeze, we need our viewports to be scrolled back to left to avoid misaligned column headers - if (newOptions.frozenColumn !== undefined && newOptions.frozenColumn >= 0) { - this._prevFrozenColumnIdx = this.getFrozenColumnIdx(); // keep ref of previous frozen column for later usage - - // make sure the freeze is also valid without breaking the UI (e.g. we can't freeze columns on left canvas wider than visible canvas width in the browser) - if (!suppressColumnSet) { - this._invalidfrozenAlerted = false; // reset frozen alert - } - if (this.validateColumnFreezeWidth(newOptions.frozenColumn)) { - this.getViewports().forEach((vp) => (vp.scrollLeft = 0)); - this.handleScroll(); // trigger scroll to realign column headers as well - } else { - newOptions.frozenColumn = this._prevFrozenColumnIdx < newOptions.frozenColumn ? this._prevFrozenColumnIdx : -1; - } + // The reading direction is applied once, during initialisation: applyRTL() sets the + // container's class and dir attribute, and the docking geometry reads the option on + // every measurement. Accepting a later change would leave the option and the rendered + // direction disagreeing, so the change is dropped rather than half-applied. + if (newOptions.rtl !== undefined && !!newOptions.rtl !== !!this._options.rtl) { + console.warn('[SlickGrid] the "rtl" option is only applied when the grid is created; the change was ignored.'); + const optionsWithoutRtl = { ...newOptions }; + delete optionsWithoutRtl.rtl; + newOptions = optionsWithoutRtl as Partial; } const originalOptions = Utils.extend(true, {}, this._options); - this._options = Utils.extend(this._options, newOptions); + this._options = Utils.extend(true, this._options, newOptions); + if (removePinning) { + delete (this._options as Partial).pinning; + } + // Row lists are complete per edge: replace them instead of deep-merging by index, + // which would leave stale entries when a list shrinks. + if (newOptions.stickyRows !== undefined) { + const incomingStickyRows = newOptions.stickyRows ?? {}; + this._options.stickyRows = { + top: Utils.replaceList(incomingStickyRows.top), + bottom: Utils.replaceList(incomingStickyRows.bottom), + both: Utils.replaceList(incomingStickyRows.both), + }; + } + if (newOptions.pinning !== undefined && newOptions.pinning !== null) { + const incomingPinning = newOptions.pinning; + const currentPinning = this._options.pinning ?? {}; + const replaceColumnReferences = ( + incoming: ColumnPinningReferences | undefined, + current: ColumnPinningReferences | undefined + ): ColumnPinningReferences => { + const references = incoming !== undefined ? incoming : current; + return typeof references === 'number' ? references : Utils.replaceList(references); + }; + this._options.pinning = { + ...currentPinning, + ...(incomingPinning.columns !== undefined + ? { + columns: { + left: replaceColumnReferences(incomingPinning.columns.left, currentPinning.columns?.left), + right: replaceColumnReferences(incomingPinning.columns.right, currentPinning.columns?.right), + }, + } + : {}), + ...(incomingPinning.rows !== undefined + ? { + rows: { + top: Utils.replaceList(incomingPinning.rows.top, currentPinning.rows?.top), + bottom: Utils.replaceList(incomingPinning.rows.bottom, currentPinning.rows?.bottom), + }, + } + : {}), + }; + } this.trigger(this.onSetOptions, { optionsBefore: originalOptions, optionsAfter: this._options }); // any option affecting row heights requires a rebuild of the row position index - if (newOptions.rowHeight !== undefined || newOptions.rowHeightProvider !== undefined || newOptions.enableVariableRowHeight !== undefined) { + if ( + newOptions.rowHeight !== undefined || + newOptions.rowHeightProvider !== undefined || + newOptions.enableVariableRowHeight !== undefined + ) { this.rowHeightsDirty = true; } @@ -1369,9 +1384,7 @@ export class SlickGrid = Column, O e activateChangedOptions(suppressRender?: boolean, suppressColumnSet?: boolean, suppressSetOverflow?: boolean): void { this.prepareForOptionsChange(); this.invalidateRow(this.getDataLength()); - this.trigger(this.onActivateChangedOptions, { options: this._options }); - this.internal_setOptions(suppressRender, suppressColumnSet, suppressSetOverflow); } @@ -1380,16 +1393,15 @@ export class SlickGrid = Column, O e * * @returns {void} - Does not return a value. */ - protected prepareForOptionsChange() { - if (!this.getEditorLock().commitCurrentEdit()) { + protected prepareForOptionsChange(): void { + if (!this.getEditorLock()?.commitCurrentEdit()) { return; } - this.makeActiveCellNormal(); } /** - * Depending on new options, sets column header visibility, validates options, sets frozen options, + * Depending on new options, sets column header visibility, validates options, applies docking options, * forces viewport height recalculation if needed, updates viewport overflow, re-renders the grid (unless suppressed), * sets the scroller elements, and reinitialises mouse wheel scrolling as needed. * @@ -1402,25 +1414,24 @@ export class SlickGrid = Column, O e this.setColumnHeaderVisibility(this._options.showColumnHeader); } this.validateAndEnforceOptions(); - this.setFrozenOptions(); + this.applyColumnPinningOptions(this.columns); + this.refreshDockingLayout(); + this.refreshRowDockingLayout(this.scrollTop, true); + if (!this.hasConfiguredDocking() && this.hasDockingHorizontalScroller()) { + this.deactivateSingleViewportLayout(); + } if (this._options.createFooterRow && !this._footerRow) { this.materializeFooterRow(); } else if (!this._options.createFooterRow && this._footerRow) { - this._footerRowScroller.forEach((scroller) => Utils.hide(scroller)); - } - - // when user changed frozen row option, we need to force a recalculation of each viewport heights - if (this._options.frozenBottom !== undefined) { - this.enforceFrozenRowHeightRecalc = true; + this._footerRowScroller.forEach((scroller) => { + Utils.hide(scroller); + }); } this._viewport.forEach((view) => { view.style.overflowY = this._options.autoHeight ? 'hidden' : 'auto'; }); - if (!suppressRender) { - this.render(); - } this.setScroller(); if (!suppressSetOverflow) { @@ -1431,43 +1442,65 @@ export class SlickGrid = Column, O e this.setColumns(this.columns); } - if (this._options.enableMouseWheelScrollHandler && this._viewport && (!this.slickMouseWheelInstances || this.slickMouseWheelInstances.length === 0)) { + // setColumns() invalidates and removes cached rows. Render only after that + // phase, otherwise option changes such as pinned-row count are painted and + // then immediately cleared by the column refresh. + if (!suppressRender) { + this.render(); + } + + if ( + this._options.enableMouseWheelScrollHandler && + this._viewport && + (!this.slickMouseWheelInstances || this.slickMouseWheelInstances.length === 0) + ) { this._viewport.forEach((view) => { - this.slickMouseWheelInstances.push(MouseWheel({ - element: view, - onMouseWheel: this.handleMouseWheel.bind(this) - })); + this.slickMouseWheelInstances.push( + MouseWheel({ + element: view, + onMouseWheel: this.handleMouseWheel.bind(this), + }) + ); }); } else if (this._options.enableMouseWheelScrollHandler === false) { this.destroyAllInstances(this.slickMouseWheelInstances); // remove scroll handler when option is disable } + + // Keep header classes and styles synchronized when column rebuilding is suppressed. + this.handleAutoHeaderHeightChange(); } /** - * Builds the footer-row DOM (scrollers, spacers and footer-row containers) in both - * panes — the single construction path shared by init and by a runtime - * `setOptions({ createFooterRow: true })` enable. On an already-initialized grid it - * also binds the footer events (during init they are bound in `finishInitialization`). - * Runtime disable hides the footer rather than destroying it (symmetric with - * `showFooterRow`). - */ + * Builds the footer-row DOM (scrollers, spacers and footer-row containers) in the + * single viewport — the construction path shared by init and by a runtime + * `setOptions({ createFooterRow: true })` enable. On an already-initialized grid it + * also binds the footer events (during init they are bound in `finishInitialization`). + * Runtime disable hides the footer rather than destroying it (symmetric with + * `showFooterRow`). + */ protected materializeFooterRow(): void { - const canvasWithScrollbarWidth = this.getCanvasWidth() + (this.scrollbarDimensions?.width ?? 0); - - 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); + const canvasWithScrollbarWidth = this.getCanvasWidth() + (this.scrollbarDimensions?.width || 0); - this._footerRowScroller = [this._footerRowScrollerL, this._footerRowScrollerR]; + this._footerRowScrollerL = Utils.createDomElement('div', { className: 'slick-footerrow ui-state-default slick-state-default' }, this._contentRoot); + this._footerRowScroller = [this._footerRowScrollerL]; - this._footerRowSpacerL = Utils.createDomElement('div', { style: { display: 'block', height: '1px', position: 'absolute', top: '0px', left: '0px' } }, this._footerRowScrollerL); + 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._footerRowL = Utils.createDomElement( + 'div', + { className: 'slick-footerrow-columns slick-footerrow-columns-left' }, + this._footerRowScrollerL + ); + this._footerRow = [this._footerRowL]; - this._footerRow = [this._footerRowL, this._footerRowR]; + if (this.hasConfiguredColumnDocking()) { + this.dockingFooterRowRegions = this.createDockingChromeRegionSet(this._footerRowL, 'slick-footerrow-columns'); + } if (!this._options.showFooterRow) { this._footerRowScroller.forEach((scroller) => { @@ -1475,15 +1508,11 @@ export class SlickGrid = Column, O e }); } + // Bind footer events only when footer row is created after init. if (this.initialized) { - 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._bindingEventService.bind(this._footerRow, 'contextmenu', this.handleFooterContextMenu.bind(this) as EventListener); + this._bindingEventService.bind(this._footerRow, 'click', this.handleFooterClick.bind(this) as EventListener); + this._bindingEventService.bind(this._footerRowScroller, 'scroll', this.handleFooterRowScroll.bind(this) as EventListener); } } @@ -1496,12 +1525,9 @@ export class SlickGrid = Column, O e if (this._options.autoHeight) { this._options.leaveSpaceForNewRows = false; } - if (this._options.forceFitColumns) { - this._options.autosizeColsMode = GridAutosizeColsMode.LegacyForceFit; - } - // make sure the freeze is also valid without breaking the UI (e.g. we can't left freeze columns wider than visible left canvas width) - if (!this.validateColumnFreezeWidth(this._options.frozenColumn)) { - this._options.frozenColumn = this._prevFrozenColumnIdx < this._options.frozenColumn! ? this._prevFrozenColumnIdx : -1; + + if (this._options.pinning?.columns) { + this.validatePinnedColumnIndexes(this.getPinnedColumnIndexes(), false); } } @@ -1509,7 +1535,11 @@ export class SlickGrid = Column, O e * Unregisters a current selection model and registers a new one. See the definition of SelectionModel for more information. * @param {Object} selectionModel A SelectionModel. */ - setSelectionModel(model: SelectionModel) { + setSelectionModel(model: SelectionModel): void { + const recreateDraggable = this.initialized && !!this.slickDraggableInstance; + if (recreateDraggable) { + this.slickDraggableInstance = this.destroyAllInstances(this.slickDraggableInstance) as null; + } if (this.selectionModel) { this.selectionModel.onSelectedRangesChanged.unsubscribe(this.handleSelectedRangesChanged.bind(this)); this.selectionModel.destroy?.(); @@ -1517,53 +1547,17 @@ export class SlickGrid = Column, O e this.selectionModel = model; if (this.selectionModel) { - this.selectionModel.init(this as unknown as SlickGridModel); + this.selectionModel.init(this as unknown as SlickGrid); this.selectionModel.onSelectedRangesChanged.subscribe(this.handleSelectedRangesChanged.bind(this)); } - - // The grid normally finishes initialization from its constructor, before - // applications assign a selection model. Recreate the draggable interaction - // so modifier-drag selection options are applied to the initialized grid. - if (this.initialized) { + if (recreateDraggable) { this.createDraggable(); } } /** Returns the current SelectionModel. See here for more information about SelectionModels. */ - getSelectionModel() { - return this.selectionModel; - } - - /** 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'); - } - } - - ////////////////////////////////////////////////////////////////////// - // End Grid and DOM Initialisation - ////////////////////////////////////////////////////////////////////// - - - ////////////////////////////////////////////////////////////////////// - // Column Management, Headers and Footers - ////////////////////////////////////////////////////////////////////// - - // Returns a boolean indicating whether the grid is configured with frozen columns. - protected hasFrozenColumns() { - return this._options.frozenColumn! > -1; - } - - /** Whether the row renders in the bottom canvas (the render split: rows >= actualFrozenRow). */ - protected isBottomBandRow(row: number) { - return this.hasFrozenRows && row >= this.actualFrozenRow; - } - - /** Whether the row index is one of the frozen (pinned) rows. */ - protected isFrozenRowIdx(row: number) { - return this.hasFrozenRows && (this._options.frozenBottom ? row >= this.actualFrozenRow : row < this.actualFrozenRow); + getSelectionModel(): T | undefined { + return this.selectionModel as T; } /** @@ -1572,7 +1566,7 @@ export class SlickGrid = Column, O e * @param {string | HTMLElement | DocumentFragment} [title] New column name. * @param {String} [toolTip] New column tooltip. */ - updateColumnHeader(columnId: number | string, title?: string | HTMLElement | DocumentFragment, toolTip?: string) { + updateColumnHeader(columnId: number | string, title?: string | HTMLElement | DocumentFragment, toolTip?: string): HTMLElement | void { if (this.initialized) { const idx = this.getColumnIndex(columnId); if (!Utils.isDefined(idx)) { @@ -1580,8 +1574,7 @@ export class SlickGrid = Column, O e } const columnDef = this.columns[idx]; - const visibleIdx = this.getVisibleColumnIndex(columnId); - const header: HTMLElement | undefined = Utils.isDefined(visibleIdx) ? this.getColumnByIndex(visibleIdx) : undefined; + const header: HTMLElement | undefined = this.getColumnByIndex(idx); if (header) { if (title !== undefined) { this.columns[idx].name = title; @@ -1593,7 +1586,7 @@ export class SlickGrid = Column, O e this.trigger(this.onBeforeHeaderCellDestroy, { node: header, column: columnDef, - grid: this + grid: this, }); header.setAttribute('title', toolTip || ''); @@ -1604,9 +1597,11 @@ export class SlickGrid = Column, O e this.trigger(this.onHeaderCellRendered, { node: header, column: columnDef, - grid: this + grid: this, }); } + + return header; } } @@ -1614,114 +1609,104 @@ export class SlickGrid = Column, O e * Get the Header DOM element * @param {C} columnDef - column definition */ - getHeader(columnDef: C) { + getHeader(columnDef?: C): HTMLDivElement | HTMLDivElement[] { if (!columnDef) { - return this.hasFrozenColumns() ? this._headers : this._headerL; + return this._headerL; } const idx = this.getColumnIndex(columnDef.id); - return this.hasFrozenColumns() ? ((idx <= this._options.frozenColumn!) ? this._headerL : this._headerR) : this._headerL; + return this.usesDockingChromeRegions() ? this.getDockingChromeRegion('header', this.getColumnDockingBand(idx)) : this._headerL; } /** * Get a specific Header Column DOM element by its column Id or index * @param {Number|String} columnIdOrIdx - column Id or index */ - 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; + getHeaderColumn(columnIdOrIdx: number | string): HTMLDivElement { + const idx = typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx); + if (this.usesDockingChromeRegions()) { + return this._headerL.querySelector( + `.slick-header-column[data-id="${String(this.columns[idx]?.id ?? columnIdOrIdx)}"]` + ) as HTMLDivElement; + } + const targetHeader = this._headerL; + const targetIndex = idx; const directMatch = targetHeader.children[targetIndex] as HTMLDivElement | undefined; const targetColumnId = String(this.columns[idx]?.id ?? columnIdOrIdx); const directMatchColumn = Utils.storage.get(directMatch, 'column') as C | undefined; if (directMatch && (directMatch.dataset?.id === targetColumnId || String(directMatchColumn?.id) === targetColumnId)) { return directMatch; } - return Array.from(targetHeader.children).find((child) => (child as HTMLDivElement).dataset?.id === targetColumnId) as HTMLDivElement; + + return ( + (Array.from(targetHeader.children).find((child) => (child as HTMLDivElement).dataset?.id === targetColumnId) as HTMLDivElement) || + (undefined as any) + ); } /** Get the Header Row DOM element */ - getHeaderRow() { - return this.hasFrozenColumns() ? this._headerRows : this._headerRows[0]; + getHeaderRow(): HTMLDivElement | HTMLDivElement[] { + return this._headerRowL; } /** Get the Footer DOM element */ - getFooterRow() { - return this.hasFrozenColumns() ? this._footerRow : this._footerRow?.[0]; + getFooterRow(): HTMLDivElement | HTMLDivElement[] { + return this._footerRowL; } /** * Get Header Row Column DOM element by its column Id or index * @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; + getHeaderRowColumn(columnIdOrIdx: number | string): HTMLDivElement { + let idx = typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx); + if (this.usesDockingChromeRegions()) { + return this._headerRowL.querySelector(`.slick-headerrow-column.l${idx}`) as HTMLDivElement; } - - return headerRowTarget.children[idx] as HTMLDivElement; + const headerRowTarget = this._headerRowL; + return (headerRowTarget.querySelector(`.slick-headerrow-column.l${idx}`) || headerRowTarget.children[idx]) as HTMLDivElement; } /** * Get the Footer Row Column DOM element by its column Id or index * @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; - } + getFooterRowColumn(columnIdOrIdx: number | string): HTMLDivElement { + let idx = typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx); + if (this.usesDockingChromeRegions()) { + return this._footerRowL?.querySelector(`.slick-footerrow-column.l${idx}`) as HTMLDivElement; + } + const footerRowTarget = this._footerRowL; + return (footerRowTarget?.querySelector(`.slick-footerrow-column.l${idx}`) || footerRowTarget?.children[idx]) as HTMLDivElement; + } /** * If footer rows are enabled, clears existing footer cells then iterates over all columns. - * For each visible column, it creates a footer cell element (adding “frozen” classes if needed), + * For each visible column, it creates a footer cell element (adding docking classes if needed), * stores the column definition in the element’s storage, and triggers the onFooterRowCellRendered event. */ - protected createColumnFooter() { + protected createColumnFooter(): void { if (this._options.createFooterRow) { - this._footerRow.forEach((footer) => { - const columnElements = footer.querySelectorAll('.slick-footerrow-column'); - columnElements.forEach((column) => { - const columnDef = Utils.storage.get(column, 'column'); - this.trigger(this.onBeforeFooterRowCellDestroy, { - node: column, - column: columnDef, - grid: this - }); - }); - }); - - Utils.emptyElement(this._footerRowL); - Utils.emptyElement(this._footerRowR); - + // The region set/reset helpers announce every existing footer cell + // (onBeforeFooterRowCellDestroy) before emptying the root. + if (this.usesDockingChromeRegions()) { + this.dockingFooterRowRegions = this.createDockingChromeRegionSet(this._footerRowL, 'slick-footerrow-columns'); + } else { + this.resetDockingChromeRegionSet(this._footerRowL, 'slick-footerrow-columns', 'left'); + this.dockingFooterRowRegions = undefined; + } for (let i = 0; i < this.columns.length; i++) { const m = this.columns[i]; - if (!m || m.hidden) { continue; } + 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 band = this.getColumnDockingBand(i); + const footerRowCell = Utils.createDomElement( + 'div', + { className: `ui-state-default slick-state-default slick-footerrow-column l${i} r${i}` }, + this.getDockingChromeRegion('footerRow', band) + ); + const className = band !== 'center' ? 'pinned' : null; if (className) { footerRowCell.classList.add(className); } @@ -1731,9 +1716,10 @@ export class SlickGrid = Column, O e this.trigger(this.onFooterRowCellRendered, { node: footerRowCell, column: m, - grid: this + grid: this, }); } + this.applyDockingToColumnChrome(); } } @@ -1744,14 +1730,11 @@ export class SlickGrid = Column, O e * --> triggers onBeforeSort * --> and if not cancelled, updates the sort columns and triggers onSort. */ - protected setupColumnSort() { + protected setupColumnSort(): void { + this._bindingEventService.unbindAll('colsorts'); this._headers.forEach((header) => { - this._bindingEventService.bind(header, 'click', (e: any) => { - if (this.columnResizeDragging) { - return; - } - - if (e.target.classList.contains('slick-resizable-handle')) { + const sortCallback = (e: (MouseEvent | KeyboardEvent) & { target: HTMLElement }) => { + if (this.columnResizeDragging || e.target.classList.contains('slick-resizable-handle')) { return; } @@ -1761,7 +1744,7 @@ export class SlickGrid = Column, O e } const column = Utils.storage.get(coll, 'column'); - if (column.sortable) { + if (column?.sortable) { if (!this.getEditorLock()?.commitCurrentEdit()) { return; } @@ -1818,18 +1801,20 @@ export class SlickGrid = Column, O e onSortArgs = { multiColumnSort: false, previousSortColumns, - columnId: (this.sortColumns.length > 0 ? column.id : null), - sortCol: (this.sortColumns.length > 0 ? column : null), - sortAsc: (this.sortColumns.length > 0 ? this.sortColumns[0].sortAsc : true) + columnId: this.sortColumns.length > 0 ? column.id : null, + sortCol: this.sortColumns.length > 0 ? column : null, + sortAsc: this.sortColumns.length > 0 ? this.sortColumns[0].sortAsc : true, }; } else { onSortArgs = { multiColumnSort: true, previousSortColumns, - sortCols: this.sortColumns.map((col) => { - const tempCol = this.columns[this.getColumnIndex(col.columnId)]; - return !tempCol || tempCol.hidden ? null : { columnId: tempCol.id, sortCol: tempCol, sortAsc: col.sortAsc }; - }).filter((el) => el) + sortCols: this.sortColumns + .map((col) => { + const tempCol = this.getColumnById(col.columnId); + return tempCol && !tempCol.hidden ? { columnId: tempCol.id, sortCol: tempCol, sortAsc: col.sortAsc } : null; + }) + .filter((el) => el), }; } @@ -1838,7 +1823,28 @@ export class SlickGrid = Column, O e this.trigger(this.onSort, onSortArgs, e); } } - }); + }; + + // Add keydown/click event handlers for sortable columns + this._bindingEventService.bind( + header, + 'keydown', + ((e: KeyboardEvent & { target: HTMLElement }) => { + this.trigger(this.onHeaderKeyDown, { event: e, column: Utils.storage.get(e.target, 'column'), grid: this }); + if (e.key === 'Enter' || e.key === ' ') { + sortCallback(e); + } + }) as EventListener, + {}, + 'colsorts' + ); + this._bindingEventService.bind( + header, + 'click', + ((e: MouseEvent & { target: HTMLElement }) => sortCallback(e)) as EventListener, + {}, + 'colsorts' + ); }); } @@ -1848,54 +1854,46 @@ export class SlickGrid = Column, O e * (and header row cells if enabled) with appropriate content, CSS classes, event bindings, * 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 - }); - } - }); - }); - - Utils.emptyElement(this._headerL); - Utils.emptyElement(this._headerR); + protected createColumnHeaders(): void { + this._bindingEventService.unbindAll('colheaders'); + // The region set/reset helpers announce every existing header and header-row cell + // (onBeforeHeaderCellDestroy / onBeforeHeaderRowCellDestroy) before emptying the roots. + if (this.hasConfiguredColumnDocking()) { + this.dockingHeaderRegions = this.createDockingChromeRegionSet(this._headerL, 'slick-header-columns'); + this.dockingHeaderRowRegions = this.createDockingChromeRegionSet(this._headerRowL, 'slick-headerrow-columns'); + } else { + this.resetDockingChromeRegionSet(this._headerL, 'slick-header-columns', 'left'); + this.resetDockingChromeRegionSet(this._headerRowL, 'slick-headerrow-columns', 'left'); + this.dockingHeaderRegions = undefined; + this.dockingHeaderRowRegions = undefined; + } this.getHeadersWidth(); - Utils.width(this._headerL, this.headersWidthL); - Utils.width(this._headerR, this.headersWidthR); - - this._headerRows.forEach((row) => { - const columnElements = row.querySelectorAll('.slick-headerrow-column'); - columnElements.forEach((column) => { - const columnDef = Utils.storage.get(column, 'column'); - if (columnDef) { - this.trigger(this.onBeforeHeaderRowCellDestroy, { - node: this, - column: columnDef, - grid: this - }); - } - }); - }); - - Utils.emptyElement(this._headerRowL); - Utils.emptyElement(this._headerRowR); + Utils.width(this._headerL, this.getDockingChromeRootWidth()); - for (let i = 0; i < this.columns.length; i++) { + for (let i = 0, ln = this.columns.length; i < ln; 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; + if (!m || m.hidden) { + continue; + } - 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); + const band = this.getColumnDockingBand(i); + const headerTarget = this.getDockingChromeRegion('header', band); + const headerRowTarget = this.getDockingChromeRegion('headerRow', band); + + 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', + tabIndex: 0, + ariaColIndex: `${i + 1}`, + }, + headerTarget + ); if (m.toolTip) { header.title = m.toolTip; } @@ -1905,38 +1903,48 @@ export class SlickGrid = Column, O e const colNameElm = Utils.createDomElement('span', { className: 'slick-column-name' }, header); this.applyHtmlCode(colNameElm, m.name); - Utils.width(header, m.width! - this.headerColumnWidthDiff); + const colWidth = m.width! - this.headerColumnWidthDiff; + Utils.width(header, colWidth); let classname = m.headerCssClass || null; if (classname) { header.classList.add(...Utils.classNameToList(classname)); } - classname = this.hasFrozenColumns() && i <= this._options.frozenColumn! ? 'frozen' : null; + classname = band !== 'center' ? 'pinned' : null; if (classname) { header.classList.add(classname); } - this._bindingEventService.bind(header, 'mouseenter', this.handleHeaderMouseEnter.bind(this) as EventListener); - this._bindingEventService.bind(header, 'mouseleave', this.handleHeaderMouseLeave.bind(this) as EventListener); + this._bindingEventService.bind(header, 'mouseenter', this.handleHeaderMouseEnter.bind(this) as EventListener, {}, 'colheaders'); + this._bindingEventService.bind(header, 'mouseleave', this.handleHeaderMouseLeave.bind(this) as EventListener, {}, 'colheaders'); + this._bindingEventService.bind(header, 'mouseover', this.handleHeaderMouseOver.bind(this) as EventListener, {}, 'colheaders'); + this._bindingEventService.bind(header, 'mouseout', this.handleHeaderMouseOut.bind(this) as EventListener, {}, 'colheaders'); Utils.storage.put(header, 'column', m); if (this._options.enableColumnReorder || m.sortable) { - this._bindingEventService.bind(header, 'mouseenter', this.handleHeaderMouseHoverOn.bind(this) as EventListener); - this._bindingEventService.bind(header, 'mouseleave', this.handleHeaderMouseHoverOff.bind(this) as EventListener); + this._bindingEventService.bind(header, 'mouseenter', this.handleHeaderMouseHoverOn.bind(this) as EventListener, {}, 'colheaders'); + this._bindingEventService.bind(header, 'mouseleave', this.handleHeaderMouseHoverOff.bind(this) as EventListener, {}, 'colheaders'); } - if (Object.prototype.hasOwnProperty.call(m, 'headerCellAttrs') && m.headerCellAttrs instanceof Object) { - Object.keys(m.headerCellAttrs).forEach(key => { - if (Object.prototype.hasOwnProperty.call(m.headerCellAttrs, key)) { + if (m.hasOwnProperty('headerCellAttrs') && m.headerCellAttrs instanceof Object) { + Object.keys(m.headerCellAttrs).forEach((key) => { + if (m.headerCellAttrs.hasOwnProperty(key)) { header.setAttribute(key, m.headerCellAttrs[key]); } }); } if (m.sortable) { + header.classList.add('slick-header-sortable'); - Utils.createDomElement('div', { className: `slick-sort-indicator ${this._options.numberedMultiColumnSort && !this._options.sortColNumberInSeparateSpan ? ' slick-sort-indicator-numbered' : ''}` }, header); + Utils.createDomElement( + 'div', + { + className: `slick-sort-indicator ${this._options.numberedMultiColumnSort && !this._options.sortColNumberInSeparateSpan ? ' slick-sort-indicator-numbered' : ''}`, + }, + header + ); if (this._options.numberedMultiColumnSort && this._options.sortColNumberInSeparateSpan) { Utils.createDomElement('div', { className: 'slick-sort-indicator-numbered' }, header); } @@ -1945,25 +1953,41 @@ export class SlickGrid = Column, O e this.trigger(this.onHeaderCellRendered, { node: header, column: m, - grid: this + grid: this, }); 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; - if (frozenClasses) { - headerRowCell.classList.add(frozenClasses); + const headerRowCell = Utils.createDomElement( + 'div', + { className: `ui-state-default slick-state-default slick-headerrow-column l${i} r${i}`, role: 'gridcell', ariaColIndex: `${i + 1}` }, + headerRowTarget + ); + const pinnedClasses = band !== 'center' ? 'pinned' : null; + if (pinnedClasses) { + headerRowCell.classList.add(pinnedClasses); } - this._bindingEventService.bind(headerRowCell, 'mouseenter', this.handleHeaderRowMouseEnter.bind(this) as EventListener); - this._bindingEventService.bind(headerRowCell, 'mouseleave', this.handleHeaderRowMouseLeave.bind(this) as EventListener); + // prettier-ignore + this._bindingEventService.bind(headerRowCell, 'mouseenter', this.handleHeaderRowMouseEnter.bind(this) as EventListener, {}, 'colheaders'); + // prettier-ignore + this._bindingEventService.bind(headerRowCell, 'mouseleave', this.handleHeaderRowMouseLeave.bind(this) as EventListener, {}, 'colheaders'); + // prettier-ignore + this._bindingEventService.bind(headerRowCell, 'mouseover', this.handleHeaderRowMouseOver.bind(this) as EventListener, {}, 'colheaders'); + this._bindingEventService.bind( + + headerRowCell, + 'mouseout', + this.handleHeaderRowMouseOut.bind(this) as EventListener, + {}, + 'colheaders' + ); Utils.storage.put(headerRowCell, 'column', m); this.trigger(this.onHeaderRowCellRendered, { node: headerRowCell, column: m, - grid: this + grid: this, }); } } @@ -1972,54 +1996,58 @@ 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 SlickGrid, + this._headers, + this.headerColumnWidthDiff, + this.setColumns as any, + this.setupColumnResize, + this.columns, + this.getColumnIndex, + this.uid, + this.trigger + ); } else { this.setupColumnReorder(); } } + this.applyDockingToColumnChrome(); this.handleAutoHeaderHeightChange(); + } /** - * Enables or disables automatic header height handling. - */ - protected handleAutoHeaderHeightChange() { + * Enables or disables automatic header height handling. + */ + protected handleAutoHeaderHeightChange(): void { const enabled = !!this._options.autoHeaderHeight; - const headers = [this._headerScrollerL, this._headerScrollerR] - .filter((header): header is HTMLDivElement => !!header); + const headers = [this._headerScrollerL]; - headers.forEach(header => { - header.classList.toggle('slick-header-auto-height', enabled); - }); + headers.forEach((header) => header.classList.toggle('slick-header-auto-height', enabled)); if (!enabled) { - this._clearAutoHeaderHeightStyles(headers); + this.clearAutoHeaderHeightStyles(headers); } } /** - * Measures natural header heights and applies one common height to all header panes. - */ - protected recalculateHeaderHeight() { + * Measures natural header heights and applies one common height to all header panes. + */ + protected recalculateHeaderHeight(): void { if (!this._headerScrollerL) { return; } - const headers = [this._headerScrollerL, this._headerScrollerR] - .filter((header): header is HTMLDivElement => !!header); - - const currentHeight = parseFloat( - this._headerScrollerL.style.getPropertyValue('--slick-auto-header-height') || '0' - ); - - // Remove previously calculated values so CSS can determine the natural height. - this._clearAutoHeaderHeightStyles(headers); + const headers = [this._headerScrollerL]; + const currentHeight = parseFloat(this._headerScrollerL.style.getPropertyValue('--slick-auto-header-height') || '0'); - const maxHeight = Math.max(...headers.map(header => header.getBoundingClientRect().height)); + // Remove the previous calculated height before measuring the rendered header content. + this.clearAutoHeaderHeightStyles(headers); + const maxHeight = Math.max(...headers.map((header) => header.getBoundingClientRect().height)); if (maxHeight > 0) { - this._setAutoHeaderHeightStyles(maxHeight, headers); + this.setAutoHeaderHeightStyles(maxHeight, headers); // A viewport resize is only necessary when the calculated height actually changed. if (Math.abs(maxHeight - currentHeight) > 0.5) { @@ -2028,68 +2056,53 @@ export class SlickGrid = Column, O e } } - /** - * Clears calculated automatic header height styles. - */ - private _clearAutoHeaderHeightStyles(headers: HTMLDivElement[]) { - headers.forEach(header => { - header.style.removeProperty('--slick-auto-header-height'); - header.style.height = ''; - }); - } - - /** - * Applies the calculated automatic header height styles. - */ - private _setAutoHeaderHeightStyles(height: number, headers: HTMLDivElement[]) { - - headers.forEach(header => { - header.style.setProperty('--slick-auto-header-height', `${height}px`); - header.style.height = `${height}px`; - }); - } - /** * Destroys any existing sortable instances and creates new ones on the left and right header * containers using the Sortable library. Configures options including animation, * drag handle selectors, auto-scroll, and callbacks (onStart, onEnd) that * update the column order, set columns, trigger onColumnsReordered, and reapply column resizing. */ - protected setupColumnReorder() { + protected setupColumnReorder(): void { this.sortableSideLeftInstance?.destroy(); + this.sortableSideCenterInstance?.destroy(); this.sortableSideRightInstance?.destroy(); + this.sortableSideLeftInstance = undefined; + this.sortableSideCenterInstance = undefined; + this.sortableSideRightInstance = undefined; - let columnScrollTimer: number | undefined; + let columnScrollTimer: ReturnType | undefined; let columnScrollDirection = 0; - let prevColumnIds: Array = []; - let hiddenColumns = new Map(); const stopAutoScroll = () => { - if (columnScrollTimer) { - clearInterval(columnScrollTimer); - columnScrollTimer = undefined; - } + clearInterval(columnScrollTimer); + columnScrollTimer = undefined; columnScrollDirection = 0; }; + let prevColumnIds: Array = []; - const autoScrollHandler = (event: DragEvent | MouseEvent) => { - const { clientX, clientY, pageX } = event; - if (!clientX || !clientY) { + // fires on document during native drag; also bind 'mousemove' for SortableJS forceFallback mode + const autoScrollHandler = (e: DragEvent | MouseEvent) => { + if (!this.initialized || !this._viewportScrollContainerX) { + stopAutoScroll(); return; } - - const viewportLeft = Utils.offset(this._viewportScrollContainerX)!.left; - const containerRight = Utils.offset(this._container)!.left + this._container.clientWidth; - const direction = pageX > containerRight ? 1 : pageX < viewportLeft ? -1 : 0; - - if (direction !== columnScrollDirection) { - stopAutoScroll(); - columnScrollDirection = direction; - - if (direction) { - columnScrollTimer = window.setInterval(() => { - this._viewportScrollContainerX.scrollLeft += direction * COLUMN_AUTOSCROLL_DISTANCE_PX; - }, COLUMN_AUTOSCROLL_INTERVAL_MS); + const { clientX, clientY, pageX } = e; + if (clientX && clientY) { + const viewportLeft = Utils.offset(this._viewportScrollContainerX)!.left; + const containerRight = Utils.offset(this._container)!.left + this._container.clientWidth; + const direction = pageX > containerRight ? 1 : pageX < viewportLeft ? -1 : 0; + if (direction !== columnScrollDirection) { + stopAutoScroll(); + columnScrollDirection = direction; + if (direction) { + columnScrollTimer = setInterval(() => { + if (!this.initialized || !this._viewportScrollContainerX) { + stopAutoScroll(); + return; + } + this._viewportScrollContainerX.scrollLeft += direction * COLUMN_AUTOSCROLL_DISTANCE_PX; + }, COLUMN_AUTOSCROLL_INTERVAL_MS); + } } } }; @@ -2097,32 +2110,35 @@ export class SlickGrid = Column, O e const sortableOptions = { animation: 50, direction: 'horizontal', - chosenClass: 'slick-header-column-active', ghostClass: 'slick-sortable-placeholder', draggable: '.slick-header-column', dragoverBubble: false, - preventOnFilter: false, // allow column to be resized even when they are not orderable + // Fixes broken Firefox-Linux dragging + forceFallback: /firefox/i.test(navigator.userAgent) && /linux/i.test(navigator.userAgent), + // allow column to be resized even when they are not orderable + preventOnFilter: false, revertClone: true, - scroll: !this.hasFrozenColumns(), // enable auto-scroll + // Use built-in SortableJS proximity scroll for unpinned grids; pinned grids use custom scroll. + scroll: !this.hasDockedColumns(), // lock unorderable columns by using a combo of filter + onMove filter: `.${this._options.unorderableColumnCssClass}`, - onMove: (event: MouseEvent & { related: HTMLElement; }) => { + onMove: (event: any) => { return !event.related.classList.contains(this._options.unorderableColumnCssClass as string); }, - onStart: (e: SortableEvent) => { + onStart: (e: any) => { e.item.classList.add('slick-header-column-active'); - this._bindingEventService.unbindAll('colreorder'); - stopAutoScroll(); - - if (!this.hasFrozenColumns() || this._headerR.contains(e.item)) { + // Only scrolling columns should auto-scroll; use contains() since offset comparisons + // are not reliable across the header regions. + const leftHeader = this.usesDockingChromeRegions() ? this.getDockingChromeRegion('header', 'left') : this._headerL; + if (!this.hasDockedColumns() || !leftHeader.contains(e.item)) { + // bind 'drag' for native HTML5 drag and 'mousemove' for SortableJS forceFallback this._bindingEventService.bind(document, 'drag', autoScrollHandler as EventListener, {}, 'colreorder'); this._bindingEventService.bind(document, 'mousemove', autoScrollHandler as EventListener, {}, 'colreorder'); } prevColumnIds = this.columns.map((c) => c.id); - hiddenColumns = new Map(this.columns.filter((column) => column.hidden).map((column) => [column.id, column] as [string | number, C])); }, - onEnd: (e: SortableEvent) => { + onEnd: (e: any) => { e.item.classList.remove('slick-header-column-active'); stopAutoScroll(); this._bindingEventService.unbindAll('colreorder'); @@ -2132,18 +2148,53 @@ export class SlickGrid = Column, O e return; } - const reorderedIds = [ - ...(this.sortableSideLeftInstance?.toArray() ?? []), - ...(this.sortableSideRightInstance?.toArray() ?? []), + const reorderedIdsByBand = [ + this.sortableSideLeftInstance?.toArray() || [], + this.sortableSideCenterInstance?.toArray() || [], + this.sortableSideRightInstance?.toArray() || [], ]; - const reorderedColumns = reorderedIds.map((columnId) => this.columns[this.getColumnIndex(columnId)]); - let visibleColumnIdx = 0; - const finalColumns = this.columns.map((column) => hiddenColumns.get(column.id) ?? reorderedColumns[visibleColumnIdx++]); + const reorderedColumnsByBand = reorderedIdsByBand.map((ids: Array) => + ids.map((id) => this.columns[this.getColumnIndex(id)]) + ); + const finalColumns = this.columns.slice(); + + // Keep each docking band in its logical slots; flattening moves center columns into pinned slots. + if (this.usesDockingChromeRegions()) { + // Slots follow the DOM band each header lives in. On the sticky transform path an active + // sticky column is docked visually but its header remains in the centre region, so the + // resolved layout bands cannot be used directly. + const transformPath = this.usesStickyColumnTransformPath(); + const inDomBand = (entry: DockedColumn): boolean => !(transformPath && entry.sticky); + const leftSlots = this.dockingLayout.left.filter(inDomBand).map((entry) => entry.index); + const rightSlots = this.dockingLayout.right.filter(inDomBand).map((entry) => entry.index); + const pinnedSlots = new Set([...leftSlots, ...rightSlots]); + const centerSlots = this.columns + .map((column, index) => (column && !column.hidden && !pinnedSlots.has(index) ? index : -1)) + .filter((index) => index >= 0); + const slotsByBand = [leftSlots, centerSlots, rightSlots]; + if (slotsByBand.some((slots, bandIndex) => slots.length !== reorderedColumnsByBand[bandIndex].length)) { + return; + } + slotsByBand.forEach((slots, bandIndex) => { + slots.forEach((index, reorderedIndex) => { + finalColumns[index] = reorderedColumnsByBand[bandIndex][reorderedIndex]; + }); + }); + } else { + let reorderedIndex = 0; + const reorderedColumns = reorderedColumnsByBand.flat(); + this.columns.forEach((column, index) => { + if (!column.hidden) { + finalColumns[index] = reorderedColumns[reorderedIndex++]; + } + }); + } e.stopPropagation(); - if (!this.arrayEquals(prevColumnIds, reorderedIds)) { + const finalColumnIds = finalColumns.map(({ id }) => id); + if (!this.arrayEquals(prevColumnIds, finalColumnIds)) { this.setColumns(finalColumns); - // reapply previous scroll position since it might move back to x=0 after calling `setColumns()` (especially when `frozenColumn` is set) + // reapply previous scroll position since it might move back to x=0 after calling `setColumns()` this.scrollToX(prevScrollLeft); this.trigger(this.onColumnsReordered, { impactedColumns: this.columns, previousColumnOrder: prevColumnIds }); this.setupColumnResize(); @@ -2154,18 +2205,28 @@ export class SlickGrid = Column, O e }, } as SortableOptions; - this.sortableSideLeftInstance = Sortable.create(this._headerL, sortableOptions); - this.sortableSideRightInstance = Sortable.create(this._headerR, sortableOptions); + if (this.usesDockingChromeRegions()) { + this.sortableSideLeftInstance = Sortable.create(this.getDockingChromeRegion('header', 'left'), sortableOptions); + this.sortableSideCenterInstance = Sortable.create(this.getDockingChromeRegion('header', 'center'), sortableOptions); + this.sortableSideRightInstance = Sortable.create(this.getDockingChromeRegion('header', 'right'), sortableOptions); + } else { + this.sortableSideLeftInstance = Sortable.create(this._headerL, sortableOptions); + this.sortableSideRightInstance = undefined; + } } /** * Returns a concatenated array containing the children (header column elements) from both the left and right header containers. * @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[]; + protected getHeaderChildren(): HTMLElement[] { + if (this.usesDockingChromeRegions()) { + const headers = Array.from(this._headerL.querySelectorAll('.slick-header-column')) as HTMLElement[]; + return this.getVisibleColumns() + .map((column) => headers.find((header) => header.dataset.id === String(column.id))) + .filter((header): header is HTMLElement => !!header); + } + return this._headers.flatMap((header) => Array.from(header.children)) as HTMLElement[]; } /** @@ -2173,7 +2234,7 @@ export class SlickGrid = Column, O e * (by removing the grid uid) and triggers the onColumnsResizeDblClick event with that identifier. * @param {MouseEvent & { target: HTMLDivElement }} evt - The double-click event on the resizable handle. */ - protected handleResizeableDoubleClick(evt: MouseEvent & { target: HTMLDivElement; }) { + protected handleResizeableDoubleClick(evt: MouseEvent & { target: HTMLDivElement }): void { const triggeredByColumn = evt.target.parentElement!.id.replace(this.uid, ''); this.trigger(this.onColumnsResizeDblClick, { triggeredByColumn }); } @@ -2184,33 +2245,43 @@ export class SlickGrid = Column, O e * and last resizable columns) and for each eligible column, creates a resizable handle, * binds a double–click event, and creates a Resizable instance with callbacks for onResizeStart, * onResize, and onResizeEnd. These callbacks manage column width adjustments (including force–fit - * and frozen column considerations), update header and canvas widths, trigger related events, + * and docked column considerations), update header and canvas widths, trigger related events, * and re–render the grid as needed. * @returns {void} */ - protected setupColumnResize() { - if (typeof Resizable === 'undefined') { - throw new Error(`Slick.Resizable is undefined, make sure to import "slick.interactions.js"`); - } - - let j: number; - let k: number; - let c: C; - let pageX: number; - let minPageX: number; - let maxPageX: number; + protected setupColumnResize(): void { + let j: number, k: number, c: C; + let pageX: number, minPageX: number, maxPageX: number; let firstResizable: number | undefined; let lastResizable = -1; - let frozenLeftColMaxWidth = 0; let resizeAutoScrollDeltaX = 0; let autoScrollClientX: number | undefined; let autoScrollOffsetX = 0; - const stopColumnResizeAutoScroll = () => { - if (this._columnResizeAutoScrollTimer) { - window.clearInterval(this._columnResizeAutoScrollTimer); - this._columnResizeAutoScrollTimer = undefined; + this._bindingEventService.unbindAll('colresizes'); + this.clearAutoScrollTimer(); + const children: HTMLElement[] = this.getHeaderChildren(); + const vc = this.getVisibleColumns(); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const handles = child.querySelectorAll('.slick-resizable-handle'); + handles.forEach((handle) => handle.remove()); + + if (i < vc.length && vc[i]?.resizable) { + if (firstResizable === undefined) { + firstResizable = i; + } + lastResizable = i; } + } + + if (firstResizable === undefined) { + return; + } + + // --- Minimal auto-scroll logic for browser edge --- + const stopColumnResizeAutoScroll = () => { + this.clearAutoScrollTimer(); autoScrollOffsetX = 0; }; @@ -2218,11 +2289,17 @@ export class SlickGrid = Column, O e if (this._columnResizeAutoScrollTimer) { return; } - this._columnResizeAutoScrollTimer = window.setInterval(() => { + this._columnResizeAutoScrollTimer = setInterval(() => { + if (!this.initialized || !this._viewportScrollContainerX) { + stopColumnResizeAutoScroll(); + return; + } const viewportOffset = Utils.offset(this._viewportScrollContainerX)!; - const targetPageX = autoScrollOffsetX > 0 - ? viewportOffset.left + this._viewportScrollContainerX.clientWidth + COLUMN_AUTOSCROLL_DISTANCE_PX - : viewportOffset.left - COLUMN_AUTOSCROLL_DISTANCE_PX; + /* v8 ignore next */ + const targetPageX = + autoScrollOffsetX > 0 + ? viewportOffset.left + this._viewportScrollContainerX.clientWidth + COLUMN_AUTOSCROLL_DISTANCE_PX + : viewportOffset.left - COLUMN_AUTOSCROLL_DISTANCE_PX; resizeCallback(targetPageX + resizeAutoScrollDeltaX); }, COLUMN_AUTOSCROLL_INTERVAL_MS); }; @@ -2232,32 +2309,36 @@ export class SlickGrid = Column, O e targetPageX: number, resizeCallback: (targetPageX: number) => void ): number => { - // Auto-scroll is intentionally disabled for RTL until its scroll coordinate model is supported. + if (!this.initialized || !this._viewportScrollContainerX) { + stopColumnResizeAutoScroll(); + return targetPageX; + } + // TODO: there is a known bug with auto-scroll in RTL, + // so disable it until someone can contribute a fix if (this._options.rtl || !this._options.autoScrollOnColumnResize) { stopColumnResizeAutoScroll(); return targetPageX; } - autoScrollClientX = typeof clientX === 'number' ? clientX : autoScrollClientX; + autoScrollClientX = isDefinedNumber(clientX) ? clientX : autoScrollClientX; const left = Utils.offset(this._viewportScrollContainerX)!.left; const viewportWidth = this._viewportScrollContainerX.clientWidth; const right = left + viewportWidth; const browserW = window.innerWidth || document.documentElement.clientWidth || 0; - if (targetPageX <= left) { autoScrollOffsetX = targetPageX - left; } else if (targetPageX >= right) { autoScrollOffsetX = targetPageX - right; - } else if (typeof autoScrollClientX === 'number' && browserW > 0) { - autoScrollOffsetX = autoScrollClientX <= RESIZE_AUTOSCROLL_BROWSER_EDGE_PX - ? -1 - : autoScrollClientX >= browserW - RESIZE_AUTOSCROLL_BROWSER_EDGE_PX - ? 1 - : 0; + } else if (isDefinedNumber(autoScrollClientX) && browserW > 0) { + autoScrollOffsetX = + autoScrollClientX <= RESIZE_AUTOSCROLL_BROWSER_EDGE_PX + ? -1 + : autoScrollClientX >= browserW - RESIZE_AUTOSCROLL_BROWSER_EDGE_PX + ? 1 + : 0; } else { autoScrollOffsetX = 0; } - if (autoScrollOffsetX) { scheduleColumnResizeAutoScroll(resizeCallback); return autoScrollOffsetX > 0 && viewportWidth ? Math.min(right, targetPageX) : targetPageX; @@ -2266,43 +2347,225 @@ export class SlickGrid = Column, O e return targetPageX; }; - stopColumnResizeAutoScroll(); - const children: HTMLElement[] = this.getHeaderChildren(); - const vc = this.getVisibleColumns(); for (let i = 0; i < children.length; i++) { - const child = children[i]; - const handles = child.querySelectorAll('.slick-resizable-handle'); - handles.forEach((handle) => handle.remove()); + const colElm = children[i]; + /* v8 ignore if */ if (i >= vc.length || !vc[i]) { continue; } + if (i < firstResizable || (this._options.forceFitColumns && i >= lastResizable)) { + continue; + } - if (vc[i].resizable) { - if (firstResizable === undefined) { - firstResizable = i; + const resizeableHandle = Utils.createDomElement( + 'div', + { className: 'slick-resizable-handle', role: 'separator', ariaOrientation: 'horizontal' }, + colElm + ); + this._bindingEventService.bind( + resizeableHandle, + 'dblclick', + this.handleResizeableDoubleClick.bind(this) as EventListener, + {}, + 'colresizes' + ); + + const applyColumnResize = ( + targetPageX: number, + resizeElms: { resizeableElement: HTMLElement; resizeableHandleElement: HTMLElement } + ) => { + this.columnResizeDragging = true; + let actualMinWidth; + let d = Math.min(maxPageX, Math.max(minPageX, targetPageX)) - pageX; + + if (this._options.rtl) { + d = -d; } - lastResizable = i; - } - } - if (firstResizable === undefined) { - return; - } + let x; + // oxlint-disable-next-line no-unused-vars + let newCanvasWidthR = 0; + + if (d < 0) { + x = d; + for (j = i; j >= 0; j--) { + c = vc[j]; + if (c && c.resizable && !c.hidden) { + actualMinWidth = Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); + /* v8 ignore if */ + if (x && (c.previousWidth || 0) + x < actualMinWidth) { + x += (c.previousWidth || 0) - actualMinWidth; + c.width = actualMinWidth; + } else { + c.width = (c.previousWidth || 0) + x; + x = 0; + } + } + } - for (let i = 0; i < children.length; i++) { - const colElm = children[i]; + for (k = 0; k <= i; k++) { + c = vc[k]; + if (c && !c.hidden) { + if (this.getColumnDockingBand(k) === 'right') { + newCanvasWidthR += c.width || 0; + } + } + } - if (i >= vc.length || !vc[i]) { - continue; - } - if (i < firstResizable || (this._options.forceFitColumns && i >= lastResizable)) { - continue; - } + if (this._options.forceFitColumns) { + x = -d; + for (j = i + 1; j < vc.length; j++) { + c = vc[j]; + if (c && !c.hidden) { + if (c.resizable) { + if (x && c.maxWidth && c.maxWidth - (c.previousWidth || 0) < x) { + x -= c.maxWidth - (c.previousWidth || 0); + c.width = c.maxWidth; + } else { + c.width = (c.previousWidth || 0) + x; + x = 0; + } + + if (this.getColumnDockingBand(j) === 'right') { + newCanvasWidthR += c.width || 0; + } + } + } + } + } else { + for (j = i + 1; j < vc.length; j++) { + c = vc[j]; + if (c && !c.hidden) { + if (this.getColumnDockingBand(j) === 'right') { + newCanvasWidthR += c.width || 0; + } else { + // The center width is resolved by the docking layout below. + } + } + } + } + + if (this._options.forceFitColumns) { + x = -d; + for (j = i + 1; j < vc.length; j++) { + c = vc[j]; + if (c && !c.hidden && c.resizable) { + /* v8 ignore if */ + if (x && c.maxWidth && c.maxWidth - (c.previousWidth || 0) < x) { + x -= c.maxWidth - (c.previousWidth || 0); + c.width = c.maxWidth; + } else { + c.width = (c.previousWidth || 0) + x; + x = 0; + } + } + } + } + } else { + x = d; + + newCanvasWidthR = 0; + + for (j = i; j >= 0; j--) { + c = vc[j]; + if (c && !c.hidden && c.resizable) { + if (x && c.maxWidth && c.maxWidth - (c.previousWidth || 0) < x) { + x -= c.maxWidth - (c.previousWidth || 0); + c.width = c.maxWidth; + } else { + const newWidth = (c.previousWidth || 0) + x; + c.width = newWidth; + x = 0; + } + } + } + + for (k = 0; k <= i; k++) { + c = vc[k]; + if (c && !c.hidden) { + if (this.getColumnDockingBand(k) === 'right') { + newCanvasWidthR += c.width || 0; + } + } + } + + if (this._options.forceFitColumns) { + x = -d; + for (j = i + 1; j < vc.length; j++) { + c = vc[j]; + if (c && !c.hidden && c.resizable) { + actualMinWidth = Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); + /* v8 ignore if */ + if (x && (c.previousWidth || 0) + x < actualMinWidth) { + x += (c.previousWidth || 0) - actualMinWidth; + c.width = actualMinWidth; + } else { + c.width = (c.previousWidth || 0) + x; + x = 0; + } + + if (this.getColumnDockingBand(j) === 'right') { + newCanvasWidthR += c.width || 0; + } + } + } + } else { + for (j = i + 1; j < vc.length; j++) { + c = vc[j]; + if (c && !c.hidden) { + if (this.getColumnDockingBand(j) === 'right') { + // eslint-disable-next-line + newCanvasWidthR += c.width || 0; + } + } + } + } + } + + this.applyColumnHeaderWidths(); + const usesStickyTransformPath = this.usesStickyColumnTransformPath(); + if (this._options.syncColumnCellResize || usesStickyTransformPath) { + this.applyColumnWidths(); + } - const resizeableHandle = Utils.createDomElement('div', { className: 'slick-resizable-handle', role: 'separator', ariaOrientation: 'horizontal' }, colElm); - this._bindingEventService.bind(resizeableHandle, 'dblclick', this.handleResizeableDoubleClick.bind(this) as EventListener); - let applyColumnResize: ((targetPageX: number, resizeElms: { resizeableElement: HTMLElement; resizeableHandleElement: HTMLElement }) => void) | undefined; + this.updateCanvasWidth(); + if (usesStickyTransformPath) { + this.updateStickyColumnTransforms(); + } + if ( + this._options.autoScrollOnColumnResize && + !this._options.rtl && + !this._options.forceFitColumns && + this.getColumnDockingBand(i) === 'center' + ) { + const columnRight = this.columnPosRight[i]; + const previousScrollLeft = this._viewportScrollContainerX.scrollLeft; + // A centre column's coordinates are relative to the centre band, so the width it has + // to outgrow is the band's visible width, not the whole scroll owner's. Comparing + // against the latter left the pinned bands' width as dead room, in which the column + // could grow past the edge without the grid ever following it. + const viewportWidth = Math.max( + 0, + this._viewportScrollContainerX.clientWidth - this.dockingLayout.leftWidth - this.dockingLayout.rightWidth + ); + const isLastVisibleColumn = i === vc.length - 1; + if (isLastVisibleColumn) { + this._isResizingColumn = true; + const maxScrollLeft = Math.max(0, this._viewportScrollContainerX.scrollWidth - this._viewportScrollContainerX.clientWidth); + this.scrollToX(maxScrollLeft); + } else if (columnRight > previousScrollLeft + viewportWidth) { + this._isResizingColumn = true; + this.scrollToX(columnRight - viewportWidth); + } + resizeAutoScrollDeltaX += this._viewportScrollContainerX.scrollLeft - previousScrollLeft; + } + + this.trigger(this.onColumnsDrag, { + triggeredByColumn: resizeElms.resizeableElement, + resizeHandle: resizeElms.resizeableHandleElement, + }); + }; this.slickResizableInstances.push( Resizable({ @@ -2314,16 +2577,14 @@ export class SlickGrid = Column, O e return false; } pageX = (targetEvent as MouseEvent).pageX; - frozenLeftColMaxWidth = 0; resizeElms.resizeableElement.classList.add('slick-header-column-active'); let shrinkLeewayOnRight: number | null = null; let stretchLeewayOnRight: number | null = null; // lock each column's width option to current width for (let pw = 0; pw < children.length; pw++) { - if (pw >= vc.length || !vc[pw]) { - continue; + if (pw < vc.length && vc[pw]) { + vc[pw].previousWidth = children[pw].offsetWidth; } - vc[pw].previousWidth = children[pw].offsetWidth; } if (this._options.forceFitColumns) { shrinkLeewayOnRight = 0; @@ -2340,6 +2601,7 @@ export class SlickGrid = Column, O e } } shrinkLeewayOnRight += (c.previousWidth || 0) - Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); + } } } @@ -2350,6 +2612,7 @@ export class SlickGrid = Column, O e c = vc[j]; if (c?.resizable) { if (stretchLeewayOnLeft !== null) { + /* v8 ignore if */ if (c.maxWidth) { stretchLeewayOnLeft += c.maxWidth - (c.previousWidth || 0); } else { @@ -2359,25 +2622,12 @@ export class SlickGrid = Column, O e shrinkLeewayOnLeft += (c.previousWidth || 0) - Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); } } - if (shrinkLeewayOnRight === null) { - shrinkLeewayOnRight = 100000; - } - if (shrinkLeewayOnLeft === null) { - shrinkLeewayOnLeft = 100000; - } - if (stretchLeewayOnRight === null) { - stretchLeewayOnRight = 100000; - } - if (stretchLeewayOnLeft === null) { - stretchLeewayOnLeft = 100000; - } - if (this._options.rtl) { - maxPageX = pageX + Math.min(shrinkLeewayOnLeft, stretchLeewayOnRight); - minPageX = pageX - Math.min(shrinkLeewayOnRight, stretchLeewayOnLeft); + maxPageX = pageX + Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); + minPageX = pageX - Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); } else { - maxPageX = pageX + Math.min(shrinkLeewayOnRight, stretchLeewayOnLeft); - minPageX = pageX - Math.min(shrinkLeewayOnLeft, stretchLeewayOnRight); + maxPageX = pageX + Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); + minPageX = pageX - Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); } resizeAutoScrollDeltaX = 0; autoScrollClientX = (targetEvent as MouseEvent).clientX; @@ -2386,294 +2636,51 @@ export class SlickGrid = Column, O e onResize: (e, resizeElms) => { const targetEvent = (e as TouchEvent).touches ? (e as TouchEvent).changedTouches[0] : e; let targetPageX = (targetEvent as MouseEvent).pageX; - if (!(this.hasFrozenColumns() && i <= this._options.frozenColumn!)) { - targetPageX = updateColumnResizeAutoScroll( - (targetEvent as MouseEvent).clientX, - targetPageX, - (resizePageX) => applyColumnResize?.(resizePageX, resizeElms) + if (this.getColumnDockingBand(i) === 'center') { + targetPageX = updateColumnResizeAutoScroll((targetEvent as MouseEvent).clientX, targetPageX, (resizePageX) => + applyColumnResize(resizePageX, resizeElms) ); - } - applyColumnResize = (resizePageX, resizeElements) => { - this.columnResizeDragging = true; - let actualMinWidth; - let d = Math.min(maxPageX, Math.max(minPageX, resizePageX)) - pageX; - - if (this._options.rtl) { - d = -d; - } - let x; - let newCanvasWidthL = 0; - let newCanvasWidthR = 0; - const viewportWidth = this.getViewportInnerWidth(); - - if (d < 0) { // shrink column - x = d; - - for (j = i; j >= 0; j--) { - c = vc[j]; - if (c?.resizable && !c.hidden) { - actualMinWidth = Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); - if (x && (c.previousWidth || 0) + x < actualMinWidth) { - x += (c.previousWidth || 0) - actualMinWidth; - c.width = actualMinWidth; - } else { - c.width = (c.previousWidth || 0) + x; - x = 0; - } - } - } + } + applyColumnResize(targetPageX + resizeAutoScrollDeltaX, resizeElms); + }, + onResizeEnd: (_e, resizeElms) => { + stopColumnResizeAutoScroll(); + resizeAutoScrollDeltaX = 0; + this._isResizingColumn = false; + resizeElms.resizeableElement.classList.remove('slick-header-column-active'); - for (k = 0; k <= i; k++) { - c = vc[k]; - if (!c || c.hidden) { continue; } + const triggeredByColumn = resizeElms.resizeableElement.id.replace(this.uid, ''); + if (this.trigger(this.onBeforeColumnsResize, { triggeredByColumn }).getReturnValue() === true) { + this.applyColumnHeaderWidths(); + } + let newWidth; + for (j = 0; j < vc.length; j++) { + c = vc[j]; + if (c && !c.hidden && children[j]) { + newWidth = children[j].offsetWidth; - if (this.hasFrozenColumns() && (k > this._options.frozenColumn!)) { - newCanvasWidthR += c.width || 0; - } else { - newCanvasWidthL += c.width || 0; - } + if (c.previousWidth !== newWidth && c.rerenderOnResize) { + this.invalidateAllRows(); } - - if (this._options.forceFitColumns) { - x = -d; - for (j = i + 1; j < vc.length; j++) { - c = vc[j]; - if (!c || c.hidden) { continue; } - if (c.resizable) { - if (x && c.maxWidth && (c.maxWidth - (c.previousWidth || 0) < x)) { - x -= c.maxWidth - (c.previousWidth || 0); - c.width = c.maxWidth; - } else { - c.width = (c.previousWidth || 0) + x; - x = 0; - } - - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { - newCanvasWidthR += c.width || 0; - } else { - newCanvasWidthL += c.width || 0; - } - } - } - } else { - for (j = i + 1; j < vc.length; j++) { - c = vc[j]; - if (!c || c.hidden) { continue; } - - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { - newCanvasWidthR += c.width || 0; - } else { - newCanvasWidthL += c.width || 0; - } - } - } - - if (this._options.forceFitColumns) { - x = -d; - for (j = i + 1; j < vc.length; j++) { - c = vc[j]; - if (!c || c.hidden) { continue; } - if (c.resizable) { - if (x && c.maxWidth && (c.maxWidth - (c.previousWidth || 0) < x)) { - x -= c.maxWidth - (c.previousWidth || 0); - c.width = c.maxWidth; - } else { - c.width = (c.previousWidth || 0) + x; - x = 0; - } - } - } - } - } else { // stretch column - x = d; - - newCanvasWidthL = 0; - newCanvasWidthR = 0; - - for (j = i; j >= 0; j--) { - c = vc[j]; - if (!c || c.hidden) { continue; } - if (c.resizable) { - if (x && c.maxWidth && (c.maxWidth - (c.previousWidth || 0) < x)) { - x -= c.maxWidth - (c.previousWidth || 0); - c.width = c.maxWidth; - } else { - const newWidth = (c.previousWidth || 0) + x; - const resizedCanvasWidthL = this.canvasWidthL + x; - - if (this.hasFrozenColumns() && (j <= this._options.frozenColumn!)) { - // 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 - } - c.width = ((resizedCanvasWidthL + this._options.frozenRightViewportMinWidth!) > viewportWidth) ? frozenLeftColMaxWidth : newWidth; - } else { - c.width = newWidth; - } - x = 0; - } - } - } - - 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; - } - } - - if (this._options.forceFitColumns) { - x = -d; - for (j = i + 1; j < vc.length; j++) { - c = vc[j]; - if (!c || c.hidden) { continue; } - if (c.resizable) { - actualMinWidth = Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); - if (x && (c.previousWidth || 0) + x < actualMinWidth) { - x += (c.previousWidth || 0) - actualMinWidth; - c.width = actualMinWidth; - } else { - c.width = (c.previousWidth || 0) + x; - x = 0; - } - - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { - newCanvasWidthR += c.width || 0; - } else { - newCanvasWidthL += c.width || 0; - } - } - } - } else { - for (j = i + 1; j < vc.length; j++) { - c = vc[j]; - if (!c || c.hidden) { continue; } - - if (this.hasFrozenColumns() && (j > this._options.frozenColumn!)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - newCanvasWidthR += c.width || 0; - } else { - newCanvasWidthL += c.width || 0; - } - } - } - } - - if (this.hasFrozenColumns() && newCanvasWidthL !== this.canvasWidthL) { - Utils.width(this._headerL, newCanvasWidthL + 1000); - Utils.setStyleSize(this._paneHeaderR, 'left', newCanvasWidthL); - } - - this.applyColumnHeaderWidths(); - if (this._options.syncColumnCellResize) { - this.applyColumnWidths(); - } - this.updateCanvasWidth(); - - if ( - this._options.autoScrollOnColumnResize && - !this._options.rtl && - !this._options.forceFitColumns && - !(this.hasFrozenColumns() && i <= this._options.frozenColumn!) - ) { - const columnRight = this.columnPosRight[i]; - const previousScrollLeft = this._viewportScrollContainerX.scrollLeft; - const viewportWidth = this._viewportScrollContainerX.clientWidth; - const isLastVisibleColumn = i === vc.length - 1; - - if (isLastVisibleColumn) { - const maxScrollLeft = Math.max(0, this._viewportScrollContainerX.scrollWidth - viewportWidth); - this.scrollToX(maxScrollLeft); - } else if (columnRight > previousScrollLeft + viewportWidth) { - this.scrollToX(columnRight - viewportWidth); - } - resizeAutoScrollDeltaX += this._viewportScrollContainerX.scrollLeft - previousScrollLeft; - } - this.trigger(this.onColumnsDrag, { - triggeredByColumn: resizeElements.resizeableElement, - resizeHandle: resizeElements.resizeableHandleElement - }); - }; - applyColumnResize(targetPageX + resizeAutoScrollDeltaX, resizeElms); - }, - onResizeEnd: (_e, resizeElms) => { - stopColumnResizeAutoScroll(); - resizeAutoScrollDeltaX = 0; - resizeElms.resizeableElement.classList.remove('slick-header-column-active'); - - const triggeredByColumn = resizeElms.resizeableElement.id.replace(this.uid, ''); - if (this.trigger(this.onBeforeColumnsResize, { triggeredByColumn }).getReturnValue() === true) { - this.applyColumnHeaderWidths(); - } - let newWidth; - for (j = 0; j < vc.length; j++) { - c = vc[j]; - if (!c || c.hidden) { continue; } - newWidth = children[j].offsetWidth; - - if (c.previousWidth !== newWidth && c.rerenderOnResize) { - this.invalidateAllRows(); } } this.updateCanvasWidth(true); - if (this._options.autoHeaderHeight) { this.recalculateHeaderHeight(); } else { this.render(); } - + this.scrollToX(this._viewportScrollContainerX.scrollLeft); this.trigger(this.onColumnsResized, { triggeredByColumn }); - window.clearTimeout(this._columnResizeTimer); - this._columnResizeTimer = window.setTimeout(() => { this.columnResizeDragging = false; }, 300); - } + clearTimeout(this._columnResizeTimer); + this._columnResizeTimer = setTimeout(() => (this.columnResizeDragging = false), this._options.columnResizingDelay); + }, }) ); } } - /** - * Validates and sets the frozenColumn option (ensuring it is within valid bounds, or setting it to –1) - * and, if a frozenRow is specified (greater than –1), sets the grid’s frozen–row flags, - * computes the frozenRowsHeight (based on rowHeight), and determines the actual frozen row index - * depending on whether frozenBottom is enabled. - */ - 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; - - if (this._options.frozenRow! > 0) { - this.hasFrozenRows = true; - const dataLength = this.getDataLength(); - this.actualFrozenRow = (this._options.frozenBottom) - ? (dataLength - this._options.frozenRow!) - : this._options.frozenRow!; - this.frozenRowsHeight = this.computeFrozenRowsHeight(dataLength); - } else { - this.hasFrozenRows = false; - } - } - - /** - * Computes the combined pixel height of the frozen rows: the first `frozenRow` rows, or the - * last `frozenRow` rows (i.e. from `actualFrozenRow` onward) when `frozenBottom` is enabled. - * - * @param {number} dataLength - The current dataset length. - * @returns {number} The combined frozen rows height in pixels. - */ - protected computeFrozenRowsHeight(dataLength: number) { - return this._options.frozenBottom - ? this.getRowPosition(dataLength) - this.getRowPosition(this.actualFrozenRow) - : this.getRowPosition(this._options.frozenRow!); - } - - ////////////////////////////////////////////////////////////////////////////////////////////// // Column Management - Autosizing ////////////////////////////////////////////////////////////////////////////////////////////// @@ -3010,6 +3017,7 @@ export class SlickGrid = Column, O e let maxLen = 0; let maxColWidth = 0; autoSize.headerWidthPx = 0; + if (!autoSize.ignoreHeaderText) { autoSize.headerWidthPx = this.getColHeaderWidth(columnDef); } @@ -3377,12 +3385,12 @@ export class SlickGrid = Column, O e } /** - * Returns an array of column definitions filtered to exclude any that are marked as hidden. - * - * @returns - */ - getVisibleColumns() { - return this.columns.filter(c => !c.hidden); + * Returns an array of column definitions filtered to exclude any that are marked as hidden. + * + * @returns + */ + getVisibleColumns(): C[] { + return this.columns.filter((column) => !!column && !column.hidden); } /** Returns the index of a visible column by its id. */ @@ -3393,15 +3401,19 @@ export class SlickGrid = Column, O e /** Returns the column object by its id. */ getColumnById(id: number | string): C | null { const index = this.getColumnIndex(id); - return Utils.isDefined(index) ? this.columns[index] : null; + if (Utils.isDefined(index)) { + return this.columns[index]; + } + return null; } /** Updates column properties by id and optionally refreshes the grid columns. */ updateColumnById(columnId: number | string, props: Partial, forceColumnUpdate = false): void { const column = this.getColumnById(columnId); - if (column) { + if (Utils.isDefined(column)) { Object.assign(column, props); } + if (forceColumnUpdate) { this.updateColumns(); } @@ -3422,91 +3434,104 @@ export class SlickGrid = Column, O e * * @returns */ - protected applyColumnHeaderWidths() { - if (!this.initialized) { - 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; + protected applyColumnHeaderWidths(): void { + if (this.initialized) { + const vc = this.getVisibleColumns(); + const headers = this.usesDockingChromeRegions() + ? this.getHeaderChildren() + : (this._headers.flatMap((header) => Array.from(header.children)) as HTMLElement[]); + headers.forEach((h, columnIndex) => { const col = vc[columnIndex] || {}; const width = (col.width || 0) - this.headerColumnWidthDiff; if (Utils.width(h) !== width) { Utils.width(h, width); } - } - }); + }); - this.updateColumnCaches(); + this.updateColumnCaches(); + } } /** * Iterates over all columns (skipping hidden ones) and, for each, retrieves the associated CSS rules * (using getColumnCssRules). It then sets the left and right CSS properties so that the columns align - * correctly within the grid canvas. It also updates the cumulative offset for non–frozen columns. + * correctly within the grid canvas. It also updates the cumulative offset for center-band columns. */ - protected applyColumnWidths() { - let x = 0; - let w = 0; + protected applyColumnWidths(): void { let rule: any; + const renderedWidths = this.getDockingRenderedWidths(); + const centerWidth = this.hasDockedColumns() ? renderedWidths.center : this.dockingLayout.centerWidth; for (let i = 0; i < this.columns.length; i++) { if (this.columns[i]) { - w = this.columns[i].hidden ? 0 : this.columns[i].width || 0; + const w = this.columns[i].hidden ? 0 : this.columns[i].width || 0; + const docked = this.dockingByColumn.get(i); + const useNaturalCenterPosition = this.usesStickyColumnTransformPath() && !this.columns[i].pinned; + const x = useNaturalCenterPosition ? docked?.naturalOffset || 0 : (this.columnPosLeft[i] ?? docked?.offset ?? 0); + const rightEdge = x + w; + const bandWidth = + useNaturalCenterPosition || docked?.band === 'center' + ? centerWidth + : docked?.band === 'left' + ? renderedWidths.left + : renderedWidths.right; rule = this.getColumnCssRules(i); - if (this._options.rtl) { - rule.left.style.right = `${x}px`; - rule.right.style.left = (((this._options.frozenColumn !== -1 && i > this._options.frozenColumn!) ? this.canvasWidthR : this.canvasWidthL) - x - w) + 'px'; + if (rule.left) { + rule.left.style.right = `${x}px`; + } + if (rule.right) { + rule.right.style.left = `${bandWidth - rightEdge}px`; + } } else { - 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'; - } - - // If this column is frozen, reset the css left value since the - // column starts in a new viewport. - if (this._options.frozenColumn !== i && !this.columns[i].hidden) { - x += this.columns[i].width!; + if (rule.left) { + rule.left.style.left = `${x}px`; + } + if (rule.right) { + rule.right.style.right = `${bandWidth - rightEdge}px`; + } } } - if (this._options.frozenColumn === i) { - x = 0; - } } + + this.updateRenderedColspanFragmentGeometry(); } /** - * A convenience method that creates a sort configuration for one column (with the given sort direction) - * and calls setSortColumns with it. Accepts a columnId string and an ascending boolean. - * Applies a sort glyph in either ascending or descending form to the header of the column. - * Note that this does not actually sort the column. It only adds the sort glyph to the header. - * - * @param {String | Number} columnId - * @param {Boolean} ascending - */ - setSortColumn(columnId: number | string, ascending: boolean) { + * A convenience method that creates a sort configuration for one column (with the given sort direction) + * and calls setSortColumns with it. Accepts a columnId string and an ascending boolean. + * Applies a sort glyph in either ascending or descending form to the header of the column. + * Note that this does not actually sort the column. It only adds the sort glyph to the header. + * + * @param {String | Number} columnId + * @param {Boolean} ascending + */ + setSortColumn(columnId: number | string, ascending: boolean): void { this.setSortColumns([{ columnId, sortAsc: ascending }]); } /** - * Get column by index - iterates over header containers and returns the header column - * element corresponding to the given index. - * - * @param {Number} id - column index - * @returns - */ - getColumnByIndex(id: number) { + * Get column by index - iterates over header containers and returns the header column + * element corresponding to the given index. + * + * @param {Number} id - column index + * @returns + */ + getColumnByIndex(idx: number): HTMLElement | undefined { + if (this.usesDockingChromeRegions()) { + const column = this.getVisibleColumns()[idx]; + return Array.from(this._headerL.querySelectorAll('.slick-header-column')).find( + (element) => (element as HTMLElement).dataset.id === String(column?.id) + ) as HTMLElement | undefined; + } let result: HTMLElement | undefined; this._headers.every((header) => { const length = header.children.length; - if (id < length) { - result = header.children[id] as HTMLElement; + if (idx < length) { + result = header.children[idx] as HTMLElement; return false; } - id -= length; + idx -= length; return true; }); @@ -3514,32 +3539,27 @@ export class SlickGrid = Column, O e } /** - * Accepts an array of objects in the form [ { columnId: [string], sortAsc: [boolean] }, ... ] to - * define the grid's sort order. When called, this will apply a sort glyph in either ascending - * or descending form to the header of each column specified in the array. - * Note that this does not actually sort the column. It only adds the sort glyph to the header. - * - * @param {ColumnSort[]} cols - column sort - */ - setSortColumns(cols: ColumnSort[]) { + * Accepts an array of objects in the form [ { columnId: [string], sortAsc: [boolean] }, ... ] to + * define the grid's sort order. When called, this will apply a sort glyph in either ascending + * or descending form to the header of each column specified in the array. + * Note that this does not actually sort the column. It only adds the sort glyph to the header. + * + * @param {ColumnSort[]} cols - column sort + */ + setSortColumns(cols: ColumnSort[]): void { 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'); - }); + // v8 ignore next + 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.forEach((indicator) => indicator.classList.remove('slick-sort-indicator-asc', 'slick-sort-indicator-desc')); + indicators = header.querySelectorAll('.slick-sort-indicator-numbered'); - indicators.forEach((el) => { - el.textContent = ''; - }); + indicators.forEach((el) => (el.textContent = '')); }); let i = 1; @@ -3557,7 +3577,7 @@ export class SlickGrid = Column, O e indicator?.classList.add(col.sortAsc ? 'slick-sort-indicator-asc' : 'slick-sort-indicator-desc'); if (numberCols) { - indicator = column.querySelector('.slick-sort-indicator-numbered'); + indicator = column.querySelector('.slick-sort-indicator-numbered') as HTMLElement; if (indicator) { indicator.textContent = String(i); } @@ -3569,7 +3589,7 @@ export class SlickGrid = Column, O e } /** Returns the current array of column definitions. */ - getColumns() { + getColumns(): C[] { return this.columns; } @@ -3580,25 +3600,11 @@ export class SlickGrid = Column, O e /** * Iterates over all columns to compute and store their left and right boundaries - * (based on cumulative widths). Resets the offset when a frozen column is encountered. + * (based on cumulative widths). Rebuilds the corresponding docking and visibility caches. */ - protected updateColumnCaches() { - // Pre-calculate cell boundaries. - this.columnPosLeft = []; - this.columnPosRight = []; - let x = 0; - for (let i = 0, ii = this.columns.length; i < ii; i++) { - if (!this.columns[i]) { continue; } - - this.columnPosLeft[i] = x; - this.columnPosRight[i] = x + (this.columns[i].hidden ? 0 : this.columns[i].width || 0); - - if (this._options.frozenColumn === i) { - x = 0; - } else { - x += this.columns[i].hidden ? 0 : this.columns[i].width || 0; - } - } + protected updateColumnCaches(): void { + this.refreshDockingLayout(); + this.updateColumnPositionCaches(); } /** @@ -3607,18 +3613,21 @@ export class SlickGrid = Column, O e * and its autoSize property, (c) update the columnsById mapping, and (d) adjust * the width if it is less than minWidth or greater than maxWidth. */ - protected updateColumnProps() { + protected updateColumnProps(): void { this.columnsById = Object.create(null); this.visibleColumnsById = Object.create(null); + for (let i = 0; i < this.columns.length; i++) { - let m: C = this.columns[i]; + let m: C = this.columns[i] || {}; if (m.width) { m.widthRequest = m.width; } if (this._options.mixinDefaults) { Utils.applyDefaults(m, this._columnDefaults); - if (!m.autoSize) { m.autoSize = {}; } + if (!m.autoSize) { + m.autoSize = {}; + } Utils.applyDefaults(m.autoSize, this._columnAutosizeDefaults); } else { m = this.columns[i] = Utils.extend({}, this._columnDefaults, m); @@ -3626,50 +3635,82 @@ export class SlickGrid = Column, O e } this.columnsById[m.id] = i; - if (m.minWidth && ((m.width || 0) < m.minWidth)) { + if (m.minWidth && (m.width || 0) < m.minWidth) { m.width = m.minWidth; } - if (m.maxWidth && ((m.width || 0) > m.maxWidth)) { + if (m.maxWidth && (m.width || 0) > m.maxWidth) { m.width = m.maxWidth; } } + // also update visible columns this.getVisibleColumns().forEach((col, idx) => { this.visibleColumnsById[col.id] = idx; }); + this.refreshDockingLayout(); } /** - * Sets grid columns. Column headers will be recreated and all rendered rows will be removed. - * To rerender the grid (if necessary), call render(). - * @param {Column[]} columnDefinitions An array of column definitions. + * Sets grid columns. Column headers will be recreated and all rendered rows will be removed. To rerender the grid (if necessary), call render(). + * @param {Column[]} newColumns An array of column definitions. + * @param {boolean} [waitNextCycle=false] - should we wait for a microtask cycle before updating column headers */ - setColumns(columnDefinitions: C[]) { - this.trigger(this.onBeforeSetColumns, { previousColumns: this.columns, newColumns: columnDefinitions, grid: this }); - this.columns = columnDefinitions; - this.updateColumns(); - this.trigger(this.onAfterSetColumns, { newColumns: columnDefinitions, grid: this }); + setColumns(newColumns: C[], waitNextCycle = false): boolean { + // Validate the prospective pinning on a copy so a rejected request leaves the caller's column + // definitions untouched and fires no events. + const shouldValidateProspectivePinning = this.hasConfiguredColumnDocking() || newColumns.some((column) => !!column?.pinned || !!column?.sticky); + if (shouldValidateProspectivePinning) { + const prospectiveColumns = newColumns.map((column) => (column ? { ...column } : column)); + if (!this.validateColumnPinning(undefined, true, prospectiveColumns)) { + return false; + } + } + this.applyColumnPinningOptions(newColumns); + this.trigger(this.onBeforeSetColumns, { previousColumns: this.columns, newColumns, grid: this }); + this.dockingController.reset(); + this.columns = newColumns; + this._container.setAttribute('aria-colcount', this.columns.length.toString()); + const updateCols = () => { + this.updateColumns(); + this.trigger(this.onAfterSetColumns, { newColumns, grid: this }); + }; + waitNextCycle ? queueMicrotaskPolyfill(() => updateCols()) : updateCols(); + return true; } /** Update columns for when a hidden property has changed but the column list itself has not changed. */ - updateColumns() { + updateColumns(): void { this.trigger(this.onBeforeUpdateColumns, { columns: this.columns, grid: this }); this.updateColumnsInternal(); this.trigger(this.onAfterUpdateColumns, { columns: this.columns, grid: this }); } /** - * Triggers onBeforeUpdateColumns and calls updateColumnsInternal to update column properties, + * Triggers onBeforeUpdateColumns and calls updateColumnProps to update column properties, * caches, header/footer elements, CSS rules, canvas dimensions, and selections without changing the column array. */ - protected updateColumnsInternal() { + protected updateColumnsInternal(): void { this.updateColumnProps(); + // updateColumns() is also reached directly (for example from the Column Picker); + // re-apply the declarative pinning so rebuilding the headers keeps the pinned flags. + this.applyColumnPinningOptions(this.columns); this.updateColumnCaches(); if (this.initialized) { - this.setPaneFrozenClasses(); - this.setPaneVisibility(); + // Materialize the docking scrollbar lazily when pinning/sticky state is + // introduced after initialization, while preserving the legacy + // viewport scroll owner for ordinary grids. + if (this.hasConfiguredDocking() && !this.hasDockingHorizontalScroller()) { + this.activateSingleViewportLayout(); + this.setScroller(); + this._bindingEventService.bind( + this._dockingHorizontalScroller!, + 'scroll', + this.handleScroll.bind(this), + {}, + 'docking-horizontal-scroll' + ); + } this.setOverflow(); - this.invalidateAllRows(); this.createColumnHeaders(); this.createColumnFooter(); @@ -3677,33 +3718,22 @@ export class SlickGrid = Column, O e this.createCssRules(); this.resizeCanvas(); this.updateCanvasWidth(); - this.applyColumnHeaderWidths(); this.applyColumnWidths(); - if (this._options.autoHeaderHeight) { this.recalculateHeaderHeight(); } - this.handleScroll(); this.getSelectionModel()?.refreshSelections(); } } - ///////////////////////////////////////////////////////////////////// - /// End Column Management - ///////////////////////////////////////////////////////////////////// - - ///////////////////////////////////////////////////////////////////// - /// Data Management and Editing - ///////////////////////////////////////////////////////////////////// - /** Get Editor lock */ getEditorLock() { return this._options.editorLock as SlickEditorLock; } /** Get Editor Controller */ - getEditController() { + getEditController(): EditController | undefined { return this.editController; } @@ -3712,7 +3742,7 @@ export class SlickGrid = Column, O e * @param {CustomDataView|Array<*>} newData New databinding source using a regular JavaScript array.. or a custom object exposing getItem(index) and getLength() functions. * @param {Number} [scrollToTop] If true, the grid will reset the vertical scroll position to the top of the grid. */ - setData(newData: CustomDataView | TData[], scrollToTop?: boolean) { + setData(newData: CustomDataView | TData[], scrollToTop?: boolean): void { this.data = newData; this.invalidateAllRows(); this.updateRowCount(); @@ -3721,18 +3751,12 @@ export class SlickGrid = Column, O e } } - /** Returns an array of every data object, unless you're using DataView in which case it returns a DataView object. */ - getData | U[]>(): U { - return this.data as U; - } - /** Returns the size of the databinding source. */ - getDataLength() { + getDataLength(): number { if ((this.data as CustomDataView).getLength) { return (this.data as CustomDataView).getLength(); - } else { - return (this.data as TData[])?.length ?? 0; } + return (this.data as TData[])?.length || 0; } /** @@ -3740,10 +3764,8 @@ export class SlickGrid = Column, O e * * @returns */ - protected getDataLengthIncludingAddNew() { - return this.getDataLength() + (!this._options.enableAddRow ? 0 - : (!this.pagingActive || this.pagingIsLastPage ? 1 : 0) - ); + protected getDataLengthIncludingAddNew(): number { + return this.getDataLength() + (!this._options.enableAddRow ? 0 : !this.pagingActive || this.pagingIsLastPage ? 1 : 0); } /** @@ -3753,36 +3775,36 @@ export class SlickGrid = Column, O e getDataItem(i: number): TData { if ((this.data as CustomDataView).getItem) { return (this.data as CustomDataView).getItem(i) as TData; - } else { - return (this.data as TData[])[i] as TData; } + return (this.data as TData[])[i] as TData; } /** - * Returns the value of a single field for a given row index. - * - * When the databinding source is a `CustomDataView` that implements the optional - * `getCellValue(index, field)` accessor, that method is used directly. This allows - * column-oriented (or otherwise non row-materializing) data sources to return a - * single cell value without first having to build a full row object via `getItem()`, - * which can be expensive when called repeatedly (e.g. during column content auto-sizing). - * - * Falls back to `getDataItem(i)[field]` for plain arrays or data sources that don't - * implement `getCellValue`. - * - * @param {Number} i Item row index. - * @param {String} field Column field name. - */ + * Returns the value of a single field for a given row index. + * + * When the databinding source is a `CustomDataView` that implements the optional + * `getCellValue(index, field)` accessor, that method is used directly. This allows + * column-oriented (or otherwise non row-materializing) data sources to return a + * single cell value without first having to build a full row object via `getItem()`, + * which can be expensive when called repeatedly (e.g. during column content auto-sizing). + * + * Falls back to `getDataItem(i)[field]` for plain arrays or data sources that don't + * implement `getCellValue`. + * + * @param {Number} i Item row index. + * @param {String} field Column field name. + */ getCellValue(i: number, field: string): TData[keyof TData] { - const customDataView = this.data as CustomDataView; - if (customDataView.getCellValue) { - return customDataView.getCellValue(i, field) as TData[keyof TData]; + const item = this.getDataItem(i) as TData; + if (Array.isArray(this.data)) { + return item?.[field as keyof TData] as TData[keyof TData]; } - return this.getDataItem(i)[field as keyof TData]; + const dataView = this.data as CustomDataView; + return (dataView.getCellValue ? dataView.getCellValue(i, field) : item?.[field as keyof TData]) as TData[keyof TData]; } - /** Are we using a DataView? */ - hasDataView() { + /** Are we using a DataView? */ + hasDataView(): boolean { return !Array.isArray(this.data); } @@ -3807,13 +3829,12 @@ export class SlickGrid = Column, O e const rowMetadata = (this.data as CustomDataView)?.getItemMetadata?.(row); // look up by id, then index - const columnOverrides = rowMetadata?.columns && - (rowMetadata.columns[column.id] || rowMetadata.columns[this.getColumnIndex(column.id)]); + const columnOverrides = rowMetadata?.columns && (rowMetadata.columns[column.id] || rowMetadata.columns[this.getColumnIndex(column.id)]); - return ((columnOverrides?.formatter) || - (rowMetadata?.formatter) || + return (columnOverrides?.formatter || + rowMetadata?.formatter || column.formatter || - (this._options.formatterFactory?.getFormatter(column)) || + this._options.formatterFactory?.getFormatter(column) || this._options.defaultFormatter) as Formatter; } @@ -3836,8 +3857,10 @@ export class SlickGrid = Column, O e if (columnMetadata?.[cell]?.editor !== undefined) { return columnMetadata[cell].editor; } - - return (column.editor || (this._options?.editorFactory?.getEditor(column))); + if (column.editor !== undefined) { + return column.editor; + } + return this._options?.editorFactory?.getEditor(column); } /** @@ -3848,38 +3871,41 @@ export class SlickGrid = Column, O e * @param {C} columnDef - The column definition containing the field key. * @returns {*} - The extracted value from the data item based on the column definition. */ - protected getDataItemValueForColumn(item: TData, columnDef: C) { + protected getDataItemValueForColumn(item: TData, columnDef: C): TData | TData[keyof TData] { if (this._options.dataItemColumnValueExtractor) { return this._options.dataItemColumnValueExtractor(item, columnDef) as TData; } return item[columnDef.field as keyof TData]; } - ////////////////////////////////////////////////////////////////////////////////////////////// - // Data Management and Editing - Cell Switchers - //////////////////////////////////////////////////////////////////// + // Cell switching /** Resets active cell by making cell normal and other internal reset. */ - resetActiveCell() { + resetActiveCell(): void { this.setActiveCellInternal(null, false); } /** Clear active cell by making cell normal & removing "active" CSS class. */ - unsetActiveCell() { + unsetActiveCell(): void { if (Utils.isDefined(this.activeCellNode)) { + const activeRow = this.activeRow; + const activeCell = this.getCellFromNode(this.activeCellNode); this.makeActiveCellNormal(); this.activeCellNode.classList.remove('active'); + if (isDefinedNumber(activeRow)) { + this.toggleCellSpanFragmentsActive(activeRow, activeCell, false); + } this.rowsCache[this.activeRow]?.rowNode?.forEach((node) => node.classList.remove('active')); } } /** @alias `setFocus` */ - focus() { + focus(): void { this.setFocus(); } - // Sets focus to one of the hidden focus sink elements based on the current tabbing direction. - protected setFocus() { + /** Restores focus to the appropriate hidden grid focus sink. */ + protected setFocus(): void { if (this.tabbingDirection === -1) { this._focusSink.focus(); } else { @@ -3901,7 +3927,13 @@ export class SlickGrid = Column, O e * @param {boolean} [suppressActiveCellChangedEvent] - If `true`, prevents triggering `onActiveCellChanged` event. * @param {Event | SlickEvent_} [e] - The event that triggered the cell activation (if applicable). */ - protected setActiveCellInternal(newCell: HTMLDivElement | null, opt_editMode?: boolean | null, preClickModeOn?: boolean | null, suppressActiveCellChangedEvent?: boolean, e?: Event | SlickEvent_) { + protected setActiveCellInternal( + newCell: HTMLDivElement | null, + opt_editMode?: boolean | null, + preClickModeOn?: boolean | null, + suppressActiveCellChangedEvent?: boolean, + e?: Event | SlickEvent_ + ): void { // make current active cell as normal cell & remove "active" CSS classes this.unsetActiveCell(); @@ -3909,34 +3941,45 @@ export class SlickGrid = Column, O e this.activeCellNode = newCell; 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.getFrozenRowOffset(this.actualFrozenRow); + const rowNode = this.activeCellNode.closest('.slick-row') as HTMLElement | null; + const rowFromDockedNode = rowNode?.dataset.row !== undefined ? Number(rowNode.dataset.row) : NaN; + const hasRenderedRowIndex = Number.isInteger(rowFromDockedNode); + + if (hasRenderedRowIndex) { + // The row DOM is the source of truth after row docking shifts or + // reparenting. Geometric conversion from a canvas position can map a + // non-contiguous pinned row to the wrong logical index. + this.activeRow = this.activePosY = rowFromDockedNode; + this.activeCell = this.activePosX = this.getCellFromNode(this.activeCellNode); + } else { + const activeCellOffset = Utils.offset(this.activeCellNode); + const activeCanvas = Utils.parents(this.activeCellNode, '.grid-canvas')[0] as HTMLElement; + const canvasOffset = Utils.offset(activeCanvas); + const rowOffset = Math.floor(canvasOffset!.top); + const cell = this.getCellFromPoint(activeCellOffset!.left - canvasOffset!.left, Math.ceil(activeCellOffset!.top) - rowOffset); + this.activeRow = this.activePosY = cell.row; + this.activeCell = this.activePosX = this.getCellFromNode(this.activeCellNode); } - const cell = this.getCellFromPoint(activeCellOffset!.left, Math.ceil(activeCellOffset!.top) - rowOffset); - this.activeRow = cell.row; - this.activePosY = cell.row; - this.activeCell = this.activePosX = this.getCellFromNode(this.activeCellNode); - if (!Utils.isDefined(opt_editMode) && this._options.autoEditNewRow) { - opt_editMode = (this.activeRow === this.getDataLength()) || this._options.autoEdit; + opt_editMode = this.activeRow === this.getDataLength() || this._options.autoEdit; } if (this._options.showCellSelection) { // make sure to never activate more than 1 cell at a time + // v8 ignore next document.querySelectorAll('.slick-cell.active').forEach((node) => node.classList.remove('active')); this.activeCellNode.classList.add('active'); + if (isDefinedNumber(this.activeRow) && isDefinedNumber(this.activeCell)) { + this.toggleCellSpanFragmentsActive(this.activeRow, this.activeCell, true); + } this.rowsCache[this.activeRow]?.rowNode?.forEach((node) => node.classList.add('active')); } if (opt_editMode && this.isCellEditable(this.activeRow, this.activeCell)) { if (this._options.asyncEditorLoading) { - window.clearTimeout(this.h_editorLoader); - this.h_editorLoader = window.setTimeout(() => { + clearTimeout(this.h_editorLoader); + this.h_editorLoader = setTimeout(() => { this.makeActiveCellEditable(undefined, preClickModeOn, e); }, this._options.asyncEditorLoadDelay); } else { @@ -3950,7 +3993,10 @@ export class SlickGrid = Column, O e // this optimisation causes trouble - MLeibman #329 // if (activeCellChanged) { if (!suppressActiveCellChangedEvent) { - this.trigger(this.onActiveCellChanged, this.getActiveCell() as OnActiveCellChangedEventArgs); + this.trigger( + this.onActiveCellChanged, + this.getActiveCell() as OnActiveCellChangedEventArgs + ); } // } } @@ -3961,22 +4007,22 @@ export class SlickGrid = Column, O e } /** - * Checks whether data for the row is loaded, whether the cell is in an “Add New” row - * (and the column disallows insert triggering), and whether an editor exists and the cell is not hidden. - * Returns true if the cell is editable. - * - * @param {number} row - The row index of the cell. - * @param {number} cell - The cell index (column index) within the row. - * @returns {boolean} - Returns `true` if the cell is editable, otherwise `false`. - */ - protected isCellPotentiallyEditable(row: number, cell: number) { + * Checks whether data for the row is loaded, whether the cell is in an “Add New” row + * (and the column disallows insert triggering), and whether an editor exists and the cell is not hidden. + * Returns true if the cell is editable. + * + * @param {number} row - The row index of the cell. + * @param {number} cell - The cell index (column index) within the row. + * @returns {boolean} - Returns `true` if the cell is editable, otherwise `false`. + */ + protected isCellPotentiallyEditable(row: number, cell: number): boolean { const dataLength = this.getDataLength(); - // is the data for this row loaded? + // is the data for this row actually loaded? if (row < dataLength && !this.getDataItem(row)) { return false; } - // are we in the Add New row? Can we create new from this cell? + // are we in the Add New row? Can we actually and allowed to create new one from this cell? if (this.columns[cell].cannotTriggerInsert && row >= dataLength) { return false; } @@ -3990,55 +4036,54 @@ export class SlickGrid = Column, O e } /** - * Make the cell normal again (for example after destroying cell editor), - * we can also optionally refocus on the current active cell (again possibly after closing cell editor) - * - * If an editor is active, triggers onBeforeCellEditorDestroy and calls the editor’s destroy method. - * It then removes “editable” and “invalid” CSS classes from the active cell, re–applies the formatter - * to restore the cell’s original content, invalidates any post–processing results, - * and deactivates the editor lock. Optionally, it can also re–focus the grid. - * In IE, it clears any text selection to ensure focus is properly reset. - * - * @param {Boolean} [refocusActiveCell] - */ - protected makeActiveCellNormal(refocusActiveCell = false) { - if (!this.currentEditor) { - return; - } - this.trigger(this.onBeforeCellEditorDestroy, { editor: this.currentEditor }); - this.currentEditor.destroy(); - this.currentEditor = null; - - if (this.activeCellNode) { - const d = this.getDataItem(this.activeRow); - this.activeCellNode.classList.remove('editable'); - this.activeCellNode.classList.remove('invalid'); - if (d) { - const column = this.columns[this.activeCell]; - const formatter = this.getFormatter(this.activeRow, column); - const formatterResult = formatter(this.activeRow, this.activeCell, this.getDataItemValueForColumn(d, column), column, d, this as unknown as SlickGridModel); - this.applyFormatResultToCellNode(formatterResult, this.activeCellNode); - this.invalidatePostProcessingResults(this.activeRow); - } - if (refocusActiveCell) { - this.setFocus(); + * Make the cell normal again (for example after destroying cell editor), + * we can also optionally refocus on the current active cell (again possibly after closing cell editor) + * + * If an editor is active, triggers onBeforeCellEditorDestroy and calls the editor’s destroy method. + * It then removes “editable” and “invalid” CSS classes from the active cell, re–applies the formatter + * to restore the cell’s original content, invalidates any post–processing results, + * and deactivates the editor lock. Optionally, it can also re–focus the grid. + * In IE, it clears any text selection to ensure focus is properly reset. + * + * @param {Boolean} [refocusActiveCell] + */ + protected makeActiveCellNormal(refocusActiveCell = false): void { + if (this.currentEditor) { + this.trigger(this.onBeforeCellEditorDestroy, { editor: this.currentEditor }); + this.currentEditor.destroy(); + this.currentEditor = null; + + if (this.activeCellNode) { + const d = this.getDataItem(this.activeRow); + this.activeCellNode.classList.remove('editable', 'invalid'); + if (d) { + const column = this.columns[this.activeCell]; + const formatter = this.getFormatter(this.activeRow, column); + const formatterResult = formatter( + this.activeRow, + this.activeCell, + this.getDataItemValueForColumn(d, column), + column, + d, + this as unknown as SlickGrid + ); + this.applyFormatResultToCellNode(formatterResult, this.activeCellNode); + this.invalidatePostProcessingResults(this.activeRow); + } + if (refocusActiveCell) { + this.setFocus(); + } } - } - // if there previously was text selected on a page (such as selected text in the edit cell just removed), - // IE can't set focus to anything else correctly - if (navigator.userAgent.toLowerCase().match(/msie/)) { - this.clearTextSelection(); + this.getEditorLock()?.deactivate(this.editController as EditController); } - - this.getEditorLock()?.deactivate(this.editController as EditController); } /** * A public method that starts editing on the active cell by calling * makeActiveCellEditable with the provided editor, pre–click flag, and event. */ - editActiveCell(editor?: EditorConstructor, preClickModeOn?: boolean | null, e?: Event) { + editActiveCell(editor?: Editor | EditorConstructor, preClickModeOn?: boolean | null, e?: Event): void { this.makeActiveCellEditable(editor, preClickModeOn, e); } @@ -4051,16 +4096,16 @@ export class SlickGrid = Column, O e * * @throws {Error} If called when the grid is not editable. */ - protected makeActiveCellEditable(editor?: EditorConstructor, preClickModeOn?: boolean | null, e?: Event | SlickEvent_) { + protected makeActiveCellEditable(editor?: Editor | EditorConstructor, preClickModeOn?: boolean | null, e?: Event | SlickEvent_): void { if (!this.activeCellNode) { return; } if (!this._options.editable) { - throw new Error('SlickGrid makeActiveCellEditable : should never get called when this._options.editable is false'); + throw new Error('SlickGrid makeActiveCellEditable : should never get called when grid options.editable is false'); } // cancel pending async call if there is one - window.clearTimeout(this.h_editorLoader); + clearTimeout(this.h_editorLoader); if (!this.isCellPotentiallyEditable(this.activeRow, this.activeCell)) { return; @@ -4069,7 +4114,15 @@ export class SlickGrid = Column, O e const columnDef = this.columns[this.activeCell]; const item = this.getDataItem(this.activeRow); - if (this.trigger(this.onBeforeEditCell, { row: this.activeRow, cell: this.activeCell, item, column: columnDef, target: 'grid' }).getReturnValue() === false) { + if ( + this.trigger(this.onBeforeEditCell, { + row: this.activeRow, + cell: this.activeCell, + item, + column: columnDef, + target: 'grid', + }).getReturnValue() === false + ) { this.setFocus(); return; } @@ -4078,48 +4131,45 @@ export class SlickGrid = Column, O e this.activeCellNode.classList.add('editable'); const useEditor = editor || this.getEditor(this.activeRow, this.activeCell); + // editor was null and columnMetadata and editorFactory returned null or undefined // the editor must be constructable. Also makes sure that useEditor is of type EditorConstructor - if (!useEditor || typeof useEditor !== 'function') { - return; - } - - // don't clear the cell if a custom editor is passed through - if (!editor && !useEditor.suppressClearOnEdit) { - Utils.emptyElement(this.activeCellNode); - } - - let metadata = this.getItemMetadaWhenExists(this.activeRow); - metadata = metadata?.columns as any; - const columnMetaData = metadata && (metadata[columnDef.id as keyof ItemMetadata] || (metadata as any)[this.activeCell]); - - - const editorArgs: EditorArguments = { - grid: this, - gridPosition: this.absBox(this._container), - position: this.absBox(this.activeCellNode), - container: this.activeCellNode, - column: columnDef, - columnMetaData, - item: item || {}, - event: e as Event, - commitChanges: this.commitEditAndSetFocus.bind(this), - cancelChanges: this.cancelEditAndSetFocus.bind(this) - }; - - this.currentEditor = new useEditor(editorArgs); + if (typeof useEditor === 'function') { + // don't clear the cell if a custom editor is passed through + if (!editor && !useEditor.suppressClearOnEdit) { + Utils.emptyElement(this.activeCellNode); + } + + let metadata = this.getItemMetadaWhenExists(this.activeRow); + metadata = metadata?.columns as any; + const columnMetaData = metadata && (metadata[columnDef.id as keyof ItemMetadata] || (metadata as any)[this.activeCell]); + + const editorArgs: EditorArguments = { + grid: this as any, + gridPosition: this.absBox(this._container), + position: this.absBox(this.activeCellNode), + container: this.activeCellNode, + column: columnDef, + columnMetaData, + item: item || {}, + event: e as Event, + commitChanges: this.commitEditAndSetFocus.bind(this), + cancelChanges: this.cancelEditAndSetFocus.bind(this), + }; + this.currentEditor = new useEditor(editorArgs); - if (item && this.currentEditor) { - this.currentEditor.loadValue(item); - if (preClickModeOn && this.currentEditor?.preClick) { - this.currentEditor.preClick(); + if (item && this.currentEditor) { + this.currentEditor.loadValue(item); + if (preClickModeOn && typeof this.currentEditor?.preClick === 'function') { + this.currentEditor.preClick(); + } } - } - this.serializedEditorValue = this.currentEditor?.serializeValue(); + this.serializedEditorValue = this.currentEditor?.serializeValue(); - if (this.currentEditor?.position) { - this.handleActiveCellPositionChange(); + if (this.currentEditor?.position) { + this.handleActiveCellPositionChange(); + } } } @@ -4127,12 +4177,12 @@ export class SlickGrid = Column, O e * Commits the current edit and sets focus back to the grid. * If the commit fails due to validation, the focus remains in the editor. */ - protected commitEditAndSetFocus() { + protected commitEditAndSetFocus(navigateCellDown = true): void { // if the commit fails, it would do so due to a validation error // if so, do not steal the focus from the editor if (this.getEditorLock()?.commitCurrentEdit()) { this.setFocus(); - if (this._options.autoEdit && !this._options.autoCommitEdit) { + if (this._options.autoEdit && !this._options.autoCommitEdit && navigateCellDown) { this.navigateDown(); } } @@ -4141,7 +4191,7 @@ export class SlickGrid = Column, O e /** * Cancels the current edit and restores focus to the grid. */ - protected cancelEditAndSetFocus() { + protected cancelEditAndSetFocus(): void { if (this.getEditorLock()?.cancelCurrentEdit()) { this.setFocus(); } @@ -4155,19 +4205,17 @@ export class SlickGrid = Column, O e * * @returns {boolean} Whether the edit was successfully committed. */ - protected commitCurrentEdit() { + protected commitCurrentEdit(): boolean { const self = this as SlickGrid; const item = self.getDataItem(self.activeRow); const column = self.columns[self.activeCell]; if (self.currentEditor) { if (self.currentEditor.isValueChanged()) { - const validationResults = self.currentEditor.validate( - undefined, { + const validationResults = self.currentEditor.validate(undefined, { rowIndex: self.activeRow, - cellIndex: self.activeCell - } - ); + cellIndex: self.activeCell, + }); if (validationResults.valid) { const row = self.activeRow; @@ -4177,6 +4225,7 @@ export class SlickGrid = Column, O e const prevSerializedValue = self.serializedEditorValue; if (self.activeRow < self.getDataLength()) { + // editing existing item found const editCommand = { row, cell, @@ -4191,8 +4240,8 @@ export class SlickGrid = Column, O e undo: () => { editor.applyValue(item, prevSerializedValue); self.updateRow(row); - self.trigger(self.onCellChange, { command: 'undo', row, cell, item, column, }); - } + self.trigger(self.onCellChange, { command: 'undo', row, cell, item, column }); + }, }; if (self._options.editCommandHandler) { @@ -4202,8 +4251,8 @@ export class SlickGrid = Column, O e editCommand.execute(); self.makeActiveCellNormal(true); } - } else { + // editing new item to add to dataset const newItem = {}; self.currentEditor.applyValue(newItem, self.currentEditor.serializeValue()); self.makeActiveCellNormal(true); @@ -4213,10 +4262,10 @@ export class SlickGrid = Column, O e // check whether the lock has been re-acquired by event handlers return !self.getEditorLock()?.isActive(); } else { - // Re-add the CSS class to trigger transitions, if any. + // invalid editing: Re-add the CSS class to trigger transitions, if any. if (self.activeCellNode) { self.activeCellNode.classList.remove('invalid'); - Utils.width(self.activeCellNode);// force layout + Utils.width(self.activeCellNode); // force layout self.activeCellNode.classList.add('invalid'); } @@ -4226,7 +4275,7 @@ export class SlickGrid = Column, O e validationResults, row: self.activeRow, cell: self.activeCell, - column + column, }); self.currentEditor.focus(); @@ -4240,17 +4289,17 @@ export class SlickGrid = Column, O e } /** - * Cancels the current edit and restores the cell to normal mode. - * - * @returns {boolean} Always returns true. - */ - protected cancelCurrentEdit() { + * Cancels the current edit and restores the cell to normal mode. + * + * @returns {boolean} Always returns true. + */ + protected cancelCurrentEdit(): boolean { this.makeActiveCellNormal(); return true; } /** Returns an array of row indices corresponding to the currently selected rows. */ - getSelectedRows() { + getSelectedRows(): number[] { if (!this.selectionModel) { throw new Error('SlickGrid Selection model is not set'); } @@ -4262,43 +4311,24 @@ export class SlickGrid = Column, O e * @param {Array} rowsArray - an array of row numbers. * @param {String} [caller] - an optional string to identify who called the method */ - setSelectedRows(rows: number[], caller?: string) { + setSelectedRows(rows: number[], caller?: string): void { if (!this.selectionModel) { throw new Error('SlickGrid Selection model is not set'); } - if (this && this.getEditorLock && !this.getEditorLock()?.isActive()) { + + const elock = this.getEditorLock(); + if (typeof elock?.isActive === 'function' && !elock.isActive()) { this.selectionModel.setSelectedRanges(this.rowsToRanges(rows, caller === 'click.selectAll'), caller || 'SlickGrid.setSelectedRows'); } } - /////////////////////////////////////////////////////////////////////////// - // Event Handling and Interactivity - ///////////////////////////////////////////////////////////////////////// - - /** - * A generic helper that creates (or uses) a SlickEventData from the provided event, - * attaches the grid instance to the event arguments, and calls notify on the given event. - * Returns the result of the notification. - * - * @param {SlickEvent_} evt - The Slick event instance to trigger. - * @param {ArgType} [args] - Optional arguments to pass with the event. - * @param {Event | SlickEventData_} [e] - The original event object or SlickEventData. - * @returns {*} - The result of the event notification. - */ - protected trigger(evt: SlickEvent_, args?: ArgType, e?: Event | SlickEventData_) { - const event: SlickEventData_ = (e || new SlickEventData(e, args)) as SlickEventData_; - const eventArgs = (args || {}) as ArgType & { grid: SlickGrid; }; - eventArgs.grid = this; - return evt.notify(eventArgs, event, this); - } - /** * Handles the mouseout event for a cell. * Triggers the `onMouseLeave` event. * * @param {MouseEvent & { target: HTMLElement }} e - The mouse event. */ - protected handleCellMouseOut(e: MouseEvent & { target: HTMLElement; }) { + protected handleCellMouseOut(e: MouseEvent & { target: HTMLElement }): void { this.trigger(this.onMouseLeave, {}, e); } @@ -4308,8 +4338,8 @@ export class SlickGrid = Column, O e * * @param {Event | SlickEventData_} e - The mouse event. */ - protected handleHeaderMouseHoverOn(e: Event | SlickEventData_) { - (e as any)?.target.classList.add('ui-state-hover', 'slick-state-hover'); + protected handleHeaderMouseHoverOn(e: Event | SlickEventData_): void { + (e as any)?.target.classList.add('slick-state-hover'); } /** @@ -4318,10 +4348,11 @@ export class SlickGrid = Column, O e * * @param {Event | SlickEventData_} e - The mouse event. */ - protected handleHeaderMouseHoverOff(e: Event | SlickEventData_) { - (e as any)?.target.classList.remove('ui-state-hover', 'slick-state-hover'); + protected handleHeaderMouseHoverOff(e: Event | SlickEventData_): void { + (e as any)?.target.classList.remove('slick-state-hover'); } + /** Returns whether the drag handle should be displayed for the supplied column. */ protected getDragHandleVisibility(): boolean | 'hover' { return this.getSelectionModel()?.getOptions()?.showDragHandle ?? true; } @@ -4335,13 +4366,14 @@ export class SlickGrid = Column, O e * @param {SlickEventData_} e - The Slick event data for selection changes. * @param {SlickRange_[]} ranges - The list of selected row and cell ranges. */ - protected handleSelectedRangesChanged(e: SlickEventData_, ranges: SlickRange_[]) { + protected handleSelectedRangesChanged(e: SlickEventData_, ranges: SlickRange_[]): void { const ne = e.getNativeEvent(); const selectionMode = ne?.detail?.selectionMode ?? ''; const caller = ne?.detail?.caller ?? 'click'; const isBulkSelection = caller === 'click.selectAll' || caller === 'click.unselectAll'; let addDragHandle = !!ne?.detail?.addDragHandle; const selectedCellCssClass = this._options.selectedCellCssClass || ''; + const selectionType = this.getSelectionModel()?.getOptions()?.selectionType; const showDragHandle = this.getDragHandleVisibility(); addDragHandle = selectionType === 'cell' || selectionType === 'mixed'; @@ -4382,11 +4414,9 @@ export class SlickGrid = Column, O e } const previousSelectedRows = this.selectedRows.slice(0); // shallow copy previously selected rows for later comparison - this.selectionBottomRow = -1; this.selectionRightCell = -1; this.dragReplaceEl.removeEl(); - this.selectedRows = []; const hash: CssStyleHash = Object.create(null); const selectedRowsSet = ranges.length > 1 ? new Set() : undefined; @@ -4409,21 +4439,21 @@ export class SlickGrid = Column, O e } } + if (!isBulkSelection || !rangesAreOrdered) { + // Preserve the legacy default sort order (numeric values are compared as strings). + this.selectedRows.sort(); + } + const activeRange = ranges[ranges.length - 1]; if (activeRange) { this.selectionBottomRow = activeRange.toRow; this.selectionRightCell = activeRange.toCell; } - if (!isBulkSelection || !rangesAreOrdered) { - // Preserve the legacy default sort order (numeric values are compared as strings). - this.selectedRows.sort(); - } - this.setCellCssStyles(selectedCellCssClass, hash); if (this.selectionBottomRow >= 0 && this.selectionRightCell >= 0 && addDragHandle && showDragHandle !== false) { - const lowerRightCell = this.getCellNode(this.selectionBottomRow, this.selectionRightCell) + const lowerRightCell = this.getCellNode(this.selectionBottomRow, this.selectionRightCell); this.dragReplaceEl.createEl(lowerRightCell, showDragHandle); } @@ -4439,43 +4469,54 @@ export class SlickGrid = Column, O e const newSelectedAdditions = previousSelectedRowsSet ? selectedRows.filter((i) => !previousSelectedRowsSet.has(i)) : selectedRows; const newSelectedDeletions = selectedRowsSet ? previousSelectedRows.filter((i) => !selectedRowsSet.has(i)) : previousSelectedRows; - this.trigger(this.onSelectedRowsChanged, { - rows: selectedRows, - previousSelectedRows, - caller, - changedSelectedRows: newSelectedAdditions, - changedUnselectedRows: newSelectedDeletions - }, e); + this.trigger( + this.onSelectedRowsChanged, + { + rows: selectedRows, + previousSelectedRows, + caller, + changedSelectedRows: newSelectedAdditions, + changedUnselectedRows: newSelectedDeletions, + }, + e + ); } } /** * Processes a mouse wheel event by adjusting the vertical scroll (scrollTop) based on deltaY (scaled by rowHeight) * and horizontal scroll (scrollLeft) based on deltaX. It then calls the internal scroll handler with the “mousewheel” - * type and, if any scrolling occurred, stops propagation. For frozen columns it also prevents the - * browser's default scrolling so the scrolling pane does not move ahead of the mirrored frozen pane. + * type and, if any scrolling occurred, stops propagation. When a docking scroller is active it also prevents the + * browser's default scrolling so the shared viewport and docked regions remain synchronized. * * @param {MouseEvent} e - The mouse event. * @param {number} _delta - Unused delta value. * @param {number} deltaX - The horizontal scroll delta. * @param {number} deltaY - The vertical scroll delta. */ - protected handleMouseWheel(e: MouseEvent, _delta: number, deltaX: number, deltaY: number) { + protected handleMouseWheel(e: MouseEvent, _delta: number, deltaX: number, deltaY: number): void { + const hasDocking = this.usesDockingRowRegions(); this.scrollHeight = this._viewportScrollContainerY.scrollHeight; - if (e.shiftKey) { - this.scrollLeft = this._viewportScrollContainerX.scrollLeft + (deltaX * 10); - } else { - this.scrollTop = Math.max(0, this._viewportScrollContainerY.scrollTop - (deltaY * this.getRowHeight())); - this.scrollLeft = this._viewportScrollContainerX.scrollLeft + (deltaX * 10); - } + const wheelEvent = e as WheelEvent; + const lineSize = Math.max(40, this._options.rowHeight!); + const nativeDelta = wheelEvent.deltaX || (e.shiftKey ? wheelEvent.deltaY : 0); + const deltaModeFactor = wheelEvent.deltaMode === 1 ? lineSize : wheelEvent.deltaMode === 2 ? this.viewportW : 1; + const horizontalDelta = nativeDelta ? nativeDelta * deltaModeFactor : (deltaX || (e.shiftKey ? -deltaY : 0)) * lineSize; + if (!e.shiftKey) { + this.scrollTop = Math.max(0, this._viewportScrollContainerY.scrollTop - deltaY * this._options.rowHeight!); + } + const nextScrollLeft = this._viewportScrollContainerX.scrollLeft + horizontalDelta; + // Chromium/WebKit expose RTL horizontal offsets as negative values. Keep + // that native coordinate system intact; LTR still gets the normal lower + // bound so wheel input cannot move the docking offsets below zero. + this.scrollLeft = this._options.rtl ? nextScrollLeft : Math.max(0, nextScrollLeft); const handled = this._handleScroll('mousewheel'); if (handled) { e.stopPropagation(); - // Frozen columns use a second viewport whose vertical position is mirrored - // from the scrolling pane. Letting the browser also process this wheel event - // advances the source pane a second time, briefly putting it ahead of the - // frozen viewport until its subsequent scroll event is handled. - if (this.hasFrozenColumns()) { + // Ordinary grids retain the browser's native wheel delta behaviour. A + // docking grid must prevent the native event from moving a second scroll + // owner after this handler synchronizes its bands. + if (hasDocking) { e.preventDefault(); } } @@ -4490,7 +4531,7 @@ export class SlickGrid = Column, O e * @param {DragPosition} dd - The drag position data. * @returns {boolean} - Whether the drag is valid or should be cancelled. */ - protected handleDragInit(e: DragEvent, dd: DragPosition) { + protected handleDragInit(e: DragEvent, dd: DragPosition): boolean { const cell = this.getCellFromEvent(e); if (!cell || !this.cellExists(cell.row, cell.cell)) { return false; @@ -4515,7 +4556,7 @@ export class SlickGrid = Column, O e * @param {DragPosition} dd - The current drag position. * @returns {boolean} - The result of the event trigger or false if propagation was not stopped. */ - protected handleDragStart(e: DragEvent, dd: DragPosition) { + protected handleDragStart(e: DragEvent, dd: DragPosition): boolean { const cell = this.getCellFromEvent(e); if (!cell || !this.cellExists(cell.row, cell.cell)) { return false; @@ -4533,139 +4574,35 @@ export class SlickGrid = Column, O e return false; } - // Triggers the onDrag event with the current drag position and event, and returns the event’s return value. - protected handleDrag(e: DragEvent, dd: DragPosition) { + /** Publishes the grid drag event for an in-progress drag operation. */ + protected handleDrag(e: DragEvent, dd: DragPosition): void { return this.trigger(this.onDrag, dd, e).getReturnValue(); } - // Called when a drag operation completes; it triggers the onDragEnd event with the current drag position and event. - protected handleDragEnd(e: DragEvent, dd: DragPosition) { + /** Publishes the grid drag-end event after a drag operation completes. */ + protected handleDragEnd(e: DragEvent, dd: DragPosition): void { this.trigger(this.onDragEnd, dd, e); } /** - * Handles keydown events for grid navigation and editing. - * It triggers the `onKeyDown` event and, based on the key pressed (such as HOME, END, arrow keys, PAGE_UP/DOWN, TAB, ENTER, ESC), - * calls the appropriate navigation or editing method. If the key event is handled, - * it stops propagation and prevents the default browser behaviour. + * Handles a click event on the grid. It logs the event (for debugging), ensures focus is restored if necessary, + * triggers the onClick event, and if the clicked cell is selectable and not already active, scrolls it into view + * and activates it. * - * @param {KeyboardEvent & { originalEvent: Event; }} e - The keydown event, with the original event attached. + * @param {DOMEvent | SlickEventData_} evt - The click event, either a native DOM event or a Slick event. */ - protected handleKeyDown(e: KeyboardEvent & { originalEvent: Event; }) { - const retval = this.trigger(this.onKeyDown, { row: this.activeRow, cell: this.activeCell }, e); - let handled: boolean | undefined | void = retval.isImmediatePropagationStopped(); + protected handleClick(evt: DOMEvent | SlickEventData_): void { + const e = evt instanceof SlickEventData ? evt.getNativeEvent() : evt; - if (!handled) { - if (!e.shiftKey && !e.altKey) { - if (this._options.editable && this.currentEditor?.keyCaptureList) { - if (this.currentEditor.keyCaptureList.indexOf(e.which) > -1) { - return; - } - } - if (e.ctrlKey && e.key === 'Home') { - this.navigateTopStart(); - } else if (e.ctrlKey && e.key === 'End') { - this.navigateBottomEnd(); - } else if (e.ctrlKey && e.key === 'ArrowUp') { - this.navigateTop(); - } else if (e.ctrlKey && e.key === 'ArrowDown') { - this.navigateBottom(); - } else if ((e.ctrlKey && e.key === 'ArrowLeft') || (!e.ctrlKey && e.key === 'Home')) { - this.navigateRowStart(); - } else if ((e.ctrlKey && e.key === 'ArrowRight') || (!e.ctrlKey && e.key === 'End')) { - this.navigateRowEnd(); - } - } - } - if (!handled) { - if (!e.shiftKey && !e.altKey && !e.ctrlKey) { - // editor may specify an array of keys to bubble - if (this._options.editable && this.currentEditor?.keyCaptureList) { - if (this.currentEditor.keyCaptureList.indexOf(e.which) > -1) { - return; - } - } - if (e.which === keyCode.ESCAPE) { - if (!this.getEditorLock()?.isActive()) { - return; // no editing mode to cancel, allow bubbling and default processing (exit without cancelling the event) - } - this.cancelEditAndSetFocus(); - } else if (e.which === keyCode.PAGE_DOWN) { - this.navigatePageDown(); - handled = true; - } else if (e.which === keyCode.PAGE_UP) { - this.navigatePageUp(); - handled = true; - } else if (e.which === keyCode.LEFT) { - handled = this.navigateLeft(); - } else if (e.which === keyCode.RIGHT) { - handled = this.navigateRight(); - } else if (e.which === keyCode.UP) { - handled = this.navigateUp(); - } else if (e.which === keyCode.DOWN) { - handled = this.navigateDown(); - } else if (e.which === keyCode.TAB) { - handled = this.navigateNext(); - } else if (e.which === keyCode.ENTER) { - if (this._options.editable) { - if (this.currentEditor) { - // adding new row - if (this.activeRow === this.getDataLength()) { - this.navigateDown(); - } else { - this.commitEditAndSetFocus(); - } - } else { - if (this.getEditorLock()?.commitCurrentEdit()) { - this.makeActiveCellEditable(undefined, undefined, e); - } - } - } - handled = true; - } - } else if (e.which === keyCode.TAB && e.shiftKey && !e.ctrlKey && !e.altKey) { - handled = this.navigatePrev(); - } - } - - const cell = this.getActiveCell(); - const isChar = /^[\p{L}\p{N}\p{P}\p{S}\s]$/u.test(e.key); // make sure it's a character being typed - if (!handled && this._options.autoEditByKeypress && cell && isChar && this.isCellEditable(cell.row, cell.cell) && !this.currentEditor) { - this.makeActiveCellEditable(undefined, false, e); - } - - if (handled) { - // the event has been handled so don't let parent element (bubbling/propagation) or browser (default) handle it - e.stopPropagation(); - e.preventDefault(); - try { - (e as any).originalEvent.keyCode = 0; // prevent default behaviour for special keys in IE browsers (F3, F5, etc.) - } - // ignore exceptions - setting the original event's keycode throws access denied exception for "Ctrl" - // (hitting control key only, nothing else), "Shift" (maybe others) - // eslint-disable-next-line no-empty - catch (error) { } - } - } - - /** - * Handles a click event on the grid. It logs the event (for debugging), ensures focus is restored if necessary, - * triggers the onClick event, and if the clicked cell is selectable and not already active, scrolls it into view - * and activates it. - * - * @param {DOMEvent | SlickEventData_} evt - The click event, either a native DOM event or a Slick event. - */ - protected handleClick(evt: DOMEvent | SlickEventData_) { - const e = evt instanceof SlickEventData ? evt.getNativeEvent() : evt; - - if (!this.currentEditor) { - // if this click resulted in some cell child node getting focus, - // don't steal it back - keyboard events will still bubble up - // IE9+ seems to default DIVs to tabIndex=0 instead of -1, so check for cell clicks directly. - if ((e as DOMEvent).target !== document.activeElement || (e as DOMEvent).target.classList.contains('slick-cell')) { - const selection = this.getTextSelection(); // store text-selection and restore it after - this.setFocus(); - this.setTextSelection(selection as Range); + if (!this.currentEditor) { + // if this click resulted in some cell child node getting focus, + // don't steal it back - keyboard events will still bubble up + // IE9+ seems to default DIVs to tabIndex=0 instead of -1, so check for cell clicks directly. + // prettier-ignore + if ((e as DOMEvent).target !== document.activeElement || (e as DOMEvent).target.classList.contains('slick-cell')) { + const selection = this.getTextSelection(); // store text-selection and restore it after + this.setFocus(); + this.setTextSelection(selection as Range); } } @@ -4675,7 +4612,7 @@ export class SlickGrid = Column, O e } evt = this.trigger(this.onClick, { row: cell.row, cell: cell.cell }, evt || e); - if ((evt as any).isImmediatePropagationStopped()) { + if ((evt as SlickEventData_).isImmediatePropagationStopped()) { return; } @@ -4687,8 +4624,18 @@ export class SlickGrid = Column, O e const preClickModeOn = !!(e as DOMEvent).target?.classList?.contains(preClickClassName); const column = this.columns[cell.cell]; - const suppressActiveCellChangedEvent = !!(this._options.editable && column?.editor && this._options.suppressActiveCellChangeOnEdit); - this.setActiveCellInternal(this.getCellNode(cell.row, cell.cell), null, preClickModeOn, suppressActiveCellChangedEvent, (e as DOMEvent)); + const suppressActiveCellChangedEvent = !!( + this._options.editable && + column?.editor && + this._options.suppressActiveCellChangeOnEdit + ); + this.setActiveCellInternal( + this.getCellNode(cell.row, cell.cell), + null, + preClickModeOn, + suppressActiveCellChangedEvent, + e as DOMEvent + ); } } } @@ -4697,18 +4644,16 @@ export class SlickGrid = Column, O e * Retrieves the cell DOM element from the event target. * If the cell exists and is not currently being edited, triggers the onContextMenu event. */ - protected handleContextMenu(e: Event & { target: HTMLElement; }) { - const cell = e.target.closest('.slick-cell'); - if (!cell) { - return; - } - - // are we editing this cell? - if (this.activeCellNode === cell && this.currentEditor !== null) { + protected handleContextMenu(e: Event & { target: HTMLElement }): void { + // cancel context menu if we have an inline editor opened + const cellElm = e.target.closest('.slick-cell'); + if (this.activeCellNode === cellElm && this.currentEditor !== null) { return; } - this.trigger(this.onContextMenu, {}, e); + // get the cell position or return {-1,-1} when opening from the grid but but not over a grid cell (e.g. empty dataset) + const cell = this.getCellFromEvent(e) ?? { cell: -1, row: -1 }; + this.trigger(this.onContextMenu, { row: cell.row, cell: cell.cell }, e); } /** @@ -4716,7 +4661,7 @@ export class SlickGrid = Column, O e * If the event is not prevented and the grid is editable, * it initiates cell editing by calling gotoCell with edit mode enabled. */ - protected handleDblClick(e: MouseEvent) { + protected handleDblClick(e: MouseEvent): void { const cell = this.getCellFromEvent(e); if (!cell || (this.currentEditor !== null && this.activeRow === cell.row && this.activeCell === cell.cell)) { return; @@ -4736,65 +4681,49 @@ export class SlickGrid = Column, O e * When the mouse enters a header column element, retrieves the column definition from the element’s * stored data and triggers the onHeaderMouseEnter event with the column and grid reference. */ - protected handleHeaderMouseEnter(e: MouseEvent & { target: HTMLElement; }) { - const c = Utils.storage.get(e.target.closest('.slick-header-column'), 'column'); - if (!c) { - return; + protected handleHeaderMouseEnter(e: MouseEvent & { target: HTMLElement }): void { + const column = Utils.storage.get(e.target.closest('.slick-header-column'), 'column'); + if (column) { + this.trigger(this.onHeaderMouseEnter, { column, grid: this }, e); } - this.trigger(this.onHeaderMouseEnter, { - column: c, - grid: this - }, e); } /** * Similar to handleHeaderMouseEnter, but triggers the onHeaderMouseLeave event * when the mouse leaves a header column element. */ - protected handleHeaderMouseLeave(e: MouseEvent & { target: HTMLElement; }) { - const c = Utils.storage.get(e.target.closest('.slick-header-column'), 'column'); - if (!c) { - return; + protected handleHeaderMouseLeave(e: MouseEvent & { target: HTMLElement }): void { + const column = Utils.storage.get(e.target.closest('.slick-header-column'), 'column'); + if (column) { + this.trigger(this.onHeaderMouseLeave, { column, grid: this }, e); } - this.trigger(this.onHeaderMouseLeave, { - column: c, - grid: this - }, e); } /** * Retrieves the column from the header row cell element and triggers the onHeaderRowMouseEnter event. */ - protected handleHeaderRowMouseEnter(e: MouseEvent & { target: HTMLElement; }) { - const c = Utils.storage.get(e.target.closest('.slick-headerrow-column'), 'column'); - if (!c) { - return; + protected handleHeaderRowMouseEnter(e: MouseEvent & { target: HTMLElement }): void { + const column = Utils.storage.get(e.target.closest('.slick-headerrow-column'), 'column'); + if (column) { + this.trigger(this.onHeaderRowMouseEnter, { column, grid: this }, e); } - this.trigger(this.onHeaderRowMouseEnter, { - column: c, - grid: this - }, e); } /** * Retrieves the column from the header row cell element and triggers the onHeaderRowMouseLeave event. */ - protected handleHeaderRowMouseLeave(e: MouseEvent & { target: HTMLElement; }) { - const c = Utils.storage.get(e.target.closest('.slick-headerrow-column'), 'column'); - if (!c) { - return; + protected handleHeaderRowMouseLeave(e: MouseEvent & { target: HTMLElement }): void { + const column = Utils.storage.get(e.target.closest('.slick-headerrow-column'), 'column'); + if (column) { + this.trigger(this.onHeaderRowMouseLeave, { column, grid: this }, e); } - this.trigger(this.onHeaderRowMouseLeave, { - column: c, - grid: this - }, e); } /** * Retrieves the header column element and its associated column definition, * then triggers the onHeaderContextMenu event with the column data. */ - protected handleHeaderContextMenu(e: MouseEvent & { target: HTMLElement; }) { + protected handleHeaderContextMenu(e: MouseEvent & { target: HTMLElement }): void { const header = e.target.closest('.slick-header-column'); const column = header && Utils.storage.get(header, 'column'); this.trigger(this.onHeaderContextMenu, { column }, e); @@ -4803,7 +4732,7 @@ export class SlickGrid = Column, O e /** * If not in the middle of a column resize, retrieves the header column element and its column definition, then triggers the onHeaderClick event. */ - protected handleHeaderClick(e: MouseEvent & { target: HTMLElement; }) { + protected handleHeaderClick(e: MouseEvent & { target: HTMLElement }): void { if (!this.columnResizeDragging) { const header = e.target.closest('.slick-header-column'); const column = header && Utils.storage.get(header, 'column'); @@ -4816,14 +4745,14 @@ export class SlickGrid = Column, O e /** * Triggers the onPreHeaderContextMenu event with the event target (typically the pre–header panel). */ - protected handlePreHeaderContextMenu(e: MouseEvent & { target: HTMLElement; }) { + protected handlePreHeaderContextMenu(e: MouseEvent & { target: HTMLElement }): void { this.trigger(this.onPreHeaderContextMenu, { node: e.target }, e); } /** * If not resizing columns, triggers the onPreHeaderClick event with the event target. */ - protected handlePreHeaderClick(e: MouseEvent & { target: HTMLElement; }) { + protected handlePreHeaderClick(e: MouseEvent & { target: HTMLElement }): void { if (!this.columnResizeDragging) { this.trigger(this.onPreHeaderClick, { node: e.target }, e); } @@ -4832,7 +4761,7 @@ export class SlickGrid = Column, O e /** * Retrieves the footer cell element and its column definition, then triggers the onFooterContextMenu event. */ - protected handleFooterContextMenu(e: MouseEvent & { target: HTMLElement; }) { + protected handleFooterContextMenu(e: MouseEvent & { target: HTMLElement }): void { const footer = e.target.closest('.slick-footerrow-column'); const column = footer && Utils.storage.get(footer, 'column'); this.trigger(this.onFooterContextMenu, { column }, e); @@ -4841,7 +4770,7 @@ export class SlickGrid = Column, O e /** * Retrieves the footer cell element and its column definition, then triggers the onFooterClick event. */ - protected handleFooterClick(e: MouseEvent & { target: HTMLElement; }) { + protected handleFooterClick(e: MouseEvent & { target: HTMLElement }): void { const footer = e.target.closest('.slick-footerrow-column'); const column = footer && Utils.storage.get(footer, 'column'); this.trigger(this.onFooterClick, { column }, e); @@ -4850,7 +4779,10 @@ export class SlickGrid = Column, O e /** * Triggers the onMouseEnter event when the mouse pointer enters a cell element. */ - protected handleCellMouseOver(e: MouseEvent & { target: HTMLElement; }) { + protected handleCellMouseOver(e: MouseEvent & { target: HTMLElement }): void { + if (!e.target?.closest('.slick-cell')) { + return; + } this.trigger(this.onMouseEnter, {}, e); } @@ -4858,25 +4790,23 @@ export class SlickGrid = Column, O e * Handles the change in the position of the active cell. * Triggers the `onActiveCellPositionChanged` event and adjusts the editor visibility and positioning. */ - protected handleActiveCellPositionChange() { - if (!this.activeCellNode) { - return; - } - - this.trigger(this.onActiveCellPositionChanged, {}); + protected handleActiveCellPositionChange(): void { + if (this.activeCellNode) { + this.trigger(this.onActiveCellPositionChanged, {}); - if (this.currentEditor) { - const cellBox = this.getActiveCellPosition(); - if (this.currentEditor.show && this.currentEditor.hide) { - if (!cellBox.visible) { - this.currentEditor.hide(); - } else { - this.currentEditor.show(); + if (this.currentEditor) { + const cellBox = this.getActiveCellPosition(); + if (this.currentEditor.show && this.currentEditor.hide) { + if (!cellBox.visible) { + this.currentEditor.hide(); + } else { + this.currentEditor.show(); + } } - } - if (this.currentEditor.position) { - this.currentEditor.position(cellBox); + if (this.currentEditor.position) { + this.currentEditor.position(cellBox); + } } } } @@ -4886,7 +4816,7 @@ export class SlickGrid = Column, O e * call enqueue to execute the action - it will execute either immediately or, if it was executed less than minPeriod_ms in the past, as soon as minPeriod_ms has expired. * call dequeue to cancel any pending action. */ - protected actionThrottle(action: () => void, minPeriod_ms: number) { + protected actionThrottle(action: () => void, minPeriod_ms: number): { enqueue: () => void; dequeue: () => void } { let blocked = false; let queued = false; @@ -4898,18 +4828,17 @@ export class SlickGrid = Column, O e } }; - const dequeue = () => { - queued = false; - }; + const dequeue = () => (queued = false); const blockAndExecute = () => { blocked = true; - window.clearTimeout(this._executionBlockTimer); - this._executionBlockTimer = window.setTimeout(unblock, minPeriod_ms); + clearTimeout(this._executionBlockTimer); + this._executionBlockTimer = setTimeout(unblock, minPeriod_ms); action.call(this); }; const unblock = () => { + /* v8 ignore if */ if (queued) { dequeue(); blockAndExecute(); @@ -4920,85 +4849,68 @@ export class SlickGrid = Column, O e return { enqueue: enqueue.bind(this), - dequeue: dequeue.bind(this) + dequeue: dequeue.bind(this), }; } /** - * Returns a hash containing row and cell indexes from a standard W3C event. - * @param {*} event A standard W3C event. - */ - getCellFromEvent(evt: Event | SlickEventData_) { + * Returns a hash containing row and cell indexes from a standard W3C event. + * @param {*} event A standard W3C event. + */ + getCellFromEvent(evt: Event | SlickEventData_): { row: number; cell: number } | null { const e = evt instanceof SlickEventData ? evt.getNativeEvent() : evt; - const targetEvent: any = (e as TouchEvent).touches ? (e as TouchEvent).touches[0] : e; - - const cellNode = (e as Event & { target: HTMLElement }).target.closest('.slick-cell'); - if (!cellNode) { + if (!e) { return null; } - let row = this.getRowFromNode(cellNode.parentNode as HTMLElement); - - if (this.hasFrozenRows) { - let rowOffset = 0; - const c = Utils.offset(Utils.parents(cellNode, '.grid-canvas')[0] as HTMLElement); - const isBottom = Utils.parents(cellNode, '.grid-canvas-bottom').length; - - if (isBottom) { - rowOffset = this.getFrozenRowOffset(this.actualFrozenRow); - } + const cellNode = (e as Event & { target?: HTMLElement }).target?.closest('.slick-cell'); - row = this.getCellFromPoint(targetEvent.clientX - c!.left, targetEvent.clientY - c!.top + rowOffset + document.documentElement.scrollTop).row; + if (!cellNode) { + return null; } + let row = this.getRowFromNode(cellNode.closest('.slick-row') as HTMLElement); + const cell = this.getCellFromNode(cellNode as HTMLElement); - if (!Utils.isDefined(row) || !Utils.isDefined(cell)) { + if (!isDefinedNumber(row) || !isDefinedNumber(cell)) { return null; } return { row, cell }; } - ///////////////////////////////////////////////////////////////////// - // End Event Management and Interactivity - /////////////////////////////////////////////////////////////////// - - ////////////////////////////////////////////////////////////////////// - // Rendering and Layout Management - /////////////////////////////////////////////////////////////////// - /** - * Apply HTML code by 3 different ways depending on what is provided as input and what options are enabled. - * 1. value is an HTMLElement or DocumentFragment, then first empty the target and simply append the HTML to the target element. - * 2. value is string and `enableHtmlRendering` is enabled, then use `target.innerHTML = value;` - * 3. value is string and `enableHtmlRendering` is disabled, then use `target.textContent = value;` - * @param {HTMLElement} target - target element to apply to - * @param {string | HTMLElement | DocumentFragment} val - input value can be either a string or an HTMLElement - * @param {{ emptyTarget?: boolean; skipEmptyReassignment?: boolean; }} [options] - - * `emptyTarget`, defaults to true, will empty the target. - * `skipEmptyReassignment`, defaults to true, when enabled it will not try to reapply an empty value when the target is already empty - */ - applyHtmlCode(target: HTMLElement, val: boolean | string | HTMLElement | DocumentFragment = '', options?: { emptyTarget?: boolean; skipEmptyReassignment?: boolean; }) { + * Apply HTML code by 3 different ways depending on what is provided as input and what options are enabled. + * 1. value is an HTMLElement or DocumentFragment, then first empty the target and simply append the HTML to the target element. + * 2. value is string and `enableHtmlRendering` is enabled, then use `target.innerHTML = value;` + * 3. value is string and `enableHtmlRendering` is disabled, then use `target.textContent = value;` + * @param {HTMLElement} target - target element to apply to + * @param {string | HTMLElement | DocumentFragment} val - input value can be either a string or an HTMLElement + * @param {{ emptyTarget?: boolean; skipEmptyReassignment?: boolean; }} [options] - + * `emptyTarget`, defaults to true, will empty the target. + * `skipEmptyReassignment`, defaults to true, when enabled it will not try to reapply an empty value when the target is already empty + */ + applyHtmlCode( + target: HTMLElement, + val: boolean | string | HTMLElement | DocumentFragment | null | undefined = '', + options?: { emptyTarget?: boolean; skipEmptyReassignment?: boolean } + ): void { if (target) { if (val instanceof HTMLElement || val instanceof DocumentFragment) { // first empty target and then append new HTML element - const emptyTarget = options?.emptyTarget !== false; - if (emptyTarget) { + if (options?.emptyTarget !== false) { Utils.emptyElement(target); } target.appendChild(val); } else { // when it's already empty and we try to reassign empty, it's probably ok to skip the assignment - const skipEmptyReassignment = options?.skipEmptyReassignment !== false; - if (skipEmptyReassignment && !Utils.isDefined(val) && !target.innerHTML) { + if (options?.skipEmptyReassignment !== false && !Utils.isDefined(val) && !target.innerHTML) { return; } - if (typeof val === 'number' || typeof val === 'boolean') { target.textContent = String(val); } else { const sanitizedText = this.sanitizeHtmlString(val as string); - // apply HTML when enableHtmlRendering is enabled but make sure we do have a value (without a value, it will simply use `textContent` to clear text content) if (this._options.enableHtmlRendering && sanitizedText) { target.innerHTML = sanitizedText; @@ -5011,12 +4923,12 @@ export class SlickGrid = Column, O e } /** Get Grid Canvas Node DOM Element */ - getCanvasNode(columnIdOrIdx?: number | string, rowIndex?: number) { - return this._getContainerElement(this.getCanvases(), columnIdOrIdx, rowIndex) as HTMLDivElement; + getCanvasNode(_columnIdOrIdx?: number | string, _rowIndex?: number): HTMLDivElement { + return this._canvasNode; } /** Get the canvas DOM element */ - getActiveCanvasNode(e?: Event | SlickEventData_) { + getActiveCanvasNode(e?: Event | SlickEventData_): HTMLDivElement { if (e === undefined) { return this._activeCanvasNode; } @@ -5025,22 +4937,25 @@ export class SlickGrid = Column, O e e = e.getNativeEvent(); } - this._activeCanvasNode = (e as any)?.target.closest('.grid-canvas'); + this._activeCanvasNode = + ((e as Event & { target: HTMLElement })?.target?.closest('.grid-canvas') as HTMLDivElement | null) || + this._activeCanvasNode || + this._canvasNode; return this._activeCanvasNode; } /** Get the canvas DOM element */ - getCanvases() { + getCanvases(): HTMLDivElement[] { return this._canvas; } /** Get the Viewport DOM node element */ - getViewportNode(columnIdOrIdx?: number | string, rowIndex?: number) { - return this._getContainerElement(this.getViewports(), columnIdOrIdx, rowIndex); + getViewportNode(_columnIdOrIdx?: number | string, _rowIndex?: number): HTMLElement | undefined { + return this._viewportNode; } /** Get all the Viewport node elements */ - getViewports() { + getViewports(): HTMLDivElement[] { return this._viewport; } @@ -5051,43 +4966,46 @@ export class SlickGrid = Column, O e * @param e * @returns */ - getActiveViewportNode(e: Event | SlickEventData_) { + getActiveViewportNode(e: Event | SlickEventData_): HTMLDivElement { this.setActiveViewportNode(e); return this._activeViewportNode; } /** - * Sets an active viewport node - * - * @param {number | string} [columnIdOrIdx] - The column identifier or index. - * @param {number} [rowIndex] - The row index. - * @returns {HTMLElement} The corresponding viewport element. - */ - setActiveViewportNode(e: Event | SlickEventData_) { + * Sets an active viewport node + * + * @param {number | string} [columnIdOrIdx] - The column identifier or index. + * @param {number} [rowIndex] - The row index. + * @returns {HTMLElement} The corresponding viewport element. + */ + setActiveViewportNode(e: Event | SlickEventData_): HTMLDivElement { if (e instanceof SlickEventData) { e = e.getNativeEvent(); } - this._activeViewportNode = (e as any)?.target.closest('.slick-viewport'); + this._activeViewportNode = + ((e as Event & { target: HTMLDivElement })?.target?.closest('.slick-viewport') as HTMLDivElement | null) || + this._activeViewportNode || + this._viewportNode; return this._activeViewportNode; } /** Get the headers width in pixel - * - * Iterates over all columns to accumulate the widths for the left and right header sections, - * adds scrollbar width if needed, and adjusts for frozen columns. - * Returns the computed overall header width in pixels. + * + * Iterates over all columns to accumulate the widths for the left and right docking sections, + * adds scrollbar width if needed, and adjusts for docked columns. + * Returns the computed overall header width in pixels. */ - getHeadersWidth() { + getHeadersWidth(): number { this.headersWidth = this.headersWidthL = this.headersWidthR = 0; const includeScrollbar = !this._options.autoHeight; for (let i = 0, ii = this.columns.length; i < ii; i++) { - if (!this.columns[i] || this.columns[i].hidden) { continue; } - + if (!this.columns[i] || this.columns[i].hidden) { + continue; + } const width = this.columns[i].width; - - if ((this._options.frozenColumn!) > -1 && (i > this._options.frozenColumn!)) { + if (this.getColumnDockingBand(i) === 'right') { this.headersWidthR += width || 0; } else { this.headersWidthL += width || 0; @@ -5095,32 +5013,30 @@ export class SlickGrid = Column, O e } if (includeScrollbar) { - // attribute the scrollbar width to the active scrollable band: the right band - // when frozen columns are enabled, otherwise the left band. - if (this.hasFrozenColumns()) { - this.headersWidthR += this.scrollbarDimensions?.width ?? 0; + // Attribute the scrollbar width to the active scrollable band: the right band + // when columns are pinned, otherwise the left band. + if (this.hasDockedColumns()) { + this.headersWidthR += this.scrollbarDimensions?.width || 0; } else { - this.headersWidthL += this.scrollbarDimensions?.width ?? 0; + this.headersWidthL += this.scrollbarDimensions?.width || 0; } } - if (this.hasFrozenColumns()) { - this.headersWidthL = this.headersWidthL + HEADER_WIDTH_SLACK; - - this.headersWidthR = Math.max(this.headersWidthR, this.viewportW) + HEADER_WIDTH_SLACK; + if (this.hasDockedColumns()) { + this.headersWidthR = Math.max(this.headersWidthR, this.viewportW); } else { - this.headersWidthL = Math.max(this.headersWidthL, this.viewportW) + HEADER_WIDTH_SLACK; + this.headersWidthL = Math.max(this.headersWidthL, this.viewportW); } this.headersWidth = this.headersWidthL + this.headersWidthR; - return Math.max(this.headersWidth, this.viewportW) + HEADER_WIDTH_SLACK; + return Math.max(this.headersWidth, this.viewportW); } /** Get the grid canvas width - * - * Computes the available width (considering vertical scrollbar if present), - * then iterates over the columns (left vs. right based on frozen columns) to sum their widths. - * If full–width rows are enabled, extra width is added. Returns the total calculated width. + * + * Computes the available width (considering vertical scrollbar if present), + * then iterates over the columns (left, center, and right docking bands) to sum their widths. + * If full–width rows are enabled, extra width is added. Returns the total calculated width. */ getCanvasWidth(): number { const availableWidth = this.getViewportInnerWidth(); @@ -5129,9 +5045,11 @@ export class SlickGrid = Column, O e this.canvasWidthL = this.canvasWidthR = 0; while (i--) { - if (!this.columns[i] || this.columns[i].hidden) { continue; } + if (!this.columns[i] || this.columns[i].hidden) { + continue; + } - if (this.hasFrozenColumns() && (i > this._options.frozenColumn!)) { + if (this.getColumnDockingBand(i) === 'right') { this.canvasWidthR += this.columns[i].width || 0; } else { this.canvasWidthL += this.columns[i].width || 0; @@ -5142,7 +5060,7 @@ export class SlickGrid = Column, O e const extraWidth = Math.max(totalRowWidth, availableWidth) - totalRowWidth; if (extraWidth > 0) { totalRowWidth += extraWidth; - if (this.hasFrozenColumns()) { + if (this.hasDockedColumns()) { this.canvasWidthR += extraWidth; } else { this.canvasWidthL += extraWidth; @@ -5152,87 +5070,6 @@ export class SlickGrid = Column, O e return totalRowWidth; } - /** - * Validate that the column freeze is allowed in the browser by making sure that the frozen column is not exceeding the available and visible left canvas width. - * Note that it will only validate when `invalidColumnFreezeWidthCallback` or `throwWhenFrozenNotAllViewable` grid option is enabled. - * @param {Number} frozenColumn the column index to freeze at - * @param {Boolean} [forceAlert] tri-state flag to alert when frozen column is invalid - * - if `undefined` it will do the condition check and never alert more than once - * - if `true` it will do the condition check and always alert even if it was called before - * - if `false` it will do the condition check but always skip the alert - */ - validateColumnFreezeWidth(frozenColumn = -1): boolean { - if (frozenColumn >= 0) { - let canvasWidthL = 0; - this.columns.forEach((col, i) => { - if (!col.hidden && i <= frozenColumn) { - const { minWidth = 0, maxWidth = 0, width = this._options.defaultColumnWidth! } = col; - let fwidth = width < minWidth ? minWidth : width; - if (maxWidth > 0 && fwidth > maxWidth) { - fwidth = maxWidth; - } - canvasWidthL += fwidth; - } - }); - - const cWidth = Utils.width(this._container) || 0; - if (cWidth > 0 && canvasWidthL > cWidth && !this._options.skipFreezeColumnValidation) { - if (this._options.invalidColumnFreezeWidthCallback || this._options.throwWhenFrozenNotAllViewable) { - if (this._options.throwWhenFrozenNotAllViewable) { - throw new Error(this._options.invalidColumnFreezeWidthMessage); - } - this._options.invalidColumnFreezeWidthCallback?.(this._options.invalidColumnFreezeWidthMessage!); - this._invalidfrozenAlerted = true; - } - return false; - } - } - return true; - } - - /** - * Validate that the frozen column is allowed by verifying there is at least 1, or more, column to the right of the frozen column otherwise show an error. - * Note that it will only validate when `invalidColumnFreezePickerCallback` grid option is enabled. - * @param {String|Number} [columnId] column id to validate - * @param {Boolean} [forceAlert] tri-state flag to alert when frozen column is invalid - * @param {Column[]} [columns] optionally provide columns to validate - * - if `undefined` it will do the condition check and never alert more than once - * - if `true` it will do the condition check and always alert even if it was called before - * - if `false` it will do the condition check but always skip the alert - */ - validateColumnFreeze(columnId?: number | string, forceAlert = false, columns?: C[]): boolean { - const hasColumnIdArg = columnId !== undefined; - columns ??= this.columns; - if (columnId === undefined && (this._prevFrozenColumnIdx >= 0 || this._options.frozenColumn! >= 0)) { - const column = columns[this._prevFrozenColumnIdx] ?? columns[this._options.frozenColumn!]; - columnId = column?.id ?? ''; - } - const currentFrozenIdx = this._options.frozenColumn!; - const frozenColumnId = currentFrozenIdx >= 0 && currentFrozenIdx < columns.length ? columns[currentFrozenIdx].id : ''; - if (!frozenColumnId || !columnId) { - return true; - } - - const visibleColumns = columns.filter((col) => !col.hidden); - const colIdx = visibleColumns.findIndex((col) => col.id === columnId); - const frozenColIdx = visibleColumns.findIndex((col) => col.id === frozenColumnId); - if ((frozenColIdx > colIdx && colIdx <= currentFrozenIdx) || currentFrozenIdx === -1) { - return true; - } - - if ( - (currentFrozenIdx >= 0 && currentFrozenIdx >= visibleColumns.length - 2 && !this._options.skipFreezeColumnValidation) || - (hasColumnIdArg && currentFrozenIdx === 0 && this.columns[0]?.id === columnId) - ) { - if ((forceAlert !== false && !this._invalidfrozenAlerted) || forceAlert === true) { - this._options.invalidColumnFreezePickerCallback?.(this._options.invalidColumnFreezePickerMessage!); - this._invalidfrozenAlerted = true; - } - return false; - } - return true; - } - /** * Recalculates the canvas width by calling getCanvasWidth and then adjusts widths of header containers, * canvases, panels, and viewports. If widths have changed (or forced), it applies the new column widths @@ -5240,180 +5077,131 @@ export class SlickGrid = Column, O e * * @param {boolean} [forceColumnWidthsUpdate] - Whether to force an update of column widths. */ - protected updateCanvasWidth(forceColumnWidthsUpdate?: boolean) { + protected updateCanvasWidth(forceColumnWidthsUpdate?: boolean): void { const oldCanvasWidth = this.canvasWidth; const oldCanvasWidthL = this.canvasWidthL; const oldCanvasWidthR = this.canvasWidthR; this.canvasWidth = this.getCanvasWidth(); - - if (this._options.createTopHeaderPanel) { - Utils.width(this._topHeaderPanel, this._options.topHeaderPanelWidth ?? this.canvasWidth); + // Keep the canvas at least viewport-wide so a right band at the visible edge leaves no + // gap after the last centre column; dockingLayout keeps the natural width. + if (this.hasDockedColumns()) { + this.canvasWidth = Math.max(this.canvasWidth, this.getDockingRenderedWidth()); + this.canvasWidthL = this.canvasWidth; } - const widthChanged = this.canvasWidth !== oldCanvasWidth || this.canvasWidthL !== oldCanvasWidthL || this.canvasWidthR !== oldCanvasWidthR; + if (this._options.createTopHeaderPanel && !this._isResizingColumn) { + const panelWidth = this._options.topHeaderPanelWidth ?? this.canvasWidth; + this._topHeaderPanel.style.width = typeof panelWidth === 'string' ? panelWidth : `${panelWidth}px`; + } + const widthChanged = + this.canvasWidth !== oldCanvasWidth || this.canvasWidthL !== oldCanvasWidthL || this.canvasWidthR !== oldCanvasWidthR; - if (widthChanged || this.hasFrozenColumns() || this.hasFrozenRows) { - Utils.width(this._canvasTopL, this.canvasWidthL); + if (widthChanged) { + Utils.width(this._canvasNode, this.canvasWidthL); this.getHeadersWidth(); - Utils.width(this._headerL, this.headersWidthL); - Utils.width(this._headerR, this.headersWidthR); - - if (this.hasFrozenColumns()) { - Utils.width(this._canvasTopR, this.canvasWidthR); - - Utils.width(this._paneHeaderL, this.canvasWidthL); - Utils.setStyleSize(this._paneHeaderR, 'left', this.canvasWidthL); - Utils.setStyleSize(this._paneHeaderR, 'width', this.viewportW - this.canvasWidthL); - - Utils.width(this._paneTopL, this.canvasWidthL); - Utils.setStyleSize(this._paneTopR, 'left', this.canvasWidthL); - Utils.width(this._paneTopR, this.viewportW - this.canvasWidthL); - - Utils.width(this._headerRowScrollerL, this.canvasWidthL); - Utils.width(this._headerRowScrollerR, this.viewportW - this.canvasWidthL); - - Utils.width(this._headerRowL, this.canvasWidthL); - Utils.width(this._headerRowR, this.canvasWidthR); - - if (this._options.createFooterRow) { - Utils.width(this._footerRowScrollerL, this.canvasWidthL); - Utils.width(this._footerRowScrollerR, this.viewportW - this.canvasWidthL); - - Utils.width(this._footerRowL, this.canvasWidthL); - Utils.width(this._footerRowR, this.canvasWidthR); - } - if (this._options.createPreHeaderPanel) { - Utils.width(this._preHeaderPanel, this._options.preHeaderPanelWidth ?? this.canvasWidth); - } - Utils.width(this._viewportTopL, this.canvasWidthL); - Utils.width(this._viewportTopR, this.viewportW - this.canvasWidthL); - - if (this.hasFrozenRows) { - Utils.width(this._paneBottomL, this.canvasWidthL); - Utils.setStyleSize(this._paneBottomR, 'left', this.canvasWidthL); - - Utils.width(this._viewportBottomL, this.canvasWidthL); - Utils.width(this._viewportBottomR, this.viewportW - this.canvasWidthL); - - Utils.width(this._canvasBottomL, this.canvasWidthL); - Utils.width(this._canvasBottomR, this.canvasWidthR); - } - } else { - Utils.width(this._paneHeaderL, '100%'); - Utils.width(this._paneTopL, '100%'); - Utils.width(this._headerRowScrollerL, '100%'); - Utils.width(this._headerRowL, this.canvasWidth); + Utils.width(this._headerL, this.getDockingChromeRootWidth()); + // v11 uses one live content root for both pinned and center columns. + this._headerRoot.style.left = ''; + this._contentRoot.style.left = ''; + Utils.width(this._headerRoot, '100%'); + Utils.width(this._contentRoot, '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.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._options.createPreHeaderPanel && !this._isResizingColumn) { + const panelWidth = this._options.preHeaderPanelWidth ?? this.canvasWidth; + this._preHeaderPanel.style.width = typeof panelWidth === 'string' ? panelWidth : `${panelWidth}px`; + } + Utils.width(this._viewportNode, '100%'); - if (this.hasFrozenRows) { - Utils.width(this._viewportBottomL, '100%'); - Utils.width(this._canvasBottomL, this.canvasWidthL); - } + if (this.rowDockingLayout.bottom.length > 0) { + this._contentRoot.style.left = ''; } } - this.viewportHasHScroll = (this.canvasWidth >= this.viewportW - (this.scrollbarDimensions?.width ?? 0)); + // Use the same test the docking scrollbar makes for itself, so the grid cannot reserve + // room for a horizontal scrollbar that the proxy has decided not to show. Content that + // exactly fills the viewport does not overflow it. + this.viewportHasHScroll = this.hasDockingHorizontalScroller() + ? (this.dockingLayout.contentWidth || this.canvasWidth) > this._viewportNode.clientWidth + : this.canvasWidth > this.getViewportInnerWidth(); - 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)); + Utils.width(this._headerRowSpacerL, this.canvasWidth + (this.viewportHasVScroll ? this.scrollbarDimensions?.width || 0 : 0)); 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)); + Utils.width(this._footerRowSpacerL, this.canvasWidth + (this.viewportHasVScroll ? this.scrollbarDimensions?.width || 0 : 0)); } - if (widthChanged || forceColumnWidthsUpdate) { + const reservedTrackBefore = this.dockingHorizontalScrollbarReserved; + this.updateDockingHorizontalScrollerDimensions(); + this.updateDockingOverlayDimensions(); + + // The proxy track sits beside the viewport rather than inside it, so the viewport has to + // give up its height when the track appears and take it back when it goes. Only + // resizeCanvas() recomputes that, and a column-resize drag never reaches it, which left + // the track painted over the last row for the rest of the session. + if (this.dockingHorizontalScrollbarReserved !== reservedTrackBefore && !this.resizingForDockingScrollbar) { + this.resizingForDockingScrollbar = true; + try { + this.resizeCanvas(); + } finally { + this.resizingForDockingScrollbar = false; + } + } + + // Row-count updates pass false; resizing docked columns still needs a refresh + // even when the rendered canvas width stays unchanged. + if (widthChanged || forceColumnWidthsUpdate || (forceColumnWidthsUpdate !== false && this.hasDockedColumns())) { this.applyColumnWidths(); + this.applyDockingToColumnChrome(); + this.applyDockingDimensionsToRows(); } } /** @alias `getPreHeaderPanelLeft` */ - getPreHeaderPanel() { + getPreHeaderPanel(): HTMLDivElement { return this._preHeaderPanel; } /** Get the Pre-Header Panel Left DOM node element */ - getPreHeaderPanelLeft() { + getPreHeaderPanelLeft(): HTMLDivElement { return this._preHeaderPanel; } /** Get the Pre-Header Panel Right DOM node element */ - getPreHeaderPanelRight() { + getPreHeaderPanelRight(): HTMLDivElement { return this._preHeaderPanelR; } /** Get the Top-Header Panel DOM node element */ - getTopHeaderPanel() { + getTopHeaderPanel(): HTMLDivElement { return this._topHeaderPanel; } /** - * Based on whether frozen columns (and/or rows) are enabled, shows or hides the right–side header - * and top panes as well as the bottom panes. If no frozen columns exist, hides right–side panes; - * otherwise, conditionally shows or hides the bottom panes depending on whether frozen rows exist. + * Sets the CSS overflowX and overflowY styles for the shared viewport and, when needed, + * the docking horizontal scroller based on the grid’s docking configuration and options such + * as alwaysAllowHorizontalScroll and alwaysShowVerticalScroll. + * If a viewportClass is specified in options, the class is added to the viewport. */ - 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); - } + protected setOverflow(): void { + this._viewportNode.style.overflowX = this.hasDockingHorizontalScroller() ? 'hidden' : 'auto'; + this._viewportNode.style.overflowY = this._options.autoHeight ? 'hidden' : this._options.alwaysShowVerticalScroll ? 'scroll' : 'auto'; + if (this._dockingHorizontalScroller) { + this._dockingHorizontalScroller.style.overflowX = 'auto'; + this._dockingHorizontalScroller.style.overflowY = 'hidden'; } - } - - /** - * Sets the CSS overflowX and overflowY styles for all four viewport elements - * (top–left, top–right, bottom–left, bottom–right) based on the grid’s frozen columns/rows status - * and options such as alwaysAllowHorizontalScroll and alwaysShowVerticalScroll. - * 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); + const viewportClasses = Utils.classNameToList(this._options.viewportClass); + this._viewportNode.classList.add(...viewportClasses); } } @@ -5423,42 +5211,45 @@ export class SlickGrid = Column, O e * It also loops through each column (if not hidden) to add empty rules for left and right column classes. * If the stylesheet cannot be accessed via the modern API, it falls back to createCssRulesAlternative. */ - protected createCssRules() { + protected createCssRules(): void { this._style = document.createElement('style'); - this._style.nonce = this._options.nonce || ''; + if (this._options.nonce) { + this._style.nonce = this._options.nonce; + } (this._options.shadowRoot || document.head).appendChild(this._style); - const rowHeight = (this._options.rowHeight! - this.cellHeightDiff); const rules = [ - `.${this.uid} .slick-group-header-column { ${this.dirSide}: 1000px; }`, - `.${this.uid} .slick-header-column { ${this.dirSide}: 1000px; }`, `.${this.uid} .slick-top-panel { height: ${this._options.topPanelHeight}px; }`, `.${this.uid} .slick-preheader-panel { height: ${this._options.preHeaderPanelHeight}px; }`, `.${this.uid} .slick-topheader-panel { height: ${this._options.topHeaderPanelHeight}px; }`, - `.${this.uid} .slick-headerrow-columns { height: ${this._options.headerRowHeight}px; }`, - `.${this.uid} .slick-footerrow-columns { height: ${this._options.footerRowHeight}px; }`, + // Docking chrome bands use `display: contents`, so their persistent root + // must carry the row height; otherwise the header-row/footer collapses to + // the 1px spacer height even though each band has the configured height. + `.${this.uid} .slick-headerrow-columns, .${this.uid} .slick-headerrow-columns-root { height: ${this._options.headerRowHeight}px; }`, + `.${this.uid} .slick-footerrow-columns, .${this.uid} .slick-footerrow-columns-root { height: ${this._options.footerRowHeight}px; }`, ]; + + // Rows get a default height from CSS; in variable-height mode, individual rows override + // this via inline `style="height: Xpx"`. The cell height is reduced by the measured + // vertical padding/border difference so its content-box plus those edges matches the row. if (this._options.enableVariableRowHeight) { - // cells simply track their row height; rows keep the default stylesheet height below and - // only rows with a non-default height get an inline height (see appendRowHtml) rules.push(`.${this.uid} .slick-cell { height: calc(100% - ${this.cellHeightDiff}px); }`); - rules.push(`.${this.uid} .slick-row { height: ${this._options.rowHeight}px; }`); } else { - rules.push(`.${this.uid} .slick-cell { height: ${rowHeight}px; }`); - rules.push(`.${this.uid} .slick-row { height: ${this._options.rowHeight}px; }`); + rules.push(`.${this.uid} .slick-cell { height: ${this._options.rowHeight! - this.cellHeightDiff}px; }`); } + rules.push(`.${this.uid} .slick-row { height: ${this._options.rowHeight}px; }`); const sheet = this._style.sheet; + + /* v8 ignore else */ if (sheet) { - rules.forEach(rule => { - sheet.insertRule(rule); - }); + rules.forEach((rule) => sheet.insertRule(rule)); for (let i = 0; i < this.columns.length; i++) { - if (!this.columns[i]) { continue; } - - sheet.insertRule(`.${this.uid} .l${i} { }`); - sheet.insertRule(`.${this.uid} .r${i} { }`); + if (this.columns[i]) { + sheet.insertRule(`.${this.uid} .l${i} { }`); + sheet.insertRule(`.${this.uid} .r${i} { }`); + } } } else { // fallback in case the 1st approach doesn't work, let's use our previous way of creating the css rules which is what works in Salesforce :( @@ -5467,27 +5258,27 @@ export class SlickGrid = Column, O e } /** Create CSS rules via template in case the first approach with createElement('style') doesn't work. - * - * In cases where the standard method of inserting CSS rules fails (as may occur in some environments), - * this function creates a