mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(core): add scrollable view properties to connected pos strategy (#2259)
* feat(scroll): provide directive and service to listen to scrolling * review response * fix lint * move scroll to overlay * feat(scroll): hide tooltip when clipped by scrollable container * use overlay for clipping instead of trigger * add tooltip test * add test for scroll dispatcher * add comment about the ScrollableViewProperties * revert tooltip demo * add extra lines to tooltip demo files * add connected position strategy tests * make lint happy * add scroll demo to tooltip * make FF and IE happy in tests * change test back to Chrome * remove fdescribe * resolve comments * remove usage of scrollables until throttling is set up * remove sha * remove combineLatest * remove unused OnInit import
This commit is contained in:
committed by
Jeremy Elbourn
parent
634bd0f0b2
commit
b60d33fa44
@@ -3,12 +3,12 @@
|
||||
|
||||
<p class="centered">
|
||||
<button #tooltip="mdTooltip"
|
||||
md-raised-button
|
||||
color="primary"
|
||||
[mdTooltip]="message"
|
||||
[mdTooltipPosition]="position"
|
||||
[mdTooltipShowDelay]="showDelay"
|
||||
[mdTooltipHideDelay]="hideDelay">
|
||||
md-raised-button
|
||||
color="primary"
|
||||
[mdTooltip]="message"
|
||||
[mdTooltipPosition]="position"
|
||||
[mdTooltipShowDelay]="showDelay"
|
||||
[mdTooltipHideDelay]="hideDelay">
|
||||
Mouse over to see the tooltip
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -12,5 +12,5 @@ export class TooltipDemo {
|
||||
position: TooltipPosition = 'below';
|
||||
message: string = 'Here is the tooltip';
|
||||
showDelay = 0;
|
||||
hideDelay = 0;
|
||||
hideDelay = 1000;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
|
||||
let originRect: ClientRect;
|
||||
let originCenterX: number;
|
||||
let originCenterY: number;
|
||||
let originElement: HTMLElement;
|
||||
let overlayElement: HTMLElement;
|
||||
let overlayContainerElement: HTMLElement;
|
||||
let strategy: ConnectedPositionStrategy;
|
||||
let fakeElementRef: ElementRef;
|
||||
let fakeViewportRuler: FakeViewportRuler;
|
||||
let positionBuilder: OverlayPositionBuilder;
|
||||
|
||||
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);
|
||||
|
||||
// Reset the origin geometry after each test so we don't accidently keep state between tests.
|
||||
originRect = null;
|
||||
originCenterX = null;
|
||||
originCenterY = null;
|
||||
});
|
||||
|
||||
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`;
|
||||
fakeViewportRuler = new FakeViewportRuler();
|
||||
|
||||
originRect = originElement.getBoundingClientRect();
|
||||
originCenterX = originRect.left + (ORIGIN_WIDTH / 2);
|
||||
originCenterY = originRect.top + (ORIGIN_HEIGHT / 2);
|
||||
});
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
fakeElementRef = new FakeElementRef(originElement);
|
||||
positionBuilder = new OverlayPositionBuilder(new ViewportRuler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(veryLargeElement);
|
||||
document.body.scrollTop = 0;
|
||||
document.body.scrollLeft = 0;
|
||||
document.body.removeChild(originElement);
|
||||
document.body.removeChild(overlayContainerElement);
|
||||
|
||||
// Reset the origin geometry after each test so we don't accidently keep state between tests.
|
||||
originRect = null;
|
||||
originCenterX = null;
|
||||
originCenterY = null;
|
||||
});
|
||||
|
||||
// Preconditions are set, now just run the full set of simple position tests.
|
||||
runSimplePositionTests();
|
||||
});
|
||||
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;
|
||||
|
||||
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.
|
||||
beforeEach(() => {
|
||||
originElement.style.left = `${ORIGIN_LEFT}px`;
|
||||
originElement.style.top = `${ORIGIN_TOP}px`;
|
||||
|
||||
originElement.style.top = '5px';
|
||||
originElement.style.left = '200px';
|
||||
originRect = originElement.getBoundingClientRect();
|
||||
originRect = originElement.getBoundingClientRect();
|
||||
originCenterX = originRect.left + (ORIGIN_WIDTH / 2);
|
||||
originCenterY = originRect.top + (ORIGIN_HEIGHT / 2);
|
||||
});
|
||||
|
||||
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);
|
||||
// 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 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.
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
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 = '5px';
|
||||
originRect = originElement.getBoundingClientRect();
|
||||
originCenterY = originRect.top + (ORIGIN_HEIGHT / 2);
|
||||
originElement.style.left = '475px';
|
||||
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'start', originY: 'bottom'},
|
||||
{overlayX: 'end', overlayY: 'top'})
|
||||
.withFallbackPosition(
|
||||
fakeElementRef,
|
||||
{originX: 'end', originY: 'center'},
|
||||
{overlayX: 'start', overlayY: 'center'});
|
||||
{overlayX: 'start', overlayY: 'center'})
|
||||
.withFallbackPosition(
|
||||
{originX: 'start', originY: 'bottom'},
|
||||
{overlayX: 'end', overlayY: 'top'});
|
||||
|
||||
const positionChangeHandler = jasmine.createSpy('positionChangeHandler');
|
||||
const subscription = strategy.onPositionChange.subscribe(positionChangeHandler);
|
||||
|
||||
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(originCenterY - (OVERLAY_HEIGHT / 2));
|
||||
expect(overlayRect.left).toBe(originRect.right);
|
||||
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 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();
|
||||
/**
|
||||
* 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 = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'start', originY: 'bottom'},
|
||||
{overlayX: 'start', overlayY: 'top'})
|
||||
.withFallbackPosition(
|
||||
{originX: 'end', originY: 'top'},
|
||||
{overlayX: 'end', overlayY: 'bottom'});
|
||||
strategy.apply(overlayElement);
|
||||
|
||||
strategy.apply(overlayElement);
|
||||
let overlayRect = overlayElement.getBoundingClientRect();
|
||||
expect(overlayRect.top).toBe(originRect.bottom);
|
||||
expect(overlayRect.left).toBe(originRect.left);
|
||||
});
|
||||
|
||||
let overlayRect = overlayElement.getBoundingClientRect();
|
||||
expect(overlayRect.bottom).toBe(originRect.top);
|
||||
expect(overlayRect.right).toBe(originRect.right);
|
||||
});
|
||||
it('should position to the right, center aligned vertically', () => {
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'end', originY: 'center'},
|
||||
{overlayX: 'start', overlayY: 'center'});
|
||||
|
||||
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);
|
||||
strategy.apply(overlayElement);
|
||||
|
||||
originElement.style.top = '200px';
|
||||
originElement.style.left = '475px';
|
||||
originRect = originElement.getBoundingClientRect();
|
||||
let overlayRect = overlayElement.getBoundingClientRect();
|
||||
expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2));
|
||||
expect(overlayRect.left).toBe(originRect.right);
|
||||
});
|
||||
|
||||
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 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');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
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(
|
||||
it('should position to the left, below', () => {
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'start', originY: 'bottom'},
|
||||
{overlayX: 'end', 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.right).toBe(originRect.left);
|
||||
});
|
||||
|
||||
originElement.style.top = '0';
|
||||
originElement.style.left = '0';
|
||||
it('should position above, right aligned', () => {
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'end', originY: 'top'},
|
||||
{overlayX: 'end', overlayY: 'bottom'});
|
||||
|
||||
// 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.bottom).toBe(originRect.top);
|
||||
expect(overlayRect.right).toBe(originRect.right);
|
||||
});
|
||||
|
||||
it('should position below, centered', () => {
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'center', originY: 'bottom'},
|
||||
{overlayX: 'center', overlayY: 'top'});
|
||||
|
||||
strategy.apply(overlayElement);
|
||||
|
||||
let overlayRect = overlayElement.getBoundingClientRect();
|
||||
expect(overlayRect.top).toBe(originRect.bottom);
|
||||
expect(overlayRect.left).toBe(originCenterX - (OVERLAY_WIDTH / 2));
|
||||
});
|
||||
|
||||
it('should center the overlay on the origin', () => {
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'center', originY: 'center'},
|
||||
{overlayX: 'center', overlayY: 'center'});
|
||||
|
||||
strategy.apply(overlayElement);
|
||||
|
||||
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', () => {
|
||||
let scrollable: HTMLDivElement;
|
||||
let positionChangeHandler: Spy;
|
||||
let onPositionChangeSubscription: Subscription;
|
||||
let positionChange: ConnectedOverlayPositionChange;
|
||||
|
||||
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: 'start', originY: 'bottom'},
|
||||
{overlayX: 'start', overlayY: 'top'});
|
||||
fakeElementRef,
|
||||
{originX: 'start', originY: 'bottom'},
|
||||
{overlayX: 'start', overlayY: 'top'});
|
||||
strategy.withScrollableContainers([new Scrollable(new FakeElementRef(scrollable), null)]);
|
||||
|
||||
strategy.apply(overlayElement);
|
||||
|
||||
let overlayRect = overlayElement.getBoundingClientRect();
|
||||
expect(overlayRect.top).toBe(originRect.bottom);
|
||||
expect(overlayRect.left).toBe(originRect.left);
|
||||
positionChangeHandler = jasmine.createSpy('positionChangeHandler');
|
||||
onPositionChangeSubscription = strategy.onPositionChange.subscribe(positionChangeHandler);
|
||||
});
|
||||
|
||||
it('should position to the right, center aligned vertically', () => {
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{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);
|
||||
afterEach(() => {
|
||||
onPositionChangeSubscription.unsubscribe();
|
||||
document.body.removeChild(scrollable);
|
||||
document.body.removeChild(overlayContainerElement);
|
||||
});
|
||||
|
||||
it('should position to the left, below', () => {
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'start', originY: 'bottom'},
|
||||
{overlayX: 'end', overlayY: 'top'});
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<ConnectedOverlayPositionChange> = new Subject<ConnectedOverlayPositionChange>();
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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<ScrollingComponent>;
|
||||
|
||||
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<ScrollingComponent>;
|
||||
|
||||
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);
|
||||
|
||||
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; });
|
||||
|
||||
// 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 have the scrollable directive deregistered when the component is destroyed', () => {
|
||||
const componentScrollable = fixture.componentInstance.scrollable;
|
||||
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true);
|
||||
describe('Nested scrollables', () => {
|
||||
let scroll: ScrollDispatcher;
|
||||
let fixture: ComponentFixture<NestedScrollingComponent>;
|
||||
|
||||
fixture.destroy();
|
||||
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false);
|
||||
});
|
||||
beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => {
|
||||
scroll = s;
|
||||
|
||||
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; });
|
||||
fixture = TestBed.createComponent(NestedScrollingComponent);
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
// Listen for notifications from scroll service
|
||||
let hasServiceScrollNotified = false;
|
||||
scroll.scrolled().subscribe(() => { hasServiceScrollNotified = true; });
|
||||
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);
|
||||
|
||||
// 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);
|
||||
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: `
|
||||
<div id="scrollable-1" cdk-scrollable>
|
||||
<div id="scrollable-1a" cdk-scrollable>
|
||||
<div #interestingElement></div>
|
||||
</div>
|
||||
<div id="scrollable-1b" cdk-scrollable></div>
|
||||
</div>
|
||||
<div id="scrollable-2" cdk-scrollable></div>
|
||||
`
|
||||
})
|
||||
class NestedScrollingComponent {
|
||||
@ViewChild('interestingElement') interestingElement: ElementRef;
|
||||
@ViewChildren(Scrollable) scrollables: QueryList<Scrollable>;
|
||||
}
|
||||
|
||||
const TEST_COMPONENTS = [ScrollingComponent, NestedScrollingComponent];
|
||||
@NgModule({
|
||||
imports: [OverlayModule],
|
||||
providers: [ScrollDispatcher],
|
||||
|
||||
@@ -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<Scrollable, Subscription> = new WeakMap();
|
||||
scrollableReferences: Map<Scrollable, Subscription> = 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<any> {
|
||||
return Observable.fromEvent(this._elementRef.nativeElement, 'scroll');
|
||||
}
|
||||
|
||||
getElementRef(): ElementRef {
|
||||
return this._elementRef;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
|
||||
<ng-content select="md-sidenav, mat-sidenav"></ng-content>
|
||||
|
||||
<div class="md-sidenav-content" [ngStyle]="_getStyles()" cdk-scrollable>
|
||||
<div class="md-sidenav-content" [ngStyle]="_getStyles()">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user