diff --git a/cypress/integration/column.js b/cypress/integration/column.js index b80d724..94f7c97 100644 --- a/cypress/integration/column.js +++ b/cypress/integration/column.js @@ -67,4 +67,50 @@ describe('Column', function () { cy.getCell(4, 1).should('have.css', 'width') .and('match', /9\dpx/); }); -}); \ No newline at end of file + + it('keeps sticky columns pinned while scrolling horizontally', function () { + const expectPinned = (actual, expected) => { + expect(actual).to.be.closeTo(expected, 1); + }; + + cy.get('.dt-scrollable').then(($scrollable) => { + const scrollable = $scrollable[0]; + const stickyCheckboxBodyCell = Cypress.$('.dt-cell--0-0')[0]; + const stickyCheckboxHeaderCell = Cypress.$('.dt-cell--header-0')[0]; + const stickySerialBodyCell = Cypress.$('.dt-cell--1-0')[0]; + const stickySerialHeaderCell = Cypress.$('.dt-cell--header-1')[0]; + const stickyCustomBodyCell = Cypress.$('.dt-cell--2-0')[0]; + const stickyCustomHeaderCell = Cypress.$('.dt-cell--header-2')[0]; + const regularBodyCell = Cypress.$('.dt-cell--4-0')[0]; + + const initialStickyCheckboxBodyLeft = stickyCheckboxBodyCell.getBoundingClientRect().left; + const initialStickyCheckboxHeaderLeft = stickyCheckboxHeaderCell.getBoundingClientRect().left; + const initialStickySerialBodyLeft = stickySerialBodyCell.getBoundingClientRect().left; + const initialStickySerialHeaderLeft = stickySerialHeaderCell.getBoundingClientRect().left; + const initialStickyCustomBodyLeft = stickyCustomBodyCell.getBoundingClientRect().left; + const initialStickyCustomHeaderLeft = stickyCustomHeaderCell.getBoundingClientRect().left; + const initialRegularBodyLeft = regularBodyCell.getBoundingClientRect().left; + + scrollable.scrollLeft = 220; + scrollable.dispatchEvent(new Event('scroll')); + + cy.wait(50).then(() => { + const nextStickyCheckboxBodyLeft = stickyCheckboxBodyCell.getBoundingClientRect().left; + const nextStickyCheckboxHeaderLeft = stickyCheckboxHeaderCell.getBoundingClientRect().left; + const nextStickySerialBodyLeft = stickySerialBodyCell.getBoundingClientRect().left; + const nextStickySerialHeaderLeft = stickySerialHeaderCell.getBoundingClientRect().left; + const nextStickyCustomBodyLeft = stickyCustomBodyCell.getBoundingClientRect().left; + const nextStickyCustomHeaderLeft = stickyCustomHeaderCell.getBoundingClientRect().left; + const nextRegularBodyLeft = regularBodyCell.getBoundingClientRect().left; + + expectPinned(nextStickyCheckboxBodyLeft, initialStickyCheckboxBodyLeft); + expectPinned(nextStickyCheckboxHeaderLeft, initialStickyCheckboxHeaderLeft); + expectPinned(nextStickySerialBodyLeft, initialStickySerialBodyLeft); + expectPinned(nextStickySerialHeaderLeft, initialStickySerialHeaderLeft); + expectPinned(nextStickyCustomBodyLeft, initialStickyCustomBodyLeft); + expectPinned(nextStickyCustomHeaderLeft, initialStickyCustomHeaderLeft); + expect(nextRegularBodyLeft).to.be.lessThan(initialRegularBodyLeft); + }); + }); + }); +}); diff --git a/index.html b/index.html index 58ed474..12fb222 100644 --- a/index.html +++ b/index.html @@ -151,9 +151,9 @@

Frappe DataTable

function buildData() { columns = [ - { name: "Name", width: 150,}, + { name: "Name", width: 150, sticky: true }, { name: "Position", width: 200 }, - { name: "Office", sticky: true }, + { name: "Office", sticky: true }, { name: "Extn." }, { name: "Start Date", diff --git a/src/cellmanager.js b/src/cellmanager.js index 8275bfd..84c675f 100644 --- a/src/cellmanager.js +++ b/src/cellmanager.js @@ -812,8 +812,15 @@ export default class CellManager { }); const row = this.datamanager.getRow(rowIndex); + const column = cell.column || this.datamanager.getColumn(colIndex) || {}; const isBodyCell = !(isHeader || isFilter || isTotalRow); + const isSticky = Boolean(column.sticky); + const stickyColumns = this.datamanager.getColumns().filter(col => col.sticky); + const lastStickyColumn = stickyColumns[stickyColumns.length - 1]; + const isLastStickyColumn = isSticky && + lastStickyColumn && + lastStickyColumn.colIndex === colIndex; const className = [ 'dt-cell', @@ -823,7 +830,10 @@ export default class CellManager { isHeader ? 'dt-cell--header' : '', isHeader ? `dt-cell--header-${colIndex}` : '', isFilter ? 'dt-cell--filter' : '', - isBodyCell && (row && row.meta.isTreeNodeClose) ? 'dt-cell--tree-close' : '' + isBodyCell && (row && row.meta.isTreeNodeClose) ? 'dt-cell--tree-close' : '', + isSticky ? 'dt-cell--sticky' : '', + isSticky && !isBodyCell ? 'dt-cell--sticky-top' : '', + isLastStickyColumn ? 'dt-cell--sticky-last' : '' ].join(' '); return ` diff --git a/src/datamanager.js b/src/datamanager.js index 6e7ba0b..1071862 100644 --- a/src/datamanager.js +++ b/src/datamanager.js @@ -62,6 +62,7 @@ export default class DataManager { sortable: false, focusable: false, dropdown: false, + sticky: true, width: 32 }; this.columns.push(cell); @@ -75,7 +76,8 @@ export default class DataManager { editable: false, resizable: false, focusable: false, - dropdown: false + dropdown: false, + sticky: true }; if (this.options.data.length > 1000) { cell.resizable = true; diff --git a/src/style.css b/src/style.css index c14fdb9..21082ff 100644 --- a/src/style.css +++ b/src/style.css @@ -17,6 +17,7 @@ --dt-toast-message-border: none; --dt-header-cell-bg: var(--dt-cell-bg); --dt-no-data-message-width: 90px; + --dt-scroll-left: 0px; } .datatable { @@ -170,6 +171,29 @@ &:last-child { border-right: 1px solid var(--dt-border-color); } + + &--sticky { + position: sticky; + left: 0; + z-index: 1; + } + + &--sticky-top { + z-index: 4; + will-change: transform; + } + + &--sticky-last::after { + content: ''; + position: absolute; + top: 0; + right: -1px; + width: 8px; + height: 100%; + pointer-events: none; + box-shadow: 4px 0 6px -4px rgba(15, 23, 42, 0.2); + } + } .datatable[dir=rtl] .dt-cell__resize-handle { diff --git a/src/style.js b/src/style.js index c846ad3..f977f67 100644 --- a/src/style.js +++ b/src/style.js @@ -41,6 +41,7 @@ export default class Style { bindScrollHeader() { this._settingHeaderPosition = false; + this.updateStickyTopPositions(0); $.on(this.bodyScrollable, 'scroll', (e) => { if (this._settingHeaderPosition) return; @@ -48,7 +49,8 @@ export default class Style { this._settingHeaderPosition = true; requestAnimationFrame(() => { - const left = -e.target.scrollLeft; + const scrollLeft = e.target.scrollLeft; + const left = -scrollLeft; $.style(this.header, { transform: `translateX(${left}px)` @@ -56,6 +58,7 @@ export default class Style { $.style(this.footer, { transform: `translateX(${left}px)` }); + this.updateStickyTopPositions(scrollLeft); this._settingHeaderPosition = false; if (this.instance.noData) { $.style($('.no-data-message'), { @@ -153,6 +156,8 @@ export default class Style { this.setupColumnWidth(); this.distributeRemainingWidth(); this.setColumnStyle(); + this.setStickyColumnStyle(); + this.updateStickyTopPositions(this.bodyScrollable.scrollLeft || 0); this.setBodyStyle(); } @@ -310,6 +315,8 @@ export default class Style { this.columnmanager.setColumnHeaderWidth(column.colIndex); this.columnmanager.setColumnWidth(column.colIndex); }); + this.setStickyColumnStyle(); + this.updateStickyTopPositions(this.bodyScrollable.scrollLeft || 0); } setBodyStyle() { @@ -371,6 +378,56 @@ export default class Style { return $(`.dt-cell--col-${colIndex}`, this.header); } + setStickyColumnStyle() { + if (!this.datamanager || !this.datamanager.getColumns) return; + + const stickySelectors = []; + let stickyOffset = 0; + let normalOffset = 0; + + this.datamanager.getColumns().forEach((column) => { + const $headerCell = this.getColumnHeaderElement(column.colIndex); + const renderedWidth = $headerCell ? $headerCell.offsetWidth : column.width; + + if (column.sticky) { + const selector = `.dt-cell--col-${column.colIndex}.dt-cell--sticky`; + const style = { + left: `${stickyOffset}px` + }; + + column.stickyLeft = stickyOffset; + column.stickyScrollTrigger = normalOffset - stickyOffset; + column.renderedWidth = renderedWidth; + this.setStyle(selector, style); + stickySelectors.push(selector); + stickyOffset += renderedWidth; + } + normalOffset += renderedWidth; + }); + + const staleSelectors = (this._stickySelectors || []) + .filter(selector => !stickySelectors.includes(selector)); + + staleSelectors.forEach(selector => this.removeStyle(selector)); + this._stickySelectors = stickySelectors; + } + + updateStickyTopPositions(scrollLeft) { + if (!this.datamanager || !this.datamanager.getColumns) return; + + const stickyColumns = this.datamanager.getColumns().filter(column => column.sticky); + + stickyColumns.forEach((column) => { + const trigger = Math.max(0, column.stickyScrollTrigger || 0); + const compensation = Math.max(0, scrollLeft - trigger); + const cells = $.each(`.dt-cell--col-${column.colIndex}.dt-cell--sticky-top`, this.wrapper) || []; + + $.style(cells, { + transform: compensation ? `translateX(${compensation}px)` : '' + }); + }); + } + getRowIndexColumnWidth() { const rowCount = this.datamanager.getRowCount(); const padding = 22;