mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(tooltip): ensure tooltip stays within viewport (#6659)
* Adds a fallback position to ensure that tooltips can't go outside the viewport. * Refactors the `transform-origin` logic to depend on the overlay position, instead of the tooltip position. Fixes #5428.
This commit is contained in:
committed by
Andrew Seguin
parent
d5f96c8727
commit
c8ddd396a7
@@ -342,71 +342,81 @@ describe('MatTooltip', () => {
|
||||
|
||||
it('should consistently position before and after overlay origin in ltr and rtl dir', () => {
|
||||
tooltipDirective.position = 'left';
|
||||
const leftOrigin = tooltipDirective._getOrigin();
|
||||
const leftOrigin = tooltipDirective._getOrigin().main;
|
||||
tooltipDirective.position = 'right';
|
||||
const rightOrigin = tooltipDirective._getOrigin();
|
||||
const rightOrigin = tooltipDirective._getOrigin().main;
|
||||
|
||||
// Test expectations in LTR
|
||||
tooltipDirective.position = 'before';
|
||||
expect(tooltipDirective._getOrigin()).toEqual(leftOrigin);
|
||||
expect(tooltipDirective._getOrigin().main).toEqual(leftOrigin);
|
||||
tooltipDirective.position = 'after';
|
||||
expect(tooltipDirective._getOrigin()).toEqual(rightOrigin);
|
||||
expect(tooltipDirective._getOrigin().main).toEqual(rightOrigin);
|
||||
|
||||
// Test expectations in LTR
|
||||
dir.value = 'rtl';
|
||||
tooltipDirective.position = 'before';
|
||||
expect(tooltipDirective._getOrigin()).toEqual(rightOrigin);
|
||||
expect(tooltipDirective._getOrigin().main).toEqual(rightOrigin);
|
||||
tooltipDirective.position = 'after';
|
||||
expect(tooltipDirective._getOrigin()).toEqual(leftOrigin);
|
||||
expect(tooltipDirective._getOrigin().main).toEqual(leftOrigin);
|
||||
});
|
||||
|
||||
it('should consistently position before and after overlay position in ltr and rtl dir', () => {
|
||||
tooltipDirective.position = 'left';
|
||||
const leftOverlayPosition = tooltipDirective._getOverlayPosition();
|
||||
const leftOverlayPosition = tooltipDirective._getOverlayPosition().main;
|
||||
tooltipDirective.position = 'right';
|
||||
const rightOverlayPosition = tooltipDirective._getOverlayPosition();
|
||||
const rightOverlayPosition = tooltipDirective._getOverlayPosition().main;
|
||||
|
||||
// Test expectations in LTR
|
||||
tooltipDirective.position = 'before';
|
||||
expect(tooltipDirective._getOverlayPosition()).toEqual(leftOverlayPosition);
|
||||
expect(tooltipDirective._getOverlayPosition().main).toEqual(leftOverlayPosition);
|
||||
tooltipDirective.position = 'after';
|
||||
expect(tooltipDirective._getOverlayPosition()).toEqual(rightOverlayPosition);
|
||||
expect(tooltipDirective._getOverlayPosition().main).toEqual(rightOverlayPosition);
|
||||
|
||||
// Test expectations in LTR
|
||||
dir.value = 'rtl';
|
||||
tooltipDirective.position = 'before';
|
||||
expect(tooltipDirective._getOverlayPosition()).toEqual(rightOverlayPosition);
|
||||
expect(tooltipDirective._getOverlayPosition().main).toEqual(rightOverlayPosition);
|
||||
tooltipDirective.position = 'after';
|
||||
expect(tooltipDirective._getOverlayPosition()).toEqual(leftOverlayPosition);
|
||||
expect(tooltipDirective._getOverlayPosition().main).toEqual(leftOverlayPosition);
|
||||
});
|
||||
|
||||
it('should have consistent left transform origin in any dir', () => {
|
||||
tooltipDirective.position = 'right';
|
||||
tooltipDirective.show();
|
||||
fixture.detectChanges();
|
||||
expect(tooltipDirective._tooltipInstance!._transformOrigin).toBe('left');
|
||||
|
||||
tooltipDirective.position = 'after';
|
||||
tooltipDirective.show();
|
||||
fixture.detectChanges();
|
||||
expect(tooltipDirective._tooltipInstance!._transformOrigin).toBe('left');
|
||||
|
||||
dir.value = 'rtl';
|
||||
tooltipDirective.position = 'before';
|
||||
tooltipDirective.show();
|
||||
fixture.detectChanges();
|
||||
expect(tooltipDirective._tooltipInstance!._transformOrigin).toBe('left');
|
||||
});
|
||||
|
||||
it('should have consistent right transform origin in any dir', () => {
|
||||
// Move the button away from the edge of the screen so
|
||||
// we don't get into the fallback positions.
|
||||
fixture.componentInstance.button.nativeElement.style.margin = '200px';
|
||||
|
||||
tooltipDirective.position = 'left';
|
||||
tooltipDirective.show();
|
||||
fixture.detectChanges();
|
||||
expect(tooltipDirective._tooltipInstance!._transformOrigin).toBe('right');
|
||||
|
||||
tooltipDirective.position = 'before';
|
||||
tooltipDirective.show();
|
||||
fixture.detectChanges();
|
||||
expect(tooltipDirective._tooltipInstance!._transformOrigin).toBe('right');
|
||||
|
||||
dir.value = 'rtl';
|
||||
tooltipDirective.position = 'after';
|
||||
tooltipDirective.show();
|
||||
fixture.detectChanges();
|
||||
expect(tooltipDirective._tooltipInstance!._transformOrigin).toBe('right');
|
||||
});
|
||||
|
||||
@@ -463,10 +473,8 @@ describe('MatTooltip', () => {
|
||||
|
||||
document.body.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
tick(500);
|
||||
|
||||
expect(tooltipDirective._isTooltipVisible()).toBe(true);
|
||||
expect(overlayContainerElement.textContent).toContain(initialTooltipMessage);
|
||||
}));
|
||||
|
||||
@@ -479,6 +487,53 @@ describe('MatTooltip', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('fallback positions', () => {
|
||||
let fixture: ComponentFixture<BasicTooltipDemo>;
|
||||
let tooltip: MatTooltip;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BasicTooltipDemo);
|
||||
fixture.detectChanges();
|
||||
tooltip = fixture.debugElement.query(By.css('button')).injector.get<MatTooltip>(MatTooltip);
|
||||
});
|
||||
|
||||
it('should set a fallback origin position by inverting the main origin position', () => {
|
||||
tooltip.position = 'left';
|
||||
expect(tooltip._getOrigin().main.originX).toBe('start');
|
||||
expect(tooltip._getOrigin().fallback.originX).toBe('end');
|
||||
|
||||
tooltip.position = 'right';
|
||||
expect(tooltip._getOrigin().main.originX).toBe('end');
|
||||
expect(tooltip._getOrigin().fallback.originX).toBe('start');
|
||||
|
||||
tooltip.position = 'above';
|
||||
expect(tooltip._getOrigin().main.originY).toBe('top');
|
||||
expect(tooltip._getOrigin().fallback.originY).toBe('bottom');
|
||||
|
||||
tooltip.position = 'below';
|
||||
expect(tooltip._getOrigin().main.originY).toBe('bottom');
|
||||
expect(tooltip._getOrigin().fallback.originY).toBe('top');
|
||||
});
|
||||
|
||||
it('should set a fallback overlay position by inverting the main overlay position', () => {
|
||||
tooltip.position = 'left';
|
||||
expect(tooltip._getOverlayPosition().main.overlayX).toBe('end');
|
||||
expect(tooltip._getOverlayPosition().fallback.overlayX).toBe('start');
|
||||
|
||||
tooltip.position = 'right';
|
||||
expect(tooltip._getOverlayPosition().main.overlayX).toBe('start');
|
||||
expect(tooltip._getOverlayPosition().fallback.overlayX).toBe('end');
|
||||
|
||||
tooltip.position = 'above';
|
||||
expect(tooltip._getOverlayPosition().main.overlayY).toBe('bottom');
|
||||
expect(tooltip._getOverlayPosition().fallback.overlayY).toBe('top');
|
||||
|
||||
tooltip.position = 'below';
|
||||
expect(tooltip._getOverlayPosition().main.overlayY).toBe('top');
|
||||
expect(tooltip._getOverlayPosition().fallback.overlayY).toBe('bottom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollable usage', () => {
|
||||
let fixture: ComponentFixture<ScrollableTooltipDemo>;
|
||||
let buttonDebugElement: DebugElement;
|
||||
@@ -581,7 +636,8 @@ describe('MatTooltip', () => {
|
||||
@Component({
|
||||
selector: 'app',
|
||||
template: `
|
||||
<button *ngIf="showButton"
|
||||
<button #button
|
||||
*ngIf="showButton"
|
||||
[matTooltip]="message"
|
||||
[matTooltipPosition]="position"
|
||||
[matTooltipClass]="{'custom-one': showTooltipClass, 'custom-two': showTooltipClass }">
|
||||
@@ -594,6 +650,7 @@ class BasicTooltipDemo {
|
||||
showButton: boolean = true;
|
||||
showTooltipClass = false;
|
||||
@ViewChild(MatTooltip) tooltip: MatTooltip;
|
||||
@ViewChild('button') button: ElementRef;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
+108
-64
@@ -19,6 +19,9 @@ import {
|
||||
OverlayConfig,
|
||||
RepositionScrollStrategy,
|
||||
ScrollStrategy,
|
||||
ConnectionPositionPair,
|
||||
HorizontalConnectionPos,
|
||||
VerticalConnectionPos,
|
||||
} from '@angular/cdk/overlay';
|
||||
import {Platform} from '@angular/cdk/platform';
|
||||
import {ComponentPortal} from '@angular/cdk/portal';
|
||||
@@ -260,17 +263,25 @@ export class MatTooltip implements OnDestroy {
|
||||
/** Create the overlay config and position strategy */
|
||||
private _createOverlay(): OverlayRef {
|
||||
const origin = this._getOrigin();
|
||||
const position = this._getOverlayPosition();
|
||||
const overlay = this._getOverlayPosition();
|
||||
|
||||
// Create connected position strategy that listens for scroll events to reposition.
|
||||
// After position changes occur and the overlay is clipped by a parent scrollable then
|
||||
// close the tooltip.
|
||||
const strategy = this._overlay.position().connectedTo(this._elementRef, origin, position);
|
||||
const strategy = this._overlay
|
||||
.position()
|
||||
.connectedTo(this._elementRef, origin.main, overlay.main)
|
||||
.withFallbackPosition(origin.fallback, overlay.fallback);
|
||||
|
||||
strategy.withScrollableContainers(this._scrollDispatcher.getScrollContainers(this._elementRef));
|
||||
strategy.onPositionChange.subscribe(change => {
|
||||
if (change.scrollableViewProperties.isOverlayClipped &&
|
||||
this._tooltipInstance && this._tooltipInstance.isVisible()) {
|
||||
this.hide(0);
|
||||
if (this._tooltipInstance) {
|
||||
if (change.scrollableViewProperties.isOverlayClipped && this._tooltipInstance.isVisible()) {
|
||||
// After position changes occur and the overlay is clipped by
|
||||
// a parent scrollable then close the tooltip.
|
||||
this.hide(0);
|
||||
} else {
|
||||
// Otherwise recalculate the origin based on the new position.
|
||||
this._tooltipInstance._setTransformOrigin(change.connectionPair);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -296,52 +307,63 @@ export class MatTooltip implements OnDestroy {
|
||||
this._tooltipInstance = null;
|
||||
}
|
||||
|
||||
/** Returns the origin position based on the user's position preference */
|
||||
_getOrigin(): OriginConnectionPosition {
|
||||
if (this.position == 'above' || this.position == 'below') {
|
||||
return {originX: 'center', originY: this.position == 'above' ? 'top' : 'bottom'};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the origin position and a fallback position based on the user's position preference.
|
||||
* The fallback position is the inverse of the origin (e.g. 'below' -> 'above').
|
||||
*/
|
||||
_getOrigin(): {main: OriginConnectionPosition, fallback: OriginConnectionPosition} {
|
||||
const isDirectionLtr = !this._dir || this._dir.value == 'ltr';
|
||||
if (this.position == 'left' ||
|
||||
this.position == 'before' && isDirectionLtr ||
|
||||
this.position == 'after' && !isDirectionLtr) {
|
||||
return {originX: 'start', originY: 'center'};
|
||||
let position: OriginConnectionPosition;
|
||||
|
||||
if (this.position == 'above' || this.position == 'below') {
|
||||
position = {originX: 'center', originY: this.position == 'above' ? 'top' : 'bottom'};
|
||||
} else if (this.position == 'left' ||
|
||||
this.position == 'before' && isDirectionLtr ||
|
||||
this.position == 'after' && !isDirectionLtr) {
|
||||
position = {originX: 'start', originY: 'center'};
|
||||
} else if (this.position == 'right' ||
|
||||
this.position == 'after' && isDirectionLtr ||
|
||||
this.position == 'before' && !isDirectionLtr) {
|
||||
position = {originX: 'end', originY: 'center'};
|
||||
} else {
|
||||
throw getMatTooltipInvalidPositionError(this.position);
|
||||
}
|
||||
|
||||
if (this.position == 'right' ||
|
||||
this.position == 'after' && isDirectionLtr ||
|
||||
this.position == 'before' && !isDirectionLtr) {
|
||||
return {originX: 'end', originY: 'center'};
|
||||
}
|
||||
const {x, y} = this._invertPosition(position.originX, position.originY);
|
||||
|
||||
throw getMatTooltipInvalidPositionError(this.position);
|
||||
return {
|
||||
main: position,
|
||||
fallback: {originX: x, originY: y}
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the overlay position based on the user's preference */
|
||||
_getOverlayPosition(): OverlayConnectionPosition {
|
||||
if (this.position == 'above') {
|
||||
return {overlayX: 'center', overlayY: 'bottom'};
|
||||
}
|
||||
|
||||
if (this.position == 'below') {
|
||||
return {overlayX: 'center', overlayY: 'top'};
|
||||
}
|
||||
|
||||
/** Returns the overlay position and a fallback position based on the user's preference */
|
||||
_getOverlayPosition(): {main: OverlayConnectionPosition, fallback: OverlayConnectionPosition} {
|
||||
const isLtr = !this._dir || this._dir.value == 'ltr';
|
||||
if (this.position == 'left' ||
|
||||
this.position == 'before' && isLtr ||
|
||||
this.position == 'after' && !isLtr) {
|
||||
return {overlayX: 'end', overlayY: 'center'};
|
||||
let position: OverlayConnectionPosition;
|
||||
|
||||
if (this.position == 'above') {
|
||||
position = {overlayX: 'center', overlayY: 'bottom'};
|
||||
} else if (this.position == 'below') {
|
||||
position = {overlayX: 'center', overlayY: 'top'};
|
||||
} else if (this.position == 'left' ||
|
||||
this.position == 'before' && isLtr ||
|
||||
this.position == 'after' && !isLtr) {
|
||||
position = {overlayX: 'end', overlayY: 'center'};
|
||||
} else if (this.position == 'right' ||
|
||||
this.position == 'after' && isLtr ||
|
||||
this.position == 'before' && !isLtr) {
|
||||
position = {overlayX: 'start', overlayY: 'center'};
|
||||
} else {
|
||||
throw getMatTooltipInvalidPositionError(this.position);
|
||||
}
|
||||
|
||||
if (this.position == 'right' ||
|
||||
this.position == 'after' && isLtr ||
|
||||
this.position == 'before' && !isLtr) {
|
||||
return {overlayX: 'start', overlayY: 'center'};
|
||||
}
|
||||
const {x, y} = this._invertPosition(position.overlayX, position.overlayY);
|
||||
|
||||
throw getMatTooltipInvalidPositionError(this.position);
|
||||
return {
|
||||
main: position,
|
||||
fallback: {overlayX: x, overlayY: y}
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates the tooltip message and repositions the overlay according to the new message length */
|
||||
@@ -367,6 +389,25 @@ export class MatTooltip implements OnDestroy {
|
||||
this._tooltipInstance._markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
/** Inverts an overlay position. */
|
||||
private _invertPosition(x: HorizontalConnectionPos, y: VerticalConnectionPos) {
|
||||
if (this.position === 'above' || this.position === 'below') {
|
||||
if (y === 'top') {
|
||||
y = 'bottom';
|
||||
} else if (y === 'bottom') {
|
||||
y = 'top';
|
||||
}
|
||||
} else {
|
||||
if (x === 'end') {
|
||||
x = 'start';
|
||||
} else if (x === 'start') {
|
||||
x = 'end';
|
||||
}
|
||||
}
|
||||
|
||||
return {x, y};
|
||||
}
|
||||
}
|
||||
|
||||
export type TooltipVisibility = 'initial' | 'visible' | 'hidden';
|
||||
@@ -419,13 +460,15 @@ export class TooltipComponent {
|
||||
private _closeOnInteraction: boolean = false;
|
||||
|
||||
/** The transform origin used in the animation for showing and hiding the tooltip */
|
||||
_transformOrigin: string = 'bottom';
|
||||
_transformOrigin: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
|
||||
|
||||
/** Current position of the tooltip. */
|
||||
private _position: TooltipPosition;
|
||||
|
||||
/** Subject for notifying that the tooltip has been hidden from the view */
|
||||
private _onHide: Subject<any> = new Subject();
|
||||
|
||||
constructor(@Optional() private _dir: Directionality,
|
||||
private _changeDetectorRef: ChangeDetectorRef) {}
|
||||
constructor(private _changeDetectorRef: ChangeDetectorRef) {}
|
||||
|
||||
/**
|
||||
* Shows the tooltip with an animation originating from the provided origin
|
||||
@@ -438,7 +481,9 @@ export class TooltipComponent {
|
||||
clearTimeout(this._hideTimeoutId);
|
||||
}
|
||||
|
||||
this._setTransformOrigin(position);
|
||||
// Body interactions should cancel the tooltip if there is a delay in showing.
|
||||
this._closeOnInteraction = true;
|
||||
this._position = position;
|
||||
this._showTimeoutId = setTimeout(() => {
|
||||
this._visibility = 'visible';
|
||||
|
||||
@@ -467,31 +512,30 @@ export class TooltipComponent {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable that notifies when the tooltip has been hidden from view
|
||||
*/
|
||||
/** Returns an observable that notifies when the tooltip has been hidden from view. */
|
||||
afterHidden(): Observable<void> {
|
||||
return this._onHide.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tooltip is being displayed
|
||||
*/
|
||||
/** Whether the tooltip is being displayed. */
|
||||
isVisible(): boolean {
|
||||
return this._visibility === 'visible';
|
||||
}
|
||||
|
||||
/** Sets the tooltip transform origin according to the tooltip position */
|
||||
_setTransformOrigin(value: TooltipPosition) {
|
||||
const isLtr = !this._dir || this._dir.value == 'ltr';
|
||||
switch (value) {
|
||||
case 'before': this._transformOrigin = isLtr ? 'right' : 'left'; break;
|
||||
case 'after': this._transformOrigin = isLtr ? 'left' : 'right'; break;
|
||||
case 'left': this._transformOrigin = 'right'; break;
|
||||
case 'right': this._transformOrigin = 'left'; break;
|
||||
case 'above': this._transformOrigin = 'bottom'; break;
|
||||
case 'below': this._transformOrigin = 'top'; break;
|
||||
default: throw getMatTooltipInvalidPositionError(value);
|
||||
/** Sets the tooltip transform origin according to the position of the tooltip overlay. */
|
||||
_setTransformOrigin(overlayPosition: ConnectionPositionPair) {
|
||||
const axis = (this._position === 'above' || this._position === 'below') ? 'Y' : 'X';
|
||||
const position = overlayPosition[`overlay${axis}`] as HorizontalConnectionPos |
|
||||
VerticalConnectionPos;
|
||||
|
||||
if (position === 'top' || position === 'bottom') {
|
||||
this._transformOrigin = position;
|
||||
} else if (position === 'start') {
|
||||
this._transformOrigin = 'left';
|
||||
} else if (position === 'end') {
|
||||
this._transformOrigin = 'right';
|
||||
} else {
|
||||
throw getMatTooltipInvalidPositionError(this._position);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user