Skip to content

Commit fbd555a

Browse files
committed
Enforce max height of 50% for header/footer
When the footer or header will take up more than 50% of the height of the table, the sticky polyfill will now position some of the cells so that they must be scrolled to in order to be seen. Otherwise, all of the body cells are covered by the sticky header/footer cells.
1 parent c9e8b41 commit fbd555a

File tree

2 files changed

+235
-12
lines changed

2 files changed

+235
-12
lines changed

addon/-private/sticky/table-sticky-polyfill.js

+110-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const TABLE_POLYFILL_MAP = new WeakMap();
66
class TableStickyPolyfill {
77
constructor(element) {
88
this.element = element;
9+
this.maxStickyProportion = 0.5;
910

1011
this.element.style.position = 'static';
1112
this.side = element.tagName === 'THEAD' ? 'top' : 'bottom';
@@ -70,19 +71,123 @@ class TableStickyPolyfill {
7071
this.resizeSensors.forEach(([cell, sensor]) => sensor.detach(cell));
7172
};
7273

74+
/**
75+
Repositions all the `td`|`th` inside each `tr` of the `tfoot`|`thead`.
76+
The `td` and `th` cells must be sticky due to existing Chrome and Edge bugs
77+
that don't apply the sticky to the footer/header:
78+
* Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=702927
79+
* Edge bug: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/16765952/
80+
* More details at: https://caniuse.com/#search=fixed
81+
82+
Calculates the table's scale and scrollable height and, working top-down for header or bottom-up for footer,
83+
sets the cells for each row to be `position:sticky` with a calculated `top` or `bottom` offset so that they
84+
appear correctly fixed in the table.
85+
The calculation takes into account the height of each row as it goes, adjusting the next row's top|bottom offset
86+
accordingly.
87+
88+
For example, assuming the following table with 2 thead and 2 tfoot rows:
89+
* There will be 2 TableStickyPolyfills created, one for the thead, one for the tfoot
90+
* For the thead TableStickyPolifyill, its `repositionStickyElements` will
91+
start at row 0, setting each of its `th` cells' `top` value to `0px`, then
92+
add row 0's height (25px) to its current offset and move on to row 1,
93+
where it will set each of that row's `th` cells' `top` to the current
94+
offset of `25px`.
95+
* For the tfoot TableStickyPolyfill, its `respositionStickyElements` will
96+
start at the bottom-most row, row 1, and set each of its `td` cells'
97+
`bottom` value to `0px`, then add row 1's height (20px) to its current
98+
offset and move on to the next row, row 0, where it will set each of that
99+
row's `td` cells' `bottom` to the current offset of `20px`.
100+
101+
+--------------------------------------------+
102+
|+------------------------------------------+|
103+
||thead ||
104+
||+----------------------------------------+||
105+
|||row 0 (height: 25px) top: 0px |||
106+
||+----------------------------------------+||
107+
||+----------------------------------------+||
108+
|||row 1 top: 25px |||
109+
||+----------------------------------------+||
110+
|+------------------------------------------+|
111+
| |
112+
| .... tbody .... |
113+
| |
114+
|+------------------------------------------+|
115+
||tfoot ||
116+
||+----------------------------------------+||
117+
|||row 0 bottom: 20px |||
118+
||+----------------------------------------+||
119+
||+----------------------------------------+||
120+
|||row 1 (height: 20px) bottom: 0px |||
121+
||+----------------------------------------+||
122+
|+------------------------------------------+|
123+
+--------------------------------------------+
124+
125+
If a table has enough header|footer rows, they cumulatively add up to greater than the
126+
table's height. In this case, the standard calculation of `stick`ing each
127+
row to its calculated offset from the top|bottom will cause the
128+
header|footer rows to stick over *all* of the scrollable body rows,
129+
preventing them (as well as possibly some header|footer rows) from being
130+
seen.
131+
132+
To account for this potential situation, the repositioning sets a maximum percentage height
133+
for the header|footer of 50%. If the rows take up greater than that percentage of the table's
134+
height, all of the overflowing rows are positioned using a negative offset so that they
135+
will be visible when scrolling to the top|bottom of the table for thead|tfoot overflow rows, respectively.
136+
137+
For example, the following table has footer rows totaling 75px, but the table's height
138+
is only 120px. The footer rows take up more than 50% of the table, so the bottom-most
139+
footer row (2) is positioned at (tableHeight - footerHeight = 75 - 120) -45px, the next
140+
footer row (1) is positioned at (-45 + 30) -15px, and the top-most footer row (0)
141+
is at (-15 + 20) 5px.
142+
143+
The effect is that the top row (0) will be fully visible, row 1 will be partially visible,
144+
and row 2 will be hidden until the table is scrolled all the way to the bottom.
145+
146+
+-----------------------------------+ ------------^---
147+
|table | |Table height: 120px
148+
| | |
149+
| | |
150+
| | |
151+
| | |
152+
| | |
153+
|+--------------------------------+ | ^--- |
154+
||tfoot | | | |
155+
||+------------------------------+| | |tfoot height |
156+
|||row 0 (20px) bottom: 5px|| | |20+25+30 = 75px |
157+
||| || | | |
158+
||| || | | |
159+
||+------------------------------+| | | |
160+
||+------------------------------+| | | |
161+
|||row 1 (25px) bottom: -15px|| | | |
162+
||| || | | |
163+
+-----------------------------------+ | ------------v---
164+
|+ +| |
165+
|+------------------------------+| |
166+
||row 2 (30px) bottom: -45px|| |
167+
|| || |
168+
|+------------------------------+| |
169+
| | |
170+
+--------------------------------+ v---
171+
*/
73172
repositionStickyElements = () => {
74173
let table = this.element.parentNode;
75174
let scale = table.offsetHeight / table.getBoundingClientRect().height;
175+
let containerHeight = table.parentNode.offsetHeight;
76176

77177
let rows = Array.from(this.element.children);
78-
let orderedRows = this.side === 'top' ? rows : rows.reverse();
79-
80178
let offset = 0;
179+
let heights = rows.map(r => r.getBoundingClientRect().height * scale);
81180

82-
let heights = orderedRows.map(r => r.getBoundingClientRect().height * scale);
181+
let totalHeight = heights.reduce((sum, h) => (sum += h), 0);
182+
let maxHeight = containerHeight * this.maxStickyProportion;
183+
if (totalHeight > maxHeight) {
184+
offset = maxHeight - totalHeight;
185+
}
83186

84-
for (let i = 0; i < orderedRows.length; i++) {
85-
let row = orderedRows[i];
187+
for (let i = 0; i < rows.length; i++) {
188+
// Work top-down (index order) for 'top', bottom-up (reverse index
189+
// order) for 'bottom' rows
190+
let row = rows[this.side === 'top' ? i : rows.length - 1 - i];
86191
let height = heights[i];
87192

88193
for (let child of row.children) {

tests/unit/-private/table-sticky-polyfill-test.js

+125-7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import wait from 'ember-test-helpers/wait';
99

1010
import { setupTableStickyPolyfill } from 'ember-table/-private/sticky/table-sticky-polyfill';
1111

12+
const HEADER_PIXEL_EPSILON = 10;
13+
const FOOTER_PIXEL_EPSILON = 10;
14+
15+
function isNearTo(value, expected, epsilon = 0.01) {
16+
return Math.abs(value - expected) <= epsilon;
17+
}
18+
1219
const standardTemplate = hbs`
1320
<div style="height: 500px;">
1421
<div class="ember-table">
@@ -45,14 +52,14 @@ const standardTemplate = hbs`
4552
</div>
4653
`;
4754

48-
function constructMatrix(n, m) {
55+
function constructMatrix(n, m, prefix = '') {
4956
let rows = emberA();
5057

5158
for (let i = 0; i < n; i++) {
5259
let cols = emberA();
5360

5461
for (let j = 0; j < m; j++) {
55-
cols.pushObject(m);
62+
cols.pushObject(`${m}${prefix}`);
5663
}
5764

5865
rows.pushObject(cols);
@@ -70,7 +77,7 @@ function verifyHeader(assert) {
7077
let cellRect = cell.getBoundingClientRect();
7178
let containerRect = find('.ember-table').getBoundingClientRect();
7279

73-
assert.ok(Math.abs(cellRect.top - containerRect.top - expectedOffset) < 10);
80+
assert.ok(Math.abs(cellRect.top - containerRect.top - expectedOffset) < HEADER_PIXEL_EPSILON);
7481
}
7582
});
7683
}
@@ -86,16 +93,18 @@ function verifyFooter(assert) {
8693
let cellRect = cell.getBoundingClientRect();
8794
let containerRect = find('.ember-table').getBoundingClientRect();
8895

89-
assert.ok(Math.abs(containerRect.bottom - cellRect.bottom - expectedOffset) < 10);
96+
assert.ok(
97+
Math.abs(containerRect.bottom - cellRect.bottom - expectedOffset) < FOOTER_PIXEL_EPSILON
98+
);
9099
}
91100
});
92101
}
93102

94103
componentModule('Unit | Private | TableStickyPolyfill', function() {
95104
test('it works', async function(assert) {
96-
this.set('headerRows', constructMatrix(3, 3));
97-
this.set('bodyRows', constructMatrix(20, 3));
98-
this.set('footerRows', constructMatrix(3, 3));
105+
this.set('headerRows', constructMatrix(3, 3, 'thead'));
106+
this.set('bodyRows', constructMatrix(20, 3, 'tbody'));
107+
this.set('footerRows', constructMatrix(3, 3, 'tfoot'));
99108

100109
await this.render(standardTemplate);
101110

@@ -175,4 +184,113 @@ componentModule('Unit | Private | TableStickyPolyfill', function() {
175184
verifyHeader(assert);
176185
verifyFooter(assert);
177186
});
187+
188+
test('maxStickyProportion: when the footer is > 50% of the height', async function(assert) {
189+
let maxStickyProportion = 0.5;
190+
this.set('headerRows', constructMatrix(3, 3, 'header'));
191+
this.set('bodyRows', constructMatrix(30, 3, 'body'));
192+
this.set('footerRows', constructMatrix(30, 3, 'footer'));
193+
194+
await this.render(standardTemplate);
195+
196+
setupTableStickyPolyfill(find('thead'));
197+
setupTableStickyPolyfill(find('tfoot'));
198+
199+
await wait();
200+
201+
let firstCell = find('tfoot tr:first-child td:first-child');
202+
let lastCell = find('tfoot tr:last-child td:first-child');
203+
let container = find('.ember-table');
204+
205+
let firstCellRect = firstCell.getBoundingClientRect();
206+
let lastCellRect = lastCell.getBoundingClientRect();
207+
let containerRect = container.getBoundingClientRect();
208+
209+
assert.ok(
210+
find('tfoot').getBoundingClientRect().height > maxStickyProportion * containerRect.height,
211+
'precond - footer is > 50% of the table height'
212+
);
213+
214+
assert.ok(
215+
isNearTo((firstCellRect.top - containerRect.top) / containerRect.height, maxStickyProportion),
216+
'the top of the first footer cell is close to 50% of the way up the table'
217+
);
218+
219+
assert.ok(
220+
isNearTo(
221+
(containerRect.bottom - firstCellRect.top) / containerRect.height,
222+
maxStickyProportion
223+
),
224+
'the top of the first footer cell is close to 50% of the way down the table'
225+
);
226+
227+
assert.ok(lastCellRect.top > containerRect.bottom, 'last footer cell is out of view');
228+
229+
await scrollTo('.ember-table', 0, container.scrollHeight);
230+
231+
// Recompute dimensions
232+
lastCellRect = lastCell.getBoundingClientRect();
233+
containerRect = container.getBoundingClientRect();
234+
235+
assert.equal(
236+
lastCellRect.bottom,
237+
containerRect.bottom,
238+
'after scroll, last footer cell is at bottom of table'
239+
);
240+
});
241+
242+
test('maxStickyProportion: when the header > 50% of the height', async function(assert) {
243+
let maxStickyProportion = 0.5;
244+
this.set('headerRows', constructMatrix(30, 3, 'header'));
245+
this.set('bodyRows', constructMatrix(30, 3, 'body'));
246+
this.set('footerRows', constructMatrix(3, 3, 'footer'));
247+
248+
await this.render(standardTemplate);
249+
250+
setupTableStickyPolyfill(find('thead'));
251+
setupTableStickyPolyfill(find('tfoot'));
252+
253+
await wait();
254+
255+
let firstCell = find('thead tr:first-child th:first-child');
256+
let lastCell = find('thead tr:last-child th:first-child');
257+
let container = find('.ember-table');
258+
259+
let firstCellRect = firstCell.getBoundingClientRect();
260+
let lastCellRect = lastCell.getBoundingClientRect();
261+
let containerRect = container.getBoundingClientRect();
262+
263+
assert.ok(
264+
find('thead').getBoundingClientRect().height > maxStickyProportion * containerRect.height,
265+
'precond - header is > 50% of the table height'
266+
);
267+
268+
assert.equal(
269+
firstCellRect.top,
270+
containerRect.top,
271+
'top of first header cell is at top of table'
272+
);
273+
assert.ok(lastCellRect.top > containerRect.bottom, 'last header cell is out of view');
274+
275+
await scrollTo('.ember-table', 0, container.scrollHeight);
276+
277+
// recompute dimensions
278+
lastCellRect = lastCell.getBoundingClientRect();
279+
containerRect = container.getBoundingClientRect();
280+
281+
assert.ok(
282+
isNearTo(
283+
(lastCellRect.bottom - containerRect.top) / containerRect.height,
284+
maxStickyProportion
285+
),
286+
'the bottom of the last header cell is close to 50% of the way up the table'
287+
);
288+
assert.ok(
289+
isNearTo(
290+
(containerRect.bottom - lastCellRect.bottom) / containerRect.height,
291+
maxStickyProportion
292+
),
293+
'the bottom of the last header cell is close to 50% of the way down the table'
294+
);
295+
});
178296
});

0 commit comments

Comments
 (0)