Skip to content

Commit 8c62fb2

Browse files
committed
feat(scroll): hide tooltip when clipped by scrollable container
1 parent a08ad54 commit 8c62fb2

File tree

7 files changed

+124
-9
lines changed

7 files changed

+124
-9
lines changed

src/demo-app/tooltip/tooltip-demo.html

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div class="demo-tooltip">
1+
<div class="demo-tooltip" cdk-scrollable>
22
<h1>Tooltip Demo</h1>
33

44
<p class="centered">
@@ -40,7 +40,7 @@ <h1>Tooltip Demo</h1>
4040
</p>
4141

4242
<strong>Mouse over to</strong>
43-
<button md-raised-button color="primary" (mouseenter)="tooltip.show()">
43+
<button md-raised-button color="primary" (mouseenter)="tooltip1.show(); tooltip2.show()">
4444
Show tooltip
4545
</button>
4646
<button md-raised-button color="primary" (mouseenter)="tooltip.hide()">
@@ -50,3 +50,28 @@ <h1>Tooltip Demo</h1>
5050
Toggle tooltip
5151
</button>
5252
</div>
53+
54+
<div class="scrolling-container" cdk-scrollable>
55+
Scroll to see tooltip button.
56+
<button #tooltip1="mdTooltip"
57+
md-raised-button
58+
color="primary"
59+
[md-tooltip]="message"
60+
[tooltip-position]="position"
61+
[tooltipShowDelay]="showDelay"
62+
[tooltipHideDelay]="hideDelay">
63+
Mouse over to see the tooltip
64+
</button>
65+
</div>
66+
<div class="scrolling-container" cdk-scrollable>
67+
Scroll to see tooltip button.
68+
<button #tooltip2="mdTooltip"
69+
md-raised-button
70+
color="primary"
71+
[md-tooltip]="message"
72+
[tooltip-position]="position"
73+
[tooltipShowDelay]="showDelay"
74+
[tooltipHideDelay]="hideDelay">
75+
Mouse over to see the tooltip
76+
</button>
77+
</div>

src/demo-app/tooltip/tooltip-demo.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,14 @@
66
display: block;
77
}
88
}
9+
10+
.scrolling-container {
11+
margin: 300px;
12+
height: 300px;
13+
width: 300px;
14+
overflow: auto;
15+
16+
button {
17+
margin: 500px;
18+
}
19+
}

src/lib/core/overlay/position/connected-position-strategy.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import {
99
} from './connected-position';
1010
import {Subject} from 'rxjs/Subject';
1111
import {Observable} from 'rxjs/Observable';
12+
import {Scrollable} from '../scroll/scrollable';
13+
14+
export type ElementBoundingPositions = {
15+
top: number;
16+
right: number;
17+
bottom: number;
18+
left: number;
19+
}
1220

1321
/**
1422
* A strategy for positioning overlays. Using this strategy, an overlay is given an
@@ -26,6 +34,9 @@ export class ConnectedPositionStrategy implements PositionStrategy {
2634
/** The offset in pixels for the overlay connection point on the y-axis */
2735
private _offsetY: number = 0;
2836

37+
/** The Scrollable containers that may cause the overlay's connectedTo element to be clipped */
38+
private scrollables: Scrollable[];
39+
2940
/** Whether the we're dealing with an RTL context */
3041
get _isRtl() {
3142
return this._dir === 'rtl';
@@ -91,7 +102,8 @@ export class ConnectedPositionStrategy implements PositionStrategy {
91102
// If the overlay in the calculated position fits on-screen, put it there and we're done.
92103
if (overlayPoint.fitsInViewport) {
93104
this._setElementPosition(element, overlayPoint);
94-
this._onPositionChange.next(new ConnectedOverlayPositionChange(pos));
105+
const isClipped = this.isConnectedToElementClipped();
106+
this._onPositionChange.next(new ConnectedOverlayPositionChange(pos, isClipped));
95107
return Promise.resolve(null);
96108
} else if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) {
97109
fallbackPoint = overlayPoint;
@@ -105,6 +117,14 @@ export class ConnectedPositionStrategy implements PositionStrategy {
105117
return Promise.resolve(null);
106118
}
107119

120+
/**
121+
* Sets the list of scrolling or resizing containers that host the connectedTo element so that
122+
* on reposition we can evaluate if it has been clipped when repositioned.
123+
*/
124+
withScrollableContainers(scrollables: Scrollable[]) {
125+
this.scrollables = scrollables;
126+
}
127+
108128
withFallbackPosition(
109129
originPos: OriginConnectionPosition,
110130
overlayPos: OverlayConnectionPosition): this {
@@ -233,6 +253,16 @@ export class ConnectedPositionStrategy implements PositionStrategy {
233253
element.style.top = overlayPoint.y + 'px';
234254
}
235255

256+
private _getElementBounds(element: ElementRef): ElementBoundingPositions {
257+
const boundingClientRect = element.nativeElement.getBoundingClientRect();
258+
return {
259+
top: boundingClientRect.top,
260+
right: boundingClientRect.left + boundingClientRect.width,
261+
bottom: boundingClientRect.top + boundingClientRect.height,
262+
left: boundingClientRect.left
263+
};
264+
}
265+
236266
/**
237267
* Subtracts the amount that an element is overflowing on an axis from it's length.
238268
*/

src/lib/core/overlay/position/connected-position.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@ export class ConnectionPositionPair {
3434

3535
/** The change event emitted by the strategy when a fallback position is used. */
3636
export class ConnectedOverlayPositionChange {
37-
constructor(public connectionPair: ConnectionPositionPair) {}
37+
constructor(public connectionPair: ConnectionPositionPair,
38+
public isConnectedToElementClipped: boolean) {}
3839
}

src/lib/core/overlay/scroll/scroll-dispatcher.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import {Injectable} from '@angular/core';
1+
import {Injectable, ElementRef} from '@angular/core';
22
import {Scrollable} from './scrollable';
33
import {Subject} from 'rxjs/Subject';
44
import {Observable} from 'rxjs/Observable';
55
import {Subscription} from 'rxjs/Subscription';
66
import 'rxjs/add/observable/fromEvent';
7+
import 'rxjs/add/observable/combineLatest';
78

89

910
/**
@@ -19,7 +20,7 @@ export class ScrollDispatcher {
1920
* Map of all the scrollable references that are registered with the service and their
2021
* scroll event subscriptions.
2122
*/
22-
scrollableReferences: WeakMap<Scrollable, Subscription> = new WeakMap();
23+
scrollableReferences: Map<Scrollable, Subscription> = new Map();
2324

2425
constructor() {
2526
// By default, notify a scroll event when the document is scrolled or the window is resized.
@@ -53,8 +54,34 @@ export class ScrollDispatcher {
5354
return this._scrolled.asObservable();
5455
}
5556

57+
/** Returns all registered Scrollables that contain the provided element. */
58+
getScrollableContainers(elementRef: ElementRef): Scrollable[] {
59+
const scrollingContainers: Scrollable[] = [];
60+
61+
this.scrollableReferences.forEach((subscription: Subscription, scrollable: Scrollable) => {
62+
if (this.scrollableContainsElement(scrollable, elementRef)) {
63+
scrollingContainers.push(scrollable);
64+
}
65+
});
66+
67+
return scrollingContainers;
68+
}
69+
70+
/** Returns true if the element is contained within the provided Scrollable. */
71+
scrollableContainsElement(scrollable: Scrollable, elementRef: ElementRef): boolean {
72+
let element = elementRef.nativeElement;
73+
let scrollableElement = scrollable.getElementRef().nativeElement;
74+
75+
// Traverse through the element parents until we reach null, checking if any of the elements
76+
// are the scrollable's element.
77+
do {
78+
if (element == scrollableElement) { return true; }
79+
} while (element = element.parentElement);
80+
}
81+
5682
/** Sends a notification that a scroll event has been fired. */
5783
_notify() {
5884
this._scrolled.next();
5985
}
6086
}
87+

src/lib/core/overlay/scroll/scrollable.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
Directive, ElementRef, OnInit, OnDestroy
2+
Directive, ElementRef, OnInit, OnDestroy, Optional, SkipSelf
33
} from '@angular/core';
44
import {Observable} from 'rxjs/Observable';
55
import {ScrollDispatcher} from './scroll-dispatcher';
@@ -15,7 +15,9 @@ import 'rxjs/add/observable/fromEvent';
1515
selector: '[cdk-scrollable]'
1616
})
1717
export class Scrollable implements OnInit, OnDestroy {
18-
constructor(private _elementRef: ElementRef, private _scroll: ScrollDispatcher) {}
18+
constructor(private _elementRef: ElementRef,
19+
@SkipSelf() @Optional() private parentScrollable: Scrollable,
20+
private _scroll: ScrollDispatcher) {}
1921

2022
ngOnInit() {
2123
this._scroll.register(this);
@@ -29,4 +31,13 @@ export class Scrollable implements OnInit, OnDestroy {
2931
elementScrolled(): Observable<any> {
3032
return Observable.fromEvent(this._elementRef.nativeElement, 'scroll');
3133
}
34+
35+
getElementRef(): ElementRef {
36+
return this._elementRef;
37+
}
38+
39+
/** Returns this scrollable along with all the scrollables that this is contained within. */
40+
getAllScrollables(): Scrollable[] {
41+
return this.parentScrollable ? this.parentScrollable.getAllScrollables().concat(this) : [this];
42+
}
3243
}

src/lib/tooltip/tooltip.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {Dir} from '../core/rtl/dir';
3434
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
3535
import {OverlayPositionBuilder} from '../core/overlay/position/overlay-position-builder';
3636
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
37+
import {Scrollable} from '../core/overlay/scroll/scrollable';
3738
import 'rxjs/add/operator/first';
3839

3940
export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after';
@@ -112,7 +113,7 @@ export class MdTooltip implements OnInit, OnDestroy {
112113
private _elementRef: ElementRef,
113114
private _viewContainerRef: ViewContainerRef,
114115
private _ngZone: NgZone,
115-
@Optional() private _dir: Dir) {}
116+
@Optional() private _dir: Dir) { }
116117

117118
ngOnInit() {
118119
// When a scroll on the page occurs, update the position in case this tooltip needs
@@ -179,7 +180,16 @@ export class MdTooltip implements OnInit, OnDestroy {
179180
private _createOverlay(): void {
180181
let origin = this._getOrigin();
181182
let position = this._getOverlayPosition();
183+
184+
// Create connected strategy that listens for scroll events to reposition. After position
185+
// changes occur, check if the scrolling trigger has been clipped and close the tooltip.
182186
let strategy = this._overlay.position().connectedTo(this._elementRef, origin, position);
187+
strategy.withScrollableContainers(
188+
this._scrollDispatcher.getScrollableContainers(this._elementRef));
189+
strategy.onPositionChange.subscribe(change => {
190+
if (change.isConnectedToElementClipped) { this.hide(0); }
191+
});
192+
183193
let config = new OverlayState();
184194
config.positionStrategy = strategy;
185195

0 commit comments

Comments
 (0)