diff --git a/CHANGELOG.md b/CHANGELOG.md index b34431f83c1..4742148f551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,22 @@ All notable changes for each version of this project will be documented in this this.carousel.select(2, Direction.NEXT); ``` +- `IgxOverlay` + - Position Settings now accept a new optional `offset` input property of type `number`. Used to set the offset of the element from the target in pixels. + +- `IgxTooltip` + - The tooltip now remains open while interacting with it. +- `IgxTooltipTarget` + - Introduced several new properties to enhance customization of tooltip content and behavior. Those include `positionSettings`, `hasArrow`, `sticky`, `closeButtonTemplate`. For detailed usage and examples, please refer to the Tooltip [README](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/directives/tooltip/README.md). + ### General - `IgxDropDown` now exposes a `role` input property, allowing users to customize the role attribute based on the use case. The default is `listbox`. +- `IgxTooltipTarget` + - **Behavioral Changes** + - The `showDelay` input property now defaults to `200`. + - The `hideDelay` input property now defaults to `300`. + - The `showTooltip` and `hideTooltip` methods do not take `showDelay`/`hideDelay` into account. ## 20.0.6 ### General diff --git a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss index 003fd366ba9..a58b1bb237a 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss @@ -10,6 +10,22 @@ @extend %tooltip-display !optional; + @include m(top) { + @extend %arrow--top !optional; + } + + @include m(bottom) { + @extend %arrow--bottom !optional; + } + + @include m(left) { + @extend %arrow--left !optional; + } + + @include m(right) { + @extend %arrow--right !optional; + } + @include m(hidden) { @extend %tooltip--hidden !optional; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss index c5ac8b82e8d..2af405f6499 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss @@ -8,23 +8,58 @@ @include css-vars($theme); $variant: map.get($theme, '_meta', 'theme'); + $transparent-border: rem(4px) solid transparent; + $color-border: rem(4px) solid var-get($theme, 'background'); + %tooltip-display { - display: inline-flex; - justify-content: center; - flex-flow: column wrap; + display: flex; + align-items: flex-start; + text-align: start; background: var-get($theme, 'background'); color: var-get($theme, 'text-color'); border-radius: var-get($theme, 'border-radius'); box-shadow: map.get($theme, 'shadow'); - margin: 0 auto; - padding: 0 rem(8px); + padding: rem(4px) rem(8px); + gap: rem(8px); min-height: rem(24px); + min-width: rem(24px); + max-width: 200px; + width: fit-content; + + igx-icon { + --component-size: 1; + } - @if $variant == 'indigo' { - padding: rem(4px) rem(8px); + igx-tooltip-close-button { + display: flex; + cursor: default; } } + %arrow--top { + border-left: $transparent-border; + border-right: $transparent-border; + border-top: $color-border; + } + + %arrow--bottom { + border-left: $transparent-border; + border-right: $transparent-border; + border-bottom: $color-border; + } + + %arrow--left { + border-top: $transparent-border; + border-bottom: $transparent-border; + border-left: $color-border; + } + + %arrow--right { + border-top: $transparent-border; + border-bottom: $transparent-border; + border-right: $color-border; + } + %tooltip--hidden { display: none; } @@ -45,6 +80,7 @@ } } @else { %tooltip-display { + line-height: rem(16px); font-size: rem(10px); font-weight: 600; } diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/README.md b/projects/igniteui-angular/src/lib/directives/tooltip/README.md index cddc0a794fe..90684e30b8e 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/README.md +++ b/projects/igniteui-angular/src/lib/directives/tooltip/README.md @@ -100,15 +100,99 @@ Since the **IgxTooltip** directive extends the **IgxToggle** directive and there | tooltipDisabled | boolean | Specifies if the tooltip should not show when hovering its target with the mouse. (defaults to false) | | tooltipHidden | boolean | Indicates if the tooltip is currently hidden. | | nativeElement | any | Reference to the native element of the directive. | +| positionSettings | PositionSettings | Controls the position and animation settings used by the tooltip. | +| hasArrow | boolean | Controls whether to display an arrow indicator for the tooltip. Defaults to `false`. | +| sticky | boolean | When set to `true`, the tooltip renders a default close icon `x`. The tooltip remains visible until the user closes it via the close icon `x` or `Esc` key. Defaults to `false`. | +| closeButtonTemplate | TemplateRef | Allows templating the default close icon `x`. | + +#### Templating the close button + +```html + + info + + + + Hello there, I am a tooltip! + + + + + +``` ### Methods | Name | Type | Arguments | Description | | :--- |:--- | :--- | :--- | -| showTooltip | void | N/A | Shows the tooltip after the amount of ms specified by the `showDelay` property. | -| hideTooltip | void | N/A | Hides the tooltip after the amount of ms specified by the `hideDelay` property. | +| showTooltip | void | N/A | Shows the tooltip. | +| hideTooltip | void | N/A | Hides the tooltip. | ### Events |Name|Description|Cancelable|Event arguments| |--|--|--|--| | tooltipShow | Emitted when the tooltip starts showing. (This event is fired before the start of the countdown to showing the tooltip.) | True | ITooltipShowEventArgs | | tooltipHide | Emitted when the tooltip starts hiding. (This event is fired before the start of the countdown to hiding the tooltip.) | True | ITooltipHideEventArgs | + +### Notes + +The `IgxTooltipTarget` uses the `TooltipPositionStrategy` to position the tooltip and arrow element. If a custom position strategy is used (`overlaySettings.positionStrategy`) and `hasArrow` is set to `true`, the custom strategy should extend the `TooltipPositionStrategy`. Otherwise, the arrow will not be displayed. + +The arrow element is positioned based on the provided position settings. If the directions and starting points do not correspond to any of the predefined position values, the arrow is positioned in the top middle side of the tooltip (default tooltip position `bottom`). + + +| Position     | Horizontal Direction          | Horizontal Start Point         | Vertical Direction            | Vertical Start Point           | +|--------------|-------------------------------|--------------------------------|-------------------------------|--------------------------------| +| top          | HorizontalAlignment.Center    | HorizontalAlignment.Center     | VerticalAlignment.Top         | VerticalAlignment.Top          | +| top-start    | HorizontalAlignment.Right     | HorizontalAlignment.Left       | VerticalAlignment.Top         | VerticalAlignment.Top          | +| top-end      | HorizontalAlignment.Left      | HorizontalAlignment.Right      | VerticalAlignment.Top         | VerticalAlignment.Top          | +| bottom       | HorizontalAlignment.Center    | HorizontalAlignment.Center     | VerticalAlignment.Bottom      | VerticalAlignment.Bottom       | +| bottom-start | HorizontalAlignment.Right     | HorizontalAlignment.Left       | VerticalAlignment.Bottom      | VerticalAlignment.Bottom       | +| bottom-end   | HorizontalAlignment.Left      | HorizontalAlignment.Right      | VerticalAlignment.Bottom      | VerticalAlignment.Bottom       | +| right        | HorizontalAlignment.Right     | HorizontalAlignment.Right      | VerticalAlignment.Middle      | VerticalAlignment.Middle       | +| right-start  | HorizontalAlignment.Right     | HorizontalAlignment.Right      | VerticalAlignment.Bottom      | VerticalAlignment.Top          | +| right-end    | HorizontalAlignment.Right     | HorizontalAlignment.Right      | VerticalAlignment.Top         | VerticalAlignment.Bottom       | +| left         | HorizontalAlignment.Left      | HorizontalAlignment.Left       | VerticalAlignment.Middle      | VerticalAlignment.Middle       | +| left-start   | HorizontalAlignment.Left      | HorizontalAlignment.Left       | VerticalAlignment.Bottom      | VerticalAlignment.Top          | +| left-end     | HorizontalAlignment.Left      | HorizontalAlignment.Left       | VerticalAlignment.Top         | VerticalAlignment.Bottom       | + + +#### Customizing the arrow's position + +The arrow's position can be customized by overriding the `positionArrow(arrow: HTMLElement, arrowFit: ArrowFit)` method. + +For example: + +```ts +export class CustomStrategy extends TooltipPositioningStrategy { + constructor(settings?: PositionSettings) { + super(settings); + } + + public override positionArrow(arrow: HTMLElement, arrowFit: ArrowFit): void { + Object.assign(arrow.style, { + left: '-0.25rem', + transform: 'rotate(-45deg)', + [arrowFit.direction]: '-0.25rem', + }); + } +} + +public overlaySettings: OverlaySettings = { + positionStrategy: new CustomStrategy({ + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }) +}; +``` + +```html + + info + + + + Hello there, I am a tooltip! + +``` diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts b/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts index 98a7195759d..787c162c2f9 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts @@ -3,6 +3,7 @@ import { IgxTooltipDirective } from './tooltip.directive'; export * from './tooltip.directive'; export * from './tooltip-target.directive'; +export { ArrowFit, TooltipPositionStrategy } from './tooltip.common'; /* NOTE: Tooltip directives collection for ease-of-use import in standalone components scenario */ export const IGX_TOOLTIP_DIRECTIVES = [ diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-close-button.component.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-close-button.component.ts new file mode 100644 index 00000000000..18f9ed23b51 --- /dev/null +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-close-button.component.ts @@ -0,0 +1,28 @@ +import { Component, Output, EventEmitter, HostListener, Input, TemplateRef } from '@angular/core'; +import { IgxIconComponent } from '../../icon/icon.component'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'igx-tooltip-close-button', + template: ` + + + + + + + `, + imports: [IgxIconComponent, CommonModule], +}) +export class IgxTooltipCloseButtonComponent { + @Input() + public customTemplate: TemplateRef; + + @Output() + public clicked = new EventEmitter(); + + @HostListener('click') + public handleClick() { + this.clicked.emit(); + } +} diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts index c82d10bc719..0014df2c1f4 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts @@ -1,14 +1,19 @@ -import { useAnimation } from '@angular/animations'; -import { Directive, OnInit, OnDestroy, Output, ElementRef, Optional, ViewContainerRef, HostListener, Input, EventEmitter, booleanAttribute } from '@angular/core'; +import { + Directive, OnInit, OnDestroy, Output, ElementRef, Optional, ViewContainerRef, HostListener, + Input, EventEmitter, booleanAttribute, TemplateRef, ComponentRef, Renderer2, OnChanges, SimpleChanges, + EnvironmentInjector, + createComponent, +} from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { IgxNavigationService } from '../../core/navigation'; import { IBaseEventArgs } from '../../core/utils'; -import { AutoPositionStrategy, HorizontalAlignment, PositionSettings } from '../../services/public_api'; +import { PositionSettings } from '../../services/public_api'; import { IgxToggleActionDirective } from '../toggle/toggle.directive'; import { IgxTooltipComponent } from './tooltip.component'; import { IgxTooltipDirective } from './tooltip.directive'; -import { fadeOut, scaleInCenter } from 'igniteui-angular/animations'; +import { IgxTooltipCloseButtonComponent } from './tooltip-close-button.component'; +import { TooltipPositionSettings, TooltipPositionStrategy } from './tooltip.common'; export interface ITooltipShowEventArgs extends IBaseEventArgs { target: IgxTooltipTargetDirective; @@ -40,7 +45,7 @@ export interface ITooltipHideEventArgs extends IBaseEventArgs { selector: '[igxTooltipTarget]', standalone: true }) -export class IgxTooltipTargetDirective extends IgxToggleActionDirective implements OnInit, OnDestroy { +export class IgxTooltipTargetDirective extends IgxToggleActionDirective implements OnChanges, OnInit, OnDestroy { /** * Gets/sets the amount of milliseconds that should pass before showing the tooltip. * @@ -56,7 +61,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen * ``` */ @Input() - public showDelay = 500; + public showDelay = 200; /** * Gets/sets the amount of milliseconds that should pass before hiding the tooltip. @@ -73,7 +78,139 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen * ``` */ @Input() - public hideDelay = 500; + public hideDelay = 300; + + /** + * Controls whether to display an arrow indicator for the tooltip. + * Set to true to show the arrow. Default value is `false`. + * + * ```typescript + * // get + * let isArrowDisabled = this.tooltip.hasArrow; + * ``` + * + * ```typescript + * // set + * this.tooltip.hasArrow = true; + * ``` + * + * ```html + * + * info + * ``` + */ + @Input() + public set hasArrow(value: boolean) { + if (this.target) { + this.target.arrow.style.display = value ? '' : 'none'; + } + this._hasArrow = value; + } + + public get hasArrow(): boolean { + return this._hasArrow; + } + + /** + * Specifies if the tooltip remains visible until the user closes it via the close button or Esc key. + * + * ```typescript + * // get + * let isSticky = this.tooltip.sticky; + * ``` + * + * ```typescript + * // set + * this.tooltip.sticky = true; + * ``` + * + * ```html + * + * info + * ``` + */ + @Input() + public set sticky (value: boolean) { + const changed = this._sticky !== value; + this._sticky = value; + + if (changed) { + this._createCloseTemplate(this._closeTemplate); + this._evaluateStickyState(); + } + }; + + public get sticky (): boolean { + return this._sticky; + } + + + /** + * Allows full control over the appearance of the close button inside the tooltip. + * + * ```typescript + * // get + * let customCloseTemplate = this.tooltip.customCloseTemplate; + * ``` + * + * ```typescript + * // set + * this.tooltip.customCloseTemplate = TemplateRef; + * ``` + * + * ```html + * + * info + * + * + * + * ``` + */ + @Input('closeButtonTemplate') + public set closeTemplate(value: TemplateRef) { + this._closeTemplate = value; + this._createCloseTemplate(this._closeTemplate); + this._evaluateStickyState(); + } + public get closeTemplate(): TemplateRef | undefined { + return this._closeTemplate; + } + + /** + * Get the position and animation settings used by the tooltip. + * ```typescript + * let positionSettings = this.tooltipTarget.positionSettings; + * ``` + */ + @Input() + public get positionSettings(): PositionSettings { + return this._positionSettings; + } + + /** + * Set the position and animation settings used by the tooltip. + * ```html + * info + * Hello there, I am a tooltip! + * ``` + * ```typescript + * + * import { PositionSettings, HorizontalAlignment, VerticalAlignment } from 'igniteui-angular'; + * ... + * public newPositionSettings: PositionSettings = { + * horizontalDirection: HorizontalAlignment.Right, + * horizontalStartPoint: HorizontalAlignment.Left, + * verticalDirection: VerticalAlignment.Top, + * verticalStartPoint: VerticalAlignment.Top, + * }; + * ``` + */ + public set positionSettings(settings: PositionSettings) { + this._positionSettings = settings; + if (this._overlayDefaults) { + this._overlayDefaults.positionStrategy = new TooltipPositionStrategy(this._positionSettings); + } + } /** * Specifies if the tooltip should not show when hovering its target with the mouse. (defaults to false) @@ -185,10 +322,22 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen @Output() public tooltipHide = new EventEmitter(); - private destroy$ = new Subject(); - - constructor(private _element: ElementRef, - @Optional() private _navigationService: IgxNavigationService, private _viewContainerRef: ViewContainerRef) { + private _destroy$ = new Subject(); + private _autoHideDelay = 180; + private _isForceClosed = false; + private _hasArrow = false; + private _closeButtonRef?: ComponentRef; + private _closeTemplate: TemplateRef; + private _sticky = false; + private _positionSettings: PositionSettings = TooltipPositionSettings; + + constructor( + private _element: ElementRef, + @Optional() private _navigationService: IgxNavigationService, + private _viewContainerRef: ViewContainerRef, + private _renderer: Renderer2, + private _envInjector: EnvironmentInjector + ) { super(_element, _navigationService); } @@ -198,7 +347,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen @HostListener('click') public override onClick() { if (!this.target.collapsed) { - this.target.forceClose(this.mergedOverlaySettings); + this._hideOnInteraction(); } } @@ -207,30 +356,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen */ @HostListener('mouseenter') public onMouseEnter() { - if (this.tooltipDisabled) { - return; - } - - this.checkOutletAndOutsideClick(); - const shouldReturn = this.preMouseEnterCheck(); - if (shouldReturn) { - return; - } - - this.target.tooltipTarget = this; - - const showingArgs = { target: this, tooltip: this.target, cancel: false }; - this.tooltipShow.emit(showingArgs); - - if (showingArgs.cancel) { - return; - } - - this.target.toBeShown = true; - this.target.timeoutId = setTimeout(() => { - this.target.open(this.mergedOverlaySettings); // Call open() of IgxTooltipDirective - this.target.toBeShown = false; - }, this.showDelay); + this._checksBeforeShowing(() => this._showOnInteraction()); } /** @@ -242,44 +368,39 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen return; } - this.checkOutletAndOutsideClick(); - const shouldReturn = this.preMouseLeaveCheck(); - if (shouldReturn || this.target.collapsed) { - return; - } - - this.target.toBeHidden = true; - this.target.timeoutId = setTimeout(() => { - this.target.close(); // Call close() of IgxTooltipDirective - this.target.toBeHidden = false; - }, this.hideDelay); - - + this._checkOutletAndOutsideClick(); + this._hideOnInteraction(); } /** * @hidden */ public onTouchStart() { - if (this.tooltipDisabled) { - return; - } - - this.showTooltip(); + this._checksBeforeShowing(() => this._showOnInteraction()); } /** * @hidden */ public onDocumentTouchStart(event) { - if (this.tooltipDisabled) { + if (this.tooltipDisabled || this?.target?.tooltipTarget !== this) { return; } if (this.nativeElement !== event.target && !this.nativeElement.contains(event.target) ) { - this.hideTooltip(); + this._hideOnInteraction(); + } + } + + + /** + * @hidden + */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes['hasArrow']) { + this.target.arrow.style.display = changes['hasArrow'].currentValue ? '' : 'none'; } } @@ -289,18 +410,11 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen public override ngOnInit() { super.ngOnInit(); - const positionSettings: PositionSettings = { - horizontalDirection: HorizontalAlignment.Center, - horizontalStartPoint: HorizontalAlignment.Center, - openAnimation: useAnimation(scaleInCenter, { params: { duration: '150ms' } }), - closeAnimation: useAnimation(fadeOut, { params: { duration: '75ms' } }) - }; - - this._overlayDefaults.positionStrategy = new AutoPositionStrategy(positionSettings); + this._overlayDefaults.positionStrategy = new TooltipPositionStrategy(this._positionSettings); this._overlayDefaults.closeOnOutsideClick = false; this._overlayDefaults.closeOnEscape = true; - this.target.closing.pipe(takeUntil(this.destroy$)).subscribe((event) => { + this.target.closing.pipe(takeUntil(this._destroy$)).subscribe((event) => { if (this.target.tooltipTarget !== this) { return; } @@ -322,105 +436,209 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen public ngOnDestroy() { this.hideTooltip(); this.nativeElement.removeEventListener('touchstart', this.onTouchStart); - this.destroy$.next(); - this.destroy$.complete(); + this._destroyCloseButton(); + this._destroy$.next(); + this._destroy$.complete(); } /** - * Shows the tooltip by respecting the 'showDelay' property. + * Shows the tooltip if not already shown. * * ```typescript * this.tooltipTarget.showTooltip(); * ``` */ public showTooltip() { - clearTimeout(this.target.timeoutId); - - if (!this.target.collapsed) { - // if close animation has started finish it, or close the tooltip with no animation - this.target.forceClose(this.mergedOverlaySettings); - this.target.toBeHidden = false; - } - this.target.tooltipTarget = this; - - const showingArgs = { target: this, tooltip: this.target, cancel: false }; - this.tooltipShow.emit(showingArgs); - - if (showingArgs.cancel) { - return; - } - - this.target.toBeShown = true; - this.target.timeoutId = setTimeout(() => { - this.target.open(this.mergedOverlaySettings); // Call open() of IgxTooltipDirective - this.target.toBeShown = false; - }, this.showDelay); + this._checksBeforeShowing(() => this._showTooltip(false, true)); } /** - * Hides the tooltip by respecting the 'hideDelay' property. + * Hides the tooltip if not already hidden. * * ```typescript * this.tooltipTarget.hideTooltip(); * ``` */ public hideTooltip() { - if (this.target.collapsed && this.target.toBeShown) { - clearTimeout(this.target.timeoutId); + this._hideTooltip(false); + } + + private get _mergedOverlaySettings() { + return Object.assign({}, this._overlayDefaults, this.overlaySettings); + } + + private _checkOutletAndOutsideClick(): void { + if (this.outlet) { + this._overlayDefaults.outlet = this.outlet; } + } - if (this.target.collapsed || this.target.toBeHidden) { + /** + * A guard method that performs precondition checks before showing the tooltip. + * It ensures that the tooltip is not disabled and not already shown in sticky mode. + * If all conditions pass, it executes the provided `action` callback. + */ + private _checksBeforeShowing(action: () => void): void { + if (this.tooltipDisabled) return; + if (!this.target.collapsed && this.target?.tooltipTarget?.sticky) return; + + this._checkOutletAndOutsideClick(); + this._checkTooltipForMultipleTargets(); + action(); + } + + private _hideTooltip(withDelay: boolean): void { + if (this.target.collapsed) { return; } - this.target.toBeHidden = true; this.target.timeoutId = setTimeout(() => { - this.target.close(); // Call close() of IgxTooltipDirective - this.target.toBeHidden = false; - }, this.hideDelay); + // Call close() of IgxTooltipDirective + this.target.close(); + }, withDelay ? this.hideDelay : 0); } - private checkOutletAndOutsideClick() { - if (this.outlet) { - this._overlayDefaults.outlet = this.outlet; + private _showTooltip(withDelay: boolean, withEvents: boolean): void { + if (!this.target.collapsed && !this._isForceClosed) { + return; + } + + if (this._isForceClosed) { + this._isForceClosed = false; + } + + if (withEvents) { + const showingArgs = { target: this, tooltip: this.target, cancel: false }; + this.tooltipShow.emit(showingArgs); + + if (showingArgs.cancel) return; } + + this._evaluateStickyState(); + + this.target.timeoutId = setTimeout(() => { + // Call open() of IgxTooltipDirective + this.target.open(this._mergedOverlaySettings); + }, withDelay ? this.showDelay : 0); } - private get mergedOverlaySettings() { - return Object.assign({}, this._overlayDefaults, this.overlaySettings); + + private _showOnInteraction(): void { + this._stopTimeoutAndAnimation(); + this._showTooltip(true, true); } - // Return true if the execution in onMouseEnter should be terminated after this method - private preMouseEnterCheck() { - // If tooltip is about to be opened - if (this.target.toBeShown) { - clearTimeout(this.target.timeoutId); - this.target.toBeShown = false; + private _hideOnInteraction(): void { + if (this.target?.tooltipTarget?.sticky) { + return; } - // If Tooltip is opened or about to be hidden - if (!this.target.collapsed || this.target.toBeHidden) { + this._setAutoHide(); + } + + private _setAutoHide(): void { + this._stopTimeoutAndAnimation(); + + this.target.timeoutId = setTimeout(() => { + this._hideTooltip(true); + }, this._autoHideDelay); + } + + /** + * Used when the browser animations are set to a lower percentage + * and the user interacts with the target or tooltip __while__ an animation is playing. + * It stops the running animation, and the tooltip is instantly shown. + */ + private _stopTimeoutAndAnimation(): void { + clearTimeout(this.target.timeoutId); + this.target.stopAnimations(); + } + + /** + * Used when a single tooltip is used for multiple targets. + * If the tooltip is shown for one target and the user interacts with another target, + * the tooltip is instantly hidden for the first target. + */ + private _checkTooltipForMultipleTargets(): void { + if (!this.target.tooltipTarget) { + this.hasArrow = this._hasArrow; + this.target.tooltipTarget = this; + } + if (this.target.tooltipTarget !== this) { + this.hasArrow = this._hasArrow; + if (this.target.tooltipTarget.sticky) { + this.target.tooltipTarget._removeCloseButtonFromTooltip(); + } + clearTimeout(this.target.timeoutId); + this.target.stopAnimations(true); - // if close animation has started finish it, or close the tooltip with no animation - this.target.forceClose(this.mergedOverlaySettings); - this.target.toBeHidden = false; + this.target.tooltipTarget = this; + this._isForceClosed = true; } + } - return false; + /** + * Updates the tooltip's sticky-related state, but only if the current target owns the tooltip. + * + * This method ensures that when the active target modifies its `sticky` or `closeTemplate` properties + * at runtime, the tooltip reflects those changes accordingly: + */ + private _evaluateStickyState(): void { + if(this?.target?.tooltipTarget === this) { + if (this.sticky) { + this._appendCloseButtonToTooltip(); + } else if (!this.sticky) { + this._removeCloseButtonFromTooltip(); + } + } } - // Return true if the execution in onMouseLeave should be terminated after this method - private preMouseLeaveCheck(): boolean { - clearTimeout(this.target.timeoutId); + /** + * Creates (if not already created) an instance of the IgxTooltipCloseButtonComponent, + * and assigns it the provided custom template. + */ + private _createCloseTemplate(template?: TemplateRef | undefined): void { + if (!this._closeButtonRef) { + this._closeButtonRef = createComponent(IgxTooltipCloseButtonComponent, { + environmentInjector: this._envInjector + }); + + this._closeButtonRef.instance.customTemplate = template; + this._closeButtonRef.instance.clicked.pipe(takeUntil(this._destroy$)).subscribe(() => { + this._hideTooltip(true); + }); + } else { + this._closeButtonRef.instance.customTemplate = template; + } + } - // If tooltip is about to be opened - if (this.target.toBeShown) { - this.target.toBeShown = false; - this.target.toBeHidden = false; - return true; + /** + * Appends the close button to the tooltip. + */ + private _appendCloseButtonToTooltip(): void { + if (this?.target && this._closeButtonRef) { + this._renderer.appendChild(this.target.element, this._closeButtonRef.location.nativeElement); + this._closeButtonRef.changeDetectorRef.detectChanges(); + this.target.role = "status" } + } - return false; + /** + * Removes the close button from the tooltip. + */ + private _removeCloseButtonFromTooltip() { + if (this?.target && this._closeButtonRef) { + this._renderer.removeChild(this.target.element, this._closeButtonRef.location.nativeElement); + this._closeButtonRef.changeDetectorRef.detectChanges(); + this.target.role = "tooltip" + } + } + + private _destroyCloseButton(): void { + if (this._closeButtonRef) { + this._closeButtonRef.destroy(); + this._closeButtonRef = undefined; + } } } diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.common.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.common.ts new file mode 100644 index 00000000000..6f25d0403fd --- /dev/null +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.common.ts @@ -0,0 +1,332 @@ +import { first } from '../../core/utils'; +import { AutoPositionStrategy } from '../../services/overlay/position/auto-position-strategy'; +import { ConnectedFit, HorizontalAlignment, Point, PositionSettings, Size, VerticalAlignment } from '../../services/overlay/utilities'; +import { useAnimation } from '@angular/animations'; +import { fadeOut, scaleInCenter } from 'igniteui-angular/animations'; + +export const TooltipRegexes = Object.freeze({ + /** Matches horizontal `Placement` end positions. `left-end` | `right-end` */ + horizontalEnd: /^(left|right)-end$/, + + /** Matches vertical `Placement` centered positions. `left` | `right` */ + horizontalCenter: /^(left|right)$/, + + /** + * Matches vertical `Placement` positions. + * `top` | `top-start` | `top-end` | `bottom` | `bottom-start` | `bottom-end` + */ + vertical: /^(top|bottom)(-(start|end))?$/, + + /** Matches vertical `Placement` end positions. `top-end` | `bottom-end` */ + verticalEnd: /^(top|bottom)-end$/, + + /** Matches vertical `Placement` centered positions. `top` | `bottom` */ + verticalCenter: /^(top|bottom)$/, +}); + +export interface ArrowFit { + /** Rectangle of the arrow element. */ + readonly arrowRect?: Partial; + /** Rectangle of the tooltip element. */ + readonly tooltipRect?: Partial; + /** Direction in which the arrow points. */ + readonly direction?: 'top' | 'bottom' | 'right' | 'left'; + /** Vertical offset of the arrow element from the tooltip */ + top?: number; + /** Horizontal offset of the arrow element from the tooltip */ + left?: number; +} + +/** + * Defines the possible positions for the tooltip relative to its target. + */ +export enum Placement { + Top = 'top', + TopStart = 'top-start', + TopEnd = 'top-end', + Bottom = 'bottom', + BottomStart = 'bottom-start', + BottomEnd = 'bottom-end', + Right = 'right', + RightStart = 'right-start', + RightEnd = 'right-end', + Left = 'left', + LeftStart = 'left-start', + LeftEnd = 'left-end' +} + +/** + * Default tooltip position settings. + */ +export const TooltipPositionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + openAnimation: useAnimation(scaleInCenter, { params: { duration: '150ms' } }), + closeAnimation: useAnimation(fadeOut, { params: { duration: '75ms' } }), + offset: 6 +}; + +export class TooltipPositionStrategy extends AutoPositionStrategy { + + private _placement: Placement; + + constructor(settings?: PositionSettings) { + if (settings) { + settings = Object.assign({}, TooltipPositionSettings, settings); + } + + super(settings || TooltipPositionSettings); + } + + public override position( + contentElement: HTMLElement, + size: Size, + document?: Document, + initialCall?: boolean, + target?: Point | HTMLElement + ): void { + super.position(contentElement, size, document, initialCall, target); + + const tooltip = contentElement.children?.[0]; + this.configArrow(tooltip); + } + + protected override fitInViewport(element: HTMLElement, connectedFit: ConnectedFit): void { + super.fitInViewport(element, connectedFit); + + const tooltip = element.children?.[0]; + this.configArrow(tooltip); + } + + /** + * Sets the position of the arrow relative to the tooltip element. + * + * @param arrow the arrow element of the tooltip. + * @param arrowFit arrowFit object containing all necessary parameters. + */ + public positionArrow(arrow: HTMLElement, arrowFit: ArrowFit): void { + this.resetArrowPositionStyles(arrow); + + const convert = (value: number) => { + if (!value) { + return ''; + } + return `${value}px` + }; + + Object.assign(arrow.style, { + top: convert(arrowFit.top), + left: convert(arrowFit.left), + [arrowFit.direction]: convert(-4), + }); + } + + /** + * Resets the element's top / bottom / left / right style properties. + * + * @param arrow the arrow element of the tooltip. + */ + private resetArrowPositionStyles(arrow: HTMLElement): void { + arrow.style.top = ''; + arrow.style.bottom = ''; + arrow.style.left = ''; + arrow.style.right = ''; + } + + /** + * Gets values for `top` or `left` position styles. + * + * @param arrowRect + * @param tooltipRect + * @param positionProperty - for which position property to get style values. + */ + private getArrowPositionStyles( + arrowRect: Partial, + tooltipRect: Partial, + positionProperty: 'top' | 'left' + ): number { + const arrowSize = arrowRect.width > arrowRect.height + ? arrowRect.width + : arrowRect.height; + + const tooltipSize = TooltipRegexes.vertical.test(this._placement) + ? tooltipRect.width + : tooltipRect.height; + + const direction = { + top: 'horizontal', + left: 'vertical', + }[positionProperty]; + + const center = `${direction}Center`; + const end = `${direction}End`; + + if (TooltipRegexes[center].test(this._placement)) { + const offset = tooltipSize / 2 - arrowSize / 2; + return Math.round(offset); + } + if (TooltipRegexes[end].test(this._placement)) { + const endOffset = TooltipRegexes.vertical.test(this._placement) ? 8 : 4; + const offset = tooltipSize - (endOffset + arrowSize); + return Math.round(offset); + } + return 0; + } + + /** + * Configure arrow class and arrowFit. + * + * @param tooltip tooltip element. + */ + private configArrow(tooltip: Element): void { + if (!tooltip) { + return; + } + + const arrow = tooltip.querySelector('[data-arrow="true"]') as HTMLElement; + + // If display is none -> tooltipTarget's hasArrow is false + if (!arrow || arrow.style.display === 'none') { + return; + } + + this._placement = this.getPlacementByPositionSettings(this.settings) ?? Placement.Bottom; + const tooltipDirection = first(this._placement.split('-')); + arrow.className = `igx-tooltip--${tooltipDirection}`; + + // Arrow direction is the opposite of tooltip direction. + const direction = this.getOppositeDirection(tooltipDirection) as 'top' | 'right' | 'bottom' | 'left'; + const arrowRect = arrow.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const top = this.getArrowPositionStyles(arrowRect, tooltipRect, 'top'); + const left = this.getArrowPositionStyles(arrowRect, tooltipRect, 'left'); + + const arrowFit: ArrowFit = { + direction, + arrowRect, + tooltipRect, + top, + left, + }; + + this.positionArrow(arrow, arrowFit); + } + + /** + * Gets the placement that correspond to the given position settings. + * Returns `undefined` if the position settings do not match any of the predefined placement values. + * + * @param settings Position settings for which to get the corresponding placement. + */ + private getPlacementByPositionSettings(settings: PositionSettings): Placement { + const { horizontalDirection, horizontalStartPoint, verticalDirection, verticalStartPoint } = settings; + + const mapArray = Array.from(PositionsMap.entries()); + const placement = mapArray.find( + ([_, val]) => + val.horizontalDirection === horizontalDirection && + val.horizontalStartPoint === horizontalStartPoint && + val.verticalDirection === verticalDirection && + val.verticalStartPoint === verticalStartPoint + ); + + return placement ? placement[0] : undefined; + } + + /** + * Gets opposite direction, e.g., top -> bottom + * + * @param direction for which direction to return its opposite. + * @returns `top` | `bottom` | `right` | `left` + */ + private getOppositeDirection(direction: string): string { + const opposite = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[direction]; + + return opposite; + } +} + +/** + * Maps the predefined placement values to the corresponding directions and starting points. + */ +export const PositionsMap = new Map([ + [Placement.Top, { + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.TopStart, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.TopEnd, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.Bottom, { + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.BottomStart, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.BottomEnd, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.Right, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Middle, + verticalStartPoint: VerticalAlignment.Middle, + }], + [Placement.RightStart, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.RightEnd, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.Left, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Middle, + verticalStartPoint: VerticalAlignment.Middle, + }], + [Placement.LeftStart, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.LeftEnd, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Bottom, + }] +]); diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts index 8b1ba79aa77..be1a6d5a23a 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts @@ -2,14 +2,17 @@ import { DebugElement } from '@angular/core'; import { fakeAsync, TestBed, tick, flush, waitForAsync, ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, IgxTooltipMultipleTooltipsComponent } from '../../test-utils/tooltip-components.spec'; +import { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, IgxTooltipMultipleTooltipsComponent, IgxTooltipWithCloseButtonComponent } from '../../test-utils/tooltip-components.spec'; import { UIInteractions } from '../../test-utils/ui-interactions.spec'; import { HorizontalAlignment, VerticalAlignment, AutoPositionStrategy } from '../../services/public_api'; import { IgxTooltipDirective } from './tooltip.directive'; import { IgxTooltipTargetDirective } from './tooltip-target.directive'; +import { Placement, PositionsMap } from './tooltip.common'; const HIDDEN_TOOLTIP_CLASS = 'igx-tooltip--hidden'; const TOOLTIP_CLASS = 'igx-tooltip'; +const HIDE_DELAY = 180; +const TOOLTIP_ARROW_SELECTOR = '[data-arrow="true"]'; describe('IgxTooltip', () => { let fix: ComponentFixture; @@ -24,7 +27,8 @@ describe('IgxTooltip', () => { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, - IgxTooltipWithToggleActionComponent + IgxTooltipWithToggleActionComponent, + IgxTooltipWithCloseButtonComponent ] }).compileComponents(); UIInteractions.clearOverlay(); @@ -44,8 +48,8 @@ describe('IgxTooltip', () => { })); it('IgxTooltipTargetDirective default values', () => { - expect(tooltipTarget.showDelay).toBe(500); - expect(tooltipTarget.hideDelay).toBe(500); + expect(tooltipTarget.showDelay).toBe(200); + expect(tooltipTarget.hideDelay).toBe(300); expect(tooltipTarget.tooltipDisabled).toBe(false); expect(tooltipTarget.overlaySettings).toBeUndefined(); }); @@ -80,6 +84,39 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); })); + it('should not render a default arrow', fakeAsync(() => { + expect(tooltipTarget.hasArrow).toBeFalse(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow).not.toBeNull(); + expect(arrow.style.display).toEqual("none"); + })); + + it('should show/hide the arrow via the `hasArrow` property', fakeAsync(() => { + expect(tooltipTarget.hasArrow).toBeFalse(); + + tooltipTarget.hasArrow = true; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + expect(tooltipTarget.hasArrow).toBeTrue(); + const arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow.style.display).toEqual(""); + + tooltipTarget.hasArrow = false; + fix.detectChanges(); + expect(arrow.style.display).toEqual("none"); + })); + it('show target tooltip when hovering its target and ignore [tooltip] input', fakeAsync(() => { hoverElement(button); flush(); @@ -91,7 +128,7 @@ describe('IgxTooltip', () => { it('verify tooltip default position', fakeAsync(() => { hoverElement(button); flush(); - verifyTooltipPosition(tooltipNativeElement, button, true); + verifyTooltipPosition(tooltipNativeElement, button); })); it('IgxTooltip is not shown when is disabled and hovering its target', fakeAsync(() => { @@ -134,6 +171,7 @@ describe('IgxTooltip', () => { flush(); unhoverElement(button); + tick(HIDE_DELAY); tick(400); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); @@ -154,20 +192,17 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); })); - it('showing tooltip through API respects showDelay', fakeAsync(() => { + it('showing tooltip through API does NOT respect showDelay', fakeAsync(() => { tooltipTarget.showDelay = 400; fix.detectChanges(); tooltipTarget.showTooltip(); tick(300); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - - tick(100); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); })); - it('hiding tooltip through API respects hideDelay', fakeAsync(() => { + it('hiding tooltip through API does NOT respect hideDelay', fakeAsync(() => { tooltipTarget.hideDelay = 450; fix.detectChanges(); @@ -177,39 +212,7 @@ describe('IgxTooltip', () => { tooltipTarget.hideTooltip(); tick(400); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - - tick(50); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - })); - - it('IgxTooltip closes and reopens if it was opened through API and then its target is hovered', fakeAsync(() => { - tooltipTarget.showTooltip(); - flush(); - - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - - hoverElement(button); - - tick(250); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - - tick(250); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - })); - - it('IgxTooltip closes and reopens if opening it through API multiple times', fakeAsync(() => { - tooltipTarget.showTooltip(); - tick(500); - - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - - tooltipTarget.showTooltip(); - tick(250); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - - tick(250); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); })); it('IgxTooltip respects the passed overlaySettings', fakeAsync(() => { @@ -217,7 +220,7 @@ describe('IgxTooltip', () => { hoverElement(button); flush(); // Verify default position of the tooltip. - verifyTooltipPosition(tooltipNativeElement, button, true); + verifyTooltipPosition(tooltipNativeElement, button); unhoverElement(button); flush(); @@ -443,6 +446,7 @@ describe('IgxTooltip', () => { const dummyDiv = fix.debugElement.query(By.css('.dummyDiv')); touchElement(dummyDiv); + tick(HIDE_DELAY); tick(400); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); @@ -505,7 +509,7 @@ describe('IgxTooltip', () => { // Tooltip is positioned relative to buttonOne and NOT relative to buttonTwo verifyTooltipVisibility(tooltipNativeElement, targetOne, true); - verifyTooltipPosition(tooltipNativeElement, buttonOne, true); + verifyTooltipPosition(tooltipNativeElement, buttonOne); verifyTooltipPosition(tooltipNativeElement, buttonTwo, false); unhoverElement(buttonOne); @@ -515,7 +519,7 @@ describe('IgxTooltip', () => { // Tooltip is positioned relative to buttonTwo and NOT relative to buttonOne verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); - verifyTooltipPosition(tooltipNativeElement, buttonTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); verifyTooltipPosition(tooltipNativeElement, buttonOne, false); })); @@ -534,7 +538,7 @@ describe('IgxTooltip', () => { // Tooltip is visible and positioned relative to buttonTwo // and it was not closed due to buttonOne mouseLeave logic. verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); - verifyTooltipPosition(tooltipNativeElement, buttonTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); verifyTooltipPosition(tooltipNativeElement, buttonOne, false); flush(); })); @@ -557,14 +561,14 @@ describe('IgxTooltip', () => { // Tooltip is visible and positioned relative to buttonTwo verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); - verifyTooltipPosition(tooltipNativeElement, buttonTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); // Tooltip is NOT visible and positioned relative to buttonOne verifyTooltipPosition(tooltipNativeElement, buttonOne, false); })); it('Should not call `hideTooltip` multiple times on document:touchstart', fakeAsync(() => { - spyOn(targetOne, 'hideTooltip').and.callThrough(); - spyOn(targetTwo, 'hideTooltip').and.callThrough(); + spyOn(targetOne, '_hideOnInteraction').and.callThrough(); + spyOn(targetTwo, '_hideOnInteraction').and.callThrough(); touchElement(buttonOne); tick(500); @@ -573,8 +577,8 @@ describe('IgxTooltip', () => { touchElement(dummyDiv); flush(); - expect(targetOne.hideTooltip).toHaveBeenCalledTimes(1); - expect(targetTwo.hideTooltip).not.toHaveBeenCalled(); + expect(targetOne['_hideOnInteraction']).toHaveBeenCalledTimes(1); + expect(targetTwo['_hideOnInteraction']).not.toHaveBeenCalled(); })); it('should not emit tooltipHide event multiple times', fakeAsync(() => { @@ -619,6 +623,183 @@ describe('IgxTooltip', () => { flush(); verifyTooltipVisibility(tooltipNativeElement, targetTwo, false); })); + + it('should show and remove close button depending on active sticky target', fakeAsync(() => { + targetOne.sticky = true; + fix.detectChanges(); + hoverElement(buttonOne); + flush(); + + let closeBtn = tooltipNativeElement.querySelector('igx-tooltip-close-button'); + expect(closeBtn).not.toBeNull(); + expect(fix.componentInstance.tooltip.role).toBe('status'); + + targetTwo.sticky = false; + fix.detectChanges(); + hoverElement(buttonTwo); + flush(); + + // It should still show tooltip for targetOne + expect(fix.componentInstance.tooltip.role).toBe('status'); + expect(tooltipNativeElement.querySelector('igx-tooltip-close-button')).not.toBeNull(); + + closeBtn = tooltipNativeElement.querySelector('igx-tooltip-close-button') as HTMLElement; + closeBtn.dispatchEvent(new Event('click')); + fix.detectChanges(); + flush(); + + hoverElement(buttonTwo); + flush(); + + expect(tooltipNativeElement.querySelector('igx-tooltip-close-button')).toBeNull(); + expect(fix.componentInstance.tooltip.role).toBe('tooltip'); + })); + + it('should assign close template programmatically and render it only for the sticky target', fakeAsync(() => { + const instance = fix.componentInstance; + + targetOne.sticky = true; + targetTwo.sticky = true; + targetOne.closeTemplate = instance.customCloseTemplate; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + + const customClose = tooltipNativeElement.querySelector('.my-close-btn'); + expect(customClose).not.toBeNull(); + expect(customClose.textContent).toContain('Custom Close Button'); + + const closeBtn = tooltipNativeElement.querySelector('igx-tooltip-close-button') as HTMLElement; + closeBtn.dispatchEvent(new Event('click')); + fix.detectChanges(); + flush(); + + hoverElement(buttonTwo); + flush(); + + expect(tooltipNativeElement.querySelector('.my-close-btn')).toBeNull(); + })); + + it('should not update tooltip state when non-active target changes sticky or closeTemplate', fakeAsync(() => { + const instance = fix.componentInstance as IgxTooltipMultipleTargetsComponent; + + targetOne.sticky = true; + targetTwo.sticky = false; + targetOne.closeTemplate = instance.customCloseTemplate; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + + // Tooltip should be shown for targetOne with custom close button and correct role + const tooltip = tooltipNativeElement; + const customClose = tooltip.querySelector('.my-close-btn'); + const roleAttr = tooltip.getAttribute('role'); + + expect(customClose).not.toBeNull(); + expect(customClose.textContent).toContain('Custom Close Button'); + expect(roleAttr).toBe('status'); + + const closeButton = tooltip.querySelector('igx-tooltip-close-button'); + + // Change sticky and template on targetTwo while tooltip is still shown for targetOne + targetTwo.sticky = true; + targetTwo.closeTemplate = instance.secondCustomCloseTemplate; + + fix.detectChanges(); + flush(); + + expect(tooltip.querySelector('igx-tooltip-close-button')).toBe(closeButton); // same reference + expect(tooltip.querySelector('.my-close-btn')).not.toBeNull(); // still the custom one + expect(tooltip.getAttribute('role')).toBe('status'); + expect(instance.tooltip.tooltipTarget).toBe(targetOne); + })); + + it('should update tooltip state when active target changes closeTemplate or sticky', fakeAsync(() => { + const instance = fix.componentInstance as IgxTooltipMultipleTargetsComponent; + + targetOne.sticky = true; + targetOne.closeTemplate = instance.customCloseTemplate; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + fix.detectChanges(); + + const tooltip = tooltipNativeElement; + const customClose = tooltip.querySelector('.my-close-btn'); + const roleAttr = tooltip.getAttribute('role'); + + expect(customClose).not.toBeNull(); + expect(customClose.textContent).toContain('Custom Close Button'); + expect(roleAttr).toBe('status'); + + // Change closeTemplate of active targetOne + targetOne.closeTemplate = instance.secondCustomCloseTemplate; + fix.detectChanges(); + flush(); + + const updatedCustomClose = tooltip.querySelector('.my-second-close-btn'); + expect(updatedCustomClose).not.toBeNull(); + expect(updatedCustomClose.textContent).toContain('Second Custom Close Button'); + + targetOne.sticky = false; + fix.detectChanges(); + flush(); + + expect(tooltip.getAttribute('role')).toBe('tooltip'); + expect(tooltip.querySelector('igx-tooltip-close-button')).toBeNull(); + })); + + it('should correctly update tooltip when showing programmatically for sticky and non-sticky targets', fakeAsync(() => { + const tooltip = tooltipNativeElement; + + targetOne.sticky = true; + fix.detectChanges(); + targetOne.showTooltip(); + flush(); + + verifyTooltipVisibility(tooltip, targetOne, true); + expect(tooltip.role).toBe('status'); + + // Programmatically show tooltip for targetTwo (non-sticky) without closing sticky tooltip + targetTwo.sticky = false; + targetTwo.showTooltip(); + flush(); + verifyTooltipPosition(tooltip, targetTwo, false); + expect(tooltip.role).toBe('status'); + + targetOne.hideTooltip(); + flush(); + + targetTwo.showTooltip(); + flush(); + verifyTooltipPosition(tooltip, targetTwo, true); + expect(tooltip.role).toBe('tooltip'); + })); + + it('should correctly manage arrow state between different targets', fakeAsync(() => { + targetOne.hasArrow = true; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, targetOne, true); + let arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow.style.display).toEqual(''); + + unhoverElement(buttonOne); + flush(); + + hoverElement(buttonTwo); + flush(); + + arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); + expect(arrow.style.display).toEqual('none'); + })); }); describe('Multiple tooltips', () => { @@ -670,7 +851,8 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); UIInteractions.simulateClickEvent(button.nativeElement); - fix.detectChanges(); + tick(HIDE_DELAY); + tick(300); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); @@ -680,6 +862,173 @@ describe('IgxTooltip', () => { expect(fix.componentInstance.toggleDir.collapsed).toBe(false); })); }); + + describe('Tooltip Sticky with Close Button', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTooltipWithCloseButtonComponent); + fix.detectChanges(); + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + tooltipTarget = fix.componentInstance.tooltipTarget as IgxTooltipTargetDirective; + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + }); + + it('should render custom close button when sticky is true', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, button, true); + const closeBtn = document.querySelector('.my-close-btn'); + expect(closeBtn).toBeTruthy(); + })); + + it('should remove close button when sticky is set to false', fakeAsync(() => { + tooltipTarget.sticky = false; + fix.detectChanges(); + tick(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const closeBtn = document.querySelector('.my-close-btn'); + expect(closeBtn).toBeFalsy(); + + })); + + it('should hide the tooltip custom close button is clicked', fakeAsync(() => { + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const closeBtn = tooltipNativeElement.querySelector('.my-close-btn') as HTMLElement; + UIInteractions.simulateClickAndSelectEvent(closeBtn); + + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('should use default close icon when no custom template is passed', fakeAsync(() => { + // Clear custom template + tooltipTarget.closeTemplate = null; + fix.detectChanges(); + tick(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const icon = document.querySelector('igx-icon'); + expect(icon).toBeTruthy(); + expect(icon?.textContent?.trim().toLowerCase()).toBe('close'); + })); + + it('should update the DOM role attribute correctly when sticky changes', fakeAsync(() => { + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + expect(tooltipNativeElement.getAttribute('role')).toBe('status'); + + tooltipTarget.sticky = false; + fix.detectChanges(); + tick(); + expect(tooltipNativeElement.getAttribute('role')).toBe('tooltip'); + })); + + it('should hide sticky tooltip when Escape is pressed', fakeAsync(() => { + tooltipTarget.sticky = true; + fix.detectChanges(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + // Dispatch Escape key + const escapeEvent = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true + }); + document.dispatchEvent(escapeEvent); + flush() + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false) + })); + + it('should correctly display a sticky tooltip on touchstart', fakeAsync(() => { + tooltipTarget.sticky = true; + fix.detectChanges(); + touchElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + const closeBtn = document.querySelector('.my-close-btn'); + expect(closeBtn).toBeTruthy(); + expect(tooltipNativeElement.getAttribute('role')).toBe('status'); + })); + }); + + describe('IgxTooltip placement and offset', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipSingleTargetComponent); + fix.detectChanges(); + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + tooltipTarget = fix.componentInstance.tooltipTarget as IgxTooltipTargetDirective; + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + })); + + afterEach(() => { + UIInteractions.clearOverlay(); + }); + + it('should respect custom positive offset', fakeAsync(() => { + const customOffset = 20; + tooltipTarget.positionSettings = { + ...PositionsMap.get(Placement.Bottom), + offset: customOffset + }; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + verifyTooltipPosition(tooltipNativeElement, button, true, Placement.Bottom, customOffset); + })); + + it('should respect custom negative offset', fakeAsync(() => { + const customOffset = -10; + tooltipTarget.positionSettings = { + ...PositionsMap.get(Placement.Right), + offset: customOffset + }; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + verifyTooltipPosition(tooltipNativeElement, button, true, Placement.Right, customOffset); + })); + + it('should correctly position arrow based on tooltip placement', fakeAsync(() => { + tooltipTarget.positionSettings = { + ...PositionsMap.get(Placement.BottomStart), + }; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + verifyTooltipPosition(tooltipNativeElement, button, true, Placement.BottomStart); + + const arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow).not.toBeNull(); + expect(arrow.style.left).toBe(""); + })); + }) }); interface ElementRefLike { @@ -698,25 +1047,89 @@ const verifyTooltipVisibility = (tooltipNativeElement, tooltipTarget, shouldBeVi expect(tooltipTarget?.tooltipHidden).not.toBe(shouldBeVisible); }; -const verifyTooltipPosition = (tooltipNativeElement, actualTarget, shouldBeAligned: boolean) => { - const targetRect = actualTarget.nativeElement.getBoundingClientRect(); - const tooltipRect = tooltipNativeElement.getBoundingClientRect(); +const directionTolerance = 2; +const alignmentTolerance = 2; + + +export const verifyTooltipPosition = ( + tooltipNativeElement: HTMLElement, + actualTarget: { nativeElement: HTMLElement }, + shouldAlign:boolean = true, + placement: Placement = Placement.Bottom, + offset: number = 6 +) => { + const tooltip = tooltipNativeElement.getBoundingClientRect(); + const target = actualTarget.nativeElement.getBoundingClientRect(); + + let directionCheckPassed = false; + let alignmentCheckPassed = false; + + let actualOffset; + + // --- placement check --- + if (placement.startsWith('top')) { + actualOffset = target.top - tooltip.bottom; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } else if (placement.startsWith('bottom')) { + actualOffset = tooltip.top - target.bottom; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } else if (placement.startsWith('left')) { + actualOffset = target.left - tooltip.right; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } else if (placement.startsWith('right')) { + actualOffset = tooltip.left - target.right; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } + - const targetRectMidX = targetRect.left + targetRect.width / 2; - const tooltipRectMidX = tooltipRect.left + tooltipRect.width / 2; + // --- alignment check --- + if (placement.startsWith('top') || placement.startsWith('bottom')) { + alignmentCheckPassed = horizontalAlignmentMatches(tooltip, target, placement); + } else { + alignmentCheckPassed = verticalAlignmentMatches(tooltip, target, placement); + } - const horizontalOffset = Math.abs(targetRectMidX - tooltipRectMidX); - const verticalOffset = tooltipRect.top - targetRect.bottom; + const result = directionCheckPassed && alignmentCheckPassed; - if (shouldBeAligned) { - // Verify that tooltip and target are horizontally aligned with approximately same center - expect(horizontalOffset >= 0).toBe(true, 'tooltip and target are horizontally MISaligned'); - expect(horizontalOffset <= 0.5).toBe(true, 'tooltip and target are horizontally MISaligned'); - // Verify that tooltip is vertically aligned beneath the target - expect(verticalOffset >= 0).toBe(true, 'tooltip and target are vertically MISaligned'); - expect(verticalOffset <= 6).toBe(true, 'tooltip and target are vertically MISaligned'); + if (shouldAlign) { + expect(result).toBeTruthy( + `Tooltip misaligned for "${placement}": actual offset=${actualOffset}, wanted offset=${offset}, accurate placement=${directionCheckPassed}, accurate alignment=${alignmentCheckPassed}` + ); } else { - // Verify that tooltip and target are NOT horizontally aligned with approximately same center - expect(horizontalOffset > 0.1).toBe(true, 'tooltip and target are horizontally aligned'); + expect(result).toBeFalsy( + `Tooltip was unexpectedly aligned` + ); } }; + +function horizontalAlignmentMatches( + tooltip: DOMRect, + target: DOMRect, + placement: Placement +): boolean { + if (placement.endsWith('start')) { + return Math.abs(tooltip.left - target.left) <= alignmentTolerance; + } else if (placement.endsWith('end')) { + return Math.abs(tooltip.right - target.right) <= alignmentTolerance; + } else { + const tooltipMid = tooltip.left + tooltip.width / 2; + const targetMid = target.left + target.width / 2; + return Math.abs(tooltipMid - targetMid) <= alignmentTolerance; + } +} + +function verticalAlignmentMatches( + tooltip: DOMRect, + target: DOMRect, + placement: Placement +): boolean { + if (placement.endsWith('start')) { + return Math.abs(tooltip.top - target.top) <= alignmentTolerance; + } else if (placement.endsWith('end')) { + return Math.abs(tooltip.bottom - target.bottom) <= alignmentTolerance; + } else { + const tooltipMid = tooltip.top + tooltip.height / 2; + const targetMid = target.top + target.height / 2; + return Math.abs(tooltipMid - targetMid) <= alignmentTolerance; + } +} diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts index 4bcd4d7aa3c..92bef088822 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts @@ -1,8 +1,8 @@ import { - Directive, ElementRef, Input, ChangeDetectorRef, Optional, HostBinding, Inject, OnDestroy, inject, DOCUMENT + Directive, ElementRef, Input, ChangeDetectorRef, Optional, HostBinding, Inject, + OnDestroy, inject, DOCUMENT, HostListener, } from '@angular/core'; import { IgxOverlayService } from '../../services/overlay/overlay'; -import { OverlaySettings } from '../../services/public_api'; import { IgxNavigationService } from '../../core/navigation'; import { IgxToggleDirective } from '../toggle/toggle.directive'; import { IgxTooltipTargetDirective } from './tooltip-target.directive'; @@ -83,32 +83,37 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy * ``` */ @HostBinding('attr.role') + @Input() + public set role(value: "tooltip" | "status"){ + this._role = value; + } public get role() { - return 'tooltip'; + return this._role; } /** - * @hidden - */ - public timeoutId; - - /** - * @hidden - * Returns whether close time out has started + * Get the arrow element of the tooltip. + * + * ```typescript + * let tooltipArrow = this.tooltip.arrow; + * ``` */ - public toBeHidden = false; + public get arrow(): HTMLElement { + return this._arrowEl; + } /** * @hidden - * Returns whether open time out has started */ - public toBeShown = false; + public timeoutId; /** * @hidden */ public tooltipTarget: IgxTooltipTargetDirective; + private _arrowEl: HTMLElement; + private _role: 'tooltip' | 'status' = 'tooltip'; private _destroy$ = new Subject(); private _document = inject(DOCUMENT); @@ -128,6 +133,8 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy this.closed.pipe(takeUntil(this._destroy$)).subscribe(() => { this._document.removeEventListener('touchstart', this.onDocumentTouchStart); }); + + this._createArrow(); } /** @hidden */ @@ -137,51 +144,65 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy this._document.removeEventListener('touchstart', this.onDocumentTouchStart); this._destroy$.next(true); this._destroy$.complete(); + this._removeArrow(); } /** - * If there is open animation in progress this method will finish is. - * If there is no open animation in progress this method will open the toggle with no animation. - * - * @param overlaySettings setting to use for opening the toggle + * @hidden */ - protected forceOpen(overlaySettings?: OverlaySettings) { - const info = this.overlayService.getOverlayById(this._overlayId); - const hasOpenAnimation = info ? info.openAnimationPlayer : false; - if (hasOpenAnimation) { - info.openAnimationPlayer.finish(); - info.openAnimationPlayer.reset(); - info.openAnimationPlayer = null; - } else if (this.collapsed) { - const animation = overlaySettings.positionStrategy.settings.openAnimation; - overlaySettings.positionStrategy.settings.openAnimation = null; - this.open(overlaySettings); - overlaySettings.positionStrategy.settings.openAnimation = animation; - } + @HostListener('mouseenter') + public onMouseEnter() { + this.tooltipTarget?.onMouseEnter(); + } + + /** + * @hidden + */ + @HostListener('mouseleave') + public onMouseLeave() { + this.tooltipTarget?.onMouseLeave(); } /** - * If there is close animation in progress this method will finish is. - * If there is no close animation in progress this method will close the toggle with no animation. + * If there is an animation in progress, this method will reset it to its initial state. + * Optional `force` parameter that ends the animation. * - * @param overlaySettings settings to use for closing the toggle + * @hidden + * @param force if set to `true`, the animation will be ended. */ - protected forceClose(overlaySettings?: OverlaySettings) { + public stopAnimations(force: boolean = false): void { const info = this.overlayService.getOverlayById(this._overlayId); - const hasCloseAnimation = info ? info.closeAnimationPlayer : false; - if (hasCloseAnimation) { - info.closeAnimationPlayer.finish(); + if (!info) return; + + if (info.openAnimationPlayer) { + info.openAnimationPlayer.reset(); + if (force) { + info.openAnimationPlayer.finish(); + info.openAnimationPlayer = null; + } + } + if (info.closeAnimationPlayer) { info.closeAnimationPlayer.reset(); - info.closeAnimationPlayer = null; - } else if (!this.collapsed) { - const animation = overlaySettings.positionStrategy.settings.closeAnimation; - overlaySettings.positionStrategy.settings.closeAnimation = null; - this.close(); - overlaySettings.positionStrategy.settings.closeAnimation = animation; + if (force) { + info.closeAnimationPlayer.finish(); + info.closeAnimationPlayer = null; + } } } + private _createArrow(): void { + this._arrowEl = document.createElement('span'); + this._arrowEl.style.position = 'absolute'; + this._arrowEl.setAttribute('data-arrow', 'true'); + this.element.appendChild(this._arrowEl); + } + + private _removeArrow(): void { + this._arrowEl.remove(); + this._arrowEl = null; + } + private onDocumentTouchStart(event) { this.tooltipTarget?.onDocumentTouchStart(event); } diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts index 3e9a2a7d1c0..cb4f5b0b3b2 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts @@ -167,7 +167,7 @@ describe('IgxGrid - Validation #grid', () => { cell = grid.gridAPI.get_cell_by_visible_index(1, 1); //min length should be 4 GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); }); @@ -186,7 +186,7 @@ describe('IgxGrid - Validation #grid', () => { cell = grid.gridAPI.get_cell_by_visible_index(1, 4); //min length should be 4 GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); }); @@ -204,7 +204,7 @@ describe('IgxGrid - Validation #grid', () => { //min length should be 4 GridFunctions.verifyCellValid(cell, false); GridSelectionFunctions.verifyCellActive(cell, true); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); const overlayService = TestBed.inject(IgxOverlayService); @@ -390,7 +390,7 @@ describe('IgxGrid - Validation #grid', () => { cell = grid.gridAPI.get_cell_by_visible_index(1, 1); //bob cannot be the name GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; expect(erorrMessage).toEqual(' This name is forbidden. '); cell.editMode = true; @@ -425,7 +425,7 @@ describe('IgxGrid - Validation #grid', () => { fixture.detectChanges(); GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); }); @@ -444,7 +444,7 @@ describe('IgxGrid - Validation #grid', () => { fixture.detectChanges(); GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); }); @@ -472,7 +472,7 @@ describe('IgxGrid - Validation #grid', () => { grid.crudService.endEdit(true); fixture.detectChanges(); GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); }); }); diff --git a/projects/igniteui-angular/src/lib/services/overlay/position/base-fit-position-strategy.ts b/projects/igniteui-angular/src/lib/services/overlay/position/base-fit-position-strategy.ts index 28f808921ca..f0f36d33bbf 100644 --- a/projects/igniteui-angular/src/lib/services/overlay/position/base-fit-position-strategy.ts +++ b/projects/igniteui-angular/src/lib/services/overlay/position/base-fit-position-strategy.ts @@ -42,12 +42,14 @@ export abstract class BaseFitPositionStrategy extends ConnectedPositioningStrate * @param connectedFit connectedFit to update */ protected updateViewPortFit(connectedFit: ConnectedFit) { + const { horizontalOffset, verticalOffset } = super.getElementOffsets(connectedFit); + connectedFit.left = this.calculateLeft( connectedFit.targetRect, connectedFit.contentElementRect, this.settings.horizontalStartPoint, this.settings.horizontalDirection, - connectedFit.horizontalOffset ? connectedFit.horizontalOffset : 0); + horizontalOffset); connectedFit.right = connectedFit.left + connectedFit.contentElementRect.width; connectedFit.fitHorizontal = { back: Math.round(connectedFit.left), @@ -59,7 +61,7 @@ export abstract class BaseFitPositionStrategy extends ConnectedPositioningStrate connectedFit.contentElementRect, this.settings.verticalStartPoint, this.settings.verticalDirection, - connectedFit.verticalOffset ? connectedFit.verticalOffset : 0); + verticalOffset); connectedFit.bottom = connectedFit.top + connectedFit.contentElementRect.height; connectedFit.fitVertical = { back: Math.round(connectedFit.top), diff --git a/projects/igniteui-angular/src/lib/services/overlay/position/connected-positioning-strategy.ts b/projects/igniteui-angular/src/lib/services/overlay/position/connected-positioning-strategy.ts index 015223b9c87..fa65bc94b56 100644 --- a/projects/igniteui-angular/src/lib/services/overlay/position/connected-positioning-strategy.ts +++ b/projects/igniteui-angular/src/lib/services/overlay/position/connected-positioning-strategy.ts @@ -72,6 +72,20 @@ export class ConnectedPositioningStrategy implements IPositionStrategy { }; } + /** + * Get element horizontal and vertical offsets by connectedFit + * or `this.settings` if connectedFit offset is not defined. + * + * @param connectedFit + * @returns horizontalOffset and verticalOffset + */ + protected getElementOffsets(connectedFit: ConnectedFit): { horizontalOffset: number; verticalOffset: number } { + return { + horizontalOffset: connectedFit.horizontalOffset ?? Util.getHorizontalOffset(this.settings), + verticalOffset: connectedFit.verticalOffset ?? Util.getVerticalOffset(this.settings) + } + } + /** * Sets element's style which effectively positions provided element according * to provided position settings @@ -81,8 +95,8 @@ export class ConnectedPositioningStrategy implements IPositionStrategy { * @param elementRect Bounding rectangle of the element */ protected setStyle(element: HTMLElement, targetRect: Partial, elementRect: Partial, connectedFit: ConnectedFit) { - const horizontalOffset = connectedFit.horizontalOffset ? connectedFit.horizontalOffset : 0; - const verticalOffset = connectedFit.verticalOffset ? connectedFit.verticalOffset : 0; + const { horizontalOffset, verticalOffset } = this.getElementOffsets(connectedFit); + const startPoint: Point = { x: targetRect.right + targetRect.width * this.settings.horizontalStartPoint + horizontalOffset, y: targetRect.bottom + targetRect.height * this.settings.verticalStartPoint + verticalOffset diff --git a/projects/igniteui-angular/src/lib/services/overlay/position/global-position-strategy.ts b/projects/igniteui-angular/src/lib/services/overlay/position/global-position-strategy.ts index 791e2485926..21b55bef665 100644 --- a/projects/igniteui-angular/src/lib/services/overlay/position/global-position-strategy.ts +++ b/projects/igniteui-angular/src/lib/services/overlay/position/global-position-strategy.ts @@ -84,4 +84,3 @@ export class GlobalPositionStrategy implements IPositionStrategy { } } } - diff --git a/projects/igniteui-angular/src/lib/services/overlay/utilities.ts b/projects/igniteui-angular/src/lib/services/overlay/utilities.ts index 755b7ca8479..3286909a8f2 100644 --- a/projects/igniteui-angular/src/lib/services/overlay/utilities.ts +++ b/projects/igniteui-angular/src/lib/services/overlay/utilities.ts @@ -87,6 +87,8 @@ export interface PositionSettings { closeAnimation?: AnimationReferenceMetadata; /** The size up to which element may shrink when shown in elastic position strategy */ minSize?: Size; + /** The offset of the element from the target in pixels */ + offset?: number; } export interface OverlaySettings { @@ -261,4 +263,51 @@ export class Util { clonedObj.settings = cloneValue(clonedObj.settings); return clonedObj; } + + /** + * Gets horizontal offset by position settings `offset`. + */ + public static getHorizontalOffset(settings: PositionSettings): number { + if (settings.offset == null) { + return 0; + } + + if ( + settings.horizontalDirection === HorizontalAlignment.Left && + settings.horizontalStartPoint === HorizontalAlignment.Left + ) { + return -settings.offset; + } else if ( + settings.horizontalDirection === HorizontalAlignment.Right && + settings.horizontalStartPoint === HorizontalAlignment.Right + ) { + return settings.offset; + } + + return 0; + } + + /** + * Gets vertical offset by position settings `offset`. + */ + public static getVerticalOffset(settings: PositionSettings): number { + if (settings.offset == null) { + return 0; + } + + if ( + settings.verticalDirection === VerticalAlignment.Top && + settings.verticalStartPoint === VerticalAlignment.Top + ) { + return -settings.offset; + } else if ( + settings.verticalDirection === VerticalAlignment.Bottom && + settings.verticalStartPoint === VerticalAlignment.Bottom + ) { + return settings.offset; + } + + return 0; + } } + diff --git a/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts b/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts index b8cd6a38a4a..89254e52c70 100644 --- a/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild } from '@angular/core'; +import { Component, TemplateRef, ViewChild } from '@angular/core'; import { IgxTooltipDirective } from '../directives/tooltip/tooltip.directive'; import { ITooltipHideEventArgs, ITooltipShowEventArgs, IgxTooltipTargetDirective } from '../directives/tooltip/tooltip-target.directive'; import { IgxToggleActionDirective, IgxToggleDirective } from '../directives/toggle/toggle.directive'; @@ -55,6 +55,14 @@ export class IgxTooltipSingleTargetComponent {
Hello, I am a tooltip!
+ + +
Custom Close Button
+
+ + +
Second Custom Close Button
+
`, imports: [IgxTooltipDirective, IgxTooltipTargetDirective] }) @@ -62,6 +70,8 @@ export class IgxTooltipMultipleTargetsComponent { @ViewChild('targetOne', { read: IgxTooltipTargetDirective, static: true }) public targetOne: IgxTooltipTargetDirective; @ViewChild('targetTwo', { read: IgxTooltipTargetDirective, static: true }) public targetTwo: IgxTooltipTargetDirective; @ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective; + @ViewChild('customClose', { static: true }) public customCloseTemplate: TemplateRef; + @ViewChild('secondCustomClose', { static: true }) public secondCustomCloseTemplate: TemplateRef; } @Component({ @@ -120,3 +130,22 @@ export class IgxTooltipWithToggleActionComponent { @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget: IgxTooltipTargetDirective; @ViewChild(IgxToggleDirective, { static: true }) public toggleDir: IgxToggleDirective; } + +@Component({ + template: ` + + + + + + +
Test
+ `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective] +}) +export class IgxTooltipWithCloseButtonComponent { + @ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective; + @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget: IgxTooltipTargetDirective; +} diff --git a/src/app/tooltip/tooltip.sample.css b/src/app/tooltip/tooltip.sample.css index 542f2a717ae..3e6ceca7add 100644 --- a/src/app/tooltip/tooltip.sample.css +++ b/src/app/tooltip/tooltip.sample.css @@ -78,3 +78,11 @@ display: flex; flex-direction: column; } + +.custom-container { + display: flex; + flex-flow: column; + justify-content: space-evenly; + align-items: center; + height: 300px; +} diff --git a/src/app/tooltip/tooltip.sample.html b/src/app/tooltip/tooltip.sample.html index 11e0e3b4ee4..a21360e2634 100644 --- a/src/app/tooltip/tooltip.sample.html +++ b/src/app/tooltip/tooltip.sample.html @@ -1,28 +1,35 @@

Simple tooltip

- + + + +
Her name is Toola Tipa -
+
+

Tooltip input

- + + info -
+
@@ -103,5 +110,5 @@

Grid with tooltips

-
+ diff --git a/src/app/tooltip/tooltip.sample.ts b/src/app/tooltip/tooltip.sample.ts index bf0c637c92c..87b45beeaa2 100644 --- a/src/app/tooltip/tooltip.sample.ts +++ b/src/app/tooltip/tooltip.sample.ts @@ -15,7 +15,8 @@ import { IgxSliderComponent, IgxSwitchComponent, IgxTooltipDirective, - IgxTooltipTargetDirective, OverlaySettings + IgxTooltipTargetDirective, + OverlaySettings, } from 'igniteui-angular'; @Component({ @@ -109,10 +110,4 @@ export class TooltipSampleComponent implements OnInit { public hideTooltip() { this.tooltipTarget.hideTooltip(); } - - public showing() { - } - - public hiding() { - } }