diff --git a/src/demo-app/tooltip/tooltip-demo.html b/src/demo-app/tooltip/tooltip-demo.html index 13031ea41301..5266f2eaeb3c 100644 --- a/src/demo-app/tooltip/tooltip-demo.html +++ b/src/demo-app/tooltip/tooltip-demo.html @@ -3,12 +3,12 @@

Tooltip Demo

diff --git a/src/demo-app/tooltip/tooltip-demo.ts b/src/demo-app/tooltip/tooltip-demo.ts index 56046e7156c7..a18c5605a24d 100644 --- a/src/demo-app/tooltip/tooltip-demo.ts +++ b/src/demo-app/tooltip/tooltip-demo.ts @@ -12,5 +12,5 @@ export class TooltipDemo { position: TooltipPosition = 'below'; message: string = 'Here is the tooltip'; showDelay = 0; - hideDelay = 0; + hideDelay = 1000; } diff --git a/src/lib/core/overlay/position/connected-position-strategy.spec.ts b/src/lib/core/overlay/position/connected-position-strategy.spec.ts index e233c2d5cd01..c50217cd982e 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.spec.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.spec.ts @@ -3,6 +3,9 @@ import {ConnectedPositionStrategy} from './connected-position-strategy'; import {ViewportRuler} from './viewport-ruler'; import {OverlayPositionBuilder} from './overlay-position-builder'; import {ConnectedOverlayPositionChange} from './connected-position'; +import {Scrollable} from '../scroll/scrollable'; +import {Subscription} from 'rxjs'; +import Spy = jasmine.Spy; // Default width and height of the overlay and origin panels throughout these tests. @@ -14,415 +17,533 @@ const DEFAULT_WIDTH = 60; // for tests on CI (both SauceLabs and Browserstack). describe('ConnectedPositionStrategy', () => { - const ORIGIN_HEIGHT = DEFAULT_HEIGHT; - const ORIGIN_WIDTH = DEFAULT_WIDTH; - const OVERLAY_HEIGHT = DEFAULT_HEIGHT; - const OVERLAY_WIDTH = DEFAULT_WIDTH; - - let originElement: HTMLElement; - let overlayElement: HTMLElement; - let overlayContainerElement: HTMLElement; - let strategy: ConnectedPositionStrategy; - let fakeElementRef: ElementRef; - let fakeViewportRuler: FakeViewportRuler; - let positionBuilder: OverlayPositionBuilder; - - let originRect: ClientRect; - let originCenterX: number; - let originCenterY: number; - - beforeEach(() => { - fakeViewportRuler = new FakeViewportRuler(); - - // The origin and overlay elements need to be in the document body in order to have geometry. - originElement = createPositionedBlockElement(); - overlayContainerElement = createFixedElement(); - overlayElement = createPositionedBlockElement(); - document.body.appendChild(originElement); - document.body.appendChild(overlayContainerElement); - overlayContainerElement.appendChild(overlayElement); - - fakeElementRef = new FakeElementRef(originElement); - positionBuilder = new OverlayPositionBuilder(new ViewportRuler()); - }); - afterEach(() => { - document.body.removeChild(originElement); - document.body.removeChild(overlayContainerElement); + describe('with origin on document body', () => { + const ORIGIN_HEIGHT = DEFAULT_HEIGHT; + const ORIGIN_WIDTH = DEFAULT_WIDTH; + const OVERLAY_HEIGHT = DEFAULT_HEIGHT; + const OVERLAY_WIDTH = DEFAULT_WIDTH; - // Reset the origin geometry after each test so we don't accidently keep state between tests. - originRect = null; - originCenterX = null; - originCenterY = null; - }); + let originElement: HTMLElement; + let overlayElement: HTMLElement; + let overlayContainerElement: HTMLElement; + let strategy: ConnectedPositionStrategy; + let fakeElementRef: ElementRef; + let fakeViewportRuler: FakeViewportRuler; + let positionBuilder: OverlayPositionBuilder; - describe('when not near viewport edge, not scrolled', () => { - // Place the original element close to the center of the window. - // (1024 / 2, 768 / 2). It's not exact, since outerWidth/Height includes browser - // chrome, but it doesn't really matter for these tests. - const ORIGIN_LEFT = 500; - const ORIGIN_TOP = 350; + let originRect: ClientRect; + let originCenterX: number; + let originCenterY: number; beforeEach(() => { - originElement.style.left = `${ORIGIN_LEFT}px`; - originElement.style.top = `${ORIGIN_TOP}px`; - - originRect = originElement.getBoundingClientRect(); - originCenterX = originRect.left + (ORIGIN_WIDTH / 2); - originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); - }); - - // Preconditions are set, now just run the full set of simple position tests. - runSimplePositionTests(); - }); - - describe('when scrolled', () => { - // Place the original element decently far outside the unscrolled document (1024x768). - const ORIGIN_LEFT = 2500; - const ORIGIN_TOP = 2500; - - // Create a very large element that will make the page scrollable. - let veryLargeElement: HTMLElement = document.createElement('div'); - veryLargeElement.style.width = '4000px'; - veryLargeElement.style.height = '4000px'; - - beforeEach(() => { - // Scroll the page such that the origin element is roughly in the - // center of the visible viewport (2500 - 1024/2, 2500 - 768/2). - document.body.appendChild(veryLargeElement); - document.body.scrollTop = 2100; - document.body.scrollLeft = 2100; - - originElement.style.top = `${ORIGIN_TOP}px`; - originElement.style.left = `${ORIGIN_LEFT}px`; - - originRect = originElement.getBoundingClientRect(); - originCenterX = originRect.left + (ORIGIN_WIDTH / 2); - originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + fakeViewportRuler = new FakeViewportRuler(); + + // The origin and overlay elements need to be in the document body in order to have geometry. + originElement = createPositionedBlockElement(); + overlayContainerElement = createFixedElement(); + overlayElement = createPositionedBlockElement(); + document.body.appendChild(originElement); + document.body.appendChild(overlayContainerElement); + overlayContainerElement.appendChild(overlayElement); + + fakeElementRef = new FakeElementRef(originElement); + positionBuilder = new OverlayPositionBuilder(new ViewportRuler()); }); afterEach(() => { - document.body.removeChild(veryLargeElement); - document.body.scrollTop = 0; - document.body.scrollLeft = 0; - }); - - // Preconditions are set, now just run the full set of simple position tests. - runSimplePositionTests(); - }); + document.body.removeChild(originElement); + document.body.removeChild(overlayContainerElement); - describe('when near viewport edge', () => { - it('should reposition the overlay if it would go off the top of the screen', () => { - // We can use the real ViewportRuler in this test since we know that zero is - // always the top of the viewport. - - originElement.style.top = '5px'; - originElement.style.left = '200px'; - originRect = originElement.getBoundingClientRect(); - - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'end', originY: 'top'}, - {overlayX: 'end', overlayY: 'bottom'}) - .withFallbackPosition( - {originX: 'start', originY: 'bottom'}, - {overlayX: 'start', overlayY: 'top'}); - - strategy.apply(overlayElement); - - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.bottom); - expect(overlayRect.left).toBe(originRect.left); + // Reset the origin geometry after each test so we don't accidently keep state between tests. + originRect = null; + originCenterX = null; + originCenterY = null; }); - it('should reposition the overlay if it would go off the left of the screen', () => { - // We can use the real ViewportRuler in this test since we know that zero is - // always the left edge of the viewport. - - originElement.style.top = '200px'; - originElement.style.left = '5px'; - originRect = originElement.getBoundingClientRect(); - originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + describe('when not near viewport edge, not scrolled', () => { + // Place the original element close to the center of the window. + // (1024 / 2, 768 / 2). It's not exact, since outerWidth/Height includes browser + // chrome, but it doesn't really matter for these tests. + const ORIGIN_LEFT = 500; + const ORIGIN_TOP = 350; - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'start', originY: 'bottom'}, - {overlayX: 'end', overlayY: 'top'}) - .withFallbackPosition( - {originX: 'end', originY: 'center'}, - {overlayX: 'start', overlayY: 'center'}); + beforeEach(() => { + originElement.style.left = `${ORIGIN_LEFT}px`; + originElement.style.top = `${ORIGIN_TOP}px`; - strategy.apply(overlayElement); + originRect = originElement.getBoundingClientRect(); + originCenterX = originRect.left + (ORIGIN_WIDTH / 2); + originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + }); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2)); - expect(overlayRect.left).toBe(originRect.right); + // Preconditions are set, now just run the full set of simple position tests. + runSimplePositionTests(); }); - it('should reposition the overlay if it would go off the bottom of the screen', () => { - // Use the fake viewport ruler because we don't know *exactly* how big the viewport is. - fakeViewportRuler.fakeRect = { - top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 - }; - positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); - - originElement.style.top = '475px'; - originElement.style.left = '200px'; - originRect = originElement.getBoundingClientRect(); - - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'start', originY: 'bottom'}, - {overlayX: 'start', overlayY: 'top'}) - .withFallbackPosition( - {originX: 'end', originY: 'top'}, - {overlayX: 'end', overlayY: 'bottom'}); + describe('when scrolled', () => { + // Place the original element decently far outside the unscrolled document (1024x768). + const ORIGIN_LEFT = 2500; + const ORIGIN_TOP = 2500; + + // Create a very large element that will make the page scrollable. + let veryLargeElement: HTMLElement = document.createElement('div'); + veryLargeElement.style.width = '4000px'; + veryLargeElement.style.height = '4000px'; + + beforeEach(() => { + // Scroll the page such that the origin element is roughly in the + // center of the visible viewport (2500 - 1024/2, 2500 - 768/2). + document.body.appendChild(veryLargeElement); + document.body.scrollTop = 2100; + document.body.scrollLeft = 2100; + + originElement.style.top = `${ORIGIN_TOP}px`; + originElement.style.left = `${ORIGIN_LEFT}px`; + + originRect = originElement.getBoundingClientRect(); + originCenterX = originRect.left + (ORIGIN_WIDTH / 2); + originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + }); + + afterEach(() => { + document.body.removeChild(veryLargeElement); + document.body.scrollTop = 0; + document.body.scrollLeft = 0; + }); + + // Preconditions are set, now just run the full set of simple position tests. + runSimplePositionTests(); + }); - strategy.apply(overlayElement); + describe('when near viewport edge', () => { + it('should reposition the overlay if it would go off the top of the screen', () => { + // We can use the real ViewportRuler in this test since we know that zero is + // always the top of the viewport. + + originElement.style.top = '5px'; + originElement.style.left = '200px'; + originRect = originElement.getBoundingClientRect(); + + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'end', originY: 'top'}, + {overlayX: 'end', overlayY: 'bottom'}) + .withFallbackPosition( + {originX: 'start', originY: 'bottom'}, + {overlayX: 'start', overlayY: 'top'}); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.left).toBe(originRect.left); + }); + + it('should reposition the overlay if it would go off the left of the screen', () => { + // We can use the real ViewportRuler in this test since we know that zero is + // always the left edge of the viewport. + + originElement.style.top = '200px'; + originElement.style.left = '5px'; + originRect = originElement.getBoundingClientRect(); + originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'bottom'}, + {overlayX: 'end', overlayY: 'top'}) + .withFallbackPosition( + {originX: 'end', originY: 'center'}, + {overlayX: 'start', overlayY: 'center'}); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2)); + expect(overlayRect.left).toBe(originRect.right); + }); + + it('should reposition the overlay if it would go off the bottom of the screen', () => { + // Use the fake viewport ruler because we don't know *exactly* how big the viewport is. + fakeViewportRuler.fakeRect = { + top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 + }; + positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); + + originElement.style.top = '475px'; + originElement.style.left = '200px'; + originRect = originElement.getBoundingClientRect(); + + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'bottom'}, + {overlayX: 'start', overlayY: 'top'}) + .withFallbackPosition( + {originX: 'end', originY: 'top'}, + {overlayX: 'end', overlayY: 'bottom'}); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.bottom).toBe(originRect.top); + expect(overlayRect.right).toBe(originRect.right); + }); + + it('should reposition the overlay if it would go off the right of the screen', () => { + // Use the fake viewport ruler because we don't know *exactly* how big the viewport is. + fakeViewportRuler.fakeRect = { + top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 + }; + positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); + + originElement.style.top = '200px'; + originElement.style.left = '475px'; + originRect = originElement.getBoundingClientRect(); + + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'end', originY: 'center'}, + {overlayX: 'start', overlayY: 'center'}) + .withFallbackPosition( + {originX: 'start', originY: 'bottom'}, + {overlayX: 'end', overlayY: 'top'}); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.right).toBe(originRect.left); + }); + + it('should position a panel properly when rtl', () => { + // must make the overlay longer than the origin to properly test attachment + overlayElement.style.width = `500px`; + originRect = originElement.getBoundingClientRect(); + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'bottom'}, + {overlayX: 'start', overlayY: 'top'}) + .withDirection('rtl'); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.right).toBe(originRect.right); + }); + + it('should position a panel with the x offset provided', () => { + originRect = originElement.getBoundingClientRect(); + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'top'}, + {overlayX: 'start', overlayY: 'top'}); + + strategy.withOffsetX(10); + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.top); + expect(overlayRect.left).toBe(originRect.left + 10); + }); + + it('should position a panel with the y offset provided', () => { + originRect = originElement.getBoundingClientRect(); + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'top'}, + {overlayX: 'start', overlayY: 'top'}); + + strategy.withOffsetY(50); + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.top + 50); + expect(overlayRect.left).toBe(originRect.left); + }); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.bottom).toBe(originRect.top); - expect(overlayRect.right).toBe(originRect.right); }); - it('should reposition the overlay if it would go off the right of the screen', () => { - // Use the fake viewport ruler because we don't know *exactly* how big the viewport is. + it('should emit onPositionChange event when position changes', () => { + // force the overlay to open in a fallback position fakeViewportRuler.fakeRect = { top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 }; positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); - originElement.style.top = '200px'; originElement.style.left = '475px'; - originRect = originElement.getBoundingClientRect(); strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'end', originY: 'center'}, - {overlayX: 'start', overlayY: 'center'}) - .withFallbackPosition( - {originX: 'start', originY: 'bottom'}, - {overlayX: 'end', overlayY: 'top'}); + fakeElementRef, + {originX: 'end', originY: 'center'}, + {overlayX: 'start', overlayY: 'center'}) + .withFallbackPosition( + {originX: 'start', originY: 'bottom'}, + {overlayX: 'end', overlayY: 'top'}); - strategy.apply(overlayElement); + const positionChangeHandler = jasmine.createSpy('positionChangeHandler'); + const subscription = strategy.onPositionChange.subscribe(positionChangeHandler); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.bottom); - expect(overlayRect.right).toBe(originRect.left); + strategy.apply(overlayElement); + expect(positionChangeHandler).toHaveBeenCalled(); + expect(positionChangeHandler.calls.mostRecent().args[0]) + .toEqual(jasmine.any(ConnectedOverlayPositionChange), + `Expected strategy to emit an instance of ConnectedOverlayPositionChange.`); + + it('should pick the fallback position that shows the largest area of the element', () => { + // Use the fake viewport ruler because we don't know *exactly* how big the viewport is. + fakeViewportRuler.fakeRect = { + top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 + }; + positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); + + originElement.style.top = '200px'; + originElement.style.left = '475px'; + originRect = originElement.getBoundingClientRect(); + + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'end', originY: 'center'}, + {overlayX: 'start', overlayY: 'center'}) + .withFallbackPosition( + {originX: 'end', originY: 'top'}, + {overlayX: 'start', overlayY: 'bottom'}) + .withFallbackPosition( + {originX: 'end', originY: 'top'}, + {overlayX: 'end', overlayY: 'top'}); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + + expect(overlayRect.top).toBe(originRect.top); + expect(overlayRect.left).toBe(originRect.left); + }); + + it('should position a panel properly when rtl', () => { + // must make the overlay longer than the origin to properly test attachment + overlayElement.style.width = `500px`; + originRect = originElement.getBoundingClientRect(); + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'bottom'}, + {overlayX: 'start', overlayY: 'top'}) + .withDirection('rtl'); + originElement.style.top = '0'; + originElement.style.left = '0'; + + // If the strategy is re-applied and the initial position would now fit, + // the position change event should be emitted again. + strategy.apply(overlayElement); + expect(positionChangeHandler).toHaveBeenCalledTimes(2); + + subscription.unsubscribe(); + }); }); - it('should pick the fallback position that shows the largest area of the element', () => { - // Use the fake viewport ruler because we don't know *exactly* how big the viewport is. - fakeViewportRuler.fakeRect = { - top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 - }; - positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); - - originElement.style.top = '200px'; - originElement.style.left = '475px'; - originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'end', originY: 'center'}, - {overlayX: 'start', overlayY: 'center'}) - .withFallbackPosition( - {originX: 'end', originY: 'top'}, - {overlayX: 'start', overlayY: 'bottom'}) - .withFallbackPosition( - {originX: 'end', originY: 'top'}, - {overlayX: 'end', overlayY: 'top'}); + /** + * Run all tests for connecting the overlay to the origin such that first preferred + * position does not go off-screen. We do this because there are several cases where we + * want to run the exact same tests with different preconditions (e.g., not scroll, scrolled, + * different element sized, etc.). + */ + function runSimplePositionTests() { + it('should position a panel below, left-aligned', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'bottom'}, + {overlayX: 'start', overlayY: 'top'}); - strategy.apply(overlayElement); + strategy.apply(overlayElement); - let overlayRect = overlayElement.getBoundingClientRect(); + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.left).toBe(originRect.left); + }); - expect(overlayRect.top).toBe(originRect.top); - expect(overlayRect.left).toBe(originRect.left); - }); + it('should position to the right, center aligned vertically', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'end', originY: 'center'}, + {overlayX: 'start', overlayY: 'center'}); - it('should position a panel properly when rtl', () => { - // must make the overlay longer than the origin to properly test attachment - overlayElement.style.width = `500px`; - originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'start', originY: 'bottom'}, - {overlayX: 'start', overlayY: 'top'}) - .withDirection('rtl'); + strategy.apply(overlayElement); - strategy.apply(overlayElement); - - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.bottom); - expect(overlayRect.right).toBe(originRect.right); - }); + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2)); + expect(overlayRect.left).toBe(originRect.right); + }); - it('should position a panel with the x offset provided', () => { - originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'start', originY: 'top'}, - {overlayX: 'start', overlayY: 'top'}); - - strategy.withOffsetX(10); - strategy.apply(overlayElement); + it('should position to the left, below', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'bottom'}, + {overlayX: 'end', overlayY: 'top'}); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.top); - expect(overlayRect.left).toBe(originRect.left + 10); - }); + strategy.apply(overlayElement); - it('should position a panel with the y offset provided', () => { - originRect = originElement.getBoundingClientRect(); - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'start', originY: 'top'}, - {overlayX: 'start', overlayY: 'top'}); + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.right).toBe(originRect.left); + }); - strategy.withOffsetY(50); - strategy.apply(overlayElement); + it('should position above, right aligned', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'end', originY: 'top'}, + {overlayX: 'end', overlayY: 'bottom'}); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.top + 50); - expect(overlayRect.left).toBe(originRect.left); - }); + strategy.apply(overlayElement); - }); + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.bottom).toBe(originRect.top); + expect(overlayRect.right).toBe(originRect.right); + }); - it('should emit onPositionChange event when position changes', () => { - // force the overlay to open in a fallback position - fakeViewportRuler.fakeRect = { - top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 - }; - positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); - originElement.style.top = '200px'; - originElement.style.left = '475px'; - - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'end', originY: 'center'}, - {overlayX: 'start', overlayY: 'center'}) - .withFallbackPosition( - {originX: 'start', originY: 'bottom'}, - {overlayX: 'end', overlayY: 'top'}); + it('should position below, centered', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'center', originY: 'bottom'}, + {overlayX: 'center', overlayY: 'top'}); - const positionChangeHandler = jasmine.createSpy('positionChangeHandler'); - const subscription = strategy.onPositionChange.subscribe(positionChangeHandler); + strategy.apply(overlayElement); - strategy.apply(overlayElement); - expect(positionChangeHandler).toHaveBeenCalled(); - expect(positionChangeHandler.calls.mostRecent().args[0]) - .toEqual(jasmine.any(ConnectedOverlayPositionChange), - `Expected strategy to emit an instance of ConnectedOverlayPositionChange.`); + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.left).toBe(originCenterX - (OVERLAY_WIDTH / 2)); + }); - originElement.style.top = '0'; - originElement.style.left = '0'; + it('should center the overlay on the origin', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'center', originY: 'center'}, + {overlayX: 'center', overlayY: 'center'}); - // If the strategy is re-applied and the initial position would now fit, - // the position change event should be emitted again. - strategy.apply(overlayElement); - expect(positionChangeHandler).toHaveBeenCalledTimes(2); + strategy.apply(overlayElement); - subscription.unsubscribe(); + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.top); + expect(overlayRect.left).toBe(originRect.left); + }); + } }); + describe('onPositionChange with scrollable view properties', () => { + let overlayElement: HTMLElement; + let overlayContainerElement: HTMLElement; + let strategy: ConnectedPositionStrategy; - /** - * Run all tests for connecting the overlay to the origin such that first preferred - * position does not go off-screen. We do this because there are several cases where we - * want to run the exact same tests with different preconditions (e.g., not scroll, scrolled, - * different element sized, etc.). - */ - function runSimplePositionTests() { - it('should position a panel below, left-aligned', () => { - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'start', originY: 'bottom'}, - {overlayX: 'start', overlayY: 'top'}); + let scrollable: HTMLDivElement; + let positionChangeHandler: Spy; + let onPositionChangeSubscription: Subscription; + let positionChange: ConnectedOverlayPositionChange; - strategy.apply(overlayElement); - - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.bottom); - expect(overlayRect.left).toBe(originRect.left); - }); - - it('should position to the right, center aligned vertically', () => { + beforeEach(() => { + // Set up the overlay + overlayContainerElement = createFixedElement(); + overlayElement = createPositionedBlockElement(); + document.body.appendChild(overlayContainerElement); + overlayContainerElement.appendChild(overlayElement); + + // Set up the origin + let originElement = createBlockElement(); + originElement.style.margin = '0 1000px 1000px 0'; // Added so that the container scrolls + + // Create a scrollable container and put the origin inside + scrollable = createOverflowContainerElement(); + document.body.appendChild(scrollable); + scrollable.appendChild(originElement); + + // Create a strategy with knowledge of the scrollable container + let positionBuilder = new OverlayPositionBuilder(new ViewportRuler()); + let fakeElementRef = new FakeElementRef(originElement); strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'end', originY: 'center'}, - {overlayX: 'start', overlayY: 'center'}); - - strategy.apply(overlayElement); + fakeElementRef, + {originX: 'start', originY: 'bottom'}, + {overlayX: 'start', overlayY: 'top'}); + strategy.withScrollableContainers([new Scrollable(new FakeElementRef(scrollable), null)]); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2)); - expect(overlayRect.left).toBe(originRect.right); + positionChangeHandler = jasmine.createSpy('positionChangeHandler'); + onPositionChangeSubscription = strategy.onPositionChange.subscribe(positionChangeHandler); }); - it('should position to the left, below', () => { - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'start', originY: 'bottom'}, - {overlayX: 'end', overlayY: 'top'}); + afterEach(() => { + onPositionChangeSubscription.unsubscribe(); + document.body.removeChild(scrollable); + document.body.removeChild(overlayContainerElement); + }); + it('should not have origin or overlay clipped or out of view without scroll', () => { strategy.apply(overlayElement); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.bottom); - expect(overlayRect.right).toBe(originRect.left); + expect(positionChangeHandler).toHaveBeenCalled(); + positionChange = positionChangeHandler.calls.mostRecent().args[0]; + expect(positionChange.scrollableViewProperties).toEqual({ + isOriginClipped: false, + isOriginOutsideView: false, + isOverlayClipped: false, + isOverlayOutsideView: false + }); }); - it('should position above, right aligned', () => { - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'end', originY: 'top'}, - {overlayX: 'end', overlayY: 'bottom'}); - + it('should evaluate if origin is clipped if scrolled slightly down', () => { + scrollable.scrollTop = 10; // Clip the origin by 10 pixels strategy.apply(overlayElement); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.bottom).toBe(originRect.top); - expect(overlayRect.right).toBe(originRect.right); + expect(positionChangeHandler).toHaveBeenCalled(); + positionChange = positionChangeHandler.calls.mostRecent().args[0]; + expect(positionChange.scrollableViewProperties).toEqual({ + isOriginClipped: true, + isOriginOutsideView: false, + isOverlayClipped: false, + isOverlayOutsideView: false + }); }); - it('should position below, centered', () => { - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'center', originY: 'bottom'}, - {overlayX: 'center', overlayY: 'top'}); - + it('should evaluate if origin is out of view and overlay is clipped if scrolled enough', () => { + scrollable.scrollTop = 31; // Origin is 30 pixels, move out of view and clip the overlay 1px strategy.apply(overlayElement); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.bottom); - expect(overlayRect.left).toBe(originCenterX - (OVERLAY_WIDTH / 2)); + expect(positionChangeHandler).toHaveBeenCalled(); + positionChange = positionChangeHandler.calls.mostRecent().args[0]; + expect(positionChange.scrollableViewProperties).toEqual({ + isOriginClipped: true, + isOriginOutsideView: true, + isOverlayClipped: true, + isOverlayOutsideView: false + }); }); - it('should center the overlay on the origin', () => { - strategy = positionBuilder.connectedTo( - fakeElementRef, - {originX: 'center', originY: 'center'}, - {overlayX: 'center', overlayY: 'center'}); - + it('should evaluate the overlay and origin are both out of the view', () => { + scrollable.scrollTop = 61; // Scroll by overlay height + origin height + 1px buffer strategy.apply(overlayElement); - let overlayRect = overlayElement.getBoundingClientRect(); - expect(overlayRect.top).toBe(originRect.top); - expect(overlayRect.left).toBe(originRect.left); + expect(positionChangeHandler).toHaveBeenCalled(); + positionChange = positionChangeHandler.calls.mostRecent().args[0]; + expect(positionChange.scrollableViewProperties).toEqual({ + isOriginClipped: true, + isOriginOutsideView: true, + isOverlayClipped: true, + isOverlayOutsideView: true + }); }); - } - + }); }); - /** Creates an absolutely positioned, display: block element with a default size. */ function createPositionedBlockElement() { - let element = document.createElement('div'); + let element = createBlockElement(); element.style.position = 'absolute'; element.style.top = '0'; element.style.left = '0'; + return element; +} + +/** Creates a block element with a default size. */ +function createBlockElement() { + let element = document.createElement('div'); element.style.width = `${DEFAULT_WIDTH}px`; element.style.height = `${DEFAULT_HEIGHT}px`; element.style.backgroundColor = 'rebeccapurple'; @@ -442,6 +563,17 @@ function createFixedElement() { return element; } +/** Creates an overflow container with a set height and width with margin. */ +function createOverflowContainerElement() { + let element = document.createElement('div'); + element.style.position = 'relative'; + element.style.overflow = 'auto'; + element.style.height = '300px'; + element.style.width = '300px'; + element.style.margin = '100px'; + return element; +} + /** Fake implementation of ViewportRuler that just returns the previously given ClientRect. */ class FakeViewportRuler implements ViewportRuler { diff --git a/src/lib/core/overlay/position/connected-position-strategy.ts b/src/lib/core/overlay/position/connected-position-strategy.ts index d6bb569b828c..e5b1059faf51 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.ts @@ -2,13 +2,26 @@ import {PositionStrategy} from './position-strategy'; import {ElementRef} from '@angular/core'; import {ViewportRuler} from './viewport-ruler'; import { - ConnectionPositionPair, - OriginConnectionPosition, - OverlayConnectionPosition, - ConnectedOverlayPositionChange + ConnectionPositionPair, + OriginConnectionPosition, + OverlayConnectionPosition, + ConnectedOverlayPositionChange, ScrollableViewProperties } from './connected-position'; import {Subject} from 'rxjs/Subject'; import {Observable} from 'rxjs/Observable'; +import {Scrollable} from '../scroll/scrollable'; + +/** + * Container to hold the bounding positions of a particular element with respect to the viewport, + * where top and bottom are the y-axis coordinates of the bounding rectangle and left and right are + * the x-axis coordinates. + */ +export type ElementBoundingPositions = { + top: number; + right: number; + bottom: number; + left: number; +} /** * A strategy for positioning overlays. Using this strategy, an overlay is given an @@ -26,6 +39,9 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** The offset in pixels for the overlay connection point on the y-axis */ private _offsetY: number = 0; + /** The Scrollable containers used to check scrollable view properties on position change. */ + private scrollables: Scrollable[] = []; + /** Whether the we're dealing with an RTL context */ get _isRtl() { return this._dir === 'rtl'; @@ -37,7 +53,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** The origin element against which the overlay will be positioned. */ private _origin: HTMLElement; - private _onPositionChange: + _onPositionChange: Subject = new Subject(); /** Emits an event when the connection point changes. */ @@ -95,7 +111,12 @@ export class ConnectedPositionStrategy implements PositionStrategy { // If the overlay in the calculated position fits on-screen, put it there and we're done. if (overlayPoint.fitsInViewport) { this._setElementPosition(element, overlayPoint); - this._onPositionChange.next(new ConnectedOverlayPositionChange(pos)); + + // Notify that the position has been changed along with its change properties. + const scrollableViewProperties = this.getScrollableViewProperties(element); + const positionChange = new ConnectedOverlayPositionChange(pos, scrollableViewProperties); + this._onPositionChange.next(positionChange); + return Promise.resolve(null); } else if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) { fallbackPoint = overlayPoint; @@ -109,6 +130,15 @@ export class ConnectedPositionStrategy implements PositionStrategy { return Promise.resolve(null); } + /** + * Sets the list of Scrollable containers that host the origin element so that + * on reposition we can evaluate if it or the overlay has been clipped or outside view. Every + * Scrollable must be an ancestor element of the strategy's origin element. + */ + withScrollableContainers(scrollables: Scrollable[]) { + this.scrollables = scrollables; + } + /** * Adds a new preferred fallback position. * @param originPos @@ -241,6 +271,53 @@ export class ConnectedPositionStrategy implements PositionStrategy { return {x, y, fitsInViewport, visibleArea}; } + /** + * Gets the view properties of the trigger and overlay, including whether they are clipped + * or completely outside the view of any of the strategy's scrollables. + */ + private getScrollableViewProperties(overlay: HTMLElement): ScrollableViewProperties { + const originBounds = this._getElementBounds(this._origin); + const overlayBounds = this._getElementBounds(overlay); + const scrollContainerBounds = this.scrollables.map((scrollable: Scrollable) => { + return this._getElementBounds(scrollable.getElementRef().nativeElement); + }); + + return { + isOriginClipped: this.isElementClipped(originBounds, scrollContainerBounds), + isOriginOutsideView: this.isElementOutsideView(originBounds, scrollContainerBounds), + isOverlayClipped: this.isElementClipped(overlayBounds, scrollContainerBounds), + isOverlayOutsideView: this.isElementOutsideView(overlayBounds, scrollContainerBounds), + }; + } + + /** Whether the element is completely out of the view of any of the containers. */ + private isElementOutsideView( + elementBounds: ElementBoundingPositions, + containersBounds: ElementBoundingPositions[]): boolean { + return containersBounds.some((containerBounds: ElementBoundingPositions) => { + const outsideAbove = elementBounds.bottom < containerBounds.top; + const outsideBelow = elementBounds.top > containerBounds.bottom; + const outsideLeft = elementBounds.right < containerBounds.left; + const outsideRight = elementBounds.left > containerBounds.right; + + return outsideAbove || outsideBelow || outsideLeft || outsideRight; + }); + } + + /** Whether the element is clipped by any of the containers. */ + private isElementClipped( + elementBounds: ElementBoundingPositions, + containersBounds: ElementBoundingPositions[]): boolean { + return containersBounds.some((containerBounds: ElementBoundingPositions) => { + const clippedAbove = elementBounds.top < containerBounds.top; + const clippedBelow = elementBounds.bottom > containerBounds.bottom; + const clippedLeft = elementBounds.left < containerBounds.left; + const clippedRight = elementBounds.right > containerBounds.right; + + return clippedAbove || clippedBelow || clippedLeft || clippedRight; + }); + } + /** * Physically positions the overlay element to the given coordinate. * @param element @@ -251,6 +328,17 @@ export class ConnectedPositionStrategy implements PositionStrategy { element.style.top = overlayPoint.y + 'px'; } + /** Returns the bounding positions of the provided element with respect to the viewport. */ + private _getElementBounds(element: HTMLElement): ElementBoundingPositions { + const boundingClientRect = element.getBoundingClientRect(); + return { + top: boundingClientRect.top, + right: boundingClientRect.left + boundingClientRect.width, + bottom: boundingClientRect.top + boundingClientRect.height, + left: boundingClientRect.left + }; + } + /** * Subtracts the amount that an element is overflowing on an axis from it's length. */ @@ -265,7 +353,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { interface Point { x: number; y: number; -}; +} /** * Expands the simple (x, y) coordinate by adding info about whether the diff --git a/src/lib/core/overlay/position/connected-position.ts b/src/lib/core/overlay/position/connected-position.ts index b2e5b9b4a000..a995851bcf89 100644 --- a/src/lib/core/overlay/position/connected-position.ts +++ b/src/lib/core/overlay/position/connected-position.ts @@ -1,4 +1,5 @@ /** Horizontal dimension of a connection point on the perimeter of the origin or overlay element. */ +import {Optional} from '@angular/core'; export type HorizontalConnectionPos = 'start' | 'center' | 'end'; /** Vertical dimension of a connection point on the perimeter of the origin or overlay element. */ @@ -32,7 +33,38 @@ export class ConnectionPositionPair { } } +/** + * Set of properties regarding the position of the origin and overlay relative to the viewport + * with respect to the containing Scrollable elements. + * + * The overlay and origin are clipped if any part of their bounding client rectangle exceeds the + * bounds of any one of the strategy's Scrollable's bounding client rectangle. + * + * The overlay and origin are outside view if there is no overlap between their bounding client + * rectangle and any one of the strategy's Scrollable's bounding client rectangle. + * + * ----------- ----------- + * | outside | | clipped | + * | view | -------------------------- + * | | | | | | + * ---------- | ----------- | + * -------------------------- | | + * | | | Scrollable | + * | | | | + * | | -------------------------- + * | Scrollable | + * | | + * -------------------------- + */ +export class ScrollableViewProperties { + isOriginClipped: boolean; + isOriginOutsideView: boolean; + isOverlayClipped: boolean; + isOverlayOutsideView: boolean; +} + /** The change event emitted by the strategy when a fallback position is used. */ export class ConnectedOverlayPositionChange { - constructor(public connectionPair: ConnectionPositionPair) {} + constructor(public connectionPair: ConnectionPositionPair, + @Optional() public scrollableViewProperties: ScrollableViewProperties) {} } diff --git a/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts b/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts index 8975198ab9ce..f342d56cb10d 100644 --- a/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts +++ b/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts @@ -1,12 +1,10 @@ import {inject, TestBed, async, ComponentFixture} from '@angular/core/testing'; -import {NgModule, Component, ViewChild, ElementRef} from '@angular/core'; +import {NgModule, Component, ViewChild, ElementRef, QueryList, ViewChildren} from '@angular/core'; import {ScrollDispatcher} from './scroll-dispatcher'; import {OverlayModule} from '../overlay-directives'; import {Scrollable} from './scrollable'; describe('Scroll Dispatcher', () => { - let scroll: ScrollDispatcher; - let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -16,45 +14,71 @@ describe('Scroll Dispatcher', () => { TestBed.compileComponents(); })); - beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => { - scroll = s; + describe('Basic usage', () => { + let scroll: ScrollDispatcher; + let fixture: ComponentFixture; - fixture = TestBed.createComponent(ScrollingComponent); - fixture.detectChanges(); - })); + beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => { + scroll = s; - it('should be registered with the scrollable directive with the scroll service', () => { - const componentScrollable = fixture.componentInstance.scrollable; - expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true); - }); + fixture = TestBed.createComponent(ScrollingComponent); + fixture.detectChanges(); + })); + + it('should be registered with the scrollable directive with the scroll service', () => { + const componentScrollable = fixture.componentInstance.scrollable; + expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true); + }); + + it('should have the scrollable directive deregistered when the component is destroyed', () => { + const componentScrollable = fixture.componentInstance.scrollable; + expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true); - it('should have the scrollable directive deregistered when the component is destroyed', () => { - const componentScrollable = fixture.componentInstance.scrollable; - expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true); + fixture.destroy(); + expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false); + }); + + it('should notify through the directive and service that a scroll event occurred', () => { + let hasDirectiveScrollNotified = false; + // Listen for notifications from scroll directive + let scrollable = fixture.componentInstance.scrollable; + scrollable.elementScrolled().subscribe(() => { hasDirectiveScrollNotified = true; }); + + // Listen for notifications from scroll service + let hasServiceScrollNotified = false; + scroll.scrolled().subscribe(() => { hasServiceScrollNotified = true; }); - fixture.destroy(); - expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false); + // Emit a scroll event from the scrolling element in our component. + // This event should be picked up by the scrollable directive and notify. + // The notification should be picked up by the service. + const scrollEvent = document.createEvent('UIEvents'); + scrollEvent.initUIEvent('scroll', true, true, window, 0); + fixture.componentInstance.scrollingElement.nativeElement.dispatchEvent(scrollEvent); + + expect(hasDirectiveScrollNotified).toBe(true); + expect(hasServiceScrollNotified).toBe(true); + }); }); - it('should notify through the directive and service that a scroll event occurred', () => { - let hasDirectiveScrollNotified = false; - // Listen for notifications from scroll directive - let scrollable = fixture.componentInstance.scrollable; - scrollable.elementScrolled().subscribe(() => { hasDirectiveScrollNotified = true; }); - - // Listen for notifications from scroll service - let hasServiceScrollNotified = false; - scroll.scrolled().subscribe(() => { hasServiceScrollNotified = true; }); - - // Emit a scroll event from the scrolling element in our component. - // This event should be picked up by the scrollable directive and notify. - // The notification should be picked up by the service. - const scrollEvent = document.createEvent('UIEvents'); - scrollEvent.initUIEvent('scroll', true, true, window, 0); - fixture.componentInstance.scrollingElement.nativeElement.dispatchEvent(scrollEvent); - - expect(hasDirectiveScrollNotified).toBe(true); - expect(hasServiceScrollNotified).toBe(true); + describe('Nested scrollables', () => { + let scroll: ScrollDispatcher; + let fixture: ComponentFixture; + + beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => { + scroll = s; + + fixture = TestBed.createComponent(NestedScrollingComponent); + fixture.detectChanges(); + })); + + it('should be able to identify the containing scrollables of an element', () => { + const interestingElement = fixture.componentInstance.interestingElement; + const scrollContainers = scroll.getScrollContainers(interestingElement); + const scrollableElementIds = + scrollContainers.map(scrollable => scrollable.getElementRef().nativeElement.id); + + expect(scrollableElementIds).toEqual(['scrollable-1', 'scrollable-1a']); + }); }); }); @@ -68,7 +92,25 @@ class ScrollingComponent { @ViewChild('scrollingElement') scrollingElement: ElementRef; } -const TEST_COMPONENTS = [ScrollingComponent]; + +/** Component containing nested scrollables. */ +@Component({ + template: ` +
+
+
+
+
+
+
+ ` +}) +class NestedScrollingComponent { + @ViewChild('interestingElement') interestingElement: ElementRef; + @ViewChildren(Scrollable) scrollables: QueryList; +} + +const TEST_COMPONENTS = [ScrollingComponent, NestedScrollingComponent]; @NgModule({ imports: [OverlayModule], providers: [ScrollDispatcher], diff --git a/src/lib/core/overlay/scroll/scroll-dispatcher.ts b/src/lib/core/overlay/scroll/scroll-dispatcher.ts index 199cee3bb12e..d9f2c25d860f 100644 --- a/src/lib/core/overlay/scroll/scroll-dispatcher.ts +++ b/src/lib/core/overlay/scroll/scroll-dispatcher.ts @@ -1,4 +1,4 @@ -import {Injectable} from '@angular/core'; +import {Injectable, ElementRef} from '@angular/core'; import {Scrollable} from './scrollable'; import {Subject} from 'rxjs/Subject'; import {Observable} from 'rxjs/Observable'; @@ -19,7 +19,7 @@ export class ScrollDispatcher { * Map of all the scrollable references that are registered with the service and their * scroll event subscriptions. */ - scrollableReferences: WeakMap = new WeakMap(); + scrollableReferences: Map = new Map(); constructor() { // By default, notify a scroll event when the document is scrolled or the window is resized. @@ -57,8 +57,34 @@ export class ScrollDispatcher { return this._scrolled.asObservable(); } + /** Returns all registered Scrollables that contain the provided element. */ + getScrollContainers(elementRef: ElementRef): Scrollable[] { + const scrollingContainers: Scrollable[] = []; + + this.scrollableReferences.forEach((subscription: Subscription, scrollable: Scrollable) => { + if (this.scrollableContainsElement(scrollable, elementRef)) { + scrollingContainers.push(scrollable); + } + }); + + return scrollingContainers; + } + + /** Returns true if the element is contained within the provided Scrollable. */ + scrollableContainsElement(scrollable: Scrollable, elementRef: ElementRef): boolean { + let element = elementRef.nativeElement; + let scrollableElement = scrollable.getElementRef().nativeElement; + + // Traverse through the element parents until we reach null, checking if any of the elements + // are the scrollable's element. + do { + if (element == scrollableElement) { return true; } + } while (element = element.parentElement); + } + /** Sends a notification that a scroll event has been fired. */ _notify() { this._scrolled.next(); } } + diff --git a/src/lib/core/overlay/scroll/scrollable.ts b/src/lib/core/overlay/scroll/scrollable.ts index cf13dec05002..3e5f2bb03616 100644 --- a/src/lib/core/overlay/scroll/scrollable.ts +++ b/src/lib/core/overlay/scroll/scrollable.ts @@ -1,6 +1,4 @@ -import { - Directive, ElementRef, OnInit, OnDestroy -} from '@angular/core'; +import {Directive, ElementRef, OnInit, OnDestroy} from '@angular/core'; import {Observable} from 'rxjs/Observable'; import {ScrollDispatcher} from './scroll-dispatcher'; import 'rxjs/add/observable/fromEvent'; @@ -15,7 +13,8 @@ import 'rxjs/add/observable/fromEvent'; selector: '[cdk-scrollable]' }) export class Scrollable implements OnInit, OnDestroy { - constructor(private _elementRef: ElementRef, private _scroll: ScrollDispatcher) {} + constructor(private _elementRef: ElementRef, + private _scroll: ScrollDispatcher) {} ngOnInit() { this._scroll.register(this); @@ -31,4 +30,8 @@ export class Scrollable implements OnInit, OnDestroy { elementScrolled(): Observable { return Observable.fromEvent(this._elementRef.nativeElement, 'scroll'); } + + getElementRef(): ElementRef { + return this._elementRef; + } } diff --git a/src/lib/sidenav/sidenav-container.html b/src/lib/sidenav/sidenav-container.html index c916ef399af4..652ebf250c62 100644 --- a/src/lib/sidenav/sidenav-container.html +++ b/src/lib/sidenav/sidenav-container.html @@ -3,6 +3,6 @@ -
+
diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts index 89852ac84dd4..f12e3c3c0f74 100644 --- a/src/lib/sidenav/sidenav.ts +++ b/src/lib/sidenav/sidenav.ts @@ -22,7 +22,6 @@ import {FocusTrap} from '../core/a11y/focus-trap'; import {ESCAPE} from '../core/keyboard/keycodes'; import {OverlayModule} from '../core/overlay/overlay-directives'; import {InteractivityChecker} from '../core/a11y/interactivity-checker'; -import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher'; /** Exception thrown when two MdSidenav are matching the same side. */ @@ -516,7 +515,7 @@ export class MdSidenavModule { static forRoot(): ModuleWithProviders { return { ngModule: MdSidenavModule, - providers: [InteractivityChecker, ScrollDispatcher] + providers: [InteractivityChecker] }; } } diff --git a/src/lib/tooltip/tooltip.spec.ts b/src/lib/tooltip/tooltip.spec.ts index 83c9ac5a1657..70abb527b47c 100644 --- a/src/lib/tooltip/tooltip.spec.ts +++ b/src/lib/tooltip/tooltip.spec.ts @@ -1,16 +1,17 @@ import { - async, - ComponentFixture, - TestBed, - tick, - fakeAsync, - flushMicrotasks + async, + ComponentFixture, + TestBed, + tick, + fakeAsync, + flushMicrotasks } from '@angular/core/testing'; import {Component, DebugElement, AnimationTransitionEvent} from '@angular/core'; import {By} from '@angular/platform-browser'; import {TooltipPosition, MdTooltip, MdTooltipModule} from './tooltip'; import {OverlayContainer} from '../core'; import {Dir, LayoutDirection} from '../core/rtl/dir'; +import {OverlayModule} from '../core/overlay/overlay-directives'; const initialTooltipMessage = 'initial tooltip message'; @@ -20,7 +21,7 @@ describe('MdTooltip', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdTooltipModule.forRoot()], + imports: [MdTooltipModule.forRoot(), OverlayModule], declarations: [BasicTooltipDemo], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -312,3 +313,4 @@ class BasicTooltipDemo { message: string = initialTooltipMessage; showButton: boolean = true; } + diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index 264b7f7851ed..e4c1849452d8 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -14,8 +14,7 @@ import { AnimationTransitionEvent, NgZone, Optional, - OnDestroy, - OnInit + OnDestroy } from '@angular/core'; import { Overlay, @@ -31,7 +30,6 @@ import {MdTooltipInvalidPositionError} from './tooltip-errors'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; import {Dir} from '../core/rtl/dir'; -import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher'; import {OVERLAY_PROVIDERS} from '../core/overlay/overlay'; import 'rxjs/add/operator/first'; @@ -56,7 +54,7 @@ export const TOUCHEND_HIDE_DELAY = 1500; }, exportAs: 'mdTooltip', }) -export class MdTooltip implements OnInit, OnDestroy { +export class MdTooltip implements OnDestroy { _overlayRef: OverlayRef; _tooltipInstance: TooltipComponent; @@ -105,21 +103,10 @@ export class MdTooltip implements OnInit, OnDestroy { set _deprecatedMessage(v: string) { this.message = v; } constructor(private _overlay: Overlay, - private _scrollDispatcher: ScrollDispatcher, private _elementRef: ElementRef, private _viewContainerRef: ViewContainerRef, private _ngZone: NgZone, - @Optional() private _dir: Dir) {} - - ngOnInit() { - // When a scroll on the page occurs, update the position in case this tooltip needs - // to be repositioned. - this._scrollDispatcher.scrolled().subscribe(() => { - if (this._overlayRef) { - this._overlayRef.updatePosition(); - } - }); - } + @Optional() private _dir: Dir) { } /** * Dispose the tooltip when destroyed. @@ -400,10 +387,7 @@ export class MdTooltipModule { static forRoot(): ModuleWithProviders { return { ngModule: MdTooltipModule, - providers: [ - OVERLAY_PROVIDERS, - ScrollDispatcher - ] + providers: [OVERLAY_PROVIDERS] }; } }