diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.ts index 65f68bc434d3..fc9103663a26 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.ts @@ -98,7 +98,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { _preferredPositions: ConnectionPositionPair[] = []; /** The origin element against which the overlay will be positioned. */ - private _origin: FlexibleConnectedPositionStrategyOrigin; + _origin: FlexibleConnectedPositionStrategyOrigin; /** The overlay pane element. */ private _pane: HTMLElement; diff --git a/src/components-examples/material/tooltip/index.ts b/src/components-examples/material/tooltip/index.ts index e3d793ea17db..4ad892a840eb 100644 --- a/src/components-examples/material/tooltip/index.ts +++ b/src/components-examples/material/tooltip/index.ts @@ -16,6 +16,7 @@ import {TooltipMessageExample} from './tooltip-message/tooltip-message-example'; import {TooltipModifiedDefaultsExample} from './tooltip-modified-defaults/tooltip-modified-defaults-example'; import {TooltipOverviewExample} from './tooltip-overview/tooltip-overview-example'; import {TooltipPositionExample} from './tooltip-position/tooltip-position-example'; +import {TooltipPositionAtOriginExample} from './tooltip-position-at-origin/tooltip-position-at-origin-example'; import {TooltipHarnessExample} from './tooltip-harness/tooltip-harness-example'; export { @@ -29,6 +30,7 @@ export { TooltipModifiedDefaultsExample, TooltipOverviewExample, TooltipPositionExample, + TooltipPositionAtOriginExample, }; const EXAMPLES = [ @@ -42,6 +44,7 @@ const EXAMPLES = [ TooltipModifiedDefaultsExample, TooltipOverviewExample, TooltipPositionExample, + TooltipPositionAtOriginExample, ]; @NgModule({ diff --git a/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.css b/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.css new file mode 100644 index 000000000000..82d14164d994 --- /dev/null +++ b/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.css @@ -0,0 +1,8 @@ +button { + width: 500px; + height: 500px; +} + +.example-enabled-checkbox { + margin-left: 8px; +} diff --git a/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.html b/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.html new file mode 100644 index 000000000000..e1aee7f8f4b7 --- /dev/null +++ b/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.html @@ -0,0 +1,10 @@ + + + + Position at origin enabled + diff --git a/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.ts b/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.ts new file mode 100644 index 000000000000..1bfb48e4fb1b --- /dev/null +++ b/src/components-examples/material/tooltip/tooltip-position-at-origin/tooltip-position-at-origin-example.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {FormControl} from '@angular/forms'; + +/** + * @title Basic tooltip + */ +@Component({ + selector: 'tooltip-position-at-origin-example', + templateUrl: 'tooltip-position-at-origin-example.html', + styleUrls: ['tooltip-position-at-origin-example.css'], +}) +export class TooltipPositionAtOriginExample { + enabled = new FormControl(false); +} diff --git a/src/dev-app/tooltip/tooltip-demo.html b/src/dev-app/tooltip/tooltip-demo.html index 0c7f2bede630..b848c7acd64f 100644 --- a/src/dev-app/tooltip/tooltip-demo.html +++ b/src/dev-app/tooltip/tooltip-demo.html @@ -24,3 +24,6 @@

Tooltip overview

Tooltip positioning

+ +

Tooltip with position at origin

+ diff --git a/src/material/legacy-tooltip/tooltip.spec.ts b/src/material/legacy-tooltip/tooltip.spec.ts index 72a816d402cc..213f143e9f9d 100644 --- a/src/material/legacy-tooltip/tooltip.spec.ts +++ b/src/material/legacy-tooltip/tooltip.spec.ts @@ -232,6 +232,77 @@ describe('MatTooltip', () => { expect(tooltipDirective._getOverlayPosition().fallback.overlayX).toBe('end'); })); + it('should position center-bottom by default', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + TestBed.resetTestingModule() + .configureTestingModule({ + imports: [MatLegacyTooltipModule, OverlayModule], + declarations: [WideTooltipDemo], + }) + .compileComponents(); + + const wideFixture = TestBed.createComponent(WideTooltipDemo); + wideFixture.detectChanges(); + tooltipDirective = wideFixture.debugElement + .query(By.css('button'))! + .injector.get(MatLegacyTooltip); + const button: HTMLButtonElement = wideFixture.nativeElement.querySelector('button'); + const triggerRect = button.getBoundingClientRect(); + + dispatchMouseEvent(button, 'mouseenter', triggerRect.right - 100, triggerRect.top + 100); + wideFixture.detectChanges(); + tick(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + expect(tooltipDirective._overlayRef!.overlayElement.offsetLeft).toBeGreaterThan( + triggerRect.left + 200, + ); + expect(tooltipDirective._overlayRef!.overlayElement.offsetLeft).toBeLessThan( + triggerRect.left + 300, + ); + expect(tooltipDirective._overlayRef!.overlayElement.offsetTop).toBe(triggerRect.bottom); + })); + + it('should be able to override the default positionAtOrigin', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + TestBed.resetTestingModule() + .configureTestingModule({ + imports: [MatLegacyTooltipModule, OverlayModule], + declarations: [WideTooltipDemo], + providers: [ + { + provide: MAT_TOOLTIP_DEFAULT_OPTIONS, + useValue: {positionAtOrigin: true}, + }, + ], + }) + .compileComponents(); + + const wideFixture = TestBed.createComponent(WideTooltipDemo); + wideFixture.detectChanges(); + tooltipDirective = wideFixture.debugElement + .query(By.css('button'))! + .injector.get(MatLegacyTooltip); + const button: HTMLButtonElement = wideFixture.nativeElement.querySelector('button'); + const triggerRect = button.getBoundingClientRect(); + + dispatchMouseEvent(button, 'mouseenter', triggerRect.left + 50, triggerRect.bottom - 10); + wideFixture.detectChanges(); + tick(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + expect(tooltipDirective._overlayRef!.overlayElement.offsetLeft).toBe(triggerRect.left + 28); + expect(tooltipDirective._overlayRef!.overlayElement.offsetTop).toBe(triggerRect.bottom - 10); + })); + it('should be able to disable tooltip interactivity', fakeAsync(() => { TestBed.resetTestingModule() .configureTestingModule({ @@ -1556,6 +1627,17 @@ class TooltipDemoWithoutPositionBinding { @ViewChild('button') button: ElementRef; } +@Component({ + selector: 'app', + styles: [`button { width: 500px; height: 500px; }`], + template: ``, +}) +class WideTooltipDemo { + message = 'Test'; + @ViewChild(MatLegacyTooltip) tooltip: MatLegacyTooltip; + @ViewChild('button') button: ElementRef; +} + /** Asserts whether a tooltip directive has a tooltip instance. */ function assertTooltipInstance(tooltip: MatLegacyTooltip, shouldExist: boolean): void { // Note that we have to cast this to a boolean, because Jasmine will go into an infinite loop diff --git a/src/material/tooltip/tooltip.md b/src/material/tooltip/tooltip.md index 69e2aaf44d29..079bd174d1b5 100644 --- a/src/material/tooltip/tooltip.md +++ b/src/material/tooltip/tooltip.md @@ -27,6 +27,12 @@ CSS class that can be used for style (e.g. to add an arrow). The possible classe +To display the tooltip relative to the mouse or touch that triggered it, use the +`matTooltipPositionAtOrigin` input. +With this setting turned on, the tooltip will display relative to the origin of the trigger rather +than the host element. In cases where the tooltip is not triggered by a touch event or mouse click, +it will display the same as if this setting was turned off. + ### Showing and hiding By default, the tooltip will be immediately shown when the user's mouse hovers over the tooltip's diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index 369779bad0e3..73f2e884ae24 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -234,6 +234,78 @@ describe('MDC-based MatTooltip', () => { expect(tooltipDirective._getOverlayPosition().fallback.overlayX).toBe('end'); })); + it('should position on the bottom-left by default', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + TestBed.resetTestingModule() + .configureTestingModule({ + imports: [MatTooltipModule, OverlayModule], + declarations: [WideTooltipDemo], + }) + .compileComponents(); + + const wideFixture = TestBed.createComponent(WideTooltipDemo); + wideFixture.detectChanges(); + tooltipDirective = wideFixture.debugElement + .query(By.css('button'))! + .injector.get(MatTooltip); + const button: HTMLButtonElement = wideFixture.nativeElement.querySelector('button'); + const triggerRect = button.getBoundingClientRect(); + + dispatchMouseEvent(button, 'mouseenter', triggerRect.right - 100, triggerRect.top + 100); + wideFixture.detectChanges(); + tick(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + expect(tooltipDirective._overlayRef!.overlayElement.offsetLeft).toBeLessThan( + triggerRect.right - 250, + ); + expect(tooltipDirective._overlayRef!.overlayElement.offsetTop).toBeGreaterThanOrEqual( + triggerRect.bottom, + ); + })); + + it('should be able to override the default positionAtOrigin', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + TestBed.resetTestingModule() + .configureTestingModule({ + imports: [MatTooltipModule, OverlayModule], + declarations: [WideTooltipDemo], + providers: [ + { + provide: MAT_TOOLTIP_DEFAULT_OPTIONS, + useValue: {positionAtOrigin: true}, + }, + ], + }) + .compileComponents(); + + const wideFixture = TestBed.createComponent(WideTooltipDemo); + wideFixture.detectChanges(); + tooltipDirective = wideFixture.debugElement + .query(By.css('button'))! + .injector.get(MatTooltip); + const button: HTMLButtonElement = wideFixture.nativeElement.querySelector('button'); + const triggerRect = button.getBoundingClientRect(); + + dispatchMouseEvent(button, 'mouseenter', triggerRect.right - 100, triggerRect.top + 100); + wideFixture.detectChanges(); + tick(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + expect(tooltipDirective._overlayRef!.overlayElement.offsetLeft).toBe( + triggerRect.right - 100 - 20, + ); + expect(tooltipDirective._overlayRef!.overlayElement.offsetTop).toBe(triggerRect.top + 100); + })); + it('should be able to disable tooltip interactivity', fakeAsync(() => { TestBed.resetTestingModule() .configureTestingModule({ @@ -1588,6 +1660,17 @@ class TooltipDemoWithoutPositionBinding { @ViewChild('button') button: ElementRef; } +@Component({ + selector: 'app', + styles: [`button { width: 500px; height: 500px; }`], + template: ``, +}) +class WideTooltipDemo { + message = 'Test'; + @ViewChild(MatTooltip) tooltip: MatTooltip; + @ViewChild('button') button: ElementRef; +} + /** Asserts whether a tooltip directive has a tooltip instance. */ function assertTooltipInstance(tooltip: MatTooltip, shouldExist: boolean): void { // Note that we have to cast this to a boolean, because Jasmine will go into an infinite loop diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index 4dbb65e9d9f5..806e8b181675 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -128,6 +128,12 @@ export interface MatTooltipDefaultOptions { /** Default position for tooltips. */ position?: TooltipPosition; + /** + * Default value for whether tooltips should be positioned near the click or touch origin + * instead of outside the element bounding box. + */ + positionAtOrigin?: boolean; + /** Disables the ability for the user to interact with the tooltip element. */ disableTooltipInteractivity?: boolean; } @@ -159,6 +165,7 @@ export abstract class _MatTooltipBase private _portal: ComponentPortal; private _position: TooltipPosition = 'below'; + private _positionAtOrigin: boolean = false; private _disabled: boolean = false; private _tooltipClass: string | string[] | Set | {[key: string]: any}; private _scrollStrategy: () => ScrollStrategy; @@ -187,6 +194,16 @@ export abstract class _MatTooltipBase } } + @Input('matTooltipPositionAtOrigin') + get positionAtOrigin(): boolean { + return this._positionAtOrigin; + } + set positionAtOrigin(value: BooleanInput) { + this._positionAtOrigin = coerceBooleanProperty(value); + this._detach(); + this._overlayRef = null; + } + /** Disables the display of the tooltip. */ @Input('matTooltipDisabled') get disabled(): boolean { @@ -329,6 +346,10 @@ export abstract class _MatTooltipBase this.position = _defaultOptions.position; } + if (_defaultOptions.positionAtOrigin) { + this.positionAtOrigin = _defaultOptions.positionAtOrigin; + } + if (_defaultOptions.touchGestures) { this.touchGestures = _defaultOptions.touchGestures; } @@ -386,7 +407,7 @@ export abstract class _MatTooltipBase } /** Shows the tooltip after the delay in ms, defaults to tooltip-delay-show or 0ms if no input */ - show(delay: number = this.showDelay): void { + show(delay: number = this.showDelay, origin?: {x: number; y: number}): void { if ( this.disabled || !this.message || @@ -397,7 +418,7 @@ export abstract class _MatTooltipBase return; } - const overlayRef = this._createOverlay(); + const overlayRef = this._createOverlay(origin); this._detach(); this._portal = this._portal || new ComponentPortal(this._tooltipComponent, this._viewContainerRef); @@ -421,8 +442,8 @@ export abstract class _MatTooltipBase } /** Shows/hides the tooltip */ - toggle(): void { - this._isTooltipVisible() ? this.hide() : this.show(); + toggle(origin?: {x: number; y: number}): void { + this._isTooltipVisible() ? this.hide() : this.show(undefined, origin); } /** Returns true if the tooltip is currently visible to the user */ @@ -431,9 +452,16 @@ export abstract class _MatTooltipBase } /** Create the overlay config and position strategy */ - private _createOverlay(): OverlayRef { + private _createOverlay(origin?: {x: number; y: number}): OverlayRef { if (this._overlayRef) { - return this._overlayRef; + const existingStrategy = this._overlayRef.getConfig() + .positionStrategy as FlexibleConnectedPositionStrategy; + + if ((!this.positionAtOrigin || !origin) && existingStrategy._origin instanceof ElementRef) { + return this._overlayRef; + } + + this._detach(); } const scrollableAncestors = this._scrollDispatcher.getAncestorScrollContainers( @@ -443,7 +471,7 @@ export abstract class _MatTooltipBase // Create connected position strategy that listens for scroll events to reposition. const strategy = this._overlay .position() - .flexibleConnectedTo(this._elementRef) + .flexibleConnectedTo(this.positionAtOrigin ? origin || this._elementRef : this._elementRef) .withTransformOriginOn(`.${this._cssClassPrefix}-tooltip`) .withFlexibleDimensions(false) .withViewportMargin(this._viewportMargin) @@ -686,9 +714,13 @@ export abstract class _MatTooltipBase if (this._platformSupportsMouseEvents()) { this._passiveListeners.push([ 'mouseenter', - () => { + event => { this._setupPointerExitEventsIfNeeded(); - this.show(); + let point = undefined; + if ((event as MouseEvent).x !== undefined && (event as MouseEvent).y !== undefined) { + point = event as MouseEvent; + } + this.show(undefined, point); }, ]); } else if (this.touchGestures !== 'off') { @@ -696,12 +728,14 @@ export abstract class _MatTooltipBase this._passiveListeners.push([ 'touchstart', - () => { + event => { + const touch = (event as TouchEvent).targetTouches?.[0]; + const origin = touch ? {x: touch.clientX, y: touch.clientY} : undefined; // Note that it's important that we don't `preventDefault` here, // because it can prevent click events from firing on the element. this._setupPointerExitEventsIfNeeded(); clearTimeout(this._touchstartTimeout); - this._touchstartTimeout = setTimeout(() => this.show(), LONGPRESS_DELAY); + this._touchstartTimeout = setTimeout(() => this.show(undefined, origin), LONGPRESS_DELAY); }, ]); } diff --git a/tools/public_api_guard/cdk/overlay.md b/tools/public_api_guard/cdk/overlay.md index a2ccaee9a40d..31371ac3d2db 100644 --- a/tools/public_api_guard/cdk/overlay.md +++ b/tools/public_api_guard/cdk/overlay.md @@ -184,6 +184,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy { // (undocumented) detach(): void; dispose(): void; + _origin: FlexibleConnectedPositionStrategyOrigin; positionChanges: Observable; get positions(): ConnectionPositionPair[]; _preferredPositions: ConnectionPositionPair[]; diff --git a/tools/public_api_guard/material/tooltip.md b/tools/public_api_guard/material/tooltip.md index 531b85e0e7ea..7dba5dde8140 100644 --- a/tools/public_api_guard/material/tooltip.md +++ b/tools/public_api_guard/material/tooltip.md @@ -107,10 +107,19 @@ export abstract class _MatTooltipBase implement _overlayRef: OverlayRef | null; get position(): TooltipPosition; set position(value: TooltipPosition); - show(delay?: number): void; + // (undocumented) + get positionAtOrigin(): boolean; + set positionAtOrigin(value: BooleanInput); + show(delay?: number, origin?: { + x: number; + y: number; + }): void; get showDelay(): number; set showDelay(value: NumberInput); - toggle(): void; + toggle(origin?: { + x: number; + y: number; + }): void; get tooltipClass(): string | string[] | Set | { [key: string]: any; }; @@ -125,7 +134,7 @@ export abstract class _MatTooltipBase implement // (undocumented) protected _viewportMargin: number; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration<_MatTooltipBase, never, never, { "position": "matTooltipPosition"; "disabled": "matTooltipDisabled"; "showDelay": "matTooltipShowDelay"; "hideDelay": "matTooltipHideDelay"; "touchGestures": "matTooltipTouchGestures"; "message": "matTooltip"; "tooltipClass": "matTooltipClass"; }, {}, never, never, false>; + static ɵdir: i0.ɵɵDirectiveDeclaration<_MatTooltipBase, never, never, { "position": "matTooltipPosition"; "positionAtOrigin": "matTooltipPositionAtOrigin"; "disabled": "matTooltipDisabled"; "showDelay": "matTooltipShowDelay"; "hideDelay": "matTooltipHideDelay"; "touchGestures": "matTooltipTouchGestures"; "message": "matTooltip"; "tooltipClass": "matTooltipClass"; }, {}, never, never, false>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration<_MatTooltipBase, never>; } @@ -135,6 +144,7 @@ export interface MatTooltipDefaultOptions { disableTooltipInteractivity?: boolean; hideDelay: number; position?: TooltipPosition; + positionAtOrigin?: boolean; showDelay: number; touchendHideDelay: number; touchGestures?: TooltipTouchGestures;