From 028746ad8c856a6d270fcfd6d48c8010fc3abd10 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 13 Aug 2018 10:58:51 -0700 Subject: [PATCH] feat(cdk-scrollable): add methods to normalize scrolling in RTL (#12607) * feat(cdk-scrollable): add methods to normalize scrolling in RTL * address comments --- .../{features.ts => features/input-types.ts} | 27 -- .../platform/features/passive-listeners.ts | 28 ++ src/cdk/platform/features/scrolling.ts | 84 ++++++ src/cdk/platform/public-api.ts | 4 +- src/cdk/scrolling/BUILD.bazel | 2 + src/cdk/scrolling/scrollable.spec.ts | 248 ++++++++++++++++++ src/cdk/scrolling/scrollable.ts | 149 ++++++++++- src/cdk/scrolling/scrolling-module.ts | 4 +- 8 files changed, 510 insertions(+), 36 deletions(-) rename src/cdk/platform/{features.ts => features/input-types.ts} (65%) create mode 100644 src/cdk/platform/features/passive-listeners.ts create mode 100644 src/cdk/platform/features/scrolling.ts create mode 100644 src/cdk/scrolling/scrollable.spec.ts diff --git a/src/cdk/platform/features.ts b/src/cdk/platform/features/input-types.ts similarity index 65% rename from src/cdk/platform/features.ts rename to src/cdk/platform/features/input-types.ts index 6b31384fc376..499c045f7c04 100644 --- a/src/cdk/platform/features.ts +++ b/src/cdk/platform/features/input-types.ts @@ -6,33 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -/** Cached result of whether the user's browser supports passive event listeners. */ -let supportsPassiveEvents: boolean; - -/** - * Checks whether the user's browser supports passive event listeners. - * See: https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md - */ -export function supportsPassiveEventListeners(): boolean { - if (supportsPassiveEvents == null && typeof window !== 'undefined') { - try { - window.addEventListener('test', null!, Object.defineProperty({}, 'passive', { - get: () => supportsPassiveEvents = true - })); - } finally { - supportsPassiveEvents = supportsPassiveEvents || false; - } - } - - return supportsPassiveEvents; -} - -/** Check whether the browser supports scroll behaviors. */ -export function supportsScrollBehavior(): boolean { - return !!(document && document.documentElement && document.documentElement.style && - 'scrollBehavior' in document.documentElement.style); -} - /** Cached result Set of input types support by the current browser. */ let supportedInputTypes: Set; diff --git a/src/cdk/platform/features/passive-listeners.ts b/src/cdk/platform/features/passive-listeners.ts new file mode 100644 index 000000000000..c2e1e149ae07 --- /dev/null +++ b/src/cdk/platform/features/passive-listeners.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Cached result of whether the user's browser supports passive event listeners. */ +let supportsPassiveEvents: boolean; + +/** + * Checks whether the user's browser supports passive event listeners. + * See: https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md + */ +export function supportsPassiveEventListeners(): boolean { + if (supportsPassiveEvents == null && typeof window !== 'undefined') { + try { + window.addEventListener('test', null!, Object.defineProperty({}, 'passive', { + get: () => supportsPassiveEvents = true + })); + } finally { + supportsPassiveEvents = supportsPassiveEvents || false; + } + } + + return supportsPassiveEvents; +} diff --git a/src/cdk/platform/features/scrolling.ts b/src/cdk/platform/features/scrolling.ts new file mode 100644 index 000000000000..34f3a397ca64 --- /dev/null +++ b/src/cdk/platform/features/scrolling.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** The possible ways the browser may handle the horizontal scroll axis in RTL languages. */ +export enum RtlScrollAxisType { + /** + * scrollLeft is 0 when scrolled all the way left and (scrollWidth - clientWidth) when scrolled + * all the way right. + */ + NORMAL, + /** + * scrollLeft is -(scrollWidth - clientWidth) when scrolled all the way left and 0 when scrolled + * all the way right. + */ + NEGATED, + /** + * scrollLeft is (scrollWidth - clientWidth) when scrolled all the way left and 0 when scrolled + * all the way right. + */ + INVERTED +} + +/** Cached result of the way the browser handles the horizontal scroll axis in RTL mode. */ +let rtlScrollAxisType: RtlScrollAxisType; + +/** Check whether the browser supports scroll behaviors. */ +export function supportsScrollBehavior(): boolean { + return !!(typeof document == 'object' && 'scrollBehavior' in document.documentElement.style); +} + +/** + * Checks the type of RTL scroll axis used by this browser. As of time of writing, Chrome is NORMAL, + * Firefox & Safari are NEGATED, and IE & Edge are INVERTED. + */ +export function getRtlScrollAxisType(): RtlScrollAxisType { + // We can't check unless we're on the browser. Just assume 'normal' if we're not. + if (typeof document !== 'object' || !document) { + return RtlScrollAxisType.NORMAL; + } + + if (!rtlScrollAxisType) { + // Create a 1px wide scrolling container and a 2px wide content element. + const scrollContainer = document.createElement('div'); + const containerStyle = scrollContainer.style; + scrollContainer.dir = 'rtl'; + containerStyle.height = '1px'; + containerStyle.width = '1px'; + containerStyle.overflow = 'auto'; + containerStyle.visibility = 'hidden'; + containerStyle.pointerEvents = 'none'; + containerStyle.position = 'absolute'; + + const content = document.createElement('div'); + const contentStyle = content.style; + contentStyle.width = '2px'; + contentStyle.height = '1px'; + + scrollContainer.appendChild(content); + document.body.appendChild(scrollContainer); + + rtlScrollAxisType = RtlScrollAxisType.NORMAL; + + // The viewport starts scrolled all the way to the right in RTL mode. If we are in a NORMAL + // browser this would mean that the scrollLeft should be 1. If it's zero instead we know we're + // dealing with one of the other two types of browsers. + if (scrollContainer.scrollLeft === 0) { + // In a NEGATED browser the scrollLeft is always somewhere in [-maxScrollAmount, 0]. For an + // INVERTED browser it is always somewhere in [0, maxScrollAmount]. We can determine which by + // setting to the scrollLeft to 1. This is past the max for a NEGATED browser, so it will + // return 0 when we read it again. + scrollContainer.scrollLeft = 1; + rtlScrollAxisType = + scrollContainer.scrollLeft === 0 ? RtlScrollAxisType.NEGATED : RtlScrollAxisType.INVERTED; + } + + scrollContainer.parentNode!.removeChild(scrollContainer); + } + return rtlScrollAxisType; +} diff --git a/src/cdk/platform/public-api.ts b/src/cdk/platform/public-api.ts index 18ad6c9e4cec..6e29689bd39e 100644 --- a/src/cdk/platform/public-api.ts +++ b/src/cdk/platform/public-api.ts @@ -7,5 +7,7 @@ */ export * from './platform'; -export * from './features'; export * from './platform-module'; +export * from './features/input-types'; +export * from './features/passive-listeners'; +export * from './features/scrolling'; diff --git a/src/cdk/scrolling/BUILD.bazel b/src/cdk/scrolling/BUILD.bazel index b22fc55e5b44..84a8315e447a 100644 --- a/src/cdk/scrolling/BUILD.bazel +++ b/src/cdk/scrolling/BUILD.bazel @@ -10,6 +10,7 @@ ng_module( module_name = "@angular/cdk/scrolling", assets = [":virtual-scroll-viewport.css"] + glob(["**/*.html"]), deps = [ + "//src/cdk/bidi", "//src/cdk/coercion", "//src/cdk/collections", "//src/cdk/platform", @@ -29,6 +30,7 @@ ts_library( srcs = glob(["**/*.spec.ts"]), deps = [ ":scrolling", + "//src/cdk/bidi", "//src/cdk/collections", "//src/cdk/testing", "@rxjs", diff --git a/src/cdk/scrolling/scrollable.spec.ts b/src/cdk/scrolling/scrollable.spec.ts new file mode 100644 index 000000000000..df1bafbc7ba9 --- /dev/null +++ b/src/cdk/scrolling/scrollable.spec.ts @@ -0,0 +1,248 @@ +import {Direction} from '@angular/cdk/bidi'; +import {CdkScrollable, ScrollingModule} from '@angular/cdk/scrolling'; +import {Component, ElementRef, Input, ViewChild} from '@angular/core'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; + +function expectOverlapping(el1: ElementRef, el2: ElementRef, expected = true) { + const r1 = el1.nativeElement.getBoundingClientRect(); + const r2 = el2.nativeElement.getBoundingClientRect(); + const actual = + r1.left < r2.right && r1.right > r2.left && r1.top < r2.bottom && r1.bottom > r2.top; + if (expected) { + expect(actual) + .toBe(expected, `${JSON.stringify(r1)} should overlap with ${JSON.stringify(r2)}`); + } else { + expect(actual) + .toBe(expected, `${JSON.stringify(r1)} should not overlap with ${JSON.stringify(r2)}`); + } +} + +describe('CdkScrollable', () => { + let fixture: ComponentFixture; + let testComponent: ScrollableViewport; + let maxOffset = 0; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ScrollingModule], + declarations: [ScrollableViewport], + }).compileComponents(); + + fixture = TestBed.createComponent(ScrollableViewport); + testComponent = fixture.componentInstance; + })); + + describe('in LTR context', () => { + beforeEach(() => { + fixture.detectChanges(); + maxOffset = testComponent.scrollContainer.nativeElement.scrollHeight - + testComponent.scrollContainer.nativeElement.clientHeight; + }); + + it('should initially be scrolled to top-left', () => { + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, true); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset); + }); + + it('should scrollTo top-left', () => { + testComponent.scrollable.scrollTo({top: 0, left: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, true); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset); + }); + + it('should scrollTo bottom-right', () => { + testComponent.scrollable.scrollTo({bottom: 0, right: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, true); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0); + }); + + it('should scroll to top-end', () => { + testComponent.scrollable.scrollTo({top: 0, end: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, true); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0); + }); + + it('should scroll to bottom-start', () => { + testComponent.scrollable.scrollTo({bottom: 0, start: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, true); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset); + }); + }); + + describe('in RTL context', () => { + beforeEach(() => { + testComponent.dir = 'rtl'; + fixture.detectChanges(); + maxOffset = testComponent.scrollContainer.nativeElement.scrollHeight - + testComponent.scrollContainer.nativeElement.clientHeight; + }); + + it('should initially be scrolled to top-right', () => { + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, true); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset); + }); + + it('should scrollTo top-left', () => { + testComponent.scrollable.scrollTo({top: 0, left: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, true); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0); + }); + + it('should scrollTo bottom-right', () => { + testComponent.scrollable.scrollTo({bottom: 0, right: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, true); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset); + }); + + it('should scroll to top-end', () => { + testComponent.scrollable.scrollTo({top: 0, end: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, true); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(0); + }); + + it('should scroll to bottom-start', () => { + testComponent.scrollable.scrollTo({bottom: 0, start: 0}); + + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowStart, false); + expectOverlapping(testComponent.scrollContainer, testComponent.firstRowEnd, false); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowStart, true); + expectOverlapping(testComponent.scrollContainer, testComponent.lastRowEnd, false); + + expect(testComponent.scrollable.measureScrollOffset('top')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('bottom')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('left')).toBe(maxOffset); + expect(testComponent.scrollable.measureScrollOffset('right')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('start')).toBe(0); + expect(testComponent.scrollable.measureScrollOffset('end')).toBe(maxOffset); + }); + }); +}); + +@Component({ + template: ` +
+
+
+
+
+
+
+
+
+
`, + styles: [` + .scroll-container { + width: 100px; + height: 100px; + overflow: auto; + } + + .row { + display: flex; + flex-direction: row; + } + + .cell { + flex: none; + width: 100px; + height: 100px; + } + `] +}) +class ScrollableViewport { + @Input() dir: Direction; + @ViewChild(CdkScrollable) scrollable: CdkScrollable; + @ViewChild('scrollContainer') scrollContainer: ElementRef; + @ViewChild('firstRowStart') firstRowStart: ElementRef; + @ViewChild('firstRowEnd') firstRowEnd: ElementRef; + @ViewChild('lastRowStart') lastRowStart: ElementRef; + @ViewChild('lastRowEnd') lastRowEnd: ElementRef; +} diff --git a/src/cdk/scrolling/scrollable.ts b/src/cdk/scrolling/scrollable.ts index afd6b84d77bc..c8dcc70cbf0c 100644 --- a/src/cdk/scrolling/scrollable.ts +++ b/src/cdk/scrolling/scrollable.ts @@ -6,11 +6,35 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, ElementRef, NgZone, OnDestroy, OnInit} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import { + getRtlScrollAxisType, + RtlScrollAxisType, + supportsScrollBehavior +} from '@angular/cdk/platform'; +import {Directive, ElementRef, NgZone, OnDestroy, OnInit, Optional} from '@angular/core'; import {fromEvent, Observable, Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {ScrollDispatcher} from './scroll-dispatcher'; +export type _Without = {[P in keyof T]?: never}; +export type _XOR = (_Without & U) | (_Without & T); +export type _Top = {top?: number}; +export type _Bottom = {bottom?: number}; +export type _Left = {left?: number}; +export type _Right = {right?: number}; +export type _Start = {start?: number}; +export type _End = {end?: number}; +export type _XAxis = _XOR<_XOR<_Left, _Right>, _XOR<_Start, _End>>; +export type _YAxis = _XOR<_Top, _Bottom>; + +/** + * An extended version of ScrollToOptions that allows expressing scroll offsets relative to the + * top, bottom, left, right, start, or end of the viewport rather than just the top and left. + * Please note: the top and bottom properties are mutually exclusive, as are the left, right, + * start, and end properties. + */ +export type ExtendedScrollToOptions = _XAxis & _YAxis & ScrollOptions; /** * Sends an event when the directive's element is scrolled. Registers itself with the @@ -28,9 +52,10 @@ export class CdkScrollable implements OnInit, OnDestroy { fromEvent(this._elementRef.nativeElement, 'scroll').pipe(takeUntil(this._destroyed)) .subscribe(observer))); - constructor(private _elementRef: ElementRef, + constructor(private _elementRef: ElementRef, private _scroll: ScrollDispatcher, - private _ngZone: NgZone) {} + private _ngZone: NgZone, + @Optional() private _dir?: Directionality) {} ngOnInit() { this._scroll.register(this); @@ -42,14 +67,124 @@ export class CdkScrollable implements OnInit, OnDestroy { this._destroyed.complete(); } - /** - * Returns observable that emits when a scroll event is fired on the host element. - */ + /** Returns observable that emits when a scroll event is fired on the host element. */ elementScrolled(): Observable { return this._elementScrolled; } - getElementRef(): ElementRef { + /** Gets the ElementRef for the viewport. */ + getElementRef(): ElementRef { return this._elementRef; } + + /** + * Scrolls to the specified offsets. This is a normalized version of the browser's native scrollTo + * method, since browsers are not consistent about what scrollLeft means in RTL. For this method + * left and right always refer to the left and right side of the scrolling container irrespective + * of the layout direction. start and end refer to left and right in an LTR context and vice-versa + * in an RTL context. + * @param options specified the offsets to scroll to. + */ + scrollTo(options: ExtendedScrollToOptions): void { + const el = this._elementRef.nativeElement; + const isRtl = this._dir && this._dir.value == 'rtl'; + + // Rewrite start & end offsets as right or left offsets. + options.left = options.left == null ? (isRtl ? options.end : options.start) : options.left; + options.right = options.right == null ? (isRtl ? options.start : options.end) : options.right; + + // Rewrite the bottom offset as a top offset. + if (options.bottom != null) { + options.top = el.scrollHeight - el.clientHeight - options.bottom; + } + + // Rewrite the right offset as a left offset. + if (isRtl && getRtlScrollAxisType() != RtlScrollAxisType.NORMAL) { + if (options.left != null) { + options.right = el.scrollWidth - el.clientWidth - options.left; + } + + if (getRtlScrollAxisType() == RtlScrollAxisType.INVERTED) { + options.left = options.right; + } else if (getRtlScrollAxisType() == RtlScrollAxisType.NEGATED) { + options.left = options.right ? -options.right : options.right; + } + } else { + if (options.right != null) { + options.left = el.scrollWidth - el.clientWidth - options.right; + } + } + + this._applyScrollToOptions(options); + } + + private _applyScrollToOptions(options: ScrollToOptions): void { + const el = this._elementRef.nativeElement; + + if (supportsScrollBehavior()) { + el.scrollTo(options); + } else { + if (options.top != null) { + el.scrollTop = options.top; + } + if (options.left != null) { + el.scrollLeft = options.left; + } + } + } + + /** + * Measures the scroll offset relative to the specified edge of the viewport. This method can be + * used instead of directly checking scrollLeft or scrollTop, since browsers are not consistent + * about what scrollLeft means in RTL. The values returned by this method are normalized such that + * left and right always refer to the left and right side of the scrolling container irrespective + * of the layout direction. start and end refer to left and right in an LTR context and vice-versa + * in an RTL context. + * @param from The edge to measure from. + */ + measureScrollOffset(from: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end'): number { + const LEFT = 'left'; + const RIGHT = 'right'; + const el = this._elementRef.nativeElement; + if (from == 'top') { + return el.scrollTop; + } + if (from == 'bottom') { + return el.scrollHeight - el.clientHeight - el.scrollTop; + } + + // Rewrite start & end as left or right offsets. + const isRtl = this._dir && this._dir.value == 'rtl'; + if (from == 'start') { + from = isRtl ? RIGHT : LEFT; + } else if (from == 'end') { + from = isRtl ? LEFT : RIGHT; + } + + if (isRtl && getRtlScrollAxisType() == RtlScrollAxisType.INVERTED) { + // For INVERTED, scrollLeft is (scrollWidth - clientWidth) when scrolled all the way left and + // 0 when scrolled all the way right. + if (from == LEFT) { + return el.scrollWidth - el.clientWidth - el.scrollLeft; + } else { + return el.scrollLeft; + } + } else if (isRtl && getRtlScrollAxisType() == RtlScrollAxisType.NEGATED) { + // For NEGATED, scrollLeft is -(scrollWidth - clientWidth) when scrolled all the way left and + // 0 when scrolled all the way right. + if (from == LEFT) { + return el.scrollLeft + el.scrollWidth - el.clientWidth; + } else { + return -el.scrollLeft; + } + } else { + // For NORMAL, as well as non-RTL contexts, scrollLeft is 0 when scrolled all the way left and + // (scrollWidth - clientWidth) when scrolled all the way right. + if (from == LEFT) { + return el.scrollLeft; + } else { + return el.scrollWidth - el.clientWidth - el.scrollLeft; + } + } + } } diff --git a/src/cdk/scrolling/scrolling-module.ts b/src/cdk/scrolling/scrolling-module.ts index a4d533dfe22c..0b0fcc28d2cd 100644 --- a/src/cdk/scrolling/scrolling-module.ts +++ b/src/cdk/scrolling/scrolling-module.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {BidiModule} from '@angular/cdk/bidi'; import {PlatformModule} from '@angular/cdk/platform'; import {NgModule} from '@angular/core'; import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll'; @@ -14,8 +15,9 @@ import {CdkVirtualForOf} from './virtual-for-of'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; @NgModule({ - imports: [PlatformModule], + imports: [PlatformModule, BidiModule], exports: [ + BidiModule, CdkFixedSizeVirtualScroll, CdkScrollable, CdkVirtualForOf,