Skip to content

Commit e4ee71d

Browse files
committed
feat(cdk-experimental/table-scroll-container): Create directive and demo
Adds the CdkTableScrollContainer and some hooks in CdkTable for it. For Webkit/Blink browsers, this sizes the scroll bars in coordinating with sticky rows/columns in the table to create the user experience that a lot of commenters in #5885 seemed to want but without the accessibility problems of other approaches.
1 parent 029136e commit e4ee71d

19 files changed

+498
-10
lines changed

src/cdk-experimental/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [
88
"popover-edit",
99
"scrolling",
1010
"selection",
11+
"table-scroll-container",
1112
]
1213

1314
# List of all entry-point targets of the Angular cdk-experimental package.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
load("//tools:defaults.bzl", "ng_module")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_module(
6+
name = "table-scroll-container",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
module_name = "@angular/cdk-experimental/table-scroll-container",
12+
deps = [
13+
"//src/cdk/bidi",
14+
"//src/cdk/table",
15+
"@npm//@angular/common",
16+
"@npm//@angular/core",
17+
"@npm//rxjs",
18+
],
19+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export * from './public-api';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export * from './table-scroll-container';
10+
export * from './table-scroll-container-module';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {NgModule} from '@angular/core';
10+
11+
import {CdkTableScrollContainer} from './table-scroll-container';
12+
13+
@NgModule({
14+
declarations: [CdkTableScrollContainer],
15+
exports: [CdkTableScrollContainer],
16+
})
17+
export class CdkTableScrollContainerModule {}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {Directive, ElementRef, Inject, OnDestroy, Optional} from '@angular/core';
2+
import {DOCUMENT} from '@angular/common';
3+
import {Directionality} from '@angular/cdk/bidi';
4+
import {StickyPositioningListener} from '@angular/cdk/table';
5+
6+
let nextId = 0;
7+
8+
type Orientation = 'horizontal' | 'vertical';
9+
10+
@Directive({
11+
selector: '[cdkTableScrollContainer]',
12+
providers: [
13+
{provide: StickyPositioningListener, useExisting: CdkTableScrollContainer},
14+
],
15+
})
16+
export class CdkTableScrollContainer implements StickyPositioningListener,
17+
OnDestroy {
18+
protected readonly CLASS_NAME = 'cdk-table-scroll-container';
19+
20+
private readonly _document: Document;
21+
private readonly _element: HTMLElement;
22+
private readonly _uniqueClassName: string;
23+
private readonly _ruleIndexes = new Map<Orientation, number>();
24+
25+
private _styleElement?: HTMLStyleElement;
26+
private _indexSequence = 0;
27+
private _startSizes: (number|null|undefined)[] = [];
28+
private _endSizes: (number|null|undefined)[] = [];
29+
private _headerSizes: (number|null|undefined)[] = [];
30+
private _footerSizes: (number|null|undefined)[] = [];
31+
32+
constructor(
33+
@Optional() private readonly _directionality: Directionality,
34+
elementRef: ElementRef<HTMLElement>,
35+
@Inject(DOCUMENT) document: any) {
36+
this._document = document;
37+
this._element = elementRef.nativeElement;
38+
this._uniqueClassName = `cdk-table-scroll-container-${++nextId}`;
39+
this._element.classList.add(this.CLASS_NAME);
40+
this._element.classList.add(this._uniqueClassName);
41+
}
42+
43+
ngOnDestroy(): void {
44+
// TODO: Use remove() once we're off IE11.
45+
if (this._styleElement?.parentNode) {
46+
this._styleElement.parentNode.removeChild(this._styleElement);
47+
this._styleElement = undefined;
48+
}
49+
}
50+
51+
stickyColsUpdated(sizes: (number|null|undefined)[]): void {
52+
this._startSizes = sizes;
53+
this._updateHorizontal();
54+
}
55+
56+
stickyEndColsUpdated(sizes: (number|null|undefined)[]): void {
57+
this._endSizes = sizes;
58+
this._updateHorizontal();
59+
}
60+
61+
stickyHeaderRowsUpdated(sizes: (number|null|undefined)[]): void {
62+
this._headerSizes = sizes;
63+
this._updateVertical();
64+
}
65+
66+
stickyFooterRowsUpdated(sizes: (number|null|undefined)[]): void {
67+
this._footerSizes = sizes;
68+
this._updateVertical();
69+
}
70+
71+
private _updateHorizontal(): void {
72+
const startMargin = computeMargin(this._startSizes);
73+
const endMargin = computeMargin(this._endSizes);
74+
75+
if (startMargin === 0 && endMargin === 0) {
76+
this._applyCss('horizontal', '');
77+
return;
78+
}
79+
80+
const direction = this._directionality ? this._directionality.value : 'ltr';
81+
const leftMargin = direction === 'rtl' ? endMargin : startMargin;
82+
const rightMargin = direction === 'rtl' ? startMargin : endMargin;
83+
84+
this._applyCss('horizontal', `0 ${rightMargin}px 0 ${endMargin}px`);
85+
}
86+
87+
private _updateVertical(): void {
88+
const topMargin = computeMargin(this._headerSizes);
89+
const bottomMargin = computeMargin(this._footerSizes);
90+
91+
if (topMargin === 0 && bottomMargin === 0) {
92+
this._applyCss('vertical', '');
93+
return;
94+
}
95+
96+
this._applyCss('vertical', `${topMargin}px 0 ${bottomMargin}px`);
97+
}
98+
99+
private _getStyleSheet(): CSSStyleSheet {
100+
if (!this._styleElement) {
101+
this._styleElement = this._document.createElement('style');
102+
this._styleElement.appendChild(this._document.createTextNode(''));
103+
this._document.head.appendChild(this._styleElement);
104+
}
105+
106+
return this._styleElement.sheet as CSSStyleSheet;
107+
}
108+
109+
private _applyCss(orientation: Orientation, value: string) {
110+
let index = this._ruleIndexes.get(orientation);
111+
if (index === undefined) {
112+
if (!value) {
113+
return;
114+
}
115+
116+
index = this._indexSequence++;
117+
this._ruleIndexes.set(orientation, index);
118+
} else {
119+
this._getStyleSheet().deleteRule(index);
120+
}
121+
122+
const selector = `.${this._uniqueClassName}::-webkit-scrollbar-track:${orientation}`;
123+
this._getStyleSheet().insertRule(`${selector} {margin: ${value}}`, index!);
124+
}
125+
}
126+
127+
function computeMargin(sizes: (number|null|undefined)[]): number {
128+
let margin = 0;
129+
for (const size of sizes) {
130+
if (size == null) {
131+
break;
132+
}
133+
margin += size;
134+
}
135+
return margin;
136+
}

src/cdk/table/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './coalesced-style-scheduler';
1212
export * from './row';
1313
export * from './table-module';
1414
export * from './sticky-styler';
15+
export * from './sticky-position-listener';
1516
export * from './can-stick';
1617
export * from './text-column';
1718
export * from './tokens';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {Injectable} from '@angular/core';
2+
3+
@Injectable()
4+
export abstract class StickyPositioningListener {
5+
abstract stickyColsUpdated(sizes: (number|null|undefined)[]): void;
6+
7+
abstract stickyEndColsUpdated(sizes: (number|null|undefined)[]): void;
8+
9+
abstract stickyHeaderRowsUpdated(sizes: (number|null|undefined)[]): void;
10+
11+
abstract stickyFooterRowsUpdated(sizes: (number|null|undefined)[]): void;
12+
}

src/cdk/table/sticky-styler.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313
import {Direction} from '@angular/cdk/bidi';
1414
import {_CoalescedStyleScheduler} from './coalesced-style-scheduler';
15+
import {StickyPositioningListener} from './sticky-position-listener';
1516

1617
export type StickyDirection = 'top' | 'bottom' | 'left' | 'right';
1718

@@ -40,6 +41,8 @@ export class StickyStyler {
4041
* @param _needsPositionStickyOnElement Whether we need to specify position: sticky on cells
4142
* using inline styles. If false, it is assumed that position: sticky is included in
4243
* the component stylesheet for _stickCellCss.
44+
* @param _positionListener A listener that is notified of changes to sticky rows/columns
45+
* and their dimensions.
4346
*/
4447
constructor(private _isNativeHtmlTable: boolean,
4548
private _stickCellCss: string,
@@ -50,7 +53,8 @@ export class StickyStyler {
5053
*/
5154
private _coalescedStyleScheduler?: _CoalescedStyleScheduler,
5255
private _isBrowser = true,
53-
private readonly _needsPositionStickyOnElement = true) { }
56+
private readonly _needsPositionStickyOnElement = true,
57+
private readonly _positionListener?: StickyPositioningListener) { }
5458

5559
/**
5660
* Clears the sticky positioning styles from the row and its cells by resetting the `position`
@@ -107,6 +111,9 @@ export class StickyStyler {
107111
const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
108112
const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);
109113

114+
const lastStickyStart = stickyStartStates.lastIndexOf(true);
115+
const firstStickyEnd = stickyEndStates.indexOf(true);
116+
110117
// Coalesce with sticky row updates (and potentially other changes like column resize).
111118
this._scheduleStyleChanges(() => {
112119
const isRtl = this.direction === 'rtl';
@@ -125,6 +132,20 @@ export class StickyStyler {
125132
}
126133
}
127134
}
135+
136+
if (this._positionListener) {
137+
this._positionListener.stickyColsUpdated(lastStickyStart === -1 ?
138+
[] :
139+
cellWidths
140+
.slice(0, lastStickyStart)
141+
.map((width, index) => stickyStartStates[index] ? width : null));
142+
this._positionListener.stickyEndColsUpdated(firstStickyEnd === -1 ?
143+
[] :
144+
cellWidths
145+
.slice(firstStickyEnd)
146+
.map((width, index) => stickyEndStates[index + firstStickyEnd] ? width : null)
147+
.reverse());
148+
}
128149
});
129150
}
130151

@@ -152,10 +173,11 @@ export class StickyStyler {
152173
const states = position === 'bottom' ? stickyStates.slice().reverse() : stickyStates;
153174

154175
// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
155-
const stickyHeights: number[] = [];
176+
const stickyOffsets: number[] = [];
177+
const stickyCellHeights: (number|undefined)[] = [];
156178
const elementsToStick: HTMLElement[][] = [];
157-
for (let rowIndex = 0, stickyHeight = 0; rowIndex < rows.length; rowIndex++) {
158-
stickyHeights[rowIndex] = stickyHeight;
179+
for (let rowIndex = 0, stickyOffset = 0; rowIndex < rows.length; rowIndex++) {
180+
stickyOffsets[rowIndex] = stickyOffset;
159181

160182
if (!states[rowIndex]) {
161183
continue;
@@ -165,9 +187,9 @@ export class StickyStyler {
165187
elementsToStick[rowIndex] = this._isNativeHtmlTable ?
166188
Array.from(row.children) as HTMLElement[] : [row];
167189

168-
if (rowIndex !== rows.length - 1) {
169-
stickyHeight += row.getBoundingClientRect().height;
170-
}
190+
const height = row.getBoundingClientRect().height;
191+
stickyOffset += height;
192+
stickyCellHeights[rowIndex] = height;
171193
}
172194

173195
// Coalesce with other sticky row updates (top/bottom), sticky columns updates
@@ -178,11 +200,17 @@ export class StickyStyler {
178200
continue;
179201
}
180202

181-
const height = stickyHeights[rowIndex];
203+
const offset = stickyOffsets[rowIndex];
182204
for (const element of elementsToStick[rowIndex]) {
183-
this._addStickyStyle(element, position, height);
205+
this._addStickyStyle(element, position, offset);
184206
}
185207
}
208+
209+
if (position === 'top') {
210+
this._positionListener?.stickyHeaderRowsUpdated(stickyCellHeights);
211+
} else {
212+
this._positionListener?.stickyFooterRowsUpdated(stickyCellHeights);
213+
}
186214
});
187215
}
188216

src/cdk/table/table.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
OnInit,
4343
Optional,
4444
QueryList,
45+
SkipSelf,
4546
TemplateRef,
4647
TrackByFunction,
4748
ViewChild,
@@ -78,6 +79,7 @@ import {
7879
getTableUnknownColumnError,
7980
getTableUnknownDataSourceError
8081
} from './table-errors';
82+
import {StickyPositioningListener} from './sticky-position-listener';
8183
import {CDK_TABLE} from './tokens';
8284

8385
/** Interface used to provide an outlet for rows to be inserted into. */
@@ -203,6 +205,8 @@ export interface RenderRow<T> {
203205
{provide: CDK_TABLE, useExisting: CdkTable},
204206
{provide: _VIEW_REPEATER_STRATEGY, useClass: _DisposeViewRepeaterStrategy},
205207
{provide: _COALESCED_STYLE_SCHEDULER, useClass: _CoalescedStyleScheduler},
208+
// Prevent nested tables from seeing this table's StickyPositioningListener.
209+
{provide: StickyPositioningListener, useValue: null},
206210
]
207211
})
208212
export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDestroy, OnInit {
@@ -492,6 +496,8 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
492496
protected readonly _viewRepeater?: _ViewRepeater<T, RenderRow<T>, RowContext<T>>,
493497
@Optional() @Inject(_COALESCED_STYLE_SCHEDULER)
494498
protected readonly _coalescedStyleScheduler?: _CoalescedStyleScheduler,
499+
@Optional() @SkipSelf()
500+
protected readonly _stickyPositioningListener?: StickyPositioningListener,
495501
// Optional for backwards compatibility. The viewport ruler is provided in root. Therefore,
496502
// this property will never be null.
497503
// tslint:disable-next-line: lightweight-tokens
@@ -1220,7 +1226,8 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
12201226
const direction: Direction = this._dir ? this._dir.value : 'ltr';
12211227
this._stickyStyler = new StickyStyler(
12221228
this._isNativeHtmlTable, this.stickyCssClass, direction, this._coalescedStyleScheduler,
1223-
this._platform.isBrowser, this.needsPositionStickyOnElement);
1229+
this._platform.isBrowser, this.needsPositionStickyOnElement,
1230+
this._stickyPositioningListener);
12241231
(this._dir ? this._dir.change : observableOf<Direction>())
12251232
.pipe(takeUntil(this._onDestroy))
12261233
.subscribe(value => {

src/dev-app/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ ng_module(
8686
"//src/dev-app/snack-bar",
8787
"//src/dev-app/stepper",
8888
"//src/dev-app/table",
89+
"//src/dev-app/table-scroll-container",
8990
"//src/dev-app/tabs",
9091
"//src/dev-app/toolbar",
9192
"//src/dev-app/tooltip",

src/dev-app/dev-app/dev-app-layout.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export class DevAppLayout {
7474
{name: 'Snack Bar', route: '/snack-bar'},
7575
{name: 'Stepper', route: '/stepper'},
7676
{name: 'Table', route: '/table'},
77+
{name: 'Table Scroll Container', route: '/table-scroll-container'},
7778
{name: 'Tabs', route: '/tabs'},
7879
{name: 'Toolbar', route: '/toolbar'},
7980
{name: 'Tooltip', route: '/tooltip'},

0 commit comments

Comments
 (0)