Skip to content

Commit ab03ea2

Browse files
authored
Merge pull request #226 from iamejaaz/3-attempt-sticky-columns
feat: sticky columns
2 parents ce4ccab + eccb137 commit ab03ea2

File tree

6 files changed

+145
-6
lines changed

6 files changed

+145
-6
lines changed

cypress/integration/column.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,50 @@ describe('Column', function () {
6767
cy.getCell(4, 1).should('have.css', 'width')
6868
.and('match', /9\dpx/);
6969
});
70-
});
70+
71+
it('keeps sticky columns pinned while scrolling horizontally', function () {
72+
const expectPinned = (actual, expected) => {
73+
expect(actual).to.be.closeTo(expected, 1);
74+
};
75+
76+
cy.get('.dt-scrollable').then(($scrollable) => {
77+
const scrollable = $scrollable[0];
78+
const stickyCheckboxBodyCell = Cypress.$('.dt-cell--0-0')[0];
79+
const stickyCheckboxHeaderCell = Cypress.$('.dt-cell--header-0')[0];
80+
const stickySerialBodyCell = Cypress.$('.dt-cell--1-0')[0];
81+
const stickySerialHeaderCell = Cypress.$('.dt-cell--header-1')[0];
82+
const stickyCustomBodyCell = Cypress.$('.dt-cell--2-0')[0];
83+
const stickyCustomHeaderCell = Cypress.$('.dt-cell--header-2')[0];
84+
const regularBodyCell = Cypress.$('.dt-cell--4-0')[0];
85+
86+
const initialStickyCheckboxBodyLeft = stickyCheckboxBodyCell.getBoundingClientRect().left;
87+
const initialStickyCheckboxHeaderLeft = stickyCheckboxHeaderCell.getBoundingClientRect().left;
88+
const initialStickySerialBodyLeft = stickySerialBodyCell.getBoundingClientRect().left;
89+
const initialStickySerialHeaderLeft = stickySerialHeaderCell.getBoundingClientRect().left;
90+
const initialStickyCustomBodyLeft = stickyCustomBodyCell.getBoundingClientRect().left;
91+
const initialStickyCustomHeaderLeft = stickyCustomHeaderCell.getBoundingClientRect().left;
92+
const initialRegularBodyLeft = regularBodyCell.getBoundingClientRect().left;
93+
94+
scrollable.scrollLeft = 220;
95+
scrollable.dispatchEvent(new Event('scroll'));
96+
97+
cy.wait(50).then(() => {
98+
const nextStickyCheckboxBodyLeft = stickyCheckboxBodyCell.getBoundingClientRect().left;
99+
const nextStickyCheckboxHeaderLeft = stickyCheckboxHeaderCell.getBoundingClientRect().left;
100+
const nextStickySerialBodyLeft = stickySerialBodyCell.getBoundingClientRect().left;
101+
const nextStickySerialHeaderLeft = stickySerialHeaderCell.getBoundingClientRect().left;
102+
const nextStickyCustomBodyLeft = stickyCustomBodyCell.getBoundingClientRect().left;
103+
const nextStickyCustomHeaderLeft = stickyCustomHeaderCell.getBoundingClientRect().left;
104+
const nextRegularBodyLeft = regularBodyCell.getBoundingClientRect().left;
105+
106+
expectPinned(nextStickyCheckboxBodyLeft, initialStickyCheckboxBodyLeft);
107+
expectPinned(nextStickyCheckboxHeaderLeft, initialStickyCheckboxHeaderLeft);
108+
expectPinned(nextStickySerialBodyLeft, initialStickySerialBodyLeft);
109+
expectPinned(nextStickySerialHeaderLeft, initialStickySerialHeaderLeft);
110+
expectPinned(nextStickyCustomBodyLeft, initialStickyCustomBodyLeft);
111+
expectPinned(nextStickyCustomHeaderLeft, initialStickyCustomHeaderLeft);
112+
expect(nextRegularBodyLeft).to.be.lessThan(initialRegularBodyLeft);
113+
});
114+
});
115+
});
116+
});

index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ <h1>Frappe DataTable</h1>
151151

152152
function buildData() {
153153
columns = [
154-
{ name: "Name", width: 150,},
154+
{ name: "Name", width: 150, sticky: true },
155155
{ name: "Position", width: 200 },
156-
{ name: "Office", sticky: true },
156+
{ name: "Office", sticky: true },
157157
{ name: "Extn." },
158158
{
159159
name: "Start Date",

src/cellmanager.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,8 +823,15 @@ export default class CellManager {
823823
});
824824

825825
const row = this.datamanager.getRow(rowIndex);
826+
const column = cell.column || this.datamanager.getColumn(colIndex) || {};
826827

827828
const isBodyCell = !(isHeader || isFilter || isTotalRow);
829+
const isSticky = Boolean(column.sticky);
830+
const stickyColumns = this.datamanager.getColumns().filter(col => col.sticky);
831+
const lastStickyColumn = stickyColumns[stickyColumns.length - 1];
832+
const isLastStickyColumn = isSticky &&
833+
lastStickyColumn &&
834+
lastStickyColumn.colIndex === colIndex;
828835

829836
const className = [
830837
'dt-cell',
@@ -834,7 +841,10 @@ export default class CellManager {
834841
isHeader ? 'dt-cell--header' : '',
835842
isHeader ? `dt-cell--header-${colIndex}` : '',
836843
isFilter ? 'dt-cell--filter' : '',
837-
isBodyCell && (row && row.meta.isTreeNodeClose) ? 'dt-cell--tree-close' : ''
844+
isBodyCell && (row && row.meta.isTreeNodeClose) ? 'dt-cell--tree-close' : '',
845+
isSticky ? 'dt-cell--sticky' : '',
846+
isSticky && !isBodyCell ? 'dt-cell--sticky-top' : '',
847+
isLastStickyColumn ? 'dt-cell--sticky-last' : ''
838848
].join(' ');
839849

840850
return `

src/datamanager.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export default class DataManager {
6262
sortable: false,
6363
focusable: false,
6464
dropdown: false,
65+
sticky: true,
6566
width: 32
6667
};
6768
this.columns.push(cell);
@@ -75,7 +76,8 @@ export default class DataManager {
7576
editable: false,
7677
resizable: false,
7778
focusable: false,
78-
dropdown: false
79+
dropdown: false,
80+
sticky: true
7981
};
8082
if (this.options.data.length > 1000) {
8183
cell.resizable = true;

src/style.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
--dt-toast-message-border: none;
1818
--dt-header-cell-bg: var(--dt-cell-bg);
1919
--dt-no-data-message-width: 90px;
20+
--dt-scroll-left: 0px;
2021
}
2122

2223
.datatable {
@@ -170,6 +171,29 @@
170171
&:last-child {
171172
border-right: 1px solid var(--dt-border-color);
172173
}
174+
175+
&--sticky {
176+
position: sticky;
177+
left: 0;
178+
z-index: 1;
179+
}
180+
181+
&--sticky-top {
182+
z-index: 4;
183+
will-change: transform;
184+
}
185+
186+
&--sticky-last::after {
187+
content: '';
188+
position: absolute;
189+
top: 0;
190+
right: -1px;
191+
width: 8px;
192+
height: 100%;
193+
pointer-events: none;
194+
box-shadow: 4px 0 6px -4px rgba(15, 23, 42, 0.2);
195+
}
196+
173197
}
174198

175199
.datatable[dir=rtl] .dt-cell__resize-handle {

src/style.js

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,24 @@ export default class Style {
4141

4242
bindScrollHeader() {
4343
this._settingHeaderPosition = false;
44+
this.updateStickyTopPositions(0);
4445

4546
$.on(this.bodyScrollable, 'scroll', (e) => {
4647
if (this._settingHeaderPosition) return;
4748

4849
this._settingHeaderPosition = true;
4950

5051
requestAnimationFrame(() => {
51-
const left = -e.target.scrollLeft;
52+
const scrollLeft = e.target.scrollLeft;
53+
const left = -scrollLeft;
5254

5355
$.style(this.header, {
5456
transform: `translateX(${left}px)`
5557
});
5658
$.style(this.footer, {
5759
transform: `translateX(${left}px)`
5860
});
61+
this.updateStickyTopPositions(scrollLeft);
5962
this._settingHeaderPosition = false;
6063
if (this.instance.noData) {
6164
$.style($('.no-data-message'), {
@@ -153,6 +156,8 @@ export default class Style {
153156
this.setupColumnWidth();
154157
this.distributeRemainingWidth();
155158
this.setColumnStyle();
159+
this.setStickyColumnStyle();
160+
this.updateStickyTopPositions(this.bodyScrollable.scrollLeft || 0);
156161
this.setBodyStyle();
157162
}
158163

@@ -310,6 +315,8 @@ export default class Style {
310315
this.columnmanager.setColumnHeaderWidth(column.colIndex);
311316
this.columnmanager.setColumnWidth(column.colIndex);
312317
});
318+
this.setStickyColumnStyle();
319+
this.updateStickyTopPositions(this.bodyScrollable.scrollLeft || 0);
313320
}
314321

315322
setBodyStyle() {
@@ -371,6 +378,56 @@ export default class Style {
371378
return $(`.dt-cell--col-${colIndex}`, this.header);
372379
}
373380

381+
setStickyColumnStyle() {
382+
if (!this.datamanager || !this.datamanager.getColumns) return;
383+
384+
const stickySelectors = [];
385+
let stickyOffset = 0;
386+
let normalOffset = 0;
387+
388+
this.datamanager.getColumns().forEach((column) => {
389+
const $headerCell = this.getColumnHeaderElement(column.colIndex);
390+
const renderedWidth = $headerCell ? $headerCell.offsetWidth : column.width;
391+
392+
if (column.sticky) {
393+
const selector = `.dt-cell--col-${column.colIndex}.dt-cell--sticky`;
394+
const style = {
395+
left: `${stickyOffset}px`
396+
};
397+
398+
column.stickyLeft = stickyOffset;
399+
column.stickyScrollTrigger = normalOffset - stickyOffset;
400+
column.renderedWidth = renderedWidth;
401+
this.setStyle(selector, style);
402+
stickySelectors.push(selector);
403+
stickyOffset += renderedWidth;
404+
}
405+
normalOffset += renderedWidth;
406+
});
407+
408+
const staleSelectors = (this._stickySelectors || [])
409+
.filter(selector => !stickySelectors.includes(selector));
410+
411+
staleSelectors.forEach(selector => this.removeStyle(selector));
412+
this._stickySelectors = stickySelectors;
413+
}
414+
415+
updateStickyTopPositions(scrollLeft) {
416+
if (!this.datamanager || !this.datamanager.getColumns) return;
417+
418+
const stickyColumns = this.datamanager.getColumns().filter(column => column.sticky);
419+
420+
stickyColumns.forEach((column) => {
421+
const trigger = Math.max(0, column.stickyScrollTrigger || 0);
422+
const compensation = Math.max(0, scrollLeft - trigger);
423+
const cells = $.each(`.dt-cell--col-${column.colIndex}.dt-cell--sticky-top`, this.wrapper) || [];
424+
425+
$.style(cells, {
426+
transform: compensation ? `translateX(${compensation}px)` : ''
427+
});
428+
});
429+
}
430+
374431
getRowIndexColumnWidth() {
375432
const rowCount = this.datamanager.getRowCount();
376433
const padding = 22;

0 commit comments

Comments
 (0)