Skip to content

Commit 6b5485f

Browse files
committed
feat(cdk-scrollable): add methods to normalize scrolling in RTL
1 parent ea10d94 commit 6b5485f

File tree

5 files changed

+475
-20
lines changed

5 files changed

+475
-20
lines changed

src/cdk/platform/features.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/** Cached result of whether the user's browser supports passive event listeners. */
1010
let supportsPassiveEvents: boolean;
11+
let rtlScrollAxisType: 'normal' | 'negated' | 'inverted';
1112

1213
/**
1314
* Checks whether the user's browser supports passive event listeners.
@@ -89,3 +90,45 @@ export function getSupportedInputTypes(): Set<string> {
8990

9091
return supportedInputTypes;
9192
}
93+
94+
/**
95+
* Checks the type of RTL scroll axis used by this browser. The possible values are
96+
* - normal: scrollLeft is 0 when scrolled all the way left and (scrollWidth - clientWidth) when
97+
* scrolled all the way right.
98+
* - negated: scrollLeft is -(scrollWidth - clientWidth) when scrolled all the way left and 0 when
99+
* scrolled all the way right.
100+
* - inverted: scrollLeft is (scrollWidth - clientWidth) when scrolled all the way left and 0 when
101+
* scrolled all the way right.
102+
*/
103+
export function getRtlScrollAxisType(): 'normal' | 'negated' | 'inverted' {
104+
// We can't check unless we're on the browser. Just assume 'normal' if we're not.
105+
if (typeof document !== 'object' || !document) {
106+
return 'normal';
107+
}
108+
109+
if (!rtlScrollAxisType) {
110+
const viewport = document.createElement('div');
111+
viewport.dir = 'rtl';
112+
viewport.style.height = '1px';
113+
viewport.style.width = '1px';
114+
viewport.style.overflow = 'auto';
115+
viewport.style.visibility = 'hidden';
116+
viewport.style.pointerEvents = 'none';
117+
viewport.style.position = 'absolute';
118+
119+
const content = document.createElement('div');
120+
content.style.width = '2px';
121+
content.style.height = '1px';
122+
123+
viewport.appendChild(content);
124+
document.body.appendChild(viewport);
125+
126+
rtlScrollAxisType = 'normal';
127+
if (viewport.scrollLeft == 0) {
128+
viewport.scrollLeft = 1;
129+
rtlScrollAxisType = viewport.scrollLeft == 0 ? 'negated' : 'inverted';
130+
}
131+
document.body.removeChild(viewport);
132+
}
133+
return rtlScrollAxisType;
134+
}

src/cdk/scrolling/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ng_module(
1010
module_name = "@angular/cdk/scrolling",
1111
assets = [":virtual-scroll-viewport.css"] + glob(["**/*.html"]),
1212
deps = [
13+
"//src/cdk/bidi",
1314
"//src/cdk/coercion",
1415
"//src/cdk/collections",
1516
"//src/cdk/platform",
@@ -29,6 +30,7 @@ ts_library(
2930
srcs = glob(["**/*.spec.ts"]),
3031
deps = [
3132
":scrolling",
33+
"//src/cdk/bidi",
3234
"//src/cdk/collections",
3335
"//src/cdk/testing",
3436
"@rxjs",

src/cdk/scrolling/scrollable.spec.ts

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import {Direction} from '@angular/cdk/bidi';
2+
import {CdkScrollable, ScrollingModule} from '@angular/cdk/scrolling';
3+
import {Component, ElementRef, Input, ViewChild} from '@angular/core';
4+
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
5+
6+
function checkIntersecting(r1: {top: number, left: number, bottom: number, right: number},
7+
r2: {top: number, left: number, bottom: number, right: number},
8+
expected = true) {
9+
const actual =
10+
r1.left < r2.right && r1.right > r2.left && r1.top < r2.bottom && r1.bottom > r2.top;
11+
if (expected) {
12+
expect(actual)
13+
.toBe(expected, `${JSON.stringify(r1)} should intersect with ${JSON.stringify(r2)}`);
14+
} else {
15+
expect(actual)
16+
.toBe(expected, `${JSON.stringify(r1)} should not intersect with ${JSON.stringify(r2)}`);
17+
}
18+
}
19+
20+
describe('CdkScrollable', () => {
21+
let fixture: ComponentFixture<ScrollableViewport>;
22+
let testComponent: ScrollableViewport;
23+
24+
beforeEach(async(() => {
25+
TestBed.configureTestingModule({
26+
imports: [ScrollingModule],
27+
declarations: [ScrollableViewport],
28+
}).compileComponents();
29+
30+
fixture = TestBed.createComponent(ScrollableViewport);
31+
testComponent = fixture.componentInstance;
32+
}));
33+
34+
describe('in LTR context', () => {
35+
let maxOffset = 0;
36+
37+
beforeEach(() => {
38+
fixture.detectChanges();
39+
maxOffset = testComponent.viewport.nativeElement.scrollHeight -
40+
testComponent.viewport.nativeElement.clientHeight;
41+
});
42+
43+
it('should initially be scrolled to top-left', () => {
44+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
45+
testComponent.topStart.nativeElement.getBoundingClientRect(), true);
46+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
47+
testComponent.topEnd.nativeElement.getBoundingClientRect(), false);
48+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
49+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), false);
50+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
51+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
52+
53+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0);
54+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset);
55+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0);
56+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset);
57+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0);
58+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset);
59+
});
60+
61+
it('should scrollTo top-left', () => {
62+
testComponent.scrollable.scrollTo({top: 0, left: 0});
63+
64+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
65+
testComponent.topStart.nativeElement.getBoundingClientRect(), true);
66+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
67+
testComponent.topEnd.nativeElement.getBoundingClientRect(), false);
68+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
69+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), false);
70+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
71+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
72+
73+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0);
74+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset);
75+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0);
76+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset);
77+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0);
78+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset);
79+
});
80+
81+
it('should scrollTo bottom-right', () => {
82+
testComponent.scrollable.scrollTo({bottom: 0, right: 0});
83+
84+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
85+
testComponent.topStart.nativeElement.getBoundingClientRect(), false);
86+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
87+
testComponent.topEnd.nativeElement.getBoundingClientRect(), false);
88+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
89+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), false);
90+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
91+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), true);
92+
93+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset);
94+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0);
95+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset);
96+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0);
97+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset);
98+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0);
99+
});
100+
101+
it('should scroll to top-end', () => {
102+
testComponent.scrollable.scrollTo({top: 0, end: 0});
103+
104+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
105+
testComponent.topStart.nativeElement.getBoundingClientRect(), false);
106+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
107+
testComponent.topEnd.nativeElement.getBoundingClientRect(), true);
108+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
109+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), false);
110+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
111+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
112+
113+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0);
114+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset);
115+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset);
116+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0);
117+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset);
118+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0);
119+
});
120+
121+
it('should scroll to bottom-start', () => {
122+
testComponent.scrollable.scrollTo({bottom: 0, start: 0});
123+
124+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
125+
testComponent.topStart.nativeElement.getBoundingClientRect(), false);
126+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
127+
testComponent.topEnd.nativeElement.getBoundingClientRect(), false);
128+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
129+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), true);
130+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
131+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
132+
133+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset);
134+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0);
135+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0);
136+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset);
137+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0);
138+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset);
139+
});
140+
});
141+
142+
describe('in RTL context', () => {
143+
let maxOffset = 0;
144+
145+
beforeEach(() => {
146+
testComponent.dir = 'rtl';
147+
fixture.detectChanges();
148+
maxOffset = testComponent.viewport.nativeElement.scrollHeight -
149+
testComponent.viewport.nativeElement.clientHeight;
150+
});
151+
152+
it('should initially be scrolled to top-right', () => {
153+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
154+
testComponent.topStart.nativeElement.getBoundingClientRect(), true);
155+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
156+
testComponent.topEnd.nativeElement.getBoundingClientRect(), false);
157+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
158+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), false);
159+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
160+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
161+
162+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0);
163+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset);
164+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset);
165+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0);
166+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0);
167+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset);
168+
});
169+
170+
it('should scrollTo top-left', () => {
171+
testComponent.scrollable.scrollTo({top: 0, left: 0});
172+
173+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
174+
testComponent.topStart.nativeElement.getBoundingClientRect(), false);
175+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
176+
testComponent.topEnd.nativeElement.getBoundingClientRect(), true);
177+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
178+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), false);
179+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
180+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
181+
182+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0);
183+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset);
184+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0);
185+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset);
186+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset);
187+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0);
188+
});
189+
190+
it('should scrollTo bottom-right', () => {
191+
testComponent.scrollable.scrollTo({bottom: 0, right: 0});
192+
193+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
194+
testComponent.topStart.nativeElement.getBoundingClientRect(), false);
195+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
196+
testComponent.topEnd.nativeElement.getBoundingClientRect(), false);
197+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
198+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), true);
199+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
200+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
201+
202+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset);
203+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0);
204+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset);
205+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0);
206+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0);
207+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset);
208+
});
209+
210+
it('should scroll to top-end', () => {
211+
testComponent.scrollable.scrollTo({top: 0, end: 0});
212+
213+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
214+
testComponent.topStart.nativeElement.getBoundingClientRect(), false);
215+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
216+
testComponent.topEnd.nativeElement.getBoundingClientRect(), true);
217+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
218+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), false);
219+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
220+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
221+
222+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0);
223+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset);
224+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0);
225+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset);
226+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset);
227+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0);
228+
});
229+
230+
it('should scroll to bottom-start', () => {
231+
testComponent.scrollable.scrollTo({bottom: 0, start: 0});
232+
233+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
234+
testComponent.topStart.nativeElement.getBoundingClientRect(), false);
235+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
236+
testComponent.topEnd.nativeElement.getBoundingClientRect(), false);
237+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
238+
testComponent.bottomStart.nativeElement.getBoundingClientRect(), true);
239+
checkIntersecting(testComponent.viewport.nativeElement.getBoundingClientRect(),
240+
testComponent.bottomEnd.nativeElement.getBoundingClientRect(), false);
241+
242+
expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset);
243+
expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0);
244+
expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset);
245+
expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0);
246+
expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0);
247+
expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset);
248+
});
249+
});
250+
});
251+
252+
@Component({
253+
template: `
254+
<div #viewport class="viewport" cdkScrollable [dir]="dir">
255+
<div class="row">
256+
<div #topStart class="cell"></div>
257+
<div #topEnd class="cell"></div>
258+
</div>
259+
<div class="row">
260+
<div #bottomStart class="cell"></div>
261+
<div #bottomEnd class="cell"></div>
262+
</div>
263+
</div>`,
264+
styles: [`
265+
.viewport {
266+
width: 100px;
267+
height: 100px;
268+
overflow: auto;
269+
}
270+
271+
.row {
272+
display: flex;
273+
flex-direction: row;
274+
}
275+
276+
.cell {
277+
flex: none;
278+
width: 100px;
279+
height: 100px;
280+
}
281+
`]
282+
})
283+
class ScrollableViewport {
284+
@Input() dir: Direction;
285+
@ViewChild(CdkScrollable) scrollable: CdkScrollable;
286+
@ViewChild('viewport') viewport: ElementRef<HTMLElement>;
287+
@ViewChild('topStart') topStart: ElementRef<HTMLElement>;
288+
@ViewChild('topEnd') topEnd: ElementRef<HTMLElement>;
289+
@ViewChild('bottomStart') bottomStart: ElementRef<HTMLElement>;
290+
@ViewChild('bottomEnd') bottomEnd: ElementRef<HTMLElement>;
291+
}

0 commit comments

Comments
 (0)