Skip to content

Commit 8c038a0

Browse files
authored
[v4] [table] fix: prevent scrolling to ghost rows (#5115)
1 parent 0019b47 commit 8c038a0

File tree

8 files changed

+179
-34
lines changed

8 files changed

+179
-34
lines changed

packages/table/src/common/grid.ts

+6
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export class Grid {
4949

5050
public static DEFAULT_GHOST_WIDTH = 150;
5151

52+
// defined in headers/_common.scss
53+
public static MIN_COLUMN_HEADER_HEIGHT = 30;
54+
55+
// defined in headers/_common.scss
56+
public static MIN_ROW_HEADER_WIDTH = 30;
57+
5258
public numCols: number;
5359

5460
public numRows: number;

packages/table/src/headers/columnHeader.tsx

-2
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,6 @@ export class ColumnHeader extends React.Component<IColumnHeaderProps> {
8888
} = this.props;
8989

9090
return (
91-
// HACKHACK(adahiya): strange shouldComponentUpdate type error with strict null checks
92-
// @ts-ignore
9391
<Header
9492
convertPointToIndex={this.convertPointToColumn}
9593
fullRegionCardinality={RegionCardinality.FULL_COLUMNS}

packages/table/src/headers/rowHeader.tsx

-2
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,6 @@ export class RowHeader extends React.Component<IRowHeaderProps> {
7474
} = this.props;
7575

7676
return (
77-
// HACKHACK(adahiya): strange shouldComponentUpdate type error with strict null checks
78-
// @ts-ignore
7977
<Header
8078
convertPointToIndex={this.convertPointToRow}
8179
fullRegionCardinality={RegionCardinality.FULL_ROWS}

packages/table/src/locator.ts

+27
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,33 @@ export class Locator implements ILocator {
158158
return maxHeight;
159159
}
160160

161+
/**
162+
* Pass in an already-computed viewport rect here, if available, to reduce DOM reads.
163+
*
164+
* @returns whether the rendered rows overflow the visible viewport vertically, helpful for scrolling calculations
165+
*/
166+
public hasVerticalOverflow(
167+
columnHeaderHeight = Grid.MIN_COLUMN_HEADER_HEIGHT,
168+
viewportRect = this.getViewportRect(),
169+
) {
170+
if (this.grid === undefined) {
171+
return false;
172+
}
173+
return this.grid.getHeight() > viewportRect.height - columnHeaderHeight;
174+
}
175+
176+
/**
177+
* Pass in an already-computed viewport rect here, if available, to reduce DOM reads.
178+
*
179+
* @returns whether the rendered columns overflow the visible viewport horizontally, helpful for scrolling calculations
180+
*/
181+
public hasHorizontalOverflow(rowHeaderWidth = Grid.MIN_ROW_HEADER_WIDTH, viewportRect = this.getViewportRect()) {
182+
if (this.grid === undefined) {
183+
return false;
184+
}
185+
return this.grid.getWidth() > viewportRect.width - rowHeaderWidth;
186+
}
187+
161188
// Converters
162189
// ==========
163190

packages/table/src/table2.tsx

+54-20
Original file line numberDiff line numberDiff line change
@@ -207,23 +207,37 @@ export class Table2 extends AbstractComponent2<TableProps, TableState, TableSnap
207207

208208
private refHandlers = {
209209
cellContainer: (ref: HTMLElement | null) => (this.cellContainerElement = ref),
210-
columnHeader: (ref: HTMLElement | null) => (this.columnHeaderElement = ref),
210+
columnHeader: (ref: HTMLElement | null) => {
211+
this.columnHeaderElement = ref;
212+
if (ref != null) {
213+
this.columnHeaderHeight = ref.clientHeight;
214+
}
215+
},
211216
quadrantStack: (ref: TableQuadrantStack) => (this.quadrantStackInstance = ref),
212217
rootTable: (ref: HTMLElement | null) => (this.rootTableElement = ref),
213-
rowHeader: (ref: HTMLElement | null) => (this.rowHeaderElement = ref),
218+
rowHeader: (ref: HTMLElement | null) => {
219+
this.rowHeaderElement = ref;
220+
if (ref != null) {
221+
this.rowHeaderWidth = ref.clientWidth;
222+
}
223+
},
214224
scrollContainer: (ref: HTMLElement | null) => (this.scrollContainerElement = ref),
215225
};
216226

217227
private cellContainerElement?: HTMLElement | null;
218228

219229
private columnHeaderElement?: HTMLElement | null;
220230

231+
private columnHeaderHeight = Grid.MIN_COLUMN_HEADER_HEIGHT;
232+
221233
private quadrantStackInstance?: TableQuadrantStack;
222234

223235
private rootTableElement?: HTMLElement | null;
224236

225237
private rowHeaderElement?: HTMLElement | null;
226238

239+
private rowHeaderWidth = Grid.MIN_ROW_HEADER_WIDTH;
240+
227241
private scrollContainerElement?: HTMLElement | null;
228242

229243
/*
@@ -761,23 +775,32 @@ export class Table2 extends AbstractComponent2<TableProps, TableState, TableSnap
761775
selectedRegionTransform,
762776
} = this.props;
763777

764-
if (this.grid === null || this.locator === undefined || viewportRect === undefined) {
765-
return undefined;
766-
}
767-
768778
const classes = classNames(Classes.TABLE_COLUMN_HEADERS, {
769779
[Classes.TABLE_SELECTION_ENABLED]: isSelectionModeEnabled(
770780
this.props as TablePropsWithDefaults,
771781
RegionCardinality.FULL_COLUMNS,
772782
),
773783
});
774784

775-
const columnIndices = this.grid.getColumnIndicesInRect(viewportRect, enableGhostCells);
785+
if (this.grid === null || this.locator === undefined || viewportRect === undefined) {
786+
// if we haven't mounted yet (which we need in order for grid/viewport calculations),
787+
// we still want to hand a DOM ref over to TableQuadrantStack for later
788+
return <div className={classes} ref={refHandler} />;
789+
}
790+
791+
// if we have horizontal overflow, no need to render ghost columns
792+
// (this avoids problems like https://github.com/palantir/blueprint/issues/5027)
793+
const hasHorizontalOverflow = this.locator.hasHorizontalOverflow(this.rowHeaderWidth, viewportRect);
794+
const columnIndices = this.grid.getColumnIndicesInRect(
795+
viewportRect,
796+
hasHorizontalOverflow ? false : enableGhostCells,
797+
);
798+
776799
const columnIndexStart = showFrozenColumnsOnly ? 0 : columnIndices.columnIndexStart;
777800
const columnIndexEnd = showFrozenColumnsOnly ? this.getMaxFrozenColumnIndex() : columnIndices.columnIndexEnd;
778801

779802
return (
780-
<div className={classes}>
803+
<div className={classes} ref={refHandler}>
781804
<ColumnHeader
782805
defaultColumnWidth={defaultColumnWidth!}
783806
enableMultipleSelection={enableMultipleSelection}
@@ -789,7 +812,6 @@ export class Table2 extends AbstractComponent2<TableProps, TableState, TableSnap
789812
loading={hasLoadingOption(loadingOptions, TableLoadingOption.COLUMN_HEADERS)}
790813
locator={this.locator}
791814
maxColumnWidth={maxColumnWidth!}
792-
measurableElementRef={refHandler}
793815
minColumnWidth={minColumnWidth!}
794816
onColumnWidthChanged={this.handleColumnWidthChanged}
795817
onFocusedCell={this.handleFocus}
@@ -831,18 +853,24 @@ export class Table2 extends AbstractComponent2<TableProps, TableState, TableSnap
831853
selectedRegionTransform,
832854
} = this.props;
833855

834-
if (this.grid === null || this.locator === undefined || viewportRect === undefined) {
835-
return undefined;
836-
}
837-
838856
const classes = classNames(Classes.TABLE_ROW_HEADERS, {
839857
[Classes.TABLE_SELECTION_ENABLED]: isSelectionModeEnabled(
840858
this.props as TablePropsWithDefaults,
841859
RegionCardinality.FULL_ROWS,
842860
),
843861
});
844862

845-
const rowIndices = this.grid.getRowIndicesInRect(viewportRect, enableGhostCells);
863+
if (this.grid === null || this.locator === undefined || viewportRect === undefined) {
864+
// if we haven't mounted yet (which we need in order for grid/viewport calculations),
865+
// we still want to hand a DOM ref over to TableQuadrantStack for later
866+
return <div className={classes} ref={refHandler} />;
867+
}
868+
869+
// if we have vertical overflow, no need to render ghost rows
870+
// (this avoids problems like https://github.com/palantir/blueprint/issues/5027)
871+
const hasVerticalOverflow = this.locator.hasVerticalOverflow(this.columnHeaderHeight, viewportRect);
872+
const rowIndices = this.grid.getRowIndicesInRect(viewportRect, hasVerticalOverflow ? false : enableGhostCells);
873+
846874
const rowIndexStart = showFrozenRowsOnly ? 0 : rowIndices.rowIndexStart;
847875
const rowIndexEnd = showFrozenRowsOnly ? this.getMaxFrozenRowIndex() : rowIndices.rowIndexEnd;
848876

@@ -926,8 +954,15 @@ export class Table2 extends AbstractComponent2<TableProps, TableState, TableSnap
926954
return undefined;
927955
}
928956

929-
const rowIndices = this.grid.getRowIndicesInRect(viewportRect, enableGhostCells);
930-
const columnIndices = this.grid.getColumnIndicesInRect(viewportRect, enableGhostCells);
957+
// if we have vertical/horizontal overflow, no need to render ghost rows/columns (respectively)
958+
// (this avoids problems like https://github.com/palantir/blueprint/issues/5027)
959+
const hasVerticalOverflow = this.locator.hasVerticalOverflow(this.columnHeaderHeight, viewportRect);
960+
const hasHorizontalOverflow = this.locator.hasHorizontalOverflow(this.rowHeaderWidth, viewportRect);
961+
const rowIndices = this.grid.getRowIndicesInRect(viewportRect, hasVerticalOverflow ? false : enableGhostCells);
962+
const columnIndices = this.grid.getColumnIndicesInRect(
963+
viewportRect,
964+
hasHorizontalOverflow ? false : enableGhostCells,
965+
);
931966

932967
// start beyond the frozen area if rendering unrelated quadrants, so we
933968
// don't render duplicate cells underneath the frozen ones.
@@ -1232,13 +1267,12 @@ export class Table2 extends AbstractComponent2<TableProps, TableState, TableSnap
12321267
};
12331268

12341269
private handleBodyScroll = (event: React.SyntheticEvent<HTMLElement>) => {
1235-
// Prevent the event from propagating to avoid a resize event on the
1236-
// resize sensor.
1270+
// Prevent the event from propagating to avoid a resize event on the resize sensor.
12371271
event.stopPropagation();
12381272

12391273
if (this.locator != null && !this.state.isLayoutLocked) {
1240-
const viewportRect = this.locator.getViewportRect();
1241-
this.updateViewportRect(viewportRect);
1274+
const newViewportRect = this.locator.getViewportRect();
1275+
this.updateViewportRect(newViewportRect);
12421276
}
12431277
};
12441278

packages/table/src/tableBody.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ export class TableBody extends AbstractComponent2<ITableBodyProps> {
6262
renderMode: RenderMode.BATCH,
6363
};
6464

65-
// TODO: Does this method need to be public?
66-
// (see: https://github.com/palantir/blueprint/issues/1617)
65+
/**
66+
* @deprecated, will be removed from public API in the next major version
67+
*/
6768
public static cellClassNames(rowIndex: number, columnIndex: number) {
6869
return cellClassNames(rowIndex, columnIndex);
6970
}

packages/table/test/table2Tests.tsx

+86-3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { ColumnIndices, RowIndices } from "../src/common/grid";
3131
import { Rect } from "../src/common/rect";
3232
import { RenderMode } from "../src/common/renderMode";
3333
import { TableQuadrant } from "../src/quadrants/tableQuadrant";
34+
import { TableQuadrantStack } from "../src/quadrants/tableQuadrantStack";
3435
import { IRegion, Regions } from "../src/regions";
3536
import { TableState } from "../src/tableState";
3637
import { CellType, expectCellLoading } from "./cellTestUtils";
@@ -208,12 +209,35 @@ describe("<Table2>", function (this) {
208209
});
209210
});
210211

211-
function mountTable(tableProps: Partial<TableProps> = {}) {
212+
it("does not render ghost columns when there is horizontal overflow", () => {
213+
const { containerElement } = mountTable(
214+
{ numRows: 2, defaultRowHeight: 20, defaultColumnWidth: 100 },
215+
{
216+
height: 200,
217+
// 300px leaves just enough space for the 3 columns, but there is 30px taken up by
218+
// the row header, which will overflow.
219+
width: 300,
220+
},
221+
);
222+
const numGhostCellsInFirstRow = containerElement.querySelectorAll(
223+
`.${Classes.TABLE_CELL_GHOST}.${Classes.rowCellIndexClass(0)}`,
224+
).length;
225+
expect(numGhostCellsInFirstRow).to.be.eq(0);
226+
227+
// cleanup
228+
document.body.removeChild(containerElement);
229+
});
230+
231+
function mountTable(
232+
tableProps: Partial<TableProps> = {},
233+
tableDimensions: { width: number; height: number } = { width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT },
234+
) {
212235
const containerElement = document.createElement("div");
213-
containerElement.style.width = `${CONTAINER_WIDTH}px`;
214-
containerElement.style.height = `${CONTAINER_HEIGHT}px`;
236+
containerElement.style.width = `${tableDimensions.width}px`;
237+
containerElement.style.height = `${tableDimensions.height}px`;
215238
document.body.appendChild(containerElement);
216239

240+
TableQuadrantStack.defaultProps.throttleScrolling = false;
217241
const table = mount(
218242
<Table2 numRows={0} enableGhostCells={true} {...tableProps}>
219243
<Column cellRenderer={renderDummyCell} />
@@ -226,6 +250,65 @@ describe("<Table2>", function (this) {
226250
}
227251
});
228252

253+
describe("Vertically scrolling", () => {
254+
runTestToEnsureScrollingIsEnabled(true);
255+
runTestToEnsureScrollingIsEnabled(false);
256+
257+
it("does not render ghost rows when there is vertical overflow", () => {
258+
const { containerElement } = mountTable(
259+
{ defaultRowHeight: 20, enableGhostCells: true },
260+
{
261+
// we need _some_ amount of vertical overflow to avoid the code path which disables vertical scroll
262+
// in the table altogether. 200px leaves just enough space for the rows, but there is 30px taken up by
263+
// the column header, which will overflow.
264+
height: 200,
265+
width: 300,
266+
},
267+
);
268+
const numGhostCellsInFirstColumn = containerElement.querySelectorAll(
269+
`.${Classes.TABLE_CELL_GHOST}.${Classes.columnCellIndexClass(0)}`,
270+
).length;
271+
expect(numGhostCellsInFirstColumn).to.be.eq(0);
272+
273+
// cleanup
274+
document.body.removeChild(containerElement);
275+
});
276+
277+
function runTestToEnsureScrollingIsEnabled(enableGhostCells: boolean) {
278+
it(`isn't disabled when there is half a row left to scroll to and enableGhostCells is set to ${enableGhostCells}`, () => {
279+
const { containerElement, table } = mountTable(
280+
{ defaultRowHeight: 30, enableGhostCells },
281+
{
282+
height: 320,
283+
width: 300,
284+
},
285+
);
286+
const tableContainer = table.find(`.${Classes.TABLE_CONTAINER}`);
287+
// There should be 10px left of scrolling. Height is 320, rows take up 300, and headerRow takes up 30
288+
expect(tableContainer.hasClass(Classes.TABLE_NO_VERTICAL_SCROLL)).to.be.false;
289+
290+
// clean up created div
291+
document.body.removeChild(containerElement);
292+
});
293+
}
294+
295+
function mountTable(tableProps: Partial<TableProps> = {}, tableDimensions: { width: number; height: number }) {
296+
const containerElement = document.createElement("div");
297+
containerElement.style.width = `${tableDimensions.width}px`;
298+
containerElement.style.height = `${tableDimensions.height}px`;
299+
document.body.appendChild(containerElement);
300+
301+
TableQuadrantStack.defaultProps.throttleScrolling = false;
302+
const table = mount(
303+
<Table2 numRows={10} {...tableProps}>
304+
<Column cellRenderer={renderDummyCell} />
305+
</Table2>,
306+
{ attachTo: containerElement },
307+
);
308+
return { containerElement, table };
309+
}
310+
});
311+
229312
describe("Instance methods", () => {
230313
describe("resizeRowsByApproximateHeight", () => {
231314
const STR_LENGTH_SHORT = 10;

packages/table/test/tableBodyTests.tsx

+3-5
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { RenderMode } from "../src/common/renderMode";
2828
import { MenuContext } from "../src/interactions/menus/menuContext";
2929
import { IRegion, Regions } from "../src/regions";
3030
import { ITableBodyProps, TableBody } from "../src/tableBody";
31+
import { cellClassNames } from "../src/tableBodyCells";
3132

3233
describe("TableBody", () => {
3334
// use enough rows that batching won't render all of them in one pass.
@@ -40,11 +41,8 @@ describe("TableBody", () => {
4041
const ROW_HEIGHT = 20;
4142

4243
it("cellClassNames", () => {
43-
expect(TableBody.cellClassNames(0, 0)).to.deep.equal([
44-
Classes.rowCellIndexClass(0),
45-
Classes.columnCellIndexClass(0),
46-
]);
47-
expect(TableBody.cellClassNames(4096, 1024)).to.deep.equal([
44+
expect(cellClassNames(0, 0)).to.deep.equal([Classes.rowCellIndexClass(0), Classes.columnCellIndexClass(0)]);
45+
expect(cellClassNames(4096, 1024)).to.deep.equal([
4846
Classes.rowCellIndexClass(4096),
4947
Classes.columnCellIndexClass(1024),
5048
]);

0 commit comments

Comments
 (0)